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
5 changes: 4 additions & 1 deletion benchmarks/src/Datasets/OrdersDataset.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
23 changes: 17 additions & 6 deletions src/extension/flow-php-ext/src/cast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -595,14 +595,25 @@ fn cast_value(kind: &CastKind, value: &Zval, ctx: &mut Ctx) -> Result<Option<Zva
// Only DateTimeType::cast's ISO branch is mirrored: its regex and checkdate() are exact, and
// what passes them goes straight to the constructor. Every other string is gated on
// StringTemporalParts, whose recogniser is a PHP class, so it bails whole
if iso_date_time_gate(string.as_bytes()) {
// None (e.g. "25:99:99") bails, and PHP throws the same exception
date_from_free_form(string.as_bytes(), ctx)?
} else {
let bytes = string.as_bytes();

// None (e.g. "25:99:99") from either parse bails, and PHP throws the same exception
if !iso_date_time_gate(bytes) {
None
} else if let Some(local) = bytes
.strip_suffix(b"\n")
.unwrap_or(bytes)
.strip_suffix(b"Z")
{
// mirrors DateTimeType::cast's Z branch
let mut zulu = ctx.timezone(b"Z")?.shallow_clone();

date_from_free_form(local, Some(&mut zulu), ctx)?
} else {
date_from_free_form(bytes, None, ctx)?
}
} else if value.is_long() || value.is_double() {
date_from_free_form(&timestamp_string(value)?, ctx)?
date_from_free_form(&timestamp_string(value)?, None, ctx)?
} else {
None
}
Expand Down Expand Up @@ -777,7 +788,7 @@ fn cast_date(value: &Zval, ctx: &mut Ctx) -> Result<Option<Zval>, PhpException>
}

let parsed = if value.is_long() || value.is_double() {
date_from_free_form(&timestamp_string(value)?, ctx)?
date_from_free_form(&timestamp_string(value)?, None, ctx)?
} else {
None
};
Expand Down
5 changes: 3 additions & 2 deletions src/extension/flow-php-ext/src/values.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<Zval>, PhpException> {
let ce = ctx.datetime_fns(false)?.ce;
Expand Down Expand Up @@ -65,7 +66,7 @@ pub(crate) fn date_from_free_form(
time_str.as_mut_ptr().cast::<c_char>(),
time_str.len() - 1,
std::ptr::null(),
std::ptr::null_mut(),
timezone.map_or(std::ptr::null_mut(), std::ptr::from_mut),
0,
)
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
--TEST--
datetime ISO strings: both hydrators build the object new DateTimeImmutable($value) builds, the Z zone included
--SKIPIF--
<?php if (!extension_loaded("flow_php")) die("skip flow_php extension not loaded"); ?>
--FILE--
<?php
require __DIR__ . '/bootstrap.php';

use function Flow\ETL\DSL\datetime_schema;
use function Flow\ETL\DSL\schema;

use Flow\ETL\Row\NativeRowHydrator;
use Flow\ETL\Row\PhpRowHydrator;
use Flow\ETL\Row\RawRowValues;

$values = [
'2026-01-02T03:04:05Z',
'2026-01-02 03:04:05Z',
'2026-01-02T03:04Z',
"2026-01-02T03:04:05Z\n",
'2026-01-02T03:04:05.1Z',
'2026-01-02T03:04:05.12Z',
'2026-01-02T03:04:05.123Z',
'2026-01-02T03:04:05.1234Z',
'2026-01-02T03:04:05.12345Z',
'2026-01-02T03:04:05.123456Z',
'2026-01-02T03:04:05.1234567Z',
'2026-01-02T03:04:05.12345678Z',
'2026-01-02T03:04:05.123456789Z',
'2026-12-31T23:59:60Z',
'2026-01-02T24:00:00Z',
'0001-01-01T00:00:00Z',
'2026-01-02T03:04:05.123456789+02:00',
'2026-01-02T03:04:05-0530',
'2026-01-02T03:04:05-05',
'2026-01-02T03:04:05+00:00',
'2026-01-02 03:04',
'2026-01-02T03:04:05',
"2026-01-02T03:04\n",
'2026-03-29T02:30:00',
];

$php = new PhpRowHydrator();
$native = new NativeRowHydrator();
$schema = schema(datetime_schema('at'));

foreach (['UTC', 'Europe/Warsaw'] as $timezone) {
date_default_timezone_set($timezone);
echo "date.timezone {$timezone}\n";

foreach ($values as $value) {
$batch = [new RawRowValues(['at' => $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
12 changes: 11 additions & 1 deletion src/lib/types/src/Flow/Types/Type/Logical/DateTimeType.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use DateTime;
use DateTimeImmutable;
use DateTimeInterface;
use DateTimeZone;
use DOMElement;
use Flow\Types\Exception\CastingException;
use Flow\Types\Exception\InvalidTypeException;
Expand All @@ -20,6 +21,7 @@
use function is_numeric;
use function is_string;
use function preg_match;
use function substr;

/**
* @template T of \DateTimeInterface
Expand All @@ -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
{
Expand Down Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,16 +193,35 @@ 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'])]
#[TestWith(['2024-04-31T10:00:00Z'])]
#[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);
Expand Down
Loading