diff --git a/README.md b/README.md index e78188df5a..1381f2a9a1 100644 --- a/README.md +++ b/README.md @@ -405,6 +405,65 @@ We have following examples to show Async Insert in action. **NOTE**: The old `AsyncInsert()` api is deprecated and will be removed in future versions. We highly recommend to use `WithAsync()` api for all the Async Insert use cases. +## Query Parameters + +ClickHouse supports server-side parameterized queries using the `{name:Type}` syntax (requires ClickHouse ≥ 22.8). Parameters are sent separately from the query text — the server substitutes them after parsing, which prevents SQL injection. + +### Usage + +**Native interface** — pass parameters via context: + +```go +ctx := clickhouse.Context(context.Background(), clickhouse.WithParameters(clickhouse.Parameters{ + "id": "42", + "name": "Alice", +})) +row := conn.QueryRow(ctx, "SELECT {id:UInt64}, {name:String}") +``` + +Or use `clickhouse.Named` as query arguments: + +```go +row := conn.QueryRow(ctx, + "SELECT {id:UInt64}, {name:String}", + clickhouse.Named("id", "42"), + clickhouse.Named("name", "Alice"), +) +``` + +**`database/sql` interface** — use `sql.Named`: + +```go +row := db.QueryRowContext(ctx, + "SELECT {id:UInt64}, {name:String}", + sql.Named("id", 42), + sql.Named("name", "Alice"), +) +``` + +### Special characters are handled automatically + +Pass raw Go strings — the driver handles all escaping. Characters such as tab (`\t`), newline (`\n`), backslash (`\`), and single quote (`'`) are encoded correctly for both the native TCP and HTTP protocols: + +```go +ctx := clickhouse.Context(context.Background(), clickhouse.WithParameters(clickhouse.Parameters{ + "tsv": "col1\tcol2", // literal tab — works as-is + "path": `C:\Users\bob`, // backslashes — works as-is + "name": "O'Brien", // single quote — works as-is +})) +``` + +### Protocol differences + +| Protocol | How parameters are encoded | +|---|---| +| Native TCP | TSV-escaped format wrapped in single quotes; the driver double-encodes control characters automatically | +| HTTP | URL query parameters (`param_=`); encoded via standard URL encoding | + +Both protocols accept the same raw Go string values — the difference is invisible to callers. + +See full examples: [native API](examples/clickhouse_api/query_parameters.go) · [database/sql](examples/std/query_parameters.go) + ## PrepareBatch options Available options: diff --git a/conn_http.go b/conn_http.go index 992bfe8da5..7b31cc50f4 100644 --- a/conn_http.go +++ b/conn_http.go @@ -32,6 +32,21 @@ const ( queryIDParamName = "query_id" ) +// httpQueryParamReplacer encodes raw string characters into the TSV-escaped format +// expected by ClickHouse for query parameters sent over the HTTP protocol. +// +// The server applies deserializeTextEscaped (TSV format) to param_ values: +// raw tab/newline/CR are treated as field/record delimiters and cause parse errors, +// while backslash introduces escape sequences (\t = tab, \n = newline, \\ = backslash). +// Characters must therefore be encoded so the server reconstructs the original string. +var httpQueryParamReplacer = strings.NewReplacer( + `\`, `\\`, // backslash → \\: server reads \\ as \ + "\t", `\t`, // tab → \t: server reads \t as tab + "\n", `\n`, // newline → \n: server reads \n as newline + "\r", `\r`, // CR → \r: server reads \r as CR + "\x00", `\0`, // NUL → \0: server reads \0 as NUL +) + type Pool[T any] struct { pool *sync.Pool } @@ -645,7 +660,7 @@ func (h *httpConnect) createRequest(ctx context.Context, requestUrl string, read query.Set(key, fmt.Sprint(value)) } for key, value := range options.parameters { - query.Set(fmt.Sprintf("param_%s", key), value) + query.Set(fmt.Sprintf("param_%s", key), httpQueryParamReplacer.Replace(value)) } req.URL.RawQuery = query.Encode() } diff --git a/conn_http_test.go b/conn_http_test.go index f4e87a9881..e72c8c1d4a 100644 --- a/conn_http_test.go +++ b/conn_http_test.go @@ -6,6 +6,33 @@ import ( "testing" ) +func TestHTTPQueryParamReplacer(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"tab", "hello\tworld", `hello\tworld`}, + {"newline", "hello\nworld", `hello\nworld`}, + {"carriage return", "hello\rworld", `hello\rworld`}, + {"backslash", `hello\world`, `hello\\world`}, + {"backslash followed by t (not a tab)", `hello\tworld`, `hello\\tworld`}, + {"single quote unchanged", "it's", "it's"}, + {"NUL byte", "hello\x00world", `hello\0world`}, + {"mixed", "tab:\there\nnewline\\backslash'quote", `tab:\there\nnewline\\backslash'quote`}, + {"no special chars", "plain string", "plain string"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := httpQueryParamReplacer.Replace(tt.input) + if got != tt.want { + t.Errorf("httpQueryParamReplacer.Replace(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + func TestCreateHTTPRoundTripper(t *testing.T) { transportFnCalled := false _, err := createHTTPRoundTripper(&Options{ diff --git a/lib/proto/query.go b/lib/proto/query.go index 42637448bf..eb158e6e20 100644 --- a/lib/proto/query.go +++ b/lib/proto/query.go @@ -219,13 +219,31 @@ func (s *Parameter) encode(buffer *chproto.Buffer, revision uint64) error { return nil } +// fieldDumpReplacer encodes raw string characters into the wire format expected by ClickHouse +// for query parameters sent over the native TCP protocol. +// +// The server decodes parameter values through two stages: +// 1. readQuoted: decodes escape sequences inside single-quoted strings (e.g. \\ → \, \t → tab) +// 2. deserializeTextEscaped: expects TSV-escaped input (treats raw 0x09/0x0a as delimiters) +// +// Characters must therefore be double-encoded so that after readQuoted the result +// remains valid TSV-escaped input for deserializeTextEscaped. +var fieldDumpReplacer = strings.NewReplacer( + `\`, `\\\\`, // backslash → 4 backslashes: readQuoted produces \\, deserializeTextEscaped produces \ + `'`, `\'`, // single quote → \': readQuoted produces ', not special in TSV + "\t", `\\t`, // tab → \\t: readQuoted produces \t (literal), deserializeTextEscaped produces tab + "\n", `\\n`, // newline → \\n: readQuoted produces \n (literal), deserializeTextEscaped produces newline + "\r", `\\r`, // CR → \\r: readQuoted produces \r (literal), deserializeTextEscaped produces CR + "\x00", `\\0`, // NUL → \\0: readQuoted produces \0 (literal), deserializeTextEscaped produces NUL +) + // encodes a field dump with an appropriate type format // implements the same logic as in ClickHouse Field::restoreFromDump (https://github.com/ClickHouse/ClickHouse/blob/master/src/Core/Field.cpp#L312) // currently, only string type is supported func encodeFieldDump(value any) (string, error) { switch v := value.(type) { case string: - return fmt.Sprintf("'%v'", strings.ReplaceAll(v, "'", "\\'")), nil + return "'" + fieldDumpReplacer.Replace(v) + "'", nil } return "", fmt.Errorf("unsupported field type %T", value) diff --git a/lib/proto/query_test.go b/lib/proto/query_test.go new file mode 100644 index 0000000000..b42b5d2ddd --- /dev/null +++ b/lib/proto/query_test.go @@ -0,0 +1,96 @@ +package proto + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEncodeFieldDump(t *testing.T) { + cases := []struct { + name string + input string + want string + }{ + { + name: "plain string", + input: "hello world", + want: `'hello world'`, + }, + { + name: "empty string", + input: "", + want: `''`, + }, + { + name: "single quote", + input: "it's", + want: `'it\'s'`, + }, + { + // backslash → 4 backslashes in wire format + // readQuoted: \\ → \, \\ → \ → \\ (two backslashes) + // deserializeTextEscaped: \\ → \ + name: "backslash", + input: `a\b`, + want: `'a\\\\b'`, + }, + { + // tab → \\t in wire format + // readQuoted: \\ → \, t → t → \t (literal backslash-t) + // deserializeTextEscaped: \t → tab + name: "tab character", + input: "hello\tworld", + want: `'hello\\tworld'`, + }, + { + // same double-encoding for newline + name: "newline character", + input: "hello\nworld", + want: `'hello\\nworld'`, + }, + { + name: "carriage return", + input: "hello\rworld", + want: `'hello\\rworld'`, + }, + { + name: "nul byte", + input: "hello\x00world", + want: `'hello\\0world'`, + }, + { + // literal backslash-t (not a tab): backslash → \\\\, t stays + name: "backslash followed by t (not a tab)", + input: `hello\tworld`, + want: `'hello\\\\tworld'`, + }, + { + // literal backslash then quote: backslash → \\\\, quote → \' + name: "backslash followed by single quote", + input: `a\'b`, + want: `'a\\\\\'b'`, + }, + { + // tab:\there\nnewline\backslash'quote + // tab → \\t, \n → \\n, \ → \\\\, ' → \' + name: "mixed special characters", + input: "tab:\there\nnewline\\backslash'quote", + want: `'tab:\\there\\nnewline\\\\backslash\'quote'`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := encodeFieldDump(tc.input) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } + + t.Run("unsupported type", func(t *testing.T) { + _, err := encodeFieldDump(42) + require.Error(t, err) + }) +} diff --git a/tests/issues/1792_test.go b/tests/issues/1792_test.go new file mode 100644 index 0000000000..09b85ebaf2 --- /dev/null +++ b/tests/issues/1792_test.go @@ -0,0 +1,143 @@ +package issues + +import ( + "context" + "database/sql" + "fmt" + "testing" + "time" + + "github.com/ClickHouse/clickhouse-go/v2" + "github.com/ClickHouse/clickhouse-go/v2/lib/proto" + clickhouse_tests "github.com/ClickHouse/clickhouse-go/v2/tests" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// controlCharCases is the shared set of test cases used by all 1792 tests. +var controlCharCases = []struct { + name string + value string +}{ + {name: "tab character", value: "hello\tworld"}, + {name: "newline character", value: "hello\nworld"}, + {name: "carriage return", value: "hello\rworld"}, + {name: "backslash", value: `hello\world`}, + {name: "single quote", value: "it's"}, + {name: "backslash followed by t (not a tab)", value: `hello\tworld`}, + {name: "mixed control characters", value: "tab:\there\nnewline\\backslash'quote"}, +} + +// Test1792 verifies that String query parameters containing control characters +// (tab, newline, carriage return, backslash, single quote) are correctly encoded +// when sent via the native TCP protocol. +// +// The ClickHouse server decodes parameter values through two stages: +// 1. readQuoted: decodes escape sequences inside single-quoted strings +// 2. deserializeTextEscaped: interprets TSV-escaped sequences +// +// The client must double-encode control characters so the round-trip preserves them. +func Test1792(t *testing.T) { + conn, err := clickhouse_tests.GetConnectionTCP("issues", clickhouse.Settings{ + "max_execution_time": 60, + }, nil, nil) + require.NoError(t, err) + + if !clickhouse_tests.CheckMinServerServerVersion(conn, 22, 8, 0) { + t.Skipf("unsupported clickhouse version") + } + + ctx := context.Background() + + for _, tc := range controlCharCases { + t.Run(tc.name, func(t *testing.T) { + chCtx := clickhouse.Context(ctx, clickhouse.WithParameters(clickhouse.Parameters{ + "str": tc.value, + })) + var got string + row := conn.QueryRow(chCtx, "SELECT {str:String}") + require.NoError(t, row.Err()) + require.NoError(t, row.Scan(&got)) + assert.Equal(t, tc.value, got) + }) + } +} + +// Test1792HTTP verifies that String query parameters containing control characters +// round-trip correctly when sent via the HTTP protocol. +// Over HTTP, parameters are URL-encoded as param_= query string entries. +func Test1792HTTP(t *testing.T) { + conn, err := clickhouse_tests.GetConnectionHTTP("issues", t.Name(), clickhouse.Settings{ + "max_execution_time": 60, + }, nil, nil) + require.NoError(t, err) + + if !clickhouse_tests.CheckMinServerServerVersion(conn, 22, 8, 0) { + t.Skipf("unsupported clickhouse version") + } + + ctx := context.Background() + + for _, tc := range controlCharCases { + t.Run(tc.name, func(t *testing.T) { + chCtx := clickhouse.Context(ctx, clickhouse.WithParameters(clickhouse.Parameters{ + "str": tc.value, + })) + var got string + row := conn.QueryRow(chCtx, "SELECT {str:String}") + require.NoError(t, row.Err()) + require.NoError(t, row.Scan(&got)) + assert.Equal(t, tc.value, got) + }) + } +} + +// Test1792Std verifies that String query parameters containing control characters +// round-trip correctly through the database/sql interface using clickhouse.Named(). +func Test1792Std(t *testing.T) { + env, err := clickhouse_tests.GetTestEnvironment(testSet) + require.NoError(t, err) + + db := clickhouse.OpenDB(&clickhouse.Options{ + Addr: []string{fmt.Sprintf("%s:%d", env.Host, env.HttpPort)}, + Protocol: clickhouse.HTTP, + Auth: clickhouse.Auth{ + Database: env.Database, + Username: env.Username, + Password: env.Password, + }, + Settings: clickhouse.Settings{ + "max_execution_time": 60, + }, + DialTimeout: 5 * time.Second, + }) + defer db.Close() + + if !checkStdMinVersion(db, 22, 8, 0) { + t.Skipf("unsupported clickhouse version") + } + + for _, tc := range controlCharCases { + t.Run(tc.name, func(t *testing.T) { + var got string + row := db.QueryRow( + "SELECT {str:String}", + clickhouse.Named("str", tc.value), + ) + require.NoError(t, row.Err()) + require.NoError(t, row.Scan(&got)) + assert.Equal(t, tc.value, got) + }) + } +} + +// checkStdMinVersion returns true if the connected server meets the minimum version requirement. +func checkStdMinVersion(db *sql.DB, major, minor, patch uint64) bool { + var version string + if err := db.QueryRow("SELECT version()").Scan(&version); err != nil { + return false + } + var v proto.Version + fmt.Sscanf(version, "%d.%d.%d", &v.Major, &v.Minor, &v.Patch) + return proto.CheckMinVersion(proto.Version{Major: major, Minor: minor, Patch: patch}, v) +}