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
2 changes: 2 additions & 0 deletions .github/actions/setup-php-env/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ runs:

- name: "Install just"
uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4
with:
just-version: "1.50.0"

- name: "Install PIE"
if: ${{ inputs.pie-extensions != '' }}
Expand Down
5,978 changes: 5,978 additions & 0 deletions .github/tools/azurite/package-lock.json

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions .github/tools/azurite/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"private": true,
"dependencies": {
"azurite": "3.37.0"
}
}
2 changes: 1 addition & 1 deletion .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ jobs:
fi

- name: "MD Link Linter"
uses: "docker://norberttech/md-link-linter:latest"
uses: "docker://norberttech/md-link-linter@sha256:bf17e53dd716dcc2f0a41ed45af3e7bfc06b56b40fe6d8064b29646c1582c54c" # latest as of 2026-09-25
with:
entrypoint: "/composer/vendor/bin/mdlinklint"
args: "--exclude=vendor --exclude=tests --exclude=examples --exclude=documentation ."
6 changes: 4 additions & 2 deletions .github/workflows/job-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,12 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '20'
node-version: '22'

- name: Install Azurite storage emulator
run: npm install -g azurite
run: |
npm ci --prefix .github/tools/azurite --no-audit --no-fund
echo "${GITHUB_WORKSPACE}/.github/tools/azurite/node_modules/.bin" >> "${GITHUB_PATH}"

- name: Start Azurite blob endpoint
shell: bash
Expand Down
3 changes: 3 additions & 0 deletions .github/zizmor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@ rules:
dangerous-triggers:
ignore:
- readonly.yaml
# actionlint (1.7.12) rejects the $/ self-repository syntax; keep ./ until it parses it
self-repository:
disable: true
2 changes: 1 addition & 1 deletion documentation/components/adapters/json.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ file.
| empty schema `{}` / boolean schema `true` | `json` (accept anything, marked in metadata) |
| `enum` / `const` | scalar type derived from the values, values preserved in metadata |
| `type: [X, "null"]`, `anyOf`/`oneOf` with a null member | nullable X |
| heterogeneous `type` arrays, `anyOf`, `oneOf` | union type |
| heterogeneous `type` arrays, `anyOf`, `oneOf` | refused - `UnsupportedUnionTypeException`, at any depth |
| `allOf` | deep merged object schema |

