Fix {name:Type} hint scanner to follow the server lexer (#508) - #509
Fix {name:Type} hint scanner to follow the server lexer (#508)#509polyglotAI-bot wants to merge 1 commit into
Conversation
The {name:Type} hint scanner only understood --/# line comments,
non-nested block comments and single-quoted strings with '' escapes, so
hints inside // comments, nested block comments, quoted identifiers,
backslash-escaped strings and heredocs were either picked up spuriously
or dropped after the scanner desynchronised.
Fixes: #508
There was a problem hiding this comment.
Pull request overview
This PR aligns the client-side {name:Type} hint scanner (SqlParameterTypeExtractor) with the ClickHouse server lexer so that parameter type hints are neither incorrectly picked up from comments/quoted tokens nor silently missed after lexer desynchronization.
Changes:
- Extended the SQL scanner to correctly skip
//line comments, nested/* */block comments, quoted identifiers (\...`,"..."), backslash escapes, and$tag$ ...$tag$heredocs; tightened#comments to only#/#!`. - Added/updated unit tests to cover the newly supported lexical constructs and the corrected
#behavior. - Added integration tests to pin the end-to-end parameter binding behavior against a real server; updated changelog/release notes.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| ClickHouse.Driver/ADO/Parameters/SqlParameterTypeExtractor.cs | Updates the lexer/scanner to match server behavior for comments, quoting, escapes, and heredocs during {name:Type} extraction. |
| ClickHouse.Driver.Tests/ADO/SqlParameterTypeExtractorTests.cs | Adds unit coverage for ignored hints inside comments/quoted tokens and ensures hints after those constructs are still found. |
| ClickHouse.Driver.Tests/SQL/SqlParameterizedSelectTests.cs | Adds integration coverage confirming real parameter typing behavior is correct end-to-end. |
| CHANGELOG.md | Documents the user-visible bug fix for issue #508. |
| RELEASENOTES.md | Mirrors the changelog entry for the same bug fix. |
| var tag = sql.Substring(startIndex, i - startIndex + 1); | ||
| var closeIndex = sql.IndexOf(tag, i + 1, StringComparison.Ordinal); | ||
| if (closeIndex < 0) | ||
| return false; | ||
|
|
||
| endIndex = closeIndex + tag.Length; | ||
| return true; |
There was a problem hiding this comment.
Thanks — I checked this and I'm keeping the current form; the "hot path" premise doesn't hold for this branch.
Where the allocation actually happens: TrySkipHeredoc returns false before the Substring unless a complete $tag$ opener is present (the sql[i] != '$' guard on the line above). So there is no allocation for an ordinary $ in a query, and no allocation at all for the overwhelmingly common case of SQL containing no heredoc. When one is present, the cost is a single short string (a heredoc tag is a handful of ASCII word chars) per heredoc opener.
Where the real allocations are: ExtractTypeHints is called once per command (ClickHouseParameterCollection.ResolveTypeNames), and on that call it unconditionally allocates the result Dictionary plus, for every extracted hint, two Substrings and two Trims in TryExtractParameter. One optional short tag string on a rare branch is noise next to the allocations the method makes by design on every invocation, so removing it wouldn't move a measurable needle.
Why I'd rather not churn it: the change is behaviour-identical by construction, which means it cannot be pinned by a regression test — it would be an unverifiable refactor of a lexer that currently has a green 18-check matrix and full coverage of this path. Our policy is not to push behaviour-neutral micro-optimisations to a reviewed lexer for that reason.
That said, the allocation-free form is a one-liner if a maintainer wants it (the driver targets net6.0+, so MemoryExtensions.IndexOf is available): replace the Substring + string.IndexOf pair with sql.AsSpan(i + 1).IndexOf(sql.AsSpan(startIndex, i - startIndex + 1)) and offset the result. Happy to apply that on request — leaving this thread open for the maintainer to decide.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
|
The top-level scanner change looks right — I verified every construct against a live 26.6 server ( One gap worth closing before merge. The
|
| Hint | main |
this PR |
|---|---|---|
{p:Enum8('a\'b' = 1)} |
hint dropped entirely → silent fallback to CLR inference | Enum8('a\'b' = 1) |
{p:Tuple(`a}b` UInt8)} |
truncated at the inner } → wrong type |
Tuple(`a}b` UInt8) |
{p:Tuple("a}b" UInt8)} |
truncated at the inner } → wrong type |
Tuple("a}b" UInt8) |
The first is the most user-visible: on main, a backslash-escaped quote inside an Enum hint leaves the old scanner believing it is still inside a string literal, so it runs off the end of the hint, returns (null, null, 0), and the parameter silently falls back to CLR-type inference. Server side these all round-trip fine:
SELECT {p:Enum8('a\'b' = 1)} param_p=a'b -> a\'b
SELECT tupleElement({p:Tuple(`a b` UInt8)}, 'a b') param_p=(5) -> 5
Suggest adding a fourth TestCaseSource group in SqlParameterTypeExtractorTests, in the same style as the existing ones:
private static IEnumerable<TestCaseData> QuotedTokenInsideTypeHint()
{
yield return new TestCaseData(@"SELECT {p:Enum8('a\'b' = 1)}", @"Enum8('a\'b' = 1)");
yield return new TestCaseData("SELECT tupleElement({p:Tuple(`a}b` UInt8)}, 'a}b')", "Tuple(`a}b` UInt8)");
yield return new TestCaseData("SELECT {p:Tuple(\"a}b\" UInt8)}", "Tuple(\"a}b\" UInt8)");
}An integration case for the Enum8 one would be worth it too, since that path is the one that silently produces a wrong parameter type rather than throwing.
Minor, non-blocking
SkipQuotedToken's unterminatedreturn sql.Lengthis the only uncovered new line after running the full parameter test surface. It matters slightly more than a normal defensive branch: an unbalanced"or`now swallows the rest of the query and drops every later hint, where before this PR both characters were transparent. Only reachable on SQL the server rejects anyway, but one negative case would pin it.- Pre-existing, not for this PR:
colonIndex = sql.IndexOf(':', startIndex + 1)is unbounded, so a{with no colon before its}steals the colon from the next parameter —"SELECT {a}, {b:Int32}"yields{ "a}, {b" => "Int32" }and losesb. The server rejects{a}(Expected colon between name and type) so impact is limited to already-invalid SQL, but the XML doc onExtractTypeHintsadvertises{name}as supported input, so the two disagree. - Also pre-existing and a good follow-up issue: this PR fixes lexing in step 1 of the pipeline, but step 2 (
ReplacePlaceholders->StringExtensions.ReplaceMultipleWords) is a bareRegex.Replacewith no quote/comment awareness. Against 26.6,SELECT 'user@val.com' AS lit, @val AS pwith parametervalreturnsuser{val:String}.com— the string literal is rewritten. That is the same bug class, but corrupting query text rather than dropping a hint.
Description
Fixes #508.
SqlParameterTypeExtractorscans the SQL text for{name:Type}hints, skipping comments and quoted tokens on the way. Its lexer only understood--and#line comments, non-nested/* */block comments, and single-quoted strings with''as the only escape. The server lexer also accepts//line comments, nested block comments, backtick- and double-quoted identifiers, backslash escapes inside quoted tokens, and$tag$ ... $tag$heredocs — and it treats#as a comment marker only when followed by a space or!. Because the two disagreed, hints were wrong in both directions: a{p:Type}sitting in text the client did not recognise as a comment was picked up as a real hint (throwingParameter 'p' has conflicting type hintson a query the server accepts), and a--,#or'inside a quoted identifier, heredoc or backslash-escaped string desynchronised the scanner so every real hint after it was silently dropped and the parameter fell back to CLR-type inference.Every construct below was verified against a real server (26.5):
SELECT 1 //x,SELECT 1 # x,SELECT 1 #!xare comments whileSELECT 1 #xis a syntax error;/* a /* b */ still comment */is one comment;`a--b`,"a--b",$$--$$and'a\'b'are single quoted tokens.Changes
ADO/Parameters/SqlParameterTypeExtractor.cs//now starts a line comment;#starts one only when followed by' 'or'!'.SkipBlockCommenttracks depth instead of jumping to the first*/.SkipQuotedTokenhandles single-quoted strings and backtick/double-quoted identifiers, honouring both doubling ('') and backslash (\') escapes. It is used both at the top level and for quoted tokens inside a type hint.TrySkipHeredocskips$tag$ ... $tag$(empty or ASCII-word-character tag, matching terminator required); a$that does not open a terminated heredoc stays an ordinary character.Test
SqlParameterTypeExtractorTests— threeTestCaseSourcegroups: a hint inside a//comment, a nested block comment, a backtick/double-quoted identifier, a heredoc ($$and$tag$) or a backslash-escaped string is ignored; a hint after any of those constructs is still extracted (including doubling escapes and a#/--hidden inside them); and a$that does not open a heredoc does not swallow the following hint.SqlParameterizedSelectTests— two integration cases throughClickHouseConnection/ClickHouseCommandagainst a real server, so the fix is pinned on the live runtime path: a conflicting hint hidden in a//comment, nested comment or heredoc no longer throws and the real hint is used; and a{val:DateTime64(3, 'UTC')}hint placed after`a--b`/"a'b"/$$--$$is honoured (with the hint dropped the value is formatted asDateTimeand the milliseconds are lost).mainand passes with the fix; the fullClickHouse.Driver.Testssuite is green (9670 passed).ExtractTypeHints_ParameterInHashCommentNoSpace_IgnoresCommentasserted that#{val:String}is a comment, which is the buggy behavior — the server rejects that query with code 62. It is nowExtractTypeHints_BareHash_NotTreatedAsCommentand pins the correct behavior. No other test was changed.Pre-PR validation gate
TestCaseSourceparametrization, integration tests against a real server, CHANGELOG + RELEASENOTES updated, no public API change)