diff --git a/examples/Monopoly/src/Game/SpaceCollection.php b/examples/Monopoly/src/Game/SpaceCollection.php index f5da06a3..f119036f 100644 --- a/examples/Monopoly/src/Game/SpaceCollection.php +++ b/examples/Monopoly/src/Game/SpaceCollection.php @@ -3,19 +3,21 @@ namespace Thunk\Verbs\Examples\Monopoly\Game; use Illuminate\Support\Collection; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Thunk\Verbs\Examples\Monopoly\Game\Spaces\Space; use Thunk\Verbs\SerializedByVerbs; class SpaceCollection extends Collection implements SerializedByVerbs { - public static function deserializeForVerbs(mixed $data): static + public static function deserializeForVerbs(mixed $data, DenormalizerInterface $denormalizer): static { return static::make($data) - ->map(fn ($serialized) => Space::deserializeForVerbs($serialized)); + ->map(fn ($serialized) => Space::deserializeForVerbs($serialized, $denormalizer)); } - public function serializeForVerbs(): string|array + public function serializeForVerbs(NormalizerInterface $normalizer): string|array { - return $this->map(fn (Space $space) => $space->serializeForVerbs())->toJson(); + return $this->map(fn (Space $space) => $space->serializeForVerbs($normalizer))->toJson(); } } diff --git a/examples/Monopoly/src/Game/Spaces/Space.php b/examples/Monopoly/src/Game/Spaces/Space.php index 9ed16f23..924db30c 100644 --- a/examples/Monopoly/src/Game/Spaces/Space.php +++ b/examples/Monopoly/src/Game/Spaces/Space.php @@ -3,15 +3,12 @@ namespace Thunk\Verbs\Examples\Monopoly\Game\Spaces; use BadMethodCallException; -use Thunk\Verbs\Examples\Monopoly\Game\PropertyColor; use Thunk\Verbs\SerializedByVerbs; use Thunk\Verbs\Support\Normalization\NormalizeToPropertiesAndClassName; abstract class Space implements SerializedByVerbs { - use NormalizeToPropertiesAndClassName { - deserializeForVerbs as genericDeserializeForVerbs; - } + use NormalizeToPropertiesAndClassName; protected string $name; @@ -19,15 +16,6 @@ abstract class Space implements SerializedByVerbs protected static array $instances = []; - public static function deserializeForVerbs(mixed $data): static - { - if (isset($data['color'])) { - $data['color'] = PropertyColor::from($data['color']); - } - - return static::genericDeserializeForVerbs($data); - } - public static function instance(): static { return self::$instances[static::class] ?? new static(); diff --git a/src/Lifecycle/EventStore.php b/src/Lifecycle/EventStore.php index e98c86cd..87fcfa92 100644 --- a/src/Lifecycle/EventStore.php +++ b/src/Lifecycle/EventStore.php @@ -88,12 +88,13 @@ protected function guardAgainstConcurrentWrites(array $events): void }); $query->each(function ($result) use ($max_event_ids) { - $key = data_get($result, 'state_type').data_get($result, 'state_id'); + $state_type = data_get($result, 'state_type'); + $state_id = (int) data_get($result, 'state_id'); $max_written_id = (int) data_get($result, 'max_event_id'); - $max_expected_id = $max_event_ids->get($key, 0); + $max_expected_id = $max_event_ids->get($state_type.$state_id, 0); if ($max_written_id > $max_expected_id) { - throw new ConcurrencyException("An event with ID {$max_written_id} has been written to the database, which is higher than {$max_expected_id}, which is in memory."); + throw new ConcurrencyException("An event with ID {$max_written_id} has been written to the database for '{$state_type}' with ID {$state_id}. This is higher than the in-memory value of {$max_expected_id}."); } }); } diff --git a/src/SerializedByVerbs.php b/src/SerializedByVerbs.php index 287f8fdd..762f45a9 100644 --- a/src/SerializedByVerbs.php +++ b/src/SerializedByVerbs.php @@ -2,9 +2,12 @@ namespace Thunk\Verbs; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + interface SerializedByVerbs { - public static function deserializeForVerbs(mixed $data): static; + public static function deserializeForVerbs(array $data, DenormalizerInterface $denormalizer): static; - public function serializeForVerbs(): string|array; + public function serializeForVerbs(NormalizerInterface $normalizer): string|array; } diff --git a/src/Support/Normalization/AcceptsNormalizerAndDenormalizer.php b/src/Support/Normalization/AcceptsNormalizerAndDenormalizer.php new file mode 100644 index 00000000..a3965d70 --- /dev/null +++ b/src/Support/Normalization/AcceptsNormalizerAndDenormalizer.php @@ -0,0 +1,27 @@ +serializer = $serializer; + + return; + } + + throw new InvalidArgumentException(sprintf( + 'The %s expects a serializer that supports both normalization and denormalization.', + class_basename($this) + )); + } +} diff --git a/src/Support/Normalization/CollectionNormalizer.php b/src/Support/Normalization/CollectionNormalizer.php index 4efccda2..498f13ba 100644 --- a/src/Support/Normalization/CollectionNormalizer.php +++ b/src/Support/Normalization/CollectionNormalizer.php @@ -7,22 +7,11 @@ use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\SerializerAwareInterface; -use Symfony\Component\Serializer\SerializerInterface; +use Thunk\Verbs\SerializedByVerbs; class CollectionNormalizer implements DenormalizerInterface, NormalizerInterface, SerializerAwareInterface { - protected NormalizerInterface|DenormalizerInterface $serializer; - - public function setSerializer(SerializerInterface $serializer) - { - if ($serializer instanceof NormalizerInterface && $serializer instanceof DenormalizerInterface) { - $this->serializer = $serializer; - - return; - } - - throw new InvalidArgumentException('The CollectionNormalizer expects a serializer that implements both normalization and denormalization.'); - } + use AcceptsNormalizerAndDenormalizer; public function supportsDenormalization(mixed $data, string $type, string $format = null): bool { @@ -36,7 +25,7 @@ public function denormalize(mixed $data, string $type, string $format = null, ar $items = data_get($data, 'items', []); if ($items === []) { - return new $fqcn; + return new $fqcn(); } $subtype = data_get($data, 'type'); @@ -44,7 +33,7 @@ public function denormalize(mixed $data, string $type, string $format = null, ar throw new InvalidArgumentException('Cannot denormalize a Collection that has no type information.'); } - return $fqcn::make($items)->map(fn ($value) => $this->serializer->denormalize($value, $subtype)); + return $fqcn::make($items)->map(fn ($value) => $this->serializer->denormalize($value, $subtype, $format, $context)); } public function supportsNormalization(mixed $data, string $format = null): bool @@ -58,19 +47,9 @@ public function normalize(mixed $object, string $format = null, array $context = throw new InvalidArgumentException(class_basename($this).' can only normalize Collection objects.'); } - $types = $object->map(fn ($value) => get_debug_type($value))->unique(); - - if ($types->count() > 1) { - throw new InvalidArgumentException(sprintf( - 'Cannot serialize a %s containing mixed types (got %s).', - class_basename($object), - $types->map(fn ($fqcn) => class_basename($fqcn))->implode(', ') - )); - } - return array_filter([ 'fqcn' => $object::class === Collection::class ? null : $object::class, - 'type' => $types->first(), + 'type' => $this->determineContainedType($object), 'items' => $object->map(fn ($value) => $this->serializer->normalize($value, $format, $context))->all(), ]); } @@ -79,4 +58,58 @@ public function getSupportedTypes(?string $format): array { return [Collection::class => false]; } + + protected function determineContainedType(Collection $collection): string + { + [$only_objects, $types] = $this->getCollectionMetadata($collection); + + // If the whole collection contains one type, then we're golden + if ($types->count() === 1) { + return $types->first(); + } + + // If not, but it's all objects, we can look at each object's parent classes + // and interfaces, and determine if they all extend something that implements + // the `SerializedByVerbs` interface. If they do, then we can use that shared + // ancestor as the type we use for serializing the whole collection. + if ($only_objects) { + $ancestor_types = $this->getSharedAncestorTypes($types); + if ($ancestor_types->count() > 1 && $ancestor_types->contains(SerializedByVerbs::class)) { + return $ancestor_types->first(); + } + } + + throw new InvalidArgumentException(sprintf( + 'Cannot serialize a %s containing mixed types (got %s).', + class_basename($collection), + $types->map(fn ($fqcn) => class_basename($fqcn))->implode(', '), + )); + } + + protected function getCollectionMetadata(Collection $collection): array + { + $only_objects = true; + $types = new Collection(); + + foreach ($collection as $value) { + $only_objects = $only_objects && is_object($value); + $types->push(get_debug_type($value)); + } + + return [$only_objects, $types->unique()]; + } + + protected function getSharedAncestorTypes(Collection $types) + { + return $types->reduce(function (Collection $common, string $fqcn) { + $parents = collect([$fqcn]) + ->merge(class_parents($fqcn)) + ->merge(class_implements($fqcn)) + ->values() + ->filter() + ->unique(); + + return $common->isEmpty() ? $parents : $parents->intersect($common); + }, new Collection()); + } } diff --git a/src/Support/Normalization/NormalizeToPropertiesAndClassName.php b/src/Support/Normalization/NormalizeToPropertiesAndClassName.php index 65cf30a8..e5f2f0e6 100644 --- a/src/Support/Normalization/NormalizeToPropertiesAndClassName.php +++ b/src/Support/Normalization/NormalizeToPropertiesAndClassName.php @@ -8,6 +8,8 @@ use ReflectionClass; use ReflectionProperty; use RuntimeException; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; trait NormalizeToPropertiesAndClassName { @@ -20,15 +22,15 @@ public static function requiredDataForVerbsDeserialization(): array ->all(); } - public static function deserializeForVerbs(mixed $data): static + public static function deserializeForVerbs(array $data, DenormalizerInterface $denormalizer): static { $required = self::requiredDataForVerbsDeserialization(); if (! Arr::has($data, $required)) { throw new InvalidArgumentException(sprintf( - 'The following data is required to deserialize to "%s": %s', + 'The following data is required to deserialize to "%s": %s.', class_basename(static::class), - implode(', ', $required) + implode(', ', $required), )); } @@ -49,13 +51,19 @@ class_basename(static::class) $instance = $reflect->newInstanceWithoutConstructor(); foreach (Arr::except($data, ['fqcn']) as $key => $value) { - $reflect->getProperty($key)->setValue($instance, $value); + $property = $reflect->getProperty($key); + + if ($property->hasType() && ! $property->getType()->isBuiltin()) { + $value = $denormalizer->denormalize($value, $property->getType()->getName()); + } + + $property->setValue($instance, $value); } return $instance; } - public function serializeForVerbs(): string|array + public function serializeForVerbs(NormalizerInterface $normalizer): string|array { $properties = Collection::make((new ReflectionClass($this))->getProperties()) ->reject(fn (ReflectionProperty $property) => $property->isStatic()) diff --git a/src/Support/Normalization/SelfSerializingNormalizer.php b/src/Support/Normalization/SelfSerializingNormalizer.php index 178cc3ff..a0ca786e 100644 --- a/src/Support/Normalization/SelfSerializingNormalizer.php +++ b/src/Support/Normalization/SelfSerializingNormalizer.php @@ -5,10 +5,13 @@ use InvalidArgumentException; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\SerializerAwareInterface; use Thunk\Verbs\SerializedByVerbs; -class SelfSerializingNormalizer implements DenormalizerInterface, NormalizerInterface +class SelfSerializingNormalizer implements DenormalizerInterface, NormalizerInterface, SerializerAwareInterface { + use AcceptsNormalizerAndDenormalizer; + public function supportsDenormalization(mixed $data, string $type, string $format = null): bool { return is_a($type, SerializedByVerbs::class, true); @@ -21,7 +24,7 @@ public function denormalize(mixed $data, string $type, string $format = null, ar $data = json_decode($data, true); } - return $type::deserializeForVerbs($data); + return $type::deserializeForVerbs($data, $this->serializer); } public function supportsNormalization(mixed $data, string $format = null): bool @@ -35,7 +38,7 @@ public function normalize(mixed $object, string $format = null, array $context = throw new InvalidArgumentException(class_basename($this).' can only normalize classes that implement SerializedByVerbs.'); } - return $object->serializeForVerbs(); + return $object->serializeForVerbs($this->serializer); } public function getSupportedTypes(?string $format): array diff --git a/tests/Unit/CollectionNormalizerTest.php b/tests/Unit/CollectionNormalizerTest.php index 1fd27565..ba116990 100644 --- a/tests/Unit/CollectionNormalizerTest.php +++ b/tests/Unit/CollectionNormalizerTest.php @@ -1,13 +1,18 @@ and($denormalized->shift())->toBe($second); }); +it('can normalize collections of objects that implement SerializedByVerbs', function () { + $serializer = new SymfonySerializer( + normalizers: [ + $normalizer = new CollectionNormalizer(), + new CarbonNormalizer(), + new SelfSerializingNormalizer(), + new ObjectNormalizer(propertyTypeExtractor: new ReflectionExtractor()), + ], + encoders: [ + new JsonEncoder(), + ], + ); + + $collection = Collection::make([ + $parent = new CollectionNormalizerTestDataObject('hello', 42, now()->toImmutable(), ['a', 'b', 'c']), + $child = new CollectionNormalizerTestChildDataObject('world', 21, now()->subDay()->toImmutable(), ['c', 'b', 'a'], false), + ]); + + expect($normalizer->supportsNormalization($collection))->toBeTrue(); + + $normalized = $serializer->serialize($collection, 'json'); + + expect($normalized)->toContain('"type":"CollectionNormalizerTestDataObject"'); + + $denormalized = $serializer->deserialize($normalized, Collection::class, 'json'); + + $denormalized_parent = $denormalized->shift(); + expect($denormalized_parent->string)->toBe($parent->string) + ->and($denormalized_parent->int)->toBe($parent->int) + ->and($parent->carbon->eq($denormalized_parent->carbon))->toBeTrue() + ->and($denormalized_parent->array)->toBe($parent->array); + + $denormalized_child = $denormalized->shift(); + expect($denormalized_child->string)->toBe($child->string) + ->and($denormalized_child->int)->toBe($child->int) + ->and($child->carbon->eq($denormalized_child->carbon))->toBeTrue() + ->and($denormalized_child->array)->toBe($child->array) + ->and($denormalized_child->bool)->toBe($child->bool); +}); + class CollectionNormalizerTestState extends State { public string $label; } + +class CollectionNormalizerTestDataObject implements SerializedByVerbs +{ + use NormalizeToPropertiesAndClassName; + + public function __construct( + public string $string, + public int $int, + public CarbonImmutable $carbon, + public array $array, + ) { + } +} + +class CollectionNormalizerTestChildDataObject extends CollectionNormalizerTestDataObject +{ + public function __construct( + string $string, + int $int, + CarbonImmutable $carbon, + array $array, + public bool $bool, + ) { + parent::__construct($string, $int, $carbon, $array); + } +}