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..10afd2b 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,10 @@ $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' +// PHP date/time objects provide at most 6 fractional digits. +// Use fromString() to preserve an existing nanosecond timestamp. ``` ### Date32 @@ -112,6 +131,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..f215b7b 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\StringableType; 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 StringableType) { + 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..4ce89b7 100644 --- a/src/Transport/Http.php +++ b/src/Transport/Http.php @@ -842,6 +842,9 @@ 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\StringableType) { + 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/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/DateType.php b/src/Type/DateType.php new file mode 100644 index 0000000..b7e1980 --- /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..3140efa --- /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/StringType.php b/src/Type/StringType.php new file mode 100644 index 0000000..e8c3c37 --- /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/StringableType.php b/src/Type/StringableType.php new file mode 100644 index 0000000..d4a7645 --- /dev/null +++ b/src/Type/StringableType.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 newValues(): array + { + return array_filter(self::values(), static fn (array $row): bool => $row[1] instanceof \ClickHouseDB\Type\StringableType); + } + + /** @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'], + '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..8f9cbfe --- /dev/null +++ b/tests/Type/StringDateTypesTest.php @@ -0,0 +1,144 @@ +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, + DateTime::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()); + 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.123456'], + [-1, '2024-02-29 23:45:12.123456'], + [10, '2024-02-29 23:45:12.123456'], + ]; + } + + 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-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** | ## Ограничения