From 2dd127e0d8d4f40939bf0a5967f0e99101f8e26c Mon Sep 17 00:00:00 2001 From: Norbert Orzechowicz Date: Thu, 24 Sep 2026 11:24:37 +0200 Subject: [PATCH] perf: build Z-suffixed ISO datetimes without timelib's abbreviation lookup - cast Z values with a cached DateTimeZone('Z') in DateTimeType and the extension - write Z datetimes in the Orders CSV benchmark fixture --- benchmarks/src/Datasets/OrdersDataset.php | 5 +- src/extension/flow-php-ext/src/cast.rs | 23 ++- src/extension/flow-php-ext/src/values.rs | 5 +- .../phpt/055_datetime_iso_fast_path.phpt | 138 ++++++++++++++++++ .../Flow/Types/Type/Logical/DateTimeType.php | 12 +- .../Unit/Type/Logical/DateTimeTypeTest.php | 23 ++- 6 files changed, 194 insertions(+), 12 deletions(-) create mode 100644 src/extension/flow-php-ext/tests/phpt/055_datetime_iso_fast_path.phpt diff --git a/benchmarks/src/Datasets/OrdersDataset.php b/benchmarks/src/Datasets/OrdersDataset.php index 2cf15cb0a..3cf1997f3 100644 --- a/benchmarks/src/Datasets/OrdersDataset.php +++ b/benchmarks/src/Datasets/OrdersDataset.php @@ -32,7 +32,10 @@ public function csv(): string $fixture->prune(); - data_frame()->read(from_parquet($this->parquet()))->write(to_csv($fixture->path()))->run(); + data_frame() + ->read(from_parquet($this->parquet())) + ->write(to_csv($fixture->path())->withDateTimeFormat('Y-m-d\TH:i:sp')) + ->run(); return $fixture->path(); } diff --git a/src/extension/flow-php-ext/src/cast.rs b/src/extension/flow-php-ext/src/cast.rs index 7a849804b..f2f7baad2 100644 --- a/src/extension/flow-php-ext/src/cast.rs +++ b/src/extension/flow-php-ext/src/cast.rs @@ -595,14 +595,25 @@ fn cast_value(kind: &CastKind, value: &Zval, ctx: &mut Ctx) -> Result Result, PhpException> } let parsed = if value.is_long() || value.is_double() { - date_from_free_form(×tamp_string(value)?, ctx)? + date_from_free_form(×tamp_string(value)?, None, ctx)? } else { None }; diff --git a/src/extension/flow-php-ext/src/values.rs b/src/extension/flow-php-ext/src/values.rs index d67788421..91dbac450 100644 --- a/src/extension/flow-php-ext/src/values.rs +++ b/src/extension/flow-php-ext/src/values.rs @@ -30,10 +30,11 @@ extern "C" { const PHP_DATE_OBJ_STD_OFFSET: usize = std::mem::size_of::<*const c_void>(); -/// `new DateTimeImmutable($str)` through the same C-level timelib parser, in its +/// `new DateTimeImmutable($str, $timezone)` through the same C-level timelib parser, in its /// non-throwing `date_create()` flavor: `Ok(None)` on parse failure, no exception. pub(crate) fn date_from_free_form( bytes: &[u8], + timezone: Option<&mut Zval>, ctx: &mut Ctx, ) -> Result, PhpException> { let ce = ctx.datetime_fns(false)?.ce; @@ -65,7 +66,7 @@ pub(crate) fn date_from_free_form( time_str.as_mut_ptr().cast::(), time_str.len() - 1, std::ptr::null(), - std::ptr::null_mut(), + timezone.map_or(std::ptr::null_mut(), std::ptr::from_mut), 0, ) }; diff --git a/src/extension/flow-php-ext/tests/phpt/055_datetime_iso_fast_path.phpt b/src/extension/flow-php-ext/tests/phpt/055_datetime_iso_fast_path.phpt new file mode 100644 index 000000000..f153b8b15 --- /dev/null +++ b/src/extension/flow-php-ext/tests/phpt/055_datetime_iso_fast_path.phpt @@ -0,0 +1,138 @@ +--TEST-- +datetime ISO strings: both hydrators build the object new DateTimeImmutable($value) builds, the Z zone included +--SKIPIF-- + +--FILE-- + $value])]; + $expected = new DateTimeImmutable($value); + $phpAt = $php->hydrate($batch, $schema)->first()->get('at'); + $nativeAt = $native->hydrate($batch, $schema)->first()->get('at'); + + printf( + "%-38s %s %s type:%d php:%s native:%s\n", + json_encode($value), + $nativeAt->format('Y-m-d H:i:s.u'), + $nativeAt->getTimezone()->getName(), + json_decode(json_encode($nativeAt), true)['timezone_type'], + serialize($phpAt) === serialize($expected) ? 'yes' : 'NO', + serialize($nativeAt) === serialize($expected) ? 'yes' : 'NO', + ); + } +} + +foreach (['2026-01-02T25:99:99Z', '2026-01-02T03:60:00Z', '2026-01-02T03:04:05+25:00'] as $value) { + $batch = [new RawRowValues(['at' => $value])]; + + foreach (['php' => $php, 'native' => $native] as $label => $hydrator) { + try { + $hydrator->hydrate($batch, $schema); + echo "{$label} {$value}: FAIL no exception\n"; + } catch (Throwable $e) { + echo "{$label} {$value}: ", $e::class, "\n"; + } + } +} +?> +--EXPECT-- +date.timezone UTC +"2026-01-02T03:04:05Z" 2026-01-02 03:04:05.000000 Z type:2 php:yes native:yes +"2026-01-02 03:04:05Z" 2026-01-02 03:04:05.000000 Z type:2 php:yes native:yes +"2026-01-02T03:04Z" 2026-01-02 03:04:00.000000 Z type:2 php:yes native:yes +"2026-01-02T03:04:05Z\n" 2026-01-02 03:04:05.000000 Z type:2 php:yes native:yes +"2026-01-02T03:04:05.1Z" 2026-01-02 03:04:05.100000 Z type:2 php:yes native:yes +"2026-01-02T03:04:05.12Z" 2026-01-02 03:04:05.120000 Z type:2 php:yes native:yes +"2026-01-02T03:04:05.123Z" 2026-01-02 03:04:05.123000 Z type:2 php:yes native:yes +"2026-01-02T03:04:05.1234Z" 2026-01-02 03:04:05.123400 Z type:2 php:yes native:yes +"2026-01-02T03:04:05.12345Z" 2026-01-02 03:04:05.123450 Z type:2 php:yes native:yes +"2026-01-02T03:04:05.123456Z" 2026-01-02 03:04:05.123456 Z type:2 php:yes native:yes +"2026-01-02T03:04:05.1234567Z" 2026-01-02 03:04:05.123456 Z type:2 php:yes native:yes +"2026-01-02T03:04:05.12345678Z" 2026-01-02 03:04:05.123456 Z type:2 php:yes native:yes +"2026-01-02T03:04:05.123456789Z" 2026-01-02 03:04:05.123456 Z type:2 php:yes native:yes +"2026-12-31T23:59:60Z" 2027-01-01 00:00:00.000000 Z type:2 php:yes native:yes +"2026-01-02T24:00:00Z" 2026-01-03 00:00:00.000000 Z type:2 php:yes native:yes +"0001-01-01T00:00:00Z" 0001-01-01 00:00:00.000000 Z type:2 php:yes native:yes +"2026-01-02T03:04:05.123456789+02:00" 2026-01-02 03:04:05.123456 +02:00 type:1 php:yes native:yes +"2026-01-02T03:04:05-0530" 2026-01-02 03:04:05.000000 -05:30 type:1 php:yes native:yes +"2026-01-02T03:04:05-05" 2026-01-02 03:04:05.000000 -05:00 type:1 php:yes native:yes +"2026-01-02T03:04:05+00:00" 2026-01-02 03:04:05.000000 +00:00 type:1 php:yes native:yes +"2026-01-02 03:04" 2026-01-02 03:04:00.000000 UTC type:3 php:yes native:yes +"2026-01-02T03:04:05" 2026-01-02 03:04:05.000000 UTC type:3 php:yes native:yes +"2026-01-02T03:04\n" 2026-01-02 03:04:00.000000 UTC type:3 php:yes native:yes +"2026-03-29T02:30:00" 2026-03-29 02:30:00.000000 UTC type:3 php:yes native:yes +date.timezone Europe/Warsaw +"2026-01-02T03:04:05Z" 2026-01-02 03:04:05.000000 Z type:2 php:yes native:yes +"2026-01-02 03:04:05Z" 2026-01-02 03:04:05.000000 Z type:2 php:yes native:yes +"2026-01-02T03:04Z" 2026-01-02 03:04:00.000000 Z type:2 php:yes native:yes +"2026-01-02T03:04:05Z\n" 2026-01-02 03:04:05.000000 Z type:2 php:yes native:yes +"2026-01-02T03:04:05.1Z" 2026-01-02 03:04:05.100000 Z type:2 php:yes native:yes +"2026-01-02T03:04:05.12Z" 2026-01-02 03:04:05.120000 Z type:2 php:yes native:yes +"2026-01-02T03:04:05.123Z" 2026-01-02 03:04:05.123000 Z type:2 php:yes native:yes +"2026-01-02T03:04:05.1234Z" 2026-01-02 03:04:05.123400 Z type:2 php:yes native:yes +"2026-01-02T03:04:05.12345Z" 2026-01-02 03:04:05.123450 Z type:2 php:yes native:yes +"2026-01-02T03:04:05.123456Z" 2026-01-02 03:04:05.123456 Z type:2 php:yes native:yes +"2026-01-02T03:04:05.1234567Z" 2026-01-02 03:04:05.123456 Z type:2 php:yes native:yes +"2026-01-02T03:04:05.12345678Z" 2026-01-02 03:04:05.123456 Z type:2 php:yes native:yes +"2026-01-02T03:04:05.123456789Z" 2026-01-02 03:04:05.123456 Z type:2 php:yes native:yes +"2026-12-31T23:59:60Z" 2027-01-01 00:00:00.000000 Z type:2 php:yes native:yes +"2026-01-02T24:00:00Z" 2026-01-03 00:00:00.000000 Z type:2 php:yes native:yes +"0001-01-01T00:00:00Z" 0001-01-01 00:00:00.000000 Z type:2 php:yes native:yes +"2026-01-02T03:04:05.123456789+02:00" 2026-01-02 03:04:05.123456 +02:00 type:1 php:yes native:yes +"2026-01-02T03:04:05-0530" 2026-01-02 03:04:05.000000 -05:30 type:1 php:yes native:yes +"2026-01-02T03:04:05-05" 2026-01-02 03:04:05.000000 -05:00 type:1 php:yes native:yes +"2026-01-02T03:04:05+00:00" 2026-01-02 03:04:05.000000 +00:00 type:1 php:yes native:yes +"2026-01-02 03:04" 2026-01-02 03:04:00.000000 Europe/Warsaw type:3 php:yes native:yes +"2026-01-02T03:04:05" 2026-01-02 03:04:05.000000 Europe/Warsaw type:3 php:yes native:yes +"2026-01-02T03:04\n" 2026-01-02 03:04:00.000000 Europe/Warsaw type:3 php:yes native:yes +"2026-03-29T02:30:00" 2026-03-29 03:30:00.000000 Europe/Warsaw type:3 php:yes native:yes +php 2026-01-02T25:99:99Z: Flow\ETL\Exception\SchemaMismatchException +native 2026-01-02T25:99:99Z: Flow\ETL\Exception\SchemaMismatchException +php 2026-01-02T03:60:00Z: Flow\ETL\Exception\SchemaMismatchException +native 2026-01-02T03:60:00Z: Flow\ETL\Exception\SchemaMismatchException +php 2026-01-02T03:04:05+25:00: Flow\ETL\Exception\SchemaMismatchException +native 2026-01-02T03:04:05+25:00: 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 1111aac94..39cd20a9c 100644 --- a/src/lib/types/src/Flow/Types/Type/Logical/DateTimeType.php +++ b/src/lib/types/src/Flow/Types/Type/Logical/DateTimeType.php @@ -8,6 +8,7 @@ use DateTime; use DateTimeImmutable; use DateTimeInterface; +use DateTimeZone; use DOMElement; use Flow\Types\Exception\CastingException; use Flow\Types\Exception\InvalidTypeException; @@ -20,6 +21,7 @@ use function is_numeric; use function is_string; use function preg_match; +use function substr; /** * @template T of \DateTimeInterface @@ -32,7 +34,7 @@ * 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})?)?$/'; + 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 function assert(mixed $value): DateTimeInterface { @@ -65,6 +67,14 @@ public function cast(mixed $value): DateTimeInterface preg_match(self::ISO_DATE_TIME, $value, $date) === 1 && checkdate((int) $date[2], (int) $date[3], (int) $date[1]) ) { + if (($date[4] ?? '') === 'Z') { + // timelib resolves the "Z" abbreviation by scanning its whole abbreviation table, ten + // times the cost of parsing the rest; the zone handed in builds the identical object + static $zulu = new DateTimeZone('Z'); + + return new DateTimeImmutable(substr($date[0], 0, -1), $zulu); + } + return new DateTimeImmutable($value); } 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 89f6e934d..6fa469b1b 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 @@ -193,9 +193,25 @@ public function test_a_compact_iso_date_casts(): void #[TestWith(['2024-03-05 12:34:56+02'])] #[TestWith(['2024-02-29 00:00:00'])] #[TestWith(['2024-03-05 24:00:00'])] - public function test_an_iso_date_time_casts_to_the_instant_it_names(string $value): void + #[TestWith(['2026-01-02T03:04:05Z'])] + #[TestWith(['2026-01-02 03:04:05Z'])] + #[TestWith(['2026-01-02T03:04Z'])] + #[TestWith(["2026-01-02T03:04:05Z\n"])] + #[TestWith(['2026-01-02T03:04:05.1Z'])] + #[TestWith(['2026-01-02T03:04:05.123456Z'])] + #[TestWith(['2026-01-02T03:04:05.123456789Z'])] + #[TestWith(['2026-12-31T23:59:60Z'])] + #[TestWith(['2026-01-02T24:00:00Z'])] + #[TestWith(['0001-01-01T00:00:00Z'])] + #[TestWith(['2026-01-02T03:04:05.123456789+02:00'])] + #[TestWith(['2026-01-02T03:04:05+00:00'])] + #[TestWith(['2026-01-02T03:04:05-0530'])] + #[TestWith(['2026-01-02T03:04:05-05'])] + #[TestWith(['2026-01-02 03:04'])] + #[TestWith(['2026-01-02T03:04:05'])] + public function test_an_iso_date_time_casts_to_the_object_the_constructor_builds(string $value): void { - static::assertEquals(new DateTimeImmutable($value), type_datetime()->cast($value)); + static::assertSame(serialize(new DateTimeImmutable($value)), serialize(type_datetime()->cast($value))); } #[TestWith(['2023-02-29 10:00:00'])] @@ -203,6 +219,9 @@ public function test_an_iso_date_time_casts_to_the_instant_it_names(string $valu #[TestWith(['2024-13-01 10:00:00'])] #[TestWith(['2024-03-05 25:00:00'])] #[TestWith(['2024-03-05 12:60:00'])] + #[TestWith(['2026-01-02T25:99:99Z'])] + #[TestWith(['2026-01-02T03:60:00Z'])] + #[TestWith(['2026-01-02T03:04:61Z'])] public function test_an_iso_date_time_off_the_calendar_or_clock_is_refused(string $value): void { $this->expectException(CastingException::class);