diff --git a/docs/pages/project/changelog.md b/docs/pages/project/changelog.md index e888fae4..3ef66227 100644 --- a/docs/pages/project/changelog.md +++ b/docs/pages/project/changelog.md @@ -9,6 +9,7 @@ Below are listed the changelogs for all released versions of the library. ## Version 1 +- [`1.13.0` — 2nd of September 2024](changelog/version-1.13.0.md) - [`1.12.0` — 4th of April 2024](changelog/version-1.12.0.md) - [`1.11.0` — 27th of March 2024](changelog/version-1.11.0.md) - [`1.10.0` — 12th of March 2024](changelog/version-1.10.0.md) diff --git a/docs/pages/project/changelog/version-1.13.0.md b/docs/pages/project/changelog/version-1.13.0.md new file mode 100644 index 00000000..d15fd06c --- /dev/null +++ b/docs/pages/project/changelog/version-1.13.0.md @@ -0,0 +1,114 @@ +# Changelog 1.13.0 — 2nd of September 2024 + +!!! info inline end "[See release on GitHub]" + [See release on GitHub]: https://github.com/CuyZ/Valinor/releases/tag/1.13.0 + +## Notable changes + +**Microseconds support for timestamp format** + +Prior to this patch, this would require a custom constructor in the form of: + +```php +static fn(float | int $timestamp): DateTimeImmutable => new + DateTimeImmutable(sprintf("@%d", $timestamp)), +``` + +This bypasses the datetime format support of Valinor entirely. This is required +because the library does not support floats as valid `DateTimeInterface` input +values. + +This commit adds support for floats and registers `timestamp.microseconds` +(`U.u`) as a valid default format. + +**Support for `value-of` type** + +This type can be used as follows: + +```php +enum Suit: string +{ + case Hearts = 'H'; + case Diamonds = 'D'; + case Clubs = 'C'; + case Spades = 'S'; +} + +$suit = (new \CuyZ\Valinor\MapperBuilder()) + ->mapper() + ->map('value-of', 'D'); + +// $suit === 'D' +``` + +**Object constructors parameters types inferring improvements** + +The collision system that checks object constructors parameters types is now way +more clever, as it no longer checks for parameters' names only. Types are now +also checked, and only true collision will be detected, for instance when two +constructors share a parameter with the same name and type. + +Note that when two parameters share the same name, the following type priority +operates: + +1. Non-scalar type +2. Integer type +3. Float type +4. String type +5. Boolean type + +With this change, the code below is now valid: + +```php +final readonly class Money +{ + private function __construct( + public int $value, + ) {} + + #[\CuyZ\Valinor\Mapper\Object\Constructor] + public static function fromInt(int $value): self + { + return new self($value); + } + + #[\CuyZ\Valinor\Mapper\Object\Constructor] + public static function fromString(string $value): self + { + if (! preg_match('/^\d+€$/', $value)) { + throw new \InvalidArgumentException('Invalid money format'); + } + + return new self((int)rtrim($value, '€')); + } +} + +$mapper = (new \CuyZ\Valinor\MapperBuilder())->mapper(); + +$mapper->map(Money::class, 42); // ✅ +$mapper->map(Money::class, '42€'); // ✅ +``` + +### Features + +* Add microseconds support to timestamp format ([02bd2e](https://github.com/CuyZ/Valinor/commit/02bd2e5e0f0e7d4daf234852464085bcdd1a0eb2)) +* Add support for `value-of` type ([b1017c](https://github.com/CuyZ/Valinor/commit/b1017ce55729f0698c7629d57a3d3a30c0f9bff3)) +* Improve object constructors parameters types inferring ([2150dc](https://github.com/CuyZ/Valinor/commit/2150dcad4ce821bfe36c3718346ccc412e37832a)) + +### Bug Fixes + +* Allow any constant in class constant type ([694275](https://github.com/CuyZ/Valinor/commit/6942755865f91c80af8ea97fde2faa390478a6b8)) +* Allow docblock for transformer callable type ([69e0e3](https://github.com/CuyZ/Valinor/commit/69e0e3a5f1de6a5eedcfa4125d8639be91f0c303)) +* Do not override invalid variadic parameter type ([c5860f](https://github.com/CuyZ/Valinor/commit/c5860f0e5b3f59f49900bfbb20ca4493916eca7a)) +* Handle interface generics ([40e6fa](https://github.com/CuyZ/Valinor/commit/40e6fa340819961068b8be178e312a99c06cede2)) +* Handle iterable objects as iterable during normalization ([436e3c](https://github.com/CuyZ/Valinor/commit/436e3c25532d5cf396b00354ec5459e812c2953e)) +* Properly format empty object with JSON normalizer ([ba22b5](https://github.com/CuyZ/Valinor/commit/ba22b5233e80f0ffbbe9591a5099b9dd62715eb8)) +* Properly handle nested local type aliases ([127839](https://github.com/CuyZ/Valinor/commit/1278392757a4e9dc9eee2ab642c5700e83ccf982)) + +### Other + +* Exclude unneeded attributes in class/function definitions ([1803d0](https://github.com/CuyZ/Valinor/commit/1803d094f08b256c64535f4f86e32ab35a07bbf1)) +* Improve mapping performance for nullable union type ([6fad94](https://github.com/CuyZ/Valinor/commit/6fad94a46785dfb853c11c241e3f60bcf6a85ede)) +* Move "float type accepting integer value" logic in `Shell` ([047953](https://github.com/CuyZ/Valinor/commit/0479532fbc96fca35dcbfb4c1f5a9ef63e7625c5)) +* Move setting values in shell ([84b1ff](https://github.com/CuyZ/Valinor/commit/84b1ffbc8190a709d752a9882f71f6f419ad0434)) +* Reorganize type resolver services ([86fb7b](https://github.com/CuyZ/Valinor/commit/86fb7b6303b15b54da6ac02ca8a7008b23c8bcff)) diff --git a/docs/pages/usage/type-reference.md b/docs/pages/usage/type-reference.md index 2fb54fe0..b91a8f26 100644 --- a/docs/pages/usage/type-reference.md +++ b/docs/pages/usage/type-reference.md @@ -50,6 +50,9 @@ final class SomeClass /** @var class-string */ private string $classStringOfAnInterface, + + /** @var value-of */ + private string $valueOfEnum, ) {} } ``` diff --git a/src/Library/Container.php b/src/Library/Container.php index 523dab68..488f9d0d 100644 --- a/src/Library/Container.php +++ b/src/Library/Container.php @@ -18,7 +18,7 @@ use CuyZ\Valinor\Definition\Repository\Reflection\ReflectionFunctionDefinitionRepository; use CuyZ\Valinor\Mapper\ArgumentsMapper; use CuyZ\Valinor\Mapper\Object\Factory\CacheObjectBuilderFactory; -use CuyZ\Valinor\Mapper\Object\Factory\CollisionObjectBuilderFactory; +use CuyZ\Valinor\Mapper\Object\Factory\SortingObjectBuilderFactory; use CuyZ\Valinor\Mapper\Object\Factory\ConstructorObjectBuilderFactory; use CuyZ\Valinor\Mapper\Object\Factory\DateTimeObjectBuilderFactory; use CuyZ\Valinor\Mapper\Object\Factory\DateTimeZoneObjectBuilderFactory; @@ -37,7 +37,6 @@ use CuyZ\Valinor\Mapper\Tree\Builder\NodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\NullNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\ObjectImplementations; -use CuyZ\Valinor\Mapper\Tree\Builder\FilteredObjectNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\ScalarNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\ShapedArrayNodeBuilder; @@ -86,12 +85,14 @@ public function __construct(Settings $settings) $this->factories = [ TreeMapper::class => fn () => new TypeTreeMapper( $this->get(TypeParser::class), - $this->get(RootNodeBuilder::class) + $this->get(RootNodeBuilder::class), + $settings, ), ArgumentsMapper::class => fn () => new TypeArgumentsMapper( $this->get(FunctionDefinitionRepository::class), - $this->get(RootNodeBuilder::class) + $this->get(RootNodeBuilder::class), + $settings, ), RootNodeBuilder::class => fn () => new RootNodeBuilder( @@ -99,8 +100,8 @@ public function __construct(Settings $settings) ), NodeBuilder::class => function () use ($settings) { - $listNodeBuilder = new ListNodeBuilder($settings->enableFlexibleCasting); - $arrayNodeBuilder = new ArrayNodeBuilder($settings->enableFlexibleCasting); + $listNodeBuilder = new ListNodeBuilder(); + $arrayNodeBuilder = new ArrayNodeBuilder(); $builder = new CasterNodeBuilder([ ListType::class => $listNodeBuilder, @@ -108,14 +109,12 @@ public function __construct(Settings $settings) ArrayType::class => $arrayNodeBuilder, NonEmptyArrayType::class => $arrayNodeBuilder, IterableType::class => $arrayNodeBuilder, - ShapedArrayType::class => new ShapedArrayNodeBuilder($settings->allowSuperfluousKeys), - ScalarType::class => new ScalarNodeBuilder($settings->enableFlexibleCasting), + ShapedArrayType::class => new ShapedArrayNodeBuilder(), + ScalarType::class => new ScalarNodeBuilder(), NullType::class => new NullNodeBuilder(), ObjectType::class => new ObjectNodeBuilder( $this->get(ClassDefinitionRepository::class), $this->get(ObjectBuilderFactory::class), - $this->get(FilteredObjectNodeBuilder::class), - $settings->enableFlexibleCasting, ), ]); @@ -125,14 +124,10 @@ public function __construct(Settings $settings) $builder, $this->get(ObjectImplementations::class), $this->get(ClassDefinitionRepository::class), - $this->get(ObjectBuilderFactory::class), - $this->get(FilteredObjectNodeBuilder::class), new FunctionsContainer( $this->get(FunctionDefinitionRepository::class), $settings->customConstructors ), - $settings->enableFlexibleCasting, - $settings->allowSuperfluousKeys, ); $builder = new CasterProxyNodeBuilder($builder); @@ -148,13 +143,11 @@ public function __construct(Settings $settings) ); } - $builder = new StrictNodeBuilder($builder, $settings->allowPermissiveTypes, $settings->enableFlexibleCasting); + $builder = new StrictNodeBuilder($builder); return new ErrorCatcherNodeBuilder($builder, $settings->exceptionFilter); }, - FilteredObjectNodeBuilder::class => fn () => new FilteredObjectNodeBuilder($settings->allowSuperfluousKeys), - ObjectImplementations::class => fn () => new ObjectImplementations( new FunctionsContainer( $this->get(FunctionDefinitionRepository::class), @@ -173,7 +166,7 @@ public function __construct(Settings $settings) $factory = new ConstructorObjectBuilderFactory($factory, $settings->nativeConstructors, $constructors); $factory = new DateTimeZoneObjectBuilderFactory($factory, $this->get(FunctionDefinitionRepository::class)); $factory = new DateTimeObjectBuilderFactory($factory, $settings->supportedDateFormats, $this->get(FunctionDefinitionRepository::class)); - $factory = new CollisionObjectBuilderFactory($factory); + $factory = new SortingObjectBuilderFactory($factory); if (! $settings->allowPermissiveTypes) { $factory = new StrictTypesObjectBuilderFactory($factory); diff --git a/src/Mapper/Object/Arguments.php b/src/Mapper/Object/Arguments.php index 023e390d..67d06f68 100644 --- a/src/Mapper/Object/Arguments.php +++ b/src/Mapper/Object/Arguments.php @@ -12,8 +12,10 @@ use IteratorAggregate; use Traversable; +use function array_keys; use function array_map; use function array_values; +use function count; /** * @internal @@ -22,19 +24,21 @@ */ final class Arguments implements IteratorAggregate, Countable { - /** @var Argument[] */ - private array $arguments; + /** @var array */ + private array $arguments = []; public function __construct(Argument ...$arguments) { - $this->arguments = $arguments; + foreach ($arguments as $argument) { + $this->arguments[$argument->name()] = $argument; + } } public static function fromParameters(Parameters $parameters): self { return new self(...array_map( fn (ParameterDefinition $parameter) => Argument::fromParameter($parameter), - array_values([...$parameters]) + [...$parameters], )); } @@ -42,24 +46,29 @@ public static function fromProperties(Properties $properties): self { return new self(...array_map( fn (PropertyDefinition $property) => Argument::fromProperty($property), - array_values([...$properties]) + [...$properties], )); } public function at(int $index): Argument { - return $this->arguments[$index]; + return array_values($this->arguments)[$index]; } - public function has(string $name): bool + /** + * @return list + */ + public function names(): array { - foreach ($this->arguments as $argument) { - if ($argument->name() === $name) { - return true; - } - } + return array_keys($this->arguments); + } - return false; + /** + * @return array + */ + public function toArray(): array + { + return $this->arguments; } public function count(): int diff --git a/src/Mapper/Object/ArgumentsValues.php b/src/Mapper/Object/ArgumentsValues.php index f2d7aee4..9b595de3 100644 --- a/src/Mapper/Object/ArgumentsValues.php +++ b/src/Mapper/Object/ArgumentsValues.php @@ -4,13 +4,12 @@ namespace CuyZ\Valinor\Mapper\Object; -use CuyZ\Valinor\Mapper\Object\Exception\InvalidSource; +use CuyZ\Valinor\Mapper\Tree\Shell; use CuyZ\Valinor\Type\CompositeTraversableType; use CuyZ\Valinor\Type\Types\ArrayKeyType; use IteratorAggregate; use Traversable; -use function array_filter; use function array_key_exists; use function count; use function is_array; @@ -27,6 +26,8 @@ final class ArgumentsValues implements IteratorAggregate private Arguments $arguments; + private bool $hasInvalidValue = false; + private bool $forInterface = false; private bool $hadSingleArgument = false; @@ -36,26 +37,31 @@ private function __construct(Arguments $arguments) $this->arguments = $arguments; } - public static function forInterface(Arguments $arguments, mixed $value, bool $allowSuperfluousKeys): self + public static function forInterface(Arguments $arguments, Shell $shell): self { $self = new self($arguments); $self->forInterface = true; if (count($arguments) > 0) { - $self = $self->transform($value, $allowSuperfluousKeys); + $self->transform($shell); } return $self; } - public static function forClass(Arguments $arguments, mixed $value, bool $allowSuperfluousKeys): self + public static function forClass(Arguments $arguments, Shell $shell): self { $self = new self($arguments); - $self = $self->transform($value, $allowSuperfluousKeys); + $self->transform($shell); return $self; } + public function hasInvalidValue(): bool + { + return $this->hasInvalidValue; + } + public function hasValue(string $name): bool { return array_key_exists($name, $this->value); @@ -66,34 +72,23 @@ public function getValue(string $name): mixed return $this->value[$name]; } - /** - * @return array - */ - public function superfluousKeys(): array - { - return array_filter( - array_keys($this->value), - fn ($key) => ! $this->arguments->has((string)$key) - ); - } - public function hadSingleArgument(): bool { return $this->hadSingleArgument; } - private function transform(mixed $value, bool $allowSuperfluousKeys): self + private function transform(Shell $shell): void { - $clone = clone $this; - - $transformedValue = $this->transformValueForSingleArgument($value, $allowSuperfluousKeys); + $transformedValue = $this->transformValueForSingleArgument($shell); if (! is_array($transformedValue)) { - throw new InvalidSource($transformedValue, $this->arguments); + $this->hasInvalidValue = true; + + return; } - if ($transformedValue !== $value) { - $clone->hadSingleArgument = true; + if ($transformedValue !== $shell->value()) { + $this->hadSingleArgument = true; } foreach ($this->arguments as $argument) { @@ -104,13 +99,13 @@ private function transform(mixed $value, bool $allowSuperfluousKeys): self } } - $clone->value = $transformedValue; - - return $clone; + $this->value = $transformedValue; } - private function transformValueForSingleArgument(mixed $value, bool $allowSuperfluousKeys): mixed + private function transformValueForSingleArgument(Shell $shell): mixed { + $value = $shell->value(); + if (count($this->arguments) !== 1) { return $value; } @@ -122,7 +117,7 @@ private function transformValueForSingleArgument(mixed $value, bool $allowSuperf && $type->keyType() !== ArrayKeyType::integer(); if (is_array($value) && array_key_exists($name, $value)) { - if ($this->forInterface || ! $isTraversableAndAllowsStringKeys || $allowSuperfluousKeys || count($value) === 1) { + if ($this->forInterface || ! $isTraversableAndAllowsStringKeys || $shell->allowSuperfluousKeys() || count($value) === 1) { return $value; } } diff --git a/src/Mapper/Object/Exception/ObjectBuildersCollision.php b/src/Mapper/Object/Exception/ObjectBuildersCollision.php index 697fed7c..02648a98 100644 --- a/src/Mapper/Object/Exception/ObjectBuildersCollision.php +++ b/src/Mapper/Object/Exception/ObjectBuildersCollision.php @@ -4,23 +4,16 @@ namespace CuyZ\Valinor\Mapper\Object\Exception; -use CuyZ\Valinor\Definition\ClassDefinition; use CuyZ\Valinor\Mapper\Object\ObjectBuilder; use RuntimeException; -use function array_map; -use function implode; - /** @internal */ final class ObjectBuildersCollision extends RuntimeException { - public function __construct(ClassDefinition $class, ObjectBuilder ...$builders) + public function __construct(ObjectBuilder $builderA, ObjectBuilder $builderB) { - $constructors = array_map(fn (ObjectBuilder $builder) => $builder->signature(), $builders); - $constructors = implode('`, `', $constructors); - parent::__construct( - "A collision was detected between the following constructors of the class `{$class->type->toString()}`: `$constructors`.", + "A type collision was detected between the constructors `{$builderA->signature()}` and `{$builderB->signature()}`.", 1654955787 ); } diff --git a/src/Mapper/Object/Exception/SeveralObjectBuildersFound.php b/src/Mapper/Object/Exception/SeveralObjectBuildersFound.php deleted file mode 100644 index 732bb66e..00000000 --- a/src/Mapper/Object/Exception/SeveralObjectBuildersFound.php +++ /dev/null @@ -1,24 +0,0 @@ -body, 1642787246); - } - - public function body(): string - { - return $this->body; - } -} diff --git a/src/Mapper/Object/Factory/CollisionObjectBuilderFactory.php b/src/Mapper/Object/Factory/CollisionObjectBuilderFactory.php deleted file mode 100644 index 702a278b..00000000 --- a/src/Mapper/Object/Factory/CollisionObjectBuilderFactory.php +++ /dev/null @@ -1,69 +0,0 @@ -delegate->for($class); - - $sortedBuilders = []; - - foreach ($builders as $builder) { - $sortedBuilders[count($builder->describeArguments())][] = $builder; - } - - foreach ($sortedBuilders as $argumentsCount => $buildersList) { - if (count($buildersList) <= 1) { - continue; - } - - if ($argumentsCount <= 1) { - throw new ObjectBuildersCollision($class, ...$buildersList); - } - - // @phpstan-ignore-next-line // false positive - while (($current = array_shift($buildersList)) && count($buildersList) > 0) { - $arguments = $current->describeArguments(); - - do { - $other = current($buildersList); - - $collisions = 0; - - foreach ($arguments as $argumentA) { - $name = $argumentA->name(); - - foreach ($other->describeArguments() as $argumentB) { - if ($argumentB->name() === $name) { - $collisions++; - // @infection-ignore-all - break; - } - } - } - - if ($collisions >= count($arguments)) { - throw new ObjectBuildersCollision($class, $current, $other); - } - } while (next($buildersList)); - } - } - - return $builders; - } -} diff --git a/src/Mapper/Object/Factory/DateTimeObjectBuilderFactory.php b/src/Mapper/Object/Factory/DateTimeObjectBuilderFactory.php index fa5613ac..86269286 100644 --- a/src/Mapper/Object/Factory/DateTimeObjectBuilderFactory.php +++ b/src/Mapper/Object/Factory/DateTimeObjectBuilderFactory.php @@ -48,6 +48,7 @@ public function for(ClassDefinition $class): array $builders[] = $this->internalDateTimeBuilder($class->type); } + /** @var non-empty-list */ return $builders; } diff --git a/src/Mapper/Object/Factory/DateTimeZoneObjectBuilderFactory.php b/src/Mapper/Object/Factory/DateTimeZoneObjectBuilderFactory.php index cc17713f..0e929d03 100644 --- a/src/Mapper/Object/Factory/DateTimeZoneObjectBuilderFactory.php +++ b/src/Mapper/Object/Factory/DateTimeZoneObjectBuilderFactory.php @@ -57,6 +57,7 @@ public function for(ClassDefinition $class): array $builders[] = $this->defaultBuilder($class->type); } + /** @var non-empty-list */ return $builders; } diff --git a/src/Mapper/Object/Factory/ObjectBuilderFactory.php b/src/Mapper/Object/Factory/ObjectBuilderFactory.php index 760f3d42..a641b363 100644 --- a/src/Mapper/Object/Factory/ObjectBuilderFactory.php +++ b/src/Mapper/Object/Factory/ObjectBuilderFactory.php @@ -11,7 +11,7 @@ interface ObjectBuilderFactory { /** - * @return list + * @return non-empty-list */ public function for(ClassDefinition $class): array; } diff --git a/src/Mapper/Object/Factory/ReflectionObjectBuilderFactory.php b/src/Mapper/Object/Factory/ReflectionObjectBuilderFactory.php index 187f11bc..0242b452 100644 --- a/src/Mapper/Object/Factory/ReflectionObjectBuilderFactory.php +++ b/src/Mapper/Object/Factory/ReflectionObjectBuilderFactory.php @@ -6,17 +6,12 @@ use CuyZ\Valinor\Definition\ClassDefinition; use CuyZ\Valinor\Mapper\Object\ReflectionObjectBuilder; -use CuyZ\Valinor\Utility\Reflection\Reflection; /** @internal */ final class ReflectionObjectBuilderFactory implements ObjectBuilderFactory { public function for(ClassDefinition $class): array { - if (Reflection::enumExists($class->name)) { - return []; - } - return [new ReflectionObjectBuilder($class)]; } } diff --git a/src/Mapper/Object/Factory/SortingObjectBuilderFactory.php b/src/Mapper/Object/Factory/SortingObjectBuilderFactory.php new file mode 100644 index 00000000..e7dea8c8 --- /dev/null +++ b/src/Mapper/Object/Factory/SortingObjectBuilderFactory.php @@ -0,0 +1,109 @@ +delegate->for($class); + + $sortedByArgumentsNumber = []; + $sortedByPriority = []; + + foreach ($builders as $builder) { + $sortedByArgumentsNumber[$builder->describeArguments()->count()][] = $builder; + } + + krsort($sortedByArgumentsNumber); + + foreach ($sortedByArgumentsNumber as $sortedBuilders) { + usort($sortedBuilders, $this->sortObjectBuilders(...)); + + $sortedByPriority = array_merge($sortedByPriority, $sortedBuilders); + } + + return $sortedByPriority; + } + + private function sortObjectBuilders(ObjectBuilder $builderA, ObjectBuilder $builderB): int + { + $argumentsA = $builderA->describeArguments()->toArray(); + $argumentsB = $builderB->describeArguments()->toArray(); + + $sharedArguments = array_keys(array_intersect_key($argumentsA, $argumentsB)); + + $winner = null; + + foreach ($sharedArguments as $name) { + $typeA = $argumentsA[$name]->type(); + $typeB = $argumentsB[$name]->type(); + + $score = $this->sortTypes($typeA, $typeB); + + if ($score === 0) { + continue; + } + + $newWinner = $score === 1 ? $builderB : $builderA; + + if ($winner && $winner !== $newWinner) { + throw new ObjectBuildersCollision($builderA, $builderB); + } + + $winner = $newWinner; + } + + if ($winner === null && count($sharedArguments) === count($argumentsA)) { + throw new ObjectBuildersCollision($builderA, $builderB); + } + + // @infection-ignore-all / Incrementing or decrementing sorting value makes no sense, so we ignore it. + return $winner === $builderA ? -1 : 1; + } + + private function sortTypes(Type $typeA, Type $typeB): int + { + if ($typeA instanceof ScalarType && $typeB instanceof ScalarType) { + return TypeHelper::typePriority($typeB) <=> TypeHelper::typePriority($typeA); + } + + if (! $typeA instanceof ScalarType) { + // @infection-ignore-all / Decrementing sorting value makes no sense, so we ignore it. + return -1; + } + + return 1; + } +} diff --git a/src/Mapper/Object/FilteredObjectBuilder.php b/src/Mapper/Object/FilteredObjectBuilder.php deleted file mode 100644 index acabadfe..00000000 --- a/src/Mapper/Object/FilteredObjectBuilder.php +++ /dev/null @@ -1,106 +0,0 @@ -delegate = $this->filterBuilder($source, ...$builders); - } - - public static function from(mixed $source, ObjectBuilder ...$builders): ObjectBuilder - { - if (count($builders) === 1) { - return $builders[0]; - } - - return new self($source, ...$builders); - } - - public function describeArguments(): Arguments - { - return $this->delegate->describeArguments(); - } - - public function build(array $arguments): object - { - return $this->delegate->build($arguments); - } - - public function signature(): string - { - return $this->delegate->signature(); - } - - private function filterBuilder(mixed $source, ObjectBuilder ...$builders): ObjectBuilder - { - if (count($builders) === 1) { - return reset($builders); - } - - /** @var non-empty-list $builders */ - $constructors = []; - - foreach ($builders as $builder) { - $filledNumber = $this->filledArguments($builder, $source); - - if ($filledNumber === false) { - continue; - } - - $constructors[$filledNumber][] = $builder; - } - - ksort($constructors); - - $constructorsWithMostArguments = array_pop($constructors) ?: []; - - if (count($constructorsWithMostArguments) === 0) { - throw new CannotFindObjectBuilder($builders); - } - - if (count($constructorsWithMostArguments) > 1) { - throw new SeveralObjectBuildersFound(); - } - - return $constructorsWithMostArguments[0]; - } - - /** - * @return false|int<0, max> - */ - private function filledArguments(ObjectBuilder $builder, mixed $source): false|int - { - $arguments = $builder->describeArguments(); - - if (! is_array($source)) { - return count($arguments) === 1 ? 1 : false; - } - - /** @infection-ignore-all */ - $filled = 0; - - foreach ($arguments as $argument) { - if (isset($source[$argument->name()])) { - $filled++; - } elseif ($argument->isRequired()) { - return false; - } - } - - return $filled; - } -} diff --git a/src/Mapper/Tree/Builder/ArrayNodeBuilder.php b/src/Mapper/Tree/Builder/ArrayNodeBuilder.php index 2ab1340a..c75e67e9 100644 --- a/src/Mapper/Tree/Builder/ArrayNodeBuilder.php +++ b/src/Mapper/Tree/Builder/ArrayNodeBuilder.php @@ -18,8 +18,6 @@ /** @internal */ final class ArrayNodeBuilder implements NodeBuilder { - public function __construct(private bool $enableFlexibleCasting) {} - public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode { $type = $shell->type(); @@ -27,7 +25,7 @@ public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode assert($type instanceof ArrayType || $type instanceof NonEmptyArrayType || $type instanceof IterableType); - if ($this->enableFlexibleCasting && $value === null) { + if ($shell->enableFlexibleCasting() && $value === null) { return TreeNode::branch($shell, [], []); } diff --git a/src/Mapper/Tree/Builder/CasterProxyNodeBuilder.php b/src/Mapper/Tree/Builder/CasterProxyNodeBuilder.php index 9a50c340..71f9d086 100644 --- a/src/Mapper/Tree/Builder/CasterProxyNodeBuilder.php +++ b/src/Mapper/Tree/Builder/CasterProxyNodeBuilder.php @@ -20,28 +20,36 @@ public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode if ($shell->hasValue()) { $value = $shell->value(); - if ($this->typeAcceptsValue($shell->type(), $value)) { - return TreeNode::leaf($shell, $value); + $typeAcceptingValue = $this->typeAcceptingValue($shell->type(), $value); + + if ($typeAcceptingValue) { + return TreeNode::leaf($shell->withType($typeAcceptingValue), $value); } } return $this->delegate->build($shell, $rootBuilder); } - private function typeAcceptsValue(Type $type, mixed $value): bool + private function typeAcceptingValue(Type $type, mixed $value): ?Type { if ($type instanceof UnionType) { foreach ($type->types() as $subType) { - if ($this->typeAcceptsValue($subType, $value)) { - return true; + if ($this->typeAcceptingValue($subType, $value)) { + return $subType; } } - return false; + return null; + } + + if ($type instanceof CompositeTraversableType || $type instanceof ShapedArrayType) { + return null; + } + + if ($type->accepts($value)) { + return $type; } - return ! $type instanceof CompositeTraversableType - && ! $type instanceof ShapedArrayType - && $type->accepts($value); + return null; } } diff --git a/src/Mapper/Tree/Builder/FilteredObjectNodeBuilder.php b/src/Mapper/Tree/Builder/FilteredObjectNodeBuilder.php deleted file mode 100644 index 712de130..00000000 --- a/src/Mapper/Tree/Builder/FilteredObjectNodeBuilder.php +++ /dev/null @@ -1,79 +0,0 @@ -describeArguments(), $shell->value(), $this->allowSuperfluousKeys); - - $children = $this->children($shell, $arguments, $rootBuilder); - - $object = $this->buildObject($builder, $children); - - $node = $arguments->hadSingleArgument() - ? TreeNode::flattenedBranch($shell, $object, $children[0]) - : TreeNode::branch($shell, $object, $children); - - if (! $this->allowSuperfluousKeys && count($arguments->superfluousKeys()) > 0) { - $node = $node->withMessage(new UnexpectedArrayKeysForClass($arguments)); - } - - return $node; - } - - /** - * @return array - */ - private function children(Shell $shell, ArgumentsValues $arguments, RootNodeBuilder $rootBuilder): array - { - $children = []; - - foreach ($arguments as $argument) { - $name = $argument->name(); - $type = $argument->type(); - $attributes = $argument->attributes(); - - $child = $shell->child($name, $type, $attributes); - - if ($arguments->hasValue($name)) { - $child = $child->withValue($arguments->getValue($name)); - } - - $children[] = $rootBuilder->build($child); - } - - return $children; - } - - /** - * @param TreeNode[] $children - */ - private function buildObject(ObjectBuilder $builder, array $children): ?object - { - $arguments = []; - - foreach ($children as $child) { - if (! $child->isValid()) { - return null; - } - - $arguments[$child->name()] = $child->value(); - } - - return $builder->build($arguments); - } -} diff --git a/src/Mapper/Tree/Builder/InterfaceNodeBuilder.php b/src/Mapper/Tree/Builder/InterfaceNodeBuilder.php index b96b663a..17b5a449 100644 --- a/src/Mapper/Tree/Builder/InterfaceNodeBuilder.php +++ b/src/Mapper/Tree/Builder/InterfaceNodeBuilder.php @@ -8,8 +8,7 @@ use CuyZ\Valinor\Definition\Repository\ClassDefinitionRepository; use CuyZ\Valinor\Mapper\Object\Arguments; use CuyZ\Valinor\Mapper\Object\ArgumentsValues; -use CuyZ\Valinor\Mapper\Object\Factory\ObjectBuilderFactory; -use CuyZ\Valinor\Mapper\Object\FilteredObjectBuilder; +use CuyZ\Valinor\Mapper\Object\Exception\InvalidSource; use CuyZ\Valinor\Mapper\Tree\Exception\CannotInferFinalClass; use CuyZ\Valinor\Mapper\Tree\Exception\CannotResolveObjectType; use CuyZ\Valinor\Mapper\Tree\Exception\InterfaceHasBothConstructorAndInfer; @@ -27,11 +26,7 @@ public function __construct( private NodeBuilder $delegate, private ObjectImplementations $implementations, private ClassDefinitionRepository $classDefinitionRepository, - private ObjectBuilderFactory $objectBuilderFactory, - private FilteredObjectNodeBuilder $filteredObjectNodeBuilder, private FunctionsContainer $constructors, - private bool $enableFlexibleCasting, - private bool $allowSuperfluousKeys, ) {} public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode @@ -50,7 +45,7 @@ public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode return $this->delegate->build($shell, $rootBuilder); } - if ($this->enableFlexibleCasting && $shell->value() === null) { + if ($shell->enableFlexibleCasting() && $shell->value() === null) { $shell = $shell->withValue([]); } @@ -71,7 +66,13 @@ public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode throw new CannotInferFinalClass($type, $function); } - $children = $this->children($shell, $arguments, $rootBuilder); + $argumentsValues = ArgumentsValues::forInterface($arguments, $shell); + + if ($argumentsValues->hasInvalidValue()) { + throw new InvalidSource($shell->value(), $arguments); + } + + $children = $this->children($shell, $argumentsValues, $rootBuilder); $values = []; @@ -89,12 +90,10 @@ public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode throw UserlandError::from($exception); } - $class = $this->classDefinitionRepository->for($classType); - $objectBuilder = FilteredObjectBuilder::from($shell->value(), ...$this->objectBuilderFactory->for($class)); + $shell = $shell->withType($classType); + $shell = $shell->withAllowedSuperfluousKeys($arguments->names()); - $shell = $this->transformSourceForClass($shell, $arguments, $objectBuilder->describeArguments()); - - return $this->filteredObjectNodeBuilder->build($objectBuilder, $shell, $rootBuilder); + return $this->delegate->build($shell, $rootBuilder); } private function constructorRegisteredFor(Type $type): bool @@ -108,40 +107,11 @@ private function constructorRegisteredFor(Type $type): bool return false; } - private function transformSourceForClass(Shell $shell, Arguments $interfaceArguments, Arguments $classArguments): Shell - { - $value = $shell->value(); - - if (! is_array($value)) { - return $shell; - } - - foreach ($interfaceArguments as $argument) { - $name = $argument->name(); - - if (array_key_exists($name, $value) && ! $classArguments->has($name)) { - unset($value[$name]); - } - } - - if (count($classArguments) === 1 && count($value) === 1) { - $name = $classArguments->at(0)->name(); - - if (array_key_exists($name, $value)) { - $value = $value[$name]; - } - } - - return $shell->withValue($value); - } - /** * @return array */ - private function children(Shell $shell, Arguments $arguments, RootNodeBuilder $rootBuilder): array + private function children(Shell $shell, ArgumentsValues $arguments, RootNodeBuilder $rootBuilder): array { - $arguments = ArgumentsValues::forInterface($arguments, $shell->value(), $this->allowSuperfluousKeys); - $children = []; foreach ($arguments as $argument) { diff --git a/src/Mapper/Tree/Builder/ListNodeBuilder.php b/src/Mapper/Tree/Builder/ListNodeBuilder.php index 8033c4d8..83406881 100644 --- a/src/Mapper/Tree/Builder/ListNodeBuilder.php +++ b/src/Mapper/Tree/Builder/ListNodeBuilder.php @@ -17,8 +17,6 @@ /** @internal */ final class ListNodeBuilder implements NodeBuilder { - public function __construct(private bool $enableFlexibleCasting) {} - public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode { $type = $shell->type(); @@ -26,7 +24,7 @@ public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode assert($type instanceof ListType || $type instanceof NonEmptyListType); - if ($this->enableFlexibleCasting && $value === null) { + if ($shell->enableFlexibleCasting() && $value === null) { return TreeNode::branch($shell, [], []); } @@ -53,7 +51,7 @@ private function children(CompositeTraversableType $type, Shell $shell, RootNode $children = []; foreach ($values as $key => $value) { - if ($this->enableFlexibleCasting || $key === $expected) { + if ($shell->enableFlexibleCasting() || $key === $expected) { $child = $shell->child((string)$expected, $subType); $children[$expected] = $rootBuilder->build($child->withValue($value)); } else { diff --git a/src/Mapper/Tree/Builder/ObjectNodeBuilder.php b/src/Mapper/Tree/Builder/ObjectNodeBuilder.php index e67b613a..88455bb0 100644 --- a/src/Mapper/Tree/Builder/ObjectNodeBuilder.php +++ b/src/Mapper/Tree/Builder/ObjectNodeBuilder.php @@ -5,13 +5,16 @@ namespace CuyZ\Valinor\Mapper\Tree\Builder; use CuyZ\Valinor\Definition\Repository\ClassDefinitionRepository; +use CuyZ\Valinor\Mapper\Object\ArgumentsValues; +use CuyZ\Valinor\Mapper\Object\Exception\CannotFindObjectBuilder; +use CuyZ\Valinor\Mapper\Object\Exception\InvalidSource; use CuyZ\Valinor\Mapper\Object\Factory\ObjectBuilderFactory; -use CuyZ\Valinor\Mapper\Object\FilteredObjectBuilder; +use CuyZ\Valinor\Mapper\Object\ObjectBuilder; use CuyZ\Valinor\Mapper\Tree\Shell; - use CuyZ\Valinor\Type\ObjectType; use function assert; +use function count; /** @internal */ final class ObjectNodeBuilder implements NodeBuilder @@ -19,8 +22,6 @@ final class ObjectNodeBuilder implements NodeBuilder public function __construct( private ClassDefinitionRepository $classDefinitionRepository, private ObjectBuilderFactory $objectBuilderFactory, - private FilteredObjectNodeBuilder $filteredObjectNodeBuilder, - private bool $enableFlexibleCasting, ) {} public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode @@ -30,13 +31,82 @@ public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode // @infection-ignore-all assert($type instanceof ObjectType); - if ($this->enableFlexibleCasting && $shell->value() === null) { + if ($shell->enableFlexibleCasting() && $shell->value() === null) { $shell = $shell->withValue([]); } $class = $this->classDefinitionRepository->for($type); - $objectBuilder = FilteredObjectBuilder::from($shell->value(), ...$this->objectBuilderFactory->for($class)); + $builders = $this->objectBuilderFactory->for($class); + + foreach ($builders as $builder) { + $argumentsValues = ArgumentsValues::forClass($builder->describeArguments(), $shell); + + if ($argumentsValues->hasInvalidValue()) { + if (count($builders) === 1) { + return TreeNode::error($shell, new InvalidSource($shell->value(), $builder->describeArguments())); + } + + continue; + } + + $children = $this->children($shell, $argumentsValues, $rootBuilder); + + $object = $this->buildObject($builder, $children); + + if ($argumentsValues->hadSingleArgument()) { + $node = TreeNode::flattenedBranch($shell, $object, $children[0]); + } else { + $node = TreeNode::branch($shell, $object, $children); + $node = $node->checkUnexpectedKeys(); + } + + if ($node->isValid() || count($builders) === 1) { + return $node; + } + } + + throw new CannotFindObjectBuilder($builders); + } + + /** + * @return list + */ + private function children(Shell $shell, ArgumentsValues $arguments, RootNodeBuilder $rootBuilder): array + { + $children = []; + + foreach ($arguments as $argument) { + $name = $argument->name(); + $type = $argument->type(); + $attributes = $argument->attributes(); + + $child = $shell->child($name, $type, $attributes); + + if ($arguments->hasValue($name)) { + $child = $child->withValue($arguments->getValue($name)); + } + + $children[] = $rootBuilder->build($child); + } + + return $children; + } + + /** + * @param list $children + */ + private function buildObject(ObjectBuilder $builder, array $children): ?object + { + $arguments = []; + + foreach ($children as $child) { + if (! $child->isValid()) { + return null; + } + + $arguments[$child->name()] = $child->value(); + } - return $this->filteredObjectNodeBuilder->build($objectBuilder, $shell, $rootBuilder); + return $builder->build($arguments); } } diff --git a/src/Mapper/Tree/Builder/ScalarNodeBuilder.php b/src/Mapper/Tree/Builder/ScalarNodeBuilder.php index 9a0d8a76..63b3df6a 100644 --- a/src/Mapper/Tree/Builder/ScalarNodeBuilder.php +++ b/src/Mapper/Tree/Builder/ScalarNodeBuilder.php @@ -12,8 +12,6 @@ /** @internal */ final class ScalarNodeBuilder implements NodeBuilder { - public function __construct(private bool $enableFlexibleCasting) {} - public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode { $type = $shell->type(); @@ -21,7 +19,7 @@ public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode assert($type instanceof ScalarType); - if (! $this->enableFlexibleCasting || ! $type->canCast($value)) { + if (! $shell->enableFlexibleCasting() || ! $type->canCast($value)) { throw $type->errorMessage(); } diff --git a/src/Mapper/Tree/Builder/ShapedArrayNodeBuilder.php b/src/Mapper/Tree/Builder/ShapedArrayNodeBuilder.php index 6dc633f2..fb0448ff 100644 --- a/src/Mapper/Tree/Builder/ShapedArrayNodeBuilder.php +++ b/src/Mapper/Tree/Builder/ShapedArrayNodeBuilder.php @@ -5,20 +5,16 @@ namespace CuyZ\Valinor\Mapper\Tree\Builder; use CuyZ\Valinor\Mapper\Tree\Exception\SourceMustBeIterable; -use CuyZ\Valinor\Mapper\Tree\Exception\UnexpectedShapedArrayKeys; use CuyZ\Valinor\Mapper\Tree\Shell; use CuyZ\Valinor\Type\Types\ShapedArrayType; use function array_key_exists; use function assert; -use function count; use function is_array; /** @internal */ final class ShapedArrayNodeBuilder implements NodeBuilder { - public function __construct(private bool $allowSuperfluousKeys) {} - public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode { $type = $shell->type(); @@ -35,10 +31,7 @@ public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode $array = $this->buildArray($children); $node = TreeNode::branch($shell, $array, $children); - - if (! $this->allowSuperfluousKeys && count($value) > count($children)) { - $node = $node->withMessage(new UnexpectedShapedArrayKeys($value, $children)); - } + $node = $node->checkUnexpectedKeys(); return $node; } diff --git a/src/Mapper/Tree/Builder/StrictNodeBuilder.php b/src/Mapper/Tree/Builder/StrictNodeBuilder.php index ecff6142..7361e9f2 100644 --- a/src/Mapper/Tree/Builder/StrictNodeBuilder.php +++ b/src/Mapper/Tree/Builder/StrictNodeBuilder.php @@ -13,20 +13,18 @@ final class StrictNodeBuilder implements NodeBuilder { public function __construct( private NodeBuilder $delegate, - private bool $allowPermissiveTypes, - private bool $enableFlexibleCasting ) {} public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode { $type = $shell->type(); - if (! $this->allowPermissiveTypes) { + if (! $shell->allowPermissiveTypes()) { TypeHelper::checkPermissiveType($type); } if (! $shell->hasValue()) { - if ($this->enableFlexibleCasting) { + if ($shell->enableFlexibleCasting()) { return $this->delegate->build($shell->withValue(null), $rootBuilder); } diff --git a/src/Mapper/Tree/Builder/TreeNode.php b/src/Mapper/Tree/Builder/TreeNode.php index 890c2ec0..afc72587 100644 --- a/src/Mapper/Tree/Builder/TreeNode.php +++ b/src/Mapper/Tree/Builder/TreeNode.php @@ -5,15 +5,18 @@ namespace CuyZ\Valinor\Mapper\Tree\Builder; use CuyZ\Valinor\Mapper\Tree\Exception\InvalidNodeValue; +use CuyZ\Valinor\Mapper\Tree\Exception\UnexpectedKeysInSource; use CuyZ\Valinor\Mapper\Tree\Message\Message; use CuyZ\Valinor\Mapper\Tree\Node; use CuyZ\Valinor\Mapper\Tree\Shell; -use CuyZ\Valinor\Type\FloatType; use CuyZ\Valinor\Type\Type; use Throwable; +use function array_diff; +use function array_keys; use function array_map; use function assert; +use function is_array; /** @internal */ final class TreeNode @@ -32,14 +35,6 @@ final class TreeNode private function __construct(Shell $shell, mixed $value) { - // When the value is an integer and the type is a float, the value needs - // to be cast to float — this special case needs to be handled in case a - // node is not a *native* PHP float type (for instance a class property - // with a `@var float` annotation). - if ($shell->type() instanceof FloatType && is_int($value)) { - $value = (float)$value; - } - $this->shell = $shell; $this->value = $value; } @@ -136,19 +131,36 @@ public function node(): Node return $this->buildNode($this); } + public function checkUnexpectedKeys(): self + { + $value = $this->shell->value(); + + if ($this->shell->allowSuperfluousKeys() || ! is_array($value)) { + return $this; + } + + $diff = array_diff(array_keys($value), array_keys($this->children), $this->shell->allowedSuperfluousKeys()); + + if ($diff !== []) { + return $this->withMessage(new UnexpectedKeysInSource($value, $this->children)); + } + + return $this; + } + private function check(): void { foreach ($this->children as $child) { if (! $child->valid) { $this->valid = false; - - return; } } - if ($this->valid && ! $this->shell->type()->accepts($this->value)) { + $type = $this->shell->type(); + + if ($this->valid && ! $type->accepts($this->value)) { $this->valid = false; - $this->messages[] = new InvalidNodeValue($this->shell->type()); + $this->messages[] = new InvalidNodeValue($type); } } diff --git a/src/Mapper/Tree/Builder/UnionNodeBuilder.php b/src/Mapper/Tree/Builder/UnionNodeBuilder.php index 4126135b..845d5df9 100644 --- a/src/Mapper/Tree/Builder/UnionNodeBuilder.php +++ b/src/Mapper/Tree/Builder/UnionNodeBuilder.php @@ -8,18 +8,17 @@ use CuyZ\Valinor\Mapper\Tree\Exception\TooManyResolvedTypesFromUnion; use CuyZ\Valinor\Mapper\Tree\Shell; use CuyZ\Valinor\Type\ClassType; -use CuyZ\Valinor\Type\FloatType; -use CuyZ\Valinor\Type\IntegerType; use CuyZ\Valinor\Type\ScalarType; -use CuyZ\Valinor\Type\StringType; use CuyZ\Valinor\Type\Types\InterfaceType; use CuyZ\Valinor\Type\Types\NullType; use CuyZ\Valinor\Type\Types\ShapedArrayType; use CuyZ\Valinor\Type\Types\UnionType; +use CuyZ\Valinor\Utility\TypeHelper; use function count; use function krsort; use function reset; +use function usort; /** @internal */ final class UnionNodeBuilder implements NodeBuilder @@ -91,28 +90,11 @@ public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode return $first[0]; } } elseif ($scalars !== []) { - // Sorting the scalar types by priority: int, float, string, bool. - $sorted = []; - - foreach ($scalars as $node) { - if ($node->type() instanceof IntegerType) { - $sorted[IntegerType::class] = $node; - } elseif ($node->type() instanceof FloatType) { - $sorted[FloatType::class] = $node; - } elseif ($node->type() instanceof StringType) { - $sorted[StringType::class] = $node; - } - } - - if (isset($sorted[IntegerType::class])) { - return $sorted[IntegerType::class]; - } elseif (isset($sorted[FloatType::class])) { - return $sorted[FloatType::class]; - } elseif (isset($sorted[StringType::class])) { - return $sorted[StringType::class]; - } + usort( + $scalars, + fn (TreeNode $a, TreeNode $b): int => TypeHelper::typePriority($b->type()) <=> TypeHelper::typePriority($a->type()), + ); - // @infection-ignore-all / We know this is a boolean, so we don't need to mutate the index return $scalars[0]; } diff --git a/src/Mapper/Tree/Exception/UnexpectedArrayKeysForClass.php b/src/Mapper/Tree/Exception/UnexpectedArrayKeysForClass.php deleted file mode 100644 index 9ae2de4b..00000000 --- a/src/Mapper/Tree/Exception/UnexpectedArrayKeysForClass.php +++ /dev/null @@ -1,46 +0,0 @@ - */ - private array $parameters; - - public function __construct(ArgumentsValues $arguments) - { - $expected = array_map(fn (Argument $argument) => $argument->name(), [...$arguments]); - - $this->parameters = [ - 'keys' => '`' . implode('`, `', $arguments->superfluousKeys()) . '`', - 'expected_keys' => '`' . implode('`, `', $expected) . '`', - ]; - - parent::__construct(StringFormatter::for($this), 1655149208); - } - - public function body(): string - { - return $this->body; - } - - public function parameters(): array - { - return $this->parameters; - } -} diff --git a/src/Mapper/Tree/Exception/UnexpectedShapedArrayKeys.php b/src/Mapper/Tree/Exception/UnexpectedKeysInSource.php similarity index 93% rename from src/Mapper/Tree/Exception/UnexpectedShapedArrayKeys.php rename to src/Mapper/Tree/Exception/UnexpectedKeysInSource.php index dc55919b..c32ff4d5 100644 --- a/src/Mapper/Tree/Exception/UnexpectedShapedArrayKeys.php +++ b/src/Mapper/Tree/Exception/UnexpectedKeysInSource.php @@ -17,7 +17,7 @@ use function in_array; /** @internal */ -final class UnexpectedShapedArrayKeys extends RuntimeException implements ErrorMessage, HasParameters +final class UnexpectedKeysInSource extends RuntimeException implements ErrorMessage, HasParameters { private string $body = 'Unexpected key(s) {keys}, expected {expected_keys}.'; diff --git a/src/Mapper/Tree/Shell.php b/src/Mapper/Tree/Shell.php index ca87bafe..10831820 100644 --- a/src/Mapper/Tree/Shell.php +++ b/src/Mapper/Tree/Shell.php @@ -5,7 +5,9 @@ namespace CuyZ\Valinor\Mapper\Tree; use CuyZ\Valinor\Definition\Attributes; +use CuyZ\Valinor\Library\Settings; use CuyZ\Valinor\Mapper\Tree\Exception\UnresolvableShellType; +use CuyZ\Valinor\Type\FloatType; use CuyZ\Valinor\Type\Type; use CuyZ\Valinor\Type\Types\UnresolvableType; @@ -16,6 +18,10 @@ /** @internal */ final class Shell { + private Settings $settings; + + private Type $type; + private string $name; private bool $hasValue = false; @@ -26,21 +32,30 @@ final class Shell private self $parent; - private function __construct(private Type $type) + /** @var list */ + private array $allowedSuperfluousKeys = []; + + private function __construct(Settings $settings, Type $type) { if ($type instanceof UnresolvableType) { throw new UnresolvableShellType($type); } + + $this->settings = $settings; + $this->type = $type; } - public static function root(Type $type, mixed $value): self - { - return (new self($type))->withValue($value); + public static function root( + Settings $settings, + Type $type, + mixed $value, + ): self { + return (new self($settings, $type))->withValue($value); } - public function child(string $name, Type $type, Attributes $attributes = null): self + public function child(string $name, Type $type, ?Attributes $attributes = null): self { - $instance = new self($type); + $instance = new self($this->settings, $type); $instance->name = $name; $instance->parent = $this; @@ -65,6 +80,7 @@ public function withType(Type $newType): self { $clone = clone $this; $clone->type = $newType; + $clone->value = self::castCompatibleValue($newType, $this->value); return $clone; } @@ -78,7 +94,7 @@ public function withValue(mixed $value): self { $clone = clone $this; $clone->hasValue = true; - $clone->value = $value; + $clone->value = self::castCompatibleValue($clone->type, $value); return $clone; } @@ -95,11 +111,45 @@ public function value(): mixed return $this->value; } + public function enableFlexibleCasting(): bool + { + return $this->settings->enableFlexibleCasting; + } + + public function allowSuperfluousKeys(): bool + { + return $this->settings->allowSuperfluousKeys; + } + + public function allowPermissiveTypes(): bool + { + return $this->settings->allowPermissiveTypes; + } + public function attributes(): Attributes { return $this->attributes ?? Attributes::empty(); } + /** + * @param list $allowedSuperfluousKeys + */ + public function withAllowedSuperfluousKeys(array $allowedSuperfluousKeys): self + { + $clone = clone $this; + $clone->allowedSuperfluousKeys = $allowedSuperfluousKeys; + + return $clone; + } + + /** + * @return list + */ + public function allowedSuperfluousKeys(): array + { + return $this->allowedSuperfluousKeys; + } + public function path(): string { if (! isset($this->parent)) { @@ -116,4 +166,17 @@ public function path(): string return implode('.', $path); } + + private static function castCompatibleValue(Type $type, mixed $value): mixed + { + // When the value is an integer and the type is a float, the value is + // cast to float, to follow the rule of PHP regarding acceptance of an + // integer value in a float type. Note that PHPStan/Psalm analysis + // applies the same rule. + if ($type instanceof FloatType && is_int($value)) { + return (float)$value; + } + + return $value; + } } diff --git a/src/Mapper/TypeArgumentsMapper.php b/src/Mapper/TypeArgumentsMapper.php index f0d73f81..465ec624 100644 --- a/src/Mapper/TypeArgumentsMapper.php +++ b/src/Mapper/TypeArgumentsMapper.php @@ -6,6 +6,7 @@ use CuyZ\Valinor\Definition\ParameterDefinition; use CuyZ\Valinor\Definition\Repository\FunctionDefinitionRepository; +use CuyZ\Valinor\Library\Settings; use CuyZ\Valinor\Mapper\Exception\TypeErrorDuringArgumentsMapping; use CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Exception\UnresolvableShellType; @@ -17,15 +18,11 @@ /** @internal */ final class TypeArgumentsMapper implements ArgumentsMapper { - private FunctionDefinitionRepository $functionDefinitionRepository; - - private RootNodeBuilder $nodeBuilder; - - public function __construct(FunctionDefinitionRepository $functionDefinitionRepository, RootNodeBuilder $nodeBuilder) - { - $this->functionDefinitionRepository = $functionDefinitionRepository; - $this->nodeBuilder = $nodeBuilder; - } + public function __construct( + private FunctionDefinitionRepository $functionDefinitionRepository, + private RootNodeBuilder $nodeBuilder, + private Settings $settings, + ) {} /** @pure */ public function mapArguments(callable $callable, mixed $source): array @@ -42,7 +39,7 @@ public function mapArguments(callable $callable, mixed $source): array ); $type = new ShapedArrayType(...$elements); - $shell = Shell::root($type, $source); + $shell = Shell::root($this->settings, $type, $source); try { $node = $this->nodeBuilder->build($shell); diff --git a/src/Mapper/TypeTreeMapper.php b/src/Mapper/TypeTreeMapper.php index d7691dc1..6c0734ca 100644 --- a/src/Mapper/TypeTreeMapper.php +++ b/src/Mapper/TypeTreeMapper.php @@ -4,6 +4,7 @@ namespace CuyZ\Valinor\Mapper; +use CuyZ\Valinor\Library\Settings; use CuyZ\Valinor\Mapper\Exception\InvalidMappingTypeSignature; use CuyZ\Valinor\Mapper\Exception\TypeErrorDuringMapping; use CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder; @@ -18,7 +19,8 @@ final class TypeTreeMapper implements TreeMapper { public function __construct( private TypeParser $typeParser, - private RootNodeBuilder $nodeBuilder + private RootNodeBuilder $nodeBuilder, + private Settings $settings, ) {} /** @pure */ @@ -41,7 +43,7 @@ private function node(string $signature, mixed $source): TreeNode throw new InvalidMappingTypeSignature($signature, $exception); } - $shell = Shell::root($type, $source); + $shell = Shell::root($this->settings, $type, $source); try { return $this->nodeBuilder->build($shell); diff --git a/src/Normalizer/ArrayNormalizer.php b/src/Normalizer/ArrayNormalizer.php index c76a000f..b351f72d 100644 --- a/src/Normalizer/ArrayNormalizer.php +++ b/src/Normalizer/ArrayNormalizer.php @@ -4,6 +4,7 @@ namespace CuyZ\Valinor\Normalizer; +use CuyZ\Valinor\Normalizer\Transformer\EmptyObject; use CuyZ\Valinor\Normalizer\Transformer\RecursiveTransformer; use function array_map; @@ -38,6 +39,8 @@ private function normalizeIterator(mixed $value): mixed } $value = array_map($this->normalizeIterator(...), $value); + } elseif ($value instanceof EmptyObject) { + return []; } return $value; diff --git a/src/Normalizer/Formatter/JsonFormatter.php b/src/Normalizer/Formatter/JsonFormatter.php index feec8356..e7dfe298 100644 --- a/src/Normalizer/Formatter/JsonFormatter.php +++ b/src/Normalizer/Formatter/JsonFormatter.php @@ -5,6 +5,7 @@ namespace CuyZ\Valinor\Normalizer\Formatter; use CuyZ\Valinor\Normalizer\Formatter\Exception\CannotFormatInvalidTypeToJson; +use CuyZ\Valinor\Normalizer\Transformer\EmptyObject; use Generator; use function array_is_list; @@ -16,6 +17,7 @@ use function is_scalar; use function json_encode; +use const JSON_FORCE_OBJECT; use const JSON_THROW_ON_ERROR; /** @internal */ @@ -41,6 +43,8 @@ public function format(mixed $value): void * tools understand that JSON_THROW_ON_ERROR is always set. */ $this->write(json_encode($value, $this->jsonEncodingOptions)); + } elseif ($value instanceof EmptyObject) { + $this->write('{}'); } elseif (is_iterable($value)) { // Note: when a generator is formatted, it is considered as a list // if its first key is 0. This is done early because the first JSON @@ -51,8 +55,11 @@ public function format(mixed $value): void // afterward, this leads to a JSON array being written, while it // should have been an object. This is a trade-off we accept, // considering most generators starting at 0 are actually lists. - $isList = ($value instanceof Generator && $value->key() === 0) - || (is_array($value) && array_is_list($value)); + $isList = ! ($this->jsonEncodingOptions & JSON_FORCE_OBJECT) + && ( + ($value instanceof Generator && $value->key() === 0) + || (is_array($value) && array_is_list($value)) + ); $isFirst = true; diff --git a/src/Normalizer/JsonNormalizer.php b/src/Normalizer/JsonNormalizer.php index 872955ed..3ccb8702 100644 --- a/src/Normalizer/JsonNormalizer.php +++ b/src/Normalizer/JsonNormalizer.php @@ -14,6 +14,7 @@ use function is_resource; use function stream_get_contents; +use const JSON_FORCE_OBJECT; use const JSON_HEX_AMP; use const JSON_HEX_APOS; use const JSON_HEX_QUOT; @@ -34,7 +35,8 @@ */ final class JsonNormalizer implements Normalizer { - private const ACCEPTABLE_JSON_OPTIONS = JSON_HEX_QUOT + private const ACCEPTABLE_JSON_OPTIONS = JSON_FORCE_OBJECT + | JSON_HEX_QUOT | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS diff --git a/src/Normalizer/Transformer/EmptyObject.php b/src/Normalizer/Transformer/EmptyObject.php new file mode 100644 index 00000000..7bd4e45b --- /dev/null +++ b/src/Normalizer/Transformer/EmptyObject.php @@ -0,0 +1,13 @@ + $item) { yield $key => $this->doTransform($item, $references); } })(); + + if (! $result->valid()) { + return EmptyObject::get(); + } + + return $result; } if (is_object($value) && ! $value instanceof Closure) { @@ -126,9 +133,15 @@ private function defaultTransformer(mixed $value, WeakMap $references): mixed } if ($value::class === stdClass::class) { + $result = (array)$value; + + if ($result === []) { + return EmptyObject::get(); + } + return array_map( fn (mixed $value) => $this->doTransform($value, $references), - (array)$value + $result ); } diff --git a/src/Normalizer/Transformer/ValueTransformersHandler.php b/src/Normalizer/Transformer/ValueTransformersHandler.php index d5cdf90a..ef42b56c 100644 --- a/src/Normalizer/Transformer/ValueTransformersHandler.php +++ b/src/Normalizer/Transformer/ValueTransformersHandler.php @@ -118,8 +118,8 @@ private function checkTransformer(MethodDefinition|FunctionDefinition $method): throw new TransformerHasTooManyParameters($method); } - if ($parameters->count() > 1 && ! $parameters->at(1)->type instanceof CallableType) { - throw new TransformerHasInvalidCallableParameter($method, $parameters->at(1)->type); + if ($parameters->count() > 1 && ! $parameters->at(1)->nativeType instanceof CallableType) { + throw new TransformerHasInvalidCallableParameter($method, $parameters->at(1)->nativeType); } } } diff --git a/src/Type/Parser/Exception/Magic/ValueOfClosingBracketMissing.php b/src/Type/Parser/Exception/Magic/ValueOfClosingBracketMissing.php new file mode 100644 index 00000000..f9d48e23 --- /dev/null +++ b/src/Type/Parser/Exception/Magic/ValueOfClosingBracketMissing.php @@ -0,0 +1,21 @@ +toString()}>`.", + 1717702289 + ); + } +} diff --git a/src/Type/Parser/Exception/Magic/ValueOfIncorrectSubType.php b/src/Type/Parser/Exception/Magic/ValueOfIncorrectSubType.php new file mode 100644 index 00000000..dc272871 --- /dev/null +++ b/src/Type/Parser/Exception/Magic/ValueOfIncorrectSubType.php @@ -0,0 +1,21 @@ +toString()}>`, it should be a `BackedEnum`.", + 1717702683 + ); + } +} diff --git a/src/Type/Parser/Exception/Magic/ValueOfOpeningBracketMissing.php b/src/Type/Parser/Exception/Magic/ValueOfOpeningBracketMissing.php new file mode 100644 index 00000000..28d1cf36 --- /dev/null +++ b/src/Type/Parser/Exception/Magic/ValueOfOpeningBracketMissing.php @@ -0,0 +1,20 @@ +`.", + 1717702268 + ); + } +} diff --git a/src/Type/Parser/Lexer/NativeLexer.php b/src/Type/Parser/Lexer/NativeLexer.php index 6f6172d3..8801cc7d 100644 --- a/src/Type/Parser/Lexer/NativeLexer.php +++ b/src/Type/Parser/Lexer/NativeLexer.php @@ -20,6 +20,7 @@ use CuyZ\Valinor\Type\Parser\Lexer\Token\IterableToken; use CuyZ\Valinor\Type\Parser\Lexer\Token\ListToken; use CuyZ\Valinor\Type\Parser\Lexer\Token\NullableToken; +use CuyZ\Valinor\Type\Parser\Lexer\Token\ValueOfToken; use CuyZ\Valinor\Type\Parser\Lexer\Token\OpeningBracketToken; use CuyZ\Valinor\Type\Parser\Lexer\Token\OpeningCurlyBracketToken; use CuyZ\Valinor\Type\Parser\Lexer\Token\OpeningSquareBracketToken; @@ -78,6 +79,7 @@ public function tokenize(string $symbol): Token 'iterable' => IterableToken::get(), 'class-string' => ClassStringToken::get(), 'callable' => CallableToken::get(), + 'value-of' => ValueOfToken::get(), 'null' => new TypeToken(NullType::get()), 'true' => new TypeToken(BooleanValueType::true()), diff --git a/src/Type/Parser/Lexer/Token/ValueOfToken.php b/src/Type/Parser/Lexer/Token/ValueOfToken.php new file mode 100644 index 00000000..5d350615 --- /dev/null +++ b/src/Type/Parser/Lexer/Token/ValueOfToken.php @@ -0,0 +1,65 @@ +done() || !$stream->forward() instanceof OpeningBracketToken) { + throw new ValueOfOpeningBracketMissing(); + } + + $subType = $stream->read(); + + if ($stream->done() || !$stream->forward() instanceof ClosingBracketToken) { + throw new ValueOfClosingBracketMissing($subType); + } + + if (! $subType instanceof EnumType) { + throw new ValueOfIncorrectSubType($subType); + } + + if (! is_a($subType->className(), BackedEnum::class, true)) { + throw new ValueOfIncorrectSubType($subType); + } + + $cases = array_map( + // @phpstan-ignore-next-line / We know it's a BackedEnum + fn (BackedEnum $case) => ValueTypeFactory::from($case->value), + array_values($subType->cases()), + ); + + if (count($cases) > 1) { + return new UnionType(...$cases); + } + + return $cases[0]; + } + + public function symbol(): string + { + return 'value-of'; + } +} diff --git a/src/Type/Parser/Lexer/TokenizedAnnotation.php b/src/Type/Parser/Lexer/TokenizedAnnotation.php index 4e4c403c..0aa15f99 100644 --- a/src/Type/Parser/Lexer/TokenizedAnnotation.php +++ b/src/Type/Parser/Lexer/TokenizedAnnotation.php @@ -13,7 +13,7 @@ final class TokenizedAnnotation public function __construct( /** @var non-empty-string */ private string $name, - /** @var non-empty-list> */ + /** @var non-empty-list */ private array $tokens, ) {} diff --git a/src/Type/Types/ClassStringType.php b/src/Type/Types/ClassStringType.php index e1404e55..d715d646 100644 --- a/src/Type/Types/ClassStringType.php +++ b/src/Type/Types/ClassStringType.php @@ -27,7 +27,7 @@ final class ClassStringType implements StringType, CompositeType private string $signature; - public function __construct(ObjectType|UnionType $subType = null) + public function __construct(ObjectType|UnionType|null $subType = null) { if ($subType instanceof UnionType) { foreach ($subType->types() as $type) { diff --git a/src/Type/Types/NativeFloatType.php b/src/Type/Types/NativeFloatType.php index 4d5be2b5..2545d312 100644 --- a/src/Type/Types/NativeFloatType.php +++ b/src/Type/Types/NativeFloatType.php @@ -12,7 +12,6 @@ use function assert; use function is_float; -use function is_integer; use function is_numeric; /** @internal */ @@ -22,7 +21,7 @@ final class NativeFloatType implements FloatType public function accepts(mixed $value): bool { - return is_float($value) || is_integer($value); + return is_float($value); } public function matches(Type $other): bool diff --git a/src/Utility/TypeHelper.php b/src/Utility/TypeHelper.php index 262ca036..51024413 100644 --- a/src/Utility/TypeHelper.php +++ b/src/Utility/TypeHelper.php @@ -6,9 +6,13 @@ use CuyZ\Valinor\Mapper\Object\Argument; use CuyZ\Valinor\Mapper\Object\Arguments; +use CuyZ\Valinor\Type\BooleanType; use CuyZ\Valinor\Type\CompositeType; use CuyZ\Valinor\Type\FixedType; +use CuyZ\Valinor\Type\FloatType; +use CuyZ\Valinor\Type\IntegerType; use CuyZ\Valinor\Type\ObjectType; +use CuyZ\Valinor\Type\StringType; use CuyZ\Valinor\Type\Type; use CuyZ\Valinor\Type\Types\EnumType; use CuyZ\Valinor\Type\Types\MixedType; @@ -17,6 +21,20 @@ /** @internal */ final class TypeHelper { + /** + * Sorting the scalar types by priority: int, float, string, bool. + */ + public static function typePriority(Type $type): int + { + return match (true) { + $type instanceof IntegerType => 4, + $type instanceof FloatType => 3, + $type instanceof StringType => 2, + $type instanceof BooleanType => 1, + default => 0, + }; + } + public static function dump(Type $type, bool $surround = true): string { if ($type instanceof EnumType) { @@ -51,7 +69,7 @@ function (Argument $argument) { return $argument->isRequired() ? "$name: $signature" : "$name?: $signature"; }, - [...$arguments] + [...$arguments], ); return '`array{' . implode(', ', $parameters) . '}`'; diff --git a/tests/Fake/Mapper/FakeShell.php b/tests/Fake/Mapper/FakeShell.php index eddfef42..91e5bb2b 100644 --- a/tests/Fake/Mapper/FakeShell.php +++ b/tests/Fake/Mapper/FakeShell.php @@ -4,15 +4,16 @@ namespace CuyZ\Valinor\Tests\Fake\Mapper; +use CuyZ\Valinor\Library\Settings; use CuyZ\Valinor\Mapper\Tree\Shell; use CuyZ\Valinor\Tests\Fake\Type\FakeType; use CuyZ\Valinor\Type\Type; final class FakeShell { - public static function new(Type $type, mixed $value = null): Shell + public static function new(Type $type, mixed $value = null, ?Settings $settings = null): Shell { - return Shell::root($type, $value); + return Shell::root($settings ?? new Settings(), $type, $value); } public static function any(): Shell diff --git a/tests/Functional/Type/Parser/LexingParserTest.php b/tests/Functional/Type/Parser/LexingParserTest.php index db84ab9e..88d9903e 100644 --- a/tests/Functional/Type/Parser/LexingParserTest.php +++ b/tests/Functional/Type/Parser/LexingParserTest.php @@ -38,6 +38,9 @@ use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayUnexpectedTokenAfterSealedType; use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayWithoutElementsWithSealedType; use CuyZ\Valinor\Type\Parser\Exception\Iterable\SimpleArrayClosingBracketMissing; +use CuyZ\Valinor\Type\Parser\Exception\Magic\ValueOfClosingBracketMissing; +use CuyZ\Valinor\Type\Parser\Exception\Magic\ValueOfIncorrectSubType; +use CuyZ\Valinor\Type\Parser\Exception\Magic\ValueOfOpeningBracketMissing; use CuyZ\Valinor\Type\Parser\Exception\MissingClosingQuoteChar; use CuyZ\Valinor\Type\Parser\Exception\RightIntersectionTypeMissing; use CuyZ\Valinor\Type\Parser\Exception\RightUnionTypeMissing; @@ -1064,6 +1067,16 @@ public static function parse_valid_types_returns_valid_result_data_provider(): i 'transformed' => PureEnum::class . '::*A*', 'type' => EnumType::class, ]; + yield 'value-of' => [ + 'raw' => "value-of<" . BackedStringEnum::class . ">", + 'transformed' => "'foo'|'bar'|'baz'", + 'type' => UnionType::class, + ]; + yield 'value-of' => [ + 'raw' => "value-of<" . BackedIntegerEnum::class . ">", + 'transformed' => "42|404|1337", + 'type' => UnionType::class, + ]; } public function test_multiple_union_types_are_parsed(): void @@ -1603,6 +1616,46 @@ public function test_duplicated_template_name_throws_exception(): void $this->parser->parse("$className"); } + + public function test_value_of_enum_missing_opening_bracket_throws_exception(): void + { + $this->expectException(ValueOfOpeningBracketMissing::class); + $this->expectExceptionCode(1717702268); + $this->expectExceptionMessage('The opening bracket is missing for `value-of<...>`.'); + + $this->parser->parse('value-of'); + } + + public function test_value_of_enum_missing_closing_bracket_throws_exception(): void + { + $enumName = BackedStringEnum::class; + + $this->expectException(ValueOfClosingBracketMissing::class); + $this->expectExceptionCode(1717702289); + $this->expectExceptionMessage("The closing bracket is missing for `value-of<$enumName>`."); + + $this->parser->parse("value-of<$enumName"); + } + + public function test_value_of_incorrect_type_throws_exception(): void + { + $this->expectException(ValueOfIncorrectSubType::class); + $this->expectExceptionCode(1717702683); + $this->expectExceptionMessage('Invalid subtype `value-of`, it should be a `BackedEnum`.'); + + $this->parser->parse('value-of'); + } + + public function test_value_of_unit_enum_type_throws_exception(): void + { + $enumName = PureEnum::class; + + $this->expectException(ValueOfIncorrectSubType::class); + $this->expectExceptionCode(1717702683); + $this->expectExceptionMessage("Invalid subtype `value-of<$enumName>`, it should be a `BackedEnum`."); + + $this->parser->parse("value-of<$enumName>"); + } } /** diff --git a/tests/Integration/Mapping/ConstructorRegistrationMappingTest.php b/tests/Integration/Mapping/ConstructorRegistrationMappingTest.php index 4e157da8..402e745a 100644 --- a/tests/Integration/Mapping/ConstructorRegistrationMappingTest.php +++ b/tests/Integration/Mapping/ConstructorRegistrationMappingTest.php @@ -20,6 +20,7 @@ use DateTime; use DateTimeImmutable; use DateTimeInterface; +use PHPUnit\Framework\Attributes\DataProvider; use stdClass; final class ConstructorRegistrationMappingTest extends IntegrationTestCase @@ -288,26 +289,6 @@ public function test_registered_native_constructor_is_called_if_registered_and_o self::assertSame(1337, $result->bar); } - public function test_registered_constructor_is_used_when_not_the_first_nor_last_one(): void - { - $object = new stdClass(); - - try { - $result = $this->mapperBuilder() - ->registerConstructor(fn (): DateTime => new DateTime()) - // This constructor is surrounded by other ones to ensure it is - // still used correctly. - ->registerConstructor(fn (): stdClass => $object) - ->registerConstructor(fn (): DateTimeImmutable => new DateTimeImmutable()) - ->mapper() - ->map(stdClass::class, []); - } catch (MappingError $error) { - $this->mappingFail($error); - } - - self::assertSame($object, $result); - } - public function test_registered_constructor_with_one_argument_is_used(): void { try { @@ -353,102 +334,6 @@ public function test_registered_constructor_with_several_arguments_is_used(): vo self::assertSame(1337.404, $result->float); } - public function test_registered_constructors_for_same_class_are_filtered_correctly(): void - { - $mapper = $this->mapperBuilder() - // Basic constructor - ->registerConstructor(function (string $foo): stdClass { - $class = new stdClass(); - $class->foo = $foo; - - return $class; - }) - // Constructor with two parameters - ->registerConstructor(function (string $foo, int $bar): stdClass { - $class = new stdClass(); - $class->foo = $foo; - $class->bar = $bar; - - return $class; - }) - // Constructor with optional parameter - ->registerConstructor(function (string $foo, int $bar, float $baz, string $fiz = 'fiz'): stdClass { - $class = new stdClass(); - $class->foo = $foo; - $class->bar = $bar; - $class->baz = $baz; - $class->fiz = $fiz; - - return $class; - }) - ->mapper(); - - try { - $resultA = $mapper->map(stdClass::class, 'foo'); - - $resultB = $mapper->map(stdClass::class, [ - 'foo' => 'foo', - 'bar' => 42, - ]); - - $resultC = $mapper->map(stdClass::class, [ - 'foo' => 'foo', - 'bar' => 42, - 'baz' => 1337.404, - ]); - } catch (MappingError $error) { - $this->mappingFail($error); - } - - self::assertSame('foo', $resultA->foo); - - self::assertSame('foo', $resultB->foo); - self::assertSame(42, $resultB->bar); - - self::assertSame('foo', $resultC->foo); - self::assertSame(42, $resultC->bar); - self::assertSame(1337.404, $resultC->baz); - self::assertSame('fiz', $resultC->fiz); - } - - public function test_several_constructors_with_same_arguments_number_are_filtered_correctly(): void - { - $mapper = $this->mapperBuilder() - ->registerConstructor(function (string $foo, string $bar): stdClass { - $class = new stdClass(); - $class->foo = $foo; - $class->bar = $bar; - - return $class; - }) - ->registerConstructor(function (string $foo, string $baz): stdClass { - $class = new stdClass(); - $class->foo = $foo; - $class->baz = $baz; - - return $class; - })->mapper(); - - try { - $resultA = $mapper->map(stdClass::class, [ - 'foo' => 'foo', - 'bar' => 'bar', - ]); - - $resultB = $mapper->map(stdClass::class, [ - 'foo' => 'foo', - 'baz' => 'baz', - ]); - } catch (MappingError $error) { - $this->mappingFail($error); - } - - self::assertSame('foo', $resultA->foo); - self::assertSame('bar', $resultA->bar); - self::assertSame('foo', $resultB->foo); - self::assertSame('baz', $resultB->baz); - } - public function test_inherited_static_constructor_is_used_to_map_child_class(): void { $class = (new class () { @@ -475,11 +360,222 @@ public function test_inherited_static_constructor_is_used_to_map_child_class(): self::assertSame(1337, $result->someOtherChild->bar); } + /** + * @param list $constructors + * @param array $data + */ + #[DataProvider('constructors_are_sorted_and_filtered_correctly_data_provider')] + public function test_constructors_are_sorted_and_filtered_correctly(array $constructors, array $data): void + { + $mapperBuilder = $this->mapperBuilder()->registerConstructor(...$constructors); + + try { + foreach ($data as $value) { + $result = $mapperBuilder->mapper()->map(stdClass::class, $value['value']); + + self::assertSame($value['expected'], $result); + + // Also testing with allowed superfluous keys to be sure that + // constructors with fewer arguments are taken into account but + //filtered correctly. + $result = $mapperBuilder->allowSuperfluousKeys()->mapper()->map(stdClass::class, $value['value']); + + self::assertSame($value['expected'], $result); + } + } catch (MappingError $error) { + $this->mappingFail($error); + } + } + + public static function constructors_are_sorted_and_filtered_correctly_data_provider(): iterable + { + $resultA = new stdClass(); + $resultB = new stdClass(); + $resultC = new stdClass(); + + yield 'constructor is used when surrounded by other constructors' => [ + 'constructors' => [ + fn (): DateTime => new DateTime(), + // This constructor is surrounded by other ones to ensure it is + // still used correctly. + fn (): stdClass => $resultA, + fn (): DateTimeImmutable => new DateTimeImmutable(), + ], + 'data' => [ + [ + 'value' => [], + 'expected' => $resultA, + ], + ], + ]; + + yield 'constructors for same class are sorted properly' => [ + 'constructors' => [ + // Basic constructor + fn (string $foo): stdClass => $resultA, + // Constructor with two parameters + fn (string $foo, int $bar): stdClass => $resultB, + // Constructor with optional parameter + fn (string $foo, int $bar, float $baz, string $fiz = 'fiz'): stdClass => $resultC, + ], + 'data' => [ + 'string source' => [ + 'value' => 'foo', + 'expected' => $resultA, + ], + 'foo and bar values' => [ + 'value' => [ + 'foo' => 'foo', + 'bar' => 42, + ], + 'expected' => $resultB, + ], + 'foo and bar and baz values' => [ + 'value' => [ + 'foo' => 'foo', + 'bar' => 42, + 'baz' => 1337.0, + ], + 'expected' => $resultC, + ], + ], + ]; + + yield 'constructors for same class with same arguments number but different types' => [ + 'constructors' => [ + fn (string $foo, string $bar): stdClass => $resultA, + fn (string $foo, string $fiz): stdClass => $resultB, + ], + 'data' => [ + 'foo and bar' => [ + 'value' => [ + 'foo' => 'foo', + 'bar' => 'bar', + ], + 'expected' => $resultA, + ], + 'foo and fiz' => [ + 'value' => [ + 'foo' => 'foo', + 'fiz' => 'fiz', + ], + 'expected' => $resultB, + ], + ], + ]; + + yield 'constructors with same parameter name but different types' => [ + 'constructors' => [ + fn (string $value): stdClass => $resultA, + fn (float $value): stdClass => $resultB, + fn (int $value): stdClass => $resultC, + ], + 'data' => [ + 'string source' => [ + 'value' => 'foo', + 'expected' => $resultA, + ], + 'float source' => [ + 'value' => 404.0, + 'expected' => $resultB, + ], + 'integer source' => [ + 'value' => 1337, + 'expected' => $resultC, + ], + ], + ]; + + yield 'constructors with same named parameter use integer over float' => [ + 'constructors' => [ + fn (float $value): stdClass => $resultA, + fn (int $value): stdClass => $resultB, + ], + 'data' => [ + [ + 'value' => 1337, + 'expected' => $resultB, + ], + ], + ]; + + yield 'constructors with same named parameters names use integer over float' => [ + 'constructors' => [ + fn (float $valueA, float $valueB): stdClass => $resultA, + fn (int $valueA, int $valueB): stdClass => $resultB, + ], + 'data' => [ + [ + 'value' => [ + 'valueA' => 42, + 'valueB' => 1337, + ], + 'expected' => $resultB, + ], + ], + ]; + + yield 'constructors with same parameter name but second one is either float or integer' => [ + 'constructors' => [ + fn (int $valueA, float $valueB): stdClass => $resultA, + fn (int $valueA, int $valueB): stdClass => $resultB, + ], + 'data' => [ + 'integer and float' => [ + 'value' => [ + 'valueA' => 42, + 'valueB' => 1337.0, + ], + 'expected' => $resultA, + ], + 'integer and integer' => [ + 'value' => [ + 'valueA' => 42, + 'valueB' => 1337, + ], + 'expected' => $resultB, + ], + ], + ]; + + yield 'constructor with non scalar argument has priority over those with scalar (non scalar constructor is registered first)' => [ + 'constructors' => [ + fn (int $valueA, SimpleObject $valueB): stdClass => $resultA, + fn (int $valueA, string $valueB): stdClass => $resultB, + ], + 'data' => [ + [ + 'value' => [ + 'valueA' => 42, + 'valueB' => 'foo', + ], + 'expected' => $resultA, + ], + ], + ]; + + yield 'constructor with non scalar argument has priority over those with scalar (non scalar constructor is registered last)' => [ + 'constructors' => [ + fn (int $valueA, string $valueB): stdClass => $resultA, + fn (int $valueA, SimpleObject $valueB): stdClass => $resultB, + ], + 'data' => [ + [ + 'value' => [ + 'valueA' => 42, + 'valueB' => 'foo', + ], + 'expected' => $resultB, + ], + ], + ]; + } + public function test_identical_registered_constructors_with_no_argument_throws_exception(): void { $this->expectException(ObjectBuildersCollision::class); $this->expectExceptionCode(1654955787); - $this->expectExceptionMessageMatches('/A collision was detected between the following constructors of the class `stdClass`: `Closure .*`, `Closure .*`\./'); + $this->expectExceptionMessageMatches('/A type collision was detected between the constructors `Closure .*` and `Closure .*`\./'); $this->mapperBuilder() ->registerConstructor( @@ -495,12 +591,27 @@ public function test_identical_registered_constructors_with_one_argument_throws_ { $this->expectException(ObjectBuildersCollision::class); $this->expectExceptionCode(1654955787); - $this->expectExceptionMessageMatches('/A collision was detected between the following constructors of the class `stdClass`: `Closure .*`, `Closure .*`\./'); + $this->expectExceptionMessageMatches('/A type collision was detected between the constructors `Closure .*` and `Closure .*`\./'); $this->mapperBuilder() ->registerConstructor( fn (int $int): stdClass => new stdClass(), - fn (float $float): stdClass => new stdClass(), + fn (int $int): stdClass => new stdClass(), + ) + ->mapper() + ->map(stdClass::class, []); + } + + public function test_constructors_with_colliding_arguments_throws_exception(): void + { + $this->expectException(ObjectBuildersCollision::class); + $this->expectExceptionCode(1654955787); + $this->expectExceptionMessageMatches('/A type collision was detected between the constructors `Closure .*` and `Closure .*`\./'); + + $this->mapperBuilder() + ->registerConstructor( + fn (int $valueA, float $valueB): stdClass => new stdClass(), + fn (float $valueA, int $valueB): stdClass => new stdClass(), ) ->mapper() ->map(stdClass::class, []); @@ -510,7 +621,7 @@ public function test_identical_registered_constructors_with_several_argument_thr { $this->expectException(ObjectBuildersCollision::class); $this->expectExceptionCode(1654955787); - $this->expectExceptionMessage('A collision was detected between the following constructors of the class `stdClass`: `CuyZ\Valinor\Tests\Integration\Mapping\constructorA()`, `CuyZ\Valinor\Tests\Integration\Mapping\constructorB()`.'); + $this->expectExceptionMessage('A type collision was detected between the constructors `CuyZ\Valinor\Tests\Integration\Mapping\constructorA()` and `CuyZ\Valinor\Tests\Integration\Mapping\constructorB()`.'); $this->mapperBuilder() ->registerConstructor( @@ -522,6 +633,33 @@ public function test_identical_registered_constructors_with_several_argument_thr ->map(stdClass::class, []); } + public function test_non_intersecting_hashmap_type_constructors_do_not_lead_to_collisions(): void + { + $mapper = $this->mapperBuilder() + ->registerConstructor( + /** @param array{key: SimpleObject} $input */ + static fn (array $input): stdClass => (object)['single-item' => $input], + /** @param array{key: list} $input */ + static fn (array $input): stdClass => (object)['multiple-items' => $input], + ) + ->mapper(); + + $hello = new SimpleObject(); + $world = new SimpleObject(); + + $hello->value = 'hello'; + $world->value = 'world'; + + try { + self::assertEquals( + (object) ['multiple-items' => ['key' => [$hello, $world]]], + $mapper->map(stdClass::class, ['key' => ['hello', 'world']]) + ); + } catch (MappingError $error) { + $this->mappingFail($error); + } + } + public function test_source_not_matching_registered_constructors_throws_exception(): void { try { diff --git a/tests/Integration/Mapping/EnumValueOfMappingTest.php b/tests/Integration/Mapping/EnumValueOfMappingTest.php new file mode 100644 index 00000000..fd3875ec --- /dev/null +++ b/tests/Integration/Mapping/EnumValueOfMappingTest.php @@ -0,0 +1,94 @@ +mapperBuilder() + ->mapper() + ->map('value-of<' . SomeStringEnumForValueOf::class . '>', SomeStringEnumForValueOf::FOO->value); + } catch (MappingError $error) { + $this->mappingFail($error); + } + + self::assertSame(SomeStringEnumForValueOf::FOO->value, $result); + } + + public function test_can_map_value_of_enum_with_one_case(): void + { + try { + $result = $this->mapperBuilder() + ->mapper() + ->map('value-of<' . SomeStringEnumForValueOfWithOneCase::class . '>', SomeStringEnumForValueOf::FOO->value); + } catch (MappingError $error) { + $this->mappingFail($error); + } + + self::assertSame(SomeStringEnumForValueOf::FOO->value, $result); // @phpstan-ignore-line + } + + public function test_can_map_value_of_integer_enum(): void + { + try { + $result = $this->mapperBuilder() + ->mapper() + ->map('value-of<' . SomeIntegerEnumForValueOf::class . '>', SomeIntegerEnumForValueOf::FOO->value); + } catch (MappingError $error) { + $this->mappingFail($error); + } + + self::assertSame(SomeIntegerEnumForValueOf::FOO->value, $result); + } + + public function test_array_keys_using_value_of(): void + { + try { + $result = $this->mapperBuilder() + ->mapper() + ->map('array, string>', [SomeStringEnumForValueOf::FOO->value => 'foo']); + } catch (MappingError $error) { + $this->mappingFail($error); + } + + self::assertSame([SomeStringEnumForValueOf::FOO->value => 'foo'], $result); + } + + public function test_array_keys_using_value_of_error(): void + { + try { + $this->mapperBuilder() + ->mapper() + ->map('array, string>', ['oof' => 'foo']); + } catch (MappingError $exception) { + $error = $exception->node()->children()['oof']->messages()[0]; + self::assertSame("Key 'oof' does not match type `'FOO'|'FOZ'|'BAZ'`.", (string)$error); + } + } +} + +enum SomeStringEnumForValueOf: string +{ + case FOO = 'FOO'; + case FOZ = 'FOZ'; + case BAZ = 'BAZ'; +} + +enum SomeStringEnumForValueOfWithOneCase: string +{ + case FOO = 'FOO'; +} + +enum SomeIntegerEnumForValueOf: int +{ + case FOO = 42; + case FOZ = 404; + case BAZ = 1337; +} diff --git a/tests/Integration/Mapping/InterfaceInferringMappingTest.php b/tests/Integration/Mapping/InterfaceInferringMappingTest.php index a4a8457d..159ee3ab 100644 --- a/tests/Integration/Mapping/InterfaceInferringMappingTest.php +++ b/tests/Integration/Mapping/InterfaceInferringMappingTest.php @@ -393,7 +393,7 @@ public function test_superfluous_values_throws_exception(): void } catch (MappingError $exception) { $error = $exception->node()->messages()[0]; - self::assertSame('1655149208', $error->code()); + self::assertSame('1655117782', $error->code()); self::assertSame('Unexpected key(s) `superfluousValue`, expected `valueA`.', (string)$error); } } diff --git a/tests/Integration/Mapping/Object/ObjectValuesMappingTest.php b/tests/Integration/Mapping/Object/ObjectValuesMappingTest.php index 04681cb8..0fc8dcd2 100644 --- a/tests/Integration/Mapping/Object/ObjectValuesMappingTest.php +++ b/tests/Integration/Mapping/Object/ObjectValuesMappingTest.php @@ -59,7 +59,7 @@ public function test_superfluous_values_throws_exception_and_keeps_nested_errors $rootError = $exception->node()->messages()[0]; $nestedError = $exception->node()->children()['stringA']->messages()[0]; - self::assertSame('1655149208', $rootError->code()); + self::assertSame('1655117782', $rootError->code()); self::assertSame('Unexpected key(s) `unexpectedValueA`, `unexpectedValueB`, `42`, expected `stringA`, `stringB`.', (string)$rootError); self::assertSame('Value 42 is not a valid string.', (string)$nestedError); } diff --git a/tests/Integration/Mapping/UnionMappingTest.php b/tests/Integration/Mapping/UnionMappingTest.php index 8612d507..e840a98c 100644 --- a/tests/Integration/Mapping/UnionMappingTest.php +++ b/tests/Integration/Mapping/UnionMappingTest.php @@ -64,6 +64,15 @@ public static function union_mapping_works_properly_data_provider(): iterable 'assertion' => fn (mixed $result) => self::assertNull($result), ]; + yield 'nullable float with integer value' => [ + 'type' => 'float|null', + 'source' => 42, + 'assertion' => function (mixed $result) { + self::assertIsFloat($result); + self::assertEquals(42.0, $result); + }, + ]; + yield 'string or list of string, with string' => [ 'type' => 'string|list', 'source' => 'foo', diff --git a/tests/Integration/Normalizer/NormalizerTest.php b/tests/Integration/Normalizer/NormalizerTest.php index 7f3183fc..6599fd70 100644 --- a/tests/Integration/Normalizer/NormalizerTest.php +++ b/tests/Integration/Normalizer/NormalizerTest.php @@ -30,6 +30,7 @@ use function array_merge; +use const JSON_FORCE_OBJECT; use const JSON_HEX_TAG; use const JSON_THROW_ON_ERROR; @@ -194,6 +195,21 @@ public static function normalize_basic_values_yields_expected_output_data_provid 'expected json' => '{"foo":"foo","bar":"bar"}', ]; + yield 'list' => [ + 'input' => ['foo', 'bar'], + 'expected array' => ['foo', 'bar'], + 'expected json' => '["foo","bar"]', + ]; + + yield 'list kept as object in json' => [ + 'input' => ['foo', 'bar'], + 'expected array' => ['foo', 'bar'], + 'expected json' => '{"0":"foo","1":"bar"}', + [], + [], + JSON_FORCE_OBJECT + ]; + yield 'ArrayObject' => [ 'input' => new ArrayObject(['foo' => 'foo', 'bar' => 'bar']), 'expected array' => [ @@ -695,6 +711,32 @@ public function __construct( 'transformerAttributes' => [], 'jsonEncodingOptions' => JSON_HEX_AMP, ]; + + yield 'stdClass with no property' => [ + 'input' => new stdClass(), + 'expected array' => [], + 'expected_json' => '{}', + ]; + + yield 'ArrayObject with no property' => [ + 'input' => new ArrayObject(), + 'expected array' => [], + 'expected_json' => '{}', + ]; + + yield 'iterable class with no property' => [ + 'input' => new class () implements IteratorAggregate { + public function getIterator(): Traversable + { + // @phpstan-ignore-next-line / Empty array is here on purpose + foreach ([] as $value) { + yield $value; + } + } + }, + 'expected array' => [], + 'expected_json' => '{}', + ]; } public function test_generator_of_scalar_yields_expected_array(): void @@ -979,6 +1021,23 @@ public function test_second_param_in_transformer_is_not_callable_throws_exceptio ->normalize(new stdClass()); } + public function test_second_param_in_transformer_is_callable_with_phpdoc_spec_does_not_throw(): void + { + $class = new class () { + /** @param callable():mixed $next */ + public function __invoke(stdClass $object, callable $next): int + { + return 42; + } + }; + $this->mapperBuilder() + ->registerTransformer($class) + ->normalizer(Format::array()) + ->normalize(new stdClass()); + + self::addToAssertionCount(1); + } + public function test_no_param_in_transformer_attribute_throws_exception(): void { $this->expectException(TransformerHasNoParameter::class); @@ -1105,16 +1164,13 @@ public function test_json_transformer_will_always_throw_on_error(): void public function test_json_transformer_only_accepts_acceptable_json_options(): void { - $normalizer = $this->mapperBuilder()->normalizer(Format::json())->withOptions(JSON_FORCE_OBJECT); - self::assertSame(JSON_THROW_ON_ERROR, (fn () => $this->jsonEncodingOptions)->call($normalizer)); - $normalizer = $this->mapperBuilder()->normalizer(Format::json())->withOptions(JSON_PARTIAL_OUTPUT_ON_ERROR); self::assertSame(JSON_THROW_ON_ERROR, (fn () => $this->jsonEncodingOptions)->call($normalizer)); $normalizer = $this->mapperBuilder()->normalizer(Format::json())->withOptions(JSON_PRETTY_PRINT); self::assertSame(JSON_THROW_ON_ERROR, (fn () => $this->jsonEncodingOptions)->call($normalizer)); - $normalizer = $this->mapperBuilder()->normalizer(Format::json())->withOptions(JSON_FORCE_OBJECT | JSON_PARTIAL_OUTPUT_ON_ERROR | JSON_PRETTY_PRINT); + $normalizer = $this->mapperBuilder()->normalizer(Format::json())->withOptions(JSON_PARTIAL_OUTPUT_ON_ERROR | JSON_PRETTY_PRINT); self::assertSame(JSON_THROW_ON_ERROR, (fn () => $this->jsonEncodingOptions)->call($normalizer)); } } diff --git a/tests/Unit/Mapper/Tree/Builder/ArrayNodeBuilderTest.php b/tests/Unit/Mapper/Tree/Builder/ArrayNodeBuilderTest.php index d5510b5a..71720616 100644 --- a/tests/Unit/Mapper/Tree/Builder/ArrayNodeBuilderTest.php +++ b/tests/Unit/Mapper/Tree/Builder/ArrayNodeBuilderTest.php @@ -5,6 +5,7 @@ namespace CuyZ\Valinor\Tests\Unit\Mapper\Tree\Builder; use AssertionError; +use CuyZ\Valinor\Library\Settings; use CuyZ\Valinor\Mapper\Tree\Builder\ArrayNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder; use CuyZ\Valinor\Tests\Fake\Mapper\FakeShell; @@ -16,7 +17,12 @@ final class ArrayNodeBuilderTest extends TestCase { public function test_build_with_null_value_in_flexible_mode_returns_empty_branch_node(): void { - $node = (new RootNodeBuilder(new ArrayNodeBuilder(true)))->build(FakeShell::new(ArrayType::native())); + $setting = new Settings(); + $setting->enableFlexibleCasting = true; + + $shell = FakeShell::new(ArrayType::native(), settings: $setting); + + $node = (new RootNodeBuilder(new ArrayNodeBuilder()))->build($shell); self::assertSame([], $node->value()); self::assertEmpty($node->node()->children()); @@ -26,6 +32,6 @@ public function test_invalid_type_fails_assertion(): void { $this->expectException(AssertionError::class); - (new RootNodeBuilder(new ArrayNodeBuilder(true)))->build(FakeShell::new(new FakeType())); + (new RootNodeBuilder(new ArrayNodeBuilder()))->build(FakeShell::new(new FakeType())); } } diff --git a/tests/Unit/Mapper/Tree/Builder/ListNodeBuilderTest.php b/tests/Unit/Mapper/Tree/Builder/ListNodeBuilderTest.php index 17cf660f..312b7ee2 100644 --- a/tests/Unit/Mapper/Tree/Builder/ListNodeBuilderTest.php +++ b/tests/Unit/Mapper/Tree/Builder/ListNodeBuilderTest.php @@ -5,6 +5,7 @@ namespace CuyZ\Valinor\Tests\Unit\Mapper\Tree\Builder; use AssertionError; +use CuyZ\Valinor\Library\Settings; use CuyZ\Valinor\Mapper\Tree\Builder\ListNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder; use CuyZ\Valinor\Tests\Fake\Mapper\FakeShell; @@ -16,7 +17,12 @@ final class ListNodeBuilderTest extends TestCase { public function test_build_with_null_value_in_flexible_mode_returns_empty_branch_node(): void { - $node = (new RootNodeBuilder(new ListNodeBuilder(true)))->build(FakeShell::new(ListType::native())); + $setting = new Settings(); + $setting->enableFlexibleCasting = true; + + $shell = FakeShell::new(ListType::native(), settings: $setting); + + $node = (new RootNodeBuilder(new ListNodeBuilder()))->build($shell); self::assertSame([], $node->value()); self::assertEmpty($node->node()->children()); @@ -26,6 +32,6 @@ public function test_invalid_type_fails_assertion(): void { $this->expectException(AssertionError::class); - (new RootNodeBuilder(new ListNodeBuilder(true)))->build(FakeShell::new(new FakeType())); + (new RootNodeBuilder(new ListNodeBuilder()))->build(FakeShell::new(new FakeType())); } } diff --git a/tests/Unit/Mapper/Tree/Builder/ScalarNodeBuilderTest.php b/tests/Unit/Mapper/Tree/Builder/ScalarNodeBuilderTest.php index ebdc268f..e1785ff7 100644 --- a/tests/Unit/Mapper/Tree/Builder/ScalarNodeBuilderTest.php +++ b/tests/Unit/Mapper/Tree/Builder/ScalarNodeBuilderTest.php @@ -16,6 +16,6 @@ public function test_invalid_type_fails_assertion(): void { $this->expectException(AssertionError::class); - (new RootNodeBuilder(new ScalarNodeBuilder(true)))->build(FakeShell::any()); + (new RootNodeBuilder(new ScalarNodeBuilder()))->build(FakeShell::any()); } } diff --git a/tests/Unit/Mapper/Tree/Builder/ShapedArrayNodeBuilderTest.php b/tests/Unit/Mapper/Tree/Builder/ShapedArrayNodeBuilderTest.php index 9c8d6cce..71b2cc56 100644 --- a/tests/Unit/Mapper/Tree/Builder/ShapedArrayNodeBuilderTest.php +++ b/tests/Unit/Mapper/Tree/Builder/ShapedArrayNodeBuilderTest.php @@ -21,7 +21,7 @@ public function test_invalid_type_fails_assertion(): void { $this->expectException(AssertionError::class); - (new RootNodeBuilder(new ShapedArrayNodeBuilder(true)))->build(FakeShell::any()); + (new RootNodeBuilder(new ShapedArrayNodeBuilder()))->build(FakeShell::any()); } public function test_build_with_null_source_throws_exception(): void @@ -32,6 +32,6 @@ public function test_build_with_null_source_throws_exception(): void $this->expectExceptionCode(1618739163); $this->expectExceptionMessage("Cannot be empty and must be filled with a value matching type `array{foo: SomeType}`."); - (new RootNodeBuilder(new ShapedArrayNodeBuilder(true)))->build(FakeShell::new($type)); + (new RootNodeBuilder(new ShapedArrayNodeBuilder()))->build(FakeShell::new($type)); } } diff --git a/tests/Unit/Mapper/Tree/Builder/TreeNodeTest.php b/tests/Unit/Mapper/Tree/Builder/TreeNodeTest.php index e9bfb4c7..fc329e34 100644 --- a/tests/Unit/Mapper/Tree/Builder/TreeNodeTest.php +++ b/tests/Unit/Mapper/Tree/Builder/TreeNodeTest.php @@ -6,6 +6,7 @@ use AssertionError; use CuyZ\Valinor\Definition\Attributes; +use CuyZ\Valinor\Library\Settings; use CuyZ\Valinor\Mapper\Tree\Builder\TreeNode; use CuyZ\Valinor\Mapper\Tree\Shell; use CuyZ\Valinor\Tests\Fake\Mapper\Tree\Builder\FakeTreeNode; @@ -22,7 +23,7 @@ public function test_node_leaf_values_can_be_retrieved(): void { $type = FakeType::permissive(); - $shell = Shell::root($type, 'some source value'); + $shell = Shell::root(new Settings(), $type, 'some source value'); $node = TreeNode::leaf($shell, 'some value')->node(); self::assertTrue($node->isRoot()); diff --git a/tests/Unit/Mapper/Tree/ShellTest.php b/tests/Unit/Mapper/Tree/ShellTest.php index 40571784..a8b243c1 100644 --- a/tests/Unit/Mapper/Tree/ShellTest.php +++ b/tests/Unit/Mapper/Tree/ShellTest.php @@ -5,6 +5,7 @@ namespace CuyZ\Valinor\Tests\Unit\Mapper\Tree; use CuyZ\Valinor\Definition\Attributes; +use CuyZ\Valinor\Library\Settings; use CuyZ\Valinor\Mapper\Tree\Shell; use CuyZ\Valinor\Tests\Fake\Type\FakeType; use PHPUnit\Framework\TestCase; @@ -16,7 +17,7 @@ public function test_type_and_value_can_be_retrieved(): void $type = new FakeType(); $value = 'foo'; - $shell = Shell::root($type, $value); + $shell = Shell::root(new Settings(), $type, $value); self::assertSame($type, $shell->type()); self::assertSame($value, $shell->value()); @@ -24,7 +25,7 @@ public function test_type_and_value_can_be_retrieved(): void public function test_root_path_is_fixed(): void { - $shell = Shell::root(new FakeType(), 'foo'); + $shell = Shell::root(new Settings(), new FakeType(), 'foo'); self::assertSame('*root*', $shell->path()); } @@ -34,19 +35,28 @@ public function test_change_type_changes_type(): void $typeA = new FakeType(); $typeB = FakeType::matching($typeA); - $shellA = Shell::root($typeA, []); + $shellA = Shell::root(new Settings(), $typeA, []); $shellB = $shellA->withType($typeB); self::assertNotSame($shellA, $shellB); self::assertSame($typeB, $shellB->type()); } + public function test_allows_superfluous_keys(): void + { + $shellA = Shell::root(new Settings(), new FakeType(), []); + $shellB = $shellA->withAllowedSuperfluousKeys(['foo', 'bar']); + + self::assertNotSame($shellA, $shellB); + self::assertSame(['foo', 'bar'], $shellB->allowedSuperfluousKeys()); + } + public function test_change_value_changes_value(): void { $valueA = 'foo'; $valueB = 'bar'; - $shellA = Shell::root(new FakeType(), $valueA); + $shellA = Shell::root(new Settings(), new FakeType(), $valueA); $shellB = $shellA->withValue($valueB); self::assertNotSame($shellA, $shellB); @@ -55,7 +65,7 @@ public function test_change_value_changes_value(): void public function test_root_shell_is_root(): void { - $shell = Shell::root(new FakeType(), []); + $shell = Shell::root(new Settings(), new FakeType(), []); self::assertTrue($shell->isRoot()); self::assertSame('', $shell->name()); @@ -67,7 +77,7 @@ public function test_shell_child_values_can_be_retrieved(): void $type = FakeType::permissive(); $attributes = new Attributes(); - $shell = Shell::root(new FakeType(), []); + $shell = Shell::root(new Settings(), new FakeType(), []); $child = $shell->child('foo', $type, $attributes)->withValue($value); self::assertSame('foo', $child->name()); diff --git a/tests/Unit/Mapper/TypeTreeMapperTest.php b/tests/Unit/Mapper/TypeTreeMapperTest.php index c31117dc..ba9c6db9 100644 --- a/tests/Unit/Mapper/TypeTreeMapperTest.php +++ b/tests/Unit/Mapper/TypeTreeMapperTest.php @@ -4,6 +4,7 @@ namespace CuyZ\Valinor\Tests\Unit\Mapper; +use CuyZ\Valinor\Library\Settings; use CuyZ\Valinor\Mapper\Exception\InvalidMappingTypeSignature; use CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder; use CuyZ\Valinor\Mapper\TypeTreeMapper; @@ -22,6 +23,7 @@ protected function setUp(): void $this->mapper = new TypeTreeMapper( new FakeTypeParser(), new RootNodeBuilder(new FakeNodeBuilder()), + new Settings(), ); } diff --git a/tests/Unit/Type/Types/NativeFloatTypeTest.php b/tests/Unit/Type/Types/NativeFloatTypeTest.php index 8a59298a..fe1623cd 100644 --- a/tests/Unit/Type/Types/NativeFloatTypeTest.php +++ b/tests/Unit/Type/Types/NativeFloatTypeTest.php @@ -30,13 +30,13 @@ protected function setUp(): void public function test_accepts_correct_values(): void { self::assertTrue($this->floatType->accepts(42.1337)); - self::assertTrue($this->floatType->accepts(404)); } public function test_does_not_accept_incorrect_values(): void { self::assertFalse($this->floatType->accepts(null)); self::assertFalse($this->floatType->accepts('Schwifty!')); + self::assertFalse($this->floatType->accepts(404)); self::assertFalse($this->floatType->accepts(['foo' => 'bar'])); self::assertFalse($this->floatType->accepts(false)); self::assertFalse($this->floatType->accepts(new stdClass())); diff --git a/tests/Unit/Utility/TypeHelperTest.php b/tests/Unit/Utility/TypeHelperTest.php index f509b1cd..75a8b5f9 100644 --- a/tests/Unit/Utility/TypeHelperTest.php +++ b/tests/Unit/Utility/TypeHelperTest.php @@ -9,12 +9,26 @@ use CuyZ\Valinor\Tests\Fake\Definition\FakeParameterDefinition; use CuyZ\Valinor\Tests\Fake\Type\FakeObjectType; use CuyZ\Valinor\Tests\Fake\Type\FakeType; +use CuyZ\Valinor\Type\Types\ArrayType; +use CuyZ\Valinor\Type\Types\NativeBooleanType; +use CuyZ\Valinor\Type\Types\NativeFloatType; +use CuyZ\Valinor\Type\Types\NativeIntegerType; +use CuyZ\Valinor\Type\Types\NativeStringType; use CuyZ\Valinor\Type\Types\UnionType; use CuyZ\Valinor\Utility\TypeHelper; use PHPUnit\Framework\TestCase; final class TypeHelperTest extends TestCase { + public function test_types_have_correct_priorities(): void + { + self::assertSame(4, TypeHelper::typePriority(NativeIntegerType::get())); + self::assertSame(3, TypeHelper::typePriority(NativeFloatType::get())); + self::assertSame(2, TypeHelper::typePriority(NativeStringType::get())); + self::assertSame(1, TypeHelper::typePriority(NativeBooleanType::get())); + self::assertSame(0, TypeHelper::typePriority(ArrayType::native())); + } + public function test_arguments_dump_is_correct(): void { $typeA = FakeType::permissive();