Skip to content

bind: []byte is bound as Array(UInt8) and time.Duration as a Go duration string, so binary and Time values cannot be bound as parameters #1942

Description

@claude

Description

Two Go value types cannot be bound through the client-side parameter binder in bind.go. Neither is a ClickHouse limitation — the server stores both fine — but formatValue has no case for either, so both fall through to a generic branch that produces SQL the server cannot use.

1. []byte is serialized as Array(UInt8), not as a String literal

[]byte is a valid database/sql driver.Value and the natural Go type for a ClickHouse String holding arbitrary bytes (the String column implementation accepts it on the batch path). But in formatValue there is no case []byte, so it falls through to the reflection block and hits reflect.Slice, which renders each element as a number:

[]byte("A\x00B")  ->  [65, 0, 66]

The bound value becomes an Array(UInt8). Against a String column this is either a type error or — worse — silently the wrong value where the expression is permissive. There is no way to bind binary data as a string through the client-side binder.

This is the Go counterpart of the missing PEP 249 Binary constructor reported for clickhouse-connect (see link below).

2. time.Duration is serialized with Duration.String()

time.Duration is the Go type this driver itself maps the ClickHouse Time/Time64 types to — column.Time.ScanType() returns scanTypeDuration, and column.Time.AppendRow accepts time.Duration and converts it via proto.IntoTime32. So a round trip through a batch works.

Through the binder it does not. time.Duration implements fmt.Stringer, so it matches case fmt.Stringer in formatValue and is quoted with Go's duration syntax:

14*time.Hour + 30*time.Minute  ->  '14h30m0s'

'14h30m0s' is not parseable by the server as a Time (or as the underlying integer number of seconds). The read path and the batch-insert path understand time.Duration; the bind path emits a literal that can never work, so conn.Query/conn.Exec against a Time column is unusable with the type the driver otherwise standardises on.

ClickHouse server version

Code analysis plus a library-level unit test only; not verified against a running server (no ClickHouse instance was reachable in this environment). The defect is entirely in client-side SQL text generation, which the test below observes directly.

Reproduction

A test in the repository root (package clickhouse):

package clickhouse

import (
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
)

func TestBindDuration(t *testing.T) {
	// time.Duration is the Go type this driver maps the ClickHouse Time /
	// Time64 types to (column.Time.ScanType() == scanTypeDuration).
	q, err := bind(time.UTC, "SELECT toTime(?)", 14*time.Hour+30*time.Minute)
	assert.NoError(t, err)
	assert.Equal(t, "SELECT toTime('14:30:00')", q)
}

func TestBindBytes(t *testing.T) {
	// []byte is a valid database/sql driver.Value and the natural Go type for
	// a ClickHouse String holding arbitrary bytes.
	q, err := bind(time.UTC, "SELECT ?", []byte("A\x00B"))
	assert.NoError(t, err)
	assert.Equal(t, "SELECT 'A\\0B'", q)
}
$ go test -run 'TestBindDuration|TestBindBytes' -v .
=== RUN   TestBindDuration
        	Error:      	Not equal:
        	            	expected: "SELECT toTime('14:30:00')"
        	            	actual  : "SELECT toTime('14h30m0s')"
--- FAIL: TestBindDuration (0.00s)
=== RUN   TestBindBytes
        	Error:      	Not equal:
        	            	expected: "SELECT 'A\0B'"
        	            	actual  : "SELECT [65, 0, 66]"
--- FAIL: TestBindBytes (0.00s)
FAIL

Expected: a quoted String literal for []byte, and a Time-parseable literal (or the integer number of seconds) for time.Duration.
Actual: [65, 0, 66] and '14h30m0s'.

The same holds through the public surface — conn.Query(ctx, "SELECT ?", []byte(...)) and db.Query("SELECT ?", someDuration) both route through bind.

Suggested fix

In formatValue (bind.go:522), add explicit cases before the fmt.Stringer case and before the reflection fallback:

  • case []byte: — quote it as a String literal, using the same escaping as the string case (so \0, ' and \ survive).
  • case time.Duration: — render as the server expects for Time/Time64: HH:MM:SS[.frac], or the plain integer number of seconds. It must come before case fmt.Stringer, which currently swallows it. *time.Duration and the formatParamText mode (server-side {name:Type} parameters) need the same treatment.

Related but distinct: #1927 covers wrong serialization of other types in the server-side {name:Type} parameter path.

Link

Reported for the Python client as ClickHouse/clickhouse-connect#919 (missing PEP 249 Binary constructor; datetime.time bound as a bare 14:30:00 literal where the Time column expects seconds).

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions