Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<name>=<value>`); 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:
Expand Down
17 changes: 16 additions & 1 deletion conn_http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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_<name> 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
}
Expand Down Expand Up @@ -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()
}
Expand Down
27 changes: 27 additions & 0 deletions conn_http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
20 changes: 19 additions & 1 deletion lib/proto/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
96 changes: 96 additions & 0 deletions lib/proto/query_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
Loading