Skip to content
Draft
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
2 changes: 1 addition & 1 deletion conn_http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
32 changes: 28 additions & 4 deletions lib/proto/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
75 changes: 75 additions & 0 deletions lib/proto/query_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
27 changes: 27 additions & 0 deletions query_parameters.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package clickhouse
import (
"errors"
"regexp"
"strings"
"time"

"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
Expand Down Expand Up @@ -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:
Expand Down
33 changes: 33 additions & 0 deletions tests/query_parameters_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
})
})
}