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
6 changes: 4 additions & 2 deletions documentation/components/adapters/excel.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,10 @@ The default sample is the first `20 480` rows of up to `10` files. Every inferre
narrowing is not safe the column floors to `string`.

A workbook has its own cell types, so inference reads those first: a numeric cell is `integer` or `float`, a boolean
cell is `boolean`, a date-formatted cell is `date` or `datetime`. A cell holding **text** stays `string` even when the
text looks like something else - `TRUE`, `12.9` and `2023-10-02` are all text a spreadsheet author chose not to type.
cell is `boolean`, a date-formatted cell is `date` or `datetime`. A date cell has no zone, so it reads as a UTC wall
clock whatever `date.timezone` says; `withSchema(schema(datetime_schema('d', zone: 'Europe/Warsaw')))` converts it.
A cell holding **text** stays `string` even when the text looks like something else - `TRUE`, `12.9` and `2023-10-02`
are all text a spreadsheet author chose not to type.

The exception is types a cell cannot hold at all. There is no uuid, json or timezone cell, so text is the only way to
write one and text is narrowed for exactly those three:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ $user = $client->fetchInto($mapper, 'SELECT id, name, address FROM users WHERE i

PostgreSQL returns `TIMESTAMP` values like `'2026-01-01 14:30:00'`. Valinor cannot construct `\DateTimeImmutable` from a raw string without explicit configuration, but `type_datetime()` can.

The string carries no offset, so `type_datetime()` reads it as UTC; `type_datetime('Europe/Warsaw')` reads it as Warsaw local time.

```php
<?php

Expand Down
3 changes: 3 additions & 0 deletions documentation/components/core/building-blocks.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ the column name, its [Flow Type](/documentation/components/libs/types.md), nulla
| 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 `datetime` column has one time zone, UTC by default: `datetime_schema('at', zone: 'Europe/Warsaw')`. A value in any
other zone is converted to it when the row is admitted, the instant unchanged.

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.
Expand Down
7 changes: 7 additions & 0 deletions documentation/components/extensions/flow-php-ext.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,10 @@ A version-skewed pair fails loudly instead of writing wrong bytes:

Either message means the extension and `flow-php/etl` disagree on the structure schema format:
reinstall one to match the other, or set the Floe engine to `FloeEngine::php` while upgrading.

Datetime column zones do not fail, they degrade:

- new `flow-php/etl` with an extension that predates column zones: the extension is ignored and the
PHP engine runs
- old `flow-php/etl` with a current extension: a zone-less `datetime` column keeps that library's
behaviour, each value in its stored zone
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ $row['created_at']->format('Y-m-d H:i:s'); // '2026-01-01 14:30:00'
`\DateTime`, `\DateTimeImmutable`, and `\DateInterval`. A **string** has to be a real calendar date -
a date-only string lands at midnight, while a wall-clock expression such as `''`, `'now'` or `'+12'`
is refused, so the same input never produces a different value on a different run.
A string without an offset is read in the column zone - UTC for `type_datetime()`, never `date.timezone`. Declare
`type_datetime('Europe/Warsaw')` to read local timestamps.

### String → Date

Expand Down
16 changes: 16 additions & 0 deletions documentation/components/libs/types.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,22 @@ would substitute a *different* value:
throw, and the message names the remedy (`wrap the value first, e.g. type_list(type_string())`).
`type_array()->cast(null)` throws too, rather than fabricating an empty array

A `datetime` type carries one time zone - UTC unless you name one - and every value it casts lands in that zone, the
instant unchanged:

```php
type_datetime()->cast('2026-01-02 03:04'); // 2026-01-02 03:04:00 UTC, date.timezone ignored
type_datetime('Europe/Warsaw')->cast('2026-01-02T03:04:05Z'); // 2026-01-02 04:04:05 Europe/Warsaw
type_datetime('Europe/Warsaw')->cast('2026-03-29 02:30:00'); // 2026-03-29 03:30:00 +02:00 - a DST gap moves forward
type_datetime('Europe/Warsaw')->cast('2026-10-25 02:30:00'); // +01:00 - an overlap takes the later occurrence...
type_datetime('America/New_York')->cast('2026-11-01 01:30:00'); // -04:00 - ...or the earlier one, west of UTC
type_datetime('America/New_York')->cast(type_date()->cast('2026-01-02')); // 2026-01-01 19:00 - a date is midnight UTC
type_equals(type_datetime(), type_datetime('+02:00')); // false
```

A zone is an IANA region (`Europe/Warsaw`), `UTC` or an offset (`+05:30`). Abbreviations such as `CET` or `PST` are
refused: PHP reads them as fixed offsets without daylight-saving rules.

`DateTimeInterface` and `DateInterval` convert to `int`/`float` in **seconds**, so they round-trip
with `type_datetime()->cast(<int>)`. `float` keeps the sub-second fraction; `int` floors to whole
seconds.
Expand Down
24 changes: 24 additions & 0 deletions documentation/upgrading.md
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,30 @@ The same applies to map values and structure elements.
|------------------------------------------------------------------------------------------|---------------------------------------------------|
| `list<integer\|string>`, `structure{x: integer\|string}`, `map<string, integer\|string>` | `UnsupportedUnionTypeException` naming the column |

### 40) `flow-php/types`, `flow-php/etl` - a `datetime` column has one time zone

| Before | After |
|-------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `type_datetime()->cast('2026-01-02 03:04')` in `date.timezone` | in the column zone: UTC, or `type_datetime('Europe/Warsaw')` |
| `cast('2026-01-02T03:04:05+02:00')` keeps `+02:00`, `cast('…Z')` keeps `Z` | the same instant in the column zone (`2026-01-02 01:04:05 UTC`) |
| a `DateTimeInterface` value in `rows()`, `from_rows()`, a cast or a Floe read keeps its own zone | converted to the column zone, instant unchanged |
| `to_csv()` / `to_json()` / `to_xml()` write each value's own offset (`+02:00` in, `+02:00` out) | the column zone's offset (`2026-01-02T01:04:05+00:00`); declare `datetime_schema('at', zone: 'Europe/Warsaw')` on the extractor to write a local one |
| `from_dbal_*()` naive `timestamp` / MySQL `datetime` text, and naive CSV/JSON text, read in `date.timezone` | read in the column zone (UTC); `->withSchema(schema(datetime_schema('at', zone: 'Europe/Warsaw')))` keeps a local reading |
| `type_datetime('CEST')`, `to_timezone($ref, 'PST')`, `'EST'`, `'CET'` | `InvalidArgumentException` - an IANA region name, `UTC` or `+HH:MM` |
| `to_timezone($v, lit('UTC'))` | `TypeError` - pass `'UTC'` or a `DateTimeZone` |
| `to_timezone($ref, ref('tz'))` - a per-row zone | `TypeError` - the zone of `to_timezone()` and `new ToTimeZone()` is `DateTimeZone\|string` |
| `now($tz)`, `to_date_time(…, $tz)`, `to_timezone(…, $tz)` return a `datetime` (UTC) column | a `datetime` column in `$tz`; a `ScalarFunction` zone in `now()` / `to_date_time()` yields UTC; `now(new DateTimeZone('EST'))`, `to_date_time(…, new DateTimeZone('PST'))` throw `InvalidArgumentException` (abbreviation zones) |
| `to_date_time($dateTimeObject)` truncates to midnight | keeps the time, converted to the zone |
| merging `datetime` columns ignores zones | equal zones kept, different zones become `UTC` |
| merging `time` with `date` / `datetime` gives `datetime` | `string` |
| `?T` inside a list, map or structure passes a valid value through `cast()` unchanged | cast like `T`: a `?datetime` moves to its zone, a `?structure` keeps only its declared keys, in declared order |
| `type_date()->cast('2026-01-02T23:00:00-05:00')` midnight `-05:00` | `2026-01-02 00:00:00 UTC`; every non-object date input lands at midnight UTC |
| `type_date()->cast($dateTime)` sets the caller's `DateTime` to midnight | returns a new `DateTimeImmutable` |
| `type_time()->cast(new DateTimeImmutable('10:00+05:00'))` = `05:00:00` | `10:00:00` - the value's wall clock |
| Excel date cells in the host zone | UTC wall clock |
| `new DateTimeDefinition('at', true)` | `new DateTimeDefinition('at', type_datetime(), true)` or `datetime_schema('at', nullable: true, zone: 'Europe/Warsaw')` |
| a `flow_php` extension older than this release | ignored - the PHP engine runs; a current extension with an older `flow-php/etl` keeps that library's behaviour |

---

## Upgrading from 0.43.x to 0.44.x
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@
use PHPUnit\Framework\Attributes\TestWith;
use stdClass;

use function Flow\ETL\DSL\datetime_schema;
use function Flow\ETL\DSL\flow_context;
use function Flow\ETL\DSL\int_schema;
use function Flow\ETL\DSL\schema;
use function Flow\ETL\DSL\str_schema;
use function Flow\Types\DSL\type_datetime;
use function Flow\Types\DSL\type_string;

final class DbalQueryExtractorTest extends FlowTestCase
Expand Down Expand Up @@ -162,4 +164,26 @@ public function test_new_types_drop_the_statistics(): void

static::assertNotSame($before, $extractor->statistics());
}

public function test_naive_datetime_text_reads_as_utc_under_non_utc_default(): void
{
$previous = date_default_timezone_get();
date_default_timezone_set('Asia/Tokyo');

try {
$values = [];

foreach ((new DbalQueryExtractor(InMemorySqlite::connection(), "SELECT '2026-01-01 14:30:00' AS at"))
->withSchema(schema(datetime_schema('at')))
->extract(flow_context()) as $batch) {
foreach ($batch as $row) {
$values[] = type_datetime()->assert($row->get('at'))->format('Y-m-d H:i:s e');
}
}

static::assertSame(['2026-01-01 14:30:00 UTC'], $values);
} finally {
date_default_timezone_set($previous);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@
namespace Flow\ETL\Adapter\Excel\Sheet;

use DateInterval;
use DateTimeImmutable;
use DateTimeInterface;
use DateTimeZone;
use Generator;
use OpenSpout\Common\Entity\Cell;
use OpenSpout\Common\Entity\Cell\DateTimeCell;
use OpenSpout\Reader\SheetInterface;
use OpenSpout\Reader\XLSX\Sheet as XlsxSheet;

Expand Down Expand Up @@ -68,8 +71,12 @@ private function values(): Generator
return;
}

static $utc = new DateTimeZone('UTC');

foreach ($this->sheet->getRowIterator() as $sheetRow) {
yield array_map(static fn(Cell $cell) => $cell->getValue(), $sheetRow->cells);
yield array_map(static fn(Cell $cell) => $cell instanceof DateTimeCell
? new DateTimeImmutable($cell->getValue()->format('Y-m-d H:i:s.u'), $utc)
: $cell->getValue(), $sheetRow->cells);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@

namespace Flow\ETL\Adapter\Excel\Sheet;

use DateTimeImmutable;
use DateTimeZone;
use DOMElement;
use Generator;
use OpenSpout\Common\Entity\Cell\DateTimeCell;
use OpenSpout\Common\Exception\InvalidArgumentException;
use OpenSpout\Common\Exception\IOException;
use OpenSpout\Reader\Exception\XMLProcessingException;
Expand Down Expand Up @@ -79,6 +82,8 @@ public function __construct(Sheet $sheet)
*/
public function values(): Generator
{
static $utc = new DateTimeZone('UTC');

$reader = new XMLReader();
// libxml reports into its own buffer only while this generator runs - never across a yield
$internalErrors = libxml_use_internal_errors(true);
Expand Down Expand Up @@ -108,7 +113,10 @@ public function values(): Generator
throw self::failure();
}

$values[$column] = $this->formatter->extractAndFormatNodeValue($node)->getValue();
$cell = $this->formatter->extractAndFormatNodeValue($node);
$values[$column] = $cell instanceof DateTimeCell
? new DateTimeImmutable($cell->getValue()->format('Y-m-d H:i:s.u'), $utc)
: $cell->getValue();
$moved = $reader->next();

continue;
Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,20 @@ function write_ods(string $file, array $rows): void
[Cell::fromValue(new DateTimeImmutable('2024-01-03 10:30:00'), $dateTimeStyle)],
]);

write_ods($dir . '/dates_mixed.ods', [
['d'],
[Cell::fromValue(new DateTimeImmutable('2024-01-01', new DateTimeZone('UTC')), $dateStyle)],
[Cell::fromValue(new DateTimeImmutable('2024-01-03 10:30:00', new DateTimeZone('UTC')), $dateTimeStyle)],
]);
// OpenSpout writes date-value with a trailing Z; LibreOffice writes it zone-less, which the reader builds in the host zone
$zip = new ZipArchive();
$zip->open($dir . '/dates_mixed.ods');
$zip->addFromString(
'content.xml',
(string) preg_replace('/(office:date-value="[^"]+)Z"/', '$1"', (string) $zip->getFromName('content.xml')),
);
$zip->close();

write_xlsx($dir . '/int_then_str.xlsx', [['v'], [1], [2], ['n/a']]);

write_xlsx($dir . '/int_float_bool.xlsx', [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
use function Flow\ETL\Adapter\Excel\DSL\is_valid_excel_sheet_name;
use function Flow\ETL\DSL\config;
use function Flow\ETL\DSL\date_schema;
use function Flow\ETL\DSL\datetime_schema;
use function Flow\ETL\DSL\df;
use function Flow\ETL\DSL\flow_context;
use function Flow\ETL\DSL\from_rows;
Expand All @@ -44,6 +45,7 @@
use function Flow\ETL\DSL\string_schema;
use function Flow\Filesystem\DSL\native_local_filesystem;
use function Flow\Filesystem\DSL\path_real;
use function Flow\Types\DSL\type_datetime;
use function max;

final class ExcelExtractorTest extends FlowTestCase
Expand Down Expand Up @@ -900,4 +902,49 @@ public function test_a_filtered_extract_declares_unknown_rows(): void

static::assertEquals(Cardinality::unknown(), $extractor->statistics()->rows);
}

public function test_date_cell_in_a_zoned_column_is_converted(): void
{
static::assertSame(
'2024-01-03 11:30:00 Europe/Warsaw',
type_datetime()
->assert(df()
->extract(
from_excel(ExcelFixtureContext::file('dates_mixed.xlsx'))
->withSchema(schema(datetime_schema('d', zone: 'Europe/Warsaw'))),
)
->fetch()
->all()[2]->get('d'))
->format('Y-m-d H:i:s e'),
);
}

public function test_date_cells_read_as_utc_wall_clock_under_non_utc_default(): void
{
$previous = date_default_timezone_get();
date_default_timezone_set('Asia/Tokyo');

try {
static::assertSame(
'2024-01-03 10:30:00 UTC',
type_datetime()
->assert(df()
->extract(from_excel(ExcelFixtureContext::file('dates_mixed.xlsx')))
->fetch()
->all()[2]->get('d'))
->format('Y-m-d H:i:s e'),
);
static::assertSame(
'2024-01-03 10:30:00 UTC',
type_datetime()
->assert(df()
->extract(from_excel(ExcelFixtureContext::file('dates_mixed.ods')))
->fetch()
->all()[1]->get('d'))
->format('Y-m-d H:i:s e'),
);
} finally {
date_default_timezone_set($previous);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ private static function fromRawList(string $directory, array $rawList): Director
$entries[] = DirectoryEntry::file(
$path,
type_optional(type_integer())->assert($value->size ?? null),
// @mago-expect analysis:less-specific-argument
isset($value->mtime) ? type_datetime()->cast($value->mtime) : null,
);
}
Expand Down
Loading
Loading