diff --git a/documentation/upgrading.md b/documentation/upgrading.md index 06c2fafc2..1b29ec2d7 100644 --- a/documentation/upgrading.md +++ b/documentation/upgrading.md @@ -336,6 +336,7 @@ $df->filter(ref('a')->notSame(lit(5))); | `$df->mode(execution_lenient())` | removed - `optional($function)` per function | | `$df->mode(execution_strict())` | removed - strict is the only behaviour | | `coalesce($a, $b)` skipped a branch that threw | rethrows - `coalesce(optional($a), $b)` | +| `cast($v, type_integer())` over `null` returned `null` | throws - `cast($v, type_optional(type_integer()))` | | `ref('l')->onEach($fn)` set an element whose `$fn` threw to `null` | throws - `ref('l')->onEach(optional($fn))` | | `ref('s')->indexOf('a')` / `->indexOfLast('a')` over `null` - `false` | throws - `optional(ref('s')->indexOf('a'))` | | `DataFrame::mode()` | removed | diff --git a/src/core/etl/src/Flow/ETL/Function/Cast.php b/src/core/etl/src/Flow/ETL/Function/Cast.php index c704fc4cb..7d629ac11 100644 --- a/src/core/etl/src/Flow/ETL/Function/Cast.php +++ b/src/core/etl/src/Flow/ETL/Function/Cast.php @@ -16,6 +16,7 @@ use function Flow\ETL\DSL\definition_from_type; use function Flow\ETL\DSL\lit; +use function Flow\Types\DSL\type_is_nullable; use function sprintf; final class Cast implements ScalarFunction @@ -82,7 +83,7 @@ public function eval(Row $row, FlowContext $context): mixed { $value = (new Parameter($this->value))->eval($row, $context); - if (null === $value) { + if (null === $value && !type_is_nullable($this->type)) { throw new InvalidArgumentException('Cast function requires non-null value'); } 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 46294355d..b8b22f3a5 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 @@ -9,16 +9,44 @@ use Flow\ETL\Tests\FlowTestCase; use Flow\Types\Value\Json; +use function Flow\ETL\DSL\array_get; +use function Flow\ETL\DSL\cast; use function Flow\ETL\DSL\df; use function Flow\ETL\DSL\from_array; use function Flow\ETL\DSL\ref; +use function Flow\ETL\DSL\schema; +use function Flow\ETL\DSL\structure_schema; use function Flow\ETL\DSL\to_memory; +use function Flow\Types\DSL\structure_element; +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_optional; +use function Flow\Types\DSL\type_string; +use function Flow\Types\DSL\type_structure; final class CastTest extends FlowTestCase { + public function test_cast_of_an_absent_nullsafe_path_to_an_optional_target_is_null(): void + { + $rows = df() + ->read(from_array([ + ['record' => ['deleteTime' => '2026-09-01T00:00:00Z']], + ['record' => []], + ], schema(structure_schema('record', type_structure([ + 'deleteTime' => structure_element('deleteTime', type_string(), optional: true), + ]))))) + ->withEntry('delete_time', cast(array_get(ref('record'), '?deleteTime'), type_optional(type_datetime()))) + ->drop('record') + ->fetch(); + + static::assertEquals( + [['delete_time' => new DateTimeImmutable('2026-09-01T00:00:00Z')], ['delete_time' => null]], + $rows->toArray(), + ); + static::assertTrue($rows->schema()->get('delete_time')->isNullable()); + } + public function test_cast(): void { df() diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Function/CastTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Function/CastTest.php index 667a8a425..f79359e51 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Function/CastTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Function/CastTest.php @@ -17,6 +17,9 @@ use function Flow\ETL\DSL\flow_context; use function Flow\ETL\DSL\ref; use function Flow\ETL\DSL\row; +use function Flow\Types\DSL\type_datetime; +use function Flow\Types\DSL\type_integer; +use function Flow\Types\DSL\type_optional; use function is_object; final class CastTest extends FlowTestCase @@ -29,6 +32,21 @@ public function test_cast_of_a_null_value_throws(): void cast(ref('value'), 'int')->eval(row(['value' => null]), flow_context()); } + public function test_cast_of_a_null_value_to_an_optional_target_is_null(): void + { + static::assertNull(cast(ref('value'), type_optional(type_integer()))->eval(row([ + 'value' => null, + ]), flow_context())); + } + + public function test_casting_an_unconvertible_value_to_an_optional_target_throws(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cast function failed: Can\'t cast "string" into "datetime" type'); + + cast(ref('value'), type_optional(type_datetime()))->eval(row(['value' => 'abc']), flow_context()); + } + public function test_constructor_rejects_a_non_representable_target(): void { $this->expectException(InvalidArgumentException::class); diff --git a/src/lib/types/src/Flow/Types/Type/TypeFactory.php b/src/lib/types/src/Flow/Types/Type/TypeFactory.php index 274f26341..32846fa9e 100644 --- a/src/lib/types/src/Flow/Types/Type/TypeFactory.php +++ b/src/lib/types/src/Flow/Types/Type/TypeFactory.php @@ -34,6 +34,7 @@ use function Flow\Types\DSL\type_null; use function Flow\Types\DSL\type_numeric_string; use function Flow\Types\DSL\type_object; +use function Flow\Types\DSL\type_optional; use function Flow\Types\DSL\type_positive_integer; use function Flow\Types\DSL\type_resource; use function Flow\Types\DSL\type_scalar; @@ -44,6 +45,8 @@ use function Flow\Types\DSL\type_xml; use function Flow\Types\DSL\type_xml_element; use function mb_strtolower; +use function str_starts_with; +use function substr; final class TypeFactory { @@ -109,6 +112,10 @@ public static function fromArray(array $data): Type */ public static function fromString(string $name): Type { + if (str_starts_with($name, '?')) { + return type_optional(self::fromString(substr($name, 1))); + } + return match (mb_strtolower($name)) { 'int', 'integer' => self::fromArray(['type' => 'integer', 'scalar_type' => 'integer']), 'float', 'double', 'real' => self::fromArray(['type' => 'float', 'scalar_type' => 'float']), diff --git a/src/lib/types/tests/Flow/Types/Tests/Unit/Type/TypeFactoryTest.php b/src/lib/types/tests/Flow/Types/Tests/Unit/Type/TypeFactoryTest.php index 8f4ba39ec..2c7cac24d 100644 --- a/src/lib/types/tests/Flow/Types/Tests/Unit/Type/TypeFactoryTest.php +++ b/src/lib/types/tests/Flow/Types/Tests/Unit/Type/TypeFactoryTest.php @@ -26,6 +26,7 @@ use function Flow\Types\DSL\type_list; 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_resource; use function Flow\Types\DSL\type_string; use function Flow\Types\DSL\type_structure; @@ -81,6 +82,15 @@ public function test_double_and_real_resolve_to_float(string $alias): void static::assertEquals(type_float(), TypeFactory::fromString($alias)); } + public function test_a_question_mark_prefix_resolves_to_an_optional_type(): void + { + static::assertEquals(type_optional(type_integer()), TypeFactory::fromString('?int')); + static::assertEquals( + type_optional(type_datetime()), + TypeFactory::fromString(type_optional(type_datetime())->toString()), + ); + } + public function test_normalizing_and_creating_enum_type(): void { $enum = type_enum(SomeEnum::class);