Skip to content

Commit

Permalink
feat: improve union type narrowing during mapping
Browse files Browse the repository at this point in the history
The algorithm used by the mapper to narrow a union type has been greatly
improved, and should cover more edge-cases that would previously prevent
the mapper from performing well.

If an interface, a class or a shaped array is matched by the input, it
will take precedence over arrays or scalars.

```php
(new \CuyZ\Valinor\MapperBuilder())
    ->mapper()
    ->map(
        signature: 'array<int>|' . Color::class,
        source: [
            'red' => 255,
            'green' => 128,
            'blue' => 64,
        ],
    ); // Returns an instance of `Color`
```

When superfluous keys are allowed, if the input matches several
interfaces, classes or shaped array, the one with the most children node
will be prioritized, as it is considered the most specific type:

```php
(new \CuyZ\Valinor\MapperBuilder())
    ->allowSuperfluousKeys()
    ->mapper()
    ->map(
        // Even if the first shaped array matches the input, the second one is
        // used because it's more specific.
        signature: 'array{foo: int}|array{foo: int, bar: int}',
        source: [
            'foo' => 42,
            'bar' => 1337,
        ],
    );
```

If the input matches several types within the union, a collision will
occur and cause the mapper to fail:

```php
(new \CuyZ\Valinor\MapperBuilder())
    ->mapper()
    ->map(
        // Even if the first shaped array matches the input, the second one is
        // used because it's more specific.
        signature: 'array{red: int, green: int, blue: int}|' . Color::class,
        source: [
            'red' => 255,
            'green' => 128,
            'blue' => 64,
        ],
    );

// ⚠️ Invalid value array{red: 255, green: 128, blue: 64}, it matches at
//    least two types from union.
```
  • Loading branch information
romm committed Mar 17, 2024
1 parent 86d021a commit f731586
Show file tree
Hide file tree
Showing 15 changed files with 581 additions and 127 deletions.
4 changes: 2 additions & 2 deletions docs/pages/usage/type-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,10 @@ final class SomeClass
private array $unionInsideArray,

/** @var int|true */
private int|bool $unionWithLiteralTrueType;
private int|bool $unionWithLiteralTrueType,

/** @var int|false */
private int|bool $unionWithLiteralFalseType;
private int|bool $unionWithLiteralFalseType,

/** @var 404.42|1337.42 */
private float $unionOfFloatValues,
Expand Down
11 changes: 4 additions & 7 deletions src/Library/Container.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
use CuyZ\Valinor\Mapper\Tree\Builder\ListNodeBuilder;
use CuyZ\Valinor\Mapper\Tree\Builder\NativeClassNodeBuilder;
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\ObjectNodeBuilder;
use CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder;
Expand Down Expand Up @@ -63,6 +64,7 @@
use CuyZ\Valinor\Type\Types\ListType;
use CuyZ\Valinor\Type\Types\NonEmptyArrayType;
use CuyZ\Valinor\Type\Types\NonEmptyListType;
use CuyZ\Valinor\Type\Types\NullType;
use CuyZ\Valinor\Type\Types\ShapedArrayType;
use Psr\SimpleCache\CacheInterface;

Expand Down Expand Up @@ -108,6 +110,7 @@ public function __construct(Settings $settings)
IterableType::class => $arrayNodeBuilder,
ShapedArrayType::class => new ShapedArrayNodeBuilder($settings->allowSuperfluousKeys),
ScalarType::class => new ScalarNodeBuilder($settings->enableFlexibleCasting),
NullType::class => new NullNodeBuilder(),
ClassType::class => new NativeClassNodeBuilder(
$this->get(ClassDefinitionRepository::class),
$this->get(ObjectBuilderFactory::class),
Expand All @@ -116,13 +119,7 @@ public function __construct(Settings $settings)
),
]);

$builder = new UnionNodeBuilder(
$builder,
$this->get(ClassDefinitionRepository::class),
$this->get(ObjectBuilderFactory::class),
$this->get(ObjectNodeBuilder::class),
$settings->enableFlexibleCasting
);
$builder = new UnionNodeBuilder($builder);

$builder = new InterfaceNodeBuilder(
$builder,
Expand Down
16 changes: 11 additions & 5 deletions src/Mapper/Object/FilteredObjectBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,23 @@ final class FilteredObjectBuilder implements ObjectBuilder
{
private ObjectBuilder $delegate;

private Arguments $arguments;

public function __construct(mixed $source, ObjectBuilder ...$builders)
private function __construct(mixed $source, ObjectBuilder ...$builders)
{
$this->delegate = $this->filterBuilder($source, ...$builders);
$this->arguments = $this->delegate->describeArguments();
}

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->arguments;
return $this->delegate->describeArguments();
}

public function build(array $arguments): object
Expand Down
23 changes: 22 additions & 1 deletion src/Mapper/Tree/Builder/CasterProxyNodeBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
namespace CuyZ\Valinor\Mapper\Tree\Builder;

use CuyZ\Valinor\Mapper\Tree\Shell;
use CuyZ\Valinor\Type\CompositeTraversableType;
use CuyZ\Valinor\Type\Type;
use CuyZ\Valinor\Type\Types\ShapedArrayType;
use CuyZ\Valinor\Type\Types\UnionType;

/** @internal */
final class CasterProxyNodeBuilder implements NodeBuilder
Expand All @@ -16,11 +20,28 @@ public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode
if ($shell->hasValue()) {
$value = $shell->value();

if ($shell->type()->accepts($value)) {
if ($this->typeAcceptsValue($shell->type(), $value)) {
return TreeNode::leaf($shell, $value);
}
}

return $this->delegate->build($shell, $rootBuilder);
}

private function typeAcceptsValue(Type $type, mixed $value): bool
{
if ($type instanceof UnionType) {
foreach ($type->types() as $subType) {
if ($this->typeAcceptsValue($subType, $value)) {
return true;
}
}

return false;
}

return ! $type instanceof CompositeTraversableType
&& ! $type instanceof ShapedArrayType
&& $type->accepts($value);
}
}
2 changes: 1 addition & 1 deletion src/Mapper/Tree/Builder/InterfaceNodeBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode
}

$class = $this->classDefinitionRepository->for($classType);
$objectBuilder = new FilteredObjectBuilder($shell->value(), ...$this->objectBuilderFactory->for($class));
$objectBuilder = FilteredObjectBuilder::from($shell->value(), ...$this->objectBuilderFactory->for($class));

$shell = $this->transformSourceForClass($shell, $arguments, $objectBuilder->describeArguments());

Expand Down
2 changes: 1 addition & 1 deletion src/Mapper/Tree/Builder/NativeClassNodeBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode
}

$class = $this->classDefinitionRepository->for($type);
$objectBuilder = new FilteredObjectBuilder($shell->value(), ...$this->objectBuilderFactory->for($class));
$objectBuilder = FilteredObjectBuilder::from($shell->value(), ...$this->objectBuilderFactory->for($class));

return $this->objectNodeBuilder->build($objectBuilder, $shell, $rootBuilder);
}
Expand Down
29 changes: 29 additions & 0 deletions src/Mapper/Tree/Builder/NullNodeBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace CuyZ\Valinor\Mapper\Tree\Builder;

use CuyZ\Valinor\Mapper\Tree\Exception\SourceIsNotNull;
use CuyZ\Valinor\Mapper\Tree\Shell;
use CuyZ\Valinor\Type\Types\NullType;

use function assert;

/** @internal */
final class NullNodeBuilder implements NodeBuilder
{
public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode
{
$type = $shell->type();
$value = $shell->value();

assert($type instanceof NullType);

if ($value !== null) {
throw new SourceIsNotNull();
}

return TreeNode::leaf($shell, null);
}
}
14 changes: 14 additions & 0 deletions src/Mapper/Tree/Builder/TreeNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
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_map;
Expand Down Expand Up @@ -87,6 +88,19 @@ public function name(): string
return $this->shell->name();
}

public function type(): Type
{
return $this->shell->type();
}

/**
* @return array<self>
*/
public function children(): array
{
return $this->children;
}

