From 173a12d83192c7e80bdd724708efac3c9d9597b5 Mon Sep 17 00:00:00 2001 From: Norbert Orzechowicz Date: Wed, 10 Jun 2026 10:48:54 +0200 Subject: [PATCH] feat(flow-php/postgresql): default DateTimeInterface to UTC timestamp split DateTimeConverter into TimestampConverter and TimestampTzConverter - normalize TIMESTAMP values to UTC, tag timestamp reads as UTC - map DateTimeEntry/DateTimeType to timestamp by default --- .../libs/postgresql/client-types.md | 201 +++++++++--------- documentation/upgrading.md | 24 +++ .../ETL/Adapter/PostgreSql/EntryTypesMap.php | 4 +- ...ostgreSqlLoaderAllTypesIntegrationTest.php | 4 +- .../Tests/Unit/EntryTypesMapTest.php | 4 +- .../Tests/Unit/SchemaConverterTest.php | 2 +- .../Types/Converter/TimestampConverter.php | 49 +++++ ...Converter.php => TimestampTzConverter.php} | 16 +- .../PostgreSql/Client/Types/ResultCaster.php | 16 ++ .../Client/Types/ValueConverters.php | 6 +- ...terTest.php => TimestampConverterTest.php} | 50 +---- .../Converter/TimestampTzConverterTest.php | 49 +++++ .../Converter/TimestampConverterTest.php | 85 ++++++++ ...rTest.php => TimestampTzConverterTest.php} | 15 +- .../Unit/Client/Types/ResultCasterTest.php | 24 ++- .../Unit/Client/Types/ValueConvertersTest.php | 6 +- 16 files changed, 391 insertions(+), 164 deletions(-) create mode 100644 src/lib/postgresql/src/Flow/PostgreSql/Client/Types/Converter/TimestampConverter.php rename src/lib/postgresql/src/Flow/PostgreSql/Client/Types/Converter/{DateTimeConverter.php => TimestampTzConverter.php} (59%) rename src/lib/postgresql/tests/Flow/PostgreSql/Tests/Integration/Client/Types/Converter/{DateTimeConverterTest.php => TimestampConverterTest.php} (70%) create mode 100644 src/lib/postgresql/tests/Flow/PostgreSql/Tests/Integration/Client/Types/Converter/TimestampTzConverterTest.php create mode 100644 src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Types/Converter/TimestampConverterTest.php rename src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Types/Converter/{DateTimeConverterTest.php => TimestampTzConverterTest.php} (87%) diff --git a/documentation/components/libs/postgresql/client-types.md b/documentation/components/libs/postgresql/client-types.md index 8785823049..4f17d6b507 100644 --- a/documentation/components/libs/postgresql/client-types.md +++ b/documentation/components/libs/postgresql/client-types.md @@ -5,22 +5,24 @@ [TOC] The client handles type conversion between PHP and PostgreSQL in two directions: + - **Parameter binding**: Converting PHP values to PostgreSQL format when executing queries - **Result casting**: Converting PostgreSQL results back to PHP types ## Result Casting -When fetching data, PostgreSQL returns all values as strings. The client automatically casts **unambiguous types** to their +When fetching data, PostgreSQL returns all values as strings. The client automatically casts **unambiguous types** to +their native PHP equivalents: -| PostgreSQL Type | PHP Type | Notes | -|--------------------|----------------------------------|----------------------------------| -| BOOL | `bool` | `'t'` → `true`, `'f'` → `false` | -| INT2, INT4 | `int` | smallint, integer | -| INT8 | `int` or `string` | `int` on 64-bit, `string` on 32-bit to avoid overflow | -| FLOAT4, FLOAT8 | `float` | Handles `Infinity`, `-Infinity`, `NaN` | -| BYTEA | `string` | Decoded binary data | -| Everything else | `string` | JSON, UUID, dates, arrays, etc. | +| PostgreSQL Type | PHP Type | Notes | +|-----------------|-------------------|-------------------------------------------------------| +| BOOL | `bool` | `'t'` → `true`, `'f'` → `false` | +| INT2, INT4 | `int` | smallint, integer | +| INT8 | `int` or `string` | `int` on 64-bit, `string` on 32-bit to avoid overflow | +| FLOAT4, FLOAT8 | `float` | Handles `Infinity`, `-Infinity`, `NaN` | +| BYTEA | `string` | Decoded binary data | +| Everything else | `string` | JSON, UUID, dates, arrays, etc. | **Why only these types?** Types like JSON, UUID, timestamps, and arrays have multiple valid PHP representations. Rather than choosing one, the client returns them as strings, letting you parse them as needed: @@ -49,15 +51,15 @@ $createdAt = new \DateTimeImmutable($row['created_at']); PHP values are automatically converted when binding parameters: -| PHP Type | PostgreSQL Type | Notes | -|----------------------|---------------------------|------------------------------------------| -| `string` | TEXT | Default for strings | -| `int` | INTEGER (INT4) | | -| `float` | DOUBLE PRECISION (FLOAT8) | | -| `bool` | BOOLEAN | `true` → `'t'`, `false` → `'f'` | -| `null` | NULL | | -| `\DateTimeInterface` | TIMESTAMPTZ | Formatted as ISO 8601 with timezone | -| `array` | ⚠️ Ambiguous | Use `typed()` to specify JSON or ARRAY | +| PHP Type | PostgreSQL Type | Notes | +|----------------------|---------------------------|-----------------------------------------------------------------------------------| +| `string` | TEXT | Default for strings | +| `int` | INTEGER (INT4) | | +| `float` | DOUBLE PRECISION (FLOAT8) | | +| `bool` | BOOLEAN | `true` → `'t'`, `false` → `'f'` | +| `null` | NULL | | +| `\DateTimeInterface` | TIMESTAMP | Normalized to UTC; use `typed()` with `ValueType::TIMESTAMPTZ` to keep the offset | +| `array` | ⚠️ Ambiguous | Use `typed()` to specify JSON or ARRAY | ### The Array Ambiguity @@ -142,98 +144,99 @@ $client->execute( ### Type DSL Functions -| DSL Function | PostgreSQL Type | Use Case | -|--------------|-----------------|----------| -| **String types** | | | -| `value_type_text()` | TEXT | Text strings | -| `value_type_varchar()` | VARCHAR | Variable-length strings | -| `value_type_char()` | CHAR | Fixed-length strings | -| **Integer types** | | | -| `value_type_int2()` / `value_type_smallint()` | SMALLINT | Small integers | -| `value_type_int4()` / `value_type_integer()` | INTEGER | Standard integers | -| `value_type_int8()` / `value_type_bigint()` | BIGINT | Large integers | -| **Floating point types** | | | -| `value_type_float4()` / `value_type_real()` | REAL | Single precision floats | -| `value_type_float8()` / `value_type_double()` | DOUBLE PRECISION | Double precision floats | -| `value_type_numeric()` | NUMERIC | Arbitrary precision numbers | -| `value_type_money()` | MONEY | Currency amounts | -| **Boolean type** | | | -| `value_type_bool()` / `value_type_boolean()` | BOOLEAN | True/false values | -| **Binary types** | | | -| `value_type_bytea()` | BYTEA | Binary data | -| `value_type_bit()` | BIT | Bit strings | -| `value_type_varbit()` | VARBIT | Variable-length bit strings | -| **Date/time types** | | | -| `value_type_date()` | DATE | Dates without time | -| `value_type_time()` | TIME | Time without timezone | -| `value_type_timetz()` | TIMETZ | Time with timezone | -| `value_type_timestamp()` | TIMESTAMP | Timestamp without timezone | -| `value_type_timestamptz()` | TIMESTAMPTZ | Timestamp with timezone | -| `value_type_interval()` | INTERVAL | Time intervals | -| **JSON types** | | | -| `value_type_json()` | JSON | JSON data | -| `value_type_jsonb()` | JSONB | Binary JSON data | -| **UUID type** | | | -| `value_type_uuid()` | UUID | Universally unique identifiers | -| **Network types** | | | -| `value_type_inet()` | INET | IPv4/IPv6 addresses | -| `value_type_cidr()` | CIDR | Network addresses | -| `value_type_macaddr()` | MACADDR | MAC addresses | -| `value_type_macaddr8()` | MACADDR8 | MAC addresses (EUI-64) | -| **Other types** | | | -| `value_type_xml()` | XML | XML data | -| `value_type_oid()` | OID | Object identifiers | -| **Array types** | | | -| `value_type_text_array()` | TEXT[] | Array of strings | -| `value_type_int4_array()` | INTEGER[] | Array of integers | -| `value_type_int8_array()` | BIGINT[] | Array of big integers | -| `value_type_float8_array()` | FLOAT8[] | Array of floats | -| `value_type_bool_array()` | BOOLEAN[] | Array of booleans | -| `value_type_uuid_array()` | UUID[] | Array of UUIDs | -| `value_type_json_array()` | JSON[] | Array of JSON | -| `value_type_jsonb_array()` | JSONB[] | Array of JSONB | +| DSL Function | PostgreSQL Type | Use Case | +|-----------------------------------------------|------------------|--------------------------------| +| **String types** | | | +| `value_type_text()` | TEXT | Text strings | +| `value_type_varchar()` | VARCHAR | Variable-length strings | +| `value_type_char()` | CHAR | Fixed-length strings | +| **Integer types** | | | +| `value_type_int2()` / `value_type_smallint()` | SMALLINT | Small integers | +| `value_type_int4()` / `value_type_integer()` | INTEGER | Standard integers | +| `value_type_int8()` / `value_type_bigint()` | BIGINT | Large integers | +| **Floating point types** | | | +| `value_type_float4()` / `value_type_real()` | REAL | Single precision floats | +| `value_type_float8()` / `value_type_double()` | DOUBLE PRECISION | Double precision floats | +| `value_type_numeric()` | NUMERIC | Arbitrary precision numbers | +| `value_type_money()` | MONEY | Currency amounts | +| **Boolean type** | | | +| `value_type_bool()` / `value_type_boolean()` | BOOLEAN | True/false values | +| **Binary types** | | | +| `value_type_bytea()` | BYTEA | Binary data | +| `value_type_bit()` | BIT | Bit strings | +| `value_type_varbit()` | VARBIT | Variable-length bit strings | +| **Date/time types** | | | +| `value_type_date()` | DATE | Dates without time | +| `value_type_time()` | TIME | Time without timezone | +| `value_type_timetz()` | TIMETZ | Time with timezone | +| `value_type_timestamp()` | TIMESTAMP | Timestamp without timezone | +| `value_type_timestamptz()` | TIMESTAMPTZ | Timestamp with timezone | +| `value_type_interval()` | INTERVAL | Time intervals | +| **JSON types** | | | +| `value_type_json()` | JSON | JSON data | +| `value_type_jsonb()` | JSONB | Binary JSON data | +| **UUID type** | | | +| `value_type_uuid()` | UUID | Universally unique identifiers | +| **Network types** | | | +| `value_type_inet()` | INET | IPv4/IPv6 addresses | +| `value_type_cidr()` | CIDR | Network addresses | +| `value_type_macaddr()` | MACADDR | MAC addresses | +| `value_type_macaddr8()` | MACADDR8 | MAC addresses (EUI-64) | +| **Other types** | | | +| `value_type_xml()` | XML | XML data | +| `value_type_oid()` | OID | Object identifiers | +| **Array types** | | | +| `value_type_text_array()` | TEXT[] | Array of strings | +| `value_type_int4_array()` | INTEGER[] | Array of integers | +| `value_type_int8_array()` | BIGINT[] | Array of big integers | +| `value_type_float8_array()` | FLOAT8[] | Array of floats | +| `value_type_bool_array()` | BOOLEAN[] | Array of booleans | +| `value_type_uuid_array()` | UUID[] | Array of UUIDs | +| `value_type_json_array()` | JSON[] | Array of JSON | +| `value_type_jsonb_array()` | JSONB[] | Array of JSONB | ## Built-in Value Converters The client includes converters for all common PostgreSQL types. These converters handle the `toDatabase()` conversion when you use `typed()`: -| Converter | PostgreSQL Types | PHP Input Types | -|---------------------|-----------------------------|---------------------------------| -| StringConverter | TEXT, VARCHAR, CHAR, BPCHAR | `string` | -| IntegerConverter | INT2, INT4, INT8 | `int` | -| FloatConverter | FLOAT4, FLOAT8 | `float` | -| BooleanConverter | BOOL | `bool` | -| DateTimeConverter | TIMESTAMP, TIMESTAMPTZ | `\DateTimeInterface` | -| DateConverter | DATE | `\DateTimeInterface` | -| TimeConverter | TIME, TIMETZ | `\DateTimeInterface` | -| UuidConverter | UUID | `string` | -| JsonConverter | JSON, JSONB | `array`, `object`, `string` | -| ByteaConverter | BYTEA | `string` (binary) | -| BoolArrayConverter | BOOL[] | `array` | -| IntArrayConverter | INT2[], INT4[], INT8[] | `array` | -| FloatArrayConverter | FLOAT4[], FLOAT8[] | `array` | -| TextArrayConverter | TEXT[], VARCHAR[] | `array` | -| UuidArrayConverter | UUID[] | `array` | -| JsonArrayConverter | JSON[], JSONB[] | `array` | -| IntervalConverter | INTERVAL | `\DateInterval`, `string` | -| NumericConverter | NUMERIC | `string`, `int`, `float` | -| MoneyConverter | MONEY | `string`, `int`, `float` | -| InetConverter | INET | `string` | -| CidrConverter | CIDR | `string` | +| Converter | PostgreSQL Types | PHP Input Types | +|----------------------|-----------------------------|-----------------------------| +| StringConverter | TEXT, VARCHAR, CHAR, BPCHAR | `string` | +| IntegerConverter | INT2, INT4, INT8 | `int` | +| FloatConverter | FLOAT4, FLOAT8 | `float` | +| BooleanConverter | BOOL | `bool` | +| TimestampConverter | TIMESTAMP | `\DateTimeInterface` (UTC) | +| TimestampTzConverter | TIMESTAMPTZ | `\DateTimeInterface` | +| DateConverter | DATE | `\DateTimeInterface` | +| TimeConverter | TIME, TIMETZ | `\DateTimeInterface` | +| UuidConverter | UUID | `string` | +| JsonConverter | JSON, JSONB | `array`, `object`, `string` | +| ByteaConverter | BYTEA | `string` (binary) | +| BoolArrayConverter | BOOL[] | `array` | +| IntArrayConverter | INT2[], INT4[], INT8[] | `array` | +| FloatArrayConverter | FLOAT4[], FLOAT8[] | `array` | +| TextArrayConverter | TEXT[], VARCHAR[] | `array` | +| UuidArrayConverter | UUID[] | `array` | +| JsonArrayConverter | JSON[], JSONB[] | `array` | +| IntervalConverter | INTERVAL | `\DateInterval`, `string` | +| NumericConverter | NUMERIC | `string`, `int`, `float` | +| MoneyConverter | MONEY | `string`, `int`, `float` | +| InetConverter | INET | `string` | +| CidrConverter | CIDR | `string` | ### Converter Notes - **FloatConverter**: Handles special PostgreSQL float values (`Infinity`, `-Infinity`, `NaN`) - **Array Converters**: Type-specific converters for PostgreSQL arrays. Each validates element types strictly: - - `BoolArrayConverter`: Expects `bool` elements - - `IntArrayConverter`: Expects `int` elements - - `FloatArrayConverter`: Expects `float` elements - - `TextArrayConverter`: Expects `string` or scalar elements - - `UuidArrayConverter`: Expects `string` elements (UUID format) - - `JsonArrayConverter`: Expects `array` or `object` elements (JSON-encodable) - - All converters support `null` elements (converted to PostgreSQL `NULL`) - - Invalid element types throw `ValueConversionException` + - `BoolArrayConverter`: Expects `bool` elements + - `IntArrayConverter`: Expects `int` elements + - `FloatArrayConverter`: Expects `float` elements + - `TextArrayConverter`: Expects `string` or scalar elements + - `UuidArrayConverter`: Expects `string` elements (UUID format) + - `JsonArrayConverter`: Expects `array` or `object` elements (JSON-encodable) + - All converters support `null` elements (converted to PostgreSQL `NULL`) + - Invalid element types throw `ValueConversionException` - **JsonConverter**: Accepts PHP arrays, objects, or already-encoded JSON strings - **NumericConverter**: Preserves precision as string to avoid floating-point errors diff --git a/documentation/upgrading.md b/documentation/upgrading.md index f995d03f2b..20a0245ec5 100644 --- a/documentation/upgrading.md +++ b/documentation/upgrading.md @@ -97,6 +97,30 @@ flow_telemetry: team: checkout ``` +### 4) `flow-php/postgresql` - `DateTimeConverter` split into `TimestampConverter` and `TimestampTzConverter` + +| Before | After | +|------------------------------------------------------------|-------------------------------------------------------------------------------| +| `Flow\PostgreSql\Client\Types\Converter\DateTimeConverter` | `TimestampConverter` (`TIMESTAMP`) and `TimestampTzConverter` (`TIMESTAMPTZ`) | +| `typed($value, ValueType::TIMESTAMP)` keeps the offset | `typed($value, ValueType::TIMESTAMP)` normalizes the value to UTC | +| `timestamp` column read as `2024-01-15 10:30:00` | `timestamp` column read as `2024-01-15 10:30:00+00:00` | + +### 5) `flow-php/etl-adapter-postgresql` - `DateTimeEntry` maps to `timestamp` instead of `timestamptz` + +| Flow type | Before | After | +|---------------------------|---------------|-------------| +| `DateTimeEntry` (binding) | `TIMESTAMPTZ` | `TIMESTAMP` | +| `DateTimeType` (DDL) | `timestamptz` | `timestamp` | + +To keep the previous behavior, pass overrides to `EntryTypesMap`: + +```php +to_pgsql_table($client, 'users')->withTypesMap(new EntryTypesMap( + [DateTimeEntry::class => ValueType::TIMESTAMPTZ], + [DateTimeType::class => ColumnType::timestamptz()], +)); +``` + --- ## Upgrading from 0.37.x to 0.38.x diff --git a/src/adapter/etl-adapter-postgresql/src/Flow/ETL/Adapter/PostgreSql/EntryTypesMap.php b/src/adapter/etl-adapter-postgresql/src/Flow/ETL/Adapter/PostgreSql/EntryTypesMap.php index b3948cd1e4..47b1fc9373 100644 --- a/src/adapter/etl-adapter-postgresql/src/Flow/ETL/Adapter/PostgreSql/EntryTypesMap.php +++ b/src/adapter/etl-adapter-postgresql/src/Flow/ETL/Adapter/PostgreSql/EntryTypesMap.php @@ -74,7 +74,7 @@ FloatEntry::class => ValueType::FLOAT8, BooleanEntry::class => ValueType::BOOL, DateEntry::class => ValueType::DATE, - DateTimeEntry::class => ValueType::TIMESTAMPTZ, + DateTimeEntry::class => ValueType::TIMESTAMP, TimeEntry::class => ValueType::TIME, UuidEntry::class => ValueType::UUID, JsonEntry::class => ValueType::JSONB, @@ -186,7 +186,7 @@ private static function defaultColumnTypes(): array BooleanType::class => ColumnType::boolean(), DateType::class => ColumnType::date(), TimeType::class => ColumnType::time(), - DateTimeType::class => ColumnType::timestamptz(), + DateTimeType::class => ColumnType::timestamp(), UuidType::class => ColumnType::uuid(), JsonType::class => ColumnType::jsonb(), ListType::class => ColumnType::jsonb(), diff --git a/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Integration/PostgreSqlLoaderAllTypesIntegrationTest.php b/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Integration/PostgreSqlLoaderAllTypesIntegrationTest.php index e05bda6f4a..896ab33f8b 100644 --- a/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Integration/PostgreSqlLoaderAllTypesIntegrationTest.php +++ b/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Integration/PostgreSqlLoaderAllTypesIntegrationTest.php @@ -44,7 +44,7 @@ use function Flow\PostgreSql\DSL\column_type_serial; use function Flow\PostgreSql\DSL\column_type_text; use function Flow\PostgreSql\DSL\column_type_time; -use function Flow\PostgreSql\DSL\column_type_timestamptz; +use function Flow\PostgreSql\DSL\column_type_timestamp; use function Flow\PostgreSql\DSL\column_type_uuid; use function Flow\PostgreSql\DSL\create; use function Flow\PostgreSql\DSL\select; @@ -76,7 +76,7 @@ protected function setUp(): void ->column(column('col_float', column_type_double_precision())) ->column(column('col_boolean', column_type_boolean())) ->column(column('col_date', column_type_date())) - ->column(column('col_datetime', column_type_timestamptz())) + ->column(column('col_datetime', column_type_timestamp())) ->column(column('col_time', column_type_time())) ->column(column('col_uuid', column_type_uuid())) ->column(column('col_json', column_type_jsonb())) diff --git a/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Unit/EntryTypesMapTest.php b/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Unit/EntryTypesMapTest.php index 753959b7aa..ee4f5020d3 100644 --- a/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Unit/EntryTypesMapTest.php +++ b/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Unit/EntryTypesMapTest.php @@ -59,14 +59,14 @@ public function test_maps_boolean_entry_to_bool_type(): void static::assertTrue($result->value); } - public function test_maps_datetime_entry_to_timestamptz_type(): void + public function test_maps_datetime_entry_to_timestamp_type(): void { $map = new EntryTypesMap(); $date = new DateTimeImmutable('2024-01-15 10:30:00'); $result = $map->mapEntry(datetime_entry('created_at', $date)); static::assertInstanceOf(TypedValue::class, $result); - static::assertSame(ValueType::TIMESTAMPTZ, $result->targetType); + static::assertSame(ValueType::TIMESTAMP, $result->targetType); static::assertEquals($date, $result->value); } diff --git a/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Unit/SchemaConverterTest.php b/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Unit/SchemaConverterTest.php index f953434777..65bfbbac58 100644 --- a/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Unit/SchemaConverterTest.php +++ b/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Unit/SchemaConverterTest.php @@ -107,7 +107,7 @@ public function test_forward_maps_scalar_types(): void static::assertTrue($table->column('name')->type->isEqual(ColumnType::text())); static::assertTrue($table->column('price')->type->isEqual(ColumnType::doublePrecision())); static::assertTrue($table->column('active')->type->isEqual(ColumnType::boolean())); - static::assertTrue($table->column('created_at')->type->isEqual(ColumnType::timestamptz())); + static::assertTrue($table->column('created_at')->type->isEqual(ColumnType::timestamp())); static::assertTrue($table->column('uuid')->type->isEqual(ColumnType::uuid())); static::assertTrue($table->column('payload')->type->isEqual(ColumnType::jsonb())); static::assertTrue($table->column('document')->type->isEqual(ColumnType::xml())); diff --git a/src/lib/postgresql/src/Flow/PostgreSql/Client/Types/Converter/TimestampConverter.php b/src/lib/postgresql/src/Flow/PostgreSql/Client/Types/Converter/TimestampConverter.php new file mode 100644 index 0000000000..3bd734573d --- /dev/null +++ b/src/lib/postgresql/src/Flow/PostgreSql/Client/Types/Converter/TimestampConverter.php @@ -0,0 +1,49 @@ +setTimezone(new DateTimeZone('UTC')) + ->format('Y-m-d H:i:s.u'); + } + + if (is_string($value)) { + return $value; + } + + throw ValueConversionException::cannotConvert($value, 'timestamp'); + } +} diff --git a/src/lib/postgresql/src/Flow/PostgreSql/Client/Types/Converter/DateTimeConverter.php b/src/lib/postgresql/src/Flow/PostgreSql/Client/Types/Converter/TimestampTzConverter.php similarity index 59% rename from src/lib/postgresql/src/Flow/PostgreSql/Client/Types/Converter/DateTimeConverter.php rename to src/lib/postgresql/src/Flow/PostgreSql/Client/Types/Converter/TimestampTzConverter.php index aed1429ce6..faf20c3052 100644 --- a/src/lib/postgresql/src/Flow/PostgreSql/Client/Types/Converter/DateTimeConverter.php +++ b/src/lib/postgresql/src/Flow/PostgreSql/Client/Types/Converter/TimestampTzConverter.php @@ -11,14 +11,18 @@ use function is_string; -final class DateTimeConverter implements ValueConverter +/** + * Converts values to PostgreSQL `timestamptz` (timestamp with time zone). + * + * The timezone offset is kept in the formatted value; PostgreSQL reads it and + * normalizes the instant to UTC on store. For naive `timestamp` columns use + * {@see TimestampConverter} instead. + */ +final class TimestampTzConverter implements ValueConverter { public function supportedTypes(): array { - return [ - ValueType::TIMESTAMP, - ValueType::TIMESTAMPTZ, - ]; + return [ValueType::TIMESTAMPTZ]; } public function toDatabase(mixed $value): ?string @@ -35,6 +39,6 @@ public function toDatabase(mixed $value): ?string return $value; } - throw ValueConversionException::cannotConvert($value, 'datetime'); + throw ValueConversionException::cannotConvert($value, 'timestamptz'); } } diff --git a/src/lib/postgresql/src/Flow/PostgreSql/Client/Types/ResultCaster.php b/src/lib/postgresql/src/Flow/PostgreSql/Client/Types/ResultCaster.php index b4b1a9fbba..784441876d 100644 --- a/src/lib/postgresql/src/Flow/PostgreSql/Client/Types/ResultCaster.php +++ b/src/lib/postgresql/src/Flow/PostgreSql/Client/Types/ResultCaster.php @@ -25,6 +25,7 @@ * - int8 → int (on 64-bit) or string (on 32-bit to avoid overflow) * - float4, float8 → float (including Infinity, -Infinity, NaN) * - bytea → string (decoded binary) + * - timestamp → string with a +00:00 offset appended (values are stored in UTC) * * All other types remain as strings for higher layers to interpret. */ @@ -38,6 +39,7 @@ public function cast(string $value, ?string $typeName): bool|float|int|string 'int8' => PHP_INT_SIZE >= 8 ? (int) $value : $value, 'float4', 'float8' => $this->castFloat($value), 'bytea' => $this->castBytea($value), + 'timestamp' => $this->castTimestamp($value), default => $value, }; } @@ -53,6 +55,20 @@ private function castBytea(string $value): string return $decoded; } + /** + * `timestamp` (without time zone) values are stored in UTC by TimestampConverter. + * The database returns them without an offset, so we tag them as UTC to keep the + * instant intact once a higher layer parses the string into a DateTimeInterface. + */ + private function castTimestamp(string $value): string + { + if ($value === 'infinity' || $value === '-infinity') { + return $value; + } + + return $value . '+00:00'; + } + private function castFloat(string $value): float { return match ($value) { diff --git a/src/lib/postgresql/src/Flow/PostgreSql/Client/Types/ValueConverters.php b/src/lib/postgresql/src/Flow/PostgreSql/Client/Types/ValueConverters.php index 61cb5fc59d..4214db7c2b 100644 --- a/src/lib/postgresql/src/Flow/PostgreSql/Client/Types/ValueConverters.php +++ b/src/lib/postgresql/src/Flow/PostgreSql/Client/Types/ValueConverters.php @@ -9,7 +9,6 @@ use Flow\PostgreSql\Client\Types\Converter\ByteaConverter; use Flow\PostgreSql\Client\Types\Converter\CidrConverter; use Flow\PostgreSql\Client\Types\Converter\DateConverter; -use Flow\PostgreSql\Client\Types\Converter\DateTimeConverter; use Flow\PostgreSql\Client\Types\Converter\FloatArrayConverter; use Flow\PostgreSql\Client\Types\Converter\FloatConverter; use Flow\PostgreSql\Client\Types\Converter\InetConverter; @@ -24,6 +23,8 @@ use Flow\PostgreSql\Client\Types\Converter\StringConverter; use Flow\PostgreSql\Client\Types\Converter\TextArrayConverter; use Flow\PostgreSql\Client\Types\Converter\TimeConverter; +use Flow\PostgreSql\Client\Types\Converter\TimestampConverter; +use Flow\PostgreSql\Client\Types\Converter\TimestampTzConverter; use Flow\PostgreSql\Client\Types\Converter\UuidArrayConverter; use Flow\PostgreSql\Client\Types\Converter\UuidConverter; @@ -122,7 +123,8 @@ private static function defaultConverters(PostgreSqlVersion $version): array new BooleanConverter(), new DateConverter(), new TimeConverter(), - new DateTimeConverter(), + new TimestampConverter(), + new TimestampTzConverter(), new UuidConverter(), new JsonConverter(), new ByteaConverter(), diff --git a/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Integration/Client/Types/Converter/DateTimeConverterTest.php b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Integration/Client/Types/Converter/TimestampConverterTest.php similarity index 70% rename from src/lib/postgresql/tests/Flow/PostgreSql/Tests/Integration/Client/Types/Converter/DateTimeConverterTest.php rename to src/lib/postgresql/tests/Flow/PostgreSql/Tests/Integration/Client/Types/Converter/TimestampConverterTest.php index 98d9f60947..da960f2675 100644 --- a/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Integration/Client/Types/Converter/DateTimeConverterTest.php +++ b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Integration/Client/Types/Converter/TimestampConverterTest.php @@ -12,22 +12,25 @@ use function Flow\PostgreSql\DSL\cast; use function Flow\PostgreSql\DSL\column_type_timestamp; -use function Flow\PostgreSql\DSL\column_type_timestamptz; use function Flow\PostgreSql\DSL\literal; use function Flow\PostgreSql\DSL\param; use function Flow\PostgreSql\DSL\select; use function Flow\PostgreSql\DSL\typed; -final class DateTimeConverterTest extends PostgreSqlTestCase +final class TimestampConverterTest extends PostgreSqlTestCase { /** * @return \Generator */ public static function provide_datetime_objects(): Generator { - yield 'datetime immutable' => [ - new DateTimeImmutable('2024-03-15 14:30:00'), - '2024-03-15 14:30:00', + yield 'utc stays utc' => [ + new DateTimeImmutable('2024-03-15 14:30:00+00:00'), + '2024-03-15 14:30:00+00:00', + ]; + yield 'offset normalized to utc' => [ + new DateTimeImmutable('2024-03-15 16:30:00+02:00'), + '2024-03-15 14:30:00+00:00', ]; } @@ -36,9 +39,9 @@ public static function provide_datetime_objects(): Generator */ public static function provide_timestamp_values(): Generator { - yield 'standard timestamp' => ['2024-03-15 14:30:00', '2024-03-15 14:30:00']; - yield 'midnight' => ['2024-03-15 00:00:00', '2024-03-15 00:00:00']; - yield 'end of day' => ['2024-03-15 23:59:59', '2024-03-15 23:59:59']; + yield 'standard timestamp' => ['2024-03-15 14:30:00', '2024-03-15 14:30:00+00:00']; + yield 'midnight' => ['2024-03-15 00:00:00', '2024-03-15 00:00:00+00:00']; + yield 'end of day' => ['2024-03-15 23:59:59', '2024-03-15 23:59:59+00:00']; } /** @@ -50,16 +53,6 @@ public static function provide_timestamp_with_microseconds(): Generator yield 'milliseconds only' => ['2024-03-15 14:30:00.123', '2024-03-15 14:30:00.123']; } - /** - * @return \Generator - */ - public static function provide_timestamptz_values(): Generator - { - yield 'utc timestamp' => ['2024-03-15 14:30:00+00']; - yield 'positive offset' => ['2024-03-15 16:30:00+02']; - yield 'negative offset' => ['2024-03-15 09:30:00-05']; - } - #[DataProvider('provide_datetime_objects')] public function test_datetime_object_to_timestamp(DateTimeImmutable $input, string $expected): void { @@ -84,16 +77,6 @@ public function test_null_timestamp(): void ); } - public function test_null_timestamptz(): void - { - static::assertNull( - $this - ->pgsqlContext() - ->client() - ->fetchScalar(select(cast(literal(null), column_type_timestamptz())->as('val'))->toSql()), - ); - } - #[DataProvider('provide_timestamp_values')] public function test_timestamp_round_trip(string $input, string $expected): void { @@ -119,15 +102,4 @@ public function test_timestamp_with_microseconds(string $input, string $expected static::assertStringStartsWith($expected, $result); } - - #[DataProvider('provide_timestamptz_values')] - public function test_timestamptz_round_trip(string $input): void - { - static::assertIsString( - $this - ->pgsqlContext() - ->client() - ->fetchScalarString(select(cast(param(1), column_type_timestamptz())->as('val'))->toSql(), [$input]), - ); - } } diff --git a/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Integration/Client/Types/Converter/TimestampTzConverterTest.php b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Integration/Client/Types/Converter/TimestampTzConverterTest.php new file mode 100644 index 0000000000..f63f7ebfcc --- /dev/null +++ b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Integration/Client/Types/Converter/TimestampTzConverterTest.php @@ -0,0 +1,49 @@ + + */ + public static function provide_timestamptz_values(): Generator + { + yield 'utc timestamp' => ['2024-03-15 14:30:00+00']; + yield 'positive offset' => ['2024-03-15 16:30:00+02']; + yield 'negative offset' => ['2024-03-15 09:30:00-05']; + } + + public function test_null_timestamptz(): void + { + static::assertNull( + $this + ->pgsqlContext() + ->client() + ->fetchScalar(select(cast(literal(null), column_type_timestamptz())->as('val'))->toSql()), + ); + } + + #[DataProvider('provide_timestamptz_values')] + public function test_timestamptz_round_trip(string $input): void + { + static::assertIsString( + $this + ->pgsqlContext() + ->client() + ->fetchScalarString(select(cast(param(1), column_type_timestamptz())->as('val'))->toSql(), [$input]), + ); + } +} diff --git a/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Types/Converter/TimestampConverterTest.php b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Types/Converter/TimestampConverterTest.php new file mode 100644 index 0000000000..58ccaa182e --- /dev/null +++ b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Types/Converter/TimestampConverterTest.php @@ -0,0 +1,85 @@ + [12345]; + yield 'array' => [['array']]; + yield 'float' => [3.14]; + yield 'boolean true' => [true]; + yield 'boolean false' => [false]; + yield 'object' => [new stdClass()]; + } + + public static function provide_valid_values(): Generator + { + yield 'utc stays utc' => [ + new DateTimeImmutable('2024-01-15 14:30:45.123456+00:00'), + '2024-01-15 14:30:45.123456', + ]; + yield 'positive offset normalized to utc' => [ + new DateTimeImmutable('2024-06-15 09:00:00.000000+02:00'), + '2024-06-15 07:00:00.000000', + ]; + yield 'negative offset normalized to utc' => [ + new DateTimeImmutable('2024-01-15 14:30:45.000000-05:00'), + '2024-01-15 19:30:45.000000', + ]; + yield 'offset rolls the day backwards' => [ + new DateTimeImmutable('2024-01-15 01:00:00.000000+02:00'), + '2024-01-14 23:00:00.000000', + ]; + yield 'mutable datetime normalized to utc' => [ + new DateTime('2024-06-15 09:00:00+02:00'), + '2024-06-15 07:00:00.000000', + ]; + yield 'max microseconds' => [ + new DateTimeImmutable('2024-01-15 12:00:00.999999+00:00'), + '2024-01-15 12:00:00.999999', + ]; + yield 'string passthrough' => ['2024-01-15 14:30:45', '2024-01-15 14:30:45']; + } + + #[DataProvider('provide_invalid_values')] + public function test_invalid_value_throws_exception(mixed $value): void + { + $converter = new TimestampConverter(); + $this->expectException(ValueConversionException::class); + $converter->toDatabase($value); + } + + public function test_null_handling(): void + { + $converter = new TimestampConverter(); + static::assertNull($converter->toDatabase(null)); + } + + public function test_supported_types(): void + { + $converter = new TimestampConverter(); + static::assertSame([ValueType::TIMESTAMP], $converter->supportedTypes()); + } + + #[DataProvider('provide_valid_values')] + public function test_to_database(DateTimeInterface|string $input, string $expected): void + { + $converter = new TimestampConverter(); + static::assertSame($expected, $converter->toDatabase($input)); + } +} diff --git a/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Types/Converter/DateTimeConverterTest.php b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Types/Converter/TimestampTzConverterTest.php similarity index 87% rename from src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Types/Converter/DateTimeConverterTest.php rename to src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Types/Converter/TimestampTzConverterTest.php index 08be1bfb39..0451aaa896 100644 --- a/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Types/Converter/DateTimeConverterTest.php +++ b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Types/Converter/TimestampTzConverterTest.php @@ -8,14 +8,14 @@ use DateTimeImmutable; use DateTimeInterface; use Flow\PostgreSql\Client\Exception\ValueConversionException; -use Flow\PostgreSql\Client\Types\Converter\DateTimeConverter; +use Flow\PostgreSql\Client\Types\Converter\TimestampTzConverter; use Flow\PostgreSql\Client\Types\ValueType; use Generator; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use stdClass; -final class DateTimeConverterTest extends TestCase +final class TimestampTzConverterTest extends TestCase { public static function provide_invalid_values(): Generator { @@ -65,30 +65,29 @@ public static function provide_valid_values(): Generator #[DataProvider('provide_invalid_values')] public function test_invalid_value_throws_exception(mixed $value): void { - $converter = new DateTimeConverter(); + $converter = new TimestampTzConverter(); $this->expectException(ValueConversionException::class); $converter->toDatabase($value); } public function test_null_handling(): void { - $converter = new DateTimeConverter(); + $converter = new TimestampTzConverter(); static::assertNull($converter->toDatabase(null)); } public function test_supported_types(): void { - $converter = new DateTimeConverter(); + $converter = new TimestampTzConverter(); $types = $converter->supportedTypes(); - static::assertContains(ValueType::TIMESTAMP, $types); - static::assertContains(ValueType::TIMESTAMPTZ, $types); + static::assertSame([ValueType::TIMESTAMPTZ], $types); } #[DataProvider('provide_valid_values')] public function test_to_database(DateTimeInterface|string $input, string $expected): void { - $converter = new DateTimeConverter(); + $converter = new TimestampTzConverter(); static::assertSame($expected, $converter->toDatabase($input)); } } diff --git a/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Types/ResultCasterTest.php b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Types/ResultCasterTest.php index beb1ed6832..253d34ba9a 100644 --- a/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Types/ResultCasterTest.php +++ b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Types/ResultCasterTest.php @@ -45,7 +45,6 @@ public static function provide_string_types(): Generator yield 'jsonb' => ['jsonb']; yield 'uuid' => ['uuid']; yield 'date' => ['date']; - yield 'timestamp' => ['timestamp']; yield 'timestamptz' => ['timestamptz']; yield 'time' => ['time']; yield 'timetz' => ['timetz']; @@ -131,4 +130,27 @@ public function test_string_types_pass_through(string $type): void $value = 'test string value'; static::assertSame($value, $this->caster->cast($value, $type)); } + + public function test_timestamp_is_marked_as_utc(): void + { + static::assertSame('2024-01-15 14:30:45+00:00', $this->caster->cast('2024-01-15 14:30:45', 'timestamp')); + } + + public function test_timestamp_with_microseconds_is_marked_as_utc(): void + { + static::assertSame('2024-01-15 14:30:45.123456+00:00', $this->caster->cast( + '2024-01-15 14:30:45.123456', + 'timestamp', + )); + } + + public function test_timestamp_negative_infinity_is_not_marked(): void + { + static::assertSame('-infinity', $this->caster->cast('-infinity', 'timestamp')); + } + + public function test_timestamp_positive_infinity_is_not_marked(): void + { + static::assertSame('infinity', $this->caster->cast('infinity', 'timestamp')); + } } diff --git a/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Types/ValueConvertersTest.php b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Types/ValueConvertersTest.php index cd01665258..01c0f0a4cf 100644 --- a/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Types/ValueConvertersTest.php +++ b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Types/ValueConvertersTest.php @@ -5,12 +5,13 @@ namespace Flow\PostgreSql\Tests\Unit\Client\Types; use Flow\PostgreSql\Client\Types\Converter\BooleanConverter; -use Flow\PostgreSql\Client\Types\Converter\DateTimeConverter; use Flow\PostgreSql\Client\Types\Converter\IntArrayConverter; use Flow\PostgreSql\Client\Types\Converter\IntegerConverter; use Flow\PostgreSql\Client\Types\Converter\JsonConverter; use Flow\PostgreSql\Client\Types\Converter\StringConverter; use Flow\PostgreSql\Client\Types\Converter\TextArrayConverter; +use Flow\PostgreSql\Client\Types\Converter\TimestampConverter; +use Flow\PostgreSql\Client\Types\Converter\TimestampTzConverter; use Flow\PostgreSql\Client\Types\Converter\UuidArrayConverter; use Flow\PostgreSql\Client\Types\Converter\UuidConverter; use Flow\PostgreSql\Client\Types\PostgreSqlVersion; @@ -63,7 +64,8 @@ public function test_for_postgrecolumn_type_returns_correct_converter(): void static::assertInstanceOf(StringConverter::class, $converters->forValueType(ValueType::TEXT)); static::assertInstanceOf(IntegerConverter::class, $converters->forValueType(ValueType::INT4)); static::assertInstanceOf(BooleanConverter::class, $converters->forValueType(ValueType::BOOL)); - static::assertInstanceOf(DateTimeConverter::class, $converters->forValueType(ValueType::TIMESTAMP)); + static::assertInstanceOf(TimestampConverter::class, $converters->forValueType(ValueType::TIMESTAMP)); + static::assertInstanceOf(TimestampTzConverter::class, $converters->forValueType(ValueType::TIMESTAMPTZ)); static::assertInstanceOf(UuidConverter::class, $converters->forValueType(ValueType::UUID)); static::assertInstanceOf(JsonConverter::class, $converters->forValueType(ValueType::JSON)); }