diff --git a/lib/column/enum16.go b/lib/column/enum16.go index c5f7863191..cdab41236d 100644 --- a/lib/column/enum16.go +++ b/lib/column/enum16.go @@ -57,6 +57,35 @@ func (col *Enum16) ScanRow(dest any, row int) error { case **string: *d = new(string) **d = col.vi[value] + case *int16: + *d = int16(value) + case **int16: + *d = new(int16) + **d = int16(value) + case *int32: + *d = int32(value) + case **int32: + *d = new(int32) + **d = int32(value) + case *int64: + *d = int64(value) + case **int64: + *d = new(int64) + **d = int64(value) + case *int: + *d = int(value) + case **int: + *d = new(int) + **d = int(value) + case *int8, **int8: + // Enum16 ordinals range over int16, so int8 cannot represent every + // value; refuse it explicitly rather than truncate silently. + return &ColumnConverterError{ + Op: "ScanRow", + To: fmt.Sprintf("%T", dest), + From: "Enum16", + Hint: "Enum16 values may exceed the int8 range; use *int16 or wider", + } default: if scan, ok := dest.(sql.Scanner); ok { return scan.Scan(col.vi[value]) diff --git a/lib/column/enum8.go b/lib/column/enum8.go index 7b1f8003b6..ce5f06030a 100644 --- a/lib/column/enum8.go +++ b/lib/column/enum8.go @@ -57,6 +57,31 @@ func (col *Enum8) ScanRow(dest any, row int) error { case **string: *d = new(string) **d = col.vi[v] + case *int8: + *d = int8(v) + case **int8: + *d = new(int8) + **d = int8(v) + case *int16: + *d = int16(v) + case **int16: + *d = new(int16) + **d = int16(v) + case *int32: + *d = int32(v) + case **int32: + *d = new(int32) + **d = int32(v) + case *int64: + *d = int64(v) + case **int64: + *d = new(int64) + **d = int64(v) + case *int: + *d = int(v) + case **int: + *d = new(int) + **d = int(v) default: if scan, ok := dest.(sql.Scanner); ok { return scan.Scan(col.vi[v]) diff --git a/lib/column/nullable.go b/lib/column/nullable.go index 15f028c116..5884ba51a1 100644 --- a/lib/column/nullable.go +++ b/lib/column/nullable.go @@ -92,6 +92,8 @@ func (col *Nullable) ScanRow(dest any, row int) error { *v = nil case **int8: *v = nil + case **int: + *v = nil case **string: *v = nil case **float32: diff --git a/tests/issues/1918_test.go b/tests/issues/1918_test.go new file mode 100644 index 0000000000..3179aaa40c --- /dev/null +++ b/tests/issues/1918_test.go @@ -0,0 +1,168 @@ +package issues + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + clickhousetests "github.com/ClickHouse/clickhouse-go/v2/tests" +) + +// Test1918 verifies that Enum8/Enum16 columns can be scanned into integer +// destinations (the underlying numeric ordinal), not only string destinations. +func Test1918(t *testing.T) { + testEnv, err := clickhousetests.GetTestEnvironment("issues") + require.NoError(t, err) + conn, err := clickhousetests.TestClientWithDefaultSettings(testEnv) + require.NoError(t, err) + t.Cleanup(func() { conn.Close() }) + + ctx := context.Background() + + const ddl = ` + CREATE TABLE test_1918 ( + c8 Enum8 ('a' = -5, 'b' = 0, 'c' = 42) + , c16 Enum16('x' = -300, 'y' = 0, 'z' = 1000) + ) Engine MergeTree() ORDER BY tuple() + ` + require.NoError(t, conn.Exec(ctx, "DROP TABLE IF EXISTS test_1918")) + t.Cleanup(func() { require.NoError(t, conn.Exec(ctx, "DROP TABLE IF EXISTS test_1918")) }) + require.NoError(t, conn.Exec(ctx, ddl)) + + // Row 'c'/'z' carries the large-magnitude ordinals (42, 1000); row 'a'/'x' + // carries the negative ordinals (-5, -300). 1000 and -300 fall outside the + // int8 range, so they double as proof that wider destinations are not + // truncated to int8. + require.NoError(t, conn.Exec(ctx, "INSERT INTO test_1918 VALUES ('c', 'z'), ('a', 'x')")) + + t.Run("Enum8 into every signed integer width", func(t *testing.T) { + var ( + i8 int8 + i16 int16 + i32 int32 + i64 int64 + i int + ) + require.NoError(t, conn.QueryRow(ctx, + "SELECT c8, c8, c8, c8, c8 FROM test_1918 WHERE c8 = 'c'"). + Scan(&i8, &i16, &i32, &i64, &i)) + assert.Equal(t, int8(42), i8) + assert.Equal(t, int16(42), i16) + assert.Equal(t, int32(42), i32) + assert.Equal(t, int64(42), i64) + assert.Equal(t, 42, i) + }) + + t.Run("Enum16 into signed integer widths >= int16", func(t *testing.T) { + var ( + i16 int16 + i32 int32 + i64 int64 + i int + ) + require.NoError(t, conn.QueryRow(ctx, + "SELECT c16, c16, c16, c16 FROM test_1918 WHERE c16 = 'z'"). + Scan(&i16, &i32, &i64, &i)) + // 1000 does not fit in int8; the wider destinations must carry it losslessly. + assert.Equal(t, int16(1000), i16) + assert.Equal(t, int32(1000), i32) + assert.Equal(t, int64(1000), i64) + assert.Equal(t, 1000, i) + }) + + t.Run("negative ordinals preserve their sign", func(t *testing.T) { + var ( + e8 int8 + e16 int16 + e int + ) + require.NoError(t, conn.QueryRow(ctx, + "SELECT c8, c16, c16 FROM test_1918 WHERE c8 = 'a'"). + Scan(&e8, &e16, &e)) + assert.Equal(t, int8(-5), e8) + assert.Equal(t, int16(-300), e16) + assert.Equal(t, -300, e) + }) + + t.Run("pointer-to-pointer integer destinations", func(t *testing.T) { + var ( + p8 *int8 + p16 *int16 + p *int + ) + require.NoError(t, conn.QueryRow(ctx, + "SELECT c8, c16, c16 FROM test_1918 WHERE c8 = 'a'"). + Scan(&p8, &p16, &p)) + require.NotNil(t, p8) + require.NotNil(t, p16) + require.NotNil(t, p) + assert.Equal(t, int8(-5), *p8) + assert.Equal(t, int16(-300), *p16) + assert.Equal(t, -300, *p) + }) + + t.Run("string destination is unchanged", func(t *testing.T) { + var s8, s16 string + require.NoError(t, conn.QueryRow(ctx, + "SELECT c8, c16 FROM test_1918 WHERE c8 = 'c'"). + Scan(&s8, &s16)) + assert.Equal(t, "c", s8) + assert.Equal(t, "z", s16) + }) + + // Contrast case: int8 cannot hold every Enum16 ordinal (e.g. 1000), so an + // int8 destination for Enum16 remains an unsupported (error) conversion + // rather than silently truncating. + t.Run("Enum16 into int8 stays an error (no silent truncation)", func(t *testing.T) { + var i8 int8 + err := conn.QueryRow(ctx, + "SELECT c16 FROM test_1918 WHERE c16 = 'z'"). + Scan(&i8) + require.ErrorContains(t, err, "converting Enum16 to *int8 is unsupported") + }) + + // Nullable(Enum) delegates element scanning to the underlying Enum column's + // ScanRow, so integer destinations work there too; a NULL leaves a pointer + // destination nil. + t.Run("Nullable(Enum) into integer destinations", func(t *testing.T) { + require.NoError(t, conn.Exec(ctx, "DROP TABLE IF EXISTS test_1918_nullable")) + t.Cleanup(func() { require.NoError(t, conn.Exec(ctx, "DROP TABLE IF EXISTS test_1918_nullable")) }) + require.NoError(t, conn.Exec(ctx, ` + CREATE TABLE test_1918_nullable ( + n8 Nullable(Enum8 ('a' = -5, 'c' = 42)) + , n16 Nullable(Enum16('z' = 1000)) + ) Engine MergeTree() ORDER BY tuple() + `)) + require.NoError(t, conn.Exec(ctx, + "INSERT INTO test_1918_nullable VALUES ('c', 'z'), (NULL, NULL)")) + + var ( + v8 int8 + v16 int16 + ) + require.NoError(t, conn.QueryRow(ctx, + "SELECT n8, n16 FROM test_1918_nullable WHERE n8 IS NOT NULL"). + Scan(&v8, &v16)) + assert.Equal(t, int8(42), v8) + assert.Equal(t, int16(1000), v16) + + // A NULL must clear a pointer destination. Seed each pointer non-nil + // first, otherwise the assertion passes whether or not ScanRow actually + // cleared it (a nil-initialised pointer is already nil). + seed8 := int8(7) + p8 := &seed8 + require.NoError(t, conn.QueryRow(ctx, + "SELECT n8 FROM test_1918_nullable WHERE n8 IS NULL"). + Scan(&p8)) + assert.Nil(t, p8, "NULL Nullable(Enum8) must clear the *int8 destination") + + seedInt := 7 + pInt := &seedInt + require.NoError(t, conn.QueryRow(ctx, + "SELECT n8 FROM test_1918_nullable WHERE n8 IS NULL"). + Scan(&pInt)) + assert.Nil(t, pInt, "NULL Nullable(Enum8) must clear the *int destination") + }) +}