diff --git a/bind.go b/bind.go index 8e8208fd28..740536c18c 100644 --- a/bind.go +++ b/bind.go @@ -483,7 +483,9 @@ func formatTime(tz *time.Location, scale TimeUnit, value time.Time) (string, err return fmt.Sprintf("toDateTime64('%s', %d, '%s')", value.Format(fmt.Sprintf("2006-01-02 15:04:05.%0*d", int(scale*3), 0)), int(scale*3), escapedTimezone), nil } -var stringQuoteReplacer = strings.NewReplacer(`\`, `\\`, `'`, `\'`) +// Escape order: backslash first so later replacements are not re-escaped. +// NUL is written as \0 so binary String values survive client-side bind. +var stringQuoteReplacer = strings.NewReplacer(`\`, `\\`, `'`, `\'`, "\x00", `\0`) // formatMode says which syntax formatValue should produce. A value spliced // into the query text needs SQL syntax; a server-side query parameter needs @@ -512,6 +514,33 @@ func format(tz *time.Location, scale TimeUnit, v any) (string, error) { return formatValue(tz, scale, v, formatSQL) } +// formatDuration renders a time.Duration as a quoted ClickHouse Time/Time64 +// literal: 'HH:MM:SS' or 'HH:MM:SS.frac' with trailing fractional zeros +// trimmed. It returns the quoted form directly, like formatTime does with +// its toDateTime('...') wrapper, so the API is consistent. +func formatDuration(d time.Duration) string { + sign := "" + if d < 0 { + sign = "-" + d = -d + } + hours := d / time.Hour + d -= hours * time.Hour + mins := d / time.Minute + d -= mins * time.Minute + secs := d / time.Second + frac := d % time.Second + var body string + if frac == 0 { + body = fmt.Sprintf("%s%02d:%02d:%02d", sign, hours, mins, secs) + } else { + fracStr := fmt.Sprintf("%09d", frac.Nanoseconds()) + fracStr = strings.TrimRight(fracStr, "0") + body = fmt.Sprintf("%s%02d:%02d:%02d.%s", sign, hours, mins, secs, fracStr) + } + return "'" + stringQuoteReplacer.Replace(body) + "'" +} + // formatValue turns v into a string in the given mode. The mode carries down // into nested values, so a bool or map keeps its formatting at any depth. // @@ -528,6 +557,11 @@ func formatValue(tz *time.Location, scale TimeUnit, v any, mode formatMode) (str return "NULL", nil case string: return quote(v), nil + case []byte: + // []byte is a valid database/sql driver.Value and the natural Go type + // for a ClickHouse String holding arbitrary bytes. Without this case + // it falls through to reflect.Slice and becomes Array(UInt8). + return quote(string(v)), nil case time.Time: if mode == formatParamText { return quote(formatTimeParam(v)), nil @@ -541,6 +575,15 @@ func formatValue(tz *time.Location, scale TimeUnit, v any, mode formatMode) (str return quote(formatTimeParam(*v)), nil } return formatTime(tz, scale, *v) + case time.Duration: + // Must precede fmt.Stringer: Duration.String() is Go's "14h30m0s". + // formatDuration already returns a quoted literal like formatTime. + return formatDuration(v), nil + case *time.Duration: + if v == nil { + return "NULL", nil + } + return formatDuration(*v), nil case bool: if mode == formatParamText { if v { diff --git a/bind_test.go b/bind_test.go index cabe479b4a..3b0ab2a5ce 100644 --- a/bind_test.go +++ b/bind_test.go @@ -983,3 +983,114 @@ func BenchmarkBindNamed(b *testing.B) { } } } + +// TestBindDuration checks that time.Duration (the ScanType for ClickHouse +// Time/Time64) binds as a Time-parseable literal, not Go's duration string. +func TestBindDuration(t *testing.T) { + 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) + + // *time.Duration and zero / fractional / negative values + d := 1*time.Second + 250*time.Millisecond + q, err = bind(time.UTC, "SELECT ?", &d) + assert.NoError(t, err) + assert.Equal(t, "SELECT '00:00:01.25'", q) + + q, err = bind(time.UTC, "SELECT ?", time.Duration(0)) + assert.NoError(t, err) + assert.Equal(t, "SELECT '00:00:00'", q) + + q, err = bind(time.UTC, "SELECT ?", -90*time.Second) + assert.NoError(t, err) + assert.Equal(t, "SELECT '-00:01:30'", q) + + var nilDur *time.Duration + q, err = bind(time.UTC, "SELECT ?", nilDur) + assert.NoError(t, err) + assert.Equal(t, "SELECT NULL", q) +} + +// TestBindBytes checks that []byte binds as a String literal (with the same +// escaping as string), not as Array(UInt8). +func TestBindBytes(t *testing.T) { + q, err := bind(time.UTC, "SELECT ?", []byte("A\x00B")) + assert.NoError(t, err) + assert.Equal(t, "SELECT 'A\\0B'", q) + + q, err = bind(time.UTC, "SELECT ?", []byte(`a'b\c`)) + assert.NoError(t, err) + assert.Equal(t, `SELECT 'a\'b\\c'`, q) + + q, err = bind(time.UTC, "SELECT ?", []byte{}) + assert.NoError(t, err) + assert.Equal(t, "SELECT ''", q) + + // nested: Array(String) of binary strings, not Array(Array(UInt8)) + q, err = bind(time.UTC, "SELECT ?", [][]byte{[]byte("x"), []byte("y")}) + assert.NoError(t, err) + assert.Equal(t, "SELECT ['x', 'y']", q) +} + +func TestFormatDuration(t *testing.T) { + cases := []struct { + in time.Duration + want string + }{ + {0, "'00:00:00'"}, + {14*time.Hour + 30*time.Minute, "'14:30:00'"}, + {1*time.Second + 250*time.Millisecond, "'00:00:01.25'"}, + {123 * time.Nanosecond, "'00:00:00.000000123'"}, + {-90 * time.Second, "'-00:01:30'"}, + {25 * time.Hour, "'25:00:00'"}, + } + for _, tc := range cases { + assert.Equal(t, tc.want, formatDuration(tc.in), "in=%v", tc.in) + } +} + +// TestBindDuration_Time64Precision covers Time/Time64 with different precision +// (seconds, milli, micro, nano) — the server parses each as the corresponding +// Time64 scale. The binder always emits the minimal fractional form via +// formatDuration, which ClickHouse accepts for any Time64 precision. +func TestBindDuration_Time64Precision(t *testing.T) { + cases := []struct { + name string + dur time.Duration + want string + }{ + // Time (seconds, no fraction) + {"Time seconds", 8*time.Hour + 15*time.Minute + 30*time.Second, "'08:15:30'"}, + // Time64(3) — milliseconds + {"Time64(3) milliseconds", 1*time.Second + 123*time.Millisecond, "'00:00:01.123'"}, + {"Time64(3) trimmed", 1*time.Second + 120*time.Millisecond, "'00:00:01.12'"}, + // Time64(6) — microseconds + {"Time64(6) microseconds", 1*time.Second + 123456*time.Microsecond, "'00:00:01.123456'"}, + {"Time64(6) trimmed", 1*time.Second + 123400*time.Microsecond, "'00:00:01.1234'"}, + // Time64(9) — nanoseconds + {"Time64(9) nanoseconds", 1*time.Second + 123456789*time.Nanosecond, "'00:00:01.123456789'"}, + {"Time64(9) trimmed", 1*time.Second + 100000000*time.Nanosecond, "'00:00:01.1'"}, + // Negative with fraction (Time64) + {"negative Time64(3)", -(2*time.Hour + 500*time.Millisecond), "'-02:00:00.5'"}, + // Array(Time) and Array(Time64) via join + {"zero", 0, "'00:00:00'"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + q, err := bind(time.UTC, "SELECT ?", tc.dur) + assert.NoError(t, err) + assert.Equal(t, "SELECT "+tc.want, q) + // formatDuration directly should match the quoted literal + assert.Equal(t, tc.want, formatDuration(tc.dur)) + }) + } + // Array(Time64) — durations inside an array keep quoted form + q, err := bind(time.UTC, "SELECT ?", []time.Duration{1 * time.Second, 2*time.Second + 500*time.Millisecond}) + assert.NoError(t, err) + assert.Equal(t, "SELECT ['00:00:01', '00:00:02.5']", q) + + // Map with Duration values + q, err = bind(time.UTC, "SELECT ?", map[string]time.Duration{"a": 1 * time.Second + 123*time.Millisecond}) + assert.NoError(t, err) + assert.Equal(t, "SELECT map('a', '00:00:01.123')", q) +}