public function isValid(): bool
{
return $this->valid;
Expand Down
128 changes: 65 additions & 63 deletions src/Mapper/Tree/Builder/UnionNodeBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,26 @@

namespace CuyZ\Valinor\Mapper\Tree\Builder;

use CuyZ\Valinor\Definition\Repository\ClassDefinitionRepository;
use CuyZ\Valinor\Mapper\Object\Factory\ObjectBuilderFactory;
use CuyZ\Valinor\Mapper\Object\FilteredObjectBuilder;
use CuyZ\Valinor\Mapper\Object\ObjectBuilder;
use CuyZ\Valinor\Mapper\Tree\Exception\CannotResolveTypeFromUnion;
use CuyZ\Valinor\Mapper\Tree\Exception\TooManyResolvedTypesFromUnion;
use CuyZ\Valinor\Mapper\Tree\Shell;
use CuyZ\Valinor\Type\ScalarType;
use CuyZ\Valinor\Type\Type;
use CuyZ\Valinor\Type\ClassType;
use CuyZ\Valinor\Type\Types\NullType;
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\ShapedArrayType;
use CuyZ\Valinor\Type\Types\UnionType;

use function count;
use function krsort;
use function reset;

/** @internal */
final class UnionNodeBuilder implements NodeBuilder
{
public function __construct(
private NodeBuilder $delegate,
private ClassDefinitionRepository $classDefinitionRepository,
private ObjectBuilderFactory $objectBuilderFactory,
private ObjectNodeBuilder $objectNodeBuilder,
private bool $enableFlexibleCasting
) {}
public function __construct(private NodeBuilder $delegate) {}

public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode
{
Expand All @@ -37,72 +33,78 @@ public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode
return $this->delegate->build($shell, $rootBuilder);
}

$classNode = $this->tryToBuildClassNode($type, $shell, $rootBuilder);

if ($classNode instanceof TreeNode) {
return $classNode;
}
$structs = [];
$scalars = [];
$all = [];

$narrowedType = $this->narrow($type, $shell->value());
foreach ($type->types() as $subType) {
$node = $rootBuilder->build($shell->withType($subType));

return $rootBuilder->build($shell->withType($narrowedType));
}

private function narrow(UnionType $type, mixed $source): Type
{
$subTypes = $type->types();

if ($source !== null && count($subTypes) === 2) {
if ($subTypes[0] instanceof NullType) {
return $subTypes[1];
} elseif ($subTypes[1] instanceof NullType) {
return $subTypes[0];
}
}

foreach ($subTypes as $subType) {
if (! $subType instanceof ScalarType) {
if (! $node->isValid()) {
continue;
}

if (! $this->enableFlexibleCasting) {
continue;
}
$all[] = $node;

if ($subType->canCast($source)) {
return $subType;
if ($subType instanceof InterfaceType || $subType instanceof ClassType || $subType instanceof ShapedArrayType) {
$structs[] = $node;
} elseif ($subType instanceof ScalarType) {
$scalars[] = $node;
}
}

throw new CannotResolveTypeFromUnion($source, $type);
}

private function tryToBuildClassNode(UnionType $type, Shell $shell, RootNodeBuilder $rootBuilder): ?TreeNode
{
$classTypes = array_filter(
$type->types(),
fn (Type $type) => $type instanceof ClassType,
);
if ($all === []) {
throw new CannotResolveTypeFromUnion($shell->value(), $type);
}

if (count($classTypes) === 0) {
return null;
if (count($all) === 1) {
return $all[0];
}

$objectBuilder = $this->objectBuilder($shell->value(), ...$classTypes);
if ($structs !== []) {
// Structs can be either an interface, a class or a shaped array.
// We prioritize the one with the most children, as it's the most
// specific type. If there are multiple types with the same number
// of children, we consider it as a collision.
$childrenCount = [];

return $this->objectNodeBuilder->build($objectBuilder, $shell, $rootBuilder);
}
foreach ($structs as $node) {
$childrenCount[count($node->children())][] = $node;
}

private function objectBuilder(mixed $value, ClassType ...$types): ObjectBuilder
{
$builders = [];
krsort($childrenCount);

$first = reset($childrenCount);

if (count($first) === 1) {
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;
}
}

foreach ($types as $type) {
$class = $this->classDefinitionRepository->for($type);
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];
}

$builders = [...$builders, ...$this->objectBuilderFactory->for($class)];
// @infection-ignore-all / We know this is a boolean, so we don't need to mutate the index
return $scalars[0];
}

return new FilteredObjectBuilder($value, ...$builders);
throw new TooManyResolvedTypesFromUnion($type);
}
}
26 changes: 26 additions & 0 deletions src/Mapper/Tree/Exception/SourceIsNotNull.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace CuyZ\Valinor\Mapper\Tree\Exception;

use CuyZ\Valinor\Mapper\Tree\Message\ErrorMessage;
use RuntimeException;

/** @internal */
final class SourceIsNotNull extends RuntimeException implements ErrorMessage
{
private string $body;

public function __construct()
{
$this->body = 'Value {source_value} is not null.';

parent::__construct($this->body, 1710263908);
}

public function body(): string
{
return $this->body;
}
}
Loading

0 comments on commit f731586

Please sign in to comment.