Skip to content

Commit 62540a4

Browse files
committed
feat(flow-php/etl): resolve union types to concrete entries in EntryFactory
- new EntryTypeResolver picks the first union member accepting the value, falling back to the first castable one - nullable union definitions no longer crash on OptionalType wrapping - schemas with union definitions (e.g. from schema_from_json_schema()) now work with extractors via withSchema()
1 parent cd6c58b commit 62540a4

4 files changed

Lines changed: 309 additions & 2 deletions

File tree

src/core/etl/src/Flow/ETL/Row/EntryFactory.php

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,13 @@
6666

6767
final readonly class EntryFactory
6868
{
69+
private EntryTypeResolver $typeResolver;
70+
71+
public function __construct()
72+
{
73+
$this->typeResolver = new EntryTypeResolver();
74+
}
75+
6976
/**
7077
* @param null|Definition<mixed>|Schema $schema
7178
*
@@ -80,7 +87,7 @@ public function create(string $entryName, mixed $value, Schema|Definition|null $
8087
return $this->createAs(
8188
$schema->entry()->name(),
8289
$value,
83-
$schema->isNullable() ? type_optional($schema->type()) : $schema->type(),
90+
$this->typeResolver->fromDefinition($schema),
8491
$schema->metadata(),
8592
);
8693
}
@@ -91,7 +98,7 @@ public function create(string $entryName, mixed $value, Schema|Definition|null $
9198
return $this->createAs(
9299
$definition->entry()->name(),
93100
$value,
94-
$definition->isNullable() ? type_optional($definition->type()) : $definition->type(),
101+
$this->typeResolver->fromDefinition($definition),
95102
$definition->metadata(),
96103
);
97104
}
@@ -158,6 +165,10 @@ public function createAs(string $entryName, mixed $value, Type $type, ?Metadata
158165
$type = $reduced;
159166
}
160167

168+
if ($type instanceof UnionType) {
169+
$type = $this->typeResolver->fromUnion($type, $value, $entryName);
170+
}
171+
161172
if ($type instanceof StringType) {
162173
return string_entry($entryName, type_optional($type)->cast($value), $metadata);
163174
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Flow\ETL\Row;
6+
7+
use Flow\ETL\Exception\InvalidArgumentException;
8+
use Flow\ETL\Schema\Definition;
9+
use Flow\Types\Exception\Exception as TypesException;
10+
use Flow\Types\Type;
11+
use Flow\Types\Type\Logical\OptionalType;
12+
use Flow\Types\Type\Native\NullType;
13+
use Flow\Types\Type\Native\UnionType;
14+
15+
use function count;
16+
use function Flow\Types\DSL\type_optional;
17+
use function Flow\Types\DSL\type_string;
18+
use function get_debug_type;
19+
20+
/**
21+
* Resolves the concrete Type an Entry should be created as, entries cannot carry union types.
22+
*/
23+
final readonly class EntryTypeResolver
24+
{
25+
/**
26+
* OptionalType cannot wrap a UnionType, nullability of union definitions is handled during union resolution.
27+
*
28+
* @param Definition<mixed> $definition
29+
*
30+
* @return Type<mixed>
31+
*/
32+
public function fromDefinition(Definition $definition): Type
33+
{
34+
if (!$definition->isNullable() || $definition->type() instanceof UnionType) {
35+
return $definition->type();
36+
}
37+
38+
return type_optional($definition->type());
39+
}
40+
41+
/**
42+
* A union is resolved to the first member accepting the value, falling back to the first
43+
* member the value can be cast to, mirroring UnionType::cast().
44+
*
45+
* @param Type<mixed>|UnionType<mixed, mixed> $type
46+
*
47+
* @throws InvalidArgumentException when the value matches no union member
48+
*
49+
* @return Type<mixed>
50+
*/
51+
public function fromUnion(UnionType|Type $type, mixed $value, string $entryName): Type
52+
{
53+
/** @var UnionType<mixed, mixed> $type */
54+
$members = [];
55+
56+
foreach ($type->types()->all() as $member) {
57+
if ($member instanceof OptionalType) {
58+
$member = $member->base();
59+
}
60+
61+
if ($member instanceof NullType) {
62+
continue;
63+
}
64+
65+
$members[] = $member;
66+
}
67+
68+
if (!count($members)) {
69+
return type_string();
70+
}
71+
72+
if ($value === null) {
73+
return $members[0];
74+
}
75+
76+
foreach ($members as $member) {
77+
if ($member->isValid($value)) {
78+
return $member;
79+
}
80+
}
81+
82+
foreach ($members as $member) {
83+
try {
84+
$member->cast($value);
85+
86+
return $member;
87+
} catch (TypesException) {
88+
continue;
89+
}
90+
}
91+
92+
throw new InvalidArgumentException(
93+
"Entry \"{$entryName}\": "
94+
. get_debug_type($value)
95+
. " value does not match any member of union type \"{$type->toString()}\"",
96+
);
97+
}
98+
}

src/core/etl/tests/Flow/ETL/Tests/Unit/Row/EntryFactoryTest.php

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
use function Flow\ETL\DSL\structure_entry;
5656
use function Flow\ETL\DSL\time_entry;
5757
use function Flow\ETL\DSL\time_schema;
58+
use function Flow\ETL\DSL\union_schema;
5859
use function Flow\ETL\DSL\uuid_entry;
5960
use function Flow\ETL\DSL\uuid_schema;
6061
use function Flow\ETL\DSL\xml_entry;
@@ -68,6 +69,7 @@
6869
use function Flow\Types\DSL\type_string;
6970
use function Flow\Types\DSL\type_structure;
7071
use function Flow\Types\DSL\type_time_zone;
72+
use function Flow\Types\DSL\type_union;
7173

7274
final class EntryFactoryTest extends TestCase
7375
{
@@ -521,6 +523,74 @@ public function test_timezone_from_string_creates_string_entry(): void
521523
);
522524
}
523525

