diff --git a/.github/workflows/infection.yml b/.github/workflows/infection.yml index 7bc9d019..d269d1ef 100644 --- a/.github/workflows/infection.yml +++ b/.github/workflows/infection.yml @@ -20,6 +20,19 @@ jobs: - name: Build the docker compose stack run: docker compose -f tests/docker-compose.yaml up -d + - name: Wait for ClickHouse + run: | + for attempt in {1..30}; do + if curl --fail --silent http://127.0.0.1:28123/ping; then + exit 0 + fi + + sleep 1 + done + + docker compose -f tests/docker-compose.yaml logs + exit 1 + - name: Install PHP uses: shivammathur/setup-php@v2 with: diff --git a/src/Param/ParamValueConverterRegistry.php b/src/Param/ParamValueConverterRegistry.php index 0358c2d2..9028b615 100644 --- a/src/Param/ParamValueConverterRegistry.php +++ b/src/Param/ParamValueConverterRegistry.php @@ -87,8 +87,16 @@ public function __construct(array $registry = []) 'UUID' => self::stringConverter(), - 'Nullable' => fn (mixed $v, Type $type) => $this->get($type->params)($v, null, false), - 'LowCardinality' => fn (mixed $v, Type $type) => $this->get($type->params)($v, null, false), + 'Nullable' => fn ( + mixed $v, + Type $type, + bool $nested = false, + ) => $this->convertWrappedValue($v, $type, $nested), + 'LowCardinality' => fn ( + mixed $v, + Type $type, + bool $nested = false, + ) => $this->convertWrappedValue($v, $type, $nested), 'decimal' => self::decimalConverter(), 'decimal32' => self::decimalConverter(), @@ -102,13 +110,15 @@ public function __construct(array $registry = []) 'date32' => self::dateConverter(), 'datetime' => self::dateTimeConverter(), 'datetime32' => self::dateTimeConverter(), - 'datetime64' => static function (mixed $value) { + 'datetime64' => static function (mixed $value, Type|string|null $type = null, bool $nested = false) { if ($value instanceof DateTimeInterface) { - return $value->format('U.u'); + $value = $value->format('U.u'); } if (is_string($value) || is_float($value) || is_int($value)) { - return $value; + return $nested + ? "'" . Escaper::escape((string) $value) . "'" + : $value; } throw UnsupportedParamValue::type($value); @@ -123,8 +133,8 @@ public function __construct(array $registry = []) 'Dynamic' => self::noopConverter(), 'Variant' => self::noopConverter(), - 'IPv4' => self::noopConverter(), - 'IPv6' => self::noopConverter(), + 'IPv4' => self::stringConverter(), + 'IPv6' => self::stringConverter(), 'enum' => self::noopConverter(), 'Enum8' => self::noopConverter(), @@ -236,7 +246,7 @@ public function __construct(array $registry = []) return '(' . $innerExpression . ')'; }, ]; - $this->registry = array_merge($defaultRegistry, $registry); + $this->registry = array_merge($defaultRegistry, $registry); } /** @@ -359,4 +369,12 @@ private function splitTypes(string $types): array return $result; } + + /** @throws UnsupportedParamType */ + private function convertWrappedValue(mixed $value, Type $type, bool $nested): mixed + { + $innerType = Type::fromString($type->params); + + return $this->get($innerType)($value, $innerType, $nested); + } } diff --git a/tests/Client/Http/RequestFactoryTest.php b/tests/Client/Http/RequestFactoryTest.php index 35be86ee..9e069f5f 100644 --- a/tests/Client/Http/RequestFactoryTest.php +++ b/tests/Client/Http/RequestFactoryTest.php @@ -17,6 +17,8 @@ use SimPod\ClickHouseClient\Settings\EmptySettingsProvider; use SimPod\ClickHouseClient\Tests\TestCaseBase; +use function sprintf; + #[CoversClass(RequestFactory::class)] final class RequestFactoryTest extends TestCaseBase { @@ -129,4 +131,120 @@ public function testMultipleNestedParamsParsed(): void self::assertStringContainsString('param_serverIds', $body); self::assertStringContainsString('param_sensorIds', $body); } + + public function testNestedDateTime64ParamIsQuoted(): void + { + $dateTime = new DateTimeImmutable('2026-07-30 12:00:00.123456+02:00'); + + $requestFactory = new RequestFactory( + new ParamValueConverterRegistry(), + new Psr17Factory(), + new Psr17Factory(), + ); + + $request = $requestFactory->prepareSqlRequest( + 'SELECT {inputs:Array(Tuple(DateTime64(6), UUID))}', + new RequestSettings( + new EmptySettingsProvider(), + new EmptySettingsProvider(), + ), + new RequestOptions( + [ + 'inputs' => [ + [ + $dateTime, + 'c8965e35-e785-4b05-a675-000000000000', + ], + ], + ], + ), + ); + + self::assertStringContainsString( + "('" . $dateTime->format('U.u') . "','c8965e35-e785-4b05-a675-000000000000')", + $request->getBody()->__toString(), + ); + } + + public function testTopLevelDateTime64ParamRemainsNumeric(): void + { + $requestFactory = new RequestFactory( + new ParamValueConverterRegistry(), + new Psr17Factory(), + new Psr17Factory(), + ); + + $request = $requestFactory->prepareSqlRequest( + 'SELECT {value:DateTime64(6)}', + new RequestSettings( + new EmptySettingsProvider(), + new EmptySettingsProvider(), + ), + new RequestOptions( + [ + 'value' => new DateTimeImmutable('2026-07-30 12:00:00.123456'), + ], + ), + ); + + self::assertStringContainsString('1785412800.123456', $request->getBody()->__toString()); + } + + public function testNullableNestedDateTime64ParamIsQuoted(): void + { + $requestFactory = new RequestFactory( + new ParamValueConverterRegistry(), + new Psr17Factory(), + new Psr17Factory(), + ); + + $request = $requestFactory->prepareSqlRequest( + 'SELECT {inputs:Array(Nullable(DateTime64(6)))}', + new RequestSettings( + new EmptySettingsProvider(), + new EmptySettingsProvider(), + ), + new RequestOptions( + [ + 'inputs' => [ + new DateTimeImmutable('2026-07-30 12:00:00.123456'), + ], + ], + ), + ); + + self::assertStringContainsString( + "['1785412800.123456']", + $request->getBody()->__toString(), + ); + } + + /** @param list $values */ + #[DataProvider('provideNestedIpParameters')] + public function testNestedIpParametersAreQuoted(string $type, array $values, string $expected): void + { + $requestFactory = new RequestFactory( + new ParamValueConverterRegistry(), + new Psr17Factory(), + new Psr17Factory(), + ); + + $request = $requestFactory->prepareSqlRequest( + sprintf('SELECT {inputs:Array(%s)}', $type), + new RequestSettings( + new EmptySettingsProvider(), + new EmptySettingsProvider(), + ), + new RequestOptions(['inputs' => $values]), + ); + + self::assertStringContainsString($expected, $request->getBody()->__toString()); + } + + /** @return Generator, string}> */ + public static function provideNestedIpParameters(): Generator + { + yield 'IPv4' => ['IPv4', ['192.0.2.1', '198.51.100.1'], "['192.0.2.1','198.51.100.1']"]; + yield 'IPv6' => ['IPv6', ['::ffff:192.0.2.1', '2001:db8::1'], "['::ffff:192.0.2.1','2001:db8::1']"]; + } } diff --git a/tests/Param/ParamValueConverterRegistryTest.php b/tests/Param/ParamValueConverterRegistryTest.php index 73fdc4f0..1c404f50 100644 --- a/tests/Param/ParamValueConverterRegistryTest.php +++ b/tests/Param/ParamValueConverterRegistryTest.php @@ -117,6 +117,7 @@ public static function providerConvert(): Generator { yield 'Array' => ['Array(String)', "['foo','bar']", "['foo','bar']"]; yield 'Array LC' => ['Array(LowCardinality(String))', "['foo','bar']", "['foo','bar']"]; + yield 'Array LC (array)' => ['Array(LowCardinality(String))', ['foo', 'bar'], "['foo','bar']"]; yield 'Array (array)' => ['Array(String)', ['foo', 'bar', "baz'"], "['foo','bar','baz\\'']"]; yield 'Array Tuple' => ['Array(Tuple(String, String))', [['foo', 'bar']], "[('foo','bar')]"]; yield 'Array Tuple Complex' => [ @@ -156,6 +157,13 @@ public static function providerConvert(): Generator yield 'UUID' => ['UUID', 'de90cd12-7100-436e-bfb8-f77e4c7a224f', 'de90cd12-7100-436e-bfb8-f77e4c7a224f']; + yield 'Array IPv4' => ['Array(IPv4)', ['192.0.2.1', '198.51.100.1'], "['192.0.2.1','198.51.100.1']"]; + yield 'Array IPv6' => [ + 'Array(IPv6)', + ['::ffff:192.0.2.1', '2001:db8::1'], + "['::ffff:192.0.2.1','2001:db8::1']", + ]; + yield 'Date' => ['Date', '2023-02-01', '2023-02-01']; yield 'Date (datetime)' => ['Date', new DateTimeImmutable('2023-02-01'), '2023-02-01']; yield 'Date32' => ['Date32', new DateTimeImmutable('2023-02-01'), '2023-02-01']; @@ -194,6 +202,30 @@ public static function providerConvert(): Generator '2023-02-01 01:02:03.123456000', ]; + yield 'Array DateTime64(9) (integer)' => [ + "Array(DateTime64(9, 'UTC'))", + [1675213323123456789], + "['2023-02-01 01:02:03.123456789']", + ]; + + yield 'Array DateTime64(9) (float)' => [ + "Array(DateTime64(9, 'UTC'))", + [1675213323.1235], + "['2023-02-01 01:02:03.123500000']", + ]; + + yield 'Array DateTime64(9) (string)' => [ + "Array(DateTime64(9, 'UTC'))", + ['1675213323.123456789'], + "['2023-02-01 01:02:03.123456789']", + ]; + + yield 'Array DateTime64(6) (DateTime with timezone)' => [ + "Array(DateTime64(6, 'UTC'))", + [new DateTimeImmutable('2023-02-01 03:02:03.123456+02:00')], + "['2023-02-01 01:02:03.123456']", + ]; + yield 'Bool' => ['Bool', true, 'true']; yield 'Dynamic' => ['Bool', true, 'true'];