From 0aec2c0a7231359688dc9743f5e9500301df8958 Mon Sep 17 00:00:00 2001 From: Simon Podlipsky Date: Mon, 12 Oct 2026 18:59:28 +0200 Subject: [PATCH 1/2] fix: preserve native string parameter values Encode top-level string parameters in ClickHouse escaped-text format so literal backslashes and control characters survive server parsing. Keep nested quoted string conversion and legacy SQL binding unchanged. Document raw string inputs and extend the existing select operation with byte-exact round trips. --- README.md | 3 ++ src/Param/ParamValueConverterRegistry.php | 12 ++++++- tests/Client/SelectTest.php | 44 +++++++++++++++++++++-- 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b5b13aa0..18a633c1 100644 --- a/README.md +++ b/README.md @@ -298,6 +298,9 @@ $output = $client->selectWithParams( All types are supported (except `AggregateFunction`, `SimpleAggregateFunction` and `Nothing` by design). You can also pass `DateTimeInterface` into `Date*` types or native array into `Array`, `Tuple`, `Nested` and Geo types (`Point`, `Polygon`, `Geometry` etc.). +Pass raw strings to native `String` and `FixedString` parameters. The client escapes backslashes and control characters +for ClickHouse; do not escape these values yourself. This also applies to `Nullable` and `LowCardinality` string wrappers. + ### Custom Query Parameter Value Conversion Query parameters passed to `selectWithParams()` are converted into an HTTP-API-compatible format. To overwrite an existing value converter or diff --git a/src/Param/ParamValueConverterRegistry.php b/src/Param/ParamValueConverterRegistry.php index 9028b615..9496c740 100644 --- a/src/Param/ParamValueConverterRegistry.php +++ b/src/Param/ParamValueConverterRegistry.php @@ -26,6 +26,7 @@ use function sprintf; use function strlen; use function strtolower; +use function strtr; use function trim; /** @@ -279,7 +280,16 @@ private static function stringConverter(): Closure string $value, Type|string|null $type = null, bool $nested = false, - ) => $nested ? "'" . Escaper::escape($value) . "'" : $value; + ) => $nested ? "'" . Escaper::escape($value) . "'" : strtr($value, [ + // Native scalar strings use escaped text; nested strings use quoted literals. + '\\' => '\\\\', + "\n" => '\\n', + "\t" => '\\t', + "\r" => '\\r', + "\0" => '\\0', + "\x08" => '\\b', + "\x0C" => '\\f', + ]); } private static function noopConverter(): Closure diff --git a/tests/Client/SelectTest.php b/tests/Client/SelectTest.php index 0711b657..39007d1f 100644 --- a/tests/Client/SelectTest.php +++ b/tests/Client/SelectTest.php @@ -14,15 +14,22 @@ use SimPod\ClickHouseClient\Format\JsonEachRow; use SimPod\ClickHouseClient\Format\Null_; use SimPod\ClickHouseClient\Format\TabSeparated; +use SimPod\ClickHouseClient\Param\ParamValueConverterRegistry; use SimPod\ClickHouseClient\Settings\ArraySettingsProvider; use SimPod\ClickHouseClient\Tests\ClickHouseVersion; use SimPod\ClickHouseClient\Tests\TestCaseBase; use SimPod\ClickHouseClient\Tests\WithClient; +use function bin2hex; +use function implode; use function iterator_to_array; +use function sprintf; +use function strlen; +use function strtoupper; #[CoversClass(RequestFactory::class)] #[CoversClass(PsrClickHouseClient::class)] +#[CoversClass(ParamValueConverterRegistry::class)] #[CoversClass(ServerError::class)] #[CoversClass(Json::class)] #[CoversClass(\SimPod\ClickHouseClient\Output\Json::class)] @@ -38,10 +45,41 @@ final class SelectTest extends TestCaseBase public function testSelectWithParams(): void { - $client = self::$client; - $output = $client->selectWithParams('SELECT {p1:UInt8} AS data', ['p1' => 3], new TabSeparated()); + $value = "actual\n\t\r\0\x08\x0C literal\\n \\N \\x41 'quoted' `identifier` trailing\\"; + $hex = strtoupper(bin2hex($value)); + $sql = sprintf( + <<<'CLICKHOUSE' +SELECT + {number:UInt8}, + hex({string:String}), + hex({fixed:FixedString(%d)}), + hex({nullable:Nullable(String)}), + hex({low_cardinality:LowCardinality(String)}), + hex({nested:Array(Tuple(String, UInt8))}[1].1), + hex({literal_null:Nullable(String)}), + hex(:legacy) +CLICKHOUSE, + strlen($value), + ); + $output = self::$client->selectWithParams( + $sql, + [ + 'number' => 3, + 'string' => $value, + 'fixed' => $value, + 'nullable' => $value, + 'low_cardinality' => $value, + 'nested' => [[$value, 1]], + 'literal_null' => '\\N', + 'legacy' => $value, + ], + new TabSeparated(), + ); - self::assertSame("3\n", $output->contents); + self::assertSame( + implode("\t", ['3', $hex, $hex, $hex, $hex, $hex, '5C4E', $hex]) . "\n", + $output->contents, + ); } #[DataProvider('providerJson')] From 73e00e8f8e890f58d33fa7908f0dbc924f186ecd Mon Sep 17 00:00:00 2001 From: Simon Podlipsky Date: Tue, 13 Oct 2026 09:34:32 +0200 Subject: [PATCH 2/2] style: align parameter test assignments --- tests/Client/SelectTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Client/SelectTest.php b/tests/Client/SelectTest.php index 39007d1f..a16dff14 100644 --- a/tests/Client/SelectTest.php +++ b/tests/Client/SelectTest.php @@ -45,9 +45,9 @@ final class SelectTest extends TestCaseBase public function testSelectWithParams(): void { - $value = "actual\n\t\r\0\x08\x0C literal\\n \\N \\x41 'quoted' `identifier` trailing\\"; - $hex = strtoupper(bin2hex($value)); - $sql = sprintf( + $value = "actual\n\t\r\0\x08\x0C literal\\n \\N \\x41 'quoted' `identifier` trailing\\"; + $hex = strtoupper(bin2hex($value)); + $sql = sprintf( <<<'CLICKHOUSE' SELECT {number:UInt8},