Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions documentation/components/libs/types.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 13 additions & 1 deletion documentation/upgrading.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<float>>` | `list<array<mixed>>` |
| `detectType([[], [1, 2]])` → `list<array<mixed>>` | `list<list<integer>>` |

### 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
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
{
Expand Down Expand Up @@ -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()]))));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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]])],
];
}

/**
Expand Down Expand Up @@ -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]])],
];
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
8 changes: 7 additions & 1 deletion src/extension/flow-php-ext/src/cast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -540,13 +540,19 @@ fn cast_value(kind: &CastKind, value: &Zval, ctx: &mut Ctx) -> Result<Option<Zva
for element in elements {
let Some(item) = values.get(element.name.as_str()) else {
if element.required {
// PHP feeds cast(null) to absent required elements
// PHP throws MissingElementCastingException for absent required elements
return Ok(None);
}

continue;
};

if item.is_null() && !matches!(element.kind, CastKind::Optional(_)) {
// PHP throws MissingElementCastingException for present-null elements
// whose type rejects null - required and structure-level optional alike
return Ok(None);
}

let Some(casted) = cast_value(&element.kind, item, ctx)? else {
return Ok(None);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')), []],
];

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'])]],
Expand All @@ -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();
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,9 @@ public static function provide_invalid_mappings(): Generator
];

yield 'jsonb list element has wrong type' => [
['tags' => '["php", 42, "flow"]'],
['tags' => '["php"]'],
type_structure([
'tags' => type_list(type_string()),
'tags' => type_list(type_uuid()),
]),
];
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace Flow\Types\Exception;

use Flow\Types\Type;
use Throwable;

use function get_debug_type;
use function sprintf;

final class MissingElementCastingException extends RuntimeException
{
/**
* @param mixed $value
* @param Type<mixed> $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,
);
}
}
11 changes: 8 additions & 3 deletions src/lib/types/src/Flow/Types/Type/Logical/ListType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)) {
Expand All @@ -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);
}
}

Expand Down
3 changes: 2 additions & 1 deletion src/lib/types/src/Flow/Types/Type/Logical/MapType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)) {
Expand Down
Loading
Loading