From 6df5385c2707854510a2248355a8b545b73a3d6a Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Fri, 17 Nov 2023 12:07:30 -0500 Subject: [PATCH 1/8] More serialization improvements --- .../Monopoly/src/Game/SpaceCollection.php | 10 +-- examples/Monopoly/src/Game/Spaces/Space.php | 14 +--- src/Lifecycle/EventStore.php | 9 +-- src/SerializedByVerbs.php | 7 +- .../Normalization/CollectionNormalizer.php | 33 +++++++-- .../NormalizeToPropertiesAndClassName.php | 26 +++++-- .../SelfSerializingNormalizer.php | 23 ++++-- tests/Unit/CollectionNormalizerTest.php | 72 +++++++++++++++++++ 8 files changed, 155 insertions(+), 39 deletions(-) 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..81118fd9 100644 --- a/examples/Monopoly/src/Game/Spaces/Space.php +++ b/examples/Monopoly/src/Game/Spaces/Space.php @@ -3,15 +3,14 @@ namespace Thunk\Verbs\Examples\Monopoly\Game\Spaces; use BadMethodCallException; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; 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 +18,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..541e4498 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'); - $max_written_id = (int) data_get($result, 'max_event_id'); - $max_expected_id = $max_event_ids->get($key, 0); + $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($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..1ed9e5e3 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(mixed $data, DenormalizerInterface $denormalizer): static; - public function serializeForVerbs(): string|array; + public function serializeForVerbs(NormalizerInterface $normalizer): string|array; } diff --git a/src/Support/Normalization/CollectionNormalizer.php b/src/Support/Normalization/CollectionNormalizer.php index 4efccda2..550cb3b8 100644 --- a/src/Support/Normalization/CollectionNormalizer.php +++ b/src/Support/Normalization/CollectionNormalizer.php @@ -8,6 +8,7 @@ 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 { @@ -43,8 +44,8 @@ public function denormalize(mixed $data, string $type, string $format = null, ar if ($subtype === null) { 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 @@ -61,11 +62,29 @@ public function normalize(mixed $object, string $format = null, array $context = $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(', ') - )); + $shared = collect($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()); + + if ($shared->count() > 1 && $shared->contains(SerializedByVerbs::class)) { + $types = $shared; + } else { + 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([ diff --git a/src/Support/Normalization/NormalizeToPropertiesAndClassName.php b/src/Support/Normalization/NormalizeToPropertiesAndClassName.php index 65cf30a8..80542cb7 100644 --- a/src/Support/Normalization/NormalizeToPropertiesAndClassName.php +++ b/src/Support/Normalization/NormalizeToPropertiesAndClassName.php @@ -8,6 +8,10 @@ use ReflectionClass; use ReflectionProperty; use RuntimeException; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Thunk\Verbs\SerializedByVerbs; +use UnexpectedValueException; trait NormalizeToPropertiesAndClassName { @@ -20,15 +24,19 @@ public static function requiredDataForVerbsDeserialization(): array ->all(); } - public static function deserializeForVerbs(mixed $data): static + public static function deserializeForVerbs(mixed $data, DenormalizerInterface $denormalizer): static { $required = self::requiredDataForVerbsDeserialization(); + + if (!is_array($data)) { + throw new UnexpectedValueException('deserializeForVerbs expects an array'); + } 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), )); } @@ -47,15 +55,21 @@ 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..aff439ae 100644 --- a/src/Support/Normalization/SelfSerializingNormalizer.php +++ b/src/Support/Normalization/SelfSerializingNormalizer.php @@ -5,10 +5,25 @@ use InvalidArgumentException; 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 SelfSerializingNormalizer implements DenormalizerInterface, NormalizerInterface +class SelfSerializingNormalizer 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 SelfSerializingNormalizer expects a serializer that implements both normalization and denormalization.'); + } + public function supportsDenormalization(mixed $data, string $type, string $format = null): bool { return is_a($type, SerializedByVerbs::class, true); @@ -20,8 +35,8 @@ public function denormalize(mixed $data, string $type, string $format = null, ar if (is_string($data)) { $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 +50,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..11ff77d6 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); + } +} From b52e09b0bcf66b829c794bf186f5b897e0d13940 Mon Sep 17 00:00:00 2001 From: inxilpro Date: Fri, 17 Nov 2023 17:10:37 +0000 Subject: [PATCH 2/8] Fix styling --- examples/Monopoly/src/Game/Spaces/Space.php | 2 - src/Lifecycle/EventStore.php | 8 +- .../Normalization/CollectionNormalizer.php | 50 ++++---- .../NormalizeToPropertiesAndClassName.php | 25 ++-- .../SelfSerializingNormalizer.php | 28 ++--- tests/Unit/CollectionNormalizerTest.php | 113 +++++++++--------- 6 files changed, 111 insertions(+), 115 deletions(-) diff --git a/examples/Monopoly/src/Game/Spaces/Space.php b/examples/Monopoly/src/Game/Spaces/Space.php index 81118fd9..924db30c 100644 --- a/examples/Monopoly/src/Game/Spaces/Space.php +++ b/examples/Monopoly/src/Game/Spaces/Space.php @@ -3,8 +3,6 @@ namespace Thunk\Verbs\Examples\Monopoly\Game\Spaces; use BadMethodCallException; -use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; -use Thunk\Verbs\Examples\Monopoly\Game\PropertyColor; use Thunk\Verbs\SerializedByVerbs; use Thunk\Verbs\Support\Normalization\NormalizeToPropertiesAndClassName; diff --git a/src/Lifecycle/EventStore.php b/src/Lifecycle/EventStore.php index 541e4498..87fcfa92 100644 --- a/src/Lifecycle/EventStore.php +++ b/src/Lifecycle/EventStore.php @@ -88,10 +88,10 @@ protected function guardAgainstConcurrentWrites(array $events): void }); $query->each(function ($result) use ($max_event_ids) { - $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($state_type.$state_id, 0); + $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($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 for '{$state_type}' with ID {$state_id}. This is higher than the in-memory value of {$max_expected_id}."); diff --git a/src/Support/Normalization/CollectionNormalizer.php b/src/Support/Normalization/CollectionNormalizer.php index 550cb3b8..2be06e15 100644 --- a/src/Support/Normalization/CollectionNormalizer.php +++ b/src/Support/Normalization/CollectionNormalizer.php @@ -44,8 +44,8 @@ public function denormalize(mixed $data, string $type, string $format = null, ar if ($subtype === null) { throw new InvalidArgumentException('Cannot denormalize a Collection that has no type information.'); } - - return $fqcn::make($items)->map(fn($value) => $this->serializer->denormalize($value, $subtype, $format, $context)); + + return $fqcn::make($items)->map(fn ($value) => $this->serializer->denormalize($value, $subtype, $format, $context)); } public function supportsNormalization(mixed $data, string $format = null): bool @@ -62,29 +62,29 @@ public function normalize(mixed $object, string $format = null, array $context = $types = $object->map(fn ($value) => get_debug_type($value))->unique(); if ($types->count() > 1) { - $shared = collect($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()); - - if ($shared->count() > 1 && $shared->contains(SerializedByVerbs::class)) { - $types = $shared; - } else { - throw new InvalidArgumentException(sprintf( - 'Cannot serialize a %s containing mixed types (got %s).', - class_basename($object), - $types->map(fn($fqcn) => class_basename($fqcn))->implode(', ') - )); - } + $shared = collect($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()); + + if ($shared->count() > 1 && $shared->contains(SerializedByVerbs::class)) { + $types = $shared; + } else { + 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([ diff --git a/src/Support/Normalization/NormalizeToPropertiesAndClassName.php b/src/Support/Normalization/NormalizeToPropertiesAndClassName.php index 80542cb7..14c2a2ef 100644 --- a/src/Support/Normalization/NormalizeToPropertiesAndClassName.php +++ b/src/Support/Normalization/NormalizeToPropertiesAndClassName.php @@ -10,7 +10,6 @@ use RuntimeException; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; -use Thunk\Verbs\SerializedByVerbs; use UnexpectedValueException; trait NormalizeToPropertiesAndClassName @@ -27,10 +26,10 @@ public static function requiredDataForVerbsDeserialization(): array public static function deserializeForVerbs(mixed $data, DenormalizerInterface $denormalizer): static { $required = self::requiredDataForVerbsDeserialization(); - - if (!is_array($data)) { - throw new UnexpectedValueException('deserializeForVerbs expects an array'); - } + + if (! is_array($data)) { + throw new UnexpectedValueException('deserializeForVerbs expects an array'); + } if (! Arr::has($data, $required)) { throw new InvalidArgumentException(sprintf( @@ -55,15 +54,15 @@ class_basename(static::class) } $instance = $reflect->newInstanceWithoutConstructor(); - + foreach (Arr::except($data, ['fqcn']) as $key => $value) { - $property = $reflect->getProperty($key); - - if ($property->hasType() && ! $property->getType()->isBuiltin()) { - $value = $denormalizer->denormalize($value, $property->getType()->getName()); - } - - $property->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; diff --git a/src/Support/Normalization/SelfSerializingNormalizer.php b/src/Support/Normalization/SelfSerializingNormalizer.php index aff439ae..ca3c29d9 100644 --- a/src/Support/Normalization/SelfSerializingNormalizer.php +++ b/src/Support/Normalization/SelfSerializingNormalizer.php @@ -11,19 +11,19 @@ class SelfSerializingNormalizer 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 SelfSerializingNormalizer expects a serializer that implements both normalization and denormalization.'); - } - + protected NormalizerInterface|DenormalizerInterface $serializer; + + public function setSerializer(SerializerInterface $serializer) + { + if ($serializer instanceof NormalizerInterface && $serializer instanceof DenormalizerInterface) { + $this->serializer = $serializer; + + return; + } + + throw new InvalidArgumentException('The SelfSerializingNormalizer expects a serializer that implements both normalization and denormalization.'); + } + public function supportsDenormalization(mixed $data, string $type, string $format = null): bool { return is_a($type, SerializedByVerbs::class, true); @@ -35,7 +35,7 @@ public function denormalize(mixed $data, string $type, string $format = null, ar if (is_string($data)) { $data = json_decode($data, true); } - + return $type::deserializeForVerbs($data, $this->serializer); } diff --git a/tests/Unit/CollectionNormalizerTest.php b/tests/Unit/CollectionNormalizerTest.php index 11ff77d6..ba116990 100644 --- a/tests/Unit/CollectionNormalizerTest.php +++ b/tests/Unit/CollectionNormalizerTest.php @@ -83,44 +83,44 @@ ->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); +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 @@ -130,27 +130,26 @@ class CollectionNormalizerTestState extends State class CollectionNormalizerTestDataObject implements SerializedByVerbs { - use NormalizeToPropertiesAndClassName; - - public function __construct( - public string $string, - public int $int, - public CarbonImmutable $carbon, - public array $array, - ) - { - } + 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); - } + public function __construct( + string $string, + int $int, + CarbonImmutable $carbon, + array $array, + public bool $bool, + ) { + parent::__construct($string, $int, $carbon, $array); + } } From e621588a92dd7c8d66a096069721f346f8881383 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Sun, 19 Nov 2023 14:01:45 -0500 Subject: [PATCH 3/8] Some refactoring --- .../Normalization/CollectionNormalizer.php | 86 ++++++++++++------- 1 file changed, 56 insertions(+), 30 deletions(-) diff --git a/src/Support/Normalization/CollectionNormalizer.php b/src/Support/Normalization/CollectionNormalizer.php index 2be06e15..9f29fdcf 100644 --- a/src/Support/Normalization/CollectionNormalizer.php +++ b/src/Support/Normalization/CollectionNormalizer.php @@ -37,7 +37,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'); @@ -59,37 +59,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) { - $shared = collect($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()); - - if ($shared->count() > 1 && $shared->contains(SerializedByVerbs::class)) { - $types = $shared; - } else { - 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->determineCollectionType($object), 'items' => $object->map(fn ($value) => $this->serializer->normalize($value, $format, $context))->all(), ]); } @@ -98,4 +70,58 @@ public function getSupportedTypes(?string $format): array { return [Collection::class => false]; } + + protected function determineCollectionType(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()); + } } From 7337b10bed86f0ed08ae94e253f5184ded11fd24 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Sun, 19 Nov 2023 14:04:37 -0500 Subject: [PATCH 4/8] Extract AcceptsNormalizerAndDenormalizer trait --- .../AcceptsNormalizerAndDenormalizer.php | 27 +++++++++++++++++++ .../Normalization/CollectionNormalizer.php | 22 +++++---------- .../SelfSerializingNormalizer.php | 14 +--------- 3 files changed, 35 insertions(+), 28 deletions(-) create mode 100644 src/Support/Normalization/AcceptsNormalizerAndDenormalizer.php diff --git a/src/Support/Normalization/AcceptsNormalizerAndDenormalizer.php b/src/Support/Normalization/AcceptsNormalizerAndDenormalizer.php new file mode 100644 index 00000000..b83677aa --- /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 implements both normalization and denormalization.', + class_basename($this) + )); + } +} diff --git a/src/Support/Normalization/CollectionNormalizer.php b/src/Support/Normalization/CollectionNormalizer.php index 9f29fdcf..68999cb6 100644 --- a/src/Support/Normalization/CollectionNormalizer.php +++ b/src/Support/Normalization/CollectionNormalizer.php @@ -7,23 +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 { @@ -60,7 +48,9 @@ public function normalize(mixed $object, string $format = null, array $context = } return array_filter([ - 'fqcn' => $object::class === Collection::class ? null : $object::class, + 'fqcn' => $object::class === Collection::class + ? null + : $object::class, 'type' => $this->determineCollectionType($object), 'items' => $object->map(fn ($value) => $this->serializer->normalize($value, $format, $context))->all(), ]); @@ -121,7 +111,9 @@ protected function getSharedAncestorTypes(Collection $types) ->filter() ->unique(); - return $common->isEmpty() ? $parents : $parents->intersect($common); + return $common->isEmpty() + ? $parents + : $parents->intersect($common); }, new Collection()); } } diff --git a/src/Support/Normalization/SelfSerializingNormalizer.php b/src/Support/Normalization/SelfSerializingNormalizer.php index ca3c29d9..a0ca786e 100644 --- a/src/Support/Normalization/SelfSerializingNormalizer.php +++ b/src/Support/Normalization/SelfSerializingNormalizer.php @@ -6,23 +6,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 SelfSerializingNormalizer 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 SelfSerializingNormalizer expects a serializer that implements both normalization and denormalization.'); - } + use AcceptsNormalizerAndDenormalizer; public function supportsDenormalization(mixed $data, string $type, string $format = null): bool { From b4074a0d85ee1cdd319f857c9517927e75492654 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Sun, 19 Nov 2023 14:05:43 -0500 Subject: [PATCH 5/8] Grammar --- src/Support/Normalization/AcceptsNormalizerAndDenormalizer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Support/Normalization/AcceptsNormalizerAndDenormalizer.php b/src/Support/Normalization/AcceptsNormalizerAndDenormalizer.php index b83677aa..a3965d70 100644 --- a/src/Support/Normalization/AcceptsNormalizerAndDenormalizer.php +++ b/src/Support/Normalization/AcceptsNormalizerAndDenormalizer.php @@ -20,7 +20,7 @@ public function setSerializer(SerializerInterface $serializer) } throw new InvalidArgumentException(sprintf( - 'The %s expects a serializer that implements both normalization and denormalization.', + 'The %s expects a serializer that supports both normalization and denormalization.', class_basename($this) )); } From 3848e699674042fba25e34c393de7cb02f350830 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Sun, 19 Nov 2023 14:07:08 -0500 Subject: [PATCH 6/8] Naming --- src/Support/Normalization/CollectionNormalizer.php | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/Support/Normalization/CollectionNormalizer.php b/src/Support/Normalization/CollectionNormalizer.php index 68999cb6..498f13ba 100644 --- a/src/Support/Normalization/CollectionNormalizer.php +++ b/src/Support/Normalization/CollectionNormalizer.php @@ -48,10 +48,8 @@ public function normalize(mixed $object, string $format = null, array $context = } return array_filter([ - 'fqcn' => $object::class === Collection::class - ? null - : $object::class, - 'type' => $this->determineCollectionType($object), + 'fqcn' => $object::class === Collection::class ? null : $object::class, + 'type' => $this->determineContainedType($object), 'items' => $object->map(fn ($value) => $this->serializer->normalize($value, $format, $context))->all(), ]); } @@ -61,7 +59,7 @@ public function getSupportedTypes(?string $format): array return [Collection::class => false]; } - protected function determineCollectionType(Collection $collection): string + protected function determineContainedType(Collection $collection): string { [$only_objects, $types] = $this->getCollectionMetadata($collection); @@ -111,9 +109,7 @@ protected function getSharedAncestorTypes(Collection $types) ->filter() ->unique(); - return $common->isEmpty() - ? $parents - : $parents->intersect($common); + return $common->isEmpty() ? $parents : $parents->intersect($common); }, new Collection()); } } From d13137de8904581d3ad0c7d242cb6e750ac31470 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Sun, 19 Nov 2023 14:12:07 -0500 Subject: [PATCH 7/8] Always pass arrays to deserializeForVerbs --- src/SerializedByVerbs.php | 2 +- .../Normalization/NormalizeToPropertiesAndClassName.php | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/SerializedByVerbs.php b/src/SerializedByVerbs.php index 1ed9e5e3..762f45a9 100644 --- a/src/SerializedByVerbs.php +++ b/src/SerializedByVerbs.php @@ -7,7 +7,7 @@ interface SerializedByVerbs { - public static function deserializeForVerbs(mixed $data, DenormalizerInterface $denormalizer): static; + public static function deserializeForVerbs(array $data, DenormalizerInterface $denormalizer): static; public function serializeForVerbs(NormalizerInterface $normalizer): string|array; } diff --git a/src/Support/Normalization/NormalizeToPropertiesAndClassName.php b/src/Support/Normalization/NormalizeToPropertiesAndClassName.php index 14c2a2ef..fbf72d67 100644 --- a/src/Support/Normalization/NormalizeToPropertiesAndClassName.php +++ b/src/Support/Normalization/NormalizeToPropertiesAndClassName.php @@ -23,14 +23,10 @@ public static function requiredDataForVerbsDeserialization(): array ->all(); } - public static function deserializeForVerbs(mixed $data, DenormalizerInterface $denormalizer): static + public static function deserializeForVerbs(array $data, DenormalizerInterface $denormalizer): static { $required = self::requiredDataForVerbsDeserialization(); - if (! is_array($data)) { - throw new UnexpectedValueException('deserializeForVerbs expects an array'); - } - if (! Arr::has($data, $required)) { throw new InvalidArgumentException(sprintf( 'The following data is required to deserialize to "%s": %s.', From 9901fcda0448193dcdd09db6d4e5f9ac2ffbc501 Mon Sep 17 00:00:00 2001 From: inxilpro Date: Sun, 19 Nov 2023 19:12:34 +0000 Subject: [PATCH 8/8] Fix styling --- src/Support/Normalization/NormalizeToPropertiesAndClassName.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Support/Normalization/NormalizeToPropertiesAndClassName.php b/src/Support/Normalization/NormalizeToPropertiesAndClassName.php index fbf72d67..e5f2f0e6 100644 --- a/src/Support/Normalization/NormalizeToPropertiesAndClassName.php +++ b/src/Support/Normalization/NormalizeToPropertiesAndClassName.php @@ -10,7 +10,6 @@ use RuntimeException; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; -use UnexpectedValueException; trait NormalizeToPropertiesAndClassName {