Annotations (`description`, `title`, `default`, `examples`, constraints like `pattern` or `minimum`) of top level
Expand Down
51 changes: 25 additions & 26 deletions documentation/components/core/building-blocks.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,32 +59,31 @@ Every column is described by a [Definition](/src/core/etl/src/Flow/ETL/Schema/De
the matching `*_schema()` [DSL function](/src/core/etl/src/Flow/ETL/DSL/functions.php). A definition owns
the column name, its [Flow Type](/documentation/components/libs/types.md), nullability and metadata.

| Column | DSL function | Definition |
|--------|--------------|------------|
| Boolean | `bool_schema()` | [BooleanDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/BooleanDefinition.php) |
| Date | `date_schema()` | [DateDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/DateDefinition.php) |
| DateTime | `datetime_schema()` | [DateTimeDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/DateTimeDefinition.php) |
| Enum | `enum_schema()` | [EnumDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/EnumDefinition.php) |
| Float | `float_schema()` | [FloatDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/FloatDefinition.php) |
| HTML | `html_schema()` | [HTMLDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/HTMLDefinition.php) |
| HTML Element | `html_element_schema()` | [HTMLElementDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/HTMLElementDefinition.php) |
| Integer | `int_schema()`, `integer_schema()` | [IntegerDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/IntegerDefinition.php) |
| Json | `json_schema()` | [JsonDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/JsonDefinition.php) |
| List | `list_schema()` | [ListDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/ListDefinition.php) |
| Map | `map_schema()` | [MapDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/MapDefinition.php) |
| Null | `null_schema()` | [NullDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/NullDefinition.php) |
| String | `str_schema()`, `string_schema()` | [StringDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/StringDefinition.php) |
| Structure | `structure_schema()` | [StructureDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/StructureDefinition.php) |
| Time | `time_schema()` | [TimeDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/TimeDefinition.php) |
| Time Zone | `time_zone_schema()` | [TimeZoneDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/TimeZoneDefinition.php) |
| Union | `union_schema()` | resolves to the single member's Definition - see below |
| Uuid | `uuid_schema()` | [UuidDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/UuidDefinition.php) |
| XML | `xml_schema()` | [XMLDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/XMLDefinition.php) |
| XML Element | `xml_element_schema()` | [XMLElementDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/XMLElementDefinition.php) |

A column holds exactly one type, so `union_schema()` accepts only `null|T` - a nullable column - and
refuses every other union. Declare the widest common type with `str_schema()`, or `json_schema()` when the
shape is genuinely dynamic.
| Column | DSL function | Definition |
|--------------|------------------------------------|-------------------------------------------------------------------------------------------------|
| Boolean | `bool_schema()` | [BooleanDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/BooleanDefinition.php) |
| Date | `date_schema()` | [DateDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/DateDefinition.php) |
| DateTime | `datetime_schema()` | [DateTimeDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/DateTimeDefinition.php) |
| Enum | `enum_schema()` | [EnumDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/EnumDefinition.php) |
| Float | `float_schema()` | [FloatDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/FloatDefinition.php) |
| HTML | `html_schema()` | [HTMLDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/HTMLDefinition.php) |
| HTML Element | `html_element_schema()` | [HTMLElementDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/HTMLElementDefinition.php) |
| Integer | `int_schema()`, `integer_schema()` | [IntegerDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/IntegerDefinition.php) |
| Json | `json_schema()` | [JsonDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/JsonDefinition.php) |
| List | `list_schema()` | [ListDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/ListDefinition.php) |
| Map | `map_schema()` | [MapDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/MapDefinition.php) |
| Null | `null_schema()` | [NullDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/NullDefinition.php) |
| String | `str_schema()`, `string_schema()` | [StringDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/StringDefinition.php) |
| Structure | `structure_schema()` | [StructureDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/StructureDefinition.php) |
| Time | `time_schema()` | [TimeDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/TimeDefinition.php) |
| Time Zone | `time_zone_schema()` | [TimeZoneDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/TimeZoneDefinition.php) |
| Uuid | `uuid_schema()` | [UuidDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/UuidDefinition.php) |
| XML | `xml_schema()` | [XMLDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/XMLDefinition.php) |
| XML Element | `xml_element_schema()` | [XMLElementDefinition](/src/core/etl/src/Flow/ETL/Schema/Definition/XMLElementDefinition.php) |

A column holds exactly one type. `null|T` spells a nullable column, and a nullable list element, map value or
structure element; every other union is refused. Declare the widest common type with `str_schema()`, or
`json_schema()` when the shape is genuinely dynamic.

The schema is declared, never guessed: `array_to_rows()` takes it as its second argument and a
[Hydrator](/src/core/etl/src/Flow/ETL/Row/Hydrator.php) turns the raw values into `Rows` against it,
Expand Down
2 changes: 1 addition & 1 deletion documentation/components/core/floe.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ Floe does not support map keys of type "uuid"
Floe does not support structures that allow extra values
```

That covers `mixed`, union columns built with `union_schema()`, and `type_structure(..., allow_extra: true)`.
That covers `mixed` and `type_structure(..., allow_extra: true)`.
Use a declared element type, or a `json_schema()` column when the shape is genuinely dynamic.

## On-Disk Layout
Expand Down
6 changes: 6 additions & 0 deletions documentation/components/core/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,12 @@ definition_from_type('value', type_union(type_string(), type_integer()));
// Possible fixes:
// * Declare the widest common type: str_schema('value')
// * Declare json_schema('value') when the shape is genuinely dynamic

list_schema('tags', type_list(type_union(type_string(), type_null())));
// list<?string>

list_schema('tags', type_list(type_union(type_string(), type_integer())));
// UnsupportedUnionTypeException: Column "tags" cannot hold elements of type "integer|string": ...
```

## Declaring the Source Schema
Expand Down
32 changes: 32 additions & 0 deletions documentation/upgrading.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,38 @@ final class MyExtractor implements Extractor
| `Flow\Floe\AdaptiveFloeEncoder` | removed - `FloeEngine::adaptive->encoder($schema)` returns `NativeFloeEncoder` when the extension supports it, else `PhpFloeEncoder` |
| `FloeEngine::encoder(): Encoder<string>` | `FloeEngine::encoder(): Flow\Floe\FloeEncoder` (`Encoder<string>` plus `decodeRows()` / `encodeFrames()`) |

### 36) `flow-php/etl` - `UnionDefinition` and `union_schema()` removed

| Before | After |
|-----------------------------------------------------------------------|-----------------------------------------------------------|
| `union_schema('a', type_union(type_string(), type_null()))` | `str_schema('a', nullable: true)` |
| `new UnionDefinition('a', type_union(type_integer(), type_string()))` | `str_schema('a')`, or `json_schema('a')` for dynamic data |

Removed with them: `UnionMembers`, `UnionTypeNormalizer`, `Row\EntryTypeResolver`, `TypeProjection::union()`.
`TypeProjection` now takes the column's `Reference`; `TypeFloor` no longer takes a `TypeProjection`.

### 37) `flow-php/etl` - a union inside a list, map or structure column type

| Column type | Before | After |
|--------------------------------------------------------------------------|------------------------------------------------------------|------------------------------------------------------------------|
| `list_schema('a', type_list(type_union(type_integer(), type_null())))` | `list<integer\|null>`, Floe and Parquet refuse to write it | `list<?integer>` |
| `list_schema('a', type_list(type_union(type_integer(), type_string())))` | accepted, Floe and Parquet refuse to write it | `UnsupportedUnionTypeException` |
| `ref('a')->cast(type_list(type_union(type_integer(), type_string())))` | accepted, fails on every row | `InvalidArgumentException` `Cast function does not support type` |

The same applies to map values and structure elements.

### 38) `flow-php/etl-adapter-postgresql` - array columns are `list<?T>`

| Before | After |
|-----------------------------------------------------------------------|------------------|
| `int4[]` → `list<integer\|null>`, Floe and Parquet refuse to write it | `list<?integer>` |

### 39) `flow-php/etl-adapter-json` - `anyOf` / `oneOf` / multi-type `type` inside `items`, `properties` or `additionalProperties`

| Before | After |
|------------------------------------------------------------------------------------------|---------------------------------------------------|
| `list<integer\|string>`, `structure{x: integer\|string}`, `map<string, integer\|string>` | `UnsupportedUnionTypeException` naming the column |

---

## Upgrading from 0.43.x to 0.44.x
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -889,12 +889,6 @@ private function metadata(array $schema): Metadata
*/
private function nullify(array $property): array
{
if (array_key_exists('anyOf', $property) && is_array($property['anyOf'])) {
$property['anyOf'][] = ['type' => 'null'];

return $property;
}

if (array_key_exists('type', $property) && is_array($property['type'])) {
if (!in_array('null', $property['type'], true)) {
$property['type'][] = 'null';
Expand Down Expand Up @@ -1010,11 +1004,6 @@ private function typeToJsonSchema(Type $type): array
: ['type' => 'array', 'items' => $this->typeToJsonSchema($type->element())],
$type instanceof MapType => $this->mapToJsonSchema($type),
$type instanceof StructureType => $this->structureToJsonSchema($type),
$type instanceof UnionType => [
'anyOf' => array_map(fn(Type $member): array => $this->typeToJsonSchema(
$member,
), $type->types()->all()),
],
default => throw new RuntimeException(sprintf('Type %s cannot be converted to JSON Schema', $type::class)),
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
use Flow\ETL\Exception\InvalidArgumentException;
use Flow\ETL\Exception\RuntimeException;
use Flow\ETL\Exception\UnsupportedUnionTypeException;
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;
Expand Down Expand Up @@ -46,7 +45,6 @@
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;
use function sprintf;

final class SchemaConverterTest extends FlowTestCase
Expand Down Expand Up @@ -417,9 +415,12 @@ public function test_to_flow_nested_structures_lists_and_maps(): void
);
}

public function test_to_flow_nested_union_inside_structure(): void
public function test_to_flow_nested_union_inside_structure_is_refused(): void
{
$flowSchema = (new SchemaConverter())->toFlow([
$this->expectException(UnsupportedUnionTypeException::class);
$this->expectExceptionMessage('Column "config" cannot hold elements of type "integer|string"');

(new SchemaConverter())->toFlow([
'type' => 'object',
'required' => ['config'],
'properties' => [
Expand All @@ -432,12 +433,40 @@ public function test_to_flow_nested_union_inside_structure(): void
],
],
]);
}

static::assertEquals(
schema(structure_schema('config', type_structure([
'value' => type_union(type_integer(), type_string()),
]))),
$flowSchema,
public function test_to_flow_items_any_of_is_refused(): void
{
$this->expectException(UnsupportedUnionTypeException::class);
$this->expectExceptionMessage('Column "tags" cannot hold elements of type "integer|string"');

(new SchemaConverter())->toFlow([
'type' => 'object',
'required' => ['tags'],
'properties' => [
'tags' => ['type' => 'array', 'items' => ['anyOf' => [['type' => 'integer'], ['type' => 'string']]]],
],
]);
}

public function test_to_flow_items_nullable_type_is_an_optional_element(): void
{
static::assertSame(
'list<?integer>',
(new SchemaConverter())
->toFlow([
'type' => 'object',
'required' => ['tags'],
'properties' => [
'tags' => [
'type' => 'array',
'items' => ['anyOf' => [['type' => 'integer'], ['type' => 'null']]],
],
],
])
->get('tags')
->type()
->toString(),
);
}

Expand Down Expand Up @@ -735,18 +764,6 @@ public function test_to_json_schema_interleaved_structure_lists_required_fields_
);
}

public function test_a_union_column_cannot_round_trip_because_it_cannot_be_read_back(): void
{
$converter = new SchemaConverter();
$jsonSchema = $converter->toJsonSchema(schema(
new UnionDefinition('value', type_union(type_integer(), type_string()), true),
));

$this->expectException(UnsupportedUnionTypeException::class);

$converter->toFlow($jsonSchema);
}

public function test_round_trip_json_schema_to_flow_and_back(): void
{
$jsonSchema = [
Expand Down Expand Up @@ -973,24 +990,6 @@ public function test_to_json_schema_null_definition(): void
);
}

public function test_to_json_schema_nullable_union_appends_null_member(): void
{
$jsonSchema = (new SchemaConverter())->toJsonSchema(schema(
new UnionDefinition('value', type_union(type_integer(), type_string()), true),
));

static::assertSame(
[
'$schema' => 'https://json-schema.org/draft/2020-12/schema',
'type' => 'object',
'properties' => [
'value' => ['anyOf' => [['type' => 'integer'], ['type' => 'string'], ['type' => 'null']]],
],
],
$jsonSchema,
);
}

public function test_to_json_schema_prefix_items_metadata_restores_tuple(): void
{
$jsonSchema = (new SchemaConverter())->toJsonSchema(schema(json_schema('pair', metadata: Metadata::with(
Expand Down
Loading
Loading