Skip to content

Commit

Permalink
Merge pull request #12 from hirethunk/chris/more-serialization-improv…
Browse files Browse the repository at this point in the history
…ements

More serialization improvements
  • Loading branch information
inxilpro authored Nov 19, 2023
2 parents 870f5fa + 9901fcd commit 81ad8a8
Show file tree
Hide file tree
Showing 9 changed files with 192 additions and 56 deletions.
10 changes: 6 additions & 4 deletions examples/Monopoly/src/Game/SpaceCollection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
14 changes: 1 addition & 13 deletions examples/Monopoly/src/Game/Spaces/Space.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,19 @@
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;

protected int $position;

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();
Expand Down
7 changes: 4 additions & 3 deletions src/Lifecycle/EventStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -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}.");
}
});
}
Expand Down
7 changes: 5 additions & 2 deletions src/SerializedByVerbs.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
27 changes: 27 additions & 0 deletions src/Support/Normalization/AcceptsNormalizerAndDenormalizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

namespace Thunk\Verbs\Support\Normalization;

use InvalidArgumentException;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\SerializerInterface;

trait AcceptsNormalizerAndDenormalizer
{
protected NormalizerInterface|DenormalizerInterface $serializer;

public function setSerializer(SerializerInterface $serializer)
{
if ($serializer instanceof NormalizerInterface && $serializer instanceof DenormalizerInterface) {
$this->serializer = $serializer;

return;
}

throw new InvalidArgumentException(sprintf(
'The %s expects a serializer that supports both normalization and denormalization.',
class_basename($this)
));
}
}
85 changes: 59 additions & 26 deletions src/Support/Normalization/CollectionNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -36,15 +25,15 @@ 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');
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
Expand All @@ -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(),
]);
}
Expand 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());
}
}
18 changes: 13 additions & 5 deletions src/Support/Normalization/NormalizeToPropertiesAndClassName.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
use ReflectionClass;
use ReflectionProperty;
use RuntimeException;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;

trait NormalizeToPropertiesAndClassName
{
Expand All @@ -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),
));
}

Expand All @@ -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())
Expand Down
9 changes: 6 additions & 3 deletions src/Support/Normalization/SelfSerializingNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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
Expand All @@ -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
Expand Down
Loading

0 comments on commit 81ad8a8

Please sign in to comment.