diff --git a/src/Client/Http/RequestFactory.php b/src/Client/Http/RequestFactory.php index 5993e10d..64480f98 100644 --- a/src/Client/Http/RequestFactory.php +++ b/src/Client/Http/RequestFactory.php @@ -13,13 +13,10 @@ use Psr\Http\Message\UriInterface; use SimPod\ClickHouseClient\Exception\UnsupportedParamType; use SimPod\ClickHouseClient\Param\ParamValueConverterRegistry; -use SimPod\ClickHouseClient\Sql\Type; +use SimPod\ClickHouseClient\Sql\NativeParameterParser; -use function array_keys; -use function array_reduce; use function http_build_query; use function is_string; -use function preg_match_all; use function SimPod\ClickHouseClient\absurd; use const PHP_QUERY_RFC3986; @@ -83,8 +80,8 @@ public function prepareSqlRequest( ): RequestInterface { $request = $this->initRequest($requestSettings); - preg_match_all('~\{([a-zA-Z\d_]+)\s*:\s*([a-zA-Z\d ]+(?:\([^{}]*\))*)\s*}~', $sql, $matches); - if ($matches[0] === []) { + $paramToType = NativeParameterParser::parse($sql); + if ($paramToType === []) { $body = $this->streamFactory->createStream($sql); try { return $request->withBody($body); @@ -93,17 +90,6 @@ public function prepareSqlRequest( } } - /** @var array $paramToType */ - $paramToType = array_reduce( - array_keys($matches[1]), - static function (array $acc, string|int $k) use ($matches) { - $acc[$matches[1][$k]] = Type::fromString($matches[2][$k]); - - return $acc; - }, - [], - ); - $streamElements = [['name' => 'query', 'contents' => $sql]]; foreach ($requestOptions->params as $name => $value) { $type = $paramToType[$name] ?? null; diff --git a/src/Sql/NativeParameterParser.php b/src/Sql/NativeParameterParser.php new file mode 100644 index 00000000..0832c961 --- /dev/null +++ b/src/Sql/NativeParameterParser.php @@ -0,0 +1,168 @@ + */ + public static function parse(string $sql): array + { + $types = []; + $length = strlen($sql); + $offset = 0; + $heredocPositions = null; + $heredocCursors = []; + while ($offset < $length) { + $char = $sql[$offset]; + if ($char === "'" || $char === '"' || $char === '`') { + $offset = self::skipQuoted($sql, $offset, $char); + + continue; + } + + if ($char === "\xE2") { + $quote = substr($sql, $offset, 3); + if ($quote === '‘' || $quote === '“') { + $end = strpos($sql, $quote === '‘' ? '’' : '”', $offset + 3); + $offset = $end === false ? $length : $end + 3; + + continue; + } + } + + $prefix = substr($sql, $offset, 2); + if ($prefix === '--' || $prefix === '//' || $prefix === '# ' || $prefix === '#!') { + $end = strpos($sql, "\n", $offset + 2); + $offset = $end === false ? $length : $end + 1; + + continue; + } + + if ($prefix === '/*') { + $offset = self::skipComment($sql, $offset); + + continue; + } + + if ($char === '$') { + $tagEnd = $offset + 1 + strspn($sql, self::WordCharacters, $offset + 1); + if ($tagEnd < $length && $sql[$tagEnd] === '$') { + $delimiter = substr($sql, $offset, $tagEnd - $offset + 1); + $heredocPositions ??= self::indexHeredocDelimiters($sql); + $positions = $heredocPositions[$delimiter]; + $cursor = $heredocCursors[$delimiter] ?? 0; + while (isset($positions[$cursor]) && $positions[$cursor] <= $tagEnd) { + ++$cursor; + } + + $heredocCursors[$delimiter] = $cursor; + if (isset($positions[$cursor])) { + $offset = $positions[$cursor] + strlen($delimiter); + + continue; + } + } + } + + if ($char === '{') { + $matched = preg_match( + '~\G\{([a-zA-Z\d_]+)\s*:\s*([a-zA-Z\d ]+(?:\([^{}]*\))*)\s*}~', + $sql, + $matches, + offset: $offset, + ); + if ($matched === false) { + throw new RuntimeException('Failed to parse native query parameter: ' . preg_last_error_msg()); + } + + if ($matched === 1) { + $types[$matches[1]] = Type::fromString($matches[2]); + $offset += strlen($matches[0]); + + continue; + } + } + + // Dollar signs can belong to bare identifiers; do not start a heredoc halfway through one. + $wordLength = strspn($sql, self::WordCharacters . '$', $offset); + $offset += $wordLength > 0 ? $wordLength : 1; + } + + return $types; + } + + /** @return array> */ + private static function indexHeredocDelimiters(string $sql): array + { + // Index overlapping delimiters once so distinct dollar-bearing identifiers do not rescan the whole SQL. + $positions = []; + $length = strlen($sql); + $offset = strpos($sql, '$'); + while ($offset !== false) { + $tagEnd = $offset + 1 + strspn($sql, self::WordCharacters, $offset + 1); + if ($tagEnd < $length && $sql[$tagEnd] === '$') { + $delimiter = substr($sql, $offset, $tagEnd - $offset + 1); + $positions[$delimiter][] = $offset; + } + + $offset = strpos($sql, '$', $offset + 1); + } + + return $positions; + } + + private static function skipQuoted(string $sql, int $offset, string $quote): int + { + $length = strlen($sql); + ++$offset; + while ($offset < $length) { + $char = $sql[$offset++]; + if ($char === '\\') { + ++$offset; + } elseif ($char === $quote) { + if ($offset >= $length || $sql[$offset] !== $quote) { + break; + } + + ++$offset; + } + } + + return $offset; + } + + private static function skipComment(string $sql, int $offset): int + { + $length = strlen($sql); + $offset += 2; + $depth = 1; + while ($offset < $length && $depth > 0) { + $prefix = substr($sql, $offset, 2); + if ($prefix === '/*') { + ++$depth; + $offset += 2; + } elseif ($prefix === '*/') { + --$depth; + $offset += 2; + } else { + ++$offset; + } + } + + return $offset; + } +} diff --git a/tests/Client/Http/RequestFactoryTest.php b/tests/Client/Http/RequestFactoryTest.php index 9e069f5f..17b0340b 100644 --- a/tests/Client/Http/RequestFactoryTest.php +++ b/tests/Client/Http/RequestFactoryTest.php @@ -83,7 +83,7 @@ public function testParamParsed(): void $now = new DateTimeImmutable(); $request = $requestFactory->prepareSqlRequest( - 'SELECT {p1:String}, {p_2:DateTime}', + "SELECT {p1:String}, {p_2:DateTime('UTC')}", new RequestSettings( new EmptySettingsProvider(), new EmptySettingsProvider(), @@ -105,6 +105,27 @@ public function testParamParsed(): void ); } + public function testQuotedPlaceholderUsesPlainSqlBody(): void + { + $requestFactory = new RequestFactory( + new ParamValueConverterRegistry(), + new Psr17Factory(), + new Psr17Factory(), + ); + + $sql = "SELECT '{context:UnknownType}'"; + $request = $requestFactory->prepareSqlRequest( + $sql, + new RequestSettings( + new EmptySettingsProvider(), + new EmptySettingsProvider(), + ), + new RequestOptions(['context' => '{context:UnknownType}']), + ); + + self::assertSame($sql, $request->getBody()->__toString()); + } + public function testMultipleNestedParamsParsed(): void { $requestFactory = new RequestFactory( diff --git a/tests/Sql/NativeParameterParserTest.php b/tests/Sql/NativeParameterParserTest.php new file mode 100644 index 00000000..4cacfdfc --- /dev/null +++ b/tests/Sql/NativeParameterParserTest.php @@ -0,0 +1,121 @@ + Type::fromString('Array(UUID)'), + 'sensorIds' => Type::fromString('Array(Array(UUID))'), + 'recordedAt' => Type::fromString("DateTime64(6, 'UTC')"), + ], + NativeParameterParser::parse( + "SELECT {serverIds: Array(UUID)}, {sensorIds:Array(Array(UUID))}, {recordedAt:DateTime64(6, 'UTC')}", + ), + ); + } + + #[DataProvider('provideParseIgnoredText')] + public function testParseIgnoredText(string $sql): void + { + self::assertSame([], NativeParameterParser::parse($sql)); + } + + /** @phpstan-return Generator */ + public static function provideParseIgnoredText(): Generator + { + yield 'string literal' => ["SELECT '{context:UnknownType}'"]; + yield 'escaped quote' => [ + <<<'SQL' + SELECT 'it\'s {context:UnknownType}' + SQL, + ]; + + yield 'doubled quote' => ["SELECT 'it''s {context:UnknownType}'"]; + yield 'double-quoted identifier' => ['SELECT "{context:UnknownType}"']; + yield 'doubled double quote' => ['SELECT "a""{context:UnknownType}"']; + yield 'backtick-quoted identifier' => ['SELECT `{context:UnknownType}`']; + yield 'escaped backtick' => ['SELECT `a\`{context:UnknownType}`']; + yield 'unicode string literal' => ['SELECT ‘{context:UnknownType}’']; + yield 'unicode string with em dash' => ['SELECT ‘a—{context:UnknownType}’']; + yield 'unicode quoted identifier' => ['SELECT “{context:UnknownType}”']; + yield 'unicode identifier with emoji' => ['SELECT “😀{context:UnknownType}”']; + yield 'heredoc' => ['SELECT $$\'quoted\' {context:UnknownType} $$']; + yield 'tagged heredoc' => ['SELECT $sql$\'quoted\' {context:UnknownType} $other$ $sql$']; + yield 'dash comment' => ["SELECT 1 -- 'quoted' {context:UnknownType}\n"]; + yield 'comment at end of input' => ['SELECT 1 -- {context:UnknownType}']; + yield 'slash comment' => ["SELECT 1 // 'quoted' {context:UnknownType}\n"]; + yield 'hash comment' => ["SELECT 1 # 'quoted' {context:UnknownType}\n"]; + yield 'shebang comment' => ["#! {context:UnknownType}\nSELECT 1"]; + yield 'block comment' => ["SELECT 1 /* 'quoted' {context:UnknownType} */"]; + yield 'block comment starting with slash' => ['SELECT 1 /*/ {context:UnknownType} */']; + yield 'nested block comment' => ['SELECT 1 /* /* nested */ {context:UnknownType} */']; + yield 'nested comment starting with slash' => ['SELECT 1 /* /*/ nested */ {context:UnknownType} */']; + } + + #[DataProvider('provideParseAroundIgnoredText')] + public function testParseAroundIgnoredText(string $sql): void + { + self::assertEquals(['context' => Type::fromString('String')], NativeParameterParser::parse($sql)); + } + + /** @phpstan-return Generator */ + public static function provideParseAroundIgnoredText(): Generator + { + yield 'literal before real parameter' => ["SELECT '{context:UnknownType}', {context:String}"]; + yield 'literal after real parameter' => ["SELECT {context:String}, '{context:UnknownType}'"]; + yield 'parameter after escaped backslash' => [ + <<<'SQL' + SELECT '\\', {context:String} + SQL, + ]; + + yield 'parameter after line comment' => ["SELECT -- {context:UnknownType}\n{context:String}"]; + yield 'parameter after empty line comment' => ["SELECT --\n{context:String}"]; + yield 'parameter after empty block comment' => ['SELECT /**/{context:String}']; + yield 'parameter directly after block comment' => ['SELECT /* ignored */{context:String}']; + yield 'division directly after block comment' => ['SELECT 8 /* ignored *// {context:String}']; + yield 'adjacent nested comment delimiters' => ['SELECT /* /**/*/ {context:String}']; + yield 'parameter after nested comment' => ['SELECT /* /* nested */ {context:UnknownType} */ {context:String}']; + + yield 'parameter after heredoc' => ['SELECT $sql${context:UnknownType}$sql$, {context:String}']; + yield 'heredoc after real parameter' => ['SELECT {context:String}, $sql${context:UnknownType}$sql$']; + yield 'parameter after empty heredoc' => ['SELECT $$$$, {context:String}']; + yield 'parameter after unicode identifier' => ['SELECT “😀{context:UnknownType}”, {context:String}']; + yield 'parameter after empty unicode string' => ['SELECT ‘’, {context:String}']; + yield 'unicode mathematical minus' => ['SELECT 1 − 2, {context:String}']; + yield 'dollars in bare identifiers' => ['SELECT foo$sql$, {context:String}, foo$sql$ FROM t']; + yield 'partial dollar tag at end of input' => ['SELECT {context:String}, $column']; + yield 'dollar identifier without closing dollar' => ['SELECT $column + {context:String}']; + yield 'many distinct dollar-bearing identifiers' => [ + 'SELECT ' . implode(', ', array_map(static fn (int $i) => '$column' . $i . '$', range(0, 29_999))) + . ', {context:String} FROM t', + ]; + + yield 'parameter after large escaped literal' => [ + "SELECT '" . str_repeat('\\x', 10_000) . "', {context:String}", + ]; + + yield 'parameter after large block comment' => [ + 'SELECT /* ' . str_repeat('/', 10_000) . ' */ {context:String}', + ]; + } +}