Description
bindQuoteState in bind.go protects placeholder-looking text inside single quotes, double quotes, backtick identifiers and comments, but it has no state for ClickHouse heredoc (dollar-quoted) string literals ($$...$$, also the tagged form $tag$...$tag$).
A heredoc is an ordinary string literal to the server — SELECT $$a?b$$ returns a?b — so any ?, $N or @name inside it is literal text. Because the scanner walks straight through the heredoc, every binding mode misbehaves:
Query (1 arg = 42) |
Expected |
Actual |
INSERT INTO t (s, n) VALUES ($$a?b$$, ?) |
(..., 42) |
error have no arg for param ? at last 1 positions — the heredoc's ? ate the argument |
INSERT INTO t (s, n) VALUES ($$a@b$$, @n) |
(..., 42) |
error have no arg for "@b" param |
INSERT INTO t (s, n) VALUES ($$1$$, $1) |
($$1$$, 42) |
silently corrupted SQL: ($42$$, 42) |
INSERT INTO t (s, n) VALUES ($$a?b$$, $1) |
(..., 42) |
error clickhouse [bind]: mixed named, numeric or positional parameters (false positive from bindParamsFormats) |
The third row is the worst case: no error is raised, the heredoc's opening $$ is partially consumed as a $1-style placeholder, and a malformed query is sent to the server. The fourth row is a spurious ErrBindMixedParamsFormats, because bindParamsFormats also scans the heredoc body.
This is the same class of gap that #1860 / #1879 / #1537 closed for quotes and comments; heredocs were simply never added. Related to but distinct from #1950, which is about the PrepareBatch insert-query parser / comment stripper rather than the bind scanner.
ClickHouse server version
Code analysis plus a client-side unit test; not verified against a running server (no ClickHouse instance was reachable in this environment). The defect is purely client-side — the server accepts all four statements once the heredoc is passed through unchanged.
Reproduction
Drop this in the root package (e.g. bind_heredoc_test.go) and run go test -run TestHeredocBind ./:
package clickhouse
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHeredocBind(t *testing.T) {
tz := time.UTC
t.Run("positional", func(t *testing.T) {
q, err := bind(tz, "INSERT INTO t (s, n) VALUES ($$a?b$$, ?)", 42)
require.NoError(t, err)
assert.Equal(t, "INSERT INTO t (s, n) VALUES ($$a?b$$, 42)", q)
})
t.Run("named", func(t *testing.T) {
q, err := bind(tz, "INSERT INTO t (s, n) VALUES ($$a@b$$, @n)", Named("n", 42))
require.NoError(t, err)
assert.Equal(t, "INSERT INTO t (s, n) VALUES ($$a@b$$, 42)", q)
})
t.Run("numeric", func(t *testing.T) {
q, err := bind(tz, "INSERT INTO t (s, n) VALUES ($$1$$, $1)", 42)
require.NoError(t, err)
assert.Equal(t, "INSERT INTO t (s, n) VALUES ($$1$$, 42)", q)
})
t.Run("mixed_false_positive", func(t *testing.T) {
q, err := bind(tz, "INSERT INTO t (s, n) VALUES ($$a?b$$, $1)", 42)
require.NoError(t, err)
assert.Equal(t, "INSERT INTO t (s, n) VALUES ($$a?b$$, 42)", q)
})
}
Actual output on main:
--- FAIL: TestHeredocBind/positional
Received unexpected error: have no arg for param ? at last 1 positions
--- FAIL: TestHeredocBind/named
Received unexpected error: have no arg for "@b" param
--- FAIL: TestHeredocBind/numeric
expected: "INSERT INTO t (s, n) VALUES ($$1$$, 42)"
actual : "INSERT INTO t (s, n) VALUES ($42$$, 42)"
--- FAIL: TestHeredocBind/mixed_false_positive
Received unexpected error: clickhouse [bind]: mixed named, numeric or positional parameters
The same reaches users through database/sql (db.Exec("INSERT INTO t (s, n) VALUES ($$a?b$$, ?)", 42)) and through conn.Exec / conn.Query, since both go through bind.
Suggested fix
In bind.go, extend bindQuoteState (roughly lines 113–214) with a heredoc state, e.g. inHeredoc bool plus heredocTag string:
- In the raw branch of
update, on $ scan forward for a closing $ over the tag charset (ClickHouse allows the empty tag $$ as well as $tag$); if one is found, enter heredoc mode and record the tag, returning the index of the tag's closing $ so the caller's loop skips it.
- While in heredoc mode, look only for the matching
$tag$ terminator — heredocs are raw, so no backslash escaping applies.
- Include
inHeredoc in both inProtectedContext() and inIdentifierOrComment(), so bindPositional skips a ? inside a heredoc without applying the \? unescaping, and bindNumeric / bindNamed / bindParamsFormats skip the body entirely.
Care is needed to keep $1-style numeric placeholders working: $ followed by a digit is a placeholder, not a heredoc opener, so $ should only start a heredoc when a valid closing tag delimiter follows.
Link
Analogous client-side parser gap reported for clickhouse-java: ClickHouse/clickhouse-java#3013 (there a heredoc makes the JavaCC INSERT-VALUES grammar fail and prepareStatement throws an NPE). Tracking: https://github.com/ClickHouse/integrations-ai-playground/issues/353
Description
bindQuoteStateinbind.goprotects placeholder-looking text inside single quotes, double quotes, backtick identifiers and comments, but it has no state for ClickHouse heredoc (dollar-quoted) string literals ($$...$$, also the tagged form$tag$...$tag$).A heredoc is an ordinary string literal to the server —
SELECT $$a?b$$returnsa?b— so any?,$Nor@nameinside it is literal text. Because the scanner walks straight through the heredoc, every binding mode misbehaves:42)INSERT INTO t (s, n) VALUES ($$a?b$$, ?)(..., 42)have no arg for param ? at last 1 positions— the heredoc's?ate the argumentINSERT INTO t (s, n) VALUES ($$a@b$$, @n)(..., 42)have no arg for "@b" paramINSERT INTO t (s, n) VALUES ($$1$$, $1)($$1$$, 42)($42$$, 42)INSERT INTO t (s, n) VALUES ($$a?b$$, $1)(..., 42)clickhouse [bind]: mixed named, numeric or positional parameters(false positive frombindParamsFormats)The third row is the worst case: no error is raised, the heredoc's opening
$$is partially consumed as a$1-style placeholder, and a malformed query is sent to the server. The fourth row is a spuriousErrBindMixedParamsFormats, becausebindParamsFormatsalso scans the heredoc body.This is the same class of gap that #1860 / #1879 / #1537 closed for quotes and comments; heredocs were simply never added. Related to but distinct from #1950, which is about the
PrepareBatchinsert-query parser / comment stripper rather than the bind scanner.ClickHouse server version
Code analysis plus a client-side unit test; not verified against a running server (no ClickHouse instance was reachable in this environment). The defect is purely client-side — the server accepts all four statements once the heredoc is passed through unchanged.
Reproduction
Drop this in the root package (e.g.
bind_heredoc_test.go) and rungo test -run TestHeredocBind ./:Actual output on
main:The same reaches users through
database/sql(db.Exec("INSERT INTO t (s, n) VALUES ($$a?b$$, ?)", 42)) and throughconn.Exec/conn.Query, since both go throughbind.Suggested fix
In
bind.go, extendbindQuoteState(roughly lines 113–214) with a heredoc state, e.g.inHeredoc boolplusheredocTag string:update, on$scan forward for a closing$over the tag charset (ClickHouse allows the empty tag$$as well as$tag$); if one is found, enter heredoc mode and record the tag, returning the index of the tag's closing$so the caller's loop skips it.$tag$terminator — heredocs are raw, so no backslash escaping applies.inHeredocin bothinProtectedContext()andinIdentifierOrComment(), sobindPositionalskips a?inside a heredoc without applying the\?unescaping, andbindNumeric/bindNamed/bindParamsFormatsskip the body entirely.Care is needed to keep
$1-style numeric placeholders working:$followed by a digit is a placeholder, not a heredoc opener, so$should only start a heredoc when a valid closing tag delimiter follows.Link
Analogous client-side parser gap reported for clickhouse-java: ClickHouse/clickhouse-java#3013 (there a heredoc makes the JavaCC INSERT-VALUES grammar fail and
prepareStatementthrows an NPE). Tracking: https://github.com/ClickHouse/integrations-ai-playground/issues/353