526+
public function test_union_with_schema_falls_back_to_first_castable_member(): void
527+
{
528+
static::assertEquals(
529+
int_entry('e', 1),
530+
$this->entryFactory->create(
531+
'e',
532+
true,
533+
schema(union_schema('e', type_union(type_integer(), type_string()))),
534+
),
535+
);
536+
}
537+
538+
public function test_union_with_schema_keeps_numeric_string_as_string(): void
539+
{
540+
static::assertEquals(
541+
str_entry('e', '123'),
542+
$this->entryFactory->create(
543+
'e',
544+
'123',
545+
schema(union_schema('e', type_union(type_integer(), type_string()))),
546+
),
547+
);
548+
}
549+
550+
public function test_union_with_schema_resolves_to_complex_member(): void
551+
{
552+
static::assertEquals(
553+
list_entry('e', [1, 2, 3], type_list(type_integer())),
554+
$this->entryFactory->create(
555+
'e',
556+
[1, 2, 3],
557+
schema(union_schema('e', type_union(type_list(type_integer()), type_string()))),
558+
),
559+
);
560+
}
561+
562+
public function test_union_with_schema_resolves_to_integer(): void
563+
{
564+
static::assertEquals(
565+
int_entry('e', 1),
566+
$this->entryFactory->create('e', 1, schema(union_schema('e', type_union(type_integer(), type_string())))),
567+
);
568+
}
569+
570+
public function test_union_with_schema_resolves_to_string(): void
571+
{
572+
static::assertEquals(
573+
str_entry('e', 'flow'),
574+
$this->entryFactory->create(
575+
'e',
576+
'flow',
577+
schema(union_schema('e', type_union(type_integer(), type_string()))),
578+
),
579+
);
580+
}
581+
582+
public function test_union_with_schema_with_null_value_creates_first_member_entry(): void
583+
{
584+
static::assertEquals(
585+
int_entry('e', null),
586+
$this->entryFactory->create(
587+
'e',
588+
null,
589+
schema(union_schema('e', type_union(type_integer(), type_string()), true)),
590+
),
591+
);
592+
}
593+
524594
#[DataProvider('provide_unrecognized_data')]
525595
public function test_unrecognized_data_set_same_as_provided(string $input): void
526596
{
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Flow\ETL\Tests\Unit\Row;
6+
7+
use Flow\ETL\Exception\InvalidArgumentException;
8+
use Flow\ETL\Row\EntryTypeResolver;
9+
use Flow\ETL\Tests\FlowTestCase;
10+
11+
use function Flow\ETL\DSL\int_schema;
12+
use function Flow\ETL\DSL\union_schema;
13+
use function Flow\Types\DSL\type_date;
14+
use function Flow\Types\DSL\type_integer;
15+
use function Flow\Types\DSL\type_list;
16+
use function Flow\Types\DSL\type_null;
17+
use function Flow\Types\DSL\type_optional;
18+
use function Flow\Types\DSL\type_string;
19+
use function Flow\Types\DSL\type_union;
20+
use function Flow\Types\DSL\type_uuid;
21+
22+
final class EntryTypeResolverTest extends FlowTestCase
23+
{
24+
public function test_from_definition_keeps_non_nullable_type(): void
25+
{
26+
static::assertEquals(type_integer(), (new EntryTypeResolver())->fromDefinition(int_schema('e')));
27+
}
28+
29+
public function test_from_definition_keeps_nullable_union_unwrapped(): void
30+
{
31+
static::assertEquals(
32+
type_union(type_integer(), type_string()),
33+
(new EntryTypeResolver())->fromDefinition(union_schema(
34+
'e',
35+
type_union(type_integer(), type_string()),
36+
true,
37+
)),
38+
);
39+
}
40+
41+
public function test_from_definition_wraps_nullable_type_as_optional(): void
42+
{
43+
static::assertEquals(
44+
type_optional(type_integer()),
45+
(new EntryTypeResolver())->fromDefinition(int_schema('e', true)),
46+
);
47+
}
48+
49+
public function test_from_union_falls_back_to_first_castable_member(): void
50+
{
51+
static::assertEquals(type_integer(), (new EntryTypeResolver())->fromUnion(
52+
type_union(type_integer(), type_string()),
53+
true,
54+
'e',
55+
));
56+
}
57+
58+
public function test_from_union_ignores_null_members(): void
59+
{
60+
static::assertEquals(type_string(), (new EntryTypeResolver())->fromUnion(
61+
type_union(type_null(), type_string()),
62+
'flow',
63+
'e',
64+
));
65+
}
66+
67+
public function test_from_union_of_only_null_members_resolves_to_string(): void
68+
{
69+
static::assertEquals(type_string(), (new EntryTypeResolver())->fromUnion(
70+
type_union(type_null(), type_null()),
71+
null,
72+
'e',
73+
));
74+
}
75+
76+
public function test_from_union_resolves_complex_member(): void
77+
{
78+
static::assertEquals(
79+
type_list(type_integer()),
80+
(new EntryTypeResolver())->fromUnion(type_union(type_list(type_integer()), type_string()), [1, 2, 3], 'e'),
81+
);
82+
}
83+
84+
public function test_from_union_resolves_to_first_valid_member(): void
85+
{
86+
$resolver = new EntryTypeResolver();
87+
$union = type_union(type_integer(), type_string());
88+
89+
static::assertEquals(type_integer(), $resolver->fromUnion($union, 1, 'e'));
90+
static::assertEquals(type_string(), $resolver->fromUnion($union, 'flow', 'e'));
91+
static::assertEquals(type_string(), $resolver->fromUnion($union, '123', 'e'));
92+
}
93+
94+
public function test_from_union_skips_members_with_non_casting_exceptions(): void
95+
{
96+
static::assertEquals(type_date(), (new EntryTypeResolver())->fromUnion(
97+
type_union(type_uuid(), type_date()),
98+
'2024-01-01',
99+
'e',
100+
));
101+
}
102+
103+
public function test_from_union_throws_when_value_matches_no_member(): void
104+
{
105+
$this->expectException(InvalidArgumentException::class);
106+
$this->expectExceptionMessage('Entry "e": string value does not match any member of union type "date|uuid"');
107+
108+
(new EntryTypeResolver())->fromUnion(type_union(type_uuid(), type_date()), 'neither uuid nor date', 'e');
109+
}
110+
111+
public function test_from_union_unwraps_optional_members(): void
112+
{
113+
static::assertEquals(type_string(), (new EntryTypeResolver())->fromUnion(
114+
type_union(type_optional(type_string()), type_integer()),
115+
'flow',
116+
'e',
117+
));
118+
}
119+
120+
public function test_from_union_with_null_value_resolves_to_first_member(): void
121+
{
122+
static::assertEquals(type_integer(), (new EntryTypeResolver())->fromUnion(
123+
type_union(type_integer(), type_string()),
124+
null,
125+
'e',
126+
));
127+
}
128+
}

0 commit comments

Comments
 (0)