diff --git a/conn_http.go b/conn_http.go index 0ac2f87734..eaa7ae08d6 100644 --- a/conn_http.go +++ b/conn_http.go @@ -639,7 +639,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), escapeQueryParam(value)) } req.URL.RawQuery = query.Encode() } diff --git a/lib/proto/query.go b/lib/proto/query.go index b6f0c44a2b..ed5ba7fa7d 100644 --- a/lib/proto/query.go +++ b/lib/proto/query.go @@ -218,13 +218,37 @@ func (s *Parameter) encode(buffer *chproto.Buffer, revision uint64) error { return nil } -// 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 +// encodeFieldDump encodes a query parameter value for transmission over the native TCP protocol. +// ClickHouse deserializes named query parameters using TSV escaped format (deserializeTextEscaped), +// so control characters must be escaped: a raw 0x09 tab byte is treated as a TSV field delimiter +// and causes error 457 (BAD_QUERY_PARAMETER). Values are wrapped in single quotes. +// 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 + var sb strings.Builder + sb.Grow(len(v) + 2) + sb.WriteByte('\'') + for i := 0; i < len(v); i++ { + switch v[i] { + case '\\': + sb.WriteString(`\\`) + case '\'': + sb.WriteString(`\'`) + case '\t': + sb.WriteString(`\t`) + case '\n': + sb.WriteString(`\n`) + case '\r': + sb.WriteString(`\r`) + case '\000': + sb.WriteString(`\0`) + default: + sb.WriteByte(v[i]) + } + } + sb.WriteByte('\'') + return sb.String(), 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..04a625e7bd --- /dev/null +++ b/lib/proto/query_test.go @@ -0,0 +1,75 @@ +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 + expected string + }{ + { + name: "plain string unchanged", + input: "hello", + expected: "'hello'", + }, + { + name: "tab is TSV-escaped", + input: "hello\tworld", + expected: `'hello\tworld'`, + }, + { + name: "newline is TSV-escaped", + input: "hello\nworld", + expected: `'hello\nworld'`, + }, + { + name: "carriage return is TSV-escaped", + input: "hello\rworld", + expected: `'hello\rworld'`, + }, + { + name: "backslash is doubled", + input: `hello\world`, + expected: `'hello\\world'`, + }, + { + name: "single quote is escaped", + input: "it's", + expected: `'it\'s'`, + }, + { + name: "null byte is escaped", + input: "hello\x00world", + expected: `'hello\0world'`, + }, + { + name: "multiple special chars", + input: "a\tb\nc", + expected: `'a\tb\nc'`, + }, + { + name: "empty string", + input: "", + expected: "''", + }, + } + + 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.expected, got) + }) + } + + t.Run("unsupported type returns error", func(t *testing.T) { + _, err := encodeFieldDump(42) + assert.Error(t, err) + }) +} diff --git a/query_parameters.go b/query_parameters.go index bb11b77d7a..c2ead49164 100644 --- a/query_parameters.go +++ b/query_parameters.go @@ -3,6 +3,7 @@ package clickhouse import ( "errors" "regexp" + "strings" "time" "github.com/ClickHouse/clickhouse-go/v2/lib/driver" @@ -60,6 +61,32 @@ func bindQueryOrAppendParameters(paramsProtocolSupport bool, options *QueryOptio return bind(timezone, query, args...) } +// escapeQueryParam applies TSV escaping to a query parameter value. +// ClickHouse deserializes named query parameters using deserializeTextEscaped, +// so control characters must be escaped before sending over any transport. +// A raw tab (0x09) is treated as a TSV field delimiter and causes error 457. +func escapeQueryParam(v string) string { + var sb strings.Builder + sb.Grow(len(v)) + for i := 0; i < len(v); i++ { + switch v[i] { + case '\\': + sb.WriteString(`\\`) + case '\t': + sb.WriteString(`\t`) + case '\n': + sb.WriteString(`\n`) + case '\r': + sb.WriteString(`\r`) + case '\000': + sb.WriteString(`\0`) + default: + sb.WriteByte(v[i]) + } + } + return sb.String() +} + func formatTimeWithScale(t time.Time, scale TimeUnit) string { switch scale { case MicroSeconds: diff --git a/tests/query_parameters_test.go b/tests/query_parameters_test.go index e3bb84d6aa..de91e9a56a 100644 --- a/tests/query_parameters_test.go +++ b/tests/query_parameters_test.go @@ -134,4 +134,37 @@ func TestQueryParameters(t *testing.T) { assert.Equal(t, uint8(42), actualNum) assert.Equal(t, "hello", actualStr) }) + + t.Run("string with special characters", func(t *testing.T) { + TestProtocols(t, func(t *testing.T, protocol clickhouse.Protocol) { + conn, err := GetNativeConnection(t, protocol, nil, nil, nil) + require.NoError(t, err) + + cases := []struct { + name string + value string + }{ + {"tab", "hello\tworld"}, + {"newline", "hello\nworld"}, + {"carriage return", "hello\rworld"}, + {"backslash", `hello\world`}, + {"single quote", "it's"}, + {"null byte", "hello\x00world"}, + {"multiple special chars", "a\tb\nc"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + chCtx := clickhouse.Context(ctx, clickhouse.WithParameters(clickhouse.Parameters{ + "str": tc.value, + })) + var result string + row := conn.QueryRow(chCtx, "SELECT {str:String}") + require.NoError(t, row.Err()) + require.NoError(t, row.Scan(&result)) + assert.Equal(t, tc.value, result) + }) + } + }) + }) }