From d0b472398ce35e58a8193ed685ae994a627da00b Mon Sep 17 00:00:00 2001 From: Norbert Orzechowicz Date: Sat, 8 Aug 2026 12:06:52 +0200 Subject: [PATCH 1/5] fix(flow-php/etl): union schema definitions accept their own member - isCompatible() fans out over union members instead of testing set equality - merge() returns the union when the other definition is one of its members - symmetric merge in the other 17 definitions, so Schema::merge() no longer depends on receiver order - extract UnionMembers to resolve a union's member definitions --- .../Schema/Definition/BooleanDefinition.php | 9 ++ .../ETL/Schema/Definition/DateDefinition.php | 9 ++ .../Schema/Definition/DateTimeDefinition.php | 9 ++ .../ETL/Schema/Definition/EnumDefinition.php | 9 ++ .../ETL/Schema/Definition/FloatDefinition.php | 9 ++ .../ETL/Schema/Definition/HTMLDefinition.php | 9 ++ .../Definition/HTMLElementDefinition.php | 9 ++ .../Schema/Definition/IntegerDefinition.php | 9 ++ .../ETL/Schema/Definition/JsonDefinition.php | 9 ++ .../ETL/Schema/Definition/ListDefinition.php | 9 ++ .../ETL/Schema/Definition/MapDefinition.php | 9 ++ .../Schema/Definition/StringDefinition.php | 9 ++ .../Schema/Definition/StructureDefinition.php | 9 ++ .../ETL/Schema/Definition/TimeDefinition.php | 9 ++ .../ETL/Schema/Definition/UnionDefinition.php | 15 ++- .../ETL/Schema/Definition/UnionMembers.php | 50 ++++++++ .../ETL/Schema/Definition/UuidDefinition.php | 9 ++ .../ETL/Schema/Definition/XMLDefinition.php | 9 ++ .../Definition/XMLElementDefinition.php | 9 ++ .../Definition/BooleanDefinitionTest.php | 21 +++ .../Schema/Definition/DateDefinitionTest.php | 22 ++++ .../Definition/DateTimeDefinitionTest.php | 22 ++++ .../Schema/Definition/EnumDefinitionTest.php | 28 ++++ .../Schema/Definition/FloatDefinitionTest.php | 22 ++++ .../Schema/Definition/HTMLDefinitionTest.php | 22 ++++ .../Definition/HTMLElementDefinitionTest.php | 25 ++++ .../Definition/IntegerDefinitionTest.php | 21 +++ .../Schema/Definition/JsonDefinitionTest.php | 22 ++++ .../Schema/Definition/ListDefinitionTest.php | 25 ++++ .../Schema/Definition/MapDefinitionTest.php | 25 ++++ .../Schema/Definition/NullDefinitionTest.php | 14 ++ .../Definition/StringDefinitionTest.php | 22 ++++ .../Definition/StructureDefinitionTest.php | 23 ++++ .../Schema/Definition/TimeDefinitionTest.php | 22 ++++ .../Schema/Definition/UnionDefinitionTest.php | 93 +++++++++++++- .../Schema/Definition/UnionMembersTest.php | 121 ++++++++++++++++++ .../Schema/Definition/UuidDefinitionTest.php | 22 ++++ .../Schema/Definition/XMLDefinitionTest.php | 22 ++++ .../Definition/XMLElementDefinitionTest.php | 22 ++++ .../Tests/Unit/Schema/StrictValidatorTest.php | 15 ++- 40 files changed, 845 insertions(+), 4 deletions(-) create mode 100644 src/core/etl/src/Flow/ETL/Schema/Definition/UnionMembers.php create mode 100644 src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UnionMembersTest.php diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/BooleanDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/BooleanDefinition.php index af6271fb1..424bd8ec6 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/BooleanDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/BooleanDefinition.php @@ -133,6 +133,15 @@ public function merge(Definition $definition): Definition ); } + if ($definition instanceof UnionDefinition && (new UnionMembers())->contains($definition, $this)) { + return new UnionDefinition( + $this->ref, + $definition->type(), + $this->nullable || $definition->isNullable(), + $this->metadata->merge($definition->metadata()), + ); + } + throw new RuntimeException(sprintf('Cannot merge %s with %s', self::class, $definition::class)); } diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/DateDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/DateDefinition.php index 705e92dca..7a5a2ffd5 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/DateDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/DateDefinition.php @@ -157,6 +157,15 @@ public function merge(Definition $definition): Definition ); } + if ($definition instanceof UnionDefinition && (new UnionMembers())->contains($definition, $this)) { + return new UnionDefinition( + $this->ref, + $definition->type(), + $this->nullable || $definition->isNullable(), + $this->metadata->merge($definition->metadata()), + ); + } + throw new RuntimeException(sprintf('Cannot merge %s with %s', self::class, $definition::class)); } diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/DateTimeDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/DateTimeDefinition.php index c5fbd35df..1066849db 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/DateTimeDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/DateTimeDefinition.php @@ -153,6 +153,15 @@ public function merge(Definition $definition): Definition ); } + if ($definition instanceof UnionDefinition && (new UnionMembers())->contains($definition, $this)) { + return new UnionDefinition( + $this->ref, + $definition->type(), + $this->nullable || $definition->isNullable(), + $this->metadata->merge($definition->metadata()), + ); + } + throw new RuntimeException(sprintf('Cannot merge %s with %s', self::class, $definition::class)); } diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/EnumDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/EnumDefinition.php index 66d954194..ed5cd0dab 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/EnumDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/EnumDefinition.php @@ -159,6 +159,15 @@ public function merge(Definition $definition): Definition ); } + if ($definition instanceof UnionDefinition && (new UnionMembers())->contains($definition, $this)) { + return new UnionDefinition( + $this->ref, + $definition->type(), + $this->nullable || $definition->isNullable(), + $this->metadata->merge($definition->metadata()), + ); + } + throw new RuntimeException(sprintf('Cannot merge %s with %s', self::class, $definition::class)); } diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/FloatDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/FloatDefinition.php index fc9ddceeb..946be9042 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/FloatDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/FloatDefinition.php @@ -149,6 +149,15 @@ public function merge(Definition $definition): Definition ); } + if ($definition instanceof UnionDefinition && (new UnionMembers())->contains($definition, $this)) { + return new UnionDefinition( + $this->ref, + $definition->type(), + $this->nullable || $definition->isNullable(), + $this->metadata->merge($definition->metadata()), + ); + } + throw new RuntimeException(sprintf('Cannot merge %s with %s', self::class, $definition::class)); } diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/HTMLDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/HTMLDefinition.php index 56ba16f8a..1c1f5f2ce 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/HTMLDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/HTMLDefinition.php @@ -134,6 +134,15 @@ public function merge(Definition $definition): Definition ); } + if ($definition instanceof UnionDefinition && (new UnionMembers())->contains($definition, $this)) { + return new UnionDefinition( + $this->ref, + $definition->type(), + $this->nullable || $definition->isNullable(), + $this->metadata->merge($definition->metadata()), + ); + } + throw new RuntimeException(sprintf('Cannot merge %s with %s', self::class, $definition::class)); } diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/HTMLElementDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/HTMLElementDefinition.php index 50331334f..b72454d27 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/HTMLElementDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/HTMLElementDefinition.php @@ -134,6 +134,15 @@ public function merge(Definition $definition): Definition ); } + if ($definition instanceof UnionDefinition && (new UnionMembers())->contains($definition, $this)) { + return new UnionDefinition( + $this->ref, + $definition->type(), + $this->nullable || $definition->isNullable(), + $this->metadata->merge($definition->metadata()), + ); + } + throw new RuntimeException(sprintf('Cannot merge %s with %s', self::class, $definition::class)); } diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/IntegerDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/IntegerDefinition.php index d0d855c75..49313ddb2 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/IntegerDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/IntegerDefinition.php @@ -149,6 +149,15 @@ public function merge(Definition $definition): Definition ); } + if ($definition instanceof UnionDefinition && (new UnionMembers())->contains($definition, $this)) { + return new UnionDefinition( + $this->ref, + $definition->type(), + $this->nullable || $definition->isNullable(), + $this->metadata->merge($definition->metadata()), + ); + } + throw new RuntimeException(sprintf('Cannot merge %s with %s', self::class, $definition::class)); } diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/JsonDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/JsonDefinition.php index b9089ba2d..3d4528c85 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/JsonDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/JsonDefinition.php @@ -134,6 +134,15 @@ public function merge(Definition $definition): Definition ); } + if ($definition instanceof UnionDefinition && (new UnionMembers())->contains($definition, $this)) { + return new UnionDefinition( + $this->ref, + $definition->type(), + $this->nullable || $definition->isNullable(), + $this->metadata->merge($definition->metadata()), + ); + } + throw new RuntimeException(sprintf('Cannot merge %s with %s', self::class, $definition::class)); } diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/ListDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/ListDefinition.php index 9b4eb69f4..a0526d5a3 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/ListDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/ListDefinition.php @@ -160,6 +160,15 @@ public function merge(Definition $definition): Definition ); } + if ($definition instanceof UnionDefinition && (new UnionMembers())->contains($definition, $this)) { + return new UnionDefinition( + $this->ref, + $definition->type(), + $this->nullable || $definition->isNullable(), + $this->metadata->merge($definition->metadata()), + ); + } + throw new RuntimeException(sprintf('Cannot merge %s with %s', self::class, $definition::class)); } diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/MapDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/MapDefinition.php index 6e11360ac..0f1de185e 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/MapDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/MapDefinition.php @@ -167,6 +167,15 @@ public function merge(Definition $definition): Definition ); } + if ($definition instanceof UnionDefinition && (new UnionMembers())->contains($definition, $this)) { + return new UnionDefinition( + $this->ref, + $definition->type(), + $this->nullable || $definition->isNullable(), + $this->metadata->merge($definition->metadata()), + ); + } + throw new RuntimeException(sprintf('Cannot merge %s with %s', self::class, $definition::class)); } diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/StringDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/StringDefinition.php index 63e8b0952..483ac2eaa 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/StringDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/StringDefinition.php @@ -117,6 +117,15 @@ public function merge(Definition $definition): Definition return $this->makeNullable()->setMetadata($this->metadata->merge($definition->metadata())); } + if ($definition instanceof UnionDefinition && (new UnionMembers())->contains($definition, $this)) { + return new UnionDefinition( + $this->ref, + $definition->type(), + $this->nullable || $definition->isNullable(), + $this->metadata->merge($definition->metadata()), + ); + } + return new self( $this->ref, $this->nullable || $definition->isNullable(), diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/StructureDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/StructureDefinition.php index 44c22f30e..02bfe8b65 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/StructureDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/StructureDefinition.php @@ -180,6 +180,15 @@ public function merge(Definition $definition): Definition ); } + if ($definition instanceof UnionDefinition && (new UnionMembers())->contains($definition, $this)) { + return new UnionDefinition( + $this->ref, + $definition->type(), + $this->nullable || $definition->isNullable(), + $this->metadata->merge($definition->metadata()), + ); + } + throw new RuntimeException(sprintf('Cannot merge %s with %s', self::class, $definition::class)); } diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/TimeDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/TimeDefinition.php index b3a8a39ea..66c84e2d1 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/TimeDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/TimeDefinition.php @@ -141,6 +141,15 @@ public function merge(Definition $definition): Definition ); } + if ($definition instanceof UnionDefinition && (new UnionMembers())->contains($definition, $this)) { + return new UnionDefinition( + $this->ref, + $definition->type(), + $this->nullable || $definition->isNullable(), + $this->metadata->merge($definition->metadata()), + ); + } + throw new RuntimeException(sprintf('Cannot merge %s with %s', self::class, $definition::class)); } diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/UnionDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/UnionDefinition.php index f32f37ee7..eaccf2845 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/UnionDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/UnionDefinition.php @@ -62,7 +62,11 @@ public function isCompatible(Definition $definition): bool return false; } - return type_equals($this->type, $definition->type()); + if (type_equals($this->type, $definition->type())) { + return true; + } + + return (new UnionMembers())->contains($this, $definition); } public function isNullable(): bool @@ -132,6 +136,15 @@ public function merge(Definition $definition): Definition ); } + if ((new UnionMembers())->contains($this, $definition)) { + return new self( + $this->ref, + $this->type, + $this->nullable || $definition->isNullable(), + $this->metadata->merge($definition->metadata()), + ); + } + throw new RuntimeException(sprintf('Cannot merge %s with %s', self::class, $definition::class)); } diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/UnionMembers.php b/src/core/etl/src/Flow/ETL/Schema/Definition/UnionMembers.php new file mode 100644 index 000000000..10a0f838e --- /dev/null +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/UnionMembers.php @@ -0,0 +1,50 @@ + $definition + */ + public function contains(UnionDefinition $union, Definition $definition): bool + { + foreach ($this->definitions($union) as $member) { + if ($member->isCompatible($definition)) { + return true; + } + } + + return false; + } + + /** + * @return array> + */ + public function definitions(UnionDefinition $union): array + { + $definitions = []; + + foreach ($union->type()->types()->all() as $member) { + try { + $definitions[] = definition_from_type( + $union->entry(), + $member instanceof OptionalType ? $member->base() : $member, + nullable: true, + ); + } catch (RuntimeException) { + continue; + } + } + + return $definitions; + } +} diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/UuidDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/UuidDefinition.php index 6c3188de5..451c43004 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/UuidDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/UuidDefinition.php @@ -134,6 +134,15 @@ public function merge(Definition $definition): Definition ); } + if ($definition instanceof UnionDefinition && (new UnionMembers())->contains($definition, $this)) { + return new UnionDefinition( + $this->ref, + $definition->type(), + $this->nullable || $definition->isNullable(), + $this->metadata->merge($definition->metadata()), + ); + } + throw new RuntimeException(sprintf('Cannot merge %s with %s', self::class, $definition::class)); } diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/XMLDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/XMLDefinition.php index 50e301592..bd1d9bdb6 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/XMLDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/XMLDefinition.php @@ -134,6 +134,15 @@ public function merge(Definition $definition): Definition ); } + if ($definition instanceof UnionDefinition && (new UnionMembers())->contains($definition, $this)) { + return new UnionDefinition( + $this->ref, + $definition->type(), + $this->nullable || $definition->isNullable(), + $this->metadata->merge($definition->metadata()), + ); + } + throw new RuntimeException(sprintf('Cannot merge %s with %s', self::class, $definition::class)); } diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/XMLElementDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/XMLElementDefinition.php index c62943aed..674da3b4a 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/XMLElementDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/XMLElementDefinition.php @@ -133,6 +133,15 @@ public function merge(Definition $definition): Definition ); } + if ($definition instanceof UnionDefinition && (new UnionMembers())->contains($definition, $this)) { + return new UnionDefinition( + $this->ref, + $definition->type(), + $this->nullable || $definition->isNullable(), + $this->metadata->merge($definition->metadata()), + ); + } + throw new RuntimeException(sprintf('Cannot merge %s with %s', self::class, $definition::class)); } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/BooleanDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/BooleanDefinitionTest.php index bfaf021fc..455d825d8 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/BooleanDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/BooleanDefinitionTest.php @@ -9,6 +9,7 @@ use Flow\ETL\Schema\Definition; use Flow\ETL\Schema\Definition\BooleanDefinition; use Flow\ETL\Schema\Definition\IntegerDefinition; +use Flow\ETL\Schema\Definition\UnionDefinition; use Flow\ETL\Schema\Metadata; use Flow\ETL\Tests\FlowTestCase; use Generator; @@ -19,6 +20,11 @@ use function Flow\ETL\DSL\int_entry; use function Flow\ETL\DSL\null_schema; use function Flow\ETL\DSL\string_schema; +use function Flow\ETL\DSL\union_schema; +use function Flow\Types\DSL\type_boolean; +use function Flow\Types\DSL\type_integer; +use function Flow\Types\DSL\type_string; +use function Flow\Types\DSL\type_union; final class BooleanDefinitionTest extends FlowTestCase { @@ -251,4 +257,19 @@ public function test_type_returns_boolean_type(): void static::assertSame('boolean', $def->type()->toString()); } + + public function test_merge_with_union_containing_this_type_returns_union(): void + { + $merged = bool_schema('col')->merge(union_schema('col', type_union(type_boolean(), type_integer()))); + + static::assertInstanceOf(UnionDefinition::class, $merged); + static::assertSame('boolean|integer', $merged->type()->toString()); + } + + public function test_merge_with_union_not_containing_this_type_throws_exception(): void + { + $this->expectException(RuntimeException::class); + + bool_schema('col')->merge(union_schema('col', type_union(type_integer(), type_string()))); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/DateDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/DateDefinitionTest.php index 472895f38..03a7e4301 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/DateDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/DateDefinitionTest.php @@ -10,6 +10,7 @@ use Flow\ETL\Schema\Definition\BooleanDefinition; use Flow\ETL\Schema\Definition\DateDefinition; use Flow\ETL\Schema\Definition\DateTimeDefinition; +use Flow\ETL\Schema\Definition\UnionDefinition; use Flow\ETL\Schema\Metadata; use Flow\ETL\Tests\FlowTestCase; use Generator; @@ -22,6 +23,12 @@ use function Flow\ETL\DSL\null_schema; use function Flow\ETL\DSL\string_schema; use function Flow\ETL\DSL\time_schema; +use function Flow\ETL\DSL\union_schema; +use function Flow\Types\DSL\type_boolean; +use function Flow\Types\DSL\type_date; +use function Flow\Types\DSL\type_integer; +use function Flow\Types\DSL\type_string; +use function Flow\Types\DSL\type_union; final class DateDefinitionTest extends FlowTestCase { @@ -283,4 +290,19 @@ public function test_type_returns_date_type(): void static::assertSame('date', $def->type()->toString()); } + + public function test_merge_with_union_containing_this_type_returns_union(): void + { + $merged = date_schema('col')->merge(union_schema('col', type_union(type_date(), type_boolean()))); + + static::assertInstanceOf(UnionDefinition::class, $merged); + static::assertSame('boolean|date', $merged->type()->toString()); + } + + public function test_merge_with_union_not_containing_this_type_throws_exception(): void + { + $this->expectException(RuntimeException::class); + + date_schema('col')->merge(union_schema('col', type_union(type_integer(), type_string()))); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/DateTimeDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/DateTimeDefinitionTest.php index cca0491b1..c2987859f 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/DateTimeDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/DateTimeDefinitionTest.php @@ -9,6 +9,7 @@ use Flow\ETL\Schema\Definition; use Flow\ETL\Schema\Definition\BooleanDefinition; use Flow\ETL\Schema\Definition\DateTimeDefinition; +use Flow\ETL\Schema\Definition\UnionDefinition; use Flow\ETL\Schema\Metadata; use Flow\ETL\Tests\FlowTestCase; use Generator; @@ -21,6 +22,12 @@ use function Flow\ETL\DSL\null_schema; use function Flow\ETL\DSL\string_schema; use function Flow\ETL\DSL\time_schema; +use function Flow\ETL\DSL\union_schema; +use function Flow\Types\DSL\type_boolean; +use function Flow\Types\DSL\type_datetime; +use function Flow\Types\DSL\type_integer; +use function Flow\Types\DSL\type_string; +use function Flow\Types\DSL\type_union; final class DateTimeDefinitionTest extends FlowTestCase { @@ -282,4 +289,19 @@ public function test_type_returns_datetime_type(): void static::assertSame('datetime', $def->type()->toString()); } + + public function test_merge_with_union_containing_this_type_returns_union(): void + { + $merged = datetime_schema('col')->merge(union_schema('col', type_union(type_datetime(), type_boolean()))); + + static::assertInstanceOf(UnionDefinition::class, $merged); + static::assertSame('boolean|datetime', $merged->type()->toString()); + } + + public function test_merge_with_union_not_containing_this_type_throws_exception(): void + { + $this->expectException(RuntimeException::class); + + datetime_schema('col')->merge(union_schema('col', type_union(type_integer(), type_string()))); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/EnumDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/EnumDefinitionTest.php index a255ebc04..bd75e453c 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/EnumDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/EnumDefinitionTest.php @@ -10,6 +10,7 @@ use Flow\ETL\Schema\Definition; use Flow\ETL\Schema\Definition\BooleanDefinition; use Flow\ETL\Schema\Definition\EnumDefinition; +use Flow\ETL\Schema\Definition\UnionDefinition; use Flow\ETL\Schema\Metadata; use Flow\ETL\Tests\Fixtures\Enum\BackedStringEnum; use Flow\ETL\Tests\Fixtures\Enum\BasicEnum; @@ -23,6 +24,12 @@ use function Flow\ETL\DSL\int_entry; use function Flow\ETL\DSL\null_schema; use function Flow\ETL\DSL\string_schema; +use function Flow\ETL\DSL\union_schema; +use function Flow\Types\DSL\type_boolean; +use function Flow\Types\DSL\type_enum; +use function Flow\Types\DSL\type_integer; +use function Flow\Types\DSL\type_string; +use function Flow\Types\DSL\type_union; final class EnumDefinitionTest extends FlowTestCase { @@ -305,4 +312,25 @@ public function test_type_returns_enum_type(): void static::assertStringContainsString('enum', $def->type()->toString()); } + + public function test_merge_with_union_containing_this_type_returns_union(): void + { + $merged = enum_schema('col', BackedStringEnum::class)->merge(union_schema('col', type_union( + type_enum(BackedStringEnum::class), + type_boolean(), + ))); + + static::assertInstanceOf(UnionDefinition::class, $merged); + static::assertSame('boolean|enum', $merged->type()->toString()); + } + + public function test_merge_with_union_not_containing_this_type_throws_exception(): void + { + $this->expectException(RuntimeException::class); + + enum_schema('col', BackedStringEnum::class)->merge(union_schema('col', type_union( + type_integer(), + type_string(), + ))); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/FloatDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/FloatDefinitionTest.php index 19dacc2de..ab6bdd4fd 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/FloatDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/FloatDefinitionTest.php @@ -9,6 +9,7 @@ use Flow\ETL\Schema\Definition; use Flow\ETL\Schema\Definition\BooleanDefinition; use Flow\ETL\Schema\Definition\FloatDefinition; +use Flow\ETL\Schema\Definition\UnionDefinition; use Flow\ETL\Schema\Metadata; use Flow\ETL\Tests\FlowTestCase; use Generator; @@ -20,6 +21,12 @@ use function Flow\ETL\DSL\int_schema; use function Flow\ETL\DSL\null_schema; use function Flow\ETL\DSL\string_schema; +use function Flow\ETL\DSL\union_schema; +use function Flow\Types\DSL\type_boolean; +use function Flow\Types\DSL\type_float; +use function Flow\Types\DSL\type_integer; +use function Flow\Types\DSL\type_string; +use function Flow\Types\DSL\type_union; final class FloatDefinitionTest extends FlowTestCase { @@ -275,4 +282,19 @@ public function test_type_returns_float_type(): void static::assertSame('float', $def->type()->toString()); } + + public function test_merge_with_union_containing_this_type_returns_union(): void + { + $merged = float_schema('col')->merge(union_schema('col', type_union(type_float(), type_boolean()))); + + static::assertInstanceOf(UnionDefinition::class, $merged); + static::assertSame('boolean|float', $merged->type()->toString()); + } + + public function test_merge_with_union_not_containing_this_type_throws_exception(): void + { + $this->expectException(RuntimeException::class); + + float_schema('col')->merge(union_schema('col', type_union(type_integer(), type_string()))); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/HTMLDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/HTMLDefinitionTest.php index b3aca0a75..c5c97fdde 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/HTMLDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/HTMLDefinitionTest.php @@ -9,6 +9,7 @@ use Flow\ETL\Schema\Definition; use Flow\ETL\Schema\Definition\BooleanDefinition; use Flow\ETL\Schema\Definition\HTMLDefinition; +use Flow\ETL\Schema\Definition\UnionDefinition; use Flow\ETL\Schema\Metadata; use Flow\ETL\Tests\FlowTestCase; use Generator; @@ -20,6 +21,12 @@ use function Flow\ETL\DSL\int_entry; use function Flow\ETL\DSL\null_schema; use function Flow\ETL\DSL\string_schema; +use function Flow\ETL\DSL\union_schema; +use function Flow\Types\DSL\type_boolean; +use function Flow\Types\DSL\type_html; +use function Flow\Types\DSL\type_integer; +use function Flow\Types\DSL\type_string; +use function Flow\Types\DSL\type_union; final class HTMLDefinitionTest extends FlowTestCase { @@ -254,4 +261,19 @@ public function test_type_returns_html_type(): void static::assertSame('html', $def->type()->toString()); } + + public function test_merge_with_union_containing_this_type_returns_union(): void + { + $merged = html_schema('col')->merge(union_schema('col', type_union(type_html(), type_boolean()))); + + static::assertInstanceOf(UnionDefinition::class, $merged); + static::assertSame('boolean|html', $merged->type()->toString()); + } + + public function test_merge_with_union_not_containing_this_type_throws_exception(): void + { + $this->expectException(RuntimeException::class); + + html_schema('col')->merge(union_schema('col', type_union(type_integer(), type_string()))); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/HTMLElementDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/HTMLElementDefinitionTest.php index db422e5f9..e5b34029b 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/HTMLElementDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/HTMLElementDefinitionTest.php @@ -9,6 +9,7 @@ use Flow\ETL\Schema\Definition; use Flow\ETL\Schema\Definition\BooleanDefinition; use Flow\ETL\Schema\Definition\HTMLElementDefinition; +use Flow\ETL\Schema\Definition\UnionDefinition; use Flow\ETL\Schema\Metadata; use Flow\ETL\Tests\FlowTestCase; use Generator; @@ -20,6 +21,12 @@ use function Flow\ETL\DSL\int_entry; use function Flow\ETL\DSL\null_schema; use function Flow\ETL\DSL\string_schema; +use function Flow\ETL\DSL\union_schema; +use function Flow\Types\DSL\type_boolean; +use function Flow\Types\DSL\type_html_element; +use function Flow\Types\DSL\type_integer; +use function Flow\Types\DSL\type_string; +use function Flow\Types\DSL\type_union; final class HTMLElementDefinitionTest extends FlowTestCase { @@ -254,4 +261,22 @@ public function test_type_returns_html_element_type(): void static::assertSame('html_element', $def->type()->toString()); } + + public function test_merge_with_union_containing_this_type_returns_union(): void + { + $merged = html_element_schema('col')->merge(union_schema('col', type_union( + type_html_element(), + type_boolean(), + ))); + + static::assertInstanceOf(UnionDefinition::class, $merged); + static::assertSame('boolean|html_element', $merged->type()->toString()); + } + + public function test_merge_with_union_not_containing_this_type_throws_exception(): void + { + $this->expectException(RuntimeException::class); + + html_element_schema('col')->merge(union_schema('col', type_union(type_integer(), type_string()))); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/IntegerDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/IntegerDefinitionTest.php index e82373204..5e166fb94 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/IntegerDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/IntegerDefinitionTest.php @@ -10,6 +10,7 @@ use Flow\ETL\Schema\Definition\BooleanDefinition; use Flow\ETL\Schema\Definition\FloatDefinition; use Flow\ETL\Schema\Definition\IntegerDefinition; +use Flow\ETL\Schema\Definition\UnionDefinition; use Flow\ETL\Schema\Metadata; use Flow\ETL\Tests\FlowTestCase; use Generator; @@ -21,6 +22,11 @@ use function Flow\ETL\DSL\null_schema; use function Flow\ETL\DSL\str_entry; use function Flow\ETL\DSL\string_schema; +use function Flow\ETL\DSL\union_schema; +use function Flow\Types\DSL\type_boolean; +use function Flow\Types\DSL\type_integer; +use function Flow\Types\DSL\type_string; +use function Flow\Types\DSL\type_union; final class IntegerDefinitionTest extends FlowTestCase { @@ -276,4 +282,19 @@ public function test_type_returns_integer_type(): void static::assertSame('integer', $def->type()->toString()); } + + public function test_merge_with_union_containing_this_type_returns_union(): void + { + $merged = int_schema('col')->merge(union_schema('col', type_union(type_integer(), type_boolean()))); + + static::assertInstanceOf(UnionDefinition::class, $merged); + static::assertSame('boolean|integer', $merged->type()->toString()); + } + + public function test_merge_with_union_not_containing_this_type_throws_exception(): void + { + $this->expectException(RuntimeException::class); + + int_schema('col')->merge(union_schema('col', type_union(type_boolean(), type_string()))); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/JsonDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/JsonDefinitionTest.php index d50484dea..39aad9f5e 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/JsonDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/JsonDefinitionTest.php @@ -9,6 +9,7 @@ use Flow\ETL\Schema\Definition; use Flow\ETL\Schema\Definition\BooleanDefinition; use Flow\ETL\Schema\Definition\JsonDefinition; +use Flow\ETL\Schema\Definition\UnionDefinition; use Flow\ETL\Schema\Metadata; use Flow\ETL\Tests\FlowTestCase; use Generator; @@ -19,6 +20,12 @@ use function Flow\ETL\DSL\json_schema; use function Flow\ETL\DSL\null_schema; use function Flow\ETL\DSL\string_schema; +use function Flow\ETL\DSL\union_schema; +use function Flow\Types\DSL\type_boolean; +use function Flow\Types\DSL\type_integer; +use function Flow\Types\DSL\type_json; +use function Flow\Types\DSL\type_string; +use function Flow\Types\DSL\type_union; final class JsonDefinitionTest extends FlowTestCase { @@ -251,4 +258,19 @@ public function test_type_returns_json_type(): void static::assertSame('json', $def->type()->toString()); } + + public function test_merge_with_union_containing_this_type_returns_union(): void + { + $merged = json_schema('col')->merge(union_schema('col', type_union(type_json(), type_boolean()))); + + static::assertInstanceOf(UnionDefinition::class, $merged); + static::assertSame('boolean|json', $merged->type()->toString()); + } + + public function test_merge_with_union_not_containing_this_type_throws_exception(): void + { + $this->expectException(RuntimeException::class); + + json_schema('col')->merge(union_schema('col', type_union(type_integer(), type_string()))); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/ListDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/ListDefinitionTest.php index 21ac19312..ddbab155e 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/ListDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/ListDefinitionTest.php @@ -9,6 +9,7 @@ use Flow\ETL\Schema\Definition; use Flow\ETL\Schema\Definition\BooleanDefinition; use Flow\ETL\Schema\Definition\ListDefinition; +use Flow\ETL\Schema\Definition\UnionDefinition; use Flow\ETL\Schema\Metadata; use Flow\ETL\Tests\FlowTestCase; use Generator; @@ -19,10 +20,13 @@ use function Flow\ETL\DSL\list_schema; use function Flow\ETL\DSL\null_schema; use function Flow\ETL\DSL\string_schema; +use function Flow\ETL\DSL\union_schema; +use function Flow\Types\DSL\type_boolean; use function Flow\Types\DSL\type_float; use function Flow\Types\DSL\type_integer; use function Flow\Types\DSL\type_list; use function Flow\Types\DSL\type_string; +use function Flow\Types\DSL\type_union; final class ListDefinitionTest extends FlowTestCase { @@ -273,4 +277,25 @@ public function test_type_returns_list_type(): void static::assertStringContainsString('list', $def->type()->toString()); } + + public function test_merge_with_union_containing_this_type_returns_union(): void + { + $merged = list_schema('col', type_list(type_integer()))->merge(union_schema('col', type_union( + type_list(type_integer()), + type_boolean(), + ))); + + static::assertInstanceOf(UnionDefinition::class, $merged); + static::assertSame('boolean|list', $merged->type()->toString()); + } + + public function test_merge_with_union_not_containing_this_type_throws_exception(): void + { + $this->expectException(RuntimeException::class); + + list_schema('col', type_list(type_integer()))->merge(union_schema('col', type_union( + type_integer(), + type_string(), + ))); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/MapDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/MapDefinitionTest.php index 1278d1b38..fe884b2da 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/MapDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/MapDefinitionTest.php @@ -9,6 +9,7 @@ use Flow\ETL\Schema\Definition; use Flow\ETL\Schema\Definition\BooleanDefinition; use Flow\ETL\Schema\Definition\MapDefinition; +use Flow\ETL\Schema\Definition\UnionDefinition; use Flow\ETL\Schema\Metadata; use Flow\ETL\Tests\FlowTestCase; use Generator; @@ -19,10 +20,13 @@ use function Flow\ETL\DSL\map_schema; use function Flow\ETL\DSL\null_schema; use function Flow\ETL\DSL\string_schema; +use function Flow\ETL\DSL\union_schema; +use function Flow\Types\DSL\type_boolean; use function Flow\Types\DSL\type_float; use function Flow\Types\DSL\type_integer; use function Flow\Types\DSL\type_map; use function Flow\Types\DSL\type_string; +use function Flow\Types\DSL\type_union; final class MapDefinitionTest extends FlowTestCase { @@ -289,4 +293,25 @@ public function test_type_returns_map_type(): void static::assertStringContainsString('map', $def->type()->toString()); } + + public function test_merge_with_union_containing_this_type_returns_union(): void + { + $merged = map_schema('col', type_map(type_string(), type_integer()))->merge(union_schema('col', type_union( + type_map(type_string(), type_integer()), + type_boolean(), + ))); + + static::assertInstanceOf(UnionDefinition::class, $merged); + static::assertSame('boolean|map', $merged->type()->toString()); + } + + public function test_merge_with_union_not_containing_this_type_throws_exception(): void + { + $this->expectException(RuntimeException::class); + + map_schema('col', type_map(type_string(), type_integer()))->merge(union_schema('col', type_union( + type_integer(), + type_string(), + ))); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/NullDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/NullDefinitionTest.php index 5caebb3a6..e57214a74 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/NullDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/NullDefinitionTest.php @@ -8,6 +8,7 @@ use Flow\ETL\Row\Entry\NullEntry; use Flow\ETL\Schema\Definition\IntegerDefinition; use Flow\ETL\Schema\Definition\NullDefinition; +use Flow\ETL\Schema\Definition\UnionDefinition; use Flow\ETL\Schema\Metadata; use Flow\ETL\Tests\FlowTestCase; use Flow\Types\Type\Native\NullType; @@ -16,6 +17,10 @@ use function Flow\ETL\DSL\int_schema; use function Flow\ETL\DSL\null_entry; use function Flow\ETL\DSL\null_schema; +use function Flow\ETL\DSL\union_schema; +use function Flow\Types\DSL\type_integer; +use function Flow\Types\DSL\type_string; +use function Flow\Types\DSL\type_union; final class NullDefinitionTest extends FlowTestCase { @@ -187,4 +192,13 @@ public function test_type_is_null_type(): void { static::assertInstanceOf(NullType::class, null_schema('id')->type()); } + + public function test_merge_with_union_returns_nullable_union(): void + { + $merged = null_schema('col')->merge(union_schema('col', type_union(type_integer(), type_string()))); + + static::assertInstanceOf(UnionDefinition::class, $merged); + static::assertSame('integer|string', $merged->type()->toString()); + static::assertTrue($merged->isNullable()); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/StringDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/StringDefinitionTest.php index 95def6b7c..fe4fa5e52 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/StringDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/StringDefinitionTest.php @@ -8,6 +8,7 @@ use Flow\ETL\Row\Entry\StringEntry; use Flow\ETL\Schema\Definition; use Flow\ETL\Schema\Definition\StringDefinition; +use Flow\ETL\Schema\Definition\UnionDefinition; use Flow\ETL\Schema\Metadata; use Flow\ETL\Tests\FlowTestCase; use Generator; @@ -18,6 +19,11 @@ use function Flow\ETL\DSL\null_schema; use function Flow\ETL\DSL\str_entry; use function Flow\ETL\DSL\string_schema; +use function Flow\ETL\DSL\union_schema; +use function Flow\Types\DSL\type_boolean; +use function Flow\Types\DSL\type_integer; +use function Flow\Types\DSL\type_string; +use function Flow\Types\DSL\type_union; final class StringDefinitionTest extends FlowTestCase { @@ -241,4 +247,20 @@ public function test_type_returns_string_type(): void static::assertSame('string', $def->type()->toString()); } + + public function test_merge_with_union_containing_this_type_returns_union(): void + { + $merged = string_schema('col')->merge(union_schema('col', type_union(type_string(), type_boolean()))); + + static::assertInstanceOf(UnionDefinition::class, $merged); + static::assertSame('boolean|string', $merged->type()->toString()); + } + + public function test_merge_with_union_not_containing_string_returns_string(): void + { + static::assertInstanceOf( + StringDefinition::class, + string_schema('col')->merge(union_schema('col', type_union(type_boolean(), type_integer()))), + ); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/StructureDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/StructureDefinitionTest.php index 60dd71843..d88961c0c 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/StructureDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/StructureDefinitionTest.php @@ -9,6 +9,7 @@ use Flow\ETL\Schema\Definition; use Flow\ETL\Schema\Definition\BooleanDefinition; use Flow\ETL\Schema\Definition\StructureDefinition; +use Flow\ETL\Schema\Definition\UnionDefinition; use Flow\ETL\Schema\Metadata; use Flow\ETL\Tests\FlowTestCase; use Generator; @@ -19,12 +20,14 @@ use function Flow\ETL\DSL\string_schema; use function Flow\ETL\DSL\structure_entry; use function Flow\ETL\DSL\structure_schema; +use function Flow\ETL\DSL\union_schema; use function Flow\Types\DSL\type_boolean; use function Flow\Types\DSL\type_integer; use function Flow\Types\DSL\type_null; use function Flow\Types\DSL\type_optional; use function Flow\Types\DSL\type_string; use function Flow\Types\DSL\type_structure; +use function Flow\Types\DSL\type_union; final class StructureDefinitionTest extends FlowTestCase { @@ -482,4 +485,24 @@ public function test_type_returns_structure_type(): void static::assertStringContainsString('structure', $def->type()->toString()); } + + public function test_merge_with_union_containing_this_type_returns_union(): void + { + $merged = structure_schema('col', type_structure([ + 'a' => type_integer(), + ]))->merge(union_schema('col', type_union(type_structure(['a' => type_integer()]), type_boolean()))); + + static::assertInstanceOf(UnionDefinition::class, $merged); + static::assertSame('boolean|structure{a: integer}', $merged->type()->toString()); + } + + public function test_merge_with_union_not_containing_this_type_throws_exception(): void + { + $this->expectException(RuntimeException::class); + + structure_schema('col', type_structure(['a' => type_integer()]))->merge(union_schema('col', type_union( + type_integer(), + type_string(), + ))); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/TimeDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/TimeDefinitionTest.php index edcc20242..3b324a7d7 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/TimeDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/TimeDefinitionTest.php @@ -10,6 +10,7 @@ use Flow\ETL\Schema\Definition\BooleanDefinition; use Flow\ETL\Schema\Definition\DateTimeDefinition; use Flow\ETL\Schema\Definition\TimeDefinition; +use Flow\ETL\Schema\Definition\UnionDefinition; use Flow\ETL\Schema\Metadata; use Flow\ETL\Tests\FlowTestCase; use Generator; @@ -22,6 +23,12 @@ use function Flow\ETL\DSL\string_schema; use function Flow\ETL\DSL\time_entry; use function Flow\ETL\DSL\time_schema; +use function Flow\ETL\DSL\union_schema; +use function Flow\Types\DSL\type_boolean; +use function Flow\Types\DSL\type_integer; +use function Flow\Types\DSL\type_string; +use function Flow\Types\DSL\type_time; +use function Flow\Types\DSL\type_union; final class TimeDefinitionTest extends FlowTestCase { @@ -283,4 +290,19 @@ public function test_type_returns_time_type(): void static::assertSame('time', $def->type()->toString()); } + + public function test_merge_with_union_containing_this_type_returns_union(): void + { + $merged = time_schema('col')->merge(union_schema('col', type_union(type_time(), type_boolean()))); + + static::assertInstanceOf(UnionDefinition::class, $merged); + static::assertSame('boolean|time', $merged->type()->toString()); + } + + public function test_merge_with_union_not_containing_this_type_throws_exception(): void + { + $this->expectException(RuntimeException::class); + + time_schema('col')->merge(union_schema('col', type_union(type_integer(), type_string()))); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UnionDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UnionDefinitionTest.php index 1916db8ac..45a46ef0e 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UnionDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UnionDefinitionTest.php @@ -20,13 +20,17 @@ use function Flow\ETL\DSL\bool_entry; use function Flow\ETL\DSL\definition_from_array; use function Flow\ETL\DSL\definition_from_type; +use function Flow\ETL\DSL\float_schema; use function Flow\ETL\DSL\int_entry; +use function Flow\ETL\DSL\int_schema; use function Flow\ETL\DSL\null_schema; use function Flow\ETL\DSL\str_entry; use function Flow\ETL\DSL\string_schema; use function Flow\ETL\DSL\union_schema; use function Flow\Types\DSL\type_boolean; +use function Flow\Types\DSL\type_class_string; use function Flow\Types\DSL\type_integer; +use function Flow\Types\DSL\type_list; use function Flow\Types\DSL\type_null; use function Flow\Types\DSL\type_optional; use function Flow\Types\DSL\type_string; @@ -66,11 +70,47 @@ public static function provideIsCompatibleCases(): Generator false, ]; - yield 'union with non union' => [ + yield 'union with its string member' => [ union_schema('col', type_union(type_string(), type_integer())), string_schema('col'), + true, + ]; + + yield 'union with its integer member' => [ + union_schema('col', type_union(type_string(), type_integer())), + int_schema('col'), + true, + ]; + + yield 'union with a type that is not a member' => [ + union_schema('col', type_union(type_string(), type_integer())), + float_schema('col'), + false, + ]; + + yield 'nullable union with nullable member' => [ + union_schema('col', type_union(type_string(), type_integer()), true), + int_schema('col', true), + true, + ]; + + yield 'not nullable union with nullable member' => [ + union_schema('col', type_union(type_string(), type_integer()), false), + int_schema('col', true), false, ]; + + yield 'union with optional member' => [ + union_schema('col', type_union(type_optional(type_string()), type_integer())), + string_schema('col'), + true, + ]; + + yield 'union with a member that has no definition' => [ + union_schema('col', type_union(type_class_string(), type_string())), + string_schema('col'), + true, + ]; } public static function provideMergeCases(): Generator @@ -156,6 +196,14 @@ public function test_is_compatible(Definition $definition, Definition $other, bo static::assertSame($expected, $definition->isCompatible($other)); } + public function test_is_compatible_with_list_of_union_elements(): void + { + static::assertTrue(definition_from_type( + 'col', + type_list(type_union(type_integer(), type_string())), + )->isCompatible(definition_from_type('col', type_list(type_integer())))); + } + public function test_is_same_with_different_metadata(): void { $def = union_schema('col', type_union(type_string(), type_integer()), false, Metadata::with('key', 'value1')); @@ -260,6 +308,49 @@ public function test_merge_with_incompatible_type_throws_exception(): void $def->merge(new BooleanDefinition('col')); } + public function test_merge_with_non_member_throws_exception(): void + { + $def = union_schema('col', type_union(type_string(), type_integer())); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'Cannot merge Flow\ETL\Schema\Definition\UnionDefinition with Flow\ETL\Schema\Definition\FloatDefinition', + ); + + $def->merge(float_schema('col')); + } + + public function test_merge_with_nullable_union_member_returns_nullable_union(): void + { + $merged = union_schema('col', type_union(type_string(), type_integer()), false)->merge(int_schema('col', true)); + + static::assertInstanceOf(UnionDefinition::class, $merged); + static::assertSame('integer|string', $merged->type()->toString()); + static::assertTrue($merged->isNullable()); + } + + public function test_merge_with_union_member_merges_metadata(): void + { + $merged = union_schema( + 'col', + type_union(type_string(), type_integer()), + false, + Metadata::with('a', '1'), + )->merge(int_schema('col', false, Metadata::with('b', '2'))); + + static::assertSame('1', $merged->metadata()->get('a')); + static::assertSame('2', $merged->metadata()->get('b')); + } + + public function test_merge_with_union_member_returns_union(): void + { + $merged = union_schema('col', type_union(type_string(), type_integer()))->merge(int_schema('col')); + + static::assertInstanceOf(UnionDefinition::class, $merged); + static::assertSame('integer|string', $merged->type()->toString()); + static::assertFalse($merged->isNullable()); + } + public function test_normalize(): void { $def = union_schema('col', type_union(type_string(), type_integer()), true, Metadata::with('key', 'value')); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UnionMembersTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UnionMembersTest.php new file mode 100644 index 000000000..29678a839 --- /dev/null +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UnionMembersTest.php @@ -0,0 +1,121 @@ +contains( + union_schema('col', type_union(type_integer(), type_string())), + int_schema('col'), + )); + } + + public function test_contains_member_regardless_of_the_nullability_of_the_union(): void + { + static::assertTrue((new UnionMembers())->contains( + union_schema('col', type_union(type_integer(), type_string()), false), + int_schema('col', true), + )); + } + + public function test_contains_rejects_non_member(): void + { + static::assertFalse((new UnionMembers())->contains( + union_schema('col', type_union(type_integer(), type_string())), + float_schema('col'), + )); + } + + public function test_definitions_are_nullable_so_membership_ignores_nullability(): void + { + $definitions = (new UnionMembers())->definitions(union_schema( + 'col', + type_union(type_integer(), type_string()), + false, + )); + + static::assertTrue($definitions[0]->isNullable()); + static::assertTrue($definitions[1]->isNullable()); + } + + public function test_definitions_maps_each_member(): void + { + $definitions = (new UnionMembers())->definitions(union_schema('col', type_union( + type_integer(), + type_string(), + ))); + + static::assertCount(2, $definitions); + static::assertInstanceOf(IntegerDefinition::class, $definitions[0]); + static::assertInstanceOf(StringDefinition::class, $definitions[1]); + } + + public function test_definitions_of_a_union_without_any_mappable_member(): void + { + static::assertSame( + [], + (new UnionMembers())->definitions(union_schema('col', type_union(type_class_string(), type_object()))), + ); + } + + public function test_definitions_skip_members_that_cannot_be_mapped(): void + { + $definitions = (new UnionMembers())->definitions(union_schema('col', type_union( + type_class_string(), + type_string(), + ))); + + static::assertCount(1, $definitions); + static::assertInstanceOf(StringDefinition::class, $definitions[0]); + } + + public function test_definitions_unwrap_optional_member_to_its_base(): void + { + $definitions = (new UnionMembers())->definitions(union_schema('col', type_union( + type_optional(type_string()), + type_integer(), + ))); + + static::assertCount(2, $definitions); + static::assertInstanceOf(StringDefinition::class, $definitions[0]); + static::assertInstanceOf(IntegerDefinition::class, $definitions[1]); + } + + public function test_definitions_use_the_reference_of_the_union(): void + { + static::assertSame( + 'col', + (new UnionMembers())->definitions(union_schema('col', type_union(type_integer(), type_string())))[0] + ->entry() + ->name(), + ); + } + + public function test_unmappable_member_does_not_stop_the_scan(): void + { + static::assertTrue((new UnionMembers())->contains( + union_schema('col', type_union(type_class_string(), type_string())), + string_schema('col'), + )); + } +} diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UuidDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UuidDefinitionTest.php index e02888d42..e483de010 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UuidDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UuidDefinitionTest.php @@ -8,6 +8,7 @@ use Flow\ETL\Row\Entry\UuidEntry; use Flow\ETL\Schema\Definition; use Flow\ETL\Schema\Definition\BooleanDefinition; +use Flow\ETL\Schema\Definition\UnionDefinition; use Flow\ETL\Schema\Definition\UuidDefinition; use Flow\ETL\Schema\Metadata; use Flow\ETL\Tests\FlowTestCase; @@ -17,8 +18,14 @@ use function Flow\ETL\DSL\int_entry; use function Flow\ETL\DSL\null_schema; use function Flow\ETL\DSL\string_schema; +use function Flow\ETL\DSL\union_schema; use function Flow\ETL\DSL\uuid_entry; use function Flow\ETL\DSL\uuid_schema; +use function Flow\Types\DSL\type_boolean; +use function Flow\Types\DSL\type_integer; +use function Flow\Types\DSL\type_string; +use function Flow\Types\DSL\type_union; +use function Flow\Types\DSL\type_uuid; final class UuidDefinitionTest extends FlowTestCase { @@ -251,4 +258,19 @@ public function test_type_returns_uuid_type(): void static::assertSame('uuid', $def->type()->toString()); } + + public function test_merge_with_union_containing_this_type_returns_union(): void + { + $merged = uuid_schema('col')->merge(union_schema('col', type_union(type_uuid(), type_boolean()))); + + static::assertInstanceOf(UnionDefinition::class, $merged); + static::assertSame('boolean|uuid', $merged->type()->toString()); + } + + public function test_merge_with_union_not_containing_this_type_throws_exception(): void + { + $this->expectException(RuntimeException::class); + + uuid_schema('col')->merge(union_schema('col', type_union(type_integer(), type_string()))); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/XMLDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/XMLDefinitionTest.php index c9fc14c43..1fb43f4ac 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/XMLDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/XMLDefinitionTest.php @@ -8,6 +8,7 @@ use Flow\ETL\Row\Entry\XMLEntry; use Flow\ETL\Schema\Definition; use Flow\ETL\Schema\Definition\BooleanDefinition; +use Flow\ETL\Schema\Definition\UnionDefinition; use Flow\ETL\Schema\Definition\XMLDefinition; use Flow\ETL\Schema\Metadata; use Flow\ETL\Tests\FlowTestCase; @@ -17,8 +18,14 @@ use function Flow\ETL\DSL\int_entry; use function Flow\ETL\DSL\null_schema; use function Flow\ETL\DSL\string_schema; +use function Flow\ETL\DSL\union_schema; use function Flow\ETL\DSL\xml_entry; use function Flow\ETL\DSL\xml_schema; +use function Flow\Types\DSL\type_boolean; +use function Flow\Types\DSL\type_integer; +use function Flow\Types\DSL\type_string; +use function Flow\Types\DSL\type_union; +use function Flow\Types\DSL\type_xml; final class XMLDefinitionTest extends FlowTestCase { @@ -251,4 +258,19 @@ public function test_type_returns_xml_type(): void static::assertSame('xml', $def->type()->toString()); } + + public function test_merge_with_union_containing_this_type_returns_union(): void + { + $merged = xml_schema('col')->merge(union_schema('col', type_union(type_xml(), type_boolean()))); + + static::assertInstanceOf(UnionDefinition::class, $merged); + static::assertSame('boolean|xml', $merged->type()->toString()); + } + + public function test_merge_with_union_not_containing_this_type_throws_exception(): void + { + $this->expectException(RuntimeException::class); + + xml_schema('col')->merge(union_schema('col', type_union(type_integer(), type_string()))); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/XMLElementDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/XMLElementDefinitionTest.php index 3e40ce6b1..6192635b2 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/XMLElementDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/XMLElementDefinitionTest.php @@ -8,6 +8,7 @@ use Flow\ETL\Row\Entry\XMLElementEntry; use Flow\ETL\Schema\Definition; use Flow\ETL\Schema\Definition\BooleanDefinition; +use Flow\ETL\Schema\Definition\UnionDefinition; use Flow\ETL\Schema\Definition\XMLElementDefinition; use Flow\ETL\Schema\Metadata; use Flow\ETL\Tests\FlowTestCase; @@ -17,8 +18,14 @@ use function Flow\ETL\DSL\int_entry; use function Flow\ETL\DSL\null_schema; use function Flow\ETL\DSL\string_schema; +use function Flow\ETL\DSL\union_schema; use function Flow\ETL\DSL\xml_element_entry; use function Flow\ETL\DSL\xml_element_schema; +use function Flow\Types\DSL\type_boolean; +use function Flow\Types\DSL\type_integer; +use function Flow\Types\DSL\type_string; +use function Flow\Types\DSL\type_union; +use function Flow\Types\DSL\type_xml_element; final class XMLElementDefinitionTest extends FlowTestCase { @@ -251,4 +258,19 @@ public function test_type_returns_xml_element_type(): void static::assertSame('xml_element', $def->type()->toString()); } + + public function test_merge_with_union_containing_this_type_returns_union(): void + { + $merged = xml_element_schema('col')->merge(union_schema('col', type_union(type_xml_element(), type_boolean()))); + + static::assertInstanceOf(UnionDefinition::class, $merged); + static::assertSame('boolean|xml_element', $merged->type()->toString()); + } + + public function test_merge_with_union_not_containing_this_type_throws_exception(): void + { + $this->expectException(RuntimeException::class); + + xml_element_schema('col')->merge(union_schema('col', type_union(type_integer(), type_string()))); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/StrictValidatorTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/StrictValidatorTest.php index 8635cba7f..d30fbd85b 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/StrictValidatorTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/StrictValidatorTest.php @@ -116,7 +116,7 @@ public function test_given_schema_with_mismatched_union_definition(): void { $context = schema_validate( expected: schema(union_schema('value', type_union(type_string(), type_integer()))), - given: schema(string_schema('value')), + given: schema(bool_schema('value')), validator: schema_strict_validator(), ); @@ -124,12 +124,23 @@ public function test_given_schema_with_mismatched_union_definition(): void static::assertEquals( [new MismatchedDefinition( union_schema('value', type_union(type_string(), type_integer())), - string_schema('value'), + bool_schema('value'), )], $context->mismatchedDefinitions(), ); } + public function test_given_schema_with_union_member_definition(): void + { + static::assertTrue( + schema_validate( + expected: schema(union_schema('value', type_union(type_string(), type_integer()))), + given: schema(string_schema('value')), + validator: schema_strict_validator(), + )->isValid(), + ); + } + public function test_rows_with_a_missing_entry(): void { static::assertFalse( From f0b21b1825341c72ca5a13402f4a3345dc3abf96 Mon Sep 17 00:00:00 2001 From: Norbert Orzechowicz Date: Sat, 8 Aug 2026 13:08:40 +0200 Subject: [PATCH 2/5] fix(flow-php/etl): declared array reaching an entr - cast values whose type definition_from_type() rewrote - normalize ArrayType union members to json - document type_array() as a json declaration --- documentation/components/core/schema.md | 14 +++ .../etl/src/Flow/ETL/Row/EntryFactory.php | 27 ++++-- .../ETL/Schema/Definition/UnionDefinition.php | 8 +- .../Schema/Definition/UnionTypeNormalizer.php | 52 ++++++++++ .../Tests/Integration/Function/CastTest.php | 12 +++ .../ETL/Tests/Unit/Row/EntryFactoryTest.php | 28 ++++++ .../Schema/Definition/UnionDefinitionTest.php | 29 ++++++ .../Definition/UnionTypeNormalizerTest.php | 95 +++++++++++++++++++ .../Unit/Schema/SelectiveValidatorTest.php | 15 +++ .../Tests/Unit/Schema/StrictValidatorTest.php | 15 +++ 10 files changed, 287 insertions(+), 8 deletions(-) create mode 100644 src/core/etl/src/Flow/ETL/Schema/Definition/UnionTypeNormalizer.php create mode 100644 src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UnionTypeNormalizerTest.php diff --git a/documentation/components/core/schema.md b/documentation/components/core/schema.md index 6f6226807..971b3c240 100644 --- a/documentation/components/core/schema.md +++ b/documentation/components/core/schema.md @@ -16,6 +16,20 @@ A schema consists of entry definitions that specify: - **Nullable**: Whether NULL values are permitted - **Metadata**: Key-value pairs for additional context +## Arrays in a Schema + +`type_array()` declares a `json` column. The data layer has no array type - parquet, Spark and Floe all express an +array as a JSON object or a JSON collection - so a declared `array` is projected onto `json`, and the value is +stored as a `Flow\Types\Value\Json`, which preserves whether it was an object or a collection. + +```php +definition_from_type('tags', type_array())->type()->toString(); // "json" +union_schema('tags', type_union(type_string(), type_array()))->type()->toString(); // "json|string" +``` + +Declare the concrete shape whenever it is known - `type_list()`, `type_map()` or `type_structure()` keep element typing +that `json` throws away, and adapters can map them onto native nested types. + ## Schema Validation Strategies Flow PHP provides two built-in validation strategies: diff --git a/src/core/etl/src/Flow/ETL/Row/EntryFactory.php b/src/core/etl/src/Flow/ETL/Row/EntryFactory.php index 271ea6e65..a75ff7e3c 100644 --- a/src/core/etl/src/Flow/ETL/Row/EntryFactory.php +++ b/src/core/etl/src/Flow/ETL/Row/EntryFactory.php @@ -22,6 +22,7 @@ use function array_values; use function Flow\ETL\DSL\definition_from_type; +use function Flow\Types\DSL\type_equals; use function Flow\Types\DSL\type_string; final class EntryFactory @@ -90,6 +91,8 @@ public function fromDefinition(Definition $definition, mixed $value): Entry */ private function build(string $name, mixed $value, Type $type, ?Metadata $metadata, bool $cast): Entry { + $declaredType = $type; + try { if ($type instanceof OptionalType) { $type = $type->base(); @@ -127,7 +130,12 @@ private function build(string $name, mixed $value, Type $type, ?Metadata $metada $definition = definition_from_type($name, $type, $value === null, $metadata); - return $this->fromDefinition($definition, $this->prepareValue($value, $definition->type(), $cast)); + return $this->fromDefinition($definition, $this->prepareValue( + $value, + $declaredType, + $definition->type(), + $cast, + )); // @mago-ignore analysis:avoid-catching-error } catch (InvalidArgumentException|CastingException|TypeError $e) { @@ -139,18 +147,23 @@ private function build(string $name, mixed $value, Type $type, ?Metadata $metada } /** - * @param Type $type + * @param Type $declaredType + * @param Type $entryType */ - private function prepareValue(mixed $value, Type $type, bool $cast): mixed + private function prepareValue(mixed $value, Type $declaredType, Type $entryType, bool $cast): mixed { - if ($value === null || !$cast) { + if ($value === null) { + return $value; + } + + if (!$cast && type_equals($declaredType, $entryType)) { return $value; } - if ($type instanceof ListType) { - return array_values($type->cast($value)); + if ($entryType instanceof ListType) { + return array_values($entryType->cast($value)); } - return $type->cast($value); + return $entryType->cast($value); } } diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/UnionDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/UnionDefinition.php index eaccf2845..0d1180ee7 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/UnionDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/UnionDefinition.php @@ -26,17 +26,23 @@ private Reference $ref; + /** + * @var UnionType + */ + private UnionType $type; + /** * @param UnionType $type */ public function __construct( string|Reference $ref, - private UnionType $type, + UnionType $type, private bool $nullable = false, ?Metadata $metadata = null, ) { $this->ref = EntryReference::init($ref); $this->metadata = $metadata ?? Metadata::empty(); + $this->type = (new UnionTypeNormalizer())->normalize($type); } /** diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/UnionTypeNormalizer.php b/src/core/etl/src/Flow/ETL/Schema/Definition/UnionTypeNormalizer.php new file mode 100644 index 000000000..1e8119fdc --- /dev/null +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/UnionTypeNormalizer.php @@ -0,0 +1,52 @@ + $type + * + * @return UnionType + */ + public function normalize(UnionType $type): UnionType + { + $members = []; + $changed = false; + + foreach ($type->types()->all() as $member) { + $normalizedMember = match (true) { + $member instanceof ArrayType => type_json(), + $member instanceof OptionalType && $member->base() instanceof ArrayType => type_optional(type_json()), + default => $member, + }; + + if ($normalizedMember !== $member) { + $changed = true; + } + + $members[] = $normalizedMember; + } + + if (!$changed) { + return $type; + } + + $union = null; + + foreach ($members as $member) { + $union = $union === null ? $member : new UnionType($union, $member); + } + + return $union instanceof UnionType ? $union : $type; + } +} diff --git a/src/core/etl/tests/Flow/ETL/Tests/Integration/Function/CastTest.php b/src/core/etl/tests/Flow/ETL/Tests/Integration/Function/CastTest.php index 265458277..ed159b669 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Integration/Function/CastTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Integration/Function/CastTest.php @@ -6,6 +6,7 @@ use DateTimeImmutable; use Flow\ETL\Memory\ArrayMemory; +use Flow\ETL\Row\Entry\JsonEntry; use Flow\ETL\Tests\FlowTestCase; use function Flow\ETL\DSL\df; @@ -36,6 +37,17 @@ public function test_cast(): void ); } + public function test_cast_to_array_produces_json_entry(): void + { + $rows = df() + ->read(from_array([['a' => '[1,2,3]']])) + ->withEntry('b', ref('a')->cast('array')) + ->fetch(); + + static::assertInstanceOf(JsonEntry::class, $rows->first()->entries()->get('b')); + static::assertSame('[1,2,3]', $rows->first()->entries()->get('b')->toString()); + } + public function test_cast_non_deterministic_values(): void { $row = df() diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Row/EntryFactoryTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Row/EntryFactoryTest.php index 8c09d7e2d..e64f50337 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Row/EntryFactoryTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Row/EntryFactoryTest.php @@ -62,6 +62,7 @@ use function Flow\ETL\DSL\uuid_schema; use function Flow\ETL\DSL\xml_entry; use function Flow\ETL\DSL\xml_schema; +use function Flow\Types\DSL\type_array; use function Flow\Types\DSL\type_datetime; use function Flow\Types\DSL\type_float; use function Flow\Types\DSL\type_integer; @@ -69,6 +70,7 @@ use function Flow\Types\DSL\type_map; use function Flow\Types\DSL\type_string; use function Flow\Types\DSL\type_structure; +use function Flow\Types\DSL\type_time_zone; use function Flow\Types\DSL\type_union; final class EntryFactoryTest extends FlowTestCase @@ -193,6 +195,32 @@ public function test_create_trusted_scalar_with_type(): void static::assertEquals(int_entry('e', 5), (new EntryFactory())->create('e', 5, type_integer())); } + public function test_create_with_array_type_and_null_value(): void + { + static::assertEquals(json_entry('e', null), (new EntryFactory())->create('e', null, type_array())); + } + + public function test_create_with_array_type_normalizes_object_shaped_value_to_json_object(): void + { + static::assertEquals( + json_object_entry('e', ['a' => 1]), + (new EntryFactory())->create('e', ['a' => 1], type_array()), + ); + } + + public function test_create_with_array_type_normalizes_value_to_json(): void + { + static::assertEquals(json_entry('e', [1, 2]), (new EntryFactory())->create('e', [1, 2], type_array())); + } + + public function test_create_with_time_zone_type_normalizes_value_to_string(): void + { + static::assertEquals( + str_entry('e', 'UTC'), + (new EntryFactory())->create('e', new DateTimeZone('UTC'), type_time_zone()), + ); + } + public function test_from_definition_instantiates_a_native_value(): void { static::assertEquals(int_entry('e', 5), (new EntryFactory())->fromDefinition(integer_schema('e'), 5)); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UnionDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UnionDefinitionTest.php index 45a46ef0e..090494509 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UnionDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UnionDefinitionTest.php @@ -27,6 +27,7 @@ use function Flow\ETL\DSL\str_entry; use function Flow\ETL\DSL\string_schema; use function Flow\ETL\DSL\union_schema; +use function Flow\Types\DSL\type_array; use function Flow\Types\DSL\type_boolean; use function Flow\Types\DSL\type_class_string; use function Flow\Types\DSL\type_integer; @@ -139,6 +140,21 @@ public function test_add_metadata(): void static::assertFalse($def->metadata()->has('key')); } + public function test_array_member_is_normalized_to_json(): void + { + static::assertSame( + 'json|string', + union_schema('col', type_union(type_string(), type_array()))->type()->toString(), + ); + } + + public function test_normalized_array_member_survives_a_normalize_round_trip(): void + { + $def = union_schema('col', type_union(type_string(), type_array())); + + static::assertEquals($def, definition_from_array($def->normalize())); + } + public function test_definition_from_type_creates_union_definition(): void { $definition = definition_from_type('col', type_union(type_string(), type_integer())); @@ -404,4 +420,17 @@ public function test_type_returns_union_type(): void static::assertSame('integer|string', $def->type()->toString()); } + + public function test_union_member_and_standalone_array_resolve_to_the_same_definition(): void + { + $standalone = definition_from_type('col', type_array()); + $member = definition_from_type( + 'col', + union_schema('col', type_union(type_string(), type_array()))->type()->types()->all()[1], + ); + + static::assertInstanceOf(JsonDefinition::class, $standalone); + static::assertInstanceOf(JsonDefinition::class, $member); + static::assertTrue($standalone->isSame($member)); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UnionTypeNormalizerTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UnionTypeNormalizerTest.php new file mode 100644 index 000000000..a056fc3f3 --- /dev/null +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UnionTypeNormalizerTest.php @@ -0,0 +1,95 @@ + $type */ + $type = type_union(type_string(), type_integer(), type_array()); + + static::assertSame( + 'integer|json|string', + (new UnionTypeNormalizer()) + ->normalize($type) + ->toString(), + ); + } + + public function test_array_member_is_normalized_to_json(): void + { + /** @var UnionType $type */ + $type = type_union(type_string(), type_array()); + + static::assertSame( + 'json|string', + (new UnionTypeNormalizer()) + ->normalize($type) + ->toString(), + ); + } + + public function test_array_nested_in_a_container_member_is_not_normalized(): void + { + /** @var UnionType $type */ + $type = type_union(type_list(type_array()), type_map(type_string(), type_array())); + + static::assertSame( + 'list>|map>', + (new UnionTypeNormalizer()) + ->normalize($type) + ->toString(), + ); + } + + public function test_duplicate_json_members_are_not_deduplicated(): void + { + /** @var UnionType $type */ + $type = type_union(type_json(), type_array()); + + static::assertSame( + 2, + (new UnionTypeNormalizer()) + ->normalize($type) + ->types() + ->count(), + ); + } + + public function test_optional_array_member_is_normalized_to_optional_json(): void + { + /** @var UnionType $type */ + $type = type_union(type_string(), type_optional(type_array())); + + static::assertSame( + 'json|null|string', + (new UnionTypeNormalizer()) + ->normalize($type) + ->toString(), + ); + } + + public function test_union_without_array_member_is_returned_untouched(): void + { + /** @var UnionType $type */ + $type = type_union(type_string(), type_optional(type_integer())); + + static::assertSame($type, (new UnionTypeNormalizer())->normalize($type)); + } +} diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/SelectiveValidatorTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/SelectiveValidatorTest.php index f28ba5bd3..adbffa5c5 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/SelectiveValidatorTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/SelectiveValidatorTest.php @@ -9,12 +9,16 @@ use Flow\ETL\Tests\FlowTestCase; use function Flow\ETL\DSL\bool_schema; +use function Flow\ETL\DSL\data_frame; +use function Flow\ETL\DSL\definition_from_type; +use function Flow\ETL\DSL\from_array; use function Flow\ETL\DSL\integer_schema; use function Flow\ETL\DSL\null_schema; use function Flow\ETL\DSL\schema; use function Flow\ETL\DSL\schema_selective_validator; use function Flow\ETL\DSL\schema_validate; use function Flow\ETL\DSL\string_schema; +use function Flow\Types\DSL\type_array; final class SelectiveValidatorTest extends FlowTestCase { @@ -57,6 +61,17 @@ public function test_given_schema_nullable_expected_non_nullable(): void ); } + public function test_given_schema_inferred_from_empty_arrays_against_declared_array_type(): void + { + static::assertTrue( + schema_validate( + expected: schema(integer_schema('id'), definition_from_type('a', type_array())), + given: data_frame()->read(from_array([['id' => 1, 'a' => []], ['id' => 2, 'a' => []]]))->schema(), + validator: schema_selective_validator(), + )->isValid(), + ); + } + public function test_schema_with_a_missing_entry(): void { static::assertFalse( diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/StrictValidatorTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/StrictValidatorTest.php index d30fbd85b..902facdbd 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/StrictValidatorTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/StrictValidatorTest.php @@ -8,6 +8,9 @@ use Flow\ETL\Tests\FlowTestCase; use function Flow\ETL\DSL\bool_schema; +use function Flow\ETL\DSL\data_frame; +use function Flow\ETL\DSL\definition_from_type; +use function Flow\ETL\DSL\from_array; use function Flow\ETL\DSL\integer_schema; use function Flow\ETL\DSL\list_schema; use function Flow\ETL\DSL\null_schema; @@ -17,6 +20,7 @@ use function Flow\ETL\DSL\string_schema; use function Flow\ETL\DSL\structure_schema; use function Flow\ETL\DSL\union_schema; +use function Flow\Types\DSL\type_array; use function Flow\Types\DSL\type_float; use function Flow\Types\DSL\type_integer; use function Flow\Types\DSL\type_list; @@ -141,6 +145,17 @@ public function test_given_schema_with_union_member_definition(): void ); } + public function test_given_schema_inferred_from_empty_arrays_against_declared_array_type(): void + { + static::assertTrue( + schema_validate( + expected: schema(integer_schema('id'), definition_from_type('a', type_array())), + given: data_frame()->read(from_array([['id' => 1, 'a' => []], ['id' => 2, 'a' => []]]))->schema(), + validator: schema_strict_validator(), + )->isValid(), + ); + } + public function test_rows_with_a_missing_entry(): void { static::assertFalse( From edfb3d0bf413ddfa9da8ee8c9c442a254b693e49 Mon Sep 17 00:00:00 2001 From: Norbert Orzechowicz Date: Sat, 8 Aug 2026 14:45:59 +0200 Subject: [PATCH 3/5] fix(flow-php/etl): Definition::matches() no longer treats nullable as "any type" - nullable now widens the accepted value domain by exactly {null} - non-nullable definitions reject a null value - NullDefinition matches only a null value --- .../Schema/Definition/BooleanDefinition.php | 8 ++--- .../ETL/Schema/Definition/DateDefinition.php | 8 ++--- .../Schema/Definition/DateTimeDefinition.php | 8 ++--- .../ETL/Schema/Definition/EnumDefinition.php | 8 ++--- .../ETL/Schema/Definition/FloatDefinition.php | 8 ++--- .../ETL/Schema/Definition/HTMLDefinition.php | 8 ++--- .../Definition/HTMLElementDefinition.php | 8 ++--- .../Schema/Definition/IntegerDefinition.php | 8 ++--- .../ETL/Schema/Definition/JsonDefinition.php | 8 ++--- .../ETL/Schema/Definition/ListDefinition.php | 8 ++--- .../ETL/Schema/Definition/MapDefinition.php | 8 ++--- .../ETL/Schema/Definition/NullDefinition.php | 2 +- .../Schema/Definition/StringDefinition.php | 8 ++--- .../Schema/Definition/StructureDefinition.php | 8 ++--- .../ETL/Schema/Definition/TimeDefinition.php | 8 ++--- .../ETL/Schema/Definition/UnionDefinition.php | 8 ++--- .../ETL/Schema/Definition/UuidDefinition.php | 8 ++--- .../ETL/Schema/Definition/XMLDefinition.php | 8 ++--- .../Definition/XMLElementDefinition.php | 8 ++--- .../Definition/BooleanDefinitionTest.php | 30 ++++++++++++++++- .../Schema/Definition/DateDefinitionTest.php | 30 ++++++++++++++++- .../Definition/DateTimeDefinitionTest.php | 30 ++++++++++++++++- .../Schema/Definition/EnumDefinitionTest.php | 30 ++++++++++++++++- .../Schema/Definition/FloatDefinitionTest.php | 30 ++++++++++++++++- .../Schema/Definition/HTMLDefinitionTest.php | 31 +++++++++++++++++- .../Definition/HTMLElementDefinitionTest.php | 31 +++++++++++++++++- .../Definition/IntegerDefinitionTest.php | 30 ++++++++++++++++- .../Schema/Definition/JsonDefinitionTest.php | 30 ++++++++++++++++- .../Schema/Definition/ListDefinitionTest.php | 30 ++++++++++++++++- .../Schema/Definition/MapDefinitionTest.php | 30 ++++++++++++++++- .../Schema/Definition/NullDefinitionTest.php | 9 ++++-- .../Definition/StringDefinitionTest.php | 30 ++++++++++++++++- .../Definition/StructureDefinitionTest.php | 32 ++++++++++++++++++- .../Schema/Definition/TimeDefinitionTest.php | 30 ++++++++++++++++- .../Schema/Definition/UnionDefinitionTest.php | 32 +++++++++++++++++-- .../Schema/Definition/UuidDefinitionTest.php | 30 ++++++++++++++++- .../Schema/Definition/XMLDefinitionTest.php | 30 ++++++++++++++++- .../Definition/XMLElementDefinitionTest.php | 30 ++++++++++++++++- 38 files changed, 607 insertions(+), 94 deletions(-) diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/BooleanDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/BooleanDefinition.php index 424bd8ec6..a57239975 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/BooleanDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/BooleanDefinition.php @@ -92,14 +92,14 @@ public function makeNullable(bool $nullable = true): static public function matches(Entry $entry): bool { - if ($this->isNullable() && $entry->is($this->ref)) { - return true; - } - if (!$entry->is($this->ref)) { return false; } + if ($entry->value() === null) { + return $this->isNullable(); + } + return $entry->type() instanceof BooleanType; } diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/DateDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/DateDefinition.php index 7a5a2ffd5..efa86e90c 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/DateDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/DateDefinition.php @@ -92,14 +92,14 @@ public function makeNullable(bool $nullable = true): static public function matches(Entry $entry): bool { - if ($this->isNullable() && $entry->is($this->ref)) { - return true; - } - if (!$entry->is($this->ref)) { return false; } + if ($entry->value() === null) { + return $this->isNullable(); + } + return $entry->type() instanceof DateType; } diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/DateTimeDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/DateTimeDefinition.php index 1066849db..8d5dea829 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/DateTimeDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/DateTimeDefinition.php @@ -92,14 +92,14 @@ public function makeNullable(bool $nullable = true): static public function matches(Entry $entry): bool { - if ($this->isNullable() && $entry->is($this->ref)) { - return true; - } - if (!$entry->is($this->ref)) { return false; } + if ($entry->value() === null) { + return $this->isNullable(); + } + return $entry->type() instanceof DateTimeType; } diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/EnumDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/EnumDefinition.php index ed5cd0dab..fe97293aa 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/EnumDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/EnumDefinition.php @@ -112,14 +112,14 @@ public function makeNullable(bool $nullable = true): static public function matches(Entry $entry): bool { - if ($this->isNullable() && $entry->is($this->ref)) { - return true; - } - if (!$entry->is($this->ref)) { return false; } + if ($entry->value() === null) { + return $this->isNullable(); + } + return $entry->type() instanceof EnumType; } diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/FloatDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/FloatDefinition.php index 946be9042..c73d3ae70 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/FloatDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/FloatDefinition.php @@ -92,14 +92,14 @@ public function makeNullable(bool $nullable = true): static public function matches(Entry $entry): bool { - if ($this->isNullable() && $entry->is($this->ref)) { - return true; - } - if (!$entry->is($this->ref)) { return false; } + if ($entry->value() === null) { + return $this->isNullable(); + } + return $entry->type() instanceof FloatType; } diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/HTMLDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/HTMLDefinition.php index 1c1f5f2ce..58a4f7a34 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/HTMLDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/HTMLDefinition.php @@ -93,14 +93,14 @@ public function makeNullable(bool $nullable = true): static public function matches(Entry $entry): bool { - if ($this->isNullable() && $entry->is($this->ref)) { - return true; - } - if (!$entry->is($this->ref)) { return false; } + if ($entry->value() === null) { + return $this->isNullable(); + } + return $entry->type() instanceof HTMLType; } diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/HTMLElementDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/HTMLElementDefinition.php index b72454d27..9ab45f0c9 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/HTMLElementDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/HTMLElementDefinition.php @@ -93,14 +93,14 @@ public function makeNullable(bool $nullable = true): static public function matches(Entry $entry): bool { - if ($this->isNullable() && $entry->is($this->ref)) { - return true; - } - if (!$entry->is($this->ref)) { return false; } + if ($entry->value() === null) { + return $this->isNullable(); + } + return $entry->type() instanceof HTMLElementType; } diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/IntegerDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/IntegerDefinition.php index 49313ddb2..6e834775d 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/IntegerDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/IntegerDefinition.php @@ -92,14 +92,14 @@ public function makeNullable(bool $nullable = true): static public function matches(Entry $entry): bool { - if ($this->isNullable() && $entry->is($this->ref)) { - return true; - } - if (!$entry->is($this->ref)) { return false; } + if ($entry->value() === null) { + return $this->isNullable(); + } + return $entry->type() instanceof IntegerType; } diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/JsonDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/JsonDefinition.php index 3d4528c85..3f05adb4b 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/JsonDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/JsonDefinition.php @@ -93,14 +93,14 @@ public function makeNullable(bool $nullable = true): static public function matches(Entry $entry): bool { - if ($this->isNullable() && $entry->is($this->ref)) { - return true; - } - if (!$entry->is($this->ref)) { return false; } + if ($entry->value() === null) { + return $this->isNullable(); + } + return $entry->type() instanceof JsonType; } diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/ListDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/ListDefinition.php index a0526d5a3..f8409d2a7 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/ListDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/ListDefinition.php @@ -118,14 +118,14 @@ public function makeNullable(bool $nullable = true): static public function matches(Entry $entry): bool { - if ($this->isNullable() && $entry->is($this->ref)) { - return true; - } - if (!$entry->is($this->ref)) { return false; } + if ($entry->value() === null) { + return $this->isNullable(); + } + return $entry->type() instanceof ListType; } diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/MapDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/MapDefinition.php index 0f1de185e..05069a3aa 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/MapDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/MapDefinition.php @@ -125,14 +125,14 @@ public function makeNullable(bool $nullable = true): static public function matches(Entry $entry): bool { - if ($this->isNullable() && $entry->is($this->ref)) { - return true; - } - if (!$entry->is($this->ref)) { return false; } + if ($entry->value() === null) { + return $this->isNullable(); + } + return $entry->type() instanceof MapType; } diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/NullDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/NullDefinition.php index 586e68acd..26c4fb7e2 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/NullDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/NullDefinition.php @@ -84,7 +84,7 @@ public function makeNullable(bool $nullable = true): static public function matches(Entry $entry): bool { - return $entry->is($this->ref); + return $entry->is($this->ref) && $entry->value() === null; } public function merge(Definition $definition): Definition diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/StringDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/StringDefinition.php index 483ac2eaa..fcf0546bb 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/StringDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/StringDefinition.php @@ -92,14 +92,14 @@ public function makeNullable(bool $nullable = true): static public function matches(Entry $entry): bool { - if ($this->isNullable() && $entry->is($this->ref)) { - return true; - } - if (!$entry->is($this->ref)) { return false; } + if ($entry->value() === null) { + return $this->isNullable(); + } + return $entry->type() instanceof StringType; } diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/StructureDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/StructureDefinition.php index 02bfe8b65..f4d27f25a 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/StructureDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/StructureDefinition.php @@ -138,14 +138,14 @@ public function makeNullable(bool $nullable = true): static public function matches(Entry $entry): bool { - if ($this->isNullable() && $entry->is($this->ref)) { - return true; - } - if (!$entry->is($this->ref)) { return false; } + if ($entry->value() === null) { + return $this->isNullable(); + } + return $entry->type() instanceof StructureType; } diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/TimeDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/TimeDefinition.php index 66c84e2d1..53b6338e1 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/TimeDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/TimeDefinition.php @@ -92,14 +92,14 @@ public function makeNullable(bool $nullable = true): static public function matches(Entry $entry): bool { - if ($this->isNullable() && $entry->is($this->ref)) { - return true; - } - if (!$entry->is($this->ref)) { return false; } + if ($entry->value() === null) { + return $this->isNullable(); + } + return $entry->type() instanceof TimeType; } diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/UnionDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/UnionDefinition.php index 0d1180ee7..d486023a9 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/UnionDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/UnionDefinition.php @@ -100,14 +100,14 @@ public function makeNullable(bool $nullable = true): static public function matches(Entry $entry): bool { - if ($this->isNullable() && $entry->is($this->ref)) { - return true; - } - if (!$entry->is($this->ref)) { return false; } + if ($entry->value() === null) { + return $this->isNullable(); + } + return $this->type->isValid($entry->value()); } diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/UuidDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/UuidDefinition.php index 451c43004..12f800776 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/UuidDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/UuidDefinition.php @@ -93,14 +93,14 @@ public function makeNullable(bool $nullable = true): static public function matches(Entry $entry): bool { - if ($this->isNullable() && $entry->is($this->ref)) { - return true; - } - if (!$entry->is($this->ref)) { return false; } + if ($entry->value() === null) { + return $this->isNullable(); + } + return $entry->type() instanceof UuidType; } diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/XMLDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/XMLDefinition.php index bd1d9bdb6..a8c3982a0 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/XMLDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/XMLDefinition.php @@ -93,14 +93,14 @@ public function makeNullable(bool $nullable = true): static public function matches(Entry $entry): bool { - if ($this->isNullable() && $entry->is($this->ref)) { - return true; - } - if (!$entry->is($this->ref)) { return false; } + if ($entry->value() === null) { + return $this->isNullable(); + } + return $entry->type() instanceof XMLType; } diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/XMLElementDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/XMLElementDefinition.php index 674da3b4a..0279d2113 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/XMLElementDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/XMLElementDefinition.php @@ -92,14 +92,14 @@ public function makeNullable(bool $nullable = true): static public function matches(Entry $entry): bool { - if ($this->isNullable() && $entry->is($this->ref)) { - return true; - } - if (!$entry->is($this->ref)) { return false; } + if ($entry->value() === null) { + return $this->isNullable(); + } + return $entry->type() instanceof XMLElementType; } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/BooleanDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/BooleanDefinitionTest.php index 455d825d8..b35daef3d 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/BooleanDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/BooleanDefinitionTest.php @@ -87,6 +87,13 @@ public function test_add_metadata(): void static::assertFalse($def->metadata()->has('key')); } + public function test_does_not_match_a_null_entry_when_not_nullable(): void + { + $def = bool_schema('col'); + + static::assertFalse($def->matches(bool_entry('col', null))); + } + public function test_does_not_match_entry_with_different_name(): void { $def = bool_schema('flag'); @@ -223,13 +230,34 @@ public function test_normalize(): void static::assertArrayHasKey('metadata', $normalized); } - public function test_nullable_matches_any_entry_with_same_name(): void + public function test_nullable_does_not_match_an_entry_of_a_different_type(): void + { + $def = bool_schema('col', true); + + static::assertFalse($def->matches(int_entry('col', 1))); + } + + public function test_nullable_matches_a_null_entry_with_same_name(): void { $def = bool_schema('col', true); static::assertTrue($def->matches(bool_entry('col', null))); } + public function test_nullable_matches_a_null_value_carried_by_an_entry_of_a_different_type(): void + { + $def = bool_schema('col', true); + + static::assertTrue($def->matches(int_entry('col', null))); + } + + public function test_nullable_matches_an_entry_with_a_non_null_value_of_its_type(): void + { + $def = bool_schema('col', true); + + static::assertTrue($def->matches(bool_entry('col', true))); + } + public function test_rename(): void { $def = bool_schema('flag'); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/DateDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/DateDefinitionTest.php index 03a7e4301..edc47ab1a 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/DateDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/DateDefinitionTest.php @@ -106,6 +106,13 @@ public function test_add_metadata(): void static::assertFalse($def->metadata()->has('key')); } + public function test_does_not_match_a_null_entry_when_not_nullable(): void + { + $def = date_schema('col'); + + static::assertFalse($def->matches(date_entry('col', null))); + } + public function test_does_not_match_entry_with_different_name(): void { $def = date_schema('created_at'); @@ -256,13 +263,34 @@ public function test_normalize(): void static::assertArrayHasKey('metadata', $normalized); } - public function test_nullable_matches_any_entry_with_same_name(): void + public function test_nullable_does_not_match_an_entry_of_a_different_type(): void + { + $def = date_schema('col', true); + + static::assertFalse($def->matches(int_entry('col', 1))); + } + + public function test_nullable_matches_a_null_entry_with_same_name(): void { $def = date_schema('col', true); static::assertTrue($def->matches(date_entry('col', null))); } + public function test_nullable_matches_a_null_value_carried_by_an_entry_of_a_different_type(): void + { + $def = date_schema('col', true); + + static::assertTrue($def->matches(int_entry('col', null))); + } + + public function test_nullable_matches_an_entry_with_a_non_null_value_of_its_type(): void + { + $def = date_schema('col', true); + + static::assertTrue($def->matches(date_entry('col', '2024-01-15'))); + } + public function test_rename(): void { $def = date_schema('created_at'); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/DateTimeDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/DateTimeDefinitionTest.php index c2987859f..3d19bfdf3 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/DateTimeDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/DateTimeDefinitionTest.php @@ -105,6 +105,13 @@ public function test_add_metadata(): void static::assertFalse($def->metadata()->has('key')); } + public function test_does_not_match_a_null_entry_when_not_nullable(): void + { + $def = datetime_schema('col'); + + static::assertFalse($def->matches(datetime_entry('col', null))); + } + public function test_does_not_match_entry_with_different_name(): void { $def = datetime_schema('created_at'); @@ -255,13 +262,34 @@ public function test_normalize(): void static::assertArrayHasKey('metadata', $normalized); } - public function test_nullable_matches_any_entry_with_same_name(): void + public function test_nullable_does_not_match_an_entry_of_a_different_type(): void + { + $def = datetime_schema('col', true); + + static::assertFalse($def->matches(int_entry('col', 1))); + } + + public function test_nullable_matches_a_null_entry_with_same_name(): void { $def = datetime_schema('col', true); static::assertTrue($def->matches(datetime_entry('col', null))); } + public function test_nullable_matches_a_null_value_carried_by_an_entry_of_a_different_type(): void + { + $def = datetime_schema('col', true); + + static::assertTrue($def->matches(int_entry('col', null))); + } + + public function test_nullable_matches_an_entry_with_a_non_null_value_of_its_type(): void + { + $def = datetime_schema('col', true); + + static::assertTrue($def->matches(datetime_entry('col', '2024-01-15 10:30:00'))); + } + public function test_rename(): void { $def = datetime_schema('created_at'); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/EnumDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/EnumDefinitionTest.php index bd75e453c..a9741d404 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/EnumDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/EnumDefinitionTest.php @@ -92,6 +92,13 @@ public function test_add_metadata(): void static::assertFalse($def->metadata()->has('key')); } + public function test_does_not_match_a_null_entry_when_not_nullable(): void + { + $def = enum_schema('col', BackedStringEnum::class); + + static::assertFalse($def->matches(enum_entry('col', null))); + } + public function test_does_not_match_entry_with_different_name(): void { $def = enum_schema('status', BackedStringEnum::class); @@ -270,13 +277,34 @@ public function test_normalize(): void static::assertArrayHasKey('metadata', $normalized); } - public function test_nullable_matches_any_entry_with_same_name(): void + public function test_nullable_does_not_match_an_entry_of_a_different_type(): void + { + $def = enum_schema('col', BackedStringEnum::class, true); + + static::assertFalse($def->matches(int_entry('col', 1))); + } + + public function test_nullable_matches_a_null_entry_with_same_name(): void { $def = enum_schema('col', BackedStringEnum::class, true); static::assertTrue($def->matches(enum_entry('col', null))); } + public function test_nullable_matches_a_null_value_carried_by_an_entry_of_a_different_type(): void + { + $def = enum_schema('col', BackedStringEnum::class, true); + + static::assertTrue($def->matches(int_entry('col', null))); + } + + public function test_nullable_matches_an_entry_with_a_non_null_value_of_its_type(): void + { + $def = enum_schema('col', BackedStringEnum::class, true); + + static::assertTrue($def->matches(enum_entry('col', BackedStringEnum::one))); + } + public function test_rename(): void { $def = enum_schema('status', BackedStringEnum::class); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/FloatDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/FloatDefinitionTest.php index ab6bdd4fd..86d11b080 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/FloatDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/FloatDefinitionTest.php @@ -98,6 +98,13 @@ public function test_add_metadata(): void static::assertFalse($def->metadata()->has('key')); } + public function test_does_not_match_a_null_entry_when_not_nullable(): void + { + $def = float_schema('col'); + + static::assertFalse($def->matches(float_entry('col', null))); + } + public function test_does_not_match_entry_with_different_name(): void { $def = float_schema('amount'); @@ -248,13 +255,34 @@ public function test_normalize(): void static::assertArrayHasKey('metadata', $normalized); } - public function test_nullable_matches_any_entry_with_same_name(): void + public function test_nullable_does_not_match_an_entry_of_a_different_type(): void + { + $def = float_schema('col', true); + + static::assertFalse($def->matches(int_entry('col', 1))); + } + + public function test_nullable_matches_a_null_entry_with_same_name(): void { $def = float_schema('col', true); static::assertTrue($def->matches(float_entry('col', null))); } + public function test_nullable_matches_a_null_value_carried_by_an_entry_of_a_different_type(): void + { + $def = float_schema('col', true); + + static::assertTrue($def->matches(int_entry('col', null))); + } + + public function test_nullable_matches_an_entry_with_a_non_null_value_of_its_type(): void + { + $def = float_schema('col', true); + + static::assertTrue($def->matches(float_entry('col', 1.5))); + } + public function test_rename(): void { $def = float_schema('amount'); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/HTMLDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/HTMLDefinitionTest.php index c5c97fdde..f41036a6b 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/HTMLDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/HTMLDefinitionTest.php @@ -89,6 +89,13 @@ public function test_add_metadata(): void static::assertFalse($def->metadata()->has('key')); } + public function test_does_not_match_a_null_entry_when_not_nullable(): void + { + $def = html_schema('col'); + + static::assertFalse($def->matches(html_entry('col', null))); + } + #[RequiresPhp('>= 8.4.0')] public function test_does_not_match_entry_with_different_name(): void { @@ -227,13 +234,35 @@ public function test_normalize(): void static::assertArrayHasKey('metadata', $normalized); } - public function test_nullable_matches_any_entry_with_same_name(): void + public function test_nullable_does_not_match_an_entry_of_a_different_type(): void + { + $def = html_schema('col', true); + + static::assertFalse($def->matches(int_entry('col', 1))); + } + + public function test_nullable_matches_a_null_entry_with_same_name(): void { $def = html_schema('col', true); static::assertTrue($def->matches(html_entry('col', null))); } + public function test_nullable_matches_a_null_value_carried_by_an_entry_of_a_different_type(): void + { + $def = html_schema('col', true); + + static::assertTrue($def->matches(int_entry('col', null))); + } + + #[RequiresPhp('>= 8.4.0')] + public function test_nullable_matches_an_entry_with_a_non_null_value_of_its_type(): void + { + $def = html_schema('col', true); + + static::assertTrue($def->matches(html_entry('col', ''))); + } + public function test_rename(): void { $def = html_schema('content'); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/HTMLElementDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/HTMLElementDefinitionTest.php index e5b34029b..f2dabc327 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/HTMLElementDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/HTMLElementDefinitionTest.php @@ -89,6 +89,13 @@ public function test_add_metadata(): void static::assertFalse($def->metadata()->has('key')); } + public function test_does_not_match_a_null_entry_when_not_nullable(): void + { + $def = html_element_schema('col'); + + static::assertFalse($def->matches(html_element_entry('col', null))); + } + #[RequiresPhp('>= 8.4.0')] public function test_does_not_match_entry_with_different_name(): void { @@ -227,13 +234,35 @@ public function test_normalize(): void static::assertArrayHasKey('metadata', $normalized); } - public function test_nullable_matches_any_entry_with_same_name(): void + public function test_nullable_does_not_match_an_entry_of_a_different_type(): void + { + $def = html_element_schema('col', true); + + static::assertFalse($def->matches(int_entry('col', 1))); + } + + public function test_nullable_matches_a_null_entry_with_same_name(): void { $def = html_element_schema('col', true); static::assertTrue($def->matches(html_element_entry('col', null))); } + public function test_nullable_matches_a_null_value_carried_by_an_entry_of_a_different_type(): void + { + $def = html_element_schema('col', true); + + static::assertTrue($def->matches(int_entry('col', null))); + } + + #[RequiresPhp('>= 8.4.0')] + public function test_nullable_matches_an_entry_with_a_non_null_value_of_its_type(): void + { + $def = html_element_schema('col', true); + + static::assertTrue($def->matches(html_element_entry('col', '
content
'))); + } + public function test_rename(): void { $def = html_element_schema('element'); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/IntegerDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/IntegerDefinitionTest.php index 5e166fb94..caf508129 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/IntegerDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/IntegerDefinitionTest.php @@ -98,6 +98,13 @@ public function test_add_metadata(): void static::assertFalse($def->metadata()->has('key')); } + public function test_does_not_match_a_null_entry_when_not_nullable(): void + { + $def = int_schema('col'); + + static::assertFalse($def->matches(int_entry('col', null))); + } + public function test_does_not_match_entry_with_different_name(): void { $def = int_schema('id'); @@ -248,13 +255,34 @@ public function test_normalize(): void static::assertArrayHasKey('metadata', $normalized); } - public function test_nullable_matches_any_entry_with_same_name(): void + public function test_nullable_does_not_match_an_entry_of_a_different_type(): void + { + $def = int_schema('col', true); + + static::assertFalse($def->matches(str_entry('col', 'value'))); + } + + public function test_nullable_matches_a_null_entry_with_same_name(): void { $def = int_schema('col', true); static::assertTrue($def->matches(int_entry('col', null))); } + public function test_nullable_matches_a_null_value_carried_by_an_entry_of_a_different_type(): void + { + $def = int_schema('col', true); + + static::assertTrue($def->matches(str_entry('col', null))); + } + + public function test_nullable_matches_an_entry_with_a_non_null_value_of_its_type(): void + { + $def = int_schema('col', true); + + static::assertTrue($def->matches(int_entry('col', 1))); + } + public function test_rename(): void { $def = int_schema('id'); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/JsonDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/JsonDefinitionTest.php index 39aad9f5e..6c2bc03e0 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/JsonDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/JsonDefinitionTest.php @@ -88,6 +88,13 @@ public function test_add_metadata(): void static::assertFalse($def->metadata()->has('key')); } + public function test_does_not_match_a_null_entry_when_not_nullable(): void + { + $def = json_schema('col'); + + static::assertFalse($def->matches(json_entry('col', null))); + } + public function test_does_not_match_entry_with_different_name(): void { $def = json_schema('data'); @@ -224,13 +231,34 @@ public function test_normalize(): void static::assertArrayHasKey('metadata', $normalized); } - public function test_nullable_matches_any_entry_with_same_name(): void + public function test_nullable_does_not_match_an_entry_of_a_different_type(): void + { + $def = json_schema('col', true); + + static::assertFalse($def->matches(int_entry('col', 1))); + } + + public function test_nullable_matches_a_null_entry_with_same_name(): void { $def = json_schema('col', true); static::assertTrue($def->matches(json_entry('col', null))); } + public function test_nullable_matches_a_null_value_carried_by_an_entry_of_a_different_type(): void + { + $def = json_schema('col', true); + + static::assertTrue($def->matches(int_entry('col', null))); + } + + public function test_nullable_matches_an_entry_with_a_non_null_value_of_its_type(): void + { + $def = json_schema('col', true); + + static::assertTrue($def->matches(json_entry('col', ['key' => 'value']))); + } + public function test_rename(): void { $def = json_schema('data'); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/ListDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/ListDefinitionTest.php index ddbab155e..bd91b11d9 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/ListDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/ListDefinitionTest.php @@ -107,6 +107,13 @@ public function test_add_metadata(): void static::assertFalse($def->metadata()->has('key')); } + public function test_does_not_match_a_null_entry_when_not_nullable(): void + { + $def = list_schema('col', type_list(type_integer())); + + static::assertFalse($def->matches(list_entry('col', null, type_list(type_integer())))); + } + public function test_does_not_match_entry_with_different_name(): void { $def = list_schema('items', type_list(type_integer())); @@ -243,13 +250,34 @@ public function test_normalize(): void static::assertArrayHasKey('metadata', $normalized); } - public function test_nullable_matches_any_entry_with_same_name(): void + public function test_nullable_does_not_match_an_entry_of_a_different_type(): void + { + $def = list_schema('col', type_list(type_integer()), true); + + static::assertFalse($def->matches(int_entry('col', 1))); + } + + public function test_nullable_matches_a_null_entry_with_same_name(): void { $def = list_schema('col', type_list(type_integer()), true); static::assertTrue($def->matches(list_entry('col', null, type_list(type_integer())))); } + public function test_nullable_matches_a_null_value_carried_by_an_entry_of_a_different_type(): void + { + $def = list_schema('col', type_list(type_integer()), true); + + static::assertTrue($def->matches(int_entry('col', null))); + } + + public function test_nullable_matches_an_entry_with_a_non_null_value_of_its_type(): void + { + $def = list_schema('col', type_list(type_integer()), true); + + static::assertTrue($def->matches(list_entry('col', [1, 2, 3], type_list(type_integer())))); + } + public function test_rename(): void { $def = list_schema('items', type_list(type_integer())); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/MapDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/MapDefinitionTest.php index fe884b2da..386bf35f0 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/MapDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/MapDefinitionTest.php @@ -119,6 +119,13 @@ public function test_add_metadata(): void static::assertFalse($def->metadata()->has('key')); } + public function test_does_not_match_a_null_entry_when_not_nullable(): void + { + $def = map_schema('col', type_map(type_string(), type_integer())); + + static::assertFalse($def->matches(map_entry('col', null, type_map(type_string(), type_integer())))); + } + public function test_does_not_match_entry_with_different_name(): void { $def = map_schema('data', type_map(type_string(), type_integer())); @@ -259,13 +266,34 @@ public function test_normalize(): void static::assertArrayHasKey('metadata', $normalized); } - public function test_nullable_matches_any_entry_with_same_name(): void + public function test_nullable_does_not_match_an_entry_of_a_different_type(): void + { + $def = map_schema('col', type_map(type_string(), type_integer()), true); + + static::assertFalse($def->matches(int_entry('col', 1))); + } + + public function test_nullable_matches_a_null_entry_with_same_name(): void { $def = map_schema('col', type_map(type_string(), type_integer()), true); static::assertTrue($def->matches(map_entry('col', null, type_map(type_string(), type_integer())))); } + public function test_nullable_matches_a_null_value_carried_by_an_entry_of_a_different_type(): void + { + $def = map_schema('col', type_map(type_string(), type_integer()), true); + + static::assertTrue($def->matches(int_entry('col', null))); + } + + public function test_nullable_matches_an_entry_with_a_non_null_value_of_its_type(): void + { + $def = map_schema('col', type_map(type_string(), type_integer()), true); + + static::assertTrue($def->matches(map_entry('col', ['a' => 1], type_map(type_string(), type_integer())))); + } + public function test_rename(): void { $def = map_schema('data', type_map(type_string(), type_integer())); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/NullDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/NullDefinitionTest.php index e57214a74..82be6e3e1 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/NullDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/NullDefinitionTest.php @@ -97,9 +97,14 @@ public function test_matches_a_null_entry_with_the_same_name(): void static::assertTrue(null_schema('id')->matches(null_entry('id'))); } - public function test_matches_any_entry_with_the_same_name(): void + public function test_matches_a_typed_entry_holding_null(): void { - static::assertTrue(null_schema('id')->matches(int_entry('id', 1))); + static::assertTrue(null_schema('id')->matches(int_entry('id', null))); + } + + public function test_does_not_match_a_non_null_entry_with_the_same_name(): void + { + static::assertFalse(null_schema('id')->matches(int_entry('id', 1))); } public function test_does_not_match_an_entry_with_a_different_name(): void diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/StringDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/StringDefinitionTest.php index fe4fa5e52..d8481a65c 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/StringDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/StringDefinitionTest.php @@ -86,6 +86,13 @@ public function test_add_metadata(): void static::assertFalse($def->metadata()->has('key')); } + public function test_does_not_match_a_null_entry_when_not_nullable(): void + { + $def = string_schema('col'); + + static::assertFalse($def->matches(str_entry('col', null))); + } + public function test_does_not_match_entry_with_different_name(): void { $def = string_schema('name'); @@ -213,13 +220,34 @@ public function test_normalize(): void static::assertArrayHasKey('metadata', $normalized); } - public function test_nullable_matches_any_entry_with_same_name(): void + public function test_nullable_does_not_match_an_entry_of_a_different_type(): void + { + $def = string_schema('col', true); + + static::assertFalse($def->matches(int_entry('col', 1))); + } + + public function test_nullable_matches_a_null_entry_with_same_name(): void { $def = string_schema('col', true); static::assertTrue($def->matches(str_entry('col', null))); } + public function test_nullable_matches_a_null_value_carried_by_an_entry_of_a_different_type(): void + { + $def = string_schema('col', true); + + static::assertTrue($def->matches(int_entry('col', null))); + } + + public function test_nullable_matches_an_entry_with_a_non_null_value_of_its_type(): void + { + $def = string_schema('col', true); + + static::assertTrue($def->matches(str_entry('col', 'value'))); + } + public function test_rename(): void { $def = string_schema('name'); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/StructureDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/StructureDefinitionTest.php index d88961c0c..31c4696cd 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/StructureDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/StructureDefinitionTest.php @@ -283,6 +283,13 @@ public function test_add_metadata(): void static::assertFalse($def->metadata()->has('key')); } + public function test_does_not_match_a_null_entry_when_not_nullable(): void + { + $def = structure_schema('col', type_structure(['name' => type_string()])); + + static::assertFalse($def->matches(structure_entry('col', null, type_structure(['name' => type_string()])))); + } + public function test_does_not_match_entry_with_different_name(): void { $def = structure_schema('data', type_structure(['name' => type_string()])); @@ -451,13 +458,36 @@ public function test_normalize(): void static::assertArrayHasKey('metadata', $normalized); } - public function test_nullable_matches_any_entry_with_same_name(): void + public function test_nullable_does_not_match_an_entry_of_a_different_type(): void + { + $def = structure_schema('col', type_structure(['name' => type_string()]), true); + + static::assertFalse($def->matches(int_entry('col', 1))); + } + + public function test_nullable_matches_a_null_entry_with_same_name(): void { $def = structure_schema('col', type_structure(['name' => type_string()]), true); static::assertTrue($def->matches(structure_entry('col', null, type_structure(['name' => type_string()])))); } + public function test_nullable_matches_a_null_value_carried_by_an_entry_of_a_different_type(): void + { + $def = structure_schema('col', type_structure(['name' => type_string()]), true); + + static::assertTrue($def->matches(int_entry('col', null))); + } + + public function test_nullable_matches_an_entry_with_a_non_null_value_of_its_type(): void + { + $def = structure_schema('col', type_structure(['name' => type_string()]), true); + + static::assertTrue($def->matches(structure_entry('col', ['name' => 'John'], type_structure([ + 'name' => type_string(), + ])))); + } + public function test_rename(): void { $def = structure_schema('data', type_structure(['name' => type_string()])); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/TimeDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/TimeDefinitionTest.php index 3b324a7d7..2bc9eb803 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/TimeDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/TimeDefinitionTest.php @@ -106,6 +106,13 @@ public function test_add_metadata(): void static::assertFalse($def->metadata()->has('key')); } + public function test_does_not_match_a_null_entry_when_not_nullable(): void + { + $def = time_schema('col'); + + static::assertFalse($def->matches(time_entry('col', null))); + } + public function test_does_not_match_entry_with_different_name(): void { $def = time_schema('duration'); @@ -256,13 +263,34 @@ public function test_normalize(): void static::assertArrayHasKey('metadata', $normalized); } - public function test_nullable_matches_any_entry_with_same_name(): void + public function test_nullable_does_not_match_an_entry_of_a_different_type(): void + { + $def = time_schema('col', true); + + static::assertFalse($def->matches(int_entry('col', 1))); + } + + public function test_nullable_matches_a_null_entry_with_same_name(): void { $def = time_schema('col', true); static::assertTrue($def->matches(time_entry('col', null))); } + public function test_nullable_matches_a_null_value_carried_by_an_entry_of_a_different_type(): void + { + $def = time_schema('col', true); + + static::assertTrue($def->matches(int_entry('col', null))); + } + + public function test_nullable_matches_an_entry_with_a_non_null_value_of_its_type(): void + { + $def = time_schema('col', true); + + static::assertTrue($def->matches(time_entry('col', '10:30:00'))); + } + public function test_rename(): void { $def = time_schema('duration'); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UnionDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UnionDefinitionTest.php index 090494509..719317216 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UnionDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UnionDefinitionTest.php @@ -163,6 +163,13 @@ public function test_definition_from_type_creates_union_definition(): void static::assertSame('col', $definition->entry()->name()); } + public function test_does_not_match_a_null_entry_when_not_nullable(): void + { + $def = union_schema('col', type_union(type_string(), type_integer())); + + static::assertFalse($def->matches(int_entry('col', null))); + } + public function test_does_not_match_entry_with_different_name(): void { $def = union_schema('col', type_union(type_string(), type_integer())); @@ -386,11 +393,32 @@ public function test_normalize_and_from_array_round_trip(): void static::assertEquals($def, definition_from_array($def->normalize())); } - public function test_nullable_matches_any_entry_with_same_name(): void + public function test_nullable_does_not_match_an_entry_of_a_type_outside_the_union(): void + { + $def = union_schema('col', type_union(type_string(), type_integer()), true); + + static::assertFalse($def->matches(bool_entry('col', true))); + } + + public function test_nullable_matches_a_null_entry_with_same_name(): void { $def = union_schema('col', type_union(type_string(), type_integer()), true); - static::assertTrue($def->matches(bool_entry('col', true))); + static::assertTrue($def->matches(int_entry('col', null))); + } + + public function test_nullable_matches_a_null_value_carried_by_an_entry_of_a_different_type(): void + { + $def = union_schema('col', type_union(type_string(), type_integer()), true); + + static::assertTrue($def->matches(bool_entry('col', null))); + } + + public function test_nullable_matches_an_entry_with_a_non_null_value_of_its_type(): void + { + $def = union_schema('col', type_union(type_string(), type_integer()), true); + + static::assertTrue($def->matches(int_entry('col', 1))); } public function test_rename(): void diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UuidDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UuidDefinitionTest.php index e483de010..69801ded3 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UuidDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UuidDefinitionTest.php @@ -88,6 +88,13 @@ public function test_add_metadata(): void static::assertFalse($def->metadata()->has('key')); } + public function test_does_not_match_a_null_entry_when_not_nullable(): void + { + $def = uuid_schema('col'); + + static::assertFalse($def->matches(uuid_entry('col', null))); + } + public function test_does_not_match_entry_with_different_name(): void { $def = uuid_schema('id'); @@ -224,13 +231,34 @@ public function test_normalize(): void static::assertArrayHasKey('metadata', $normalized); } - public function test_nullable_matches_any_entry_with_same_name(): void + public function test_nullable_does_not_match_an_entry_of_a_different_type(): void + { + $def = uuid_schema('col', true); + + static::assertFalse($def->matches(int_entry('col', 1))); + } + + public function test_nullable_matches_a_null_entry_with_same_name(): void { $def = uuid_schema('col', true); static::assertTrue($def->matches(uuid_entry('col', null))); } + public function test_nullable_matches_a_null_value_carried_by_an_entry_of_a_different_type(): void + { + $def = uuid_schema('col', true); + + static::assertTrue($def->matches(int_entry('col', null))); + } + + public function test_nullable_matches_an_entry_with_a_non_null_value_of_its_type(): void + { + $def = uuid_schema('col', true); + + static::assertTrue($def->matches(uuid_entry('col', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'))); + } + public function test_rename(): void { $def = uuid_schema('id'); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/XMLDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/XMLDefinitionTest.php index 1fb43f4ac..9092b493e 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/XMLDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/XMLDefinitionTest.php @@ -88,6 +88,13 @@ public function test_add_metadata(): void static::assertFalse($def->metadata()->has('key')); } + public function test_does_not_match_a_null_entry_when_not_nullable(): void + { + $def = xml_schema('col'); + + static::assertFalse($def->matches(xml_entry('col', null))); + } + public function test_does_not_match_entry_with_different_name(): void { $def = xml_schema('document'); @@ -224,13 +231,34 @@ public function test_normalize(): void static::assertArrayHasKey('metadata', $normalized); } - public function test_nullable_matches_any_entry_with_same_name(): void + public function test_nullable_does_not_match_an_entry_of_a_different_type(): void + { + $def = xml_schema('col', true); + + static::assertFalse($def->matches(int_entry('col', 1))); + } + + public function test_nullable_matches_a_null_entry_with_same_name(): void { $def = xml_schema('col', true); static::assertTrue($def->matches(xml_entry('col', null))); } + public function test_nullable_matches_a_null_value_carried_by_an_entry_of_a_different_type(): void + { + $def = xml_schema('col', true); + + static::assertTrue($def->matches(int_entry('col', null))); + } + + public function test_nullable_matches_an_entry_with_a_non_null_value_of_its_type(): void + { + $def = xml_schema('col', true); + + static::assertTrue($def->matches(xml_entry('col', ''))); + } + public function test_rename(): void { $def = xml_schema('document'); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/XMLElementDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/XMLElementDefinitionTest.php index 6192635b2..0607abf87 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/XMLElementDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/XMLElementDefinitionTest.php @@ -88,6 +88,13 @@ public function test_add_metadata(): void static::assertFalse($def->metadata()->has('key')); } + public function test_does_not_match_a_null_entry_when_not_nullable(): void + { + $def = xml_element_schema('col'); + + static::assertFalse($def->matches(xml_element_entry('col', null))); + } + public function test_does_not_match_entry_with_different_name(): void { $def = xml_element_schema('element'); @@ -224,13 +231,34 @@ public function test_normalize(): void static::assertArrayHasKey('metadata', $normalized); } - public function test_nullable_matches_any_entry_with_same_name(): void + public function test_nullable_does_not_match_an_entry_of_a_different_type(): void + { + $def = xml_element_schema('col', true); + + static::assertFalse($def->matches(int_entry('col', 1))); + } + + public function test_nullable_matches_a_null_entry_with_same_name(): void { $def = xml_element_schema('col', true); static::assertTrue($def->matches(xml_element_entry('col', null))); } + public function test_nullable_matches_a_null_value_carried_by_an_entry_of_a_different_type(): void + { + $def = xml_element_schema('col', true); + + static::assertTrue($def->matches(int_entry('col', null))); + } + + public function test_nullable_matches_an_entry_with_a_non_null_value_of_its_type(): void + { + $def = xml_element_schema('col', true); + + static::assertTrue($def->matches(xml_element_entry('col', 'value'))); + } + public function test_rename(): void { $def = xml_element_schema('element'); From 2111a7aa708627eb421cfd1f8e3b439780a82706 Mon Sep 17 00:00:00 2001 From: Norbert Orzechowicz Date: Sat, 8 Aug 2026 16:24:45 +0200 Subject: [PATCH 4/5] fix(flow-php/etl): resolve union column members per value when building entrie - UnionDefinition::memberFor() picks the member matching the value - native hydrator resolves union columns through it, per row - native cast now casts before resolving, matching PhpRowHydrator --- .../etl/src/Flow/ETL/Row/EntryFactory.php | 5 + .../ETL/Schema/Definition/UnionDefinition.php | 17 +++ .../Unit/Extractor/ArrayExtractorTest.php | 33 +++++ .../ETL/Tests/Unit/Row/EntryFactoryTest.php | 62 ++++++++++ .../Tests/Unit/Row/NativeRowHydratorTest.php | 49 ++++++++ .../Schema/Definition/UnionDefinitionTest.php | 35 ++++++ src/extension/flow-php-ext/src/cast.rs | 25 ++-- src/extension/flow-php-ext/src/hydrate.rs | 57 +++++++-- .../tests/phpt/029_union_column_parity.phpt | 113 ++++++++++++++++++ 9 files changed, 372 insertions(+), 24 deletions(-) create mode 100644 src/extension/flow-php-ext/tests/phpt/029_union_column_parity.phpt diff --git a/src/core/etl/src/Flow/ETL/Row/EntryFactory.php b/src/core/etl/src/Flow/ETL/Row/EntryFactory.php index a75ff7e3c..dea8648eb 100644 --- a/src/core/etl/src/Flow/ETL/Row/EntryFactory.php +++ b/src/core/etl/src/Flow/ETL/Row/EntryFactory.php @@ -8,6 +8,7 @@ use Flow\ETL\Row\Entry\Instantiators; use Flow\ETL\Row\Entry\NullEntry; use Flow\ETL\Schema\Definition; +use Flow\ETL\Schema\Definition\UnionDefinition; use Flow\ETL\Schema\Metadata; use Flow\Types\Exception\CastingException; use Flow\Types\Type; @@ -75,6 +76,10 @@ public function cast(string $name, mixed $value, Type $type, ?Metadata $metadata */ public function fromDefinition(Definition $definition, mixed $value): Entry { + if ($definition instanceof UnionDefinition) { + $definition = $definition->memberFor($value); + } + $variant = $value === null && !$definition->isNullable() ? $definition->makeNullable() : $definition; return $this->instantiators->for($variant->entryClass())->instantiate( diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/UnionDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/UnionDefinition.php index d486023a9..97199afda 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/UnionDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/UnionDefinition.php @@ -4,9 +4,11 @@ namespace Flow\ETL\Schema\Definition; +use Flow\ETL\Exception\InvalidArgumentException; use Flow\ETL\Exception\RuntimeException; use Flow\ETL\Row\Entry; use Flow\ETL\Row\EntryReference; +use Flow\ETL\Row\EntryTypeResolver; use Flow\ETL\Row\Reference; use Flow\ETL\Schema\Definition; use Flow\ETL\Schema\Metadata; @@ -111,6 +113,21 @@ public function matches(Entry $entry): bool return $this->type->isValid($entry->value()); } + /** + * @throws InvalidArgumentException when the value matches no member of the union + * + * @return Definition + */ + public function memberFor(mixed $value): Definition + { + return definition_from_type( + $this->ref, + (new EntryTypeResolver())->fromUnion($this->type, $value, $this->ref->name()), + $this->nullable, + $this->metadata, + ); + } + public function merge(Definition $definition): Definition { if (!$this->ref->is($definition->entry())) { diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/ArrayExtractorTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/ArrayExtractorTest.php index 7fd6c7d86..5ad6abcdc 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/ArrayExtractorTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/ArrayExtractorTest.php @@ -4,13 +4,22 @@ namespace Flow\ETL\Tests\Unit\Extractor; +use Flow\ETL\Row\Entry\IntegerEntry; +use Flow\ETL\Row\Entry\StringEntry; use Flow\ETL\Rows; use Flow\ETL\Tests\FlowTestCase; +use Flow\Types\Type\Native\UnionType; use function Flow\ETL\DSL\config; use function Flow\ETL\DSL\config_builder; use function Flow\ETL\DSL\execution_context; use function Flow\ETL\DSL\from_array; +use function Flow\ETL\DSL\int_schema; +use function Flow\ETL\DSL\schema; +use function Flow\ETL\DSL\union_schema; +use function Flow\Types\DSL\type_integer; +use function Flow\Types\DSL\type_string; +use function Flow\Types\DSL\type_union; use function iterator_to_array; final class ArrayExtractorTest extends FlowTestCase @@ -31,6 +40,30 @@ public function test_array_extractor(): void static::assertSame(['id' => 2, 'name' => 'Michal'], $rows[1]->first()->toArray()); } + public function test_extraction_with_a_union_column_in_the_schema(): void + { + /** @var UnionType $union */ + $union = type_union(type_string(), type_integer()); + + $extractor = from_array( + [ + ['id' => 1, 'a' => 42], + ['id' => 2, 'a' => 'x'], + ['id' => 3, 'a' => null], + ], + schema: schema(int_schema('id'), union_schema('a', $union, true)), + ); + + $rows = iterator_to_array($extractor->extract(execution_context(config_builder()->build()))); + + static::assertInstanceOf(IntegerEntry::class, $rows[0]->first()->get('a')); + static::assertSame(42, $rows[0]->first()->get('a')->value()); + static::assertInstanceOf(StringEntry::class, $rows[1]->first()->get('a')); + static::assertSame('x', $rows[1]->first()->get('a')->value()); + static::assertInstanceOf(StringEntry::class, $rows[2]->first()->get('a')); + static::assertNull($rows[2]->first()->get('a')->value()); + } + public function test_generator_extraction(): void { $generator = static function () { diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Row/EntryFactoryTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Row/EntryFactoryTest.php index e64f50337..1fe139b25 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Row/EntryFactoryTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Row/EntryFactoryTest.php @@ -18,6 +18,7 @@ use Flow\ETL\Schema\Metadata; use Flow\ETL\Tests\Fixtures\Enum\BackedIntEnum; use Flow\ETL\Tests\FlowTestCase; +use Flow\Types\Type\Native\UnionType; use Flow\Types\Value\Json; use Flow\Types\Value\Uuid as FlowUuid; use Generator; @@ -72,6 +73,7 @@ use function Flow\Types\DSL\type_structure; use function Flow\Types\DSL\type_time_zone; use function Flow\Types\DSL\type_union; +use function Flow\Types\DSL\type_uuid; final class EntryFactoryTest extends FlowTestCase { @@ -242,6 +244,66 @@ public function test_from_definition_preserves_definition_metadata(): void ); } + public function test_from_definition_with_union_resolves_the_member_matching_the_value(): void + { + /** @var UnionType $type */ + $type = type_union(type_string(), type_integer()); + + static::assertEquals(int_entry('e', 42), (new EntryFactory())->fromDefinition(union_schema('e', $type), 42)); + } + + public function test_from_definition_with_union_resolves_the_first_member_for_a_matching_value(): void + { + /** @var UnionType $type */ + $type = type_union(type_string(), type_integer()); + + static::assertEquals(str_entry('e', 'x'), (new EntryFactory())->fromDefinition(union_schema('e', $type), 'x')); + } + + public function test_from_definition_with_union_carries_metadata_onto_the_resolved_entry(): void + { + /** @var UnionType $type */ + $type = type_union(type_string(), type_integer()); + + static::assertEquals( + int_entry('e', 42, metadata: Metadata::with('k', 1)), + (new EntryFactory())->fromDefinition(union_schema('e', $type, metadata: Metadata::with('k', 1)), 42), + ); + } + + public function test_from_definition_with_nullable_union_and_null_value(): void + { + /** @var UnionType $type */ + $type = type_union(type_string(), type_integer()); + + $entry = (new EntryFactory())->fromDefinition(union_schema('e', $type, true), null); + + static::assertEquals(str_entry('e', null), $entry); + static::assertTrue($entry->definition()->isNullable()); + } + + public function test_from_definition_with_non_nullable_union_and_null_value(): void + { + /** @var UnionType $type */ + $type = type_union(type_string(), type_integer()); + + $entry = (new EntryFactory())->fromDefinition(union_schema('e', $type), null); + + static::assertNull($entry->value()); + static::assertTrue($entry->definition()->isNullable()); + } + + public function test_from_definition_with_union_throws_for_a_value_outside_every_member(): void + { + /** @var UnionType $type */ + $type = type_union(type_uuid(), type_datetime()); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Entry "e": array value does not match any member of union type'); + + (new EntryFactory())->fromDefinition(union_schema('e', $type), [1, 2]); + } + public function test_date(): void { static::assertEquals( diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Row/NativeRowHydratorTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Row/NativeRowHydratorTest.php index f9076fe26..12e07e21a 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Row/NativeRowHydratorTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Row/NativeRowHydratorTest.php @@ -16,6 +16,7 @@ use Flow\ETL\Schema\Metadata; use Flow\ETL\Tests\FlowTestCase; use Flow\Types\Type\Logical\StructureType; +use Flow\Types\Type\Native\UnionType; use Flow\Types\Value\Json; use Flow\Types\Value\Uuid; use Generator; @@ -40,9 +41,11 @@ use function Flow\ETL\DSL\str_schema; use function Flow\ETL\DSL\structure_schema; use function Flow\ETL\DSL\time_schema; +use function Flow\ETL\DSL\union_schema; use function Flow\ETL\DSL\uuid_schema; use function Flow\ETL\DSL\xml_entry; use function Flow\ETL\DSL\xml_schema; +use function Flow\Types\DSL\type_datetime; use function Flow\Types\DSL\type_integer; use function Flow\Types\DSL\type_list; use function Flow\Types\DSL\type_map; @@ -52,6 +55,8 @@ use function Flow\Types\DSL\type_positive_integer; use function Flow\Types\DSL\type_string; use function Flow\Types\DSL\type_structure; +use function Flow\Types\DSL\type_union; +use function Flow\Types\DSL\type_uuid; use function serialize; final class NativeRowHydratorTest extends FlowTestCase @@ -61,6 +66,19 @@ final class NativeRowHydratorTest extends FlowTestCase */ public static function serializable_datasets(): Generator { + /** @var UnionType $union */ + $union = type_union(type_string(), type_integer()); + + yield 'union column across members, null and absent' => [ + schema(int_schema('id'), union_schema('a', $union, true)), + [ + new RawRowValues(['id' => 1, 'a' => 42]), + new RawRowValues(['id' => 2, 'a' => 'x']), + new RawRowValues(['id' => 3, 'a' => null]), + new RawRowValues(['id' => 4]), + ], + ]; + yield 'scalars over multiple rows' => [ schema(int_schema('id'), str_schema('name', nullable: true), float_schema('p'), bool_schema('a')), [ @@ -151,6 +169,29 @@ public static function serializable_datasets(): Generator */ public static function castable_datasets(): Generator { + /** @var UnionType $union */ + $union = type_union(type_string(), type_integer()); + + yield 'union column across members, null and absent' => [ + schema(int_schema('id'), union_schema('a', $union, true)), + [ + new RawRowValues(['id' => 1, 'a' => 42]), + new RawRowValues(['id' => 2, 'a' => 'x']), + new RawRowValues(['id' => 3, 'a' => null]), + new RawRowValues(['id' => 4]), + new RawRowValues(['id' => 5, 'a' => '42']), + new RawRowValues(['id' => 6, 'a' => 1.5]), + ], + ]; + + yield 'union column with per-value metadata' => [ + schema(union_schema('a', $union, true)), + [ + new RawRowValues(['a' => 42], ['a' => Metadata::fromArray(['k' => 'v'])]), + new RawRowValues(['a' => 'x'], ['a' => Metadata::fromArray(['k' => 'v'])]), + ], + ]; + yield 'scalar columns from raw strings and coercion edges' => [ schema(int_schema('id'), float_schema('price'), bool_schema('active'), str_schema('name')), [ @@ -285,6 +326,14 @@ public static function castable_datasets(): Generator */ public static function throwing_cast_datasets(): Generator { + /** @var UnionType $unmatchable */ + $unmatchable = type_union(type_uuid(), type_datetime()); + + yield 'union column with a value outside every member' => [ + schema(union_schema('a', $unmatchable)), + [new RawRowValues(['a' => [1, 2]])], + ]; + yield 'invalid uuid string' => [ schema(uuid_schema('u')), [new RawRowValues(['u' => 'not-a-uuid'])], diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UnionDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UnionDefinitionTest.php index 719317216..fe2ebccd5 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UnionDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UnionDefinitionTest.php @@ -4,6 +4,7 @@ namespace Flow\ETL\Tests\Unit\Schema\Definition; +use Flow\ETL\Exception\InvalidArgumentException; use Flow\ETL\Exception\RuntimeException; use Flow\ETL\Row\Entry\IntegerEntry; use Flow\ETL\Row\Entry\NullEntry; @@ -30,12 +31,14 @@ use function Flow\Types\DSL\type_array; use function Flow\Types\DSL\type_boolean; use function Flow\Types\DSL\type_class_string; +use function Flow\Types\DSL\type_datetime; use function Flow\Types\DSL\type_integer; use function Flow\Types\DSL\type_list; use function Flow\Types\DSL\type_null; use function Flow\Types\DSL\type_optional; use function Flow\Types\DSL\type_string; use function Flow\Types\DSL\type_union; +use function Flow\Types\DSL\type_uuid; final class UnionDefinitionTest extends FlowTestCase { @@ -277,6 +280,38 @@ public function test_matches_entry_with_value_valid_for_any_member(): void static::assertTrue($def->matches(int_entry('col', 1))); } + public function test_member_for_carries_nullability_and_metadata(): void + { + $def = union_schema('col', type_union(type_string(), type_integer()), true, Metadata::with('key', 'value')); + + static::assertEquals(int_schema('col', true, Metadata::with('key', 'value')), $def->memberFor(1)); + } + + public function test_member_for_resolves_the_first_non_null_member_for_null(): void + { + $def = union_schema('col', type_union(type_string(), type_integer())); + + static::assertEquals(string_schema('col'), $def->memberFor(null)); + } + + public function test_member_for_resolves_the_member_accepting_the_value(): void + { + $def = union_schema('col', type_union(type_string(), type_integer())); + + static::assertEquals(int_schema('col'), $def->memberFor(1)); + static::assertEquals(string_schema('col'), $def->memberFor('value')); + } + + public function test_member_for_throws_for_a_value_outside_every_member(): void + { + $def = union_schema('col', type_union(type_uuid(), type_datetime())); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Entry "col": array value does not match any member of union type'); + + $def->memberFor([1, 2]); + } + /** * @param Definition $definition * @param Definition $other diff --git a/src/extension/flow-php-ext/src/cast.rs b/src/extension/flow-php-ext/src/cast.rs index 01bd97e14..422b8b3f7 100644 --- a/src/extension/flow-php-ext/src/cast.rs +++ b/src/extension/flow-php-ext/src/cast.rs @@ -748,7 +748,7 @@ pub fn cast_rows( let (definition, entry_ce, entry_slots) = resolve_entry_definition( column, None, - true, + None, entry_slot_cache, def_rare_cache, )?; @@ -761,17 +761,10 @@ pub fn cast_rows( } else { None }; - let value_is_null = value.is_null(); - - let (definition, entry_ce, entry_slots) = resolve_entry_definition( - column, - metadata, - value_is_null, - entry_slot_cache, - def_rare_cache, - )?; - - let casted = if value_is_null { + // Cast first: `PhpRowHydrator::cast()` hands `fromDefinition()` the + // already-cast value, so a union column must pick its member from that + // same value for both engines to agree. + let casted = if value.is_null() { null_zval() } else { match cast_value(&cast_column.kind, value, ctx)? { @@ -791,6 +784,14 @@ pub fn cast_rows( } }; + let (definition, entry_ce, entry_slots) = resolve_entry_definition( + column, + metadata, + Some(&casted), + entry_slot_cache, + def_rare_cache, + )?; + (definition, entry_ce, entry_slots, casted) } }; diff --git a/src/extension/flow-php-ext/src/hydrate.rs b/src/extension/flow-php-ext/src/hydrate.rs index 7327f6201..3a0376b3e 100644 --- a/src/extension/flow-php-ext/src/hydrate.rs +++ b/src/extension/flow-php-ext/src/hydrate.rs @@ -11,9 +11,9 @@ use ext_php_rs::types::{ZendHashTable, ZendObject, Zval}; use ext_php_rs::zend::{ClassEntry, Function}; use crate::ctx::{ - array_key_index, call_handle, call_handle_on, ce_method_ref, construct_with_zvals, - find_class, ht_add, ht_find_key, ht_insert, ht_insert_key, property_offset, - write_slot, zval_str, Ctx, HtKey, + array_key_index, call_handle, call_handle_on, call_handle_transparent, ce_method_ref, + construct_with_zvals, find_class, ht_add, ht_find_key, ht_insert, ht_insert_key, + property_offset, write_slot, zval_str, Ctx, HtKey, }; use crate::encode::{expect_object, ht_for_each, read_slot}; use crate::exception::ext_exception; @@ -389,6 +389,9 @@ pub(crate) struct HydrateColumn { pub(crate) nullable: bool, pub(crate) entry_ce: &'static ClassEntry, pub(crate) entry_slots: (u32, u32, u32), + /// A union column's concrete type is a per-row property, so `entry_ce` above - resolved + /// once per column from the value-blind `entryClass()` - does not apply to it. + pub(crate) member_for: Option<&'static Function>, } impl HydrateColumn { @@ -451,6 +454,7 @@ pub(crate) fn build_hydrate_plan( .ok_or_else(|| ext_exception("flow_php expected Schema::definitions to return an array"))?; let mut columns = Vec::with_capacity(definitions_ht.len()); + let union_ce = find_class("Flow\\ETL\\Schema\\Definition\\UnionDefinition")?; ht_for_each(definitions_ht, |_, _, def_zv| { let def_obj = expect_object(def_zv, "a Definition")?; @@ -486,6 +490,12 @@ pub(crate) fn build_hydrate_plan( let entry_ce = find_class(&entry_class_name)?; let entry_slots = entry_slots(entry_slot_cache, entry_ce)?; + let member_for = if def_obj.instance_of(union_ce) { + Some(ce_method_ref(def_ce, "memberFor")?) + } else { + None + }; + columns.push(HydrateColumn { numeric_key, name_zv, @@ -493,6 +503,7 @@ pub(crate) fn build_hydrate_plan( nullable, entry_ce, entry_slots, + member_for, }); Ok(()) @@ -507,22 +518,44 @@ pub(crate) fn build_hydrate_plan( /// Resolves the `(definition, entry class, entry slots)` triple for one column /// occurrence, mirroring `PhpRowHydrator::instantiate` + `EntryFactory::fromDefinition`: -/// the common path shares the retained base `Definition`; per-value metadata takes -/// the new instance `$def->setMetadata(...)` returns, and a null value on a -/// non-nullable definition produces a fresh `makeNullable()` variant. +/// the common path shares the retained base `Definition`; a union column resolves its +/// member from the value; per-value metadata takes the new instance +/// `$def->setMetadata(...)` returns, and a null value on a non-nullable definition +/// produces a fresh `makeNullable()` variant. pub(crate) fn resolve_entry_definition( column: &HydrateColumn, metadata: Option<&Zval>, - value_is_null: bool, + value: Option<&Zval>, entry_slot_cache: &mut EntrySlotCache, def_rare_cache: &mut DefRareFnCache, ) -> Result<(Zval, &'static ClassEntry, (u32, u32, u32)), PhpException> { - if metadata.is_none() && (!value_is_null || column.nullable) { + let value_is_null = value.is_none_or(Zval::is_null); + + if column.member_for.is_none() && metadata.is_none() && (!value_is_null || column.nullable) { return Ok((column.base_def.shallow_clone(), column.entry_ce, column.entry_slots)); } - let base_obj = column - .base_def + let resolved_def = match column.member_for { + Some(member_for) => { + let union_obj = column + .base_def + .object() + .ok_or_else(|| ext_exception("flow_php expected a Definition object"))?; + let mut args = [match value { + Some(value) => value.shallow_clone(), + None => { + let mut null = Zval::new(); + null.set_null(); + null + } + }]; + + call_handle_transparent(member_for, Some(union_obj), &mut args)? + } + None => column.base_def.shallow_clone(), + }; + + let base_obj = resolved_def .object() .ok_or_else(|| ext_exception("flow_php expected a Definition object"))?; @@ -537,7 +570,7 @@ pub(crate) fn resolve_entry_definition( "set per-value metadata", )? } else { - column.base_def.shallow_clone() + resolved_def.shallow_clone() }; let variant_obj = variant @@ -646,7 +679,7 @@ pub fn hydrate_rows( let (definition, entry_ce, entry_slots) = resolve_entry_definition( column, metadata, - value.is_null(), + Some(value), entry_slot_cache, def_rare_cache, )?; diff --git a/src/extension/flow-php-ext/tests/phpt/029_union_column_parity.phpt b/src/extension/flow-php-ext/tests/phpt/029_union_column_parity.phpt new file mode 100644 index 000000000..71b9edbd7 --- /dev/null +++ b/src/extension/flow-php-ext/tests/phpt/029_union_column_parity.phpt @@ -0,0 +1,113 @@ +--TEST-- +NativeRowHydrator resolves a union column member per value, identically to PhpRowHydrator +--SKIPIF-- + +--FILE-- + [ + schema(int_schema('id'), union_schema('a', $union, true)), + [ + new RawRowValues(['id' => 1, 'a' => 42]), + new RawRowValues(['id' => 2, 'a' => 'x']), + new RawRowValues(['id' => 3, 'a' => null]), + new RawRowValues(['id' => 4]), + ], + ], + 'metadata' => [ + schema(union_schema('a', $union, true)), + [ + new RawRowValues(['a' => 42], ['a' => Metadata::fromArray(['k' => 'v'])]), + new RawRowValues(['a' => 'x'], ['a' => Metadata::fromArray(['k' => 'v'])]), + ], + ], +]; + +$castOnly = [ + 'castable' => [ + schema(union_schema('a', $union, true)), + [ + new RawRowValues(['a' => '42']), + new RawRowValues(['a' => 1.5]), + new RawRowValues(['a' => true]), + ], + ], +]; + +$php = new PhpRowHydrator(); +$native = new NativeRowHydrator(); + +foreach ($conforming as $label => [$s, $batch]) { + printf( + "%-9s hydrate:%s cast:%s\n", + $label, + serialize($php->hydrate($batch, $s)) === serialize($native->hydrate($batch, $s)) ? 'yes' : 'NO', + serialize($php->cast($batch, $s)) === serialize($native->cast($batch, $s)) ? 'yes' : 'NO', + ); +} + +foreach ($castOnly as $label => [$s, $batch]) { + printf( + "%-9s cast:%s\n", + $label, + serialize($php->cast($batch, $s)) === serialize($native->cast($batch, $s)) ? 'yes' : 'NO', + ); +} + +$entry = $native->cast([new RawRowValues(['id' => 1, 'a' => 42])], schema(int_schema('id'), union_schema('a', $union, true))) + ->first() + ->get('a'); +printf("int value entry:%s\n", (new ReflectionClass($entry))->getShortName()); + +$entry = $native->cast([new RawRowValues(['id' => 1, 'a' => 'x'])], schema(int_schema('id'), union_schema('a', $union, true))) + ->first() + ->get('a'); +printf("string value entry:%s\n", (new ReflectionClass($entry))->getShortName()); + +$outside = [new RawRowValues(['a' => [1, 2]])]; +$outsideSchema = schema(union_schema('a', $unmatchable)); + +$phpError = null; + +try { + $php->cast($outside, $outsideSchema); +} catch (Throwable $e) { + $phpError = $e; +} + +$nativeError = null; + +try { + $native->cast($outside, $outsideSchema); +} catch (Throwable $e) { + $nativeError = $e; +} + +printf( + "outside member exception parity:%s\n", + $phpError !== null && $nativeError !== null + && $phpError::class === $nativeError::class + && $phpError->getMessage() === $nativeError->getMessage() ? 'yes' : 'NO', +); +?> +--EXPECT-- +members hydrate:yes cast:yes +metadata hydrate:yes cast:yes +castable cast:yes +int value entry:IntegerEntry +string value entry:StringEntry +outside member exception parity:yes From 4ee6f1fafeff68ff80d10057ea67a82294aa86be Mon Sep 17 00:00:00 2001 From: Norbert Orzechowicz Date: Sun, 9 Aug 2026 11:28:22 +0200 Subject: [PATCH 5/5] fix(flow-php/etl): schema definition matching and merging - matches() validates the value, not just the type class - merge() widens to a common type instead of throwing - drop lossy date/datetime to integer and float widening - widen containers to json at element level too, matching columns - document flow-php-ext alongside arrow-ext in the Rust guide --- documentation/contributing.md | 2 +- documentation/contributing/nix.md | 9 +- documentation/contributing/rust.md | 102 ++++++++++++----- .../Schema/Definition/BooleanDefinition.php | 2 +- .../Flow/ETL/Schema/Definition/CommonType.php | 37 ++++++ .../ETL/Schema/Definition/DateDefinition.php | 18 +-- .../Schema/Definition/DateTimeDefinition.php | 18 +-- .../ETL/Schema/Definition/EnumDefinition.php | 4 +- .../ETL/Schema/Definition/FloatDefinition.php | 10 +- .../ETL/Schema/Definition/HTMLDefinition.php | 2 +- .../Definition/HTMLElementDefinition.php | 2 +- .../Schema/Definition/IntegerDefinition.php | 10 +- .../ETL/Schema/Definition/JsonDefinition.php | 2 +- .../ETL/Schema/Definition/ListDefinition.php | 4 +- .../ETL/Schema/Definition/MapDefinition.php | 4 +- .../Schema/Definition/StructureDefinition.php | 4 +- .../ETL/Schema/Definition/TimeDefinition.php | 2 +- .../Flow/ETL/Schema/Definition/TypeMerge.php | 17 +++ .../ETL/Schema/Definition/UnionDefinition.php | 2 +- .../ETL/Schema/Definition/UuidDefinition.php | 2 +- .../ETL/Schema/Definition/XMLDefinition.php | 2 +- .../Definition/XMLElementDefinition.php | 2 +- .../Integration/DataFrame/SchemaTest.php | 14 +++ .../ETL/Tests/Mother/DefinitionMother.php | 67 +++++++++++ .../Definition/BooleanDefinitionTest.php | 18 +-- .../Schema/Definition/DateDefinitionTest.php | 18 +-- .../Definition/DateTimeDefinitionTest.php | 18 +-- .../Schema/Definition/EnumDefinitionTest.php | 58 +++++++--- .../Schema/Definition/FloatDefinitionTest.php | 18 +-- .../Schema/Definition/HTMLDefinitionTest.php | 18 +-- .../Definition/HTMLElementDefinitionTest.php | 18 +-- .../Definition/IntegerDefinitionTest.php | 18 +-- .../Schema/Definition/JsonDefinitionTest.php | 18 +-- .../Schema/Definition/ListDefinitionTest.php | 59 ++++++++-- .../Schema/Definition/MapDefinitionTest.php | 52 +++++++-- .../Schema/Definition/MergeMatrixTest.php | 107 ++++++++++++++++++ .../Definition/StructureDefinitionTest.php | 69 +++++++++-- .../Schema/Definition/TimeDefinitionTest.php | 18 +-- .../Unit/Schema/Definition/TypeMergeTest.php | 22 +++- .../Schema/Definition/UnionDefinitionTest.php | 15 +-- .../Schema/Definition/UuidDefinitionTest.php | 18 +-- .../Schema/Definition/XMLDefinitionTest.php | 18 +-- .../Definition/XMLElementDefinitionTest.php | 18 +-- 43 files changed, 680 insertions(+), 256 deletions(-) create mode 100644 src/core/etl/src/Flow/ETL/Schema/Definition/CommonType.php create mode 100644 src/core/etl/tests/Flow/ETL/Tests/Mother/DefinitionMother.php create mode 100644 src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/MergeMatrixTest.php diff --git a/documentation/contributing.md b/documentation/contributing.md index 7d28bd90b..b12365112 100644 --- a/documentation/contributing.md +++ b/documentation/contributing.md @@ -111,6 +111,6 @@ It's mandatory to follow all of them without any exceptions unless explicitly ov - [Development Guidelines](/documentation/contributing/guidelines.md) - [Benchmarks](/documentation/contributing/benchmarks.md) - Extension Development - - [Rust - Arrow Extension](/documentation/contributing/rust.md) + - [Rust - arrow-ext & flow-php-ext](/documentation/contributing/rust.md) - [C - pg-query Extension](/documentation/contributing/c.md) - [WASM - Interactive Playground](/documentation/contributing/wasm.md) diff --git a/documentation/contributing/nix.md b/documentation/contributing/nix.md index 3b788f850..4e5cc60a9 100644 --- a/documentation/contributing/nix.md +++ b/documentation/contributing/nix.md @@ -132,13 +132,16 @@ nix-shell --arg with-pg-query-ext false --arg with-c true See [C - pg-query Extension Development](/documentation/contributing/c.md) for details. -### arrow-ext Extension (Rust) +### arrow-ext and flow-php-ext Extensions (Rust) ```shell -nix-shell --arg with-arrow-ext false --arg with-rust true +nix-shell --arg with-rust true ``` -See [Rust - Arrow Extension Development](/documentation/contributing/rust.md) for details. +`with-arrow-ext` and `with-flow-php-ext` both default to `!with-rust`, so this single flag already turns the prebuilt +extensions off for both. + +See [Rust - Extension Development](/documentation/contributing/rust.md) for details. ### Protobuf / gRPC Code Generation (protoc) diff --git a/documentation/contributing/rust.md b/documentation/contributing/rust.md index 031e4ac81..82f8455cb 100644 --- a/documentation/contributing/rust.md +++ b/documentation/contributing/rust.md @@ -1,26 +1,38 @@ -# Rust - Arrow Extension Development +# Rust - Extension Development [TOC] -This document describes how to develop the `arrow-ext` PHP extension, which is written in Rust -using the [ext-php-rs](https://github.com/extphprs/ext-php-rs) framework. +This document describes how to develop the two Rust PHP extensions in this monorepo, both written with the +[ext-php-rs](https://github.com/extphprs/ext-php-rs) framework. -## Overview +| Extension | Package | What it provides | +|---|---|---| +| `arrow-ext` | `flow-php/arrow-ext` | Parquet reader and writer powered by the [Apache Arrow](https://arrow.apache.org/) Rust ecosystem, exposed as `Flow\Arrow\Parquet\Reader` and `Flow\Arrow\Parquet\Writer` | +| `flow-php-ext` | `flow-php/flow-php-ext` | Native Floe binary frame encoding/decoding and row hydration/casting against a schema | -The `arrow-ext` extension provides a high-performance Parquet reader and writer for PHP, -powered by the [Apache Arrow](https://arrow.apache.org/) Rust ecosystem. It exposes -`Flow\Arrow\Parquet\Reader` and `Flow\Arrow\Parquet\Writer` classes to PHP. +Both are optional. The pure-PHP implementations in `flow-php/etl` remain the canonical behaviour reference, and Flow +routes to the native code automatically when the extension is loaded. -For usage documentation, see [Arrow Extension](/documentation/components/extensions/arrow-ext.md). +For usage documentation, see [Arrow Extension](/documentation/components/extensions/arrow-ext.md) and +[Flow PHP Extension](/documentation/components/extensions/flow-php-ext.md). ## Development Setup ```bash -nix-shell --arg with-arrow-ext false --arg with-rust true +nix-shell --arg with-rust true ``` -This provides the Rust toolchain, clang, libclang, and PHP dev headers for building the extension -from source. After `make build`, the freshly compiled extension is loaded by PHP automatically. +This provides the Rust toolchain, clang, libclang, and PHP dev headers for building either extension from source. + +`with-arrow-ext` and `with-flow-php-ext` both default to `!with-rust`, so `--arg with-rust true` already turns the +prebuilt extensions off — you do not need to pass them yourself. Pass `--arg with-arrow-ext false` or +`--arg with-flow-php-ext false` only to override an explicit `true`; `shell.nix` asserts when either is combined with +`--arg with-rust true`, because a prebuilt extension and a source build would collide. + +> [!IMPORTANT] +> `make build` does **not** make PHP pick up the freshly compiled extension. Each Makefile's `test` target loads the +> binary explicitly with `php -d extension=...`. To use a new build from anything else, run `make install`, pass +> `-d extension=` yourself, or re-enter `nix-shell` — see [Rebuilding after a source change](#rebuilding-after-a-source-change). ## Project Structure @@ -37,41 +49,59 @@ src/extension/arrow-ext/ ├── tests/ │ ├── phpt/ # PHPT test files │ └── fixtures/ # Test parquet files +└── ext/ + └── config.m4 # PIE compatibility + +src/extension/flow-php-ext/ +├── Cargo.toml # Rust dependencies and build config +├── build.rs # Build script +├── Makefile # Build orchestration +├── src/ # Rust source code +│ ├── lib.rs # Extension entry point, module registration +│ ├── encode.rs # Floe frame body encoder +│ ├── format.rs # Floe binary format primitives +│ ├── hydrate.rs # Row hydration against a schema +│ ├── cast.rs # Value casting +│ ├── plan.rs # Per-column plan resolved once per schema +│ ├── ctx.rs # Shared module context +│ ├── values.rs # Zval <-> PHP value helpers +│ └── exception.rs # Exception mapping +├── php/ # PHP stubs for static analysis +│ └── Flow/ +├── tests/ +│ └── phpt/ # PHPT test files └── ext/ └── config.m4 # PIE compatibility ``` ## Commands -Build the extension: +Substitute `arrow-ext` or `flow-php-ext` for ``. -```bash -nix-shell --arg with-arrow-ext false --arg with-rust true --run "cd src/extension/arrow-ext && make build" -``` - -Run PHPT tests: +Build: ```bash -nix-shell --arg with-arrow-ext false --arg with-rust true --run "cd src/extension/arrow-ext && make test" +nix-shell --arg with-rust true --run "cd src/extension/ && make build" ``` -Build and run PHPT tests: +Run PHPT tests (`test` depends on `build`, so this rebuilds first): ```bash -nix-shell --arg with-arrow-ext false --arg with-rust true --run "cd src/extension/arrow-ext && make build && make test" +nix-shell --arg with-rust true --run "cd src/extension/ && make test" ``` -Run PHP-side parquet tests (uses the pre-built extension): +Clean build artifacts: ```bash -nix-shell --run "just test --testsuite=lib-parquet-integration" -nix-shell --run "just test --testsuite=adapter-parquet-integration" +nix-shell --arg with-rust true --run "cd src/extension/ && make clean" ``` -Clean build artifacts: +Run the PHP-side test suites against the prebuilt extension — note these use the **default** shell, not the Rust one: ```bash -nix-shell --arg with-arrow-ext false --arg with-rust true --run "cd src/extension/arrow-ext && make clean" +nix-shell --run "just test --testsuite=lib-parquet-integration" # arrow-ext +nix-shell --run "just test --testsuite=adapter-parquet-integration" # arrow-ext +nix-shell --run "just test --testsuite=etl-unit" # flow-php-ext ``` ## Make Targets @@ -79,7 +109,25 @@ nix-shell --arg with-arrow-ext false --arg with-rust true --run "cd src/extensio | Target | Description | |-----------|------------------------------------| | `build` | Build the extension (cargo + copy) | -| `test` | Run PHPT tests | -| `install` | Install to system PHP | +| `test` | Build, then run PHPT tests | +| `install` | Copy the built module into PHP's `extension_dir` | | `clean` | Remove build artifacts | | `rebuild` | Full clean + build | + +The two PHPT runners differ in two ways: + +- `arrow-ext` runs each test with `php -n`, so `php.ini` is ignored and no other extension is loaded. + `flow-php-ext` does not, so the ambient extensions load alongside it. +- `flow-php-ext` honours `--SKIPIF--` blocks and reports a skipped count. `arrow-ext` ignores them. + +## Rebuilding after a source change + +`.nix/pkgs/php-arrow-ext` and `.nix/pkgs/php-flow-php-ext` build their extension from the local repository source, so a +shell you entered before editing any `.rs` still embeds the **previous** build. Running `just test` in a stale shell +produces failures that are artifacts of the old binary, not of your change. + +Re-enter `nix-shell` to rebuild the derivation, or build and test the extension directly: + +```bash +nix-shell --arg with-rust true --run "cd src/extension/flow-php-ext && make build && make test" +``` diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/BooleanDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/BooleanDefinition.php index a57239975..6357ce46c 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/BooleanDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/BooleanDefinition.php @@ -142,7 +142,7 @@ public function merge(Definition $definition): Definition ); } - throw new RuntimeException(sprintf('Cannot merge %s with %s', self::class, $definition::class)); + return (new CommonType())->merge($this, $definition); } public function metadata(): Metadata diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/CommonType.php b/src/core/etl/src/Flow/ETL/Schema/Definition/CommonType.php new file mode 100644 index 000000000..93c0fa8ea --- /dev/null +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/CommonType.php @@ -0,0 +1,37 @@ + $left + * @param Definition $right + * + * @return Definition + */ + public function merge(Definition $left, Definition $right): Definition + { + $nullable = $left->isNullable() || $right->isNullable(); + $metadata = $left->metadata()->merge($right->metadata()); + + if (in_array($left::class, self::CONTAINERS, true) && in_array($right::class, self::CONTAINERS, true)) { + return new JsonDefinition($left->entry(), $nullable, $metadata); + } + + return new StringDefinition($left->entry(), $nullable, $metadata); + } +} diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/DateDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/DateDefinition.php index efa86e90c..0956b988b 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/DateDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/DateDefinition.php @@ -141,22 +141,6 @@ public function merge(Definition $definition): Definition ); } - if ($definition instanceof FloatDefinition) { - return new FloatDefinition( - $this->ref, - $this->nullable || $definition->isNullable(), - $this->metadata->merge($definition->metadata()), - ); - } - - if ($definition instanceof IntegerDefinition) { - return new IntegerDefinition( - $this->ref, - $this->nullable || $definition->isNullable(), - $this->metadata->merge($definition->metadata()), - ); - } - if ($definition instanceof UnionDefinition && (new UnionMembers())->contains($definition, $this)) { return new UnionDefinition( $this->ref, @@ -166,7 +150,7 @@ public function merge(Definition $definition): Definition ); } - throw new RuntimeException(sprintf('Cannot merge %s with %s', self::class, $definition::class)); + return (new CommonType())->merge($this, $definition); } public function metadata(): Metadata diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/DateTimeDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/DateTimeDefinition.php index 8d5dea829..13ad9e432 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/DateTimeDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/DateTimeDefinition.php @@ -137,22 +137,6 @@ public function merge(Definition $definition): Definition ); } - if ($definition instanceof FloatDefinition) { - return new FloatDefinition( - $this->ref, - $this->nullable || $definition->isNullable(), - $this->metadata->merge($definition->metadata()), - ); - } - - if ($definition instanceof IntegerDefinition) { - return new IntegerDefinition( - $this->ref, - $this->nullable || $definition->isNullable(), - $this->metadata->merge($definition->metadata()), - ); - } - if ($definition instanceof UnionDefinition && (new UnionMembers())->contains($definition, $this)) { return new UnionDefinition( $this->ref, @@ -162,7 +146,7 @@ public function merge(Definition $definition): Definition ); } - throw new RuntimeException(sprintf('Cannot merge %s with %s', self::class, $definition::class)); + return (new CommonType())->merge($this, $definition); } public function metadata(): Metadata diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/EnumDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/EnumDefinition.php index fe97293aa..ead705d07 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/EnumDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/EnumDefinition.php @@ -120,7 +120,7 @@ public function matches(Entry $entry): bool return $this->isNullable(); } - return $entry->type() instanceof EnumType; + return $entry->type() instanceof EnumType && $this->type->isValid($entry->value()); } public function merge(Definition $definition): Definition @@ -168,7 +168,7 @@ public function merge(Definition $definition): Definition ); } - throw new RuntimeException(sprintf('Cannot merge %s with %s', self::class, $definition::class)); + return (new CommonType())->merge($this, $definition); } public function metadata(): Metadata diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/FloatDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/FloatDefinition.php index c73d3ae70..f7dd27edd 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/FloatDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/FloatDefinition.php @@ -133,14 +133,6 @@ public function merge(Definition $definition): Definition ); } - if ($definition instanceof DateDefinition || $definition instanceof DateTimeDefinition) { - return new self( - $this->ref, - $this->nullable || $definition->isNullable(), - $this->metadata->merge($definition->metadata()), - ); - } - if ($definition instanceof StringDefinition) { return new StringDefinition( $this->ref, @@ -158,7 +150,7 @@ public function merge(Definition $definition): Definition ); } - throw new RuntimeException(sprintf('Cannot merge %s with %s', self::class, $definition::class)); + return (new CommonType())->merge($this, $definition); } public function metadata(): Metadata diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/HTMLDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/HTMLDefinition.php index 58a4f7a34..cf7efcf6b 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/HTMLDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/HTMLDefinition.php @@ -143,7 +143,7 @@ public function merge(Definition $definition): Definition ); } - throw new RuntimeException(sprintf('Cannot merge %s with %s', self::class, $definition::class)); + return (new CommonType())->merge($this, $definition); } public function metadata(): Metadata diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/HTMLElementDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/HTMLElementDefinition.php index 9ab45f0c9..13ec25d19 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/HTMLElementDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/HTMLElementDefinition.php @@ -143,7 +143,7 @@ public function merge(Definition $definition): Definition ); } - throw new RuntimeException(sprintf('Cannot merge %s with %s', self::class, $definition::class)); + return (new CommonType())->merge($this, $definition); } public function metadata(): Metadata diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/IntegerDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/IntegerDefinition.php index 6e834775d..ceb1baaf4 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/IntegerDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/IntegerDefinition.php @@ -133,14 +133,6 @@ public function merge(Definition $definition): Definition ); } - if ($definition instanceof DateDefinition || $definition instanceof DateTimeDefinition) { - return new self( - $this->ref, - $this->nullable || $definition->isNullable(), - $this->metadata->merge($definition->metadata()), - ); - } - if ($definition instanceof StringDefinition) { return new StringDefinition( $this->ref, @@ -158,7 +150,7 @@ public function merge(Definition $definition): Definition ); } - throw new RuntimeException(sprintf('Cannot merge %s with %s', self::class, $definition::class)); + return (new CommonType())->merge($this, $definition); } public function metadata(): Metadata diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/JsonDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/JsonDefinition.php index 3f05adb4b..0e3b4c29e 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/JsonDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/JsonDefinition.php @@ -143,7 +143,7 @@ public function merge(Definition $definition): Definition ); } - throw new RuntimeException(sprintf('Cannot merge %s with %s', self::class, $definition::class)); + return (new CommonType())->merge($this, $definition); } public function metadata(): Metadata diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/ListDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/ListDefinition.php index f8409d2a7..9efffb943 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/ListDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/ListDefinition.php @@ -126,7 +126,7 @@ public function matches(Entry $entry): bool return $this->isNullable(); } - return $entry->type() instanceof ListType; + return $entry->type() instanceof ListType && $this->type->isValid($entry->value()); } public function merge(Definition $definition): Definition @@ -169,7 +169,7 @@ public function merge(Definition $definition): Definition ); } - throw new RuntimeException(sprintf('Cannot merge %s with %s', self::class, $definition::class)); + return (new CommonType())->merge($this, $definition); } public function metadata(): Metadata diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/MapDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/MapDefinition.php index 05069a3aa..baf6a1096 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/MapDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/MapDefinition.php @@ -133,7 +133,7 @@ public function matches(Entry $entry): bool return $this->isNullable(); } - return $entry->type() instanceof MapType; + return $entry->type() instanceof MapType && $this->type->isValid($entry->value()); } public function merge(Definition $definition): Definition @@ -176,7 +176,7 @@ public function merge(Definition $definition): Definition ); } - throw new RuntimeException(sprintf('Cannot merge %s with %s', self::class, $definition::class)); + return (new CommonType())->merge($this, $definition); } public function metadata(): Metadata diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/StructureDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/StructureDefinition.php index f4d27f25a..fd6ae8e70 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/StructureDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/StructureDefinition.php @@ -146,7 +146,7 @@ public function matches(Entry $entry): bool return $this->isNullable(); } - return $entry->type() instanceof StructureType; + return $entry->type() instanceof StructureType && $this->type->isValid($entry->value()); } public function merge(Definition $definition): Definition @@ -189,7 +189,7 @@ public function merge(Definition $definition): Definition ); } - throw new RuntimeException(sprintf('Cannot merge %s with %s', self::class, $definition::class)); + return (new CommonType())->merge($this, $definition); } public function metadata(): Metadata diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/TimeDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/TimeDefinition.php index 53b6338e1..b23e0f66e 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/TimeDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/TimeDefinition.php @@ -150,7 +150,7 @@ public function merge(Definition $definition): Definition ); } - throw new RuntimeException(sprintf('Cannot merge %s with %s', self::class, $definition::class)); + return (new CommonType())->merge($this, $definition); } public function metadata(): Metadata diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/TypeMerge.php b/src/core/etl/src/Flow/ETL/Schema/Definition/TypeMerge.php index f575dbf1f..7d02752be 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/TypeMerge.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/TypeMerge.php @@ -7,10 +7,12 @@ use Flow\Types\Type; use Flow\Types\Type\Logical\DateTimeType; use Flow\Types\Type\Logical\DateType; +use Flow\Types\Type\Logical\JsonType; use Flow\Types\Type\Logical\ListType; use Flow\Types\Type\Logical\MapType; use Flow\Types\Type\Logical\OptionalType; use Flow\Types\Type\Logical\StructureType; +use Flow\Types\Type\Native\ArrayType; use Flow\Types\Type\Native\FloatType; use Flow\Types\Type\Native\IntegerType; use Flow\Types\Type\Native\NullType; @@ -20,14 +22,24 @@ use function Flow\Types\DSL\type_datetime; use function Flow\Types\DSL\type_equals; use function Flow\Types\DSL\type_float; +use function Flow\Types\DSL\type_json; use function Flow\Types\DSL\type_list; use function Flow\Types\DSL\type_map; use function Flow\Types\DSL\type_optional; use function Flow\Types\DSL\type_string; use function Flow\Types\DSL\type_structure; +use function in_array; final readonly class TypeMerge { + private const CONTAINERS = [ + ArrayType::class, + JsonType::class, + ListType::class, + MapType::class, + StructureType::class, + ]; + /** * @param Type $left * @param Type $right @@ -83,6 +95,11 @@ public function merge(Type $left, Type $right): Type return $this->mergeMaps($left, $right); } + // json holds any container shape without flattening it to text, so it beats the string fallback below. + if (in_array($left::class, self::CONTAINERS, true) && in_array($right::class, self::CONTAINERS, true)) { + return type_json(); + } + // Inference cannot throw, so an irreconcilable pair widens to the most permissive type instead. return type_string(); } diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/UnionDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/UnionDefinition.php index 97199afda..89a89fc1c 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/UnionDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/UnionDefinition.php @@ -168,7 +168,7 @@ public function merge(Definition $definition): Definition ); } - throw new RuntimeException(sprintf('Cannot merge %s with %s', self::class, $definition::class)); + return (new CommonType())->merge($this, $definition); } public function metadata(): Metadata diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/UuidDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/UuidDefinition.php index 12f800776..d726ba350 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/UuidDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/UuidDefinition.php @@ -143,7 +143,7 @@ public function merge(Definition $definition): Definition ); } - throw new RuntimeException(sprintf('Cannot merge %s with %s', self::class, $definition::class)); + return (new CommonType())->merge($this, $definition); } public function metadata(): Metadata diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/XMLDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/XMLDefinition.php index a8c3982a0..3f31d167a 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/XMLDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/XMLDefinition.php @@ -143,7 +143,7 @@ public function merge(Definition $definition): Definition ); } - throw new RuntimeException(sprintf('Cannot merge %s with %s', self::class, $definition::class)); + return (new CommonType())->merge($this, $definition); } public function metadata(): Metadata diff --git a/src/core/etl/src/Flow/ETL/Schema/Definition/XMLElementDefinition.php b/src/core/etl/src/Flow/ETL/Schema/Definition/XMLElementDefinition.php index 0279d2113..ee7ef92c5 100644 --- a/src/core/etl/src/Flow/ETL/Schema/Definition/XMLElementDefinition.php +++ b/src/core/etl/src/Flow/ETL/Schema/Definition/XMLElementDefinition.php @@ -142,7 +142,7 @@ public function merge(Definition $definition): Definition ); } - throw new RuntimeException(sprintf('Cannot merge %s with %s', self::class, $definition::class)); + return (new CommonType())->merge($this, $definition); } public function metadata(): Metadata diff --git a/src/core/etl/tests/Flow/ETL/Tests/Integration/DataFrame/SchemaTest.php b/src/core/etl/tests/Flow/ETL/Tests/Integration/DataFrame/SchemaTest.php index 52d3ddd92..91211303b 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Integration/DataFrame/SchemaTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Integration/DataFrame/SchemaTest.php @@ -24,6 +24,7 @@ use function Flow\ETL\DSL\from_rows; use function Flow\ETL\DSL\int_entry; use function Flow\ETL\DSL\int_schema; +use function Flow\ETL\DSL\json_schema; use function Flow\ETL\DSL\null_entry; use function Flow\ETL\DSL\null_schema; use function Flow\ETL\DSL\row; @@ -82,6 +83,19 @@ public function test_extraction_without_to_schema(): void static::assertEquals(schema(int_schema('id'), str_schema('name'), null_schema('active')), $rows->schema()); } + public function test_getting_schema_of_a_column_holding_both_an_empty_array_and_a_structure(): void + { + static::assertEquals( + schema(json_schema('a')), + df()->read(from_array([['a' => []], ['a' => ['x' => 1]]]))->schema(), + ); + } + + public function test_getting_schema_of_a_column_holding_unrelated_types(): void + { + static::assertEquals(schema(str_schema('a')), df()->read(from_array([['a' => 1], ['a' => true]]))->schema()); + } + public function test_getting_schema_of_enum_column_with_null_values(): void { static::assertEquals( diff --git a/src/core/etl/tests/Flow/ETL/Tests/Mother/DefinitionMother.php b/src/core/etl/tests/Flow/ETL/Tests/Mother/DefinitionMother.php new file mode 100644 index 000000000..8d07df7e5 --- /dev/null +++ b/src/core/etl/tests/Flow/ETL/Tests/Mother/DefinitionMother.php @@ -0,0 +1,67 @@ +> + */ + public static function oneOfEachType(string $ref = 'a'): array + { + return [ + 'null' => null_schema($ref), + 'bool' => bool_schema($ref), + 'int' => int_schema($ref), + 'float' => float_schema($ref), + 'string' => string_schema($ref), + 'date' => date_schema($ref), + 'datetime' => datetime_schema($ref), + 'time' => time_schema($ref), + 'uuid' => uuid_schema($ref), + 'enum' => enum_schema($ref, BackedStringEnum::class), + 'json' => json_schema($ref), + 'struct' => structure_schema($ref, type_structure(['x' => type_integer()])), + 'list' => list_schema($ref, type_list(type_integer())), + 'map' => map_schema($ref, type_map(type_string(), type_integer())), + 'union' => union_schema($ref, type_union(type_string(), type_integer())), + 'html' => html_schema($ref), + 'htmlel' => html_element_schema($ref), + 'xml' => xml_schema($ref), + 'xmlel' => xml_element_schema($ref), + ]; + } +} diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/BooleanDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/BooleanDefinitionTest.php index b35daef3d..49446476c 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/BooleanDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/BooleanDefinitionTest.php @@ -209,13 +209,11 @@ public function test_merge_with_different_entry_name_throws_exception(): void $def->merge(bool_schema('other')); } - public function test_merge_with_incompatible_type_throws_exception(): void + public function test_merge_with_incompatible_type_falls_back_to_string(): void { $def = bool_schema('col'); - $this->expectException(RuntimeException::class); - - $def->merge(new IntegerDefinition('col')); + static::assertSame('string', $def->merge(new IntegerDefinition('col'))->type()->toString()); } public function test_normalize(): void @@ -294,10 +292,14 @@ public function test_merge_with_union_containing_this_type_returns_union(): void static::assertSame('boolean|integer', $merged->type()->toString()); } - public function test_merge_with_union_not_containing_this_type_throws_exception(): void + public function test_merge_with_union_not_containing_this_type_falls_back_to_string(): void { - $this->expectException(RuntimeException::class); - - bool_schema('col')->merge(union_schema('col', type_union(type_integer(), type_string()))); + static::assertSame( + 'string', + bool_schema('col') + ->merge(union_schema('col', type_union(type_integer(), type_string()))) + ->type() + ->toString(), + ); } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/DateDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/DateDefinitionTest.php index edc47ab1a..f1a5bd7c2 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/DateDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/DateDefinitionTest.php @@ -242,13 +242,11 @@ public function test_merge_with_different_entry_name_throws_exception(): void $def->merge(date_schema('other')); } - public function test_merge_with_incompatible_type_throws_exception(): void + public function test_merge_with_incompatible_type_falls_back_to_string(): void { $def = date_schema('col'); - $this->expectException(RuntimeException::class); - - $def->merge(new BooleanDefinition('col')); + static::assertSame('string', $def->merge(new BooleanDefinition('col'))->type()->toString()); } public function test_normalize(): void @@ -327,10 +325,14 @@ public function test_merge_with_union_containing_this_type_returns_union(): void static::assertSame('boolean|date', $merged->type()->toString()); } - public function test_merge_with_union_not_containing_this_type_throws_exception(): void + public function test_merge_with_union_not_containing_this_type_falls_back_to_string(): void { - $this->expectException(RuntimeException::class); - - date_schema('col')->merge(union_schema('col', type_union(type_integer(), type_string()))); + static::assertSame( + 'string', + date_schema('col') + ->merge(union_schema('col', type_union(type_integer(), type_string()))) + ->type() + ->toString(), + ); } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/DateTimeDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/DateTimeDefinitionTest.php index 3d19bfdf3..6a5aa1712 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/DateTimeDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/DateTimeDefinitionTest.php @@ -241,13 +241,11 @@ public function test_merge_with_different_entry_name_throws_exception(): void $def->merge(datetime_schema('other')); } - public function test_merge_with_incompatible_type_throws_exception(): void + public function test_merge_with_incompatible_type_falls_back_to_string(): void { $def = datetime_schema('col'); - $this->expectException(RuntimeException::class); - - $def->merge(new BooleanDefinition('col')); + static::assertSame('string', $def->merge(new BooleanDefinition('col'))->type()->toString()); } public function test_normalize(): void @@ -326,10 +324,14 @@ public function test_merge_with_union_containing_this_type_returns_union(): void static::assertSame('boolean|datetime', $merged->type()->toString()); } - public function test_merge_with_union_not_containing_this_type_throws_exception(): void + public function test_merge_with_union_not_containing_this_type_falls_back_to_string(): void { - $this->expectException(RuntimeException::class); - - datetime_schema('col')->merge(union_schema('col', type_union(type_integer(), type_string()))); + static::assertSame( + 'string', + datetime_schema('col') + ->merge(union_schema('col', type_union(type_integer(), type_string()))) + ->type() + ->toString(), + ); } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/EnumDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/EnumDefinitionTest.php index a9741d404..ed0f81f74 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/EnumDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/EnumDefinitionTest.php @@ -12,6 +12,7 @@ use Flow\ETL\Schema\Definition\EnumDefinition; use Flow\ETL\Schema\Definition\UnionDefinition; use Flow\ETL\Schema\Metadata; +use Flow\ETL\Tests\Fixtures\Enum\BackedIntEnum; use Flow\ETL\Tests\Fixtures\Enum\BackedStringEnum; use Flow\ETL\Tests\Fixtures\Enum\BasicEnum; use Flow\ETL\Tests\FlowTestCase; @@ -23,6 +24,7 @@ use function Flow\ETL\DSL\enum_schema; use function Flow\ETL\DSL\int_entry; use function Flow\ETL\DSL\null_schema; +use function Flow\ETL\DSL\str_entry; use function Flow\ETL\DSL\string_schema; use function Flow\ETL\DSL\union_schema; use function Flow\Types\DSL\type_boolean; @@ -99,6 +101,20 @@ public function test_does_not_match_a_null_entry_when_not_nullable(): void static::assertFalse($def->matches(enum_entry('col', null))); } + public function test_does_not_match_a_string_entry_holding_the_enum_class_name(): void + { + $def = enum_schema('col', BackedStringEnum::class); + + static::assertFalse($def->matches(str_entry('col', BackedStringEnum::class))); + } + + public function test_does_not_match_an_entry_holding_a_different_enum(): void + { + $def = enum_schema('col', BackedStringEnum::class); + + static::assertFalse($def->matches(enum_entry('col', BackedIntEnum::one))); + } + public function test_does_not_match_entry_with_different_name(): void { $def = enum_schema('status', BackedStringEnum::class); @@ -177,6 +193,21 @@ public function test_make_nullable(): void static::assertFalse($def->isNullable()); } + public function test_matches_an_entry_holding_any_enum_when_declared_as_unit_enum(): void + { + $def = enum_schema('col', UnitEnum::class); + + static::assertTrue($def->matches(enum_entry('col', BackedStringEnum::one))); + } + + public function test_matches_and_is_compatible_agree_on_a_different_enum(): void + { + $def = enum_schema('col', BackedStringEnum::class); + + static::assertFalse($def->matches(enum_entry('col', BackedIntEnum::one))); + static::assertFalse($def->isCompatible(enum_schema('col', BackedIntEnum::class))); + } + public function test_matches_entry_with_same_name_and_type(): void { $def = enum_schema('status', BackedStringEnum::class); @@ -247,22 +278,18 @@ public function test_merge_with_different_entry_name_throws_exception(): void $def->merge(enum_schema('other', BackedStringEnum::class)); } - public function test_merge_with_different_enum_class_throws_exception(): void + public function test_merge_with_different_enum_class_falls_back_to_string(): void { $def = enum_schema('col', BackedStringEnum::class); - $this->expectException(RuntimeException::class); - - $def->merge(enum_schema('col', BasicEnum::class)); + static::assertSame('string', $def->merge(enum_schema('col', BasicEnum::class))->type()->toString()); } - public function test_merge_with_incompatible_type_throws_exception(): void + public function test_merge_with_incompatible_type_falls_back_to_string(): void { $def = enum_schema('col', BackedStringEnum::class); - $this->expectException(RuntimeException::class); - - $def->merge(new BooleanDefinition('col')); + static::assertSame('string', $def->merge(new BooleanDefinition('col'))->type()->toString()); } public function test_normalize(): void @@ -352,13 +379,14 @@ public function test_merge_with_union_containing_this_type_returns_union(): void static::assertSame('boolean|enum', $merged->type()->toString()); } - public function test_merge_with_union_not_containing_this_type_throws_exception(): void + public function test_merge_with_union_not_containing_this_type_falls_back_to_string(): void { - $this->expectException(RuntimeException::class); - - enum_schema('col', BackedStringEnum::class)->merge(union_schema('col', type_union( - type_integer(), - type_string(), - ))); + static::assertSame( + 'string', + enum_schema('col', BackedStringEnum::class) + ->merge(union_schema('col', type_union(type_integer(), type_string()))) + ->type() + ->toString(), + ); } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/FloatDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/FloatDefinitionTest.php index 86d11b080..08828970f 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/FloatDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/FloatDefinitionTest.php @@ -234,13 +234,11 @@ public function test_merge_with_different_entry_name_throws_exception(): void $def->merge(float_schema('other')); } - public function test_merge_with_incompatible_type_throws_exception(): void + public function test_merge_with_incompatible_type_falls_back_to_string(): void { $def = float_schema('col'); - $this->expectException(RuntimeException::class); - - $def->merge(new BooleanDefinition('col')); + static::assertSame('string', $def->merge(new BooleanDefinition('col'))->type()->toString()); } public function test_normalize(): void @@ -319,10 +317,14 @@ public function test_merge_with_union_containing_this_type_returns_union(): void static::assertSame('boolean|float', $merged->type()->toString()); } - public function test_merge_with_union_not_containing_this_type_throws_exception(): void + public function test_merge_with_union_not_containing_this_type_falls_back_to_string(): void { - $this->expectException(RuntimeException::class); - - float_schema('col')->merge(union_schema('col', type_union(type_integer(), type_string()))); + static::assertSame( + 'string', + float_schema('col') + ->merge(union_schema('col', type_union(type_integer(), type_string()))) + ->type() + ->toString(), + ); } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/HTMLDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/HTMLDefinitionTest.php index f41036a6b..f954c0e03 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/HTMLDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/HTMLDefinitionTest.php @@ -213,13 +213,11 @@ public function test_merge_with_different_entry_name_throws_exception(): void $def->merge(html_schema('other')); } - public function test_merge_with_incompatible_type_throws_exception(): void + public function test_merge_with_incompatible_type_falls_back_to_string(): void { $def = html_schema('col'); - $this->expectException(RuntimeException::class); - - $def->merge(new BooleanDefinition('col')); + static::assertSame('string', $def->merge(new BooleanDefinition('col'))->type()->toString()); } public function test_normalize(): void @@ -299,10 +297,14 @@ public function test_merge_with_union_containing_this_type_returns_union(): void static::assertSame('boolean|html', $merged->type()->toString()); } - public function test_merge_with_union_not_containing_this_type_throws_exception(): void + public function test_merge_with_union_not_containing_this_type_falls_back_to_string(): void { - $this->expectException(RuntimeException::class); - - html_schema('col')->merge(union_schema('col', type_union(type_integer(), type_string()))); + static::assertSame( + 'string', + html_schema('col') + ->merge(union_schema('col', type_union(type_integer(), type_string()))) + ->type() + ->toString(), + ); } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/HTMLElementDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/HTMLElementDefinitionTest.php index f2dabc327..cfb480229 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/HTMLElementDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/HTMLElementDefinitionTest.php @@ -213,13 +213,11 @@ public function test_merge_with_different_entry_name_throws_exception(): void $def->merge(html_element_schema('other')); } - public function test_merge_with_incompatible_type_throws_exception(): void + public function test_merge_with_incompatible_type_falls_back_to_string(): void { $def = html_element_schema('col'); - $this->expectException(RuntimeException::class); - - $def->merge(new BooleanDefinition('col')); + static::assertSame('string', $def->merge(new BooleanDefinition('col'))->type()->toString()); } public function test_normalize(): void @@ -302,10 +300,14 @@ public function test_merge_with_union_containing_this_type_returns_union(): void static::assertSame('boolean|html_element', $merged->type()->toString()); } - public function test_merge_with_union_not_containing_this_type_throws_exception(): void + public function test_merge_with_union_not_containing_this_type_falls_back_to_string(): void { - $this->expectException(RuntimeException::class); - - html_element_schema('col')->merge(union_schema('col', type_union(type_integer(), type_string()))); + static::assertSame( + 'string', + html_element_schema('col') + ->merge(union_schema('col', type_union(type_integer(), type_string()))) + ->type() + ->toString(), + ); } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/IntegerDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/IntegerDefinitionTest.php index caf508129..6de268829 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/IntegerDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/IntegerDefinitionTest.php @@ -234,13 +234,11 @@ public function test_merge_with_different_entry_name_throws_exception(): void $def->merge(int_schema('other')); } - public function test_merge_with_incompatible_type_throws_exception(): void + public function test_merge_with_incompatible_type_falls_back_to_string(): void { $def = int_schema('col'); - $this->expectException(RuntimeException::class); - - $def->merge(new BooleanDefinition('col')); + static::assertSame('string', $def->merge(new BooleanDefinition('col'))->type()->toString()); } public function test_normalize(): void @@ -319,10 +317,14 @@ public function test_merge_with_union_containing_this_type_returns_union(): void static::assertSame('boolean|integer', $merged->type()->toString()); } - public function test_merge_with_union_not_containing_this_type_throws_exception(): void + public function test_merge_with_union_not_containing_this_type_falls_back_to_string(): void { - $this->expectException(RuntimeException::class); - - int_schema('col')->merge(union_schema('col', type_union(type_boolean(), type_string()))); + static::assertSame( + 'string', + int_schema('col') + ->merge(union_schema('col', type_union(type_boolean(), type_string()))) + ->type() + ->toString(), + ); } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/JsonDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/JsonDefinitionTest.php index 6c2bc03e0..6969bd073 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/JsonDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/JsonDefinitionTest.php @@ -210,13 +210,11 @@ public function test_merge_with_different_entry_name_throws_exception(): void $def->merge(json_schema('other')); } - public function test_merge_with_incompatible_type_throws_exception(): void + public function test_merge_with_incompatible_type_falls_back_to_string(): void { $def = json_schema('col'); - $this->expectException(RuntimeException::class); - - $def->merge(new BooleanDefinition('col')); + static::assertSame('string', $def->merge(new BooleanDefinition('col'))->type()->toString()); } public function test_normalize(): void @@ -295,10 +293,14 @@ public function test_merge_with_union_containing_this_type_returns_union(): void static::assertSame('boolean|json', $merged->type()->toString()); } - public function test_merge_with_union_not_containing_this_type_throws_exception(): void + public function test_merge_with_union_not_containing_this_type_falls_back_to_string(): void { - $this->expectException(RuntimeException::class); - - json_schema('col')->merge(union_schema('col', type_union(type_integer(), type_string()))); + static::assertSame( + 'string', + json_schema('col') + ->merge(union_schema('col', type_union(type_integer(), type_string()))) + ->type() + ->toString(), + ); } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/ListDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/ListDefinitionTest.php index bd91b11d9..353340975 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/ListDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/ListDefinitionTest.php @@ -18,6 +18,7 @@ use function Flow\ETL\DSL\int_entry; use function Flow\ETL\DSL\list_entry; use function Flow\ETL\DSL\list_schema; +use function Flow\ETL\DSL\map_entry; use function Flow\ETL\DSL\null_schema; use function Flow\ETL\DSL\string_schema; use function Flow\ETL\DSL\union_schema; @@ -25,6 +26,7 @@ use function Flow\Types\DSL\type_float; use function Flow\Types\DSL\type_integer; use function Flow\Types\DSL\type_list; +use function Flow\Types\DSL\type_map; use function Flow\Types\DSL\type_string; use function Flow\Types\DSL\type_union; @@ -107,6 +109,13 @@ public function test_add_metadata(): void static::assertFalse($def->metadata()->has('key')); } + public function test_does_not_match_a_map_entry_holding_a_list_shaped_value(): void + { + $def = list_schema('col', type_list(type_integer())); + + static::assertFalse($def->matches(map_entry('col', [1, 2], type_map(type_integer(), type_integer())))); + } + public function test_does_not_match_a_null_entry_when_not_nullable(): void { $def = list_schema('col', type_list(type_integer())); @@ -114,6 +123,20 @@ public function test_does_not_match_a_null_entry_when_not_nullable(): void static::assertFalse($def->matches(list_entry('col', null, type_list(type_integer())))); } + public function test_does_not_match_an_entry_of_a_different_list_instantiation(): void + { + $def = list_schema('col', type_list(type_integer())); + + static::assertFalse($def->matches(list_entry('col', ['x'], type_list(type_string())))); + } + + public function test_does_not_match_an_entry_of_a_different_nested_list_instantiation(): void + { + $def = list_schema('col', type_list(type_list(type_integer()))); + + static::assertFalse($def->matches(list_entry('col', [['x']], type_list(type_list(type_string()))))); + } + public function test_does_not_match_entry_with_different_name(): void { $def = list_schema('items', type_list(type_integer())); @@ -185,6 +208,21 @@ public function test_make_nullable(): void static::assertFalse($def->isNullable()); } + public function test_matches_an_empty_list_entry_of_a_different_instantiation(): void + { + $def = list_schema('col', type_list(type_integer())); + + static::assertTrue($def->matches(list_entry('col', [], type_list(type_string())))); + } + + public function test_matches_and_is_compatible_agree_on_a_different_instantiation(): void + { + $def = list_schema('col', type_list(type_integer())); + + static::assertFalse($def->matches(list_entry('col', ['x'], type_list(type_string())))); + static::assertFalse($def->isCompatible(list_schema('col', type_list(type_string())))); + } + public function test_matches_entry_with_same_name_and_type(): void { $def = list_schema('items', type_list(type_integer())); @@ -229,13 +267,11 @@ public function test_merge_with_different_entry_name_throws_exception(): void $def->merge(list_schema('other', type_list(type_integer()))); } - public function test_merge_with_incompatible_type_throws_exception(): void + public function test_merge_with_incompatible_type_falls_back_to_string(): void { $def = list_schema('col', type_list(type_integer())); - $this->expectException(RuntimeException::class); - - $def->merge(new BooleanDefinition('col')); + static::assertSame('string', $def->merge(new BooleanDefinition('col'))->type()->toString()); } public function test_normalize(): void @@ -317,13 +353,14 @@ public function test_merge_with_union_containing_this_type_returns_union(): void static::assertSame('boolean|list', $merged->type()->toString()); } - public function test_merge_with_union_not_containing_this_type_throws_exception(): void + public function test_merge_with_union_not_containing_this_type_falls_back_to_string(): void { - $this->expectException(RuntimeException::class); - - list_schema('col', type_list(type_integer()))->merge(union_schema('col', type_union( - type_integer(), - type_string(), - ))); + static::assertSame( + 'string', + list_schema('col', type_list(type_integer())) + ->merge(union_schema('col', type_union(type_integer(), type_string()))) + ->type() + ->toString(), + ); } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/MapDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/MapDefinitionTest.php index 386bf35f0..ee68259f8 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/MapDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/MapDefinitionTest.php @@ -16,6 +16,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use function Flow\ETL\DSL\int_entry; +use function Flow\ETL\DSL\list_entry; use function Flow\ETL\DSL\map_entry; use function Flow\ETL\DSL\map_schema; use function Flow\ETL\DSL\null_schema; @@ -24,6 +25,7 @@ use function Flow\Types\DSL\type_boolean; use function Flow\Types\DSL\type_float; use function Flow\Types\DSL\type_integer; +use function Flow\Types\DSL\type_list; use function Flow\Types\DSL\type_map; use function Flow\Types\DSL\type_string; use function Flow\Types\DSL\type_union; @@ -119,6 +121,13 @@ public function test_add_metadata(): void static::assertFalse($def->metadata()->has('key')); } + public function test_does_not_match_a_list_entry_holding_a_map_shaped_value(): void + { + $def = map_schema('col', type_map(type_integer(), type_integer())); + + static::assertFalse($def->matches(list_entry('col', [1, 2], type_list(type_integer())))); + } + public function test_does_not_match_a_null_entry_when_not_nullable(): void { $def = map_schema('col', type_map(type_string(), type_integer())); @@ -126,6 +135,13 @@ public function test_does_not_match_a_null_entry_when_not_nullable(): void static::assertFalse($def->matches(map_entry('col', null, type_map(type_string(), type_integer())))); } + public function test_does_not_match_an_entry_of_a_different_map_instantiation(): void + { + $def = map_schema('col', type_map(type_string(), type_integer())); + + static::assertFalse($def->matches(map_entry('col', ['a' => 'x'], type_map(type_string(), type_string())))); + } + public function test_does_not_match_entry_with_different_name(): void { $def = map_schema('data', type_map(type_string(), type_integer())); @@ -197,6 +213,21 @@ public function test_make_nullable(): void static::assertFalse($def->isNullable()); } + public function test_matches_an_empty_map_entry_of_a_different_instantiation(): void + { + $def = map_schema('col', type_map(type_string(), type_integer())); + + static::assertTrue($def->matches(map_entry('col', [], type_map(type_string(), type_string())))); + } + + public function test_matches_and_is_compatible_agree_on_a_different_instantiation(): void + { + $def = map_schema('col', type_map(type_string(), type_integer())); + + static::assertFalse($def->matches(map_entry('col', ['a' => 'x'], type_map(type_string(), type_string())))); + static::assertFalse($def->isCompatible(map_schema('col', type_map(type_string(), type_string())))); + } + public function test_matches_entry_with_same_name_and_type(): void { $def = map_schema('data', type_map(type_string(), type_integer())); @@ -245,13 +276,11 @@ public function test_merge_with_different_entry_name_throws_exception(): void $def->merge(map_schema('other', type_map(type_string(), type_integer()))); } - public function test_merge_with_incompatible_type_throws_exception(): void + public function test_merge_with_incompatible_type_falls_back_to_string(): void { $def = map_schema('col', type_map(type_string(), type_integer())); - $this->expectException(RuntimeException::class); - - $def->merge(new BooleanDefinition('col')); + static::assertSame('string', $def->merge(new BooleanDefinition('col'))->type()->toString()); } public function test_normalize(): void @@ -333,13 +362,14 @@ public function test_merge_with_union_containing_this_type_returns_union(): void static::assertSame('boolean|map', $merged->type()->toString()); } - public function test_merge_with_union_not_containing_this_type_throws_exception(): void + public function test_merge_with_union_not_containing_this_type_falls_back_to_string(): void { - $this->expectException(RuntimeException::class); - - map_schema('col', type_map(type_string(), type_integer()))->merge(union_schema('col', type_union( - type_integer(), - type_string(), - ))); + static::assertSame( + 'string', + map_schema('col', type_map(type_string(), type_integer())) + ->merge(union_schema('col', type_union(type_integer(), type_string()))) + ->type() + ->toString(), + ); } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/MergeMatrixTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/MergeMatrixTest.php new file mode 100644 index 000000000..5fdb03700 --- /dev/null +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/MergeMatrixTest.php @@ -0,0 +1,107 @@ + $expected) { + yield "{$left} merged with {$columns[$index]}" => [$left, $columns[$index], $expected]; + } + } + } + + /** + * @param Definition $definition + */ + public static function columnName(Definition $definition): string + { + $type = $definition->type()->toString(); + + return match (true) { + str_starts_with($type, 'structure') => 'struct', + str_starts_with($type, 'list') => 'list', + str_starts_with($type, 'map') => 'map', + str_starts_with($type, 'enum') => 'enum', + str_contains($type, '|') => 'union', + $type === 'boolean' => 'bool', + $type === 'integer' => 'int', + $type === 'html_element' => 'htmlel', + $type === 'xml_element' => 'xmlel', + default => $type, + }; + } + + #[DataProvider('provideMergeCases')] + public function test_merge(string $left, string $right, string $expected): void + { + $definitions = DefinitionMother::oneOfEachType(); + + static::assertSame( + $expected, + self::columnName($definitions[$left]->merge($definitions[$right])), + "{$left}->merge({$right})", + ); + } + + #[DataProvider('provideMergeCases')] + public function test_merge_is_symmetric(string $left, string $right, string $expected): void + { + $definitions = DefinitionMother::oneOfEachType(); + + static::assertSame( + $expected, + self::columnName($definitions[$right]->merge($definitions[$left])), + "{$right}->merge({$left}) disagrees with {$left}->merge({$right})", + ); + } +} diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/StructureDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/StructureDefinitionTest.php index 31c4696cd..694a4f747 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/StructureDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/StructureDefinitionTest.php @@ -16,6 +16,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use function Flow\ETL\DSL\int_entry; +use function Flow\ETL\DSL\map_entry; use function Flow\ETL\DSL\null_schema; use function Flow\ETL\DSL\string_schema; use function Flow\ETL\DSL\structure_entry; @@ -23,6 +24,7 @@ use function Flow\ETL\DSL\union_schema; use function Flow\Types\DSL\type_boolean; use function Flow\Types\DSL\type_integer; +use function Flow\Types\DSL\type_map; use function Flow\Types\DSL\type_null; use function Flow\Types\DSL\type_optional; use function Flow\Types\DSL\type_string; @@ -283,6 +285,13 @@ public function test_add_metadata(): void static::assertFalse($def->metadata()->has('key')); } + public function test_does_not_match_a_map_entry_holding_a_structure_shaped_value(): void + { + $def = structure_schema('col', type_structure(['a' => type_integer()])); + + static::assertFalse($def->matches(map_entry('col', ['a' => 1], type_map(type_string(), type_integer())))); + } + public function test_does_not_match_a_null_entry_when_not_nullable(): void { $def = structure_schema('col', type_structure(['name' => type_string()])); @@ -290,6 +299,25 @@ public function test_does_not_match_a_null_entry_when_not_nullable(): void static::assertFalse($def->matches(structure_entry('col', null, type_structure(['name' => type_string()])))); } + public function test_does_not_match_an_entry_of_a_different_structure_instantiation(): void + { + $def = structure_schema('col', type_structure(['a' => type_integer()])); + + static::assertFalse($def->matches(structure_entry('col', ['b' => 'x'], type_structure([ + 'b' => type_string(), + ])))); + } + + public function test_does_not_match_an_entry_with_extra_elements_when_extra_is_not_allowed(): void + { + $def = structure_schema('col', type_structure(['a' => type_integer()])); + + static::assertFalse($def->matches(structure_entry('col', ['a' => 1, 'b' => 'x'], type_structure([ + 'a' => type_integer(), + 'b' => type_string(), + ])))); + } + public function test_does_not_match_entry_with_different_name(): void { $def = structure_schema('data', type_structure(['name' => type_string()])); @@ -386,6 +414,26 @@ public function test_make_nullable(): void static::assertFalse($def->isNullable()); } + public function test_matches_an_entry_with_extra_elements_when_extra_is_allowed(): void + { + $def = structure_schema('col', type_structure(['a' => type_integer()], [], true)); + + static::assertTrue($def->matches(structure_entry('col', ['a' => 1, 'b' => 'x'], type_structure([ + 'a' => type_integer(), + 'b' => type_string(), + ])))); + } + + public function test_matches_and_is_compatible_agree_on_a_different_instantiation(): void + { + $def = structure_schema('col', type_structure(['a' => type_integer()])); + + static::assertFalse($def->matches(structure_entry('col', ['b' => 'x'], type_structure([ + 'b' => type_string(), + ])))); + static::assertFalse($def->isCompatible(structure_schema('col', type_structure(['b' => type_string()])))); + } + public function test_matches_entry_with_same_name_and_type(): void { $def = structure_schema('data', type_structure(['name' => type_string()])); @@ -432,13 +480,11 @@ public function test_merge_with_different_entry_name_throws_exception(): void $def->merge(structure_schema('other', type_structure(['name' => type_string()]))); } - public function test_merge_with_incompatible_type_throws_exception(): void + public function test_merge_with_incompatible_type_falls_back_to_string(): void { $def = structure_schema('col', type_structure(['name' => type_string()])); - $this->expectException(RuntimeException::class); - - $def->merge(new BooleanDefinition('col')); + static::assertSame('string', $def->merge(new BooleanDefinition('col'))->type()->toString()); } public function test_normalize(): void @@ -526,13 +572,14 @@ public function test_merge_with_union_containing_this_type_returns_union(): void static::assertSame('boolean|structure{a: integer}', $merged->type()->toString()); } - public function test_merge_with_union_not_containing_this_type_throws_exception(): void + public function test_merge_with_union_not_containing_this_type_falls_back_to_string(): void { - $this->expectException(RuntimeException::class); - - structure_schema('col', type_structure(['a' => type_integer()]))->merge(union_schema('col', type_union( - type_integer(), - type_string(), - ))); + static::assertSame( + 'string', + structure_schema('col', type_structure(['a' => type_integer()])) + ->merge(union_schema('col', type_union(type_integer(), type_string()))) + ->type() + ->toString(), + ); } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/TimeDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/TimeDefinitionTest.php index 2bc9eb803..ad05c308b 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/TimeDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/TimeDefinitionTest.php @@ -242,13 +242,11 @@ public function test_merge_with_different_entry_name_throws_exception(): void $def->merge(time_schema('other')); } - public function test_merge_with_incompatible_type_throws_exception(): void + public function test_merge_with_incompatible_type_falls_back_to_string(): void { $def = time_schema('col'); - $this->expectException(RuntimeException::class); - - $def->merge(new BooleanDefinition('col')); + static::assertSame('string', $def->merge(new BooleanDefinition('col'))->type()->toString()); } public function test_normalize(): void @@ -327,10 +325,14 @@ public function test_merge_with_union_containing_this_type_returns_union(): void static::assertSame('boolean|time', $merged->type()->toString()); } - public function test_merge_with_union_not_containing_this_type_throws_exception(): void + public function test_merge_with_union_not_containing_this_type_falls_back_to_string(): void { - $this->expectException(RuntimeException::class); - - time_schema('col')->merge(union_schema('col', type_union(type_integer(), type_string()))); + static::assertSame( + 'string', + time_schema('col') + ->merge(union_schema('col', type_union(type_integer(), type_string()))) + ->type() + ->toString(), + ); } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/TypeMergeTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/TypeMergeTest.php index 620f37eef..d9d3d35ab 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/TypeMergeTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/TypeMergeTest.php @@ -11,11 +11,13 @@ use Generator; use PHPUnit\Framework\Attributes\DataProvider; +use function Flow\Types\DSL\type_array; use function Flow\Types\DSL\type_boolean; use function Flow\Types\DSL\type_date; use function Flow\Types\DSL\type_datetime; use function Flow\Types\DSL\type_float; use function Flow\Types\DSL\type_integer; +use function Flow\Types\DSL\type_json; use function Flow\Types\DSL\type_list; use function Flow\Types\DSL\type_map; use function Flow\Types\DSL\type_null; @@ -145,9 +147,27 @@ public static function provideMergeCases(): Generator type_string(), ]; - yield 'list against a map widens to string' => [ + yield 'list against a map widens to json' => [ type_list(type_string()), type_map(type_string(), type_string()), + type_json(), + ]; + + yield 'array against a structure widens to json' => [ + type_array(), + type_structure(['id' => type_integer()]), + type_json(), + ]; + + yield 'json against a list widens to json' => [ + type_json(), + type_list(type_string()), + type_json(), + ]; + + yield 'array against a scalar widens to string' => [ + type_array(), + type_integer(), type_string(), ]; } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UnionDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UnionDefinitionTest.php index fe2ebccd5..2e4ef3c9e 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UnionDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UnionDefinitionTest.php @@ -357,25 +357,18 @@ public function test_merge_with_different_union_produces_json(): void static::assertInstanceOf(JsonDefinition::class, $def->merge($other)); } - public function test_merge_with_incompatible_type_throws_exception(): void + public function test_merge_with_incompatible_type_falls_back_to_string(): void { $def = union_schema('col', type_union(type_string(), type_integer())); - $this->expectException(RuntimeException::class); - - $def->merge(new BooleanDefinition('col')); + static::assertSame('string', $def->merge(new BooleanDefinition('col'))->type()->toString()); } - public function test_merge_with_non_member_throws_exception(): void + public function test_merge_with_non_member_falls_back_to_string(): void { $def = union_schema('col', type_union(type_string(), type_integer())); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage( - 'Cannot merge Flow\ETL\Schema\Definition\UnionDefinition with Flow\ETL\Schema\Definition\FloatDefinition', - ); - - $def->merge(float_schema('col')); + static::assertSame('string', $def->merge(float_schema('col'))->type()->toString()); } public function test_merge_with_nullable_union_member_returns_nullable_union(): void diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UuidDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UuidDefinitionTest.php index 69801ded3..f27017752 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UuidDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/UuidDefinitionTest.php @@ -210,13 +210,11 @@ public function test_merge_with_different_entry_name_throws_exception(): void $def->merge(uuid_schema('other')); } - public function test_merge_with_incompatible_type_throws_exception(): void + public function test_merge_with_incompatible_type_falls_back_to_string(): void { $def = uuid_schema('col'); - $this->expectException(RuntimeException::class); - - $def->merge(new BooleanDefinition('col')); + static::assertSame('string', $def->merge(new BooleanDefinition('col'))->type()->toString()); } public function test_normalize(): void @@ -295,10 +293,14 @@ public function test_merge_with_union_containing_this_type_returns_union(): void static::assertSame('boolean|uuid', $merged->type()->toString()); } - public function test_merge_with_union_not_containing_this_type_throws_exception(): void + public function test_merge_with_union_not_containing_this_type_falls_back_to_string(): void { - $this->expectException(RuntimeException::class); - - uuid_schema('col')->merge(union_schema('col', type_union(type_integer(), type_string()))); + static::assertSame( + 'string', + uuid_schema('col') + ->merge(union_schema('col', type_union(type_integer(), type_string()))) + ->type() + ->toString(), + ); } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/XMLDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/XMLDefinitionTest.php index 9092b493e..c3b1c1d79 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/XMLDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/XMLDefinitionTest.php @@ -210,13 +210,11 @@ public function test_merge_with_different_entry_name_throws_exception(): void $def->merge(xml_schema('other')); } - public function test_merge_with_incompatible_type_throws_exception(): void + public function test_merge_with_incompatible_type_falls_back_to_string(): void { $def = xml_schema('col'); - $this->expectException(RuntimeException::class); - - $def->merge(new BooleanDefinition('col')); + static::assertSame('string', $def->merge(new BooleanDefinition('col'))->type()->toString()); } public function test_normalize(): void @@ -295,10 +293,14 @@ public function test_merge_with_union_containing_this_type_returns_union(): void static::assertSame('boolean|xml', $merged->type()->toString()); } - public function test_merge_with_union_not_containing_this_type_throws_exception(): void + public function test_merge_with_union_not_containing_this_type_falls_back_to_string(): void { - $this->expectException(RuntimeException::class); - - xml_schema('col')->merge(union_schema('col', type_union(type_integer(), type_string()))); + static::assertSame( + 'string', + xml_schema('col') + ->merge(union_schema('col', type_union(type_integer(), type_string()))) + ->type() + ->toString(), + ); } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/XMLElementDefinitionTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/XMLElementDefinitionTest.php index 0607abf87..d0c4fe339 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/XMLElementDefinitionTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Schema/Definition/XMLElementDefinitionTest.php @@ -210,13 +210,11 @@ public function test_merge_with_different_entry_name_throws_exception(): void $def->merge(xml_element_schema('other')); } - public function test_merge_with_incompatible_type_throws_exception(): void + public function test_merge_with_incompatible_type_falls_back_to_string(): void { $def = xml_element_schema('col'); - $this->expectException(RuntimeException::class); - - $def->merge(new BooleanDefinition('col')); + static::assertSame('string', $def->merge(new BooleanDefinition('col'))->type()->toString()); } public function test_normalize(): void @@ -295,10 +293,14 @@ public function test_merge_with_union_containing_this_type_returns_union(): void static::assertSame('boolean|xml_element', $merged->type()->toString()); } - public function test_merge_with_union_not_containing_this_type_throws_exception(): void + public function test_merge_with_union_not_containing_this_type_falls_back_to_string(): void { - $this->expectException(RuntimeException::class); - - xml_element_schema('col')->merge(union_schema('col', type_union(type_integer(), type_string()))); + static::assertSame( + 'string', + xml_element_schema('col') + ->merge(union_schema('col', type_union(type_integer(), type_string()))) + ->type() + ->toString(), + ); } }