Skip to content

bind: heredoc string literals are not protected, so ?/$N/@name inside a dollar-quoted literal are substituted as placeholders #1951

Description

@claude

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions