diff --git a/src/Library/Container.php b/src/Library/Container.php index f453fc43..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; @@ -116,7 +115,6 @@ public function __construct(Settings $settings) ObjectType::class => new ObjectNodeBuilder( $this->get(ClassDefinitionRepository::class), $this->get(ObjectBuilderFactory::class), - $this->get(FilteredObjectNodeBuilder::class), ), ]); @@ -126,8 +124,6 @@ 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 @@ -152,8 +148,6 @@ public function __construct(Settings $settings) return new ErrorCatcherNodeBuilder($builder, $settings->exceptionFilter); }, - FilteredObjectNodeBuilder::class => fn () => new FilteredObjectNodeBuilder(), - ObjectImplementations::class => fn () => new ObjectImplementations( new FunctionsContainer( $this->get(FunctionDefinitionRepository::class), @@ -172,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 407112d9..9b595de3 100644 --- a/src/Mapper/Object/ArgumentsValues.php +++ b/src/Mapper/Object/ArgumentsValues.php @@ -4,7 +4,6 @@ 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; @@ -27,6 +26,8 @@ final class ArgumentsValues implements IteratorAggregate private Arguments $arguments; + private bool $hasInvalidValue = false; + private bool $forInterface = false; private bool $hadSingleArgument = false; @@ -42,7 +43,7 @@ public static function forInterface(Arguments $arguments, Shell $shell): self $self->forInterface = true; if (count($arguments) > 0) { - $self = $self->transform($shell); + $self->transform($shell); } return $self; @@ -51,11 +52,16 @@ public static function forInterface(Arguments $arguments, Shell $shell): self public static function forClass(Arguments $arguments, Shell $shell): self { $self = new self($arguments); - $self = $self->transform($shell); + $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); @@ -71,18 +77,18 @@ public function hadSingleArgument(): bool return $this->hadSingleArgument; } - private function transform(Shell $shell): self + private function transform(Shell $shell): void { - $clone = clone $this; - $transformedValue = $this->transformValueForSingleArgument($shell); if (! is_array($transformedValue)) { - throw new InvalidSource($transformedValue, $this->arguments); + $this->hasInvalidValue = true; + + return; } if ($transformedValue !== $shell->value()) { - $clone->hadSingleArgument = true; + $this->hadSingleArgument = true; } foreach ($this->arguments as $argument) { @@ -93,9 +99,7 @@ private function transform(Shell $shell): self } } - $clone->value = $transformedValue; - - return $clone; + $this->value = $transformedValue; } private function transformValueForSingleArgument(Shell $shell): mixed 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/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 5eb70e07..00000000 --- a/src/Mapper/Tree/Builder/FilteredObjectNodeBuilder.php +++ /dev/null @@ -1,68 +0,0 @@ -describeArguments(), $shell); - - $children = $this->children($shell, $arguments, $rootBuilder); - - $object = $this->buildObject($builder, $children); - - return $arguments->hadSingleArgument() - ? TreeNode::flattenedBranch($shell, $object, $children[0]) - : TreeNode::branch($shell, $object, $children); - } - - /** - * @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 73c9b7e2..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,8 +26,6 @@ public function __construct( private NodeBuilder $delegate, private ObjectImplementations $implementations, private ClassDefinitionRepository $classDefinitionRepository, - private ObjectBuilderFactory $objectBuilderFactory, - private FilteredObjectNodeBuilder $filteredObjectNodeBuilder, private FunctionsContainer $constructors, ) {} @@ -69,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 = []; @@ -87,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 @@ -106,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); - $children = []; foreach ($arguments as $argument) { diff --git a/src/Mapper/Tree/Builder/ObjectNodeBuilder.php b/src/Mapper/Tree/Builder/ObjectNodeBuilder.php index 007de11a..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,7 +22,6 @@ final class ObjectNodeBuilder implements NodeBuilder public function __construct( private ClassDefinitionRepository $classDefinitionRepository, private ObjectBuilderFactory $objectBuilderFactory, - private FilteredObjectNodeBuilder $filteredObjectNodeBuilder, ) {} public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode @@ -34,8 +36,77 @@ public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode } $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/ShapedArrayNodeBuilder.php b/src/Mapper/Tree/Builder/ShapedArrayNodeBuilder.php index e270f36a..fb0448ff 100644 --- a/src/Mapper/Tree/Builder/ShapedArrayNodeBuilder.php +++ b/src/Mapper/Tree/Builder/ShapedArrayNodeBuilder.php @@ -30,7 +30,10 @@ public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode $array = $this->buildArray($children); - return TreeNode::branch($shell, $array, $children); + $node = TreeNode::branch($shell, $array, $children); + $node = $node->checkUnexpectedKeys(); + + return $node; } /** diff --git a/src/Mapper/Tree/Builder/TreeNode.php b/src/Mapper/Tree/Builder/TreeNode.php index 3b37d5ee..afc72587 100644 --- a/src/Mapper/Tree/Builder/TreeNode.php +++ b/src/Mapper/Tree/Builder/TreeNode.php @@ -12,9 +12,10 @@ use CuyZ\Valinor\Type\Type; use Throwable; +use function array_diff; +use function array_keys; use function array_map; use function assert; -use function count; use function is_array; /** @internal */ @@ -130,6 +131,23 @@ 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) { @@ -138,17 +156,8 @@ private function check(): void } } - $value = $this->shell->value(); $type = $this->shell->type(); - if (! $this->shell->allowSuperfluousKeys() - && is_array($value) - && count($value) > count($this->children) - ) { - $this->valid = false; - $this->messages[] = new UnexpectedKeysInSource($value, $this->children); - } - if ($this->valid && ! $type->accepts($this->value)) { $this->valid = false; $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/Shell.php b/src/Mapper/Tree/Shell.php index 32c2d131..cec31b4d 100644 --- a/src/Mapper/Tree/Shell.php +++ b/src/Mapper/Tree/Shell.php @@ -32,6 +32,9 @@ final class Shell private self $parent; + /** @var list */ + private array $allowedSuperfluousKeys = []; + private function __construct(Settings $settings, Type $type) { if ($type instanceof UnresolvableType) { @@ -135,6 +138,25 @@ 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)) { 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/Integration/Mapping/ConstructorRegistrationMappingTest.php b/tests/Integration/Mapping/ConstructorRegistrationMappingTest.php index 4e157da8..9282923f 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( diff --git a/tests/Unit/Mapper/Tree/ShellTest.php b/tests/Unit/Mapper/Tree/ShellTest.php index 96cb70b0..a8b243c1 100644 --- a/tests/Unit/Mapper/Tree/ShellTest.php +++ b/tests/Unit/Mapper/Tree/ShellTest.php @@ -42,6 +42,15 @@ public function test_change_type_changes_type(): void 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'; 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();