Fix parameters: rewrite @name placeholders only in code positions, start a heredoc only at a token boundary, and treat $ as part of a placeholder name - #513
Conversation
ReplacePlaceholders rewrote @name to {name:ResolvedType} with a bare regex, so any occurrence inside a string literal, quoted identifier, heredoc or comment was rewritten too. The server does not substitute parameters inside literals, so the braces came back as data: a query selecting 'user@id' returned 'user{id:Int32}'. The rewrite now walks the SQL once and only replaces placeholders in code positions, using a shared SqlTextScanner that the type-hint extractor also uses for comment skipping. Fixes: #512
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Pull request overview
This PR fixes a silent data-corruption bug in the ADO.NET parameter pipeline by rewriting @name placeholders only when they appear in SQL code positions (not inside string literals, quoted identifiers, heredocs, or comments), aligning placeholder rewriting with ClickHouse server behavior and preventing literal content like 'user@id' from being mutated into 'user{id:Int32}'.
Changes:
- Replaced the prior regex-based placeholder rewriting with a single-pass, context-aware scanner/rewriter.
- Refactored shared SQL text “skip” primitives into a new
SqlTextScannerhelper and reused them from the type-hint extractor. - Added both an end-to-end regression test and a focused unit test suite for placeholder rewriting contexts; updated changelog/release notes.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| ClickHouse.Driver/ADO/Parameters/SqlPlaceholderRewriter.cs | New single-pass, context-aware @name → {name:Type} rewriter (skips strings/idents/heredocs/comments). |
| ClickHouse.Driver/ADO/Parameters/SqlTextScanner.cs | New shared primitives for skipping quoted regions, heredocs, and comments during SQL scans. |
| ClickHouse.Driver/ADO/Parameters/SqlParameterTypeExtractor.cs | Uses SqlTextScanner for comment skipping helpers (no intended behavior change). |
| ClickHouse.Driver/ADO/Parameters/ClickHouseParameterCollection.cs | Switched placeholder replacement to SqlPlaceholderRewriter and documented the new behavior. |
| ClickHouse.Driver/Utility/StringExtensions.cs | Removed ReplaceMultipleWords (no remaining callers). |
| ClickHouse.Driver.Tests/SQL/SqlParameterizedSelectTests.cs | Added an end-to-end regression test reproducing issue #512 against a real server. |
| ClickHouse.Driver.Tests/ADO/SqlPlaceholderRewriteTests.cs | Added parametrized unit tests covering rewrite/non-rewrite contexts and edge cases. |
| CHANGELOG.md | Added unreleased bug-fix entry for issue #512. |
| RELEASENOTES.md | Added unreleased bug-fix entry for issue #512. |
TrySkipHeredoc returned -1 both when a $ does not open a heredoc and when a valid $tag$ opener is never closed. In the second case the rewriter advanced a single character and went on to rewrite @name placeholders inside the heredoc body, unlike unterminated quotes and block comments which swallow the rest of the text. Return sql.Length for an unterminated heredoc so all unterminated regions behave the same; -1 now means only "no heredoc opens here".
…er-context-aware-rewrite # Conflicts: # CHANGELOG.md # RELEASENOTES.md
…er-context-aware-rewrite # Conflicts: # ClickHouse.Driver/ADO/Parameters/SqlParameterTypeExtractor.cs
Three divergences between SqlPlaceholderRewriter and the ClickHouse lexer (and hence SqlParameterTypeExtractor, which already follows it): - a bare `#` started a line comment, so `@name` after it was skipped even though the server rejects `#x` as an unrecognized token; only `# ` and `#!` open a comment now - `//` line comments were not recognized at all, so `@name` after `//` was rewritten inside a comment - block comments did not nest, so the scanner could leave a nested comment early and mis-lex the remaining comment text as code Verified against a live server (26.5): `SELECT 1 AS a #x` and `#<tab>` are syntax errors, `# `/`#!`/`//` are comments, and `/* /* a */ 1, */ 2` returns 2, i.e. block comments nest.
|
Please resolve the conflicts. |
…er-context-aware-rewrite # Conflicts: # ClickHouse.Driver.Tests/SQL/SqlParameterizedSelectTests.cs
|
Conflicts resolved — the PR is The conflict came from #511 landing on
Merged as a real merge commit ( Verification on the merged head (net10.0): driver builds with 0 errors; focused Note that resolving the conflict may have dismissed a pending review state — re-review welcome. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 306629a. Configure here.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (2)
ClickHouse.Driver.Tests/SQL/SqlParameterizedSelectTests.cs:423
- The test reads from ExecuteReaderAsync() without disposing the returned DbDataReader. If the reader owns the HTTP response/stream, this can keep resources open longer than necessary and can cause flakiness under parallel test runs. Prefer a
using var reader = await ...and then read the single row from it.
using var command = connection.CreateCommand();
command.CommandText = "SELECT 'user@id' AS s, @id AS v";
command.AddParameter("id", 42);
var result = (await command.ExecuteReaderAsync()).GetEnsureSingleRow();
ClickHouse.Driver/ADO/Parameters/SqlTextScanner.cs:9
- SqlTextScanner largely duplicates the SQL-skipping helpers already implemented in SqlParameterTypeExtractor (quoted tokens, heredocs, block/line comments). Keeping two independent implementations reintroduces the drift risk that caused this class of bugs in the first place. Consider making SqlParameterTypeExtractor delegate to SqlTextScanner (or vice versa) so both placeholder rewrite and type-hint extraction share one authoritative scanner.
/// <summary>
/// Low-level helpers for walking SQL text and skipping over regions where parameter placeholders
/// must not be interpreted: quoted strings and identifiers, heredocs and comments.
/// </summary>
internal static class SqlTextScanner
The server only starts a heredoc when the opening $tag$ is closed again. An unclosed tag falls back to lexing the whole run of word characters and dollar signs as one ordinary token, and query-parameter substitution continues after it, so the rewriter must keep rewriting placeholders there.
|
Can we include a fix for #514 in this PR? |
A $ is a word character of an ordinary ClickHouse token, so the server lexes
b$c$ as a single identifier and only opens a $tag$ heredoc at a $ that begins a
token. Both SQL scanners attempted heredoc detection at every $, so an unquoted
identifier containing a $ was mistaken for a heredoc opener and everything up to
the next occurrence of the same $...$ text was skipped as heredoc body, silently
dropping any {name:Type} hint or @name placeholder in between.
Fixes: #514
|
@alex-clickhouse yes — done, folded into this PR as Root cause of #514. Fix (2 source files, ~10 lines): both Verified against a live server (26.5.1.882) rather than by inspection: The last line is the contrast: a real heredoc in the same query stays opaque. A heredoc still opens at index 0, after whitespace/comma/newline, and directly after a closing backtick or quote. Known, documented divergence (in an XML doc comment on the new helper): looking at the preceding character misses the case where it ends a literal rather than a word — Tests — 11 of them fail without the fix (verified by stashing only the two source files), including both new end-to-end cases which reproduce #514's exact
No existing test was edited, weakened or deleted. Focused |
|
Let's fold in #516 as well into this PR? |
ClickHouse lexes a word as a run of word characters and dollar signs and accepts a $ in a query parameter name, so @id$x names the single parameter id$x. The placeholder matcher's end-of-name check excluded $, so a shorter name won over a longer one: with only id defined, SELECT @id$x was rewritten into a different, still valid query instead of being left for the server to reject, as @id2 and @id_x already are. Fixes #516
|
@alex-clickhouse done — #516 is folded in as Concretely, the scanner in this PR had already fixed the "cannot bind a
|

Description
Fixes #512.
ClickHouseParameterCollection.ReplacePlaceholdersrewrote each ADO-style@nameinto{name:ResolvedType}throughStringExtensions.ReplaceMultipleWords, which built one regex out of the parameter names and replaced every match anywhere in the query text. That scanner had no notion of quoting or commenting, so an occurrence inside a string literal was rewritten as well — and because the server does not substitute query parameters inside literals, the rewritten text was returned verbatim as data:SELECT 'user@id' AS scame back asuser{id:Int32}. Silent corruption, no error. The{name:Type}hint extractor on the other side of the same call site is quote/comment-aware, so the two scanners disagreed about what a placeholder is. The rewrite now walks the query once and replaces a placeholder only when it appears in a code position; string literals, double-quoted and backtick identifiers, heredocs and--/#//* */comments are copied through untouched.Realistic shapes that hit the bug: email addresses and handles in literals,
LIKE '%@id%'patterns, and any literal containing@followed by a parameter name — common with ORMs that name parameters after columns.Changes
ADO/Parameters/SqlPlaceholderRewriter.cs(new): single-pass, context-aware@namerewrite. Placeholders are matched longest-first (so@idcannot shadow@id_2) and, as before, are not recognized inside a longer identifier (@idxstays@idx). Unchanged queries are returned as-is; a rewritten query is assembled with oneStringBuilderand spliced copies rather than a regex.ADO/Parameters/SqlTextScanner.cs(new): the skip primitives — quoted regions (both\'and''escapes), heredocs ($$…$$,$tag$…$tag$), line and block comments.ADO/Parameters/SqlParameterTypeExtractor.cs: its two private comment-skip helpers moved toSqlTextScannerverbatim and are now called from there. No behavior change.Utility/StringExtensions.cs: droppedReplaceMultipleWords, whose only caller was the placeholder rewrite.CHANGELOG.md/RELEASENOTES.md: bug-fix entry.Only internal types changed, so
PublicAPI/*.txtis untouched.Test
ClickHouse.Driver.Tests/SQL/SqlParameterizedSelectTests.AddParameter_AdoPlaceholderInsideStringLiteral_LeavesLiteralIntact— end-to-end against a real server, the reproduction from the issue:SELECT 'user@id' AS s, @id AS vmust returnuser@idand42. Fails onmainwithuser{id:Int32}.ClickHouse.Driver.Tests/ADO/SqlPlaceholderRewriteTests— one parametrized test over the contexts the rewrite has to distinguish: literals (plain,''-escaped,\'-escaped,LIKEpattern, empty, unterminated), double-quoted and backtick identifiers, plain and tagged heredocs,--/#/#!//* */comments (including that block comments do not nest, and that code after a line comment is still rewritten), plus the code-position cases that must keep working: single and repeated placeholders,@id_2not shadowed by@id,@idxand@idäleft alone, a lone@, and a placeholder followed by an operator. 14 of its 26 cases fail without the fix; the 12 code-position cases pass before and after, pinning the unchanged behavior.Full
ClickHouse.Driver.Testssuite onnet10.0: 9667 passed, 0 failed, no existing test modified.Follow-up (not in this PR)
While verifying the two scanners now agree, the
{name:Type}extractor still has its own literal tracking that honours''but not\', and skips neither quoted identifiers nor heredocs — so it can still extract a phantom hint from inside a literal. That is the other half already described in #508 and is left to it, to keep this PR to one concern.Pre-PR validation gate
TestCaseSource, integration test against a real server, CHANGELOG + RELEASENOTES updated, no public API change)Also fixes #514 — a heredoc must only start where a token starts
Added at @alex-clickhouse's request (folded in here because it is the same lexing surface): fixes #514.
$is a word character of an ordinary ClickHouse token, so the server lexesb$c$as a single identifier and only opens a$tag$heredoc at a$that begins a token. Both scanners attempted heredoc detection at every$, so an unquoted identifier containing a$was mistaken for an opener and everything up to the next occurrence of the same$...$text was skipped as heredoc body — silently dropping any{name:Type}hint or@nameplaceholder in between.WITH 1 AS b$c$ SELECT {d:Date} AS v, b$c$ AS xfailed withCode: 457 ... cannot be parsed as Date(hint dropped, value formatted as an inferredDateTime), and the@idform failed withCode: 62 Syntax error ... (@).Both
TrySkipHeredocimplementations —SqlParameterTypeExtractor(hint extractor) andSqlTextScanner(used by the@namerewriter) — now refuse to open a heredoc at a$that continues a token, i.e. when the preceding character is an ASCII letter, digit,_or$. Verified against a live 26.5.1.882 server:b$c$,a1$c$,a_$c$,a$$c$$anda$$c$are each single identifiers with parameter substitution continuing around them, while a heredoc still opens at index 0, after whitespace/comma/newline, and directly after a closing backtick or quote.Documented divergence (XML doc comment on the new helper): inspecting the preceding character misses the case where it ends a literal rather than a word —
1$tag$…$tag$,$$a$$$tag$…$tag$— where the server does open a heredoc. Both shapes place two literals next to each other, which the server rejects as a syntax error, so no query it accepts is affected.Tests: 11 fail without the fix (verified by stashing only the two source files) — 5 new cases in the extractor's existing
DollarSignThatDoesNotOpenAHeredocprovider, 4 inHintInsideCommentOrQuotedTokenpinning that a real heredoc still opens, 7 newSqlPlaceholderRewriteTestscases split the same way, and two end-to-end cases inSqlParameterizedSelectTestsreproducing the issue'sCode: 457andCode: 62. No existing test edited, weakened or deleted. FocusedParameter|Sqlsubset onnet10.0: 4400 passed / 0 failed / 136 skipped. CHANGELOG + RELEASENOTES carry a separate #514 entry.Also fixes #516 — a
$is part of the placeholder nameAdded at @alex-clickhouse's request, again the same surface: fixes #516.
ClickHouse accepts a
$in a query parameter name, and its lexer treats$as a word character. Verified on a live 26.5.1.882 server:SELECT {id$x:Int32}withparam_id$x=42returns42, as do{$x:Int32}and{id$:Int32}, andWITH 1 AS id$x SELECT id$xreturns1. The ADO@namerewrite disagreed. Onmainthe regex built from the parameter names made@id$x\b, in which$is an end-of-input anchor, so a$name could never be bound at all; and the\bboundary treated$as a non-word character, so@idmatched inside@id$x.The scanner introduced by this PR already fixed the first half (ordinal compare, longest name first). The second half survived, because
MatchPlaceholder's end-of-name check still excluded$:iddefinedSELECT @id2Code: 62— left alone, server rejects itSELECT @id_xCode: 62— left alone, server rejects itSELECT @id$xSELECT {id:Int32}$xSo an unknown placeholder was reported for a name ending in a letter, digit or$as part of the name, matching the server's word lexer,</code>SqlParameterTypeExtractor.IsParameterNameChar<code>(which already accepted$ `), and the token-boundary rule added above for #514.
_, but silently turned into a different, still valid query when the name contained a$(SELECT @id$xexecuted asSELECT 45 AS \$x`).SqlPlaceholderRewriter.IsWordCharnow countsNon-ASCII letters and digits are deliberately left in
IsWordChareven though the server's word lexer is ASCII-only: such a name cannot be sent asparam_<name>at all, so keeping it part of the name leaves the query untouched for the server to reject instead of rewriting it into another one. The existing@idäcase pins that.Tests: 10 fail without the fix (verified by stashing only
SqlPlaceholderRewriter.cs) — 5 new cases in the existingSqlPlaceholderRewriteTestsprovider for a name continued by a$, a newDollarNamedPlaceholdersprovider (17 cases) covering$at the start/middle/end of a name, doubled$,$1, longest-name precedence, dollar names in literals/quoted identifiers/heredocs/comments, and a name whose trailing$must not be taken for a heredoc opener, plus three end-to-end tests inSqlParameterizedSelectTests:AddParameter_NameContainingDollar_IsBound,AddParameter_TypeHintForNameContainingDollar_IsApplied(the hint extractor and the rewriter must agree on where a$name ends) andAddParameter_NameIsPrefixOfALongerName_IsNotSubstituted, parametrized over@id$x/@id2/@id_xso the equivalence claimed above is asserted rather than assumed.One expectation added earlier in this PR was corrected: the case
SELECT $tag$@id$other$asserted that@idis rewritten there, which #516 disproves —id$other$is one name (and a legal parameter name:{id$other$:Int32}returns42), and substituting@idproduced$tag$42$other$, which the server lexes as one unknown identifier (Code: 47). Its original intent — that the text between a tag and a different tag is code — is now pinned by two cases that separate the two concerns:SELECT $tag$ @id $other$andSELECT $tag$@id$ AS a, $other$(withid$defined). No other test was edited, weakened or deleted.Focused
Parameter|Sqlsubset onnet10.0: 4434 passed / 0 failed / 136 skipped. CHANGELOG + RELEASENOTES carry a separate #516 entry. Only internal types changed, soPublicAPI/*.txtis untouched.