From 9dfdbb3c9f7176a565cb62f1a5a156cceac18497 Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Fri, 11 Jul 2025 11:40:53 -0500 Subject: [PATCH 01/19] Leave note in MixedExporter for later cleanup. --- src/PropertyHandler/MixedExporter.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/PropertyHandler/MixedExporter.php b/src/PropertyHandler/MixedExporter.php index 502a45b..b178b75 100644 --- a/src/PropertyHandler/MixedExporter.php +++ b/src/PropertyHandler/MixedExporter.php @@ -67,6 +67,8 @@ public function canImport(Field $field, string $format): bool // We can only import if we know that the $source will be an array so that it // can be introspected. If it's not, then this class has no way to tell what // type to tell the Deformatter to read. + // @todo In 2.0, change the API to pass the full Deformatter, not just the format string, + // so that we can check against an ArrayBased interface instead of a hard coded list. return $field->typeCategory === TypeCategory::Mixed && in_array($format, ['json', 'yaml', 'array', 'toml']); } } From 0e7627720552c40f3c9b8c22e38b71fe0832cb75 Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Fri, 11 Jul 2025 11:44:22 -0500 Subject: [PATCH 02/19] Add a UnionField type field. --- src/Attributes/Field.php | 50 ++++++++++++++++++++---------- src/Attributes/UnionField.php | 57 +++++++++++++++++++++++++++++++++++ src/CompoundType.php | 19 ++++++++++++ 3 files changed, 110 insertions(+), 16 deletions(-) create mode 100644 src/Attributes/UnionField.php create mode 100644 src/CompoundType.php diff --git a/src/Attributes/Field.php b/src/Attributes/Field.php index 2a115b7..82718aa 100644 --- a/src/Attributes/Field.php +++ b/src/Attributes/Field.php @@ -11,7 +11,10 @@ use Crell\AttributeUtils\HasSubAttributes; use Crell\AttributeUtils\ReadsClass; use Crell\AttributeUtils\SupportsScopes; +use Crell\AttributeUtils\TypeComplexity; +use Crell\AttributeUtils\TypeDef; use Crell\fp\Evolvable; +use Crell\Serde\CompoundType; use Crell\Serde\FieldTypeIncompatible; use Crell\Serde\IntersectionTypesNotSupported; use Crell\Serde\PropValue; @@ -100,6 +103,13 @@ class Field implements FromReflectionProperty, HasSubAttributes, Excludable, Sup */ public readonly bool $omitIfNull; + /** + * For more complex cases, the full type definition of the property. + * + * The phpType field contains the "preferred type" in those complex cases, + * so even a complex type can "look like" a simple type in most cases. + */ + public readonly TypeDef $typeDef; /** * Additional key/value pairs to be included with an object. @@ -112,6 +122,7 @@ class Field implements FromReflectionProperty, HasSubAttributes, Excludable, Sup * @var array */ public readonly array $extraProperties; + public const TYPE_NOT_SPECIFIED = '__NO_TYPE__'; /** @@ -194,7 +205,17 @@ public function scopes(): array public function fromReflection(\ReflectionProperty $subject): void { $this->phpName = $subject->name; - $this->phpType ??= $this->getNativeType($subject); + + $this->typeDef = new TypeDef($subject->getType()); + if (!in_array($this->typeDef->complexity, [TypeComplexity::Simple, TypeComplexity::Union], true)) { + throw IntersectionTypesNotSupported::create($subject); + } + + // If it's a simple type, we can derive it now. + $type = $this->typeDef->getSimpleType(); + if ($type) { + $this->phpType = $type; + } // An untyped property is equivalent to mixed, which is nullable. // Damnit, PHP. @@ -247,6 +268,18 @@ protected function getDefaultValueFromConstructor(\ReflectionProperty $subject): public function finalize(): void { + if (!$this->phpType) { + if ($this->typeField instanceof CompoundType) { + $this->phpType = $this->typeField->primaryType(); + } else { + $this->phpType = 'mixed'; + } + } + + if ($this->typeField && !$this->typeField->acceptsType($this->phpType)) { + throw FieldTypeIncompatible::create($this->typeField::class, $this->phpType); + } + // We cannot compute these until we have the PHP type, // but they can still be determined entirely at analysis time // and cached. @@ -283,9 +316,6 @@ public function subAttributes(): array protected function fromTypeField(?TypeField $typeField): void { - if ($typeField && !$typeField->acceptsType($this->phpType)) { - throw FieldTypeIncompatible::create($typeField::class, $this->phpType); - } // This may assign to null, which is OK as that will // evaluate to false when we need it to. $this->typeField = $typeField; @@ -339,18 +369,6 @@ protected function deriveTypeCategory(): TypeCategory }; } - protected function getNativeType(\ReflectionProperty $property): string - { - // @todo Support easy unions, like int|float. - $rType = $property->getType(); - return match(true) { - $rType instanceof \ReflectionUnionType => throw UnionTypesNotSupported::create($property), - $rType instanceof \ReflectionIntersectionType => throw IntersectionTypesNotSupported::create($property), - $rType instanceof \ReflectionNamedType => $rType->getName(), - default => static::TYPE_NOT_SPECIFIED, - }; - } - public function deriveSerializedName(): string { return $this->rename?->convert($this->phpName) diff --git a/src/Attributes/UnionField.php b/src/Attributes/UnionField.php new file mode 100644 index 0000000..d6165cb --- /dev/null +++ b/src/Attributes/UnionField.php @@ -0,0 +1,57 @@ + $scopes + * The scopes in which this attribute should apply. + * + * @todo Maybe we need to allow passing manual TypeFields as an array to this attribute, + * so as to allow things like array or datetime in the union? + */ + public function __construct( + public readonly string $primaryType, + protected readonly array $scopes = [null], + ) {} + + public function fromReflection(\ReflectionProperty $subject): void + { + $this->typeDef ??= new TypeDef($subject->getType()); + } + + public function primaryType(): string + { + return $this->primaryType; + } + + public function scopes(): array + { + return $this->scopes; + } + + public function acceptsType(string $type): bool + { + return $this->typeDef->accepts($type); + } + + public function validate(mixed $value): bool + { + // @todo We can probably do better. + return true; + } +} diff --git a/src/CompoundType.php b/src/CompoundType.php new file mode 100644 index 0000000..fabe0e0 --- /dev/null +++ b/src/CompoundType.php @@ -0,0 +1,19 @@ + Date: Mon, 14 Jul 2025 15:19:21 -0500 Subject: [PATCH 03/19] Expand gitattributes to exclude files from downloads. --- .gitattributes | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitattributes b/.gitattributes index 8cfe851..f7d414c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,6 +5,11 @@ /.gitattributes export-ignore /.gitignore export-ignore /.travis.yml export-ignore +/.github export-ignore +/.editorconfig export-ignore +/default-.env export-ignore +/Taskfile export-ignore +/phpstan.neon.dist export-ignore /phpunit.xml.dist export-ignore /.scrutinizer.yml export-ignore /tests export-ignore From 26b4c3ae4c7e46609265541ff7d9ffcbee1fef20 Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Mon, 14 Jul 2025 15:21:59 -0500 Subject: [PATCH 04/19] Require AttributeUtils 1.3, for the improved TypeDef. --- Taskfile | 8 ++++++-- composer.json | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Taskfile b/Taskfile index 2cf1bd6..eed379e 100755 --- a/Taskfile +++ b/Taskfile @@ -2,7 +2,11 @@ set -euo pipefail -project="serde" +dirname=${PWD##*/} # Get the current dir name, without full path +dirname=${dirname:-/} # to correct for the case where PWD is / (root) +dirname=`echo $dirname | tr '[:upper:]' '[:lower:]'` # Convert the dirname to lowercase. + +project=${dirname} appContainer="php-fpm" function build { @@ -24,7 +28,7 @@ function restart { function shell { start - docker compose exec -it -u $(id -u):$(id -g) ${appContainer} bash + docker compose exec -u $(id -u):$(id -g) ${appContainer} bash } function default { diff --git a/composer.json b/composer.json index 36cfc43..0907297 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ ], "require": { "php": "~8.1", - "crell/attributeutils": "~1.2", + "crell/attributeutils": "~1.3", "crell/fp": "~1.0" }, "require-dev": { From 1bd89df66d17b143839b362064e191fd07ff580b Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Mon, 14 Jul 2025 15:39:27 -0500 Subject: [PATCH 05/19] Track the full TypeDef in Field, and fold compound types to 'mixed' as the primary PHP type. --- src/Attributes/Field.php | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/src/Attributes/Field.php b/src/Attributes/Field.php index 82718aa..1607185 100644 --- a/src/Attributes/Field.php +++ b/src/Attributes/Field.php @@ -11,20 +11,17 @@ use Crell\AttributeUtils\HasSubAttributes; use Crell\AttributeUtils\ReadsClass; use Crell\AttributeUtils\SupportsScopes; -use Crell\AttributeUtils\TypeComplexity; use Crell\AttributeUtils\TypeDef; use Crell\fp\Evolvable; -use Crell\Serde\CompoundType; use Crell\Serde\FieldTypeIncompatible; -use Crell\Serde\IntersectionTypesNotSupported; use Crell\Serde\PropValue; use Crell\Serde\Renaming\LiteralName; use Crell\Serde\Renaming\RenamingStrategy; use Crell\Serde\TypeCategory; use Crell\Serde\TypeField; use Crell\Serde\TypeMap; -use Crell\Serde\UnionTypesNotSupported; use Crell\Serde\UnsupportedType; + use function Crell\fp\indexBy; use function Crell\fp\method; use function Crell\fp\pipe; @@ -207,9 +204,6 @@ public function fromReflection(\ReflectionProperty $subject): void $this->phpName = $subject->name; $this->typeDef = new TypeDef($subject->getType()); - if (!in_array($this->typeDef->complexity, [TypeComplexity::Simple, TypeComplexity::Union], true)) { - throw IntersectionTypesNotSupported::create($subject); - } // If it's a simple type, we can derive it now. $type = $this->typeDef->getSimpleType(); @@ -268,15 +262,9 @@ protected function getDefaultValueFromConstructor(\ReflectionProperty $subject): public function finalize(): void { - if (!$this->phpType) { - if ($this->typeField instanceof CompoundType) { - $this->phpType = $this->typeField->primaryType(); - } else { - $this->phpType = 'mixed'; - } - } + $this->phpType ??= 'mixed'; - if ($this->typeField && !$this->typeField->acceptsType($this->phpType)) { + if ($this->typeField && !$this->typeField->acceptsType($this->phpType) && $this->typeDef->accepts($this->phpType)) { throw FieldTypeIncompatible::create($this->typeField::class, $this->phpType); } From 883d91c82756f1c1a050c4b4060e42abc2ee0bb3 Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Mon, 14 Jul 2025 15:42:39 -0500 Subject: [PATCH 06/19] Formatting. --- src/SerdeCommon.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/SerdeCommon.php b/src/SerdeCommon.php index 97ac1c8..1a57561 100644 --- a/src/SerdeCommon.php +++ b/src/SerdeCommon.php @@ -29,6 +29,7 @@ use Crell\Serde\PropertyHandler\UnixTimeExporter; use Devium\Toml\Toml; use Symfony\Component\Yaml\Yaml; + use function Crell\fp\afilter; use function Crell\fp\indexBy; use function Crell\fp\method; From b6a1c7f897d4d9181210e73f0ce50eb410d22e94 Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Mon, 14 Jul 2025 15:44:31 -0500 Subject: [PATCH 07/19] Allow Mixed types to also handle union/intersection types, too. --- src/Attributes/MixedField.php | 8 +++- src/CompoundType.php | 2 +- src/PropertyHandler/MixedExporter.php | 29 +++++++++---- tests/Records/ClassWithInterfaces.php | 15 +++++++ tests/Records/CompoundTypes.php | 15 +++++++ tests/Records/InterfaceA.php | 10 +++++ tests/Records/InterfaceB.php | 10 +++++ tests/Records/UnionTypes.php | 20 +++++++++ tests/SerdeTestCases.php | 62 ++++++++++++++++++++++++++- 9 files changed, 158 insertions(+), 13 deletions(-) create mode 100644 tests/Records/ClassWithInterfaces.php create mode 100644 tests/Records/CompoundTypes.php create mode 100644 tests/Records/InterfaceA.php create mode 100644 tests/Records/InterfaceB.php create mode 100644 tests/Records/UnionTypes.php diff --git a/src/Attributes/MixedField.php b/src/Attributes/MixedField.php index 1169e49..50c8ec8 100644 --- a/src/Attributes/MixedField.php +++ b/src/Attributes/MixedField.php @@ -6,6 +6,7 @@ use Attribute; use Crell\AttributeUtils\SupportsScopes; +use Crell\Serde\CompoundType; use Crell\Serde\TypeField; /** @@ -15,7 +16,7 @@ * value should be deserialized. */ #[Attribute(Attribute::TARGET_PROPERTY)] -class MixedField implements TypeField, SupportsScopes +class MixedField implements TypeField, SupportsScopes, CompoundType { /** * @param string $suggestedType @@ -29,6 +30,11 @@ public function __construct( protected readonly array $scopes = [null], ) {} + public function suggestedType(): string + { + return $this->suggestedType; + } + public function scopes(): array { return $this->scopes; diff --git a/src/CompoundType.php b/src/CompoundType.php index fabe0e0..eebb1f1 100644 --- a/src/CompoundType.php +++ b/src/CompoundType.php @@ -15,5 +15,5 @@ interface CompoundType * Because most of Serde assumes a single type, any compound type * needs to declare what single type it falls back to in most cases. */ - public function primaryType(): string; + public function suggestedType(): string; } diff --git a/src/PropertyHandler/MixedExporter.php b/src/PropertyHandler/MixedExporter.php index b178b75..6817fb6 100644 --- a/src/PropertyHandler/MixedExporter.php +++ b/src/PropertyHandler/MixedExporter.php @@ -4,15 +4,10 @@ namespace Crell\Serde\PropertyHandler; -use Crell\Serde\Attributes\DictionaryField; +use Crell\AttributeUtils\TypeComplexity; use Crell\Serde\Attributes\Field; use Crell\Serde\Attributes\MixedField; -use Crell\Serde\CollectionItem; use Crell\Serde\Deserializer; -use Crell\Serde\Dict; -use Crell\Serde\InvalidArrayKeyType; -use Crell\Serde\KeyType; -use Crell\Serde\Sequence; use Crell\Serde\Serializer; use Crell\Serde\TypeCategory; @@ -47,8 +42,24 @@ public function importValue(Deserializer $deserializer, Field $field, mixed $sou /** @var MixedField|null $typeField */ $typeField = $field->typeField; - if ($typeField && class_exists($typeField->suggestedType) && $type === 'array') { - $type = $typeField->suggestedType; + + // We can make some educated guesses about the type. + + if ($typeField && $type === 'array' && class_exists($typeField->suggestedType())) { + // If the data is an array, and a suggested type is specified, assume the specified type. + $type = $typeField->suggestedType(); + } + else if ($field->typeDef->complexity === TypeComplexity::Union && $type === 'array') { + // If it's a union type, and the incoming data is an array, and one of the + // listed types is a class, we can deduce that is probably what it should + // be deserialized into. If multiple classes are specified, the first + // will be used. If that's not desired, specify a suggested type via attribute. + foreach ($field->typeDef->getUnionTypes() as $t) { + if (class_exists($t)) { + $type = $t; + break; + } + } } return $deserializer->deserialize($source, Field::create( @@ -59,7 +70,7 @@ public function importValue(Deserializer $deserializer, Field $field, mixed $sou public function canExport(Field $field, mixed $value, string $format): bool { - return $field->typeCategory === TypeCategory::Mixed; + return $field->typeCategory === TypeCategory::Mixed && $field->typeDef->accepts(get_debug_type($value)); } public function canImport(Field $field, string $format): bool diff --git a/tests/Records/ClassWithInterfaces.php b/tests/Records/ClassWithInterfaces.php new file mode 100644 index 0000000..9d1992c --- /dev/null +++ b/tests/Records/ClassWithInterfaces.php @@ -0,0 +1,15 @@ + [new MixedVal(['a' => 'A', 'b' => 'B', 'c' => 'C'])]; } + public function mixed_val_property_validate(mixed $serialized, mixed $data): void + { + } + #[Test, DataProvider('mixed_val_property_object_examples')] public function mixed_val_property_object(mixed $data): void { @@ -1089,7 +1097,7 @@ public function mixed_val_property_object(mixed $data): void $serialized = $s->serialize($data, $this->format); - $this->mixed_val_property_validate($serialized, $data); + $this->mixed_val_property_object_validate($serialized, $data); $result = $s->deserialize($serialized, from: $this->format, to: MixedValObject::class); @@ -1104,7 +1112,57 @@ public static function mixed_val_property_object_examples(): iterable yield 'object' => [new MixedValObject(new Point(1, 2, 3))]; } - public function mixed_val_property_validate(mixed $serialized, mixed $data): void + public function mixed_val_property_object_validate(mixed $serialized, mixed $data): void + { + } + + #[Test, DataProvider('union_types_examples')] + public function union_types(mixed $data): void + { + $s = new SerdeCommon(formatters: $this->formatters); + + $serialized = $s->serialize($data, $this->format); + + $this->union_types_validate($serialized, $data); + + $result = $s->deserialize($serialized, from: $this->format, to: $data::class); + + self::assertEquals($data, $result); + } + + public static function union_types_examples(): iterable + { + yield 'all primitives' => [new UnionTypes(5, 3.14, 'point', 'email')]; + yield 'object and string' => [new UnionTypes('five', 3, new Point(1, 2, 3), 'email')]; + yield 'property with 2 classes' => [new UnionTypes('five', 3, new Point(1, 2, 3), new Email('email@example.com'))]; + } + + public function union_types_validate(mixed $serialized, mixed $data): void + { + } + + #[Test, DataProvider('compound_types_examples')] + #[RequiresPhp('>=8.2')] + public function compound_types(mixed $data): void + { + $s = new SerdeCommon(formatters: $this->formatters); + + $serialized = $s->serialize($data, $this->format); + + $this->compound_types_validate($serialized, $data); + + $result = $s->deserialize($serialized, from: $this->format, to: $data::class); + + self::assertEquals($data, $result); + } + + public static function compound_types_examples(): iterable + { + yield 'string' => [new CompoundTypes('foo')]; + yield 'intersection type' => [new CompoundTypes(new ClassWithInterfaces('a'))]; + } + + public function compound_types_validate(mixed $serialized, mixed $data): void { } From 8ea04dfff1058644a21e89b8081235e84159f768 Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Mon, 14 Jul 2025 16:21:47 -0500 Subject: [PATCH 08/19] Allow formatters to opt in to being able to derive types for the incoming data. --- src/Formatter/ArrayBasedDeformatter.php | 5 ++ src/Formatter/ArrayFormatter.php | 2 +- src/Formatter/JsonFormatter.php | 2 +- src/Formatter/SupportsCollecting.php | 5 +- src/Formatter/SupportsTypeIntrospection.php | 28 ++++++++++ src/Formatter/TomlFormatter.php | 2 +- src/Formatter/YamlFormatter.php | 2 +- src/PropertyHandler/MixedExporter.php | 57 +++++++++++++-------- src/UnableToDeriveTypeOnMixedField.php | 25 +++++++++ 9 files changed, 102 insertions(+), 26 deletions(-) create mode 100644 src/Formatter/SupportsTypeIntrospection.php create mode 100644 src/UnableToDeriveTypeOnMixedField.php diff --git a/src/Formatter/ArrayBasedDeformatter.php b/src/Formatter/ArrayBasedDeformatter.php index 12839a2..660143a 100644 --- a/src/Formatter/ArrayBasedDeformatter.php +++ b/src/Formatter/ArrayBasedDeformatter.php @@ -325,4 +325,9 @@ public function getRemainingData(mixed $source, array $used): array { return array_diff_key($source, array_flip($used)); } + + public function getType(mixed $decoded, Field $field): string + { + return \get_debug_type($decoded[$field->serializedName]); + } } diff --git a/src/Formatter/ArrayFormatter.php b/src/Formatter/ArrayFormatter.php index 209f358..ce904db 100644 --- a/src/Formatter/ArrayFormatter.php +++ b/src/Formatter/ArrayFormatter.php @@ -8,7 +8,7 @@ use Crell\Serde\Attributes\Field; use Crell\Serde\Deserializer; -class ArrayFormatter implements Formatter, Deformatter, SupportsCollecting +class ArrayFormatter implements Formatter, Deformatter, SupportsCollecting, SupportsTypeIntrospection { use ArrayBasedFormatter; use ArrayBasedDeformatter; diff --git a/src/Formatter/JsonFormatter.php b/src/Formatter/JsonFormatter.php index efa0ce9..4adeebd 100644 --- a/src/Formatter/JsonFormatter.php +++ b/src/Formatter/JsonFormatter.php @@ -8,7 +8,7 @@ use Crell\Serde\Attributes\Field; use Crell\Serde\Deserializer; -class JsonFormatter implements Formatter, Deformatter, SupportsCollecting +class JsonFormatter implements Formatter, Deformatter, SupportsCollecting, SupportsTypeIntrospection { use ArrayBasedFormatter; use ArrayBasedDeformatter; diff --git a/src/Formatter/SupportsCollecting.php b/src/Formatter/SupportsCollecting.php index 5d4dc84..60adf90 100644 --- a/src/Formatter/SupportsCollecting.php +++ b/src/Formatter/SupportsCollecting.php @@ -4,11 +4,12 @@ namespace Crell\Serde\Formatter; +/** + * Indicates a Deformatter that supports reading "the rest of the data" into a collected value. + */ interface SupportsCollecting { /** - * - * * @param mixed $source * The deformatter-specific source value being passed around. * @param string[] $used diff --git a/src/Formatter/SupportsTypeIntrospection.php b/src/Formatter/SupportsTypeIntrospection.php new file mode 100644 index 0000000..387c531 --- /dev/null +++ b/src/Formatter/SupportsTypeIntrospection.php @@ -0,0 +1,28 @@ +serializedName field is the + * name of the field for which we want the type. + * @return string + * The type of the specified Field. This must be a valid PHP type. So + * not "uint32", just "int," for example. PHP class names are allowed. + */ + public function getType(mixed $decoded, Field $field): string; +} diff --git a/src/Formatter/TomlFormatter.php b/src/Formatter/TomlFormatter.php index ae2017e..3070de2 100644 --- a/src/Formatter/TomlFormatter.php +++ b/src/Formatter/TomlFormatter.php @@ -17,7 +17,7 @@ use function Crell\fp\collect; -class TomlFormatter implements Formatter, Deformatter, SupportsCollecting +class TomlFormatter implements Formatter, Deformatter, SupportsCollecting, SupportsTypeIntrospection { use ArrayBasedFormatter { ArrayBasedFormatter::serializeSequence as serializeArraySequence; diff --git a/src/Formatter/YamlFormatter.php b/src/Formatter/YamlFormatter.php index f3a42f8..5c5cc38 100644 --- a/src/Formatter/YamlFormatter.php +++ b/src/Formatter/YamlFormatter.php @@ -9,7 +9,7 @@ use Crell\Serde\Deserializer; use Symfony\Component\Yaml\Yaml; -class YamlFormatter implements Formatter, Deformatter, SupportsCollecting +class YamlFormatter implements Formatter, Deformatter, SupportsCollecting, SupportsTypeIntrospection { use ArrayBasedFormatter; use ArrayBasedDeformatter; diff --git a/src/PropertyHandler/MixedExporter.php b/src/PropertyHandler/MixedExporter.php index 6817fb6..18fe868 100644 --- a/src/PropertyHandler/MixedExporter.php +++ b/src/PropertyHandler/MixedExporter.php @@ -7,7 +7,9 @@ use Crell\AttributeUtils\TypeComplexity; use Crell\Serde\Attributes\Field; use Crell\Serde\Attributes\MixedField; +use Crell\Serde\UnableToDeriveTypeOnMixedField; use Crell\Serde\Deserializer; +use Crell\Serde\Formatter\SupportsTypeIntrospection; use Crell\Serde\Serializer; use Crell\Serde\TypeCategory; @@ -35,37 +37,55 @@ public function exportValue(Serializer $serializer, Field $field, mixed $value, public function importValue(Deserializer $deserializer, Field $field, mixed $source): mixed { - // This is normally a bad idea, as the $source should be opaque. In this - // case, we're guaranteed that the $source is array-based, so we can introspect - // it directly. - $type = \get_debug_type($source[$field->serializedName]); + $type = $this->deriveType($deserializer, $field, $source) + ?? throw UnableToDeriveTypeOnMixedField::create($deserializer->deformatter, $field); + + return $deserializer->deserialize($source, Field::create( + serializedName: $field->serializedName, + phpType: $type, + )); + } + + /** + * Determines the type of the incoming value. + * + * If the MixedField attribute specifies a preferred type, that takes precedence. + * If the deformatter is able to determine it for us, that will be trusted. + * If it's a union type, we'll make an educated guess that it's the first class listed. + */ + protected function deriveType(Deserializer $deserializer, Field $field, mixed $source): ?string + { + $type = null; + + if ($deserializer->deformatter instanceof SupportsTypeIntrospection) { + $type = $deserializer->deformatter->getType($source, $field); + } /** @var MixedField|null $typeField */ $typeField = $field->typeField; - // We can make some educated guesses about the type. - - if ($typeField && $type === 'array' && class_exists($typeField->suggestedType())) { - // If the data is an array, and a suggested type is specified, assume the specified type. - $type = $typeField->suggestedType(); + if ($typeField && ($type === 'array' || $type === null) && class_exists($typeField->suggestedType())) { + // If the data is an array or unspecified, + // and a suggested type is specified, assume the specified type. + return $typeField->suggestedType(); + } + if (class_exists($type)) { + // The deformatter already determined what class it should be. Trust it. + return $type; } - else if ($field->typeDef->complexity === TypeComplexity::Union && $type === 'array') { + if ($field->typeDef->complexity === TypeComplexity::Union && ($type === 'array' || $type == null)) { // If it's a union type, and the incoming data is an array, and one of the // listed types is a class, we can deduce that is probably what it should // be deserialized into. If multiple classes are specified, the first // will be used. If that's not desired, specify a suggested type via attribute. foreach ($field->typeDef->getUnionTypes() as $t) { if (class_exists($t)) { - $type = $t; - break; + return $t; } } } - return $deserializer->deserialize($source, Field::create( - serializedName: $field->serializedName, - phpType: $type, - )); + return $type; } public function canExport(Field $field, mixed $value, string $format): bool @@ -75,11 +95,8 @@ public function canExport(Field $field, mixed $value, string $format): bool public function canImport(Field $field, string $format): bool { - // We can only import if we know that the $source will be an array so that it - // can be introspected. If it's not, then this class has no way to tell what - // type to tell the Deformatter to read. // @todo In 2.0, change the API to pass the full Deformatter, not just the format string, - // so that we can check against an ArrayBased interface instead of a hard coded list. + // so that we can check against the SupportsTypeIntrospection interface instead of a hard coded list. return $field->typeCategory === TypeCategory::Mixed && in_array($format, ['json', 'yaml', 'array', 'toml']); } } diff --git a/src/UnableToDeriveTypeOnMixedField.php b/src/UnableToDeriveTypeOnMixedField.php new file mode 100644 index 0000000..f1bf20a --- /dev/null +++ b/src/UnableToDeriveTypeOnMixedField.php @@ -0,0 +1,25 @@ +deformatter = $deformatter; + $new->field = $field; + + $new->message = sprintf('The %s format does not support type introspection, and the %s (%s) field does not specify a type to deserialize to.', $deformatter->format(), $field->phpName, $field->serializedName); + + return $new; + } +} From 1714499fa0ebd5803b9f4aadde46eae8f65c0d59 Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Tue, 15 Jul 2025 07:29:32 -0500 Subject: [PATCH 09/19] Support interfaces in union types. --- src/PropertyHandler/MixedExporter.php | 4 +-- tests/Records/UnionTypeWithInterface.php | 34 ++++++++++++++++++++++++ tests/SerdeTestCases.php | 6 +++++ 3 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 tests/Records/UnionTypeWithInterface.php diff --git a/src/PropertyHandler/MixedExporter.php b/src/PropertyHandler/MixedExporter.php index 18fe868..36f3bb0 100644 --- a/src/PropertyHandler/MixedExporter.php +++ b/src/PropertyHandler/MixedExporter.php @@ -69,7 +69,7 @@ protected function deriveType(Deserializer $deserializer, Field $field, mixed $s // and a suggested type is specified, assume the specified type. return $typeField->suggestedType(); } - if (class_exists($type)) { + if (class_exists($type) || interface_exists($type)) { // The deformatter already determined what class it should be. Trust it. return $type; } @@ -79,7 +79,7 @@ protected function deriveType(Deserializer $deserializer, Field $field, mixed $s // be deserialized into. If multiple classes are specified, the first // will be used. If that's not desired, specify a suggested type via attribute. foreach ($field->typeDef->getUnionTypes() as $t) { - if (class_exists($t)) { + if (class_exists($t) || interface_exists($t)) { return $t; } } diff --git a/tests/Records/UnionTypeWithInterface.php b/tests/Records/UnionTypeWithInterface.php new file mode 100644 index 0000000..350e8a7 --- /dev/null +++ b/tests/Records/UnionTypeWithInterface.php @@ -0,0 +1,34 @@ + ACT::class, + 'sat' => SAT::class, +])] +interface StandardTest {} + +class ACT implements StandardTest +{ + public function __construct( + public int $score, + ) {} +} + +class SAT implements StandardTest +{ + public function __construct( + public int $score, + ) {} +} diff --git a/tests/SerdeTestCases.php b/tests/SerdeTestCases.php index 1f5ff0f..7d716bd 100644 --- a/tests/SerdeTestCases.php +++ b/tests/SerdeTestCases.php @@ -14,6 +14,7 @@ use Crell\Serde\PropertyHandler\Exporter; use Crell\Serde\PropertyHandler\ObjectExporter; use Crell\Serde\PropertyHandler\ObjectImporter; +use Crell\Serde\Records\ACT; use Crell\Serde\Records\AliasedFields; use Crell\Serde\Records\AllFieldTypes; use Crell\Serde\Records\BackedSize; @@ -80,6 +81,7 @@ use Crell\Serde\Records\RequiresFieldValuesClass; use Crell\Serde\Records\RootMap\Type; use Crell\Serde\Records\RootMap\TypeB; +use Crell\Serde\Records\SAT; use Crell\Serde\Records\ScalarArrays; use Crell\Serde\Records\SequenceOfStrings; use Crell\Serde\Records\Shapes\Box; @@ -97,6 +99,7 @@ use Crell\Serde\Records\TraversableInts; use Crell\Serde\Records\TraversablePoints; use Crell\Serde\Records\Traversables; +use Crell\Serde\Records\UnionTypeWithInterface; use Crell\Serde\Records\UnixTimeExample; use Crell\Serde\Records\ValueObjects\Age; use Crell\Serde\Records\ValueObjects\Email; @@ -1135,6 +1138,9 @@ public static function union_types_examples(): iterable yield 'all primitives' => [new UnionTypes(5, 3.14, 'point', 'email')]; yield 'object and string' => [new UnionTypes('five', 3, new Point(1, 2, 3), 'email')]; yield 'property with 2 classes' => [new UnionTypes('five', 3, new Point(1, 2, 3), new Email('email@example.com'))]; + yield 'union with interface, with int' => [new UnionTypeWithInterface(99)]; + yield 'union with interface, with ACT' => [new UnionTypeWithInterface(new ACT(30))]; + yield 'union with interface, with SAT' => [new UnionTypeWithInterface(new SAT(1300))]; } public function union_types_validate(mixed $serialized, mixed $data): void From 705f53b72be32b5fd7a35a872a21818f89b40147 Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Tue, 15 Jul 2025 08:09:30 -0500 Subject: [PATCH 10/19] Support sub-typefields on union types. --- src/Attributes/UnionField.php | 34 ++++++++----------------- src/PropertyHandler/MixedExporter.php | 9 +++++++ tests/Records/UnionTypeSubTypeField.php | 17 +++++++++++++ tests/SerdeTestCases.php | 3 +++ 4 files changed, 40 insertions(+), 23 deletions(-) create mode 100644 tests/Records/UnionTypeSubTypeField.php diff --git a/src/Attributes/UnionField.php b/src/Attributes/UnionField.php index d6165cb..aedc510 100644 --- a/src/Attributes/UnionField.php +++ b/src/Attributes/UnionField.php @@ -5,45 +5,34 @@ namespace Crell\Serde\Attributes; use Crell\AttributeUtils\FromReflectionProperty; -use Crell\AttributeUtils\SupportsScopes; use Crell\AttributeUtils\TypeDef; -use Crell\Serde\CompoundType; -use Crell\Serde\TypeField; #[\Attribute(\Attribute::TARGET_PROPERTY)] -class UnionField implements TypeField, SupportsScopes, FromReflectionProperty, CompoundType +class UnionField extends MixedField implements FromReflectionProperty { - // @todo This may make more sense on Field. Not sure yet. + // @todo This is also stored on Field; it would be nice to read from there, + // but TypeFields don't get a reference back to the Field. That may be + // something to improve in 2.0. public readonly TypeDef $typeDef; /** * @param string $primaryType * @param array $scopes * The scopes in which this attribute should apply. - * - * @todo Maybe we need to allow passing manual TypeFields as an array to this attribute, - * so as to allow things like array or datetime in the union? */ public function __construct( - public readonly string $primaryType, - protected readonly array $scopes = [null], - ) {} + string $primaryType, + public readonly array $typeFields = [], + array $scopes = [null], + ) { + parent::__construct($primaryType, $scopes); + } public function fromReflection(\ReflectionProperty $subject): void { $this->typeDef ??= new TypeDef($subject->getType()); } - public function primaryType(): string - { - return $this->primaryType; - } - - public function scopes(): array - { - return $this->scopes; - } - public function acceptsType(string $type): bool { return $this->typeDef->accepts($type); @@ -51,7 +40,6 @@ public function acceptsType(string $type): bool public function validate(mixed $value): bool { - // @todo We can probably do better. - return true; + return $this->typeDef->accepts(get_debug_type($value)); } } diff --git a/src/PropertyHandler/MixedExporter.php b/src/PropertyHandler/MixedExporter.php index 36f3bb0..071d59c 100644 --- a/src/PropertyHandler/MixedExporter.php +++ b/src/PropertyHandler/MixedExporter.php @@ -7,6 +7,7 @@ use Crell\AttributeUtils\TypeComplexity; use Crell\Serde\Attributes\Field; use Crell\Serde\Attributes\MixedField; +use Crell\Serde\Attributes\UnionField; use Crell\Serde\UnableToDeriveTypeOnMixedField; use Crell\Serde\Deserializer; use Crell\Serde\Formatter\SupportsTypeIntrospection; @@ -40,9 +41,17 @@ public function importValue(Deserializer $deserializer, Field $field, mixed $sou $type = $this->deriveType($deserializer, $field, $source) ?? throw UnableToDeriveTypeOnMixedField::create($deserializer->deformatter, $field); + // Folding UnionField in here is not ideal, but it means we don't have to + // worry about ordering a UnionExporter vs this one, and this is the only + // difference between the fields. + $subTypeField = ($field->typeField instanceof UnionField) + ? $field->typeField->typeFields[$type] ?? null + : null; + return $deserializer->deserialize($source, Field::create( serializedName: $field->serializedName, phpType: $type, + typeField: $subTypeField, )); } diff --git a/tests/Records/UnionTypeSubTypeField.php b/tests/Records/UnionTypeSubTypeField.php new file mode 100644 index 0000000..a43fecd --- /dev/null +++ b/tests/Records/UnionTypeSubTypeField.php @@ -0,0 +1,17 @@ + new DictionaryField(Point::class, KeyType::String)])] + public string|array $values, + ) {} +} diff --git a/tests/SerdeTestCases.php b/tests/SerdeTestCases.php index 7d716bd..8c4042c 100644 --- a/tests/SerdeTestCases.php +++ b/tests/SerdeTestCases.php @@ -99,6 +99,7 @@ use Crell\Serde\Records\TraversableInts; use Crell\Serde\Records\TraversablePoints; use Crell\Serde\Records\Traversables; +use Crell\Serde\Records\UnionTypeSubTypeField; use Crell\Serde\Records\UnionTypeWithInterface; use Crell\Serde\Records\UnixTimeExample; use Crell\Serde\Records\ValueObjects\Age; @@ -1141,6 +1142,8 @@ public static function union_types_examples(): iterable yield 'union with interface, with int' => [new UnionTypeWithInterface(99)]; yield 'union with interface, with ACT' => [new UnionTypeWithInterface(new ACT(30))]; yield 'union with interface, with SAT' => [new UnionTypeWithInterface(new SAT(1300))]; + yield 'union with sub-typefield, with string' => [new UnionTypeSubTypeField('hello')]; + yield 'union with sub-typefield, with array' => [new UnionTypeSubTypeField(['hello' => new Point(1, 2, 3)])]; } public function union_types_validate(mixed $serialized, mixed $data): void From c0c5b3ae02c7b94209347b03ef41496b8e01a9fa Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Tue, 15 Jul 2025 08:16:08 -0500 Subject: [PATCH 11/19] Use proper serialized validation routines in tests. --- tests/SerdeTestCases.php | 26 +++++--------------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/tests/SerdeTestCases.php b/tests/SerdeTestCases.php index 8c4042c..2968e56 100644 --- a/tests/SerdeTestCases.php +++ b/tests/SerdeTestCases.php @@ -125,7 +125,7 @@ * * - Extend this class. * - In setUp(), set the $formatters and $format property accordingly. - * - Override any of the *_validate() methods desired to introspect + * - Optionally define a _validate($serialized) method to introspect * the serialized data for that test in a format-specific way. */ abstract class SerdeTestCases extends TestCase @@ -1074,7 +1074,7 @@ public function mixed_val_property(mixed $data): void $serialized = $s->serialize($data, $this->format); - $this->mixed_val_property_validate($serialized, $data); + $this->validateSerialized($serialized, __FUNCTION__); $result = $s->deserialize($serialized, from: $this->format, to: MixedVal::class); @@ -1090,10 +1090,6 @@ public static function mixed_val_property_examples(): iterable yield 'dict' => [new MixedVal(['a' => 'A', 'b' => 'B', 'c' => 'C'])]; } - public function mixed_val_property_validate(mixed $serialized, mixed $data): void - { - } - #[Test, DataProvider('mixed_val_property_object_examples')] public function mixed_val_property_object(mixed $data): void { @@ -1101,7 +1097,7 @@ public function mixed_val_property_object(mixed $data): void $serialized = $s->serialize($data, $this->format); - $this->mixed_val_property_object_validate($serialized, $data); + $this->validateSerialized($serialized, __FUNCTION__); $result = $s->deserialize($serialized, from: $this->format, to: MixedValObject::class); @@ -1116,10 +1112,6 @@ public static function mixed_val_property_object_examples(): iterable yield 'object' => [new MixedValObject(new Point(1, 2, 3))]; } - public function mixed_val_property_object_validate(mixed $serialized, mixed $data): void - { - } - #[Test, DataProvider('union_types_examples')] public function union_types(mixed $data): void { @@ -1127,7 +1119,7 @@ public function union_types(mixed $data): void $serialized = $s->serialize($data, $this->format); - $this->union_types_validate($serialized, $data); + $this->validateSerialized($serialized, __FUNCTION__); $result = $s->deserialize($serialized, from: $this->format, to: $data::class); @@ -1146,10 +1138,6 @@ public static function union_types_examples(): iterable yield 'union with sub-typefield, with array' => [new UnionTypeSubTypeField(['hello' => new Point(1, 2, 3)])]; } - public function union_types_validate(mixed $serialized, mixed $data): void - { - } - #[Test, DataProvider('compound_types_examples')] #[RequiresPhp('>=8.2')] public function compound_types(mixed $data): void @@ -1158,7 +1146,7 @@ public function compound_types(mixed $data): void $serialized = $s->serialize($data, $this->format); - $this->compound_types_validate($serialized, $data); + $this->validateSerialized($serialized, __FUNCTION__); $result = $s->deserialize($serialized, from: $this->format, to: $data::class); @@ -1171,10 +1159,6 @@ public static function compound_types_examples(): iterable yield 'intersection type' => [new CompoundTypes(new ClassWithInterfaces('a'))]; } - public function compound_types_validate(mixed $serialized, mixed $data): void - { - } - #[Test] public function generator_property_is_run_out(): void { From 93a1907710bd150ac6efc83ba679e43f93ba544b Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Tue, 15 Jul 2025 08:42:24 -0500 Subject: [PATCH 12/19] Link all round-tripping data providers to the existing test method. --- tests/SerdeTestCases.php | 193 ++++++++++++++++++++------------------- 1 file changed, 99 insertions(+), 94 deletions(-) diff --git a/tests/SerdeTestCases.php b/tests/SerdeTestCases.php index 2968e56..81162c4 100644 --- a/tests/SerdeTestCases.php +++ b/tests/SerdeTestCases.php @@ -190,13 +190,18 @@ abstract protected function arrayify(mixed $serialized): array; protected function validateSerialized(mixed $serialized, string $name): void { - $validateMethod = $name . '_validate'; + $validateMethod = str_replace([' ', ':', ','], '_', $name) . '_validate'; if (method_exists($this, $validateMethod)) { $this->$validateMethod($serialized); } } - #[Test, DataProvider('round_trip_examples')] + #[Test] + #[DataProvider('round_trip_examples')] + #[DataProvider('union_types_examples')] + #[DataProvider('compound_types_examples')] + #[DataProvider('mixed_val_property_examples')] + #[DataProvider('mixed_val_property_object_examples')] public function round_trip(object $data, string $name): void { $s = new SerdeCommon(formatters: $this->formatters); @@ -368,6 +373,98 @@ public static function round_trip_examples(): iterable ]; } + public static function mixed_val_property_examples(): iterable + { + yield 'mixed val: string' => [ + 'data' => new MixedVal('hello'), + 'name' => 'mixed val: string', + ]; + yield 'mixed val: int' => [ + 'data' => new MixedVal(5), + 'name' => 'mixed val: int', + ]; + yield 'mixed val: float' => [ + 'data' => new MixedVal(3.14), + 'name' => 'mixed val: float', + ]; + yield 'mixed val: sequence' => [ + 'data' => new MixedVal(['a', 'b', 'c']), + 'name' => 'mixed val: sequence', + ]; + yield 'mixed val: dict' => [ + 'data' => new MixedVal(['a' => 'A', 'b' => 'B', 'c' => 'C']), + 'name' => 'mixed val: dict', + ]; + } + + public static function mixed_val_property_object_examples(): iterable + { + yield 'mixed val, object: string' => [ + 'data' => new MixedValObject('hello'), + 'name' => 'mixed val, object: string', + ]; + yield 'mixed val, object: int' => [ + 'data' => new MixedValObject(5), + 'name' => 'mixed val, object: int', + ]; + yield 'mixed val, object: float' => [ + 'data' => new MixedValObject(3.14), + 'name' => 'mixed val, object: float', + ]; + yield 'mixed val, object: object' => [ + 'data' => new MixedValObject(new Point(1, 2, 3)), + 'name' => 'mixed val, object: object', + ]; + } + + public static function union_types_examples(): iterable + { + yield 'union: all primitives' => [ + 'data' => new UnionTypes(5, 3.14, 'point', 'email'), + 'name' => 'union: all primitives', + ]; + yield 'union: object and string' => [ + 'data' => new UnionTypes('five', 3, new Point(1, 2, 3), 'email'), + 'name' => 'union: object and string', + ]; + yield 'union: property with 2 classes' => [ + 'data' => new UnionTypes('five', 3, new Point(1, 2, 3), new Email('email@example.com')), + 'name' => 'union: property with 2 classes', + ]; + yield 'union: union with interface, with int' => [ + 'data' => new UnionTypeWithInterface(99), + 'name' => 'union: union with interface, with int', + ]; + yield 'union: union with interface, with ACT' => [ + 'data' => new UnionTypeWithInterface(new ACT(30)), + 'name' => 'union: union with interface, with ACT', + ]; + yield 'union: union with interface, with SAT' => [ + 'data' => new UnionTypeWithInterface(new SAT(1300)), + 'name' => 'union: union with interface, with SAT', + ]; + yield 'union: union with sub-typefield, with string' => [ + 'data' => new UnionTypeSubTypeField('hello'), + 'name' => 'union: union with sub-typefield, with string', + ]; + yield 'union: union with sub-typefield, with array' => [ + 'data' => new UnionTypeSubTypeField(['hello' => new Point(1, 2, 3)]), + 'name' => 'union: union with sub-typefield, with array', + ]; + } + + public static function compound_types_examples(): iterable + { + yield 'string' => [ + 'data' => new CompoundTypes('foo'), + 'name' => 'string', + ]; + yield 'intersection type' => [ + 'data' => new CompoundTypes(new ClassWithInterfaces('a')), + 'name' => 'intersection type', + ]; + } + /** * These all relate to flattening, so only work on some formatters. */ @@ -1067,98 +1164,6 @@ public function dictionary_key_string_in_int_throws_on_deserialize(): void $result = $s->deserialize($this->invalidDictStringKey, $this->format, DictionaryKeyTypes::class); } - #[Test, DataProvider('mixed_val_property_examples')] - public function mixed_val_property(mixed $data): void - { - $s = new SerdeCommon(formatters: $this->formatters); - - $serialized = $s->serialize($data, $this->format); - - $this->validateSerialized($serialized, __FUNCTION__); - - $result = $s->deserialize($serialized, from: $this->format, to: MixedVal::class); - - self::assertEquals($data, $result); - } - - public static function mixed_val_property_examples(): iterable - { - yield 'string' => [new MixedVal('hello')]; - yield 'int' => [new MixedVal(5)]; - yield 'float' => [new MixedVal(3.14)]; - yield 'sequence' => [new MixedVal(['a', 'b', 'c'])]; - yield 'dict' => [new MixedVal(['a' => 'A', 'b' => 'B', 'c' => 'C'])]; - } - - #[Test, DataProvider('mixed_val_property_object_examples')] - public function mixed_val_property_object(mixed $data): void - { - $s = new SerdeCommon(formatters: $this->formatters); - - $serialized = $s->serialize($data, $this->format); - - $this->validateSerialized($serialized, __FUNCTION__); - - $result = $s->deserialize($serialized, from: $this->format, to: MixedValObject::class); - - self::assertEquals($data, $result); - } - - public static function mixed_val_property_object_examples(): iterable - { - yield 'string' => [new MixedValObject('hello')]; - yield 'int' => [new MixedValObject(5)]; - yield 'float' => [new MixedValObject(3.14)]; - yield 'object' => [new MixedValObject(new Point(1, 2, 3))]; - } - - #[Test, DataProvider('union_types_examples')] - public function union_types(mixed $data): void - { - $s = new SerdeCommon(formatters: $this->formatters); - - $serialized = $s->serialize($data, $this->format); - - $this->validateSerialized($serialized, __FUNCTION__); - - $result = $s->deserialize($serialized, from: $this->format, to: $data::class); - - self::assertEquals($data, $result); - } - - public static function union_types_examples(): iterable - { - yield 'all primitives' => [new UnionTypes(5, 3.14, 'point', 'email')]; - yield 'object and string' => [new UnionTypes('five', 3, new Point(1, 2, 3), 'email')]; - yield 'property with 2 classes' => [new UnionTypes('five', 3, new Point(1, 2, 3), new Email('email@example.com'))]; - yield 'union with interface, with int' => [new UnionTypeWithInterface(99)]; - yield 'union with interface, with ACT' => [new UnionTypeWithInterface(new ACT(30))]; - yield 'union with interface, with SAT' => [new UnionTypeWithInterface(new SAT(1300))]; - yield 'union with sub-typefield, with string' => [new UnionTypeSubTypeField('hello')]; - yield 'union with sub-typefield, with array' => [new UnionTypeSubTypeField(['hello' => new Point(1, 2, 3)])]; - } - - #[Test, DataProvider('compound_types_examples')] - #[RequiresPhp('>=8.2')] - public function compound_types(mixed $data): void - { - $s = new SerdeCommon(formatters: $this->formatters); - - $serialized = $s->serialize($data, $this->format); - - $this->validateSerialized($serialized, __FUNCTION__); - - $result = $s->deserialize($serialized, from: $this->format, to: $data::class); - - self::assertEquals($data, $result); - } - - public static function compound_types_examples(): iterable - { - yield 'string' => [new CompoundTypes('foo')]; - yield 'intersection type' => [new CompoundTypes(new ClassWithInterfaces('a'))]; - } - #[Test] public function generator_property_is_run_out(): void { From 0a222b888350aec4480aaa3cf99d3bb01362ad07 Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Tue, 15 Jul 2025 08:45:56 -0500 Subject: [PATCH 13/19] Reorganize test case providers a little. --- tests/SerdeTestCases.php | 8 ++++++-- tests/TomlFormatterTest.php | 11 +++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/tests/SerdeTestCases.php b/tests/SerdeTestCases.php index 81162c4..3459b4f 100644 --- a/tests/SerdeTestCases.php +++ b/tests/SerdeTestCases.php @@ -198,10 +198,11 @@ protected function validateSerialized(mixed $serialized, string $name): void #[Test] #[DataProvider('round_trip_examples')] - #[DataProvider('union_types_examples')] - #[DataProvider('compound_types_examples')] + #[DataProvider('value_object_flatten_examples')] #[DataProvider('mixed_val_property_examples')] #[DataProvider('mixed_val_property_object_examples')] + #[DataProvider('union_types_examples')] + #[DataProvider('compound_types_examples')] public function round_trip(object $data, string $name): void { $s = new SerdeCommon(formatters: $this->formatters); @@ -349,7 +350,10 @@ public static function round_trip_examples(): iterable ), 'name' => 'arrays_with_valid_scalar_values', ]; + } + public static function value_object_flatten_examples(): \Generator + { // This set is for ensuring value objects can flatten cleanly. yield [ 'data' => new Person('Larry', new Age(21), new Email('me@example.com')), diff --git a/tests/TomlFormatterTest.php b/tests/TomlFormatterTest.php index a6a6770..e68c02d 100644 --- a/tests/TomlFormatterTest.php +++ b/tests/TomlFormatterTest.php @@ -62,7 +62,13 @@ public function setUp(): void ]); } - #[Test, DataProvider('round_trip_examples')] + #[Test] + #[DataProvider('round_trip_examples')] + #[DataProvider('value_object_flatten_examples')] + #[DataProvider('mixed_val_property_examples')] + #[DataProvider('mixed_val_property_object_examples')] + #[DataProvider('union_types_examples')] + #[DataProvider('compound_types_examples')] public function round_trip(object $data, string $name): void { if ($name === 'empty_values') { @@ -101,7 +107,8 @@ public function round_trip(object $data, string $name): void // a sign of a design flaw in the object to begin with. self::assertEmpty($result->arr); } else { - parent::round_trip($data, $name); // TODO: Change the autogenerated stub + // Defer back to the parent for the rest. + parent::round_trip($data, $name); } } From ac43d0669f909bad9ffa945ab3d839a6dedf71af Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Tue, 15 Jul 2025 09:00:45 -0500 Subject: [PATCH 14/19] Use the sub-typefield for serialization, too. --- src/Attributes/UnionField.php | 7 ++++++- src/PropertyHandler/MixedExporter.php | 12 +++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/Attributes/UnionField.php b/src/Attributes/UnionField.php index aedc510..2cab1c6 100644 --- a/src/Attributes/UnionField.php +++ b/src/Attributes/UnionField.php @@ -6,6 +6,7 @@ use Crell\AttributeUtils\FromReflectionProperty; use Crell\AttributeUtils\TypeDef; +use Crell\Serde\TypeField; #[\Attribute(\Attribute::TARGET_PROPERTY)] class UnionField extends MixedField implements FromReflectionProperty @@ -17,9 +18,13 @@ class UnionField extends MixedField implements FromReflectionProperty /** * @param string $primaryType + * @param array $typeFields + * A list of TypeFields that should apply only if the specified value is + * one of the types in the union. The key is the type, the value is a TypeField + * instance. * @param array $scopes * The scopes in which this attribute should apply. - */ + */ public function __construct( string $primaryType, public readonly array $typeFields = [], diff --git a/src/PropertyHandler/MixedExporter.php b/src/PropertyHandler/MixedExporter.php index 071d59c..c0cab6f 100644 --- a/src/PropertyHandler/MixedExporter.php +++ b/src/PropertyHandler/MixedExporter.php @@ -28,11 +28,21 @@ class MixedExporter implements Importer, Exporter { public function exportValue(Serializer $serializer, Field $field, mixed $value, mixed $runningValue): mixed { + $type = \get_debug_type($value); + + // Folding UnionField in here is not ideal, but it means we don't have to + // worry about ordering a UnionExporter vs this one, and this is the only + // difference between the fields. + $subTypeField = ($field->typeField instanceof UnionField) + ? $field->typeField->typeFields[$type] ?? null + : null; + // We need to bypass the circular reference check in Serializer::serialize(), // or else an object would always fail here. return $serializer->doSerialize($value, $runningValue, Field::create( serializedName: $field->serializedName, - phpType: \get_debug_type($value), + phpType: $type, + typeField: $subTypeField, )); } From d709c8fd175cd07583a3b2f838db0c9ea553b627 Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Tue, 15 Jul 2025 09:02:59 -0500 Subject: [PATCH 15/19] Update README. --- README.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 43da8df..67710c9 100644 --- a/README.md +++ b/README.md @@ -616,7 +616,7 @@ Note that the permissible range of milliseconds and microseconds is considerably On serialization, Serde will make a good faith effort to derive the type to serialize to from the value itself. So if a `mixed` property has a value of `"beep"`, it will try to serialize as a string. If it has a value `[1, 2, 3]`, it will try to serialize as an array. -On deserialization, primitive types (`int`, `float`, `string`) will be read successfully and written to the property. If no additional information is provided, then sequences and dictionaries will also be read into the property as an `array`, but objects are not supported. (They'll be treated like a dictionary.) +On deserialization, Serde will attempt to derive the type by making educated guesses. If the Deformatter in use implements `SupportsTypeIntrospection` (`json`, `yaml`, `toml`, and `array` already do), then the formatter will be asked what the type should be. If no additional information is provided, then sequences and dictionaries will also be read into the property as an `array`, but objects are not supported. (They'll be treated like a dictionary.) Alternatively, you may specify the field as a `#[MixedField(Point::class)]`, which has one required argument, `suggestedType`. If that is specified, any incoming array-ish value will be deserialized to the specified class. If the value is not compatible with that class, an exception will be thrown. That means it is not possible to support both array deserialization and object deserialization at the same time. @@ -631,6 +631,28 @@ class Message If you are only ever serializing an object with a `mixed` property, these concerns should not apply and no additional effort should be required. +### Union and Compound types + +Most Serde behavior assumes a single type for a given field. If the field has a compound type (a union, intersection, or a combination of the two), Serde will internally treat it as if it were `mixed` and will behave like a `mixed` field above. + +That does mean that, in practice, union and compound types are only supported when using a `SupportsTypeIntrospection` formatter. That includes the most common formats, however, so most of the time it will work. The `MixedField` attribute may be applied to a compound type, and will work as described above. + +Alternatively, if the field is a Union type specifically, you may also use the `UnionField` type field attribute. It works the same as `MixedField`, but has an additional array parameter that lets you specify a separate TypeField for each type in the union. That is especially useful if, for instance, one of the subtypes is an array, and you want to mark it as a sequence or dictionary so that a list of objects will deserialize correctly. + +The example below, for instance, specifies that `$values` could be a `string` or an array of `Point` objects, keyed by a string. If the system cannot otherwise figure out the type, it will default to just `array`. + +```php +class UnionTypeSubTypeField +{ + public function __construct( + #[UnionField('array', [ + 'array' => new DictionaryField(Point::class, KeyType::String)] + )] + public string|array $values, + ) {} +} +``` + ### Generators, Iterables, and Traversables PHP has a number of "lazy list" options. Generally, they are all objects that implement the `\Traversable` interface. However, there are several syntax options available with their own subtleties. Serde supports them in different ways. From ef14ff7a0549f1ebf521f01090da2dab09c0e9cb Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Tue, 15 Jul 2025 09:04:09 -0500 Subject: [PATCH 16/19] Add credit for MakersHub. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 67710c9..04aa620 100644 --- a/README.md +++ b/README.md @@ -1400,6 +1400,8 @@ If you discover any security related issues, please use the [GitHub security rep Initial development of this library was sponsored by [TYPO3 GmbH](https://typo3.com/). +Additional development sponsored in part by [MakersHub](https://makershub.ai/). + ## License The Lesser GPL version 3 or later. Please see [License File](LICENSE.md) for more information. From a918e6f9260d9e37145a1a39291546ac6f60987f Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Tue, 15 Jul 2025 09:19:39 -0500 Subject: [PATCH 17/19] Update changelog. --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10c5e91..8861c1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to `Serde` will be documented in this file. Updates should follow the [Keep a CHANGELOG](http://keepachangelog.com/) principles. +## 1.5.0 - 2025-07-15 + +### Added +- Union, Intersection, and Compound types are now supported. They work by falling back to `mixed`, and then relying on the Deformatter to derive the type. Not all Deformatters will have that ability, but the most common bundled ones do. (`json`, `yaml`, `toml`, and `array`.) Additionally, Union Types may specify `TypeField`s that apply only when specific types are used. + +### Deprecated +- Nothing + +### Fixed +- Nothing + +### Removed +- Nothing + +### Security +- Nothing + ## 1.4.0 - 2025-06-19 ### Added From 9742ba7f8604b5881b5c91b370330f7eea36f1d1 Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Tue, 15 Jul 2025 09:56:37 -0500 Subject: [PATCH 18/19] SA fixes. --- phpstan.neon.dist | 1 + src/PropertyHandler/MixedExporter.php | 4 ++-- tests/Records/ACT.php | 12 ++++++++++++ tests/Records/SAT.php | 12 ++++++++++++ tests/Records/UnionTypeWithInterface.php | 18 ++---------------- 5 files changed, 29 insertions(+), 18 deletions(-) create mode 100644 tests/Records/ACT.php create mode 100644 tests/Records/SAT.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 64a4e10..8c6b706 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -10,6 +10,7 @@ parameters: - tests/Records/* ignoreErrors: - identifier: missingType.generics + reportUnmatched: false # PHPStan whines about every match without a default, even if logically it's still complete. - '#Match expression does not handle remaining value#' # As far as I can tell, PHPStan is just buggy on explode(). It gives this error diff --git a/src/PropertyHandler/MixedExporter.php b/src/PropertyHandler/MixedExporter.php index c0cab6f..53c556d 100644 --- a/src/PropertyHandler/MixedExporter.php +++ b/src/PropertyHandler/MixedExporter.php @@ -88,7 +88,7 @@ protected function deriveType(Deserializer $deserializer, Field $field, mixed $s // and a suggested type is specified, assume the specified type. return $typeField->suggestedType(); } - if (class_exists($type) || interface_exists($type)) { + if ($type && (class_exists($type) || interface_exists($type))) { // The deformatter already determined what class it should be. Trust it. return $type; } @@ -97,7 +97,7 @@ protected function deriveType(Deserializer $deserializer, Field $field, mixed $s // listed types is a class, we can deduce that is probably what it should // be deserialized into. If multiple classes are specified, the first // will be used. If that's not desired, specify a suggested type via attribute. - foreach ($field->typeDef->getUnionTypes() as $t) { + foreach ($field->typeDef->getUnionTypes() ?? [] as $t) { if (class_exists($t) || interface_exists($t)) { return $t; } diff --git a/tests/Records/ACT.php b/tests/Records/ACT.php new file mode 100644 index 0000000..7bea62d --- /dev/null +++ b/tests/Records/ACT.php @@ -0,0 +1,12 @@ + ACT::class, 'sat' => SAT::class, ])] -interface StandardTest {} - -class ACT implements StandardTest -{ - public function __construct( - public int $score, - ) {} -} - -class SAT implements StandardTest -{ - public function __construct( - public int $score, - ) {} -} +interface StandardizedTest {} From 47c299797b38482fc7c3766d22e28e64fe75f9c1 Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Tue, 15 Jul 2025 10:34:44 -0500 Subject: [PATCH 19/19] Drop PHP 8.1 support. --- .github/workflows/quality-assurance.yaml | 4 ++-- CHANGELOG.md | 2 +- composer.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/quality-assurance.yaml b/.github/workflows/quality-assurance.yaml index 0368476..6707f7c 100644 --- a/.github/workflows/quality-assurance.yaml +++ b/.github/workflows/quality-assurance.yaml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php: [ '8.1', '8.2', '8.3', '8.4' ] + php: [ '8.2', '8.3', '8.4' ] composer-flags: [ '' ] phpunit-flags: [ '--coverage-text' ] steps: @@ -28,7 +28,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php: [ '8.1', '8.2', '8.3', '8.4' ] + php: [ '8.2', '8.3', '8.4' ] composer-flags: [ '' ] steps: - uses: actions/checkout@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8861c1e..6053eb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ Updates should follow the [Keep a CHANGELOG](http://keepachangelog.com/) princip - Union, Intersection, and Compound types are now supported. They work by falling back to `mixed`, and then relying on the Deformatter to derive the type. Not all Deformatters will have that ability, but the most common bundled ones do. (`json`, `yaml`, `toml`, and `array`.) Additionally, Union Types may specify `TypeField`s that apply only when specific types are used. ### Deprecated -- Nothing +- Version 1.5 and later requires at least PHP 8.2. PHP 8.1 is no longer supported. ### Fixed - Nothing diff --git a/composer.json b/composer.json index 0907297..1dcd85b 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,7 @@ } ], "require": { - "php": "~8.1", + "php": "~8.2", "crell/attributeutils": "~1.3", "crell/fp": "~1.0" },