From 7d1298dfb421a6f8360076f443564d2c478f3665 Mon Sep 17 00:00:00 2001 From: Norbert Orzechowicz Date: Thu, 13 Aug 2026 14:17:31 +0200 Subject: [PATCH 1/2] fix(flow-php/types): structure/list/map casting no longer fabricates missing data - absent or present-null structure elements whose type rejects null throw CastingException with MissingElementCastingException as previous, naming the element - list/map cast(null) throws instead of returning empty; JSON string payloads decode and cast element-wise - all-optional structures now accept an empty payload (array_is_list([]) rejected it) - native extension bails to PHP cast for null non-optional elements, keeping exception parity --- documentation/components/libs/types.md | 9 + documentation/upgrading.md | 14 +- .../PsrHttpClientPaginatedExtractorTest.php | 19 ++ .../PsrHttpClientStaticExtractorTest.php | 43 +++++ .../Unit/Row/AdaptiveRowHydratorTest.php | 14 ++ .../Tests/Unit/Row/NativeRowHydratorTest.php | 21 ++- .../ETL/Tests/Unit/Row/PhpRowHydratorTest.php | 10 ++ src/extension/flow-php-ext/src/cast.rs | 8 +- .../Unit/Client/RowMapper/TypeMapperTest.php | 4 +- .../MissingElementCastingException.php | 38 ++++ .../src/Flow/Types/Type/Logical/ListType.php | 11 +- .../src/Flow/Types/Type/Logical/MapType.php | 3 +- .../Flow/Types/Type/Logical/StructureType.php | 33 ++-- .../MissingElementCastingExceptionTest.php | 48 +++++ .../Tests/Unit/Type/Logical/ListTypeTest.php | 40 +++++ .../Tests/Unit/Type/Logical/MapTypeTest.php | 14 ++ .../Unit/Type/Logical/StructureTypeTest.php | 165 ++++++++++++++++++ 17 files changed, 471 insertions(+), 23 deletions(-) create mode 100644 src/lib/types/src/Flow/Types/Exception/MissingElementCastingException.php create mode 100644 src/lib/types/tests/Flow/Types/Tests/Unit/Exception/MissingElementCastingExceptionTest.php diff --git a/documentation/components/libs/types.md b/documentation/components/libs/types.md index 4197872527..96fb3ea013 100644 --- a/documentation/components/libs/types.md +++ b/documentation/components/libs/types.md @@ -96,6 +96,15 @@ $variable = $input->get('some-input'); $string = type_string()->cast($variable); ``` +Casting structures, lists and maps: + +- a structure element that is absent, or present with `null`, throws `CastingException` when the element's type does + not accept `null` - `getPrevious()` returns a `MissingElementCastingException` whose `element` property names the + failing element +- elements whose type accepts `null` (`type_optional(...)`, `type_union(..., type_null())`) cast `null` to `null`; + an absent optional element stays absent from the output +- `type_list(...)->cast(null)` and `type_map(...)->cast(null)` throw `CastingException` +- a JSON string payload is decoded and cast element-wise, exactly like an array payload ### Complex Types diff --git a/documentation/upgrading.md b/documentation/upgrading.md index 9a105fdafe..2ae34bb629 100644 --- a/documentation/upgrading.md +++ b/documentation/upgrading.md @@ -45,6 +45,18 @@ Columns that may only become null in later batches, declare the schema explicitl | `detectType([[1.2], [4.0, 5]])` → `list>` | `list>` | | `detectType([[], [1, 2]])` → `list>` | `list>` | +### 5) `flow-php/types` - structure/list/map casting no longer fabricates missing data + +| Before | After | +|----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| +| `type_structure(['id' => type_integer(), 'name' => type_string()])->cast(['id' => 1])` → `['id' => 1, 'name' => '']` | throws `CastingException` | +| same type, `->cast(['id' => 1, 'name' => null])` → `['id' => 1, 'name' => '']` | throws `CastingException` | +| same type, `->cast([])`, `->cast(null)` → `['id' => 0, 'name' => '']` | throws `CastingException` | +| `type_structure(['id' => type_integer()], ['name' => type_string()])->cast(['id' => 1, 'name' => null])` → `['id' => 1, 'name' => '']` | throws `CastingException` | +| `type_list(type_string())->cast(null)` → `['']` | throws `CastingException` | +| `type_structure(['id' => type_integer()])->cast('{"id":"1"}')` → throws | `['id' => 1]` | +| `type_list(type_integer())->cast('["1","2"]')` → throws | `[1, 2]` | + --- ## Upgrading from 0.42.x to 0.43.x @@ -2458,7 +2470,7 @@ After: ->run(); ``` -### 4) ConfigBuilder::putInputIntoRows () output is now prefixed with _ (underscore) +### 4) ConfigBuilder::putInputIntoRows () output is now prefixed with _ (underscore) In order to avoid collisions with datasets columns, additional columns created after using putInputIntoRows () would now be prefixed with `_` (underscore) symbol. diff --git a/src/adapter/etl-adapter-http/tests/Flow/ETL/Adapter/HTTP/Tests/Integration/PsrHttpClientPaginatedExtractorTest.php b/src/adapter/etl-adapter-http/tests/Flow/ETL/Adapter/HTTP/Tests/Integration/PsrHttpClientPaginatedExtractorTest.php index 3f8f7a2a35..b9441ae179 100644 --- a/src/adapter/etl-adapter-http/tests/Flow/ETL/Adapter/HTTP/Tests/Integration/PsrHttpClientPaginatedExtractorTest.php +++ b/src/adapter/etl-adapter-http/tests/Flow/ETL/Adapter/HTTP/Tests/Integration/PsrHttpClientPaginatedExtractorTest.php @@ -8,6 +8,7 @@ use Flow\ETL\Exception\RuntimeException; use Flow\ETL\Row\Entry\StructureEntry; use Flow\ETL\Tests\FlowTestCase; +use Flow\Types\Exception\CastingException; use Http\Mock\Client; use Nyholm\Psr7\Factory\Psr17Factory; use Nyholm\Psr7\Response; @@ -341,6 +342,24 @@ public function test_schema_typed_response_body(): void static::assertInstanceOf(StructureEntry::class, $rows[0]->first()->get('response_body')); } + public function test_schema_typed_response_body_with_missing_field(): void + { + $client = new Client(new Psr17Factory()); + $client->addResponse(PaginationMother::jsonResponse(['login' => 'flow-php'])); + + $this->expectException(CastingException::class); + + iterator_to_array(from_http_paginated( + $client, + PaginationMother::request('GET', 'https://api.example.com/orgs/flow-php'), + http_pagination_cursor('next', http_request_option_query('cursor')), + schema(structure_schema('response_body', type_structure([ + 'login' => type_string(), + 'id' => type_integer(), + ]))), + )->extract(flow_context(config()))); + } + public function test_schema_typed_response_body_via_with_schema(): void { $client = new Client(new Psr17Factory()); diff --git a/src/adapter/etl-adapter-http/tests/Flow/ETL/Adapter/HTTP/Tests/Integration/PsrHttpClientStaticExtractorTest.php b/src/adapter/etl-adapter-http/tests/Flow/ETL/Adapter/HTTP/Tests/Integration/PsrHttpClientStaticExtractorTest.php index 13f72f3055..2f477f7f5a 100644 --- a/src/adapter/etl-adapter-http/tests/Flow/ETL/Adapter/HTTP/Tests/Integration/PsrHttpClientStaticExtractorTest.php +++ b/src/adapter/etl-adapter-http/tests/Flow/ETL/Adapter/HTTP/Tests/Integration/PsrHttpClientStaticExtractorTest.php @@ -7,6 +7,7 @@ use Flow\ETL\Row\Entry\StructureEntry; use Flow\ETL\Rows; use Flow\ETL\Tests\FlowTestCase; +use Flow\Types\Exception\CastingException; use Http\Mock\Client; use Nyholm\Psr7\Factory\Psr17Factory; use Nyholm\Psr7\Response; @@ -93,6 +94,48 @@ public function test_http_extractor(): void static::assertSame('tomaszhanc', $tomekResponseBody['login']); } + public function test_schema_typed_response_body_with_empty_body(): void + { + $factory = new Psr17Factory(); + $client = new Client($factory); + $client->addResponse(new Response(200, ['Content-Type' => 'application/json'], '{}')); + + $this->expectException(CastingException::class); + + from_static_http_requests( + $client, + [$factory->createRequest('GET', 'https://api.github.com/users/norberttech')], + schema(structure_schema('response_body', type_structure([ + 'login' => type_string(), + 'id' => type_integer(), + ]))), + ) + ->extract(flow_context(config())) + ->current(); + } + + public function test_schema_typed_response_body_with_missing_field(): void + { + $factory = new Psr17Factory(); + $client = new Client($factory); + $client->addResponse(new Response(200, ['Content-Type' => 'application/json'], json_encode([ + 'login' => 'norberttech', + ], JSON_THROW_ON_ERROR))); + + $this->expectException(CastingException::class); + + from_static_http_requests( + $client, + [$factory->createRequest('GET', 'https://api.github.com/users/norberttech')], + schema(structure_schema('response_body', type_structure([ + 'login' => type_string(), + 'id' => type_integer(), + ]))), + ) + ->extract(flow_context(config())) + ->current(); + } + public function test_schema_typed_response_body(): void { $factory = new Psr17Factory(); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Row/AdaptiveRowHydratorTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Row/AdaptiveRowHydratorTest.php index c807bb81d5..0d2fdba5b0 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Row/AdaptiveRowHydratorTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Row/AdaptiveRowHydratorTest.php @@ -7,6 +7,7 @@ use Flow\ETL\Row\AdaptiveRowHydrator; use Flow\ETL\Row\RawRowValues; use Flow\ETL\Tests\FlowTestCase; +use Flow\Types\Exception\CastingException; use function Flow\ETL\DSL\int_entry; use function Flow\ETL\DSL\int_schema; @@ -15,6 +16,10 @@ use function Flow\ETL\DSL\schema; use function Flow\ETL\DSL\str_entry; use function Flow\ETL\DSL\str_schema; +use function Flow\ETL\DSL\structure_schema; +use function Flow\Types\DSL\type_integer; +use function Flow\Types\DSL\type_string; +use function Flow\Types\DSL\type_structure; final class AdaptiveRowHydratorTest extends FlowTestCase { @@ -45,4 +50,13 @@ public function test_cast_infers_rows_without_a_schema(): void static::assertSame(1, $rows->first()->valueOf('id')); static::assertSame('x', $rows->first()->valueOf('name')); } + + public function test_cast_throws_on_missing_required_structure_element(): void + { + $this->expectException(CastingException::class); + + (new AdaptiveRowHydrator())->cast([new RawRowValues(['data' => [ + 'id' => 1, + ]])], schema(structure_schema('data', type_structure(['id' => type_integer(), 'name' => type_string()])))); + } } 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 12e07e21aa..0b6455f5d5 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 @@ -319,6 +319,11 @@ public static function castable_datasets(): Generator ]; yield 'empty cast batch' => [schema(int_schema('id')), []]; + + yield 'all-optional structure with no matching keys' => [ + schema(structure_schema('st', type_structure([], ['b' => type_string()]))), + [new RawRowValues(['st' => ['other' => 1]])], + ]; } /** @@ -394,9 +399,19 @@ public static function throwing_cast_datasets(): Generator [new RawRowValues(['l' => [-3]])], ]; - yield 'all-optional structure with no matching keys' => [ - schema(structure_schema('st', type_structure([], ['b' => type_string()]))), - [new RawRowValues(['st' => ['other' => 1]])], + yield 'structure missing required element' => [ + schema(structure_schema('data', type_structure(['id' => type_integer(), 'name' => type_string()]))), + [new RawRowValues(['data' => ['id' => 1]])], + ]; + + yield 'structure present-null required element' => [ + schema(structure_schema('data', type_structure(['id' => type_integer(), 'name' => type_string()]))), + [new RawRowValues(['data' => ['id' => 1, 'name' => null]])], + ]; + + yield 'structure present-null optional element' => [ + schema(structure_schema('data', type_structure(['id' => type_integer()], ['name' => type_string()]))), + [new RawRowValues(['data' => ['id' => 1, 'name' => null]])], ]; } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Row/PhpRowHydratorTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Row/PhpRowHydratorTest.php index 71312adab3..9788bdc9d5 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Row/PhpRowHydratorTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Row/PhpRowHydratorTest.php @@ -16,6 +16,7 @@ use Flow\ETL\Row\RawRowValues; use Flow\ETL\Row\TypedRowValues; use Flow\ETL\Tests\FlowTestCase; +use Flow\Types\Exception\CastingException; use Flow\Types\Value\Uuid; use function Flow\ETL\DSL\bool_schema; @@ -51,6 +52,15 @@ public function test_absent_schema_column_is_filled_with_typed_null(): void static::assertSame(['id' => 1, 'name' => null], $rows->first()->toArray()); } + public function test_cast_throws_on_missing_required_structure_element(): void + { + $this->expectException(CastingException::class); + + (new PhpRowHydrator())->cast([new RawRowValues(['data' => [ + 'id' => 1, + ]])], schema(structure_schema('data', type_structure(['id' => type_integer(), 'name' => type_string()])))); + } + public function test_casts_datetime_and_uuid_strings(): void { $rows = (new PhpRowHydrator())->cast( diff --git a/src/extension/flow-php-ext/src/cast.rs b/src/extension/flow-php-ext/src/cast.rs index 422b8b3f76..bfff1a31be 100644 --- a/src/extension/flow-php-ext/src/cast.rs +++ b/src/extension/flow-php-ext/src/cast.rs @@ -540,13 +540,19 @@ fn cast_value(kind: &CastKind, value: &Zval, ctx: &mut Ctx) -> Result [ - ['tags' => '["php", 42, "flow"]'], + ['tags' => '["php"]'], type_structure([ - 'tags' => type_list(type_string()), + 'tags' => type_list(type_uuid()), ]), ]; } diff --git a/src/lib/types/src/Flow/Types/Exception/MissingElementCastingException.php b/src/lib/types/src/Flow/Types/Exception/MissingElementCastingException.php new file mode 100644 index 0000000000..f1e1774653 --- /dev/null +++ b/src/lib/types/src/Flow/Types/Exception/MissingElementCastingException.php @@ -0,0 +1,38 @@ + $type + * @param string $element + * @param null|\Throwable $previous + */ + public function __construct( + public readonly mixed $value, + public readonly Type $type, + public readonly string $element, + ?Throwable $previous = null, + ) { + parent::__construct( + sprintf( + "Can't cast \"%s\" into \"%s\" type: element \"%s\" cannot be null", + get_debug_type($value), + $type->toString(), + $element, + ), + 0, + $previous, + ); + } +} diff --git a/src/lib/types/src/Flow/Types/Type/Logical/ListType.php b/src/lib/types/src/Flow/Types/Type/Logical/ListType.php index ca7889173f..c546f22da0 100644 --- a/src/lib/types/src/Flow/Types/Type/Logical/ListType.php +++ b/src/lib/types/src/Flow/Types/Type/Logical/ListType.php @@ -11,6 +11,7 @@ use Throwable; use function array_is_list; +use function Flow\Types\DSL\type_array; use function Flow\Types\DSL\type_from_array; use function Flow\Types\DSL\type_literal; use function Flow\Types\DSL\type_map; @@ -76,7 +77,11 @@ public function cast(mixed $value): array } if (is_string($value) && (str_starts_with($value, '{') || str_starts_with($value, '['))) { - return $this->assert(json_decode($value, true, 512, JSON_THROW_ON_ERROR)); + $value = type_array()->assert(json_decode($value, true, 512, JSON_THROW_ON_ERROR)); + } + + if ($value === null) { + throw new CastingException($value, $this); } if (!is_array($value)) { @@ -91,8 +96,8 @@ public function cast(mixed $value): array } return $this->assert($castedList); - } catch (Throwable) { - throw new CastingException($value, $this); + } catch (Throwable $e) { + throw new CastingException($value, $this, $e); } } diff --git a/src/lib/types/src/Flow/Types/Type/Logical/MapType.php b/src/lib/types/src/Flow/Types/Type/Logical/MapType.php index 5c9e8855bc..0b9fbe23fe 100644 --- a/src/lib/types/src/Flow/Types/Type/Logical/MapType.php +++ b/src/lib/types/src/Flow/Types/Type/Logical/MapType.php @@ -14,6 +14,7 @@ use Throwable; use function array_key_exists; +use function Flow\Types\DSL\type_array; use function Flow\Types\DSL\type_from_array; use function Flow\Types\DSL\type_literal; use function Flow\Types\DSL\type_map; @@ -94,7 +95,7 @@ public function cast(mixed $value): array } if (is_string($value) && (str_starts_with($value, '{') || str_starts_with($value, '['))) { - return $this->assert(json_decode($value, true, 512, JSON_THROW_ON_ERROR)); + $value = type_array()->assert(json_decode($value, true, 512, JSON_THROW_ON_ERROR)); } if (!is_array($value)) { diff --git a/src/lib/types/src/Flow/Types/Type/Logical/StructureType.php b/src/lib/types/src/Flow/Types/Type/Logical/StructureType.php index 63e40837a4..26d4188b9c 100644 --- a/src/lib/types/src/Flow/Types/Type/Logical/StructureType.php +++ b/src/lib/types/src/Flow/Types/Type/Logical/StructureType.php @@ -7,6 +7,7 @@ use Flow\Types\Exception\CastingException; use Flow\Types\Exception\InvalidArgumentException; use Flow\Types\Exception\InvalidTypeException; +use Flow\Types\Exception\MissingElementCastingException; use Flow\Types\Type; use Flow\Types\Value\Json; use Throwable; @@ -18,6 +19,7 @@ use function array_keys; use function array_merge; use function count; +use function Flow\Types\DSL\type_array; use function Flow\Types\DSL\type_boolean; use function Flow\Types\DSL\type_from_array; use function Flow\Types\DSL\type_literal; @@ -134,23 +136,33 @@ public function cast(mixed $value): array } if (is_string($value) && (str_starts_with($value, '{') || str_starts_with($value, '['))) { - return $this->assert(json_decode($value, true, 512, JSON_THROW_ON_ERROR)); + $value = type_array()->assert(json_decode($value, true, 512, JSON_THROW_ON_ERROR)); + } + + if (!is_array($value)) { + throw new CastingException($value, $this); } $castedStructure = []; - // Cast required elements foreach ($this->elements as $elementName => $elementType) { - $castedStructure[$elementName] = is_array($value) && array_key_exists($elementName, $value) - ? $elementType->cast($value[$elementName]) - : $elementType->cast(null); + if (($value[$elementName] ?? null) === null && !$elementType->isValid(null)) { + throw new MissingElementCastingException(null, $elementType, (string) $elementName); + } + + $castedStructure[$elementName] = $elementType->cast($value[$elementName] ?? null); } - // Cast optional elements only if they are present in the input foreach ($this->optionalElements as $elementName => $elementType) { - if (is_array($value) && array_key_exists($elementName, $value)) { - $castedStructure[$elementName] = $elementType->cast($value[$elementName]); + if (!array_key_exists($elementName, $value)) { + continue; } + + if ($value[$elementName] === null && !$elementType->isValid(null)) { + throw new MissingElementCastingException(null, $elementType, (string) $elementName); + } + + $castedStructure[$elementName] = $elementType->cast($value[$elementName]); } return $this->assert($castedStructure); @@ -173,25 +185,22 @@ public function isValid(mixed $value): bool return false; } - if (array_is_list($value)) { + if ($value !== [] && array_is_list($value)) { return false; } - // Check if we have all required elements foreach ($this->elements as $name => $element) { if (!array_key_exists($name, $value) || !$element->isValid($value[$name])) { return false; } } - // Check optional elements (if present, they must be valid) foreach ($this->optionalElements as $name => $element) { if (array_key_exists($name, $value) && !$element->isValid($value[$name])) { return false; } } - // If allow_extra is false, check that we don't have unexpected keys if (!$this->allowExtra) { $allKnownKeys = array_merge(array_keys($this->elements), array_keys($this->optionalElements)); $extraKeys = array_diff(array_keys($value), $allKnownKeys); diff --git a/src/lib/types/tests/Flow/Types/Tests/Unit/Exception/MissingElementCastingExceptionTest.php b/src/lib/types/tests/Flow/Types/Tests/Unit/Exception/MissingElementCastingExceptionTest.php new file mode 100644 index 0000000000..7de766fd1e --- /dev/null +++ b/src/lib/types/tests/Flow/Types/Tests/Unit/Exception/MissingElementCastingExceptionTest.php @@ -0,0 +1,48 @@ +element); + } + + public function test_message_names_the_element(): void + { + $exception = new MissingElementCastingException(null, type_string(), 'name'); + + static::assertStringContainsString('"name"', $exception->getMessage()); + static::assertStringContainsString(type_string()->toString(), $exception->getMessage()); + } + + public function test_previous_is_chained(): void + { + $previous = new RuntimeException('root cause'); + + static::assertSame( + $previous, + (new MissingElementCastingException(null, type_string(), 'name', $previous))->getPrevious(), + ); + } + + public function test_value_and_type_properties(): void + { + $type = type_string(); + $exception = new MissingElementCastingException(null, $type, 'name'); + + static::assertNull($exception->value); + static::assertSame($type, $exception->type); + } +} diff --git a/src/lib/types/tests/Flow/Types/Tests/Unit/Type/Logical/ListTypeTest.php b/src/lib/types/tests/Flow/Types/Tests/Unit/Type/Logical/ListTypeTest.php index f1f6b217d6..ec16fc6ee6 100644 --- a/src/lib/types/tests/Flow/Types/Tests/Unit/Type/Logical/ListTypeTest.php +++ b/src/lib/types/tests/Flow/Types/Tests/Unit/Type/Logical/ListTypeTest.php @@ -5,6 +5,7 @@ namespace Flow\Types\Tests\Unit\Type\Logical; use DateTimeZone; +use Flow\Types\Exception\CastingException; use Flow\Types\Exception\InvalidTypeException; use Flow\Types\Type\Logical\ListType; use Generator; @@ -19,6 +20,7 @@ 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_uuid; final class ListTypeTest extends TestCase { @@ -100,6 +102,34 @@ public static function cast_data_provider(): Generator 'expected' => [1], 'exceptionClass' => null, ]; + + yield 'JSON string payload casts element-wise' => [ + 'value' => '["1","2"]', + 'listType' => type_list(type_integer()), + 'expected' => [1, 2], + 'exceptionClass' => null, + ]; + + yield 'malformed JSON string payload' => [ + 'value' => '[invalid', + 'listType' => type_list(type_integer()), + 'expected' => null, + 'exceptionClass' => CastingException::class, + ]; + + yield 'cast(null) throws' => [ + 'value' => null, + 'listType' => type_list(type_string()), + 'expected' => null, + 'exceptionClass' => CastingException::class, + ]; + + yield 'scalar into single-item list stays' => [ + 'value' => 'hello', + 'listType' => type_list(type_string()), + 'expected' => ['hello'], + 'exceptionClass' => null, + ]; } public static function is_valid_data_provider(): Generator @@ -181,6 +211,16 @@ public function test_cast(mixed $value, ListType $listType, mixed $expected, ?st } } + public function test_cast_element_failure_chains_previous(): void + { + try { + type_list(type_uuid())->cast(['not-a-uuid']); + static::fail('Expected CastingException'); + } catch (CastingException $e) { + static::assertNotNull($e->getPrevious()); + } + } + #[DataProvider('is_valid_data_provider')] public function test_is_valid(mixed $value, ListType $listType, bool $expected): void { diff --git a/src/lib/types/tests/Flow/Types/Tests/Unit/Type/Logical/MapTypeTest.php b/src/lib/types/tests/Flow/Types/Tests/Unit/Type/Logical/MapTypeTest.php index bc64620c00..3cc7b93b42 100644 --- a/src/lib/types/tests/Flow/Types/Tests/Unit/Type/Logical/MapTypeTest.php +++ b/src/lib/types/tests/Flow/Types/Tests/Unit/Type/Logical/MapTypeTest.php @@ -100,6 +100,20 @@ public static function cast_data_provider(): Generator 'expected' => null, 'exceptionClass' => CastingException::class, ]; + + yield 'JSON string payload casts element-wise' => [ + 'value' => '{"a":"1"}', + 'mapType' => type_map(type_string(), type_integer()), + 'expected' => ['a' => 1], + 'exceptionClass' => null, + ]; + + yield 'malformed JSON string payload' => [ + 'value' => '{invalid', + 'mapType' => type_map(type_string(), type_integer()), + 'expected' => null, + 'exceptionClass' => CastingException::class, + ]; } public static function is_valid_data_provider(): Generator diff --git a/src/lib/types/tests/Flow/Types/Tests/Unit/Type/Logical/StructureTypeTest.php b/src/lib/types/tests/Flow/Types/Tests/Unit/Type/Logical/StructureTypeTest.php index fac3b4b7b2..eab3a886c2 100644 --- a/src/lib/types/tests/Flow/Types/Tests/Unit/Type/Logical/StructureTypeTest.php +++ b/src/lib/types/tests/Flow/Types/Tests/Unit/Type/Logical/StructureTypeTest.php @@ -5,11 +5,14 @@ namespace Flow\Types\Tests\Unit\Type\Logical; use DateTimeZone; +use Flow\Types\Exception\CastingException; use Flow\Types\Exception\InvalidArgumentException; use Flow\Types\Exception\InvalidTypeException; +use Flow\Types\Exception\MissingElementCastingException; use Flow\Types\Type; use Flow\Types\Type\Logical\StructureType; use Generator; +use JsonException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use stdClass; @@ -23,9 +26,11 @@ use function Flow\Types\DSL\type_list; use function Flow\Types\DSL\type_map; use function Flow\Types\DSL\type_mixed; +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 StructureTypeTest extends TestCase { @@ -300,6 +305,118 @@ public static function cast_data_provider(): Generator ], 'exceptionClass' => null, ]; + + yield 'throws on missing required element' => [ + 'structure' => type_structure(['id' => type_integer(), 'name' => type_string()]), + 'value' => ['id' => 1], + 'expected' => null, + 'exceptionClass' => CastingException::class, + ]; + + yield 'throws on present null required element' => [ + 'structure' => type_structure(['id' => type_integer(), 'name' => type_string()]), + 'value' => ['id' => 1, 'name' => null], + 'expected' => null, + 'exceptionClass' => CastingException::class, + ]; + + yield 'throws on empty payload' => [ + 'structure' => type_structure(['id' => type_integer(), 'name' => type_string()]), + 'value' => [], + 'expected' => null, + 'exceptionClass' => CastingException::class, + ]; + + yield 'throws on null payload' => [ + 'structure' => type_structure(['id' => type_integer(), 'name' => type_string()]), + 'value' => null, + 'expected' => null, + 'exceptionClass' => CastingException::class, + ]; + + yield 'throws on scalar payload' => [ + 'structure' => type_structure(['id' => type_integer(), 'name' => type_string()]), + 'value' => 'hello', + 'expected' => null, + 'exceptionClass' => CastingException::class, + ]; + + yield 'optional-wrapped required element stays null when present-null' => [ + 'structure' => type_structure(['id' => type_integer(), 'name' => type_optional(type_string())]), + 'value' => ['id' => 1, 'name' => null], + 'expected' => ['id' => 1, 'name' => null], + 'exceptionClass' => null, + ]; + + yield 'union-with-null element stays null when present-null' => [ + 'structure' => type_structure(['id' => type_integer(), 'tag' => type_union(type_string(), type_null())]), + 'value' => ['id' => 1, 'tag' => null], + 'expected' => ['id' => 1, 'tag' => null], + 'exceptionClass' => null, + ]; + + yield 'structure-level optional scalar element throws on present-null' => [ + 'structure' => type_structure(['id' => type_integer()], ['name' => type_string()]), + 'value' => ['id' => 1, 'name' => null], + 'expected' => null, + 'exceptionClass' => CastingException::class, + ]; + + yield 'structure-level optional list element throws on present-null' => [ + 'structure' => type_structure(['id' => type_integer()], ['tags' => type_list(type_string())]), + 'value' => ['id' => 1, 'tags' => null], + 'expected' => null, + 'exceptionClass' => CastingException::class, + ]; + + yield 'absent optional element stays absent' => [ + 'structure' => type_structure(['id' => type_integer()], ['name' => type_string()]), + 'value' => ['id' => 1], + 'expected' => ['id' => 1], + 'exceptionClass' => null, + ]; + + yield 'present optional element is cast' => [ + 'structure' => type_structure(['id' => type_integer()], ['name' => type_string()]), + 'value' => ['id' => 1, 'name' => 123], + 'expected' => ['id' => 1, 'name' => '123'], + 'exceptionClass' => null, + ]; + + yield 'empty payload casts into all-optional structure' => [ + 'structure' => type_structure([], ['data' => type_list(type_string())]), + 'value' => [], + 'expected' => [], + 'exceptionClass' => null, + ]; + + yield 'empty JSON object casts into all-optional structure' => [ + 'structure' => type_structure([], ['data' => type_list(type_string())]), + 'value' => '{}', + 'expected' => [], + 'exceptionClass' => null, + ]; + + yield 'valid JSON string payload casts element-wise' => [ + 'structure' => type_structure(['id' => type_integer()]), + 'value' => '{"id":"1"}', + 'expected' => ['id' => 1], + 'exceptionClass' => null, + ]; + + yield 'partial JSON string payload throws naming the element' => [ + 'structure' => type_structure(['id' => type_integer(), 'name' => type_string()]), + 'value' => '{"id":1}', + 'expected' => null, + 'exceptionClass' => CastingException::class, + ]; + + yield 'malformed JSON string payload' => [ + 'structure' => type_structure(['id' => type_integer(), 'name' => type_string()]), + 'value' => '{invalid', + 'expected' => null, + 'exceptionClass' => CastingException::class, + ]; } public static function is_valid_data_provider(): Generator @@ -389,6 +506,18 @@ public static function is_valid_data_provider(): Generator 'expected' => true, ]; + yield 'valid empty payload for all-optional structure' => [ + 'structure' => type_structure([], ['name' => type_string()]), + 'value' => [], + 'expected' => true, + ]; + + yield 'invalid empty payload when required elements exist' => [ + 'structure' => type_structure(['id' => type_integer()]), + 'value' => [], + 'expected' => false, + ]; + yield 'invalid structure with wrong type for optional element' => [ 'structure' => type_structure(['id' => type_integer()], ['name' => type_string()]), 'value' => ['id' => 1, 'name' => 123], @@ -450,6 +579,42 @@ public function test_cast(Type $structure, mixed $value, mixed $expected, ?strin } } + public function test_cast_malformed_json_string_payload_chains_json_exception(): void + { + try { + type_structure(['id' => type_integer(), 'name' => type_string()])->cast('{invalid'); + static::fail('Expected CastingException'); + } catch (CastingException $e) { + static::assertInstanceOf(JsonException::class, $e->getPrevious()); + } + } + + public function test_cast_missing_required_element_exception_names_the_element(): void + { + try { + type_structure(['id' => type_integer(), 'name' => type_string()])->cast(['id' => 1]); + static::fail('Expected CastingException'); + } catch (CastingException $e) { + $previous = $e->getPrevious(); + static::assertInstanceOf(MissingElementCastingException::class, $previous); + static::assertSame('name', $previous->element); + } + } + + public function test_cast_nested_structure_missing_element_chains_through_both_levels(): void + { + try { + type_structure(['address' => type_structure(['zip' => type_string()])])->cast(['address' => []]); + static::fail('Expected CastingException'); + } catch (CastingException $e) { + $addressLevel = $e->getPrevious(); + static::assertInstanceOf(CastingException::class, $addressLevel); + $elementLevel = $addressLevel->getPrevious(); + static::assertInstanceOf(MissingElementCastingException::class, $elementLevel); + static::assertSame('zip', $elementLevel->element); + } + } + public function test_constructor_allows_empty_required_if_optional_provided(): void { $type = type_structure([], ['id' => type_integer()]); From bb10992ef046215ad360606275dd71981fdad16c Mon Sep 17 00:00:00 2001 From: Norbert Orzechowicz Date: Thu, 13 Aug 2026 14:30:51 +0200 Subject: [PATCH 2/2] test(flow-php/flow-php-ext): all-optional structure phpt case follows the empty-payload cast fix --- .../tests/phpt/026_row_hydrator_cast_parity.phpt | 5 +++++ .../tests/phpt/027_cast_fallback_exceptions.phpt | 9 ++------- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/extension/flow-php-ext/tests/phpt/026_row_hydrator_cast_parity.phpt b/src/extension/flow-php-ext/tests/phpt/026_row_hydrator_cast_parity.phpt index f829b4df3f..e79a23dfbc 100644 --- a/src/extension/flow-php-ext/tests/phpt/026_row_hydrator_cast_parity.phpt +++ b/src/extension/flow-php-ext/tests/phpt/026_row_hydrator_cast_parity.phpt @@ -96,6 +96,10 @@ $datasets = [ new RawRowValues(['id' => null], ['id' => Metadata::fromArray(['k' => 'v2'])]), ], ], + 'all_optional_st' => [ + schema(structure_schema('st', type_structure([], ['b' => type_string()]))), + [new RawRowValues(['st' => ['other' => 1]])], + ], 'empty' => [schema(int_schema('id')), []], ]; @@ -135,6 +139,7 @@ uuid_json cast:yes containers cast:yes exotic_fallback cast:yes fill_and_metadata cast:yes +all_optional_st cast:yes empty cast:yes schema_mutation cast:yes null_schema cast:yes diff --git a/src/extension/flow-php-ext/tests/phpt/027_cast_fallback_exceptions.phpt b/src/extension/flow-php-ext/tests/phpt/027_cast_fallback_exceptions.phpt index 957f9f0b39..90a8132720 100644 --- a/src/extension/flow-php-ext/tests/phpt/027_cast_fallback_exceptions.phpt +++ b/src/extension/flow-php-ext/tests/phpt/027_cast_fallback_exceptions.phpt @@ -10,8 +10,8 @@ use Flow\ETL\Row\NativeRowHydrator; use Flow\ETL\Row\PhpRowHydrator; use Flow\ETL\Row\RawRowValues; -use function Flow\ETL\DSL\{schema, datetime_schema, date_schema, uuid_schema, json_schema, list_schema, map_schema, structure_schema}; -use function Flow\Types\DSL\{type_list, type_map, type_structure, type_integer, type_string, type_positive_integer}; +use function Flow\ETL\DSL\{schema, datetime_schema, date_schema, uuid_schema, json_schema, list_schema, map_schema}; +use function Flow\Types\DSL\{type_list, type_map, type_integer, type_string, type_positive_integer}; $throwing = [ 'uuid invalid' => [schema(uuid_schema('u')), [new RawRowValues(['u' => 'not-a-uuid'])]], @@ -38,10 +38,6 @@ $throwing = [ schema(list_schema('l', type_list(type_positive_integer()))), [new RawRowValues(['l' => [-3]])], ], - 'all-optional structure' => [ - schema(structure_schema('st', type_structure([], ['b' => type_string()]))), - [new RawRowValues(['st' => ['other' => 1]])], - ], ]; $php = new PhpRowHydrator(); @@ -86,4 +82,3 @@ string map int keys exception:match aborted:yes list bad keys exception:match aborted:yes positive int list string exception:match aborted:yes positive int list negative exception:match aborted:yes -all-optional structure exception:match aborted:yes