From 9a67b5f175fe6d8880bbfff2f3375bbd665949dc Mon Sep 17 00:00:00 2001 From: sander-hash Date: Thu, 17 Sep 2026 13:57:58 +0200 Subject: [PATCH 1/4] feat: add string and date type support --- .gitignore | 5 +- doc/types.md | 56 +++++++- src/Quote/ValueFormatter.php | 7 + src/Transport/Http.php | 13 +- src/Type/Date.php | 38 +++++ src/Type/Date32.php | 2 +- src/Type/DateTime.php | 44 ++++++ src/Type/DateTime64.php | 27 ++-- src/Type/DateType.php | 9 ++ src/Type/Enum16.php | 32 +++++ src/Type/Enum8.php | 32 +++++ src/Type/EnumType.php | 9 ++ src/Type/FixedString.php | 39 +++++ src/Type/IPv4.php | 2 +- src/Type/IPv6.php | 2 +- src/Type/StringType.php | 32 +++++ src/Type/StringValue.php | 11 ++ src/Type/UUID.php | 2 +- tests/Type/StringDateTypesIntegrationTest.php | 77 ++++++++++ tests/Type/StringDateTypesTest.php | 135 ++++++++++++++++++ todo.md | 16 +-- 21 files changed, 553 insertions(+), 37 deletions(-) create mode 100644 src/Type/Date.php create mode 100644 src/Type/DateTime.php create mode 100644 src/Type/DateType.php create mode 100644 src/Type/Enum16.php create mode 100644 src/Type/Enum8.php create mode 100644 src/Type/EnumType.php create mode 100644 src/Type/FixedString.php create mode 100644 src/Type/StringType.php create mode 100644 src/Type/StringValue.php create mode 100644 tests/Type/StringDateTypesIntegrationTest.php create mode 100644 tests/Type/StringDateTypesTest.php diff --git a/.gitignore b/.gitignore index 80d8ca3..8e8c95c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,7 @@ composer.lock vendor/ var/ phpunit -temp/ \ No newline at end of file +temp/ +/.phpunit.result.cache +/tests/docker-clickhouse-21/ +/tests/docker-clickhouse-latest/ diff --git a/doc/types.md b/doc/types.md index 7a1a1e0..7cc04f0 100644 --- a/doc/types.md +++ b/doc/types.md @@ -46,6 +46,23 @@ $db->insert('table', [ ## Date & Time Types +### Date and DateTime + +```php +use ClickHouseDB\Type\Date; +use ClickHouseDB\Type\DateTime; + +Date::fromString('2024-02-29'); +Date::fromDateTime(new DateTimeImmutable('2024-02-29 12:00:00')); +DateTime::fromString('2024-02-29 12:00:00'); +DateTime::fromDateTime(new DateTimeImmutable('2024-02-29 12:00:00+00:00'), 'Europe/Amsterdam'); +``` + +The optional timezone converts PHP date/time objects before formatting, without +mutating them. Use the timezone of the destination column (or native parameter). +Without it, the object's timezone is preserved. String factories preserve the +input exactly; ClickHouse validates date ranges and parses strings in the column's timezone. + ### DateTime64 Sub-second precision timestamps (milliseconds, microseconds, nanoseconds). @@ -64,8 +81,12 @@ $db->insert('table', [ [DateTime64::fromDateTime($dt, 3)], // → '2024-06-15 12:00:00.456' ], ['created_at']); -// Precision options: 1-9 (1=tenths, 3=ms, 6=μs, 9=ns) +// Precision options: 0-9 (1=tenths, 3=ms, 6=μs, 9=ns) DateTime64::fromDateTime($dt, 6); // → '2024-06-15 12:00:00.456789' +DateTime64::fromDateTime($dt, 9, 'UTC'); +// PHP has microsecond precision; digits 7-9 are padded with zeros. +// Use fromString() to preserve an existing nanosecond timestamp. +// Precision outside 0-9 throws InvalidArgumentException. ``` ### Date32 @@ -112,6 +133,39 @@ $db->insert('table', [ ## String Types +### StringType and FixedString + +PHP reserves the name `String`, so the wrapper is named `StringType`. + +```php +use ClickHouseDB\Type\StringType; +use ClickHouseDB\Type\FixedString; + +StringType::fromString("it's a string"); +FixedString::fromString('a', 1); // For a FixedString(1) column. +FixedString::fromString('ab', 2); // For a FixedString(2) column. +``` + +`FixedString` requires a positive length in bytes and a value with exactly that +byte length. Both shorter and longer values are rejected. +These wrappers retain raw values in `getValue()`, `__toString()`, and `$value`; +use bindings or `insert()` to escape them safely, rather than SQL interpolation. + +### Enum8 and Enum16 + +```php +use ClickHouseDB\Type\Enum8; +use ClickHouseDB\Type\Enum16; + +Enum8::fromString('active'); +Enum16::fromString('pending'); +``` + +These wrappers represent enum labels. Define the label-to-number mapping in the +column or native parameter type, e.g. `Enum8('active' = 1, 'inactive' = 2)`. +ClickHouse validates membership and the numeric range of that mapping. + + ### UUID ```php diff --git a/src/Quote/ValueFormatter.php b/src/Quote/ValueFormatter.php index f9b88d3..8d820ba 100644 --- a/src/Quote/ValueFormatter.php +++ b/src/Quote/ValueFormatter.php @@ -6,8 +6,10 @@ use ClickHouseDB\Exception\UnsupportedValueType; use ClickHouseDB\Query\Expression\Expression; +use ClickHouseDB\Type\StringValue; use ClickHouseDB\Type\Type; use DateTimeInterface; + use function addslashes; use function is_bool; use function is_callable; @@ -15,6 +17,7 @@ use function is_int; use function is_object; use function is_string; +use function property_exists; use function sprintf; class ValueFormatter @@ -29,6 +32,10 @@ public static function formatValue(mixed $value, bool $addQuotes = true): mixed return $value; } + if ($value instanceof StringValue) { + return self::formatValue($value->getValue(), $addQuotes); + } + if ($value instanceof Type) { return $value->getValue(); } diff --git a/src/Transport/Http.php b/src/Transport/Http.php index 9d4e434..388f8b0 100644 --- a/src/Transport/Http.php +++ b/src/Transport/Http.php @@ -830,17 +830,8 @@ public function writeWithParams(string $sql, array $params, bool $exception = tr */ private function convertParamValue(mixed $value): string { - if ($value instanceof \ClickHouseDB\Type\DateTime64) { - return $value->value; - } - if ($value instanceof \ClickHouseDB\Type\Date32) { - return $value->value; - } - if ($value instanceof \ClickHouseDB\Type\UUID) { - return $value->value; - } - if ($value instanceof \ClickHouseDB\Type\IPv4 || $value instanceof \ClickHouseDB\Type\IPv6) { - return $value->value; + if ($value instanceof \ClickHouseDB\Type\StringValue) { + return $this->convertParamValue($value->getValue()); } if ($value instanceof \ClickHouseDB\Type\MapType) { return json_encode($value->value); diff --git a/src/Type/Date.php b/src/Type/Date.php new file mode 100644 index 0000000..640743c --- /dev/null +++ b/src/Type/Date.php @@ -0,0 +1,38 @@ +value = $value; + } + + public static function fromString(string $value): self + { + return new self($value); + } + + public static function fromDateTime(DateTimeInterface $dateTime): self + { + return new self($dateTime->format('Y-m-d')); + } + + public function getValue(): string + { + return $this->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Type/Date32.php b/src/Type/Date32.php index aa5db18..1e7da0e 100644 --- a/src/Type/Date32.php +++ b/src/Type/Date32.php @@ -7,7 +7,7 @@ use DateTimeInterface; use Stringable; -final class Date32 implements Type, Stringable +final class Date32 implements DateType, Stringable { public string $value; diff --git a/src/Type/DateTime.php b/src/Type/DateTime.php new file mode 100644 index 0000000..3370d62 --- /dev/null +++ b/src/Type/DateTime.php @@ -0,0 +1,44 @@ +value = $value; + } + + public static function fromString(string $value): self + { + return new self($value); + } + + public static function fromDateTime(DateTimeInterface $dateTime, ?string $timezone = null): self + { + if ($timezone !== null) { + $dateTime = DateTimeImmutable::createFromInterface($dateTime)->setTimezone(new DateTimeZone($timezone)); + } + + return new self($dateTime->format('Y-m-d H:i:s')); + } + + public function getValue(): string + { + return $this->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Type/DateTime64.php b/src/Type/DateTime64.php index 3dfcced..23bb8ae 100644 --- a/src/Type/DateTime64.php +++ b/src/Type/DateTime64.php @@ -4,10 +4,15 @@ namespace ClickHouseDB\Type; +use DateTimeImmutable; use DateTimeInterface; +use DateTimeZone; +use InvalidArgumentException; use Stringable; -final class DateTime64 implements Type, Stringable +use function substr; + +final class DateTime64 implements DateType, Stringable { public string $value; @@ -21,15 +26,21 @@ public static function fromString(string $value): self return new self($value); } - public static function fromDateTime(DateTimeInterface $dateTime, int $precision = 3): self + public static function fromDateTime(DateTimeInterface $dateTime, int $precision = 3, ?string $timezone = null): self { - $formatted = $dateTime->format('Y-m-d H:i:s.u'); - $dotPos = strpos($formatted, '.'); - if ($dotPos !== false && $precision > 0) { - $formatted = substr($formatted, 0, $dotPos + 1 + $precision); - } elseif ($precision === 0) { - $formatted = $dateTime->format('Y-m-d H:i:s'); + if ($precision < 0 || $precision > 9) { + throw new InvalidArgumentException('DateTime64 precision must be between 0 and 9.'); + } + + if ($timezone !== null) { + $dateTime = DateTimeImmutable::createFromInterface($dateTime)->setTimezone(new DateTimeZone($timezone)); } + + $formatted = $dateTime->format('Y-m-d H:i:s'); + if ($precision > 0) { + $formatted .= '.' . substr($dateTime->format('u') . '000', 0, $precision); + } + return new self($formatted); } diff --git a/src/Type/DateType.php b/src/Type/DateType.php new file mode 100644 index 0000000..3867433 --- /dev/null +++ b/src/Type/DateType.php @@ -0,0 +1,9 @@ +value = $value; + } + + public static function fromString(string $value): self + { + return new self($value); + } + + public function getValue(): string + { + return $this->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Type/Enum8.php b/src/Type/Enum8.php new file mode 100644 index 0000000..0c81ba9 --- /dev/null +++ b/src/Type/Enum8.php @@ -0,0 +1,32 @@ +value = $value; + } + + public static function fromString(string $value): self + { + return new self($value); + } + + public function getValue(): string + { + return $this->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Type/EnumType.php b/src/Type/EnumType.php new file mode 100644 index 0000000..78ec637 --- /dev/null +++ b/src/Type/EnumType.php @@ -0,0 +1,9 @@ +value = $value; + } + + public static function fromString(string $value, int $length): self + { + if ($length < 1 || strlen($value) !== $length) { + throw new InvalidArgumentException('FixedString requires a positive byte length equal to the value length.'); + } + + return new self($value); + } + + public function getValue(): string + { + return $this->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Type/IPv4.php b/src/Type/IPv4.php index 8f5b2bf..ce2228b 100644 --- a/src/Type/IPv4.php +++ b/src/Type/IPv4.php @@ -6,7 +6,7 @@ use Stringable; -final class IPv4 implements Type, Stringable +final class IPv4 implements StringValue, Stringable { public string $value; diff --git a/src/Type/IPv6.php b/src/Type/IPv6.php index b54c9b9..0cae3c6 100644 --- a/src/Type/IPv6.php +++ b/src/Type/IPv6.php @@ -6,7 +6,7 @@ use Stringable; -final class IPv6 implements Type, Stringable +final class IPv6 implements StringValue, Stringable { public string $value; diff --git a/src/Type/StringType.php b/src/Type/StringType.php new file mode 100644 index 0000000..e5209d1 --- /dev/null +++ b/src/Type/StringType.php @@ -0,0 +1,32 @@ +value = $value; + } + + public static function fromString(string $value): self + { + return new self($value); + } + + public function getValue(): string + { + return $this->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Type/StringValue.php b/src/Type/StringValue.php new file mode 100644 index 0000000..d87dda9 --- /dev/null +++ b/src/Type/StringValue.php @@ -0,0 +1,11 @@ +client->write('DROP TABLE IF EXISTS string_date_types'); + $this->client->write('CREATE TABLE string_date_types (value ' . $type . ') ENGINE = Memory'); + try { + $this->client->insert('string_date_types', [[$value]], ['value']); + $this->client->write('INSERT INTO string_date_types VALUES (:value)', ['value' => $value]); + self::assertSame([['value' => $expected], ['value' => $expected]], $this->client->select( + 'SELECT value FROM string_date_types' + )->rows()); + } finally { + $this->client->write('DROP TABLE IF EXISTS string_date_types'); + } + } + + /** @dataProvider values */ + public function testNativeParametersRoundTrip(string $type, Type $value, string $expected): void + { + self::assertSame($expected, $this->client->selectWithParams( + 'SELECT {value:' . $type . '} AS value', + ['value' => $value] + )->fetchOne('value')); + } + + /** @return array */ + public static function values(): array + { + return [ + 'string' => ['String', StringType::fromString("it's\\a test"), "it's\\a test"], + 'empty string' => ['String', StringType::fromString(''), ''], + 'fixed string' => ['FixedString(1)', FixedString::fromString('a', 1), 'a'], + 'fixed bytes' => ['FixedString(2)', FixedString::fromString('é', 2), 'é'], + 'date' => ['Date', Date::fromString('2024-02-29'), '2024-02-29'], + 'date32' => ['Date32', Date32::fromString('1925-01-01'), '1925-01-01'], + 'datetime' => ["DateTime('UTC')", DateTime::fromString('2024-02-29 23:45:12'), '2024-02-29 23:45:12'], + 'datetime64' => ["DateTime64(9, 'UTC')", DateTime64::fromString('2024-02-29 23:45:12.123456789'), '2024-02-29 23:45:12.123456789'], + 'datetime64 timezone' => [ + "DateTime64(3, 'Europe/Amsterdam')", + DateTime64::fromDateTime(new DateTimeImmutable('2024-02-29 23:45:12.123456+00:00'), 3, 'Europe/Amsterdam'), + '2024-03-01 00:45:12.123', + ], + 'uuid' => ['UUID', UUID::fromString('550e8400-e29b-41d4-a716-446655440000'), '550e8400-e29b-41d4-a716-446655440000'], + 'ipv4' => ['IPv4', IPv4::fromString('192.168.1.1'), '192.168.1.1'], + 'ipv6' => ['IPv6', IPv6::fromString('2001:db8::1'), '2001:db8::1'], + 'enum8' => ["Enum8('low' = -128, 'high' = 127)", Enum8::fromString('low'), 'low'], + 'enum16' => ["Enum16('low' = -32768, 'high' = 32767)", Enum16::fromString('high'), 'high'], + ]; + } +} diff --git a/tests/Type/StringDateTypesTest.php b/tests/Type/StringDateTypesTest.php new file mode 100644 index 0000000..116b5bc --- /dev/null +++ b/tests/Type/StringDateTypesTest.php @@ -0,0 +1,135 @@ +getValue()); + self::assertSame("a'b\\c\0", (string) $value); + self::assertSame("a'b\\c\0", ValueFormatter::formatValue($value, false)); + self::assertSame("'a\\'b\\\\c\\0'", ValueFormatter::formatValue($value)); + $bindings = new Bindings(); + $bindings->bindParam('value', $value); + self::assertSame("SELECT 'a\\'b\\\\c\\0'", $bindings->process('SELECT :value')); + } + + /** @return list */ + public static function stringTypes(): array + { + return array_map(static fn (string $class): array => [$class], [ + StringType::class, + Date::class, + Date32::class, + DateTime::class, + DateTime64::class, + UUID::class, + IPv4::class, + IPv6::class, + Enum8::class, + Enum16::class, + ]); + } + + public function testFixedStringRequiresExactByteLength(): void + { + self::assertSame('é', FixedString::fromString('é', 2)->getValue()); + self::assertSame("'x'", ValueFormatter::formatValue(FixedString::fromString('x', 1))); + self::assertSame('ab', FixedString::fromString('ab', 2)->getValue()); + self::assertSame("'a\\'b'", ValueFormatter::formatValue(FixedString::fromString("a'b", 3))); + } + + /** @dataProvider invalidFixedStrings */ + public function testFixedStringRejectsInvalidLength(string $value, int $length): void + { + $this->expectException(InvalidArgumentException::class); + FixedString::fromString($value, $length); + } + + /** @return list */ + public static function invalidFixedStrings(): array + { + return [['', 0], ['', -1], ['', 1], ['x', 2], ['ab', 3], ['é', 1], ['abc', 2]]; + } + + public function testDateFactories(): void + { + $date = new DateTimeImmutable('2024-02-29 23:45:12.123456+00:00'); + self::assertSame('2024-02-29', Date::fromDateTime($date)->getValue()); + self::assertSame('2024-02-29', Date32::fromDateTime($date)->getValue()); + self::assertSame('2024-02-29 23:45:12', DateTime::fromDateTime($date)->getValue()); + } + + /** @dataProvider precisions */ + public function testDateTime64Precision(int $precision, string $expected): void + { + $date = new DateTimeImmutable('2024-02-29 23:45:12.123456+00:00'); + self::assertSame($expected, DateTime64::fromDateTime($date, $precision)->getValue()); + } + + /** @return list */ + public static function precisions(): array + { + return [ + [0, '2024-02-29 23:45:12'], + [1, '2024-02-29 23:45:12.1'], + [3, '2024-02-29 23:45:12.123'], + [6, '2024-02-29 23:45:12.123456'], + [9, '2024-02-29 23:45:12.123456000'], + ]; + } + + /** @dataProvider invalidPrecisions */ + public function testDateTime64RejectsInvalidPrecision(int $precision): void + { + $this->expectException(InvalidArgumentException::class); + DateTime64::fromDateTime(new DateTimeImmutable('2024-01-01'), $precision); + } + + /** @return list */ + public static function invalidPrecisions(): array + { + return [[-1], [10]]; + } + + public function testTimezoneConversionDoesNotMutateInput(): void + { + $date = new \DateTime('2024-02-29 23:45:12.123456+00:00'); + self::assertSame('2024-03-01 00:45:12', DateTime::fromDateTime($date, 'Europe/Amsterdam')->getValue()); + self::assertSame('2024-03-01 00:45:12.123456000', DateTime64::fromDateTime($date, 9, 'Europe/Amsterdam')->getValue()); + self::assertSame('2024-02-29 23:45:12.123456', $date->format('Y-m-d H:i:s.u')); + } + + public function testNanosecondStringRemainsExact(): void + { + self::assertSame('2024-01-01 00:00:00.123456789', DateTime64::fromString('2024-01-01 00:00:00.123456789')->getValue()); + } +} diff --git a/todo.md b/todo.md index df3c959..efbe88e 100644 --- a/todo.md +++ b/todo.md @@ -36,8 +36,9 @@ ClickHouse поддерживает типизированные парамет ### Текущее состояние - `ValueFormatter`: int, float, bool, string, null, DateTimeInterface, Expression, Type -- `UInt64` — единственный кастомный тип -- Нет поддержки: DateTime64, Date32, IPv4/IPv6, UUID, Map, Tuple, Enum, Decimal, Geo-типы +- Реализованы строковые типы `String` (`StringType`), `FixedString(N)`, даты `Date`, `Date32`, `DateTime`, `DateTime64`, а также `UUID`, `IPv4`, `IPv6`, `Enum8`, `Enum16` +- Для строк и дат добавлены модульные тесты и интеграционные тесты для CH 21 и CH 26 +- Остальные задачи по типам перечислены в фазах ниже ### План — Фаза 1: Основные типы - [ ] `src/Type/` — расширить систему типов: @@ -48,15 +49,6 @@ ClickHouse поддерживает типизированные парамет - [ ] `Bool` - [ ] Тесты на каждый тип: insert + select + сравнение -### План — Фаза 2: Строки и даты -- [ ] `String`, `FixedString(N)` -- [ ] `Date`, `Date32` -- [ ] `DateTime`, `DateTime64(precision, timezone)` -- [ ] `UUID` -- [ ] `IPv4`, `IPv6` -- [ ] `Enum8`, `Enum16` -- [ ] Тесты - ### План — Фаза 3: Составные типы - [ ] `Array(T)` — уже частично работает, формализовать - [ ] `Tuple(T1, T2, ...)` @@ -211,7 +203,7 @@ $db->select('SELECT 1'); | 3 | Structured exceptions | Низкая | Нулевой | **P0** | | 1 | Native Query Parameters | Средняя | Нулевой (новые методы) | **P1** | | 4 | PHPStan level max | Средняя | Нулевой | **P1** | -| 2 | 60+ типов (фаза 1-2) | Средняя | Нулевой | **P2** | +| 2 | 60+ типов (фаза 1) | Средняя | Нулевой | **P2** | | 2 | 60+ типов (фаза 3-4) | Высокая | Нулевой | **P3** | ## Ограничения From 23c1f05be23070c9d0d502370924c48b81b846f0 Mon Sep 17 00:00:00 2001 From: sander-hash Date: Thu, 17 Sep 2026 14:45:59 +0200 Subject: [PATCH 2/4] Remove breaking changes --- doc/types.md | 4 +- src/Transport/Http.php | 12 ++ src/Type/Date32.php | 2 +- src/Type/DateTime64.php | 27 ++-- src/Type/IPv4.php | 2 +- src/Type/IPv6.php | 2 +- src/Type/UUID.php | 2 +- tests/ExceptionParsingTest.php | 117 ++++++++++++++++++ tests/Type/StringDateTypesIntegrationTest.php | 14 +-- tests/Type/StringDateTypesTest.php | 49 +++++--- 10 files changed, 178 insertions(+), 53 deletions(-) create mode 100644 tests/ExceptionParsingTest.php diff --git a/doc/types.md b/doc/types.md index 7cc04f0..10afd2b 100644 --- a/doc/types.md +++ b/doc/types.md @@ -83,10 +83,8 @@ $db->insert('table', [ // Precision options: 0-9 (1=tenths, 3=ms, 6=μs, 9=ns) DateTime64::fromDateTime($dt, 6); // → '2024-06-15 12:00:00.456789' -DateTime64::fromDateTime($dt, 9, 'UTC'); -// PHP has microsecond precision; digits 7-9 are padded with zeros. +// PHP date/time objects provide at most 6 fractional digits. // Use fromString() to preserve an existing nanosecond timestamp. -// Precision outside 0-9 throws InvalidArgumentException. ``` ### Date32 diff --git a/src/Transport/Http.php b/src/Transport/Http.php index 388f8b0..e1e4e4f 100644 --- a/src/Transport/Http.php +++ b/src/Transport/Http.php @@ -830,6 +830,18 @@ public function writeWithParams(string $sql, array $params, bool $exception = tr */ private function convertParamValue(mixed $value): string { + if ($value instanceof \ClickHouseDB\Type\DateTime64) { + return $value->value; + } + if ($value instanceof \ClickHouseDB\Type\Date32) { + return $value->value; + } + if ($value instanceof \ClickHouseDB\Type\UUID) { + return $value->value; + } + if ($value instanceof \ClickHouseDB\Type\IPv4 || $value instanceof \ClickHouseDB\Type\IPv6) { + return $value->value; + } if ($value instanceof \ClickHouseDB\Type\StringValue) { return $this->convertParamValue($value->getValue()); } diff --git a/src/Type/Date32.php b/src/Type/Date32.php index 1e7da0e..aa5db18 100644 --- a/src/Type/Date32.php +++ b/src/Type/Date32.php @@ -7,7 +7,7 @@ use DateTimeInterface; use Stringable; -final class Date32 implements DateType, Stringable +final class Date32 implements Type, Stringable { public string $value; diff --git a/src/Type/DateTime64.php b/src/Type/DateTime64.php index 23bb8ae..3dfcced 100644 --- a/src/Type/DateTime64.php +++ b/src/Type/DateTime64.php @@ -4,15 +4,10 @@ namespace ClickHouseDB\Type; -use DateTimeImmutable; use DateTimeInterface; -use DateTimeZone; -use InvalidArgumentException; use Stringable; -use function substr; - -final class DateTime64 implements DateType, Stringable +final class DateTime64 implements Type, Stringable { public string $value; @@ -26,21 +21,15 @@ public static function fromString(string $value): self return new self($value); } - public static function fromDateTime(DateTimeInterface $dateTime, int $precision = 3, ?string $timezone = null): self + public static function fromDateTime(DateTimeInterface $dateTime, int $precision = 3): self { - if ($precision < 0 || $precision > 9) { - throw new InvalidArgumentException('DateTime64 precision must be between 0 and 9.'); - } - - if ($timezone !== null) { - $dateTime = DateTimeImmutable::createFromInterface($dateTime)->setTimezone(new DateTimeZone($timezone)); + $formatted = $dateTime->format('Y-m-d H:i:s.u'); + $dotPos = strpos($formatted, '.'); + if ($dotPos !== false && $precision > 0) { + $formatted = substr($formatted, 0, $dotPos + 1 + $precision); + } elseif ($precision === 0) { + $formatted = $dateTime->format('Y-m-d H:i:s'); } - - $formatted = $dateTime->format('Y-m-d H:i:s'); - if ($precision > 0) { - $formatted .= '.' . substr($dateTime->format('u') . '000', 0, $precision); - } - return new self($formatted); } diff --git a/src/Type/IPv4.php b/src/Type/IPv4.php index ce2228b..8f5b2bf 100644 --- a/src/Type/IPv4.php +++ b/src/Type/IPv4.php @@ -6,7 +6,7 @@ use Stringable; -final class IPv4 implements StringValue, Stringable +final class IPv4 implements Type, Stringable { public string $value; diff --git a/src/Type/IPv6.php b/src/Type/IPv6.php index 0cae3c6..b54c9b9 100644 --- a/src/Type/IPv6.php +++ b/src/Type/IPv6.php @@ -6,7 +6,7 @@ use Stringable; -final class IPv6 implements StringValue, Stringable +final class IPv6 implements Type, Stringable { public string $value; diff --git a/src/Type/UUID.php b/src/Type/UUID.php index ff99502..a0afb95 100644 --- a/src/Type/UUID.php +++ b/src/Type/UUID.php @@ -6,7 +6,7 @@ use Stringable; -final class UUID implements StringValue, Stringable +final class UUID implements Type, Stringable { public string $value; diff --git a/tests/ExceptionParsingTest.php b/tests/ExceptionParsingTest.php new file mode 100644 index 0000000..dd9a89f --- /dev/null +++ b/tests/ExceptionParsingTest.php @@ -0,0 +1,117 @@ +createMock(CurlerResponse::class); + $response->method('body')->willReturn($body); + // Streaming errors can arrive after the server has already sent HTTP 200. + $response->method('http_code')->willReturn(200); + $response->method('content_type')->willReturn('text/plain'); + $response->method('error_no')->willReturn(0); + $response->method('error')->willReturn(''); + $response->method('headers')->with('X-ClickHouse-Query-Id')->willReturn($queryId); + $request = $this->createMock(CurlerRequest::class); + $request->method('response')->willReturn($response); + $request->method('getRequestExtendedInfo')->willReturnMap([['sql', 'SELECT broken']]); + + try { + (new Statement($request))->error(); + self::fail('Expected DatabaseException'); + } catch (DatabaseException $exception) { + self::assertSame($code, $exception->getCode()); + self::assertSame($name, $exception->getClickHouseExceptionName()); + self::assertSame($queryId, $exception->getQueryId()); + self::assertSame($version, $exception->getServerVersion()); + self::assertSame($trace, $exception->getServerStackTrace()); + self::assertStringContainsString('broken', $exception->getMessage()); + self::assertStringEndsWith("\nIN:SELECT broken", $exception->getMessage()); + } + } + + public function errorFormatProvider(): Generator + { + yield 'legacy e.what without metadata' => [ + 'Code: 60. DB::Exception: Table broken does not exist., e.what() = DB::Exception', + 60, null, null, null, null, + ]; + yield 'legacy with version' => [ + 'Code: 62. DB::Exception: Syntax error: broken (version 21.3.20.1 (official build))', + 62, null, '21.3.20.1', null, 'old-query-id', + ]; + yield 'modern syntax error' => [ + 'Code: 62. DB::Exception: Syntax error: broken. (SYNTAX_ERROR) (version 26.3.3.20 (official build))', + 62, 'SYNTAX_ERROR', '26.3.3.20', null, 'new-query-id', + ]; + yield 'multiline message and plain version' => [ + "Code: 60. DB::Exception: Table broken\ndoes not exist. (UNKNOWN_TABLE) (version 26.3.3)", + 60, 'UNKNOWN_TABLE', '26.3.3', null, null, + ]; + yield 'name without version' => [ + 'Code: 117. DB::Exception: broken UUID. (CANNOT_PARSE_UUID)', + 117, 'CANNOT_PARSE_UUID', null, null, null, + ]; + yield 'bare error' => ['Code: 42. DB::Exception: broken', 42, null, null, null, null]; + yield 'parentheses inside message are not an exception name' => [ + 'Code: 62. DB::Exception: broken (SELECT) near input. (version 21.9.6.24 (official build))', + 62, null, '21.9.6.24', null, null, + ]; + foreach (['21.9.6.24', '26.3.3.20'] as $version) { + yield 'stack trace ' . $version => [ + 'Code: 46. DB::Exception: Unknown function broken. (UNKNOWN_FUNCTION), ' + . "Stack trace (when copying this message, always include the lines below):\n\n" + . "0. DB::Exception::Exception() @ 0x123\n1. DB::executeQuery() @ 0x456\n" + . ' (version ' . $version . " (official build))\n", + 46, 'UNKNOWN_FUNCTION', $version, + "0. DB::Exception::Exception() @ 0x123\n1. DB::executeQuery() @ 0x456", 'trace-query-id', + ]; + } + yield 'legacy stack trace without version' => [ + "Code: 60. DB::Exception: broken, e.what() = DB::Exception, Stack trace:\n\n0. DB::executeQuery() @ 0x123\n", + 60, null, null, '0. DB::executeQuery() @ 0x123', null, + ]; + yield 'streamed error after data' => [ + '{"data":[{"value":1}Code: 241. DB::Exception: broken. (MEMORY_LIMIT_EXCEEDED)' + . ' (version 26.3.3.20 (official build))', + 241, 'MEMORY_LIMIT_EXCEEDED', '26.3.3.20', null, null, + ]; + } + + public function testConstructorAndExistingFactoryRemainCompatible(): void + { + $previous = new \RuntimeException('previous'); + $exception = new DatabaseException('message', 42, $previous); + self::assertSame($previous, $exception->getPrevious()); + self::assertSame('message', $exception->getMessage()); + self::assertSame(42, $exception->getCode()); + self::assertNull($exception->getClickHouseExceptionName()); + self::assertNull($exception->getQueryId()); + self::assertNull($exception->getServerVersion()); + self::assertNull($exception->getServerStackTrace()); + + $exception = DatabaseException::fromClickHouse('message', 42, 'SOME_ERROR', 'query-id'); + self::assertSame('SOME_ERROR', $exception->getClickHouseExceptionName()); + self::assertSame('query-id', $exception->getQueryId()); + self::assertNull($exception->getServerVersion()); + self::assertNull($exception->getServerStackTrace()); + } +} diff --git a/tests/Type/StringDateTypesIntegrationTest.php b/tests/Type/StringDateTypesIntegrationTest.php index b1ecd0d..1ac8b29 100644 --- a/tests/Type/StringDateTypesIntegrationTest.php +++ b/tests/Type/StringDateTypesIntegrationTest.php @@ -17,7 +17,6 @@ use ClickHouseDB\Type\StringType; use ClickHouseDB\Type\Type; use ClickHouseDB\Type\UUID; -use DateTimeImmutable; use PHPUnit\Framework\TestCase; /** @group integration */ @@ -25,7 +24,7 @@ final class StringDateTypesIntegrationTest extends TestCase { use WithClient; - /** @dataProvider values */ + /** @dataProvider newValues */ public function testInsertAndBindingsRoundTrip(string $type, Type $value, string $expected): void { $this->client->write('DROP TABLE IF EXISTS string_date_types'); @@ -50,6 +49,12 @@ public function testNativeParametersRoundTrip(string $type, Type $value, string )->fetchOne('value')); } + /** @return array */ + public static function newValues(): array + { + return array_filter(self::values(), static fn (array $row): bool => $row[1] instanceof \ClickHouseDB\Type\StringValue); + } + /** @return array */ public static function values(): array { @@ -62,11 +67,6 @@ public static function values(): array 'date32' => ['Date32', Date32::fromString('1925-01-01'), '1925-01-01'], 'datetime' => ["DateTime('UTC')", DateTime::fromString('2024-02-29 23:45:12'), '2024-02-29 23:45:12'], 'datetime64' => ["DateTime64(9, 'UTC')", DateTime64::fromString('2024-02-29 23:45:12.123456789'), '2024-02-29 23:45:12.123456789'], - 'datetime64 timezone' => [ - "DateTime64(3, 'Europe/Amsterdam')", - DateTime64::fromDateTime(new DateTimeImmutable('2024-02-29 23:45:12.123456+00:00'), 3, 'Europe/Amsterdam'), - '2024-03-01 00:45:12.123', - ], 'uuid' => ['UUID', UUID::fromString('550e8400-e29b-41d4-a716-446655440000'), '550e8400-e29b-41d4-a716-446655440000'], 'ipv4' => ['IPv4', IPv4::fromString('192.168.1.1'), '192.168.1.1'], 'ipv6' => ['IPv6', IPv6::fromString('2001:db8::1'), '2001:db8::1'], diff --git a/tests/Type/StringDateTypesTest.php b/tests/Type/StringDateTypesTest.php index 116b5bc..8f9cbfe 100644 --- a/tests/Type/StringDateTypesTest.php +++ b/tests/Type/StringDateTypesTest.php @@ -48,17 +48,38 @@ public static function stringTypes(): array return array_map(static fn (string $class): array => [$class], [ StringType::class, Date::class, - Date32::class, DateTime::class, - DateTime64::class, - UUID::class, - IPv4::class, - IPv6::class, Enum8::class, Enum16::class, ]); } + /** @dataProvider legacyStringTypes */ + public function testExistingTypesPreserveRawBindings(string $class): void + { + $value = $class::fromString("'already quoted'"); + self::assertSame("'already quoted'", ValueFormatter::formatValue($value)); + self::assertSame("'already quoted'", ValueFormatter::formatValue($value, false)); + $bindings = new Bindings(); + $bindings->bindParam('value', $value); + self::assertSame("SELECT 'already quoted'", $bindings->process('SELECT :value')); + } + + /** @dataProvider legacyStringTypes */ + public function testExistingTypesPreserveNativeParameterValues(string $class): void + { + $transport = (new \ReflectionClass(\ClickHouseDB\Transport\Http::class))->newInstanceWithoutConstructor(); + $convert = new \ReflectionMethod($transport, 'convertParamValue'); + $convert->setAccessible(true); + self::assertSame("'raw'", $convert->invoke($transport, $class::fromString("'raw'"))); + } + + /** @return list */ + public static function legacyStringTypes(): array + { + return [[Date32::class], [DateTime64::class], [UUID::class], [IPv4::class], [IPv6::class]]; + } + public function testFixedStringRequiresExactByteLength(): void { self::assertSame('é', FixedString::fromString('é', 2)->getValue()); @@ -103,28 +124,16 @@ public static function precisions(): array [1, '2024-02-29 23:45:12.1'], [3, '2024-02-29 23:45:12.123'], [6, '2024-02-29 23:45:12.123456'], - [9, '2024-02-29 23:45:12.123456000'], + [9, '2024-02-29 23:45:12.123456'], + [-1, '2024-02-29 23:45:12.123456'], + [10, '2024-02-29 23:45:12.123456'], ]; } - /** @dataProvider invalidPrecisions */ - public function testDateTime64RejectsInvalidPrecision(int $precision): void - { - $this->expectException(InvalidArgumentException::class); - DateTime64::fromDateTime(new DateTimeImmutable('2024-01-01'), $precision); - } - - /** @return list */ - public static function invalidPrecisions(): array - { - return [[-1], [10]]; - } - public function testTimezoneConversionDoesNotMutateInput(): void { $date = new \DateTime('2024-02-29 23:45:12.123456+00:00'); self::assertSame('2024-03-01 00:45:12', DateTime::fromDateTime($date, 'Europe/Amsterdam')->getValue()); - self::assertSame('2024-03-01 00:45:12.123456000', DateTime64::fromDateTime($date, 9, 'Europe/Amsterdam')->getValue()); self::assertSame('2024-02-29 23:45:12.123456', $date->format('Y-m-d H:i:s.u')); } From db3cfb01b1a055902dc3e9bd945877ef690be940 Mon Sep 17 00:00:00 2001 From: sander-hash Date: Thu, 17 Sep 2026 14:50:33 +0200 Subject: [PATCH 3/4] Fix type naming --- src/Quote/ValueFormatter.php | 4 ++-- src/Transport/Http.php | 2 +- src/Type/DateType.php | 2 +- src/Type/EnumType.php | 2 +- src/Type/FixedString.php | 2 +- src/Type/StringType.php | 2 +- src/Type/{StringValue.php => StringableType.php} | 2 +- tests/Type/StringDateTypesIntegrationTest.php | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) rename src/Type/{StringValue.php => StringableType.php} (82%) diff --git a/src/Quote/ValueFormatter.php b/src/Quote/ValueFormatter.php index 8d820ba..f215b7b 100644 --- a/src/Quote/ValueFormatter.php +++ b/src/Quote/ValueFormatter.php @@ -6,7 +6,7 @@ use ClickHouseDB\Exception\UnsupportedValueType; use ClickHouseDB\Query\Expression\Expression; -use ClickHouseDB\Type\StringValue; +use ClickHouseDB\Type\StringableType; use ClickHouseDB\Type\Type; use DateTimeInterface; @@ -32,7 +32,7 @@ public static function formatValue(mixed $value, bool $addQuotes = true): mixed return $value; } - if ($value instanceof StringValue) { + if ($value instanceof StringableType) { return self::formatValue($value->getValue(), $addQuotes); } diff --git a/src/Transport/Http.php b/src/Transport/Http.php index e1e4e4f..4ce89b7 100644 --- a/src/Transport/Http.php +++ b/src/Transport/Http.php @@ -842,7 +842,7 @@ private function convertParamValue(mixed $value): string if ($value instanceof \ClickHouseDB\Type\IPv4 || $value instanceof \ClickHouseDB\Type\IPv6) { return $value->value; } - if ($value instanceof \ClickHouseDB\Type\StringValue) { + if ($value instanceof \ClickHouseDB\Type\StringableType) { return $this->convertParamValue($value->getValue()); } if ($value instanceof \ClickHouseDB\Type\MapType) { diff --git a/src/Type/DateType.php b/src/Type/DateType.php index 3867433..b7e1980 100644 --- a/src/Type/DateType.php +++ b/src/Type/DateType.php @@ -4,6 +4,6 @@ namespace ClickHouseDB\Type; -interface DateType extends StringValue +interface DateType extends StringableType { } diff --git a/src/Type/EnumType.php b/src/Type/EnumType.php index 78ec637..3140efa 100644 --- a/src/Type/EnumType.php +++ b/src/Type/EnumType.php @@ -4,6 +4,6 @@ namespace ClickHouseDB\Type; -interface EnumType extends StringValue +interface EnumType extends StringableType { } diff --git a/src/Type/FixedString.php b/src/Type/FixedString.php index b1fd515..341b281 100644 --- a/src/Type/FixedString.php +++ b/src/Type/FixedString.php @@ -9,7 +9,7 @@ use function strlen; -final class FixedString implements StringValue, Stringable +final class FixedString implements StringableType, Stringable { public string $value; diff --git a/src/Type/StringType.php b/src/Type/StringType.php index e5209d1..e8c3c37 100644 --- a/src/Type/StringType.php +++ b/src/Type/StringType.php @@ -6,7 +6,7 @@ use Stringable; -final class StringType implements StringValue, Stringable +final class StringType implements StringableType, Stringable { public string $value; diff --git a/src/Type/StringValue.php b/src/Type/StringableType.php similarity index 82% rename from src/Type/StringValue.php rename to src/Type/StringableType.php index d87dda9..d4a7645 100644 --- a/src/Type/StringValue.php +++ b/src/Type/StringableType.php @@ -5,7 +5,7 @@ namespace ClickHouseDB\Type; /** A raw string value that must be escaped when used as an SQL literal. */ -interface StringValue extends Type +interface StringableType extends Type { public function getValue(): string; } diff --git a/tests/Type/StringDateTypesIntegrationTest.php b/tests/Type/StringDateTypesIntegrationTest.php index 1ac8b29..ece308e 100644 --- a/tests/Type/StringDateTypesIntegrationTest.php +++ b/tests/Type/StringDateTypesIntegrationTest.php @@ -52,7 +52,7 @@ public function testNativeParametersRoundTrip(string $type, Type $value, string /** @return array */ public static function newValues(): array { - return array_filter(self::values(), static fn (array $row): bool => $row[1] instanceof \ClickHouseDB\Type\StringValue); + return array_filter(self::values(), static fn (array $row): bool => $row[1] instanceof \ClickHouseDB\Type\StringableType); } /** @return array */ From 51edc81600aac444b0d5b7f3b30ca639baa0dd1e Mon Sep 17 00:00:00 2001 From: sander-hash Date: Thu, 17 Sep 2026 15:00:00 +0200 Subject: [PATCH 4/4] Remove exception parsing test wrong branch --- tests/ExceptionParsingTest.php | 117 --------------------------------- 1 file changed, 117 deletions(-) delete mode 100644 tests/ExceptionParsingTest.php diff --git a/tests/ExceptionParsingTest.php b/tests/ExceptionParsingTest.php deleted file mode 100644 index dd9a89f..0000000 --- a/tests/ExceptionParsingTest.php +++ /dev/null @@ -1,117 +0,0 @@ -createMock(CurlerResponse::class); - $response->method('body')->willReturn($body); - // Streaming errors can arrive after the server has already sent HTTP 200. - $response->method('http_code')->willReturn(200); - $response->method('content_type')->willReturn('text/plain'); - $response->method('error_no')->willReturn(0); - $response->method('error')->willReturn(''); - $response->method('headers')->with('X-ClickHouse-Query-Id')->willReturn($queryId); - $request = $this->createMock(CurlerRequest::class); - $request->method('response')->willReturn($response); - $request->method('getRequestExtendedInfo')->willReturnMap([['sql', 'SELECT broken']]); - - try { - (new Statement($request))->error(); - self::fail('Expected DatabaseException'); - } catch (DatabaseException $exception) { - self::assertSame($code, $exception->getCode()); - self::assertSame($name, $exception->getClickHouseExceptionName()); - self::assertSame($queryId, $exception->getQueryId()); - self::assertSame($version, $exception->getServerVersion()); - self::assertSame($trace, $exception->getServerStackTrace()); - self::assertStringContainsString('broken', $exception->getMessage()); - self::assertStringEndsWith("\nIN:SELECT broken", $exception->getMessage()); - } - } - - public function errorFormatProvider(): Generator - { - yield 'legacy e.what without metadata' => [ - 'Code: 60. DB::Exception: Table broken does not exist., e.what() = DB::Exception', - 60, null, null, null, null, - ]; - yield 'legacy with version' => [ - 'Code: 62. DB::Exception: Syntax error: broken (version 21.3.20.1 (official build))', - 62, null, '21.3.20.1', null, 'old-query-id', - ]; - yield 'modern syntax error' => [ - 'Code: 62. DB::Exception: Syntax error: broken. (SYNTAX_ERROR) (version 26.3.3.20 (official build))', - 62, 'SYNTAX_ERROR', '26.3.3.20', null, 'new-query-id', - ]; - yield 'multiline message and plain version' => [ - "Code: 60. DB::Exception: Table broken\ndoes not exist. (UNKNOWN_TABLE) (version 26.3.3)", - 60, 'UNKNOWN_TABLE', '26.3.3', null, null, - ]; - yield 'name without version' => [ - 'Code: 117. DB::Exception: broken UUID. (CANNOT_PARSE_UUID)', - 117, 'CANNOT_PARSE_UUID', null, null, null, - ]; - yield 'bare error' => ['Code: 42. DB::Exception: broken', 42, null, null, null, null]; - yield 'parentheses inside message are not an exception name' => [ - 'Code: 62. DB::Exception: broken (SELECT) near input. (version 21.9.6.24 (official build))', - 62, null, '21.9.6.24', null, null, - ]; - foreach (['21.9.6.24', '26.3.3.20'] as $version) { - yield 'stack trace ' . $version => [ - 'Code: 46. DB::Exception: Unknown function broken. (UNKNOWN_FUNCTION), ' - . "Stack trace (when copying this message, always include the lines below):\n\n" - . "0. DB::Exception::Exception() @ 0x123\n1. DB::executeQuery() @ 0x456\n" - . ' (version ' . $version . " (official build))\n", - 46, 'UNKNOWN_FUNCTION', $version, - "0. DB::Exception::Exception() @ 0x123\n1. DB::executeQuery() @ 0x456", 'trace-query-id', - ]; - } - yield 'legacy stack trace without version' => [ - "Code: 60. DB::Exception: broken, e.what() = DB::Exception, Stack trace:\n\n0. DB::executeQuery() @ 0x123\n", - 60, null, null, '0. DB::executeQuery() @ 0x123', null, - ]; - yield 'streamed error after data' => [ - '{"data":[{"value":1}Code: 241. DB::Exception: broken. (MEMORY_LIMIT_EXCEEDED)' - . ' (version 26.3.3.20 (official build))', - 241, 'MEMORY_LIMIT_EXCEEDED', '26.3.3.20', null, null, - ]; - } - - public function testConstructorAndExistingFactoryRemainCompatible(): void - { - $previous = new \RuntimeException('previous'); - $exception = new DatabaseException('message', 42, $previous); - self::assertSame($previous, $exception->getPrevious()); - self::assertSame('message', $exception->getMessage()); - self::assertSame(42, $exception->getCode()); - self::assertNull($exception->getClickHouseExceptionName()); - self::assertNull($exception->getQueryId()); - self::assertNull($exception->getServerVersion()); - self::assertNull($exception->getServerStackTrace()); - - $exception = DatabaseException::fromClickHouse('message', 42, 'SOME_ERROR', 'query-id'); - self::assertSame('SOME_ERROR', $exception->getClickHouseExceptionName()); - self::assertSame('query-id', $exception->getQueryId()); - self::assertNull($exception->getServerVersion()); - self::assertNull($exception->getServerStackTrace()); - } -}