Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: improve union type narrowing during mapping #493

Merged
merged 5 commits into from
Mar 17, 2024

Conversation

romm
Copy link
Member

@romm romm commented Mar 12, 2024

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.

(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:

(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:

(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.

Fixes #395
Fixes #356
Fixes #385
Closes #487
Closes #495

Copy link
Contributor

@Ocramius Ocramius left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, but please consider rebasing on top of #487 to preserve research/history

docs/pages/usage/type-reference.md Show resolved Hide resolved
$this->arguments = $this->delegate->describeArguments();
}

public static function from(mixed $source, ObjectBuilder ...$builders): ObjectBuilder
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/from/mix, perhaps? You seem to aggregate multiple builders?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It actually filters builders depending on the input, so that only one remains at the end. I added this static constructor to check if there is only one builder, in which case it can be returned as is and does not need the whole filtering process.

Comment on lines +43 to +44
return ! $type instanceof CompositeTraversableType
&& ! $type instanceof ShapedArrayType
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excluded on purpose? 🤔

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From a reader's perspective, it's not 100% clear why these checks are in place: shouldn't CompositeTraversableType and ShapedArrayType also have accepts()?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a tricky one, and I'm still not convinced this class is at the correct place.

The goal was to save resources by checking if the input is accepted by the current node's type, before trying casting. It led to issues during development of this PR, so I adapted it, but I think I need to find a better way to check whether the input is valid or not. I wrote it in my internal notes, I'll try to improve it in the future.

];

$this->body = TypeHelper::containsObject($unionType)
? 'Invalid value {source_value}, it matches at least two types from union.'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! We should probably add "couldn't take a decision" or such?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What sentence would you have in mind?

FYI the TypeHelper::containsObject is there because right now the system cannot display a meaningful representation of an object constructor. This is because an object might have several constructors, for instance when taking the Color class from the docs, its representation should be non-empty-string|array{red: int<0, 255>, green: int<0, 255>, blue: int<0, 255>}. I have plans to improve it, but this will be in a separate PR.

Copy link
Contributor

@Ocramius Ocramius Mar 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

-'Invalid value {source_value}, it matches at least two types from union.'
-'Invalid value {source_value}, it matches two or more types in an union: cannot take a decision.'

This would already be a good improvement :)

@TimWolla
Copy link
Contributor

Possibly related issues of mine you might want to look into: #356, #358.

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.
```
@romm romm force-pushed the feat/better-union-narrowing branch from efd6267 to 59520c1 Compare March 17, 2024 15:13
@@ -94,6 +94,18 @@ public static function union_mapping_works_properly_data_provider(): iterable
'assertion' => fn (mixed $result) => self::assertSame(['key' => [42, 1337]], $result),
];

yield 'shaped array representing http response with status 200' => [
'type' => "array{status: 200, data: array{text: string}} | array{status: 400, error: string}",
'source' => ['status' => 200, 'data' => ['text' => 'foo']],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: The test case in my issue was specifically about allowing superfluous fields, because I don't care about extra fields in the response, i.e. this:

'source' => ['status' => 200, 'data' => ['text' => 'foo', 'extra' => 'bar']],

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I edited my message already. Just pushed a new test case that should match!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome, thank you!

@romm romm merged commit f731586 into master Mar 17, 2024
21 checks passed
@romm romm deleted the feat/better-union-narrowing branch March 17, 2024 15:39
@romm
Copy link
Member Author

romm commented Mar 17, 2024

There we go! 🎉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
3 participants