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 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 10c5e91..6053eb9 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 +- Version 1.5 and later requires at least PHP 8.2. PHP 8.1 is no longer supported. + +### Fixed +- Nothing + +### Removed +- Nothing + +### Security +- Nothing + ## 1.4.0 - 2025-06-19 ### Added diff --git a/README.md b/README.md index 43da8df..04aa620 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. @@ -1378,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. 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..1dcd85b 100644 --- a/composer.json +++ b/composer.json @@ -17,8 +17,8 @@ } ], "require": { - "php": "~8.1", - "crell/attributeutils": "~1.2", + "php": "~8.2", + "crell/attributeutils": "~1.3", "crell/fp": "~1.0" }, "require-dev": { 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/Attributes/Field.php b/src/Attributes/Field.php index 2a115b7..1607185 100644 --- a/src/Attributes/Field.php +++ b/src/Attributes/Field.php @@ -11,17 +11,17 @@ use Crell\AttributeUtils\HasSubAttributes; use Crell\AttributeUtils\ReadsClass; use Crell\AttributeUtils\SupportsScopes; +use Crell\AttributeUtils\TypeDef; use Crell\fp\Evolvable; 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; @@ -100,6 +100,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 +119,7 @@ class Field implements FromReflectionProperty, HasSubAttributes, Excludable, Sup * @var array */ public readonly array $extraProperties; + public const TYPE_NOT_SPECIFIED = '__NO_TYPE__'; /** @@ -194,7 +202,14 @@ 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 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 +262,12 @@ protected function getDefaultValueFromConstructor(\ReflectionProperty $subject): public function finalize(): void { + $this->phpType ??= 'mixed'; + + if ($this->typeField && !$this->typeField->acceptsType($this->phpType) && $this->typeDef->accepts($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 +304,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 +357,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/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/Attributes/UnionField.php b/src/Attributes/UnionField.php new file mode 100644 index 0000000..2cab1c6 --- /dev/null +++ b/src/Attributes/UnionField.php @@ -0,0 +1,50 @@ + $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 = [], + array $scopes = [null], + ) { + parent::__construct($primaryType, $scopes); + } + + public function fromReflection(\ReflectionProperty $subject): void + { + $this->typeDef ??= new TypeDef($subject->getType()); + } + + public function acceptsType(string $type): bool + { + return $this->typeDef->accepts($type); + } + + public function validate(mixed $value): bool + { + return $this->typeDef->accepts(get_debug_type($value)); + } +} diff --git a/src/CompoundType.php b/src/CompoundType.php new file mode 100644 index 0000000..eebb1f1 --- /dev/null +++ b/src/CompoundType.php @@ -0,0 +1,19 @@ +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 502a45b..53c556d 100644 --- a/src/PropertyHandler/MixedExporter.php +++ b/src/PropertyHandler/MixedExporter.php @@ -4,15 +4,13 @@ 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\Attributes\UnionField; +use Crell\Serde\UnableToDeriveTypeOnMixedField; use Crell\Serde\Deserializer; -use Crell\Serde\Dict; -use Crell\Serde\InvalidArrayKeyType; -use Crell\Serde\KeyType; -use Crell\Serde\Sequence; +use Crell\Serde\Formatter\SupportsTypeIntrospection; use Crell\Serde\Serializer; use Crell\Serde\TypeCategory; @@ -30,43 +28,94 @@ 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, )); } 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); - /** @var MixedField|null $typeField */ - $typeField = $field->typeField; - if ($typeField && class_exists($typeField->suggestedType) && $type === 'array') { - $type = $typeField->suggestedType; - } + // 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, )); } + /** + * 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; + + 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 ($type && (class_exists($type) || interface_exists($type))) { + // The deformatter already determined what class it should be. Trust it. + return $type; + } + 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) || interface_exists($t)) { + return $t; + } + } + } + + return $type; + } + 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 { - // 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 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/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; 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; + } +} 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 @@ + new DictionaryField(Point::class, KeyType::String)])] + public string|array $values, + ) {} +} diff --git a/tests/Records/UnionTypeWithInterface.php b/tests/Records/UnionTypeWithInterface.php new file mode 100644 index 0000000..69ec405 --- /dev/null +++ b/tests/Records/UnionTypeWithInterface.php @@ -0,0 +1,20 @@ + ACT::class, + 'sat' => SAT::class, +])] +interface StandardizedTest {} diff --git a/tests/Records/UnionTypes.php b/tests/Records/UnionTypes.php new file mode 100644 index 0000000..d3c4383 --- /dev/null +++ b/tests/Records/UnionTypes.php @@ -0,0 +1,20 @@ +_validate($serialized) method to introspect * the serialized data for that test in a format-specific way. */ abstract class SerdeTestCases extends TestCase @@ -182,13 +190,19 @@ 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('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); @@ -336,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')), @@ -360,6 +377,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. */ @@ -1059,55 +1168,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->mixed_val_property_validate($serialized, $data); - - $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->mixed_val_property_validate($serialized, $data); - - $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))]; - } - - public function mixed_val_property_validate(mixed $serialized, mixed $data): void - { - } - #[Test] public function generator_property_is_run_out(): void { 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); } }