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
201 changes: 102 additions & 99 deletions documentation/components/libs/postgresql/client-types.md

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions documentation/upgrading.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

namespace Flow\PostgreSql\Client\Types\Converter;

use DateTimeImmutable;
use DateTimeInterface;
use DateTimeZone;
use Flow\PostgreSql\Client\Exception\ValueConversionException;
use Flow\PostgreSql\Client\Types\ValueConverter;
use Flow\PostgreSql\Client\Types\ValueType;

use function is_string;

/**
* Converts values to PostgreSQL `timestamp` (without time zone).
*
* PostgreSQL discards any offset on a `timestamp` column, so a DateTimeInterface
* is first normalized to UTC and then formatted without an offset. This keeps the
* stored wall-clock in UTC and preserves the instant instead of letting the
* database silently drop the timezone. For `timestamptz` use {@see TimestampTzConverter}.
*/
final class TimestampConverter implements ValueConverter
{
public function supportedTypes(): array
{
return [ValueType::TIMESTAMP];
}

public function toDatabase(mixed $value): ?string
{
if ($value === null) {
return null;
}

if ($value instanceof DateTimeInterface) {
return DateTimeImmutable::createFromInterface($value)
->setTimezone(new DateTimeZone('UTC'))
->format('Y-m-d H:i:s.u');
}

if (is_string($value)) {
return $value;
}

throw ValueConversionException::cannotConvert($value, 'timestamp');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,6 +39,6 @@ public function toDatabase(mixed $value): ?string
return $value;
}

throw ValueConversionException::cannotConvert($value, 'datetime');
throw ValueConversionException::cannotConvert($value, 'timestamptz');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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,
};
}
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, array{\DateTimeImmutable, string}>
*/
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',
];
}

Expand All @@ -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'];
}

/**
Expand All @@ -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<string, array{string}>
*/
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
{
Expand All @@ -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
{
Expand All @@ -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]),
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

namespace Flow\PostgreSql\Tests\Integration\Client\Types\Converter;

use Flow\PostgreSql\Tests\Integration\PostgreSqlTestCase;
use Generator;
use PHPUnit\Framework\Attributes\DataProvider;

use function Flow\PostgreSql\DSL\cast;
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;

final class TimestampTzConverterTest extends PostgreSqlTestCase
{
/**
* @return \Generator<string, array{string}>
*/
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]),
);
}
}
Loading
Loading