Skip to content

[backfill: ClickHouse/clickhouse-go] Int128/UInt128 bind parameters serialized as quoted strings, not integer literals #1917

Description

@alex-clickhouse

Target client repo: ClickHouse/clickhouse-go

Severity: sev:1 — easy client-side workaround. The buggy code path produces a String-typed literal that ClickHouse implicitly coerces back to Int128/UInt128 in column-typed contexts (INSERT, WHERE comparison), so common INSERT/SELECT-by-id flows still return correct data. Users hitting a context that depends on inferred type (e.g. toTypeName, arithmetic on the literal, function dispatch) can wrap with CAST(? AS Int128) or toInt128(?). Per the rubric, "explicit type hint / CAST in the query defeats the bug" → sev:1.

Description

The format() function in bind.go is used to inline parameter values into the SQL string when callers use ?/$N/@name placeholders with Conn.Query/Exec (the SQL-rewrite bind path, not the native batch path). For Int128/UInt128 values, Go users naturally pass *big.Int (the same representation the column drivers use under lib/column/bigint.go).

*big.Int satisfies fmt.Stringer via its String() method, so it matches the case fmt.Stringer: arm in bind.go (around line 316) and is emitted as quote(v.String()) — i.e. wrapped in single quotes. The serialized SQL therefore contains a String literal instead of an integer literal. The reporter's expected behaviour — "Number is interpreted as (U)Int128" — fails: in a context where ClickHouse infers the literal's type (no target column to coerce against), the value is typed as String.

This is the same family of bug as the upstream Rust issue: the human-readable bind serialization does not produce a value that ClickHouse types as Int128/UInt128. The Rust client over-aggressively emits a bare numeric literal that the server then mis-types as Float64 (the upstream ClickHouse/ClickHouse#38480 story); the Go client under-aggressively quotes it as a string. Both miss the goal stated in the source issue.

ClickHouse server version

Code analysis only; not verified against a running server (local http://localhost:8123 was unreachable in this environment). The buggy serialization was verified by running the unit-level bind path directly — see below.

Reproduction

The bind path is a pure SQL-rewrite, so the bug is fully observable without a server. Adding this test to the root package (bind_bigint_test.go) demonstrates it:

package clickhouse

import (
	"math/big"
	"testing"
	"time"
)

func TestBindBigIntAsInt128(t *testing.T) {
	v, _ := new(big.Int).SetString("170141183460469231731687303715884105727", 10) // max Int128

	cases := []struct {
		query string
		args  []any
		want  string
	}{
		{"SELECT toTypeName(?)", []any{v},
			"SELECT toTypeName(170141183460469231731687303715884105727)"},
		{"SELECT toTypeName($1)", []any{v},
			"SELECT toTypeName(170141183460469231731687303715884105727)"},
		{"SELECT toTypeName(@x)", []any{Named("x", v)},
			"SELECT toTypeName(170141183460469231731687303715884105727)"},
	}
	for _, c := range cases {
		got, err := bind(time.UTC, c.query, c.args...)
		if err != nil {
			t.Fatalf("bind err for %q: %v", c.query, err)
		}
		if got != c.want {
			t.Errorf("query=%q\n  got:  %q\n  want: %q", c.query, got, c.want)
		}
	}
}

Actual output (observed by running this against the current main):

query="SELECT toTypeName(?)"   got: "SELECT toTypeName('170141183460469231731687303715884105727')"
query="SELECT toTypeName($1)"  got: "SELECT toTypeName('170141183460469231731687303715884105727')"
query="SELECT toTypeName(@x)"  got: "SELECT toTypeName('170141183460469231731687303715884105727')"

(Compare to int64(123), which the same bind path renders as bare 123 without quotes — the Stringer-quoting path is what's specific to *big.Int.)

When sent to a server, SELECT toTypeName('170141183460469231731687303715884105727') returns 'String' rather than 'Int128', which is the user-visible expression of the source bug ("Number is interpreted as (U)Int128" fails).

Suggested fix

In bind.go format(), add a case *big.Int: (and likely big.Int) arm before the case fmt.Stringer: arm so 128/256-bit integers are emitted as unquoted numeric literals — matching the way int64 is already handled by the fallthrough fmt.Sprint(v) at the end of the function. Sketch:

case *big.Int:
    if v == nil {
        return "NULL", nil
    }
    return v.String(), nil
case big.Int:
    return v.String(), nil

Ordering matters: case fmt.Stringer: must not come first, or *big.Int will be caught there again.

A regression test under tests/issues/ (per the project's AGENTS.md convention) covering at least Int128, UInt128, Int256, UInt256, including the negative-value and big.Int (non-pointer) cases, would lock the fix.

Source bug

Relayed from ClickHouse/clickhouse-rs#208 (upstream root cause documented at ClickHouse/ClickHouse#38480).

Dedup search performed

  • gh issue list --repo ClickHouse/clickhouse-go --search "Int128 bind" --state all --limit 20 → 0 results
  • gh issue list --repo ClickHouse/clickhouse-go --search "big.Int bind" --state all --limit 20 → 0 results
  • gh issue list --repo ClickHouse/clickhouse-go --search "bigint bind" --state all --limit 20 → 0 results
  • gh issue list --repo ClickHouse/clickhouse-go --search "Int128 parameter" --state all --limit 20 → 0 results
  • gh issue list --repo ClickHouse/clickhouse-go --search "big.Int serialization" --state all --limit 20 → 0 results
  • gh pr list --repo ClickHouse/clickhouse-go --search "Int128 bind" --state all --limit 20 → 0 results
  • gh issue list --repo ClickHouse/integrations-ai-playground --label backfill --label "target:clickhouse-go" --search "Int128" --state all --limit 20 → 0 results
  • gh issue list --repo ClickHouse/integrations-ai-playground --label backfill --label "target:clickhouse-go" --search "bind" --state all --limit 20 → 4 unrelated (Tuple, Array(Date)/Array(DateTime), Array(Bool), HTTP URL length)
  • gh pr list --repo ClickHouse/integrations-ai-playground --search "Int128 bind" --state all --limit 20 → 0 results

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions