From 6fd73e436e4e5c693f6343e49b148a70ca8578c5 Mon Sep 17 00:00:00 2001 From: Norbert Orzechowicz Date: Fri, 25 Sep 2026 10:37:38 +0200 Subject: [PATCH] perf: ISO temporal fast paths and file constants after hydration - schema inference narrows ISO dates/datetimes without date_parse() - ISO date cast fast path in DateType/DateTimeType and native cast - ISO regexes tightened to timelib field ranges - extractors stamp file constants after hydration via fillRows() - shared date_check/uuid_check modules in the extension - ordered_on date column in the benchmark Orders fixture --- benchmarks/src/Pipeline/OrdersSchema.php | 5 + .../Unit/Pipeline/InMemoryOrdersTest.php | 1 + .../Tests/Unit/Pipeline/OrdersSchemaTest.php | 4 +- .../src/Flow/ETL/Adapter/CSV/CSVExtractor.php | 10 +- .../Flow/ETL/Adapter/Excel/ExcelExtractor.php | 8 +- .../JSON/JSONMachine/JsonExtractor.php | 10 +- .../JSON/JSONMachine/JsonLinesExtractor.php | 10 +- .../ETL/Adapter/Parquet/ParquetExtractor.php | 14 ++- .../Flow/ETL/Adapter/Text/TextExtractor.php | 21 +--- .../ETL/Adapter/XML/XMLParserExtractor.php | 7 +- .../ETL/Adapter/XML/XMLReaderExtractor.php | 21 +--- .../Double/FakeRandomOrdersExtractor.php | 3 + src/extension/flow-php-ext/src/cast.rs | 102 ++------------- src/extension/flow-php-ext/src/csv/fold.rs | 36 ++---- src/extension/flow-php-ext/src/date_check.rs | 102 +++++++++++++++ src/extension/flow-php-ext/src/lib.rs | 2 + src/extension/flow-php-ext/src/uuid_check.rs | 8 ++ .../tests/phpt/046_csv_narrow_parity.phpt | 90 +++++++++++++ .../tests/phpt/061_date_iso_fast_path.phpt | 102 +++++++++++++++ .../Flow/Types/Type/Logical/DateTimeType.php | 10 +- .../src/Flow/Types/Type/Logical/DateType.php | 6 + .../Native/String/StringTemporalParts.php | 55 +++++--- .../Unit/Type/Logical/DateTimeTypeTest.php | 20 +++ .../Tests/Unit/Type/Logical/DateTypeTest.php | 20 +++ .../Native/String/StringTemporalPartsTest.php | 118 +++++++++++------- 25 files changed, 535 insertions(+), 250 deletions(-) create mode 100644 src/extension/flow-php-ext/src/date_check.rs create mode 100644 src/extension/flow-php-ext/src/uuid_check.rs create mode 100644 src/extension/flow-php-ext/tests/phpt/061_date_iso_fast_path.phpt diff --git a/benchmarks/src/Pipeline/OrdersSchema.php b/benchmarks/src/Pipeline/OrdersSchema.php index d11ee565a..ceb359176 100644 --- a/benchmarks/src/Pipeline/OrdersSchema.php +++ b/benchmarks/src/Pipeline/OrdersSchema.php @@ -6,6 +6,7 @@ use Flow\ETL\Schema; +use function Flow\ETL\DSL\date_schema; use function Flow\ETL\DSL\datetime_schema; use function Flow\ETL\DSL\float_schema; use function Flow\ETL\DSL\json_schema; @@ -49,6 +50,7 @@ public static function of(Source $source): Schema json_schema('address', true), json_schema('notes', true), json_schema('items', true), + date_schema('ordered_on', true), ), Source::json, Source::json_lines => schema( string_schema('order_id', true), @@ -79,6 +81,7 @@ public static function of(Source $source): Schema ])), true, ), + string_schema('ordered_on', true), ), Source::array, Source::floe, Source::memory, Source::parquet => schema( uuid_schema('order_id', true), @@ -109,6 +112,7 @@ public static function of(Source $source): Schema ])), true, ), + date_schema('ordered_on', true), ), }; } @@ -132,6 +136,7 @@ public static function ofService(ServiceSource $source): Schema json_schema('address', true), json_schema('notes', true), json_schema('items', true), + date_schema('ordered_on', true), ), }; } diff --git a/benchmarks/tests/Flow/Benchmarks/Tests/Unit/Pipeline/InMemoryOrdersTest.php b/benchmarks/tests/Flow/Benchmarks/Tests/Unit/Pipeline/InMemoryOrdersTest.php index 5b97555a4..6ccefef88 100644 --- a/benchmarks/tests/Flow/Benchmarks/Tests/Unit/Pipeline/InMemoryOrdersTest.php +++ b/benchmarks/tests/Flow/Benchmarks/Tests/Unit/Pipeline/InMemoryOrdersTest.php @@ -28,6 +28,7 @@ public function test_every_row_is_keyed_by_column_name(): void 'address', 'notes', 'items', + 'ordered_on', ], array_keys(InMemoryOrders::of(self::ROWS)[0]), ); diff --git a/benchmarks/tests/Flow/Benchmarks/Tests/Unit/Pipeline/OrdersSchemaTest.php b/benchmarks/tests/Flow/Benchmarks/Tests/Unit/Pipeline/OrdersSchemaTest.php index 19146c4cf..b960da897 100644 --- a/benchmarks/tests/Flow/Benchmarks/Tests/Unit/Pipeline/OrdersSchemaTest.php +++ b/benchmarks/tests/Flow/Benchmarks/Tests/Unit/Pipeline/OrdersSchemaTest.php @@ -14,14 +14,14 @@ final class OrdersSchemaTest extends TestCase public function test_every_service_source_declares_eleven_columns(): void { foreach (ServiceSource::cases() as $source) { - static::assertCount(11, OrdersSchema::ofService($source)->definitions(), $source->value); + static::assertCount(12, OrdersSchema::ofService($source)->definitions(), $source->value); } } public function test_every_source_declares_eleven_columns(): void { foreach (Source::cases() as $source) { - static::assertCount(11, OrdersSchema::of($source)->definitions(), $source->value); + static::assertCount(12, OrdersSchema::of($source)->definitions(), $source->value); } } diff --git a/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/CSVExtractor.php b/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/CSVExtractor.php index 229bde028..d47cd226c 100644 --- a/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/CSVExtractor.php +++ b/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/CSVExtractor.php @@ -19,7 +19,6 @@ use Flow\ETL\Extractor\Signal; use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; -use Flow\ETL\Row\RawRowValues; use Flow\ETL\Rows; use Flow\ETL\Schema; use Flow\ETL\Schema\Inference\SchemaInference; @@ -124,6 +123,7 @@ public function extract(FlowContext $context, ?int $limit = null, Filter $pathFi } $schema = $fileColumns->declare($base); + $body = $fileColumns->withoutTail($schema); $tail = $fileColumns->tail(); $expected = $base->references()->names(); @@ -154,13 +154,7 @@ public function extract(FlowContext $context, ?int $limit = null, Filter $pathFi } } - $batch = []; - - foreach ($rawBatch as $values) { - $batch[] = new RawRowValues($constants->fill($values->values)); - } - - $hydrated = $hydrator->hydrate($batch, $schema); + $hydrated = $constants->fillRows($hydrator->hydrate($rawBatch, $body), $schema); $yielded += $hydrated->count(); diff --git a/src/adapter/etl-adapter-excel/src/Flow/ETL/Adapter/Excel/ExcelExtractor.php b/src/adapter/etl-adapter-excel/src/Flow/ETL/Adapter/Excel/ExcelExtractor.php index 858b0a539..53ac1e39a 100644 --- a/src/adapter/etl-adapter-excel/src/Flow/ETL/Adapter/Excel/ExcelExtractor.php +++ b/src/adapter/etl-adapter-excel/src/Flow/ETL/Adapter/Excel/ExcelExtractor.php @@ -19,7 +19,6 @@ use Flow\ETL\Extractor\Signal; use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; -use Flow\ETL\Row\RawRowValues; use Flow\ETL\Rows; use Flow\ETL\Schema; use Flow\ETL\Schema\Inference\SchemaInference; @@ -170,6 +169,7 @@ public function extract(FlowContext $context, ?int $limit = null, Filter $pathFi } $schema = $fileColumns->declare($base); + $body = $fileColumns->withoutTail($schema); $tail = $fileColumns->tail(); $expected = $base->references()->names(); @@ -199,13 +199,13 @@ public function extract(FlowContext $context, ?int $limit = null, Filter $pathFi $batch = []; foreach ($sheet->rows() as $rowValues) { - $batch[] = new RawRowValues($constants->fill($rowValues->values)); + $batch[] = $rowValues; if (count($batch) < $batchSize) { continue; } - $hydrated = $hydrator->hydrate($batch, $schema); + $hydrated = $constants->fillRows($hydrator->hydrate($batch, $body), $schema); $batch = []; $yielded += $hydrated->count(); @@ -225,7 +225,7 @@ public function extract(FlowContext $context, ?int $limit = null, Filter $pathFi continue; } - $hydrated = $hydrator->hydrate($batch, $schema); + $hydrated = $constants->fillRows($hydrator->hydrate($batch, $body), $schema); $yielded += $hydrated->count(); diff --git a/src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonExtractor.php b/src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonExtractor.php index fe306d8b1..ce7ec0ce4 100644 --- a/src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonExtractor.php +++ b/src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonExtractor.php @@ -18,7 +18,6 @@ use Flow\ETL\Extractor\Signal; use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; -use Flow\ETL\Row\RawRowValues; use Flow\ETL\Rows; use Flow\ETL\Schema; use Flow\ETL\Schema\Inference\SchemaInference; @@ -126,19 +125,14 @@ public function extract(FlowContext $context, ?int $limit = null, Filter $pathFi } $schema = $fileColumns->declare($base); + $body = $fileColumns->withoutTail($schema); foreach ($sources as $source) { // forFile() reads the PARTITION definitions, which only declare() creates - $base is the body $constants = $fileColumns->forFile($source, $schema); foreach ($reader->batches($source, $batchSize) as $rawBatch) { - $batch = []; - - foreach ($rawBatch as $values) { - $batch[] = new RawRowValues($constants->fill($values->values)); - } - - $hydrated = $hydrator->hydrate($batch, $schema); + $hydrated = $constants->fillRows($hydrator->hydrate($rawBatch, $body), $schema); $yielded += $hydrated->count(); diff --git a/src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonLinesExtractor.php b/src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonLinesExtractor.php index 249feaac0..2d55db2a2 100644 --- a/src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonLinesExtractor.php +++ b/src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonLinesExtractor.php @@ -18,7 +18,6 @@ use Flow\ETL\Extractor\Signal; use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; -use Flow\ETL\Row\RawRowValues; use Flow\ETL\Rows; use Flow\ETL\Schema; use Flow\ETL\Schema\Inference\SchemaInference; @@ -126,19 +125,14 @@ public function extract(FlowContext $context, ?int $limit = null, Filter $pathFi } $schema = $fileColumns->declare($base); + $body = $fileColumns->withoutTail($schema); foreach ($sources as $source) { // forFile() reads the PARTITION definitions, which only declare() creates - $base is the body $constants = $fileColumns->forFile($source, $schema); foreach ($reader->batches($source, $batchSize) as $rawBatch) { - $batch = []; - - foreach ($rawBatch as $values) { - $batch[] = new RawRowValues($constants->fill($values->values)); - } - - $hydrated = $hydrator->hydrate($batch, $schema); + $hydrated = $constants->fillRows($hydrator->hydrate($rawBatch, $body), $schema); $yielded += $hydrated->count(); diff --git a/src/adapter/etl-adapter-parquet/src/Flow/ETL/Adapter/Parquet/ParquetExtractor.php b/src/adapter/etl-adapter-parquet/src/Flow/ETL/Adapter/Parquet/ParquetExtractor.php index fd53ee644..fddc59157 100644 --- a/src/adapter/etl-adapter-parquet/src/Flow/ETL/Adapter/Parquet/ParquetExtractor.php +++ b/src/adapter/etl-adapter-parquet/src/Flow/ETL/Adapter/Parquet/ParquetExtractor.php @@ -140,6 +140,8 @@ public function extract(FlowContext $context, ?int $limit = null, Filter $pathFi // R6: over the FILE's schema, never over schema()'s output $fileSchema = $fileColumns->declare($this->schema ?? $file->schema()); $constants = $fileColumns->forFile($file->source(), $fileSchema); + $rowsSchema = $promisedSchema ?? $fileSchema; + $body = $fileColumns->withoutTail($rowsSchema); $matchTo = $promisedSchema === null && !$fileSchema->isSame($target) ? $target : null; $encoder = new ParquetEncoder($file->file->schema()); @@ -151,10 +153,13 @@ public function extract(FlowContext $context, ?int $limit = null, Filter $pathFi $limit === null ? null : $limit - $yielded, $fileOffset, ) as $row) { - $rawBatch[] = $constants->fill($row); + $rawBatch[] = $row; if (count($rawBatch) >= $batchSize) { - $hydrated = $hydrator->hydrate($encoder->decode($rawBatch), $promisedSchema ?? $fileSchema); + $hydrated = $constants->fillRows( + $hydrator->hydrate($encoder->decode($rawBatch), $body), + $rowsSchema, + ); if ($matchTo !== null) { $hydrated = $hydrated->matchTo($matchTo); @@ -177,7 +182,10 @@ public function extract(FlowContext $context, ?int $limit = null, Filter $pathFi } if ($rawBatch !== []) { - $hydrated = $hydrator->hydrate($encoder->decode($rawBatch), $promisedSchema ?? $fileSchema); + $hydrated = $constants->fillRows( + $hydrator->hydrate($encoder->decode($rawBatch), $body), + $rowsSchema, + ); if ($matchTo !== null) { $hydrated = $hydrated->matchTo($matchTo); diff --git a/src/adapter/etl-adapter-text/src/Flow/ETL/Adapter/Text/TextExtractor.php b/src/adapter/etl-adapter-text/src/Flow/ETL/Adapter/Text/TextExtractor.php index dbe45a2bf..555626b1e 100644 --- a/src/adapter/etl-adapter-text/src/Flow/ETL/Adapter/Text/TextExtractor.php +++ b/src/adapter/etl-adapter-text/src/Flow/ETL/Adapter/Text/TextExtractor.php @@ -16,7 +16,6 @@ use Flow\ETL\Extractor\Signal; use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; -use Flow\ETL\Row\RawRowValues; use Flow\ETL\Rows; use Flow\ETL\Schema; use Flow\Filesystem\Filesystem; @@ -83,6 +82,7 @@ public function extract(FlowContext $context, ?int $limit = null, Filter $pathFi $fileColumns = $this->fileColumns($this->filesystem, $this->path); $schema = $fileColumns->declare($baseSchema); + $body = $fileColumns->withoutTail($schema); foreach ($this->sourceFiles($this->filesystem, $this->path, $pathFilter) as $source) { $stream = $this->filesystem->readFrom($source->path); @@ -96,16 +96,13 @@ public function extract(FlowContext $context, ?int $limit = null, Filter $pathFi $rawLines[] = $line; if (count($rawLines) >= $batchSize) { - $batch = []; - - foreach ($encoder->decode($rawLines) as $rowValues) { - $batch[] = new RawRowValues($constants->fill($rowValues->values)); - } + $hydrated = $constants->fillRows( + $hydrator->hydrate($encoder->decode($rawLines), $body), + $schema, + ); $rawLines = []; - $hydrated = $hydrator->hydrate($batch, $schema); - $yielded += $hydrated->count(); $signal = yield $hydrated; @@ -121,13 +118,7 @@ public function extract(FlowContext $context, ?int $limit = null, Filter $pathFi } if ($rawLines !== []) { - $batch = []; - - foreach ($encoder->decode($rawLines) as $rowValues) { - $batch[] = new RawRowValues($constants->fill($rowValues->values)); - } - - $hydrated = $hydrator->hydrate($batch, $schema); + $hydrated = $constants->fillRows($hydrator->hydrate($encoder->decode($rawLines), $body), $schema); $yielded += $hydrated->count(); diff --git a/src/adapter/etl-adapter-xml/src/Flow/ETL/Adapter/XML/XMLParserExtractor.php b/src/adapter/etl-adapter-xml/src/Flow/ETL/Adapter/XML/XMLParserExtractor.php index 36f7a8ce3..4d64eee9b 100644 --- a/src/adapter/etl-adapter-xml/src/Flow/ETL/Adapter/XML/XMLParserExtractor.php +++ b/src/adapter/etl-adapter-xml/src/Flow/ETL/Adapter/XML/XMLParserExtractor.php @@ -105,6 +105,7 @@ public function extract(FlowContext $context, ?int $limit = null, Filter $pathFi $fileColumns = $this->fileColumns($this->filesystem, $this->path); $schema = $fileColumns->declare($baseSchema); + $body = $fileColumns->withoutTail($schema); foreach ($this->sourceFiles($this->filesystem, $this->path, $pathFilter) as $source) { $stream = $this->filesystem->readFrom($source->path); @@ -115,10 +116,10 @@ public function extract(FlowContext $context, ?int $limit = null, Filter $pathFi $batch = []; foreach ($nodes->of($stream, $this->bufferSize) as $node) { - $batch[] = new RawRowValues($constants->fill(['node' => $node])); + $batch[] = new RawRowValues(['node' => $node]); if (count($batch) >= $batchSize) { - $hydrated = $hydrator->hydrate($batch, $schema); + $hydrated = $constants->fillRows($hydrator->hydrate($batch, $body), $schema); $batch = []; @@ -137,7 +138,7 @@ public function extract(FlowContext $context, ?int $limit = null, Filter $pathFi } if ($batch !== []) { - $hydrated = $hydrator->hydrate($batch, $schema); + $hydrated = $constants->fillRows($hydrator->hydrate($batch, $body), $schema); $yielded += $hydrated->count(); diff --git a/src/adapter/etl-adapter-xml/src/Flow/ETL/Adapter/XML/XMLReaderExtractor.php b/src/adapter/etl-adapter-xml/src/Flow/ETL/Adapter/XML/XMLReaderExtractor.php index f41e7b0ef..a6541813b 100644 --- a/src/adapter/etl-adapter-xml/src/Flow/ETL/Adapter/XML/XMLReaderExtractor.php +++ b/src/adapter/etl-adapter-xml/src/Flow/ETL/Adapter/XML/XMLReaderExtractor.php @@ -17,7 +17,6 @@ use Flow\ETL\Extractor\Signal; use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; -use Flow\ETL\Row\RawRowValues; use Flow\ETL\Rows; use Flow\ETL\Schema; use Flow\Filesystem\Filesystem; @@ -112,6 +111,7 @@ public function extract(FlowContext $context, ?int $limit = null, Filter $pathFi $fileColumns = $this->fileColumns($this->filesystem, $this->path); $schema = $fileColumns->declare($baseSchema); + $body = $fileColumns->withoutTail($schema); foreach ($this->sourceFiles($this->filesystem, $this->path, $pathFilter) as $source) { $constants = $fileColumns->forFile($source, $schema); @@ -152,16 +152,13 @@ public function extract(FlowContext $context, ?int $limit = null, Filter $pathFi $rawNodes[] = $node === false ? '' : (string) $dom->saveXML($node); if (count($rawNodes) >= $batchSize) { - $batch = []; - - foreach ($encoder->decode($rawNodes) as $rowValues) { - $batch[] = new RawRowValues($constants->fill($rowValues->values)); - } + $hydrated = $constants->fillRows( + $hydrator->hydrate($encoder->decode($rawNodes), $body), + $schema, + ); $rawNodes = []; - $hydrated = $hydrator->hydrate($batch, $schema); - $yielded += $hydrated->count(); $signal = yield $hydrated; @@ -181,13 +178,7 @@ public function extract(FlowContext $context, ?int $limit = null, Filter $pathFi } if ($rawNodes !== []) { - $batch = []; - - foreach ($encoder->decode($rawNodes) as $rowValues) { - $batch[] = new RawRowValues($constants->fill($rowValues->values)); - } - - $hydrated = $hydrator->hydrate($batch, $schema); + $hydrated = $constants->fillRows($hydrator->hydrate($encoder->decode($rawNodes), $body), $schema); $yielded += $hydrated->count(); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Double/FakeRandomOrdersExtractor.php b/src/core/etl/tests/Flow/ETL/Tests/Double/FakeRandomOrdersExtractor.php index 6d8db4a73..fc44c791f 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Double/FakeRandomOrdersExtractor.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Double/FakeRandomOrdersExtractor.php @@ -16,6 +16,7 @@ use function array_map; use function count; use function Flow\ETL\DSL\array_to_rows; +use function Flow\ETL\DSL\date_schema; use function Flow\ETL\DSL\datetime_schema; use function Flow\ETL\DSL\float_schema; use function Flow\ETL\DSL\list_schema; @@ -69,6 +70,7 @@ public function schema(): Schema 'price' => type_float(), ])), ), + date_schema('ordered_on'), ); } @@ -147,6 +149,7 @@ public function rawData(): Generator ], range(1, $faker->numberBetween(1, 4)), ), + 'ordered_on' => $createdAt->setTime(0, 0), ]; if ($signal === Signal::STOP) { diff --git a/src/extension/flow-php-ext/src/cast.rs b/src/extension/flow-php-ext/src/cast.rs index 363b0bc2d..6118e70e0 100644 --- a/src/extension/flow-php-ext/src/cast.rs +++ b/src/extension/flow-php-ext/src/cast.rs @@ -11,6 +11,7 @@ use crate::ctx::{ ce_method_ref, construct_with_zvals, ht_find_key, ht_insert, ht_insert_key, null_zval, schema_mismatch, transparent_exception, write_slot, zval_long, Ctx, HtKey, }; +use crate::date_check::{iso_date_gate, iso_date_time_gate}; use crate::encode::{expect_object, ht_for_each, read_slot}; use crate::exception::ext_exception; use crate::format::Reader; @@ -20,6 +21,7 @@ use crate::hydrate::{ }; use crate::json_check::json_valid; use crate::plan::{parse_schema_json, Plan, TypeJson}; +use crate::uuid_check::is_uuid; use crate::values::date_from_free_form; extern "C" { @@ -312,18 +314,6 @@ fn bool_from_str(bytes: &[u8]) -> Option { } } -/// The `Uuid::UUID_REGEXP` pattern: 8-4-4-4-12 LOWERCASE hex groups. -fn is_uuid(bytes: &[u8]) -> bool { - if bytes.len() != 36 { - return false; - } - - bytes.iter().enumerate().all(|(index, byte)| match index { - 8 | 13 | 18 | 23 => *byte == b'-', - _ => matches!(byte, b'0'..=b'9' | b'a'..=b'f'), - }) -} - /// The cheap prefix of `Json::isValid`: non-empty + matching `{}`/`[]` pair. fn json_gate(bytes: &[u8]) -> bool { bytes.len() >= 2 @@ -331,78 +321,6 @@ fn json_gate(bytes: &[u8]) -> bool { || (bytes[0] == b'[' && bytes[bytes.len() - 1] == b']')) } -/// `DateTimeType::ISO_DATE_TIME` followed by `checkdate()`: true only when PHP takes its -/// `new DateTimeImmutable($value)` branch without consulting StringTemporalParts. -fn iso_date_time_gate(bytes: &[u8]) -> bool { - // PCRE `$` without the D modifier also matches before one final "\n" - let bytes = bytes.strip_suffix(b"\n").unwrap_or(bytes); - let byte_at = |at: usize| bytes.get(at).copied(); - let digits_at = |at: usize, count: usize| { - bytes - .get(at..at + count) - .is_some_and(|run| run.iter().all(u8::is_ascii_digit)) - }; - - if !(digits_at(0, 4) - && byte_at(4) == Some(b'-') - && digits_at(5, 2) - && byte_at(7) == Some(b'-') - && digits_at(8, 2) - && matches!(byte_at(10), Some(b'T' | b' ')) - && digits_at(11, 2) - && byte_at(13) == Some(b':') - && digits_at(14, 2)) - { - return false; - } - - let mut at = 16; - - if byte_at(at) == Some(b':') && digits_at(at + 1, 2) { - at += 3; - - if byte_at(at) == Some(b'.') { - let fraction = bytes[at + 1..].iter().take_while(|byte| byte.is_ascii_digit()).count(); - - if !(1..=9).contains(&fraction) { - return false; - } - - at += 1 + fraction; - } - } - - match byte_at(at) { - Some(b'Z') => at += 1, - Some(b'+' | b'-') if digits_at(at + 1, 2) => { - at += 3; - - if byte_at(at) == Some(b':') && digits_at(at + 1, 2) { - at += 3; - } else if digits_at(at, 2) { - at += 2; - } - } - _ => {} - } - - let number = |range: std::ops::Range| { - bytes[range] - .iter() - .fold(0u32, |value, digit| value * 10 + u32::from(digit - b'0')) - }; - let (year, month, day) = (number(0..4), number(5..7), number(8..10)); - let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); - let days_in_month = match month { - 2 if leap => 29, - 2 => 28, - 4 | 6 | 9 | 11 => 30, - _ => 31, - }; - - at == bytes.len() && year >= 1 && (1..=12).contains(&month) && (1..=days_in_month).contains(&day) -} - /// `source` is a gated JSON string; the Json shares its zend_string instead of copying the bytes. fn json_object_from(source: &Zval, ctx: &mut Ctx) -> Result { let bytes = source @@ -596,13 +514,10 @@ fn cast_value(kind: &CastKind, value: &Zval, ctx: &mut Ctx) -> Result Result, PhpException> return set_midnight(set_time, object); } - if value.is_string() { - // DateType::cast has no ISO branch: every string is gated on StringTemporalParts, a PHP class - return Ok(None); + if let Some(string) = value.zend_str() { + return if iso_date_gate(string.as_bytes()) { + date_from_free_form(string.as_bytes(), None, ctx) + } else { + Ok(None) + }; } let parsed = if value.is_long() || value.is_double() { diff --git a/src/extension/flow-php-ext/src/csv/fold.rs b/src/extension/flow-php-ext/src/csv/fold.rs index d963b4131..4075aa872 100644 --- a/src/extension/flow-php-ext/src/csv/fold.rs +++ b/src/extension/flow-php-ext/src/csv/fold.rs @@ -12,8 +12,10 @@ use ext_php_rs::types::{ZendHashTable, Zval}; use crate::ctx::{call_handle, zval_str, Ctx}; use crate::csv::php_trim; use crate::csv::tokenizer::is_space; +use crate::date_check::{checkdate, iso_date_gate, iso_date_time_gate}; use crate::exception::ext_exception; use crate::json_check::json_valid; +use crate::uuid_check::is_uuid; #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Leaf { @@ -198,6 +200,14 @@ impl Narrower { /// `StringTemporalParts::from()` past its `hasExplicitDay()` gate: `Date`, `DateTime`, or `None` when the value /// is not a calendar date. fn temporal(&mut self, value: &[u8]) -> Result, PhpException> { + if iso_date_time_gate(value) { + return Ok(Some(Leaf::DateTime)); + } + + if iso_date_gate(value) { + return Ok(Some(Leaf::Date)); + } + let parts_zv = call_handle(self.ctx.date_parse()?, None, &mut [zval_str(value)], "parse a temporal cell")?; let parts = parts_zv .array() @@ -254,15 +264,6 @@ fn is_json_shaped(value: &[u8]) -> bool { matches!((value.first(), value.last()), (Some(b'{'), Some(b'}')) | (Some(b'['), Some(b']'))) } -/// `Uuid::isValid()`: 36 bytes, `/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/`. -fn is_uuid(value: &[u8]) -> bool { - value.len() == 36 - && value.iter().enumerate().all(|(index, byte)| match index { - 8 | 13 | 18 | 23 => *byte == b'-', - _ => byte.is_ascii_digit() || (b'a'..=b'f').contains(byte), - }) -} - /// PHP 8 `is_numeric()` on a string: optional leading and trailing whitespace, optional sign, /// `digits[.digits] | .digits | digits.`, optional exponent. No hex, no `INF`/`NAN`. fn is_numeric(value: &[u8]) -> bool { @@ -345,23 +346,6 @@ fn is_offset(value: &[u8]) -> bool { matches!(value, [b'+' | b'-', h1, h2, b':', m1, m2] if [h1, h2, m1, m2].iter().all(|byte| byte.is_ascii_digit())) } -/// `checkdate()`. -fn checkdate(month: i64, day: i64, year: i64) -> bool { - if !(1..=32767).contains(&year) || !(1..=12).contains(&month) || day < 1 { - return false; - } - - let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0; - let days = match month { - 2 if leap => 29, - 2 => 28, - 4 | 6 | 9 | 11 => 30, - _ => 31, - }; - - day <= days -} - struct Column { name: Vec, kind: Kind, diff --git a/src/extension/flow-php-ext/src/date_check.rs b/src/extension/flow-php-ext/src/date_check.rs new file mode 100644 index 000000000..23e0c211a --- /dev/null +++ b/src/extension/flow-php-ext/src/date_check.rs @@ -0,0 +1,102 @@ +/// `DateTimeType::ISO_DATE_TIME` + `checkdate()`. +pub fn iso_date_time_gate(bytes: &[u8]) -> bool { + // PCRE `$` without the D modifier also matches before one final "\n" + let bytes = bytes.strip_suffix(b"\n").unwrap_or(bytes); + let byte_at = |at: usize| bytes.get(at).copied(); + let number_at = |at: usize, max: u32| { + bytes + .get(at..at + 2) + .filter(|run| run.iter().all(u8::is_ascii_digit)) + .is_some_and(|run| u32::from(run[0] - b'0') * 10 + u32::from(run[1] - b'0') <= max) + }; + + if !(iso_date_prefix(bytes) + && matches!(byte_at(10), Some(b'T' | b' ')) + && number_at(11, 24) + && byte_at(13) == Some(b':') + && number_at(14, 59)) + { + return false; + } + + let mut at = 16; + + if byte_at(at) == Some(b':') && number_at(at + 1, 60) { + at += 3; + + if byte_at(at) == Some(b'.') { + let fraction = bytes[at + 1..].iter().take_while(|byte| byte.is_ascii_digit()).count(); + + if !(1..=9).contains(&fraction) { + return false; + } + + at += 1 + fraction; + } + } + + match byte_at(at) { + Some(b'Z') => at += 1, + Some(b'+' | b'-') if number_at(at + 1, 99) => { + at += 3; + + // a bare ±hh takes any two digits; the hour of ±hhmm and ±hh:mm stops at 24 + let colon = usize::from(byte_at(at) == Some(b':')); + + if number_at(at + colon, 59) && number_at(at - 2, 24) { + at += colon + 2; + } + } + _ => {} + } + + at == bytes.len() +} + +/// `DateType::ISO_DATE` + `checkdate()`. +pub fn iso_date_gate(bytes: &[u8]) -> bool { + let bytes = bytes.strip_suffix(b"\n").unwrap_or(bytes); + + bytes.len() == 10 && iso_date_prefix(bytes) +} + +fn iso_date_prefix(bytes: &[u8]) -> bool { + let digits_at = |at: usize, count: usize| { + bytes + .get(at..at + count) + .is_some_and(|run| run.iter().all(u8::is_ascii_digit)) + }; + + if !(digits_at(0, 4) + && bytes.get(4) == Some(&b'-') + && digits_at(5, 2) + && bytes.get(7) == Some(&b'-') + && digits_at(8, 2)) + { + return false; + } + + let number = |range: std::ops::Range| { + bytes[range] + .iter() + .fold(0i64, |value, digit| value * 10 + i64::from(digit - b'0')) + }; + + checkdate(number(5..7), number(8..10), number(0..4)) +} + +pub fn checkdate(month: i64, day: i64, year: i64) -> bool { + if !(1..=32767).contains(&year) || !(1..=12).contains(&month) || day < 1 { + return false; + } + + let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0; + let days = match month { + 2 if leap => 29, + 2 => 28, + 4 | 6 | 9 | 11 => 30, + _ => 31, + }; + + day <= days +} diff --git a/src/extension/flow-php-ext/src/lib.rs b/src/extension/flow-php-ext/src/lib.rs index 0f2b69dba..82ee3ab54 100644 --- a/src/extension/flow-php-ext/src/lib.rs +++ b/src/extension/flow-php-ext/src/lib.rs @@ -1,12 +1,14 @@ mod cast; mod csv; mod ctx; +mod date_check; mod encode; mod exception; mod format; mod hydrate; mod json_check; mod plan; +mod uuid_check; mod values; use std::alloc::System; diff --git a/src/extension/flow-php-ext/src/uuid_check.rs b/src/extension/flow-php-ext/src/uuid_check.rs new file mode 100644 index 000000000..f97280142 --- /dev/null +++ b/src/extension/flow-php-ext/src/uuid_check.rs @@ -0,0 +1,8 @@ +/// `Uuid::isValid()`: 36 bytes, `/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/`. +pub fn is_uuid(value: &[u8]) -> bool { + value.len() == 36 + && value.iter().enumerate().all(|(index, byte)| match index { + 8 | 13 | 18 | 23 => *byte == b'-', + _ => byte.is_ascii_digit() || (b'a'..=b'f').contains(byte), + }) +} diff --git a/src/extension/flow-php-ext/tests/phpt/046_csv_narrow_parity.phpt b/src/extension/flow-php-ext/tests/phpt/046_csv_narrow_parity.phpt index 33c146000..2f17aeaaf 100644 --- a/src/extension/flow-php-ext/tests/phpt/046_csv_narrow_parity.phpt +++ b/src/extension/flow-php-ext/tests/phpt/046_csv_narrow_parity.phpt @@ -6,16 +6,106 @@ native narrowing matches StringTypeNarrower::narrow() on its fixtures, the byte- = 80); var_dump(count($corpus) >= 300); assert_narrow_parity('default candidates', InferredTypes::default()->toArray(), $corpus); + +// without integer, '20240305' reaches the temporal rung +$temporal = [ + '2026-01-02T03:04', + '2026-01-02 03:04', + '2026-01-02T03:04:05', + '2026-01-02T03:04:05.1', + '2026-01-02T03:04:05.123456789', + '2026-01-02T03:04:05.1234567890', + '2026-01-02T03:04:05Z', + '2026-01-02T03:04:05+02', + '2026-01-02T03:04+99', + '2026-01-02T03:04:05-0230', + '2026-01-02T03:04:05.5+02:00', + '2026-01-02T03:04+24:59', + '2024-02-29T00:00', + '2026-01-02T24:00', + '2026-01-02T03:04:60', + '2026-01-02T25:00', + '2026-01-02T03:60', + '2026-01-02T03:04:61', + '2026-01-02T03:04+25:00', + '2026-01-02T03:04+0060', + '2026-02-30T03:04', + '2026-13-01T03:04', + '2026-01-02', + '2024-02-29', + '2026-02-30', + '2026-13-01', + '0000-01-01', + '2024-01', + 'March 5, 2024', + '05/03/2024', + '02-Jun-2022', + '20240305', + '12345678', + '2024-03-05 noon', + 'tomorrow 2024-03-05', + 'now', +]; +$candidates = [type_date(), type_datetime(), type_string()]; +$native = new RustColumnFoldNative([], ['date', 'datetime', 'string']); + +foreach ($temporal as $value) { + printf("%-32s %s\n", $value, $native->narrowOne($value)); +} + +assert_narrow_parity('temporal candidates', $candidates, $temporal); ?> --EXPECT-- bool(true) bool(true) default candidates: identical +2026-01-02T03:04 datetime +2026-01-02 03:04 datetime +2026-01-02T03:04:05 datetime +2026-01-02T03:04:05.1 datetime +2026-01-02T03:04:05.123456789 datetime +2026-01-02T03:04:05.1234567890 datetime +2026-01-02T03:04:05Z datetime +2026-01-02T03:04:05+02 datetime +2026-01-02T03:04+99 datetime +2026-01-02T03:04:05-0230 datetime +2026-01-02T03:04:05.5+02:00 datetime +2026-01-02T03:04+24:59 datetime +2024-02-29T00:00 datetime +2026-01-02T24:00 datetime +2026-01-02T03:04:60 datetime +2026-01-02T25:00 string +2026-01-02T03:60 string +2026-01-02T03:04:61 string +2026-01-02T03:04+25:00 string +2026-01-02T03:04+0060 string +2026-02-30T03:04 string +2026-13-01T03:04 string +2026-01-02 date +2024-02-29 date +2026-02-30 string +2026-13-01 string +0000-01-01 string +2024-01 string +March 5, 2024 date +05/03/2024 date +02-Jun-2022 date +20240305 date +12345678 string +2024-03-05 noon datetime +tomorrow 2024-03-05 datetime +now string +temporal candidates: identical diff --git a/src/extension/flow-php-ext/tests/phpt/061_date_iso_fast_path.phpt b/src/extension/flow-php-ext/tests/phpt/061_date_iso_fast_path.phpt new file mode 100644 index 000000000..accf01af2 --- /dev/null +++ b/src/extension/flow-php-ext/tests/phpt/061_date_iso_fast_path.phpt @@ -0,0 +1,102 @@ +--TEST-- +ISO date strings: both hydrators build the object new DateTimeImmutable($value) builds, for date and datetime columns +--SKIPIF-- + +--FILE-- + $value, 'at' => $value])]; + $expected = serialize(new DateTimeImmutable($value)); + $phpRow = $php->hydrate($batch, $schema)->first(); + $nativeRow = $native->hydrate($batch, $schema)->first(); + + printf( + "%-14s %s %s date php:%s native:%s datetime php:%s native:%s\n", + json_encode($value), + $nativeRow->get('on')->format('Y-m-d H:i:s'), + $nativeRow->get('on')->getTimezone()->getName(), + serialize($phpRow->get('on')) === $expected ? 'yes' : 'NO', + serialize($nativeRow->get('on')) === $expected ? 'yes' : 'NO', + serialize($phpRow->get('at')) === $expected ? 'yes' : 'NO', + serialize($nativeRow->get('at')) === $expected ? 'yes' : 'NO', + ); + } +} + +foreach (['2026-02-30', '2026-13-01', '0000-01-01'] as $value) { + foreach (['on' => date_schema('on'), 'at' => datetime_schema('at')] as $column => $definition) { + $batch = [new RawRowValues([$column => $value])]; + + foreach (['php' => $php, 'native' => $native] as $label => $hydrator) { + try { + $hydrator->hydrate($batch, schema($definition)); + echo "{$label} {$column} {$value}: FAIL no exception\n"; + } catch (Throwable $e) { + echo "{$label} {$column} {$value}: ", $e::class, "\n"; + } + } + } +} +?> +--EXPECT-- +date.timezone UTC +"2026-01-02" 2026-01-02 00:00:00 UTC date php:yes native:yes datetime php:yes native:yes +"2024-02-29" 2024-02-29 00:00:00 UTC date php:yes native:yes datetime php:yes native:yes +"2026-01-02\n" 2026-01-02 00:00:00 UTC date php:yes native:yes datetime php:yes native:yes +"0001-01-01" 0001-01-01 00:00:00 UTC date php:yes native:yes datetime php:yes native:yes +"9999-12-31" 9999-12-31 00:00:00 UTC date php:yes native:yes datetime php:yes native:yes +"2026-09-06" 2026-09-06 00:00:00 UTC date php:yes native:yes datetime php:yes native:yes +date.timezone Europe/Warsaw +"2026-01-02" 2026-01-02 00:00:00 Europe/Warsaw date php:yes native:yes datetime php:yes native:yes +"2024-02-29" 2024-02-29 00:00:00 Europe/Warsaw date php:yes native:yes datetime php:yes native:yes +"2026-01-02\n" 2026-01-02 00:00:00 Europe/Warsaw date php:yes native:yes datetime php:yes native:yes +"0001-01-01" 0001-01-01 00:00:00 Europe/Warsaw date php:yes native:yes datetime php:yes native:yes +"9999-12-31" 9999-12-31 00:00:00 Europe/Warsaw date php:yes native:yes datetime php:yes native:yes +"2026-09-06" 2026-09-06 00:00:00 Europe/Warsaw date php:yes native:yes datetime php:yes native:yes +date.timezone America/Santiago +"2026-01-02" 2026-01-02 00:00:00 America/Santiago date php:yes native:yes datetime php:yes native:yes +"2024-02-29" 2024-02-29 00:00:00 America/Santiago date php:yes native:yes datetime php:yes native:yes +"2026-01-02\n" 2026-01-02 00:00:00 America/Santiago date php:yes native:yes datetime php:yes native:yes +"0001-01-01" 0001-01-01 00:00:00 America/Santiago date php:yes native:yes datetime php:yes native:yes +"9999-12-31" 9999-12-31 00:00:00 America/Santiago date php:yes native:yes datetime php:yes native:yes +"2026-09-06" 2026-09-06 01:00:00 America/Santiago date php:yes native:yes datetime php:yes native:yes +php on 2026-02-30: Flow\ETL\Exception\SchemaMismatchException +native on 2026-02-30: Flow\ETL\Exception\SchemaMismatchException +php at 2026-02-30: Flow\ETL\Exception\SchemaMismatchException +native at 2026-02-30: Flow\ETL\Exception\SchemaMismatchException +php on 2026-13-01: Flow\ETL\Exception\SchemaMismatchException +native on 2026-13-01: Flow\ETL\Exception\SchemaMismatchException +php at 2026-13-01: Flow\ETL\Exception\SchemaMismatchException +native at 2026-13-01: Flow\ETL\Exception\SchemaMismatchException +php on 0000-01-01: Flow\ETL\Exception\SchemaMismatchException +native on 0000-01-01: Flow\ETL\Exception\SchemaMismatchException +php at 0000-01-01: Flow\ETL\Exception\SchemaMismatchException +native at 0000-01-01: Flow\ETL\Exception\SchemaMismatchException diff --git a/src/lib/types/src/Flow/Types/Type/Logical/DateTimeType.php b/src/lib/types/src/Flow/Types/Type/Logical/DateTimeType.php index 39cd20a9c..5ece91725 100644 --- a/src/lib/types/src/Flow/Types/Type/Logical/DateTimeType.php +++ b/src/lib/types/src/Flow/Types/Type/Logical/DateTimeType.php @@ -30,11 +30,7 @@ */ final readonly class DateTimeType implements Type { - /** - * A date, its day spelled out, and a time: every string this matches with a real calendar day is one - * StringTemporalParts would accept, and the constructor rejects the rest just as it would after that check. - */ - private const string ISO_DATE_TIME = '/^(\d{4})-(\d{2})-(\d{2})[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(Z|[+-]\d{2}(?::?\d{2})?)?$/'; + public const string ISO_DATE_TIME = '/^(\d{4})-(\d{2})-(\d{2})[T ](?:[01]\d|2[0-4]):[0-5]\d(?::(?:[0-5]\d|60)(?:\.\d{1,9})?)?(Z|[+-](?:(?:[01]\d|2[0-4]):?[0-5]\d|\d{2}))?$/'; public function assert(mixed $value): DateTimeInterface { @@ -78,6 +74,10 @@ public function cast(mixed $value): DateTimeInterface return new DateTimeImmutable($value); } + if (StringTemporalParts::isoDate($value)) { + return new DateTimeImmutable($value); + } + $parts = StringTemporalParts::from($value); if (!$parts->isDate() && !$parts->isDateTime()) { diff --git a/src/lib/types/src/Flow/Types/Type/Logical/DateType.php b/src/lib/types/src/Flow/Types/Type/Logical/DateType.php index f411860f4..6cb083636 100644 --- a/src/lib/types/src/Flow/Types/Type/Logical/DateType.php +++ b/src/lib/types/src/Flow/Types/Type/Logical/DateType.php @@ -26,6 +26,8 @@ */ final readonly class DateType implements Type { + public const string ISO_DATE = '/^(\d{4})-(\d{2})-(\d{2})$/'; + public function assert(mixed $value): DateTimeInterface { if ($this->isValid($value)) { @@ -51,6 +53,10 @@ public function cast(mixed $value): DateTimeInterface } if (is_string($value)) { + if (StringTemporalParts::isoDate($value)) { + return new DateTimeImmutable($value); + } + $parts = StringTemporalParts::from($value); if (!$parts->isDate() && !$parts->isDateTime()) { diff --git a/src/lib/types/src/Flow/Types/Type/Native/String/StringTemporalParts.php b/src/lib/types/src/Flow/Types/Type/Native/String/StringTemporalParts.php index 1345688ef..161a4f4f5 100644 --- a/src/lib/types/src/Flow/Types/Type/Native/String/StringTemporalParts.php +++ b/src/lib/types/src/Flow/Types/Type/Native/String/StringTemporalParts.php @@ -4,6 +4,9 @@ namespace Flow\Types\Type\Native\String; +use Flow\Types\Type\Logical\DateTimeType; +use Flow\Types\Type\Logical\DateType; + use function checkdate; use function date_parse; use function Flow\Types\DSL\type_integer; @@ -15,15 +18,23 @@ { public function __construct( private bool $calendarDate, - private bool $explicitDay, private bool $time, ) {} - /** - * One date_parse() for both temporal rungs; running it per rung cost ~90% of narrow() on non-temporal cells. - */ public static function from(string $value): self { + if (!self::hasExplicitDay($value)) { + return new self(false, false); + } + + if (self::isoDateTime($value)) { + return new self(true, true); + } + + if (self::isoDate($value)) { + return new self(true, false); + } + $parts = date_parse($value); if ( @@ -33,7 +44,7 @@ public static function from(string $value): self || $parts['day'] === false || !checkdate((int) $parts['month'], (int) $parts['day'], (int) $parts['year']) ) { - return new self(false, false, false); + return new self(false, false); } $time = @@ -49,17 +60,29 @@ public static function from(string $value): self ($relative['hour'] ?? 0) !== 0 || ($relative['minute'] ?? 0) !== 0 || ($relative['second'] ?? 0) !== 0; } - return new self(true, self::hasExplicitDay($value), $time); + return new self(true, $time); + } + + public static function isoDateTime(string $value): bool + { + $iso = []; + + return ( + preg_match(DateTimeType::ISO_DATE_TIME, $value, $iso) === 1 + && checkdate((int) $iso[2], (int) $iso[3], (int) $iso[1]) + ); + } + + public static function isoDate(string $value): bool + { + $iso = []; + + return ( + preg_match(DateType::ISO_DATE, $value, $iso) === 1 + && checkdate((int) $iso[2], (int) $iso[3], (int) $iso[1]) + ); } - /** - * date_parse() defaults a missing day to 1, so '2024-01' is indistinguishable from '2024-01-01' by its parts - * alone and a month-precision column would be typed date with a fabricated day. The day has to be read back - * out of the input: three numeric groups, or two plus a spelled-out month ('02-Jun-2022'). - * - * Compact ISO ('20240305') is one group and a real calendar date; from()'s checkdate() gate keeps '12345678' - * and friends out, so the widening is exactly that one form. - */ public static function hasExplicitDay(string $value): bool { $numericGroups = (int) preg_match_all('/\d+/', $value); @@ -77,11 +100,11 @@ public static function hasExplicitDay(string $value): bool public function isDate(): bool { - return $this->calendarDate && $this->explicitDay && !$this->time; + return $this->calendarDate && !$this->time; } public function isDateTime(): bool { - return $this->calendarDate && $this->explicitDay && $this->time; + return $this->calendarDate && $this->time; } } diff --git a/src/lib/types/tests/Flow/Types/Tests/Unit/Type/Logical/DateTimeTypeTest.php b/src/lib/types/tests/Flow/Types/Tests/Unit/Type/Logical/DateTimeTypeTest.php index 6fa469b1b..4ad9a418b 100644 --- a/src/lib/types/tests/Flow/Types/Tests/Unit/Type/Logical/DateTimeTypeTest.php +++ b/src/lib/types/tests/Flow/Types/Tests/Unit/Type/Logical/DateTimeTypeTest.php @@ -229,6 +229,26 @@ public function test_an_iso_date_time_off_the_calendar_or_clock_is_refused(strin type_datetime()->cast($value); } + #[TestWith(['2026-01-02'])] + #[TestWith(['2024-02-29'])] + #[TestWith(["2026-01-02\n"])] + #[TestWith(['0001-01-01'])] + #[TestWith(['9999-12-31'])] + public function test_an_iso_date_casts_to_the_object_the_constructor_builds(string $value): void + { + static::assertSame(serialize(new DateTimeImmutable($value)), serialize(type_datetime()->cast($value))); + } + + #[TestWith(['2026-02-30'])] + #[TestWith(['2026-13-01'])] + #[TestWith(['0000-01-01'])] + public function test_an_iso_date_off_the_calendar_is_refused(string $value): void + { + $this->expectException(CastingException::class); + + type_datetime()->cast($value); + } + /** * @param null|class-string<\Throwable> $exceptionClass */ diff --git a/src/lib/types/tests/Flow/Types/Tests/Unit/Type/Logical/DateTypeTest.php b/src/lib/types/tests/Flow/Types/Tests/Unit/Type/Logical/DateTypeTest.php index f00d79bbe..3955d1f2a 100644 --- a/src/lib/types/tests/Flow/Types/Tests/Unit/Type/Logical/DateTypeTest.php +++ b/src/lib/types/tests/Flow/Types/Tests/Unit/Type/Logical/DateTypeTest.php @@ -175,6 +175,26 @@ public function test_a_compact_iso_date_casts(): void static::assertSame('2024-03-05', type_date()->cast('20240305')->format('Y-m-d')); } + #[TestWith(['2026-01-02'])] + #[TestWith(['2024-02-29'])] + #[TestWith(["2026-01-02\n"])] + #[TestWith(['0001-01-01'])] + #[TestWith(['9999-12-31'])] + public function test_an_iso_date_casts_to_the_object_the_constructor_builds(string $value): void + { + static::assertSame(serialize(new DateTimeImmutable($value)), serialize(type_date()->cast($value))); + } + + #[TestWith(['2026-02-30'])] + #[TestWith(['2026-13-01'])] + #[TestWith(['0000-01-01'])] + public function test_an_iso_date_off_the_calendar_is_refused(string $value): void + { + $this->expectException(CastingException::class); + + type_date()->cast($value); + } + /** * @param null|class-string<\Throwable> $exceptionClass */ diff --git a/src/lib/types/tests/Flow/Types/Tests/Unit/Type/Native/String/StringTemporalPartsTest.php b/src/lib/types/tests/Flow/Types/Tests/Unit/Type/Native/String/StringTemporalPartsTest.php index 066ed0771..bd51c50ee 100644 --- a/src/lib/types/tests/Flow/Types/Tests/Unit/Type/Native/String/StringTemporalPartsTest.php +++ b/src/lib/types/tests/Flow/Types/Tests/Unit/Type/Native/String/StringTemporalPartsTest.php @@ -6,6 +6,7 @@ use Flow\Types\Type\Native\String\StringTemporalParts; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; final class StringTemporalPartsTest extends TestCase @@ -27,64 +28,91 @@ public static function inputs(): array ]; } - #[DataProvider('inputs')] - public function test_the_day_has_to_be_present_in_the_input(string $value, bool $explicit): void - { - static::assertSame($explicit, StringTemporalParts::hasExplicitDay($value)); - } - - public function test_a_month_precision_cell_is_neither_a_date_nor_a_datetime(): void - { - $parts = StringTemporalParts::from('2024-01'); - - static::assertFalse($parts->isDate()); - static::assertFalse($parts->isDateTime()); - } - - public function test_a_compact_iso_date_is_a_date(): void - { - $parts = StringTemporalParts::from('20240305'); - - static::assertTrue($parts->isDate()); - static::assertFalse($parts->isDateTime()); - } - - public function test_eight_digits_that_are_not_a_calendar_date_are_neither(): void + /** + * @return array + */ + public static function temporal(): array { - $parts = StringTemporalParts::from('12345678'); - - static::assertFalse($parts->isDate()); - static::assertFalse($parts->isDateTime()); + return [ + 'ISO minutes, T' => ['2026-01-02T03:04', false, true], + 'ISO minutes, space' => ['2026-01-02 03:04', false, true], + 'ISO seconds' => ['2026-01-02T03:04:05', false, true], + 'ISO one fraction digit' => ['2026-01-02T03:04:05.1', false, true], + 'ISO nine fraction digits' => ['2026-01-02T03:04:05.123456789', false, true], + 'ISO ten fraction digits' => ['2026-01-02T03:04:05.1234567890', false, true], + 'ISO Z' => ['2026-01-02T03:04:05Z', false, true], + 'ISO +hh' => ['2026-01-02T03:04:05+02', false, true], + 'ISO bare zone hour beyond 24' => ['2026-01-02T03:04+99', false, true], + 'ISO -hhmm' => ['2026-01-02T03:04:05-0230', false, true], + 'ISO +hh:mm' => ['2026-01-02T03:04:05.5+02:00', false, true], + 'ISO zone at its upper bound' => ['2026-01-02T03:04+24:59', false, true], + 'ISO trailing newline' => ["2026-01-02T03:04:05Z\n", false, true], + 'ISO leap day' => ['2024-02-29T00:00', false, true], + 'ISO hour 24' => ['2026-01-02T24:00', false, true], + 'ISO second 60' => ['2026-01-02T03:04:60', false, true], + 'ISO hour 25' => ['2026-01-02T25:00', false, false], + 'ISO minute 60' => ['2026-01-02T03:60', false, false], + 'ISO second 61' => ['2026-01-02T03:04:61', false, false], + 'ISO zone hour 25' => ['2026-01-02T03:04+25:00', false, false], + 'ISO zone minute 60' => ['2026-01-02T03:04+0060', false, false], + 'ISO datetime on February 30' => ['2026-02-30T03:04', false, false], + 'ISO datetime in month 13' => ['2026-13-01T03:04', false, false], + 'ISO date' => ['2026-01-02', true, false], + 'ISO date, trailing newline' => ["2026-01-02\n", true, false], + 'ISO date, leap day' => ['2024-02-29', true, false], + 'ISO date, February 30' => ['2026-02-30', false, false], + 'ISO date, month 13' => ['2026-13-01', false, false], + 'ISO date, year zero' => ['0000-01-01', false, false], + 'month precision' => ['2024-01', false, false], + 'spelled-out month' => ['March 5, 2024', true, false], + 'slashed' => ['05/03/2024', true, false], + 'day-month-year' => ['02-Jun-2022', true, false], + 'compact ISO' => ['20240305', true, false], + 'eight digits that are not a date' => ['12345678', false, false], + 'relative time word' => ['2024-03-05 noon', false, true], + 'relative day word' => ['tomorrow 2024-03-05', false, true], + 'relative time' => ['2023-01-01 +10 hours', false, true], + 'wall clock' => ['now', false, false], + 'words' => ['not a date', false, false], + 'slashed month 13' => ['2021/13/01', false, false], + 'empty' => ['', false, false], + ]; } - public function test_a_calendar_date_is_a_date_and_not_a_datetime(): void + #[DataProvider('temporal')] + public function test_date_and_datetime_verdicts(string $value, bool $date, bool $dateTime): void { - $parts = StringTemporalParts::from('2024-01-01'); + $parts = StringTemporalParts::from($value); - static::assertTrue($parts->isDate()); - static::assertFalse($parts->isDateTime()); + static::assertSame($date, $parts->isDate()); + static::assertSame($dateTime, $parts->isDateTime()); } - public function test_a_date_carrying_a_time_is_a_datetime_and_not_a_date(): void + #[TestWith(['2026-01-02T03:04', true])] + #[TestWith(['2026-01-02 03:04:05.123456789+02:00', true])] + #[TestWith(["2026-01-02T03:04:05Z\n", true])] + #[TestWith(['2026-02-30T03:04', false])] + #[TestWith(['2026-01-02T25:00', false])] + #[TestWith(['2026-01-02', false])] + public function test_iso_date_time_gate(string $value, bool $expected): void { - $parts = StringTemporalParts::from('2024-01-01 10:00'); - - static::assertTrue($parts->isDateTime()); - static::assertFalse($parts->isDate()); + static::assertSame($expected, StringTemporalParts::isoDateTime($value)); } - public function test_a_relative_time_counts_as_a_time(): void + #[TestWith(['2026-01-02', true])] + #[TestWith(["2026-01-02\n", true])] + #[TestWith(['2024-02-29', true])] + #[TestWith(['2026-02-30', false])] + #[TestWith(['0000-01-01', false])] + #[TestWith(['2026-01-02T03:04', false])] + public function test_iso_date_gate(string $value, bool $expected): void { - static::assertTrue(StringTemporalParts::from('2023-01-01 +10 hours')->isDateTime()); + static::assertSame($expected, StringTemporalParts::isoDate($value)); } - public function test_an_unparseable_value_is_neither(): void + #[DataProvider('inputs')] + public function test_the_day_has_to_be_present_in_the_input(string $value, bool $explicit): void { - foreach (['not a date', '2021-13-01', ''] as $value) { - $parts = StringTemporalParts::from($value); - - static::assertFalse($parts->isDate(), $value); - static::assertFalse($parts->isDateTime(), $value); - } + static::assertSame($explicit, StringTemporalParts::hasExplicitDay($value)); } }