Skip to content

[backfill: ClickHouse/clickhouse-go] Enum columns cannot be scanned into integer destinations #1918

Description

@alex-clickhouse

Target client repo

ClickHouse/clickhouse-go

Severity

sev:1 — Easy client-side workaround (cast in SQL, e.g. SELECT CAST(enum_col AS Int8), or scan into a sql.Scanner wrapper). The bug is "missing functionality" rather than wrong-value/blocking-error on a default path. No silent corruption, no default-path hang. Users can always retrieve the integer form by changing the SQL.

Description

ClickHouse Enum8 / Enum16 values are conceptually pairs of (name, number). A complete client should let the caller materialize either form on read:

  • the string name ('open', 'closed', …) — what the user wrote in the DDL, or
  • the underlying integer ordinal (int8 / int16) — what is sent on the wire.

In clickhouse-go's native columnar reader, only the string form is exposed. Enum8.ScanRow / Enum16.ScanRow accept only *string and **string destinations; any integer destination falls through to ColumnConverterError. The Row() helper also returns the string form unconditionally.

This mirrors the source bug filed against the Java v2 client (ClickHouse/clickhouse-java#2026), which asks for both representations to be supported on read.

Relevant code:

lib/column/enum8.go:52-71

func (col *Enum8) ScanRow(dest any, row int) error {
    v := col.col.Row(row)
    switch d := dest.(type) {
    case *string:
        *d = col.vi[v]
    case **string:
        *d = new(string)
        **d = col.vi[v]
    default:
        if scan, ok := dest.(sql.Scanner); ok {
            return scan.Scan(col.vi[v])
        }
        return &ColumnConverterError{
            Op:   "ScanRow",
            To:   fmt.Sprintf("%T", dest),
            From: "Enum8",
        }
    }
    return nil
}

lib/column/enum8.go:44-50

func (col *Enum8) Row(i int, ptr bool) any {
    value := col.vi[col.col.Row(i)]
    if ptr {
        return &value
    }
    return value
}

lib/column/enum16.go:52-71 is structurally identical and has the same limitation for int8/int16/int destinations.

Note that the write side already accepts both forms (see Enum8.AppendRow / Enum8.Append, also covered by the merged PR #802 "Support inserting Enums as int8/int16/int values"). Read-side support was never added.

ClickHouse server version

Code analysis only; not verified against a running server (the dev-env ClickHouse at http://localhost:8123 refused connection during this investigation).

Reproduction

Minimal failing program against any ClickHouse server:

package main

import (
    "context"
    "fmt"

    "github.com/ClickHouse/clickhouse-go/v2"
)

func main() {
    ctx := context.Background()
    conn, err := clickhouse.Open(&clickhouse.Options{
        Addr: []string{"localhost:9000"},
    })
    if err != nil { panic(err) }
    defer conn.Close()

    if err := conn.Exec(ctx, `DROP TABLE IF EXISTS enum_scan_test`); err != nil { panic(err) }
    if err := conn.Exec(ctx, `
        CREATE TABLE enum_scan_test (
            v Enum8('a' = 1, 'b' = 2)
        ) ENGINE = Memory
    `); err != nil { panic(err) }
    if err := conn.Exec(ctx, `INSERT INTO enum_scan_test VALUES ('b')`); err != nil { panic(err) }

    // Works: scan into *string returns the constant name.
    var asString string
    if err := conn.QueryRow(ctx, `SELECT v FROM enum_scan_test`).Scan(&asString); err != nil {
        panic(err)
    }
    fmt.Printf("string: %q\n", asString) // string: "b"

    // Fails: scan into *int8 returns ColumnConverterError, even though the
    // underlying wire value is an int8 ordinal.
    var asInt int8
    err = conn.QueryRow(ctx, `SELECT v FROM enum_scan_test`).Scan(&asInt)
    fmt.Printf("int8 err: %v\n", err)
    // int8 err: clickhouse [ScanRow]: into=*int8, from=Enum8
}

Expected: scanning into *int8 / *int16 / *int returns the numeric ordinal (2 for 'b').
Actual: clickhouse [ScanRow]: into=*int8, from=Enum8.

Equivalent regression-style test (place under tests/issues/):

func TestEnumScanIntoInt(t *testing.T) {
    ctx := context.Background()
    conn, err := GetNativeConnection(...)
    require.NoError(t, err)

    require.NoError(t, conn.Exec(ctx, `DROP TABLE IF EXISTS enum_scan_int`))
    require.NoError(t, conn.Exec(ctx, `
        CREATE TABLE enum_scan_int (v Enum8('a'=1,'b'=2)) ENGINE = Memory`))
    require.NoError(t, conn.Exec(ctx, `INSERT INTO enum_scan_int VALUES ('b')`))

    var got int8
    require.NoError(t, conn.QueryRow(ctx, `SELECT v FROM enum_scan_int`).Scan(&got))
    require.Equal(t, int8(2), got)
}

Suggested fix

Extend the ScanRow switch in lib/column/enum8.go and lib/column/enum16.go to accept the integer destinations the column already understands on the write side: *int8, **int8, *int16, **int16, *int32, *int64, *int, and their pointer-to-pointer variants. The numeric value is already available as col.col.Row(row) (typed proto.Enum8 / proto.Enum16, which are integer types) — no extra lookup is needed.

A round-trip test for both representations should be added in tests/, in the spirit of the Workflow rules in AGENTS.md.

Search performed (dedup)

  • gh issue list --repo ClickHouse/clickhouse-go --search "enum int" --state all --limit 20 → 0 results
  • gh issue list --repo ClickHouse/clickhouse-go --search "enum scan" --state all --limit 20 → 1 result (Default value for Nullable(Enum8/16) Cannot be Read  #198 — about Nullable(Enum) default values, unrelated)
  • gh issue list --repo ClickHouse/clickhouse-go --search "enum read" --state all --limit 20 → 1 result (invalid Enum value: 0 #285 — about invalid Enum value: 0, unrelated)
  • gh pr list --repo ClickHouse/clickhouse-go --search "enum int" --state all --limit 20 → 4 results, all unrelated except Support inserting Enums as int8/int16/int values #802 ("Support inserting Enums as int8/int16/int values"), which is the write-side counterpart, not read
  • gh issue list --repo ClickHouse/integrations-ai-playground --label backfill --label "target:clickhouse-go" --search "enum" --state all --limit 20 → 0 results
  • gh pr list --repo ClickHouse/integrations-ai-playground --search "enum int" --state all --limit 20 → 0 results

Source bug

ClickHouse/clickhouse-java#2026

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