From 1b11dfd2dff66f0ec12f6fd20c667202648e8cd0 Mon Sep 17 00:00:00 2001 From: nikandagent Date: Sat, 1 Aug 2026 23:10:43 +0000 Subject: [PATCH] feat(proto): support Variant type Co-Authored-By: Claude Opus 5 --- README.md | 3 + proto/_golden/col_variant_of_int64_str.hex | 2 + proto/_golden/col_variant_of_int64_str.raw | Bin 0 -> 32 bytes proto/col_variant.go | 307 +++++++++++++++++++++ proto/col_variant_test.go | 216 +++++++++++++++ proto/column.go | 46 +++ proto/column_test.go | 30 ++ query_test.go | 62 +++++ 8 files changed, 666 insertions(+) create mode 100644 proto/_golden/col_variant_of_int64_str.hex create mode 100644 proto/_golden/col_variant_of_int64_str.raw create mode 100644 proto/col_variant.go create mode 100644 proto/col_variant_test.go diff --git a/README.md b/README.md index f949c1b3..05598e6c 100644 --- a/README.md +++ b/README.md @@ -302,6 +302,7 @@ colV.Reset() * Nullable(T) * Point * Nothing, Interval +* Variant(T1, T2, ..., Tn) ## Enums @@ -451,6 +452,8 @@ func TestLocalNativeDump(t *testing.T) { - [ ] Types - [ ] [Decimal(P, S)](https://clickhouse.com/docs/en/sql-reference/data-types/decimal/) API - [ ] JSON + - [x] Variant + - [ ] Dynamic - [ ] SimpleAggregateFunction - [ ] AggregateFunction - [x] Nothing diff --git a/proto/_golden/col_variant_of_int64_str.hex b/proto/_golden/col_variant_of_int64_str.hex new file mode 100644 index 00000000..a56b3ef9 --- /dev/null +++ b/proto/_golden/col_variant_of_int64_str.hex @@ -0,0 +1,2 @@ +00000000 00 00 00 00 00 00 00 00 00 01 ff 00 2a 00 00 00 |............*...| +00000010 00 00 00 00 64 00 00 00 00 00 00 00 03 66 6f 6f |....d........foo| diff --git a/proto/_golden/col_variant_of_int64_str.raw b/proto/_golden/col_variant_of_int64_str.raw new file mode 100644 index 0000000000000000000000000000000000000000..c1cecea3deba8f5205962fb7b3824e8bd8bf903e GIT binary patch literal 32 XcmZQzfC9$<3|dfT3Y2C}%g+Y@Afy7; literal 0 HcmV?d00001 diff --git a/proto/col_variant.go b/proto/col_variant.go new file mode 100644 index 00000000..068e9e18 --- /dev/null +++ b/proto/col_variant.go @@ -0,0 +1,307 @@ +package proto + +import ( + "sort" + + "github.com/go-faster/errors" +) + +// Serialization reference: +// https://github.com/ClickHouse/ClickHouse/blob/2038a36c2b330e33e1c0ad06b0d28fd2fd3676a9/src/DataTypes/Serializations/SerializationVariant.h + +// VariantNullDiscriminator marks NULL row of Variant column. +const VariantNullDiscriminator uint8 = 255 + +// MaxVariants is maximum number of types in Variant column. +const MaxVariants = 255 + +// Discriminators serialization modes. +const ( + VariantDiscriminatorsBasic uint64 = 0 + VariantDiscriminatorsCompact uint64 = 1 +) + +// Granule formats of compact discriminators mode. +const ( + variantGranulePlain uint8 = 0 + variantGranuleCompact uint8 = 1 +) + +// ColVariant is Variant(T1, T2, ...) column. +// +// Row has discriminator, an index in Columns, or VariantNullDiscriminator for +// NULL. Values are stored sparsely, Columns[i] holds only rows of variant i. +// +// ClickHouse sorts variant types by name, so Columns must be sorted by Type() +// to match server discriminators, see Prepare. +type ColVariant struct { + Discriminators ColUInt8 + Columns []Column + + offsets []int // index of row i in Columns[Discriminators[i]] + counts []int // rows per column + mode uint64 // discriminators mode of decoded state +} + +// Compile-time assertions for ColVariant. +var ( + _ ColInput = (*ColVariant)(nil) + _ ColResult = (*ColVariant)(nil) + _ Column = (*ColVariant)(nil) + _ StateEncoder = (*ColVariant)(nil) + _ StateDecoder = (*ColVariant)(nil) + _ Inferable = (*ColVariant)(nil) + _ Preparable = (*ColVariant)(nil) +) + +// NewVariant constructs Variant(T1, T2, ...) of columns sorted by type. +func NewVariant(columns ...Column) *ColVariant { + return &ColVariant{ + Columns: columns, + } +} + +// SortColumns sorts columns by type as ClickHouse does for Variant and +// Dynamic, so that their order matches discriminators. +func SortColumns(columns []Column) { + sort.Slice(columns, func(i, j int) bool { + return columns[i].Type() < columns[j].Type() + }) +} + +func (c ColVariant) Type() ColumnType { + types := make([]ColumnType, len(c.Columns)) + for i, v := range c.Columns { + types[i] = v.Type() + } + return ColumnTypeVariant.Sub(types...) +} + +func (c ColVariant) Rows() int { + return c.Discriminators.Rows() +} + +// Discriminator returns discriminator of column with type t. +func (c ColVariant) Discriminator(t ColumnType) (uint8, bool) { + for i, v := range c.Columns { + if v.Type() == t { + return uint8(i), true + } + } + return 0, false +} + +// RowDiscriminator returns discriminator of row i. +func (c ColVariant) RowDiscriminator(i int) uint8 { + return c.Discriminators[i] +} + +// RowIsNull reports whether row i is NULL. +func (c ColVariant) RowIsNull(i int) bool { + return c.Discriminators[i] == VariantNullDiscriminator +} + +// RowOffset returns index of row i in Columns[RowDiscriminator(i)]. +func (c ColVariant) RowOffset(i int) int { + return c.offsets[i] +} + +// AppendDiscriminator appends row of variant d. +// +// Value itself must be appended to Columns[d] by caller. +// +// v.AppendDiscriminator(0) +// ints.Append(42) +func (c *ColVariant) AppendDiscriminator(d uint8) { + c.ensureCounts() + c.offsets = append(c.offsets, c.counts[d]) + c.counts[d]++ + c.Discriminators.Append(d) +} + +// AppendNull appends NULL row. +func (c *ColVariant) AppendNull() { + c.offsets = append(c.offsets, 0) + c.Discriminators.Append(VariantNullDiscriminator) +} + +func (c *ColVariant) ensureCounts() { + if n := len(c.Columns) - len(c.counts); n > 0 { + c.counts = append(c.counts, make([]int, n)...) + } +} + +func (c ColVariant) EncodeState(b *Buffer) { + b.PutUInt64(VariantDiscriminatorsBasic) + for _, v := range c.Columns { + if s, ok := v.(StateEncoder); ok { + s.EncodeState(b) + } + } +} + +func (c *ColVariant) DecodeState(r *Reader) error { + mode, err := r.UInt64() + if err != nil { + return errors.Wrap(err, "mode") + } + if mode > VariantDiscriminatorsCompact { + return errors.Errorf("unknown discriminators mode %d", mode) + } + c.mode = mode + for i, v := range c.Columns { + if s, ok := v.(StateDecoder); ok { + if err := s.DecodeState(r); err != nil { + return errors.Wrapf(err, "[%d]", i) + } + } + } + return nil +} + +func (c ColVariant) EncodeColumn(b *Buffer) { + c.Discriminators.EncodeColumn(b) + for _, v := range c.Columns { + v.EncodeColumn(b) + } +} + +func (c ColVariant) WriteColumn(w *Writer) { + c.Discriminators.WriteColumn(w) + for _, v := range c.Columns { + v.WriteColumn(w) + } +} + +func (c *ColVariant) DecodeColumn(r *Reader, rows int) error { + if rows == 0 { + return nil + } + if err := c.decodeDiscriminators(r, rows); err != nil { + return errors.Wrap(err, "discriminators") + } + if err := c.index(); err != nil { + return errors.Wrap(err, "index") + } + for i, v := range c.Columns { + if err := v.DecodeColumn(r, c.counts[i]); err != nil { + return errors.Wrapf(err, "[%d]", i) + } + } + return nil +} + +func (c *ColVariant) decodeDiscriminators(r *Reader, rows int) error { + if c.mode == VariantDiscriminatorsCompact { + return c.decodeCompactDiscriminators(r, rows) + } + return c.Discriminators.DecodeColumn(r, rows) +} + +// index fills offsets and counts of decoded discriminators. +func (c *ColVariant) index() error { + c.offsets = c.offsets[:0] + c.counts = c.counts[:0] + c.ensureCounts() + + for _, d := range c.Discriminators { + if d == VariantNullDiscriminator { + c.offsets = append(c.offsets, 0) + continue + } + if int(d) >= len(c.Columns) { + return errors.Errorf("discriminator %d of %d variants", d, len(c.Columns)) + } + c.offsets = append(c.offsets, c.counts[d]) + c.counts[d]++ + } + return nil +} + +func (c *ColVariant) Reset() { + c.Discriminators.Reset() + for _, v := range c.Columns { + v.Reset() + } + c.offsets = c.offsets[:0] + c.counts = c.counts[:0] +} + +// Prepare ensures Preparable column propagation and column order. +func (c *ColVariant) Prepare() error { + if len(c.Columns) > MaxVariants { + return errors.Errorf("%d variants exceed maximum of %d", len(c.Columns), MaxVariants) + } + for i, v := range c.Columns { + if i > 0 && c.Columns[i-1].Type() >= v.Type() { + return errors.Errorf("variants [%d] %s and [%d] %s are not sorted by type", + i-1, c.Columns[i-1].Type(), i, v.Type(), + ) + } + if s, ok := v.(Preparable); ok { + if err := s.Prepare(); err != nil { + return errors.Wrapf(err, "[%d]", i) + } + } + } + return nil +} + +// Infer ensures Inferable column propagation. +func (c *ColVariant) Infer(t ColumnType) error { + elems := t.Elems() + if len(elems) != len(c.Columns) { + return errors.Errorf("%q has %d variants, got %d columns", t, len(elems), len(c.Columns)) + } + for i, v := range c.Columns { + if s, ok := v.(Inferable); ok { + if err := s.Infer(elems[i]); err != nil { + return errors.Wrapf(err, "[%d]", i) + } + } + } + return nil +} + +// decodeCompactDiscriminators decodes granules of compact mode. +// +// Native format does not use compact mode: it is enabled by the +// use_compact_variant_discriminators_serialization MergeTree setting and +// affects data parts only. Implemented to keep the protocol whole and to +// read discriminators from raw data parts. +func (c *ColVariant) decodeCompactDiscriminators(r *Reader, rows int) error { + for row := 0; row < rows; { + size, err := r.UVarInt() + if err != nil { + return errors.Wrap(err, "granule size") + } + if err := checkRows(int(size)); err != nil { + return errors.Wrap(err, "granule size") + } + if row+int(size) > rows { + return errors.Errorf("granule of %d rows exceeds %d rows left", size, rows-row) + } + format, err := r.UInt8() + if err != nil { + return errors.Wrap(err, "granule format") + } + switch format { + case variantGranuleCompact: + d, err := r.UInt8() + if err != nil { + return errors.Wrap(err, "granule discriminator") + } + for i := 0; i < int(size); i++ { + c.Discriminators.Append(d) + } + case variantGranulePlain: + if err := c.Discriminators.DecodeColumn(r, int(size)); err != nil { + return errors.Wrap(err, "granule") + } + default: + return errors.Errorf("unknown granule format %d", format) + } + row += int(size) + } + return nil +} diff --git a/proto/col_variant_test.go b/proto/col_variant_test.go new file mode 100644 index 00000000..2b3960bd --- /dev/null +++ b/proto/col_variant_test.go @@ -0,0 +1,216 @@ +package proto + +import ( + "bytes" + "io" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/ClickHouse/ch-go/internal/gold" +) + +// newTestVariant returns Variant(Int64, String) and its columns. +func newTestVariant() (*ColVariant, *ColInt64, *ColStr) { + var ( + ints = new(ColInt64) + strs = new(ColStr) + ) + return NewVariant(ints, strs), ints, strs +} + +func TestColVariantGolden(t *testing.T) { + v, ints, strs := newTestVariant() + require.Equal(t, ColumnType("Variant(Int64, String)"), v.Type()) + + v.AppendDiscriminator(0) + ints.Append(42) + v.AppendDiscriminator(1) + strs.Append("foo") + v.AppendNull() + v.AppendDiscriminator(0) + ints.Append(100) + + var buf Buffer + v.EncodeState(&buf) + v.EncodeColumn(&buf) + gold.Bytes(t, buf.Buf, "col_variant_of_int64_str") +} + +func TestColVariant(t *testing.T) { + v, ints, strs := newTestVariant() + + v.AppendDiscriminator(0) + ints.Append(42) + v.AppendDiscriminator(1) + strs.Append("foo") + v.AppendNull() + v.AppendDiscriminator(0) + ints.Append(100) + const rows = 4 + + require.Equal(t, rows, v.Rows()) + require.True(t, v.RowIsNull(2)) + require.False(t, v.RowIsNull(0)) + require.Equal(t, 1, v.RowOffset(3)) + + d, ok := v.Discriminator(ColumnTypeString) + require.True(t, ok) + require.Equal(t, uint8(1), d) + _, ok = v.Discriminator(ColumnTypeUInt8) + require.False(t, ok) + + var buf Buffer + v.EncodeColumn(&buf) + + t.Run("Ok", func(t *testing.T) { + br := bytes.NewReader(buf.Buf) + r := NewReader(br) + + dec, decInts, decStrs := newTestVariant() + require.NoError(t, dec.DecodeColumn(r, rows)) + + require.Equal(t, []uint8{0, 1, VariantNullDiscriminator, 0}, []uint8(dec.Discriminators)) + require.Equal(t, int64(42), decInts.Row(dec.RowOffset(0))) + require.Equal(t, "foo", decStrs.Row(dec.RowOffset(1))) + require.True(t, dec.RowIsNull(2)) + require.Equal(t, int64(100), decInts.Row(dec.RowOffset(3))) + + dec.Reset() + require.Equal(t, 0, dec.Rows()) + require.Equal(t, 0, decInts.Rows()) + require.Equal(t, 0, decStrs.Rows()) + }) + t.Run("EOF", func(t *testing.T) { + r := NewReader(bytes.NewReader(nil)) + dec, _, _ := newTestVariant() + require.ErrorIs(t, dec.DecodeColumn(r, rows), io.EOF) + }) + t.Run("NoShortRead", func(t *testing.T) { + dec, _, _ := newTestVariant() + requireNoShortRead(t, buf.Buf, colAware(dec, rows)) + }) +} + +func TestColVariant_State(t *testing.T) { + v, _, _ := newTestVariant() + + var buf Buffer + v.EncodeState(&buf) + require.Equal(t, []byte{0, 0, 0, 0, 0, 0, 0, 0}, buf.Buf) + + t.Run("Basic", func(t *testing.T) { + dec, _, _ := newTestVariant() + require.NoError(t, dec.DecodeState(NewReader(bytes.NewReader(buf.Buf)))) + require.Equal(t, VariantDiscriminatorsBasic, dec.mode) + }) + t.Run("Compact", func(t *testing.T) { + var b Buffer + b.PutUInt64(VariantDiscriminatorsCompact) + + dec, _, _ := newTestVariant() + require.NoError(t, dec.DecodeState(NewReader(bytes.NewReader(b.Buf)))) + require.Equal(t, VariantDiscriminatorsCompact, dec.mode) + }) + t.Run("Unknown", func(t *testing.T) { + var b Buffer + b.PutUInt64(2) + + dec, _, _ := newTestVariant() + require.Error(t, dec.DecodeState(NewReader(bytes.NewReader(b.Buf)))) + }) + t.Run("EOF", func(t *testing.T) { + dec, _, _ := newTestVariant() + require.ErrorIs(t, dec.DecodeState(NewReader(bytes.NewReader(nil))), io.EOF) + }) +} + +func TestColVariant_DecodeCompact(t *testing.T) { + // Granule of 2 rows of single Int64 variant, then plain granule of + // String and NULL rows. + var buf Buffer + buf.PutUVarInt(2) + buf.PutUInt8(variantGranuleCompact) + buf.PutUInt8(0) + buf.PutUVarInt(2) + buf.PutUInt8(variantGranulePlain) + buf.PutUInt8(1) + buf.PutUInt8(VariantNullDiscriminator) + + var data Buffer + ColInt64{42, 100}.EncodeColumn(&data) + var strs ColStr + strs.Append("foo") + strs.EncodeColumn(&data) + buf.PutRaw(data.Buf) + + const rows = 4 + dec, decInts, decStrs := newTestVariant() + dec.mode = VariantDiscriminatorsCompact + require.NoError(t, dec.DecodeColumn(NewReader(bytes.NewReader(buf.Buf)), rows)) + + require.Equal(t, []uint8{0, 0, 1, VariantNullDiscriminator}, []uint8(dec.Discriminators)) + require.Equal(t, int64(42), decInts.Row(dec.RowOffset(0))) + require.Equal(t, int64(100), decInts.Row(dec.RowOffset(1))) + require.Equal(t, "foo", decStrs.Row(dec.RowOffset(2))) + require.True(t, dec.RowIsNull(3)) + + t.Run("UnknownGranule", func(t *testing.T) { + var b Buffer + b.PutUVarInt(1) + b.PutUInt8(2) + + dec, _, _ := newTestVariant() + dec.mode = VariantDiscriminatorsCompact + require.Error(t, dec.DecodeColumn(NewReader(bytes.NewReader(b.Buf)), 1)) + }) + t.Run("GranuleTooBig", func(t *testing.T) { + var b Buffer + b.PutUVarInt(2) + b.PutUInt8(variantGranuleCompact) + b.PutUInt8(0) + + dec, _, _ := newTestVariant() + dec.mode = VariantDiscriminatorsCompact + require.Error(t, dec.DecodeColumn(NewReader(bytes.NewReader(b.Buf)), 1)) + }) +} + +func TestColVariant_DecodeInvalidDiscriminator(t *testing.T) { + var buf Buffer + buf.PutUInt8(7) + + dec, _, _ := newTestVariant() + require.Error(t, dec.DecodeColumn(NewReader(bytes.NewReader(buf.Buf)), 1)) +} + +func TestColVariant_Prepare(t *testing.T) { + t.Run("Ok", func(t *testing.T) { + v, _, _ := newTestVariant() + require.NoError(t, v.Prepare()) + }) + t.Run("NotSorted", func(t *testing.T) { + v := NewVariant(new(ColStr), new(ColInt64)) + require.Error(t, v.Prepare()) + }) + t.Run("Duplicate", func(t *testing.T) { + v := NewVariant(new(ColInt64), new(ColInt64)) + require.Error(t, v.Prepare()) + }) + t.Run("LowCardinality", func(t *testing.T) { + v := NewVariant(new(ColInt64), new(ColStr).LowCardinality()) + require.Equal(t, ColumnType("Variant(Int64, LowCardinality(String))"), v.Type()) + require.NoError(t, v.Prepare()) + }) +} + +func TestColVariant_Infer(t *testing.T) { + t.Run("Ok", func(t *testing.T) { + v, _, _ := newTestVariant() + require.NoError(t, v.Infer("Variant(Int64, String)")) + }) + t.Run("Mismatch", func(t *testing.T) { + v, _, _ := newTestVariant() + require.Error(t, v.Infer("Variant(Int64, String, UInt8)")) + }) +} diff --git a/proto/column.go b/proto/column.go index 9e00cc72..f669fed5 100644 --- a/proto/column.go +++ b/proto/column.go @@ -195,6 +195,51 @@ func (c ColumnType) Elem() ColumnType { return c[start+1 : end] } +// Elems splits Elem() by top level commas. +// +// Nested parentheses and quoted strings are respected, so +// Variant(Array(Int8), Map(String, String)) results in +// Array(Int8) and Map(String, String). +func (c ColumnType) Elems() []ColumnType { + elem := string(c.Elem()) + if elem == "" { + return nil + } + var ( + out []ColumnType + depth int + quote bool + start int + ) + for i := 0; i < len(elem); i++ { + switch elem[i] { + case '\\': + if quote { + i++ + } + case '\'': + quote = !quote + case '(': + if !quote { + depth++ + } + case ')': + if !quote { + depth-- + } + case ',': + if quote || depth > 0 { + continue + } + out = append(out, ColumnType(strings.TrimSpace(elem[start:i]))) + start = i + 1 + default: + // skipping name or string content + } + } + return append(out, ColumnType(strings.TrimSpace(elem[start:]))) +} + // IsArray reports whether ColumnType is composite. func (c ColumnType) IsArray() bool { return strings.HasPrefix(string(c), string(ColumnTypeArray)) @@ -255,6 +300,7 @@ const ( ColumnTypeNothing ColumnType = "Nothing" ColumnTypeJSON ColumnType = "JSON" ColumnTypeQBit ColumnType = "QBit" + ColumnTypeVariant ColumnType = "Variant" ) // colWrap wraps Column with type t. diff --git a/proto/column_test.go b/proto/column_test.go index 227b0ea2..5a9e2bc9 100644 --- a/proto/column_test.go +++ b/proto/column_test.go @@ -94,3 +94,33 @@ func TestColumnType_Elem(t *testing.T) { }) }) } + +func TestColumnType_Elems(t *testing.T) { + for _, tt := range []struct { + Type ColumnType + Elems []ColumnType + }{ + {Type: ColumnTypeInt32}, + {Type: "Variant(Int64)", Elems: []ColumnType{"Int64"}}, + {Type: "Variant(Int64, String)", Elems: []ColumnType{"Int64", "String"}}, + {Type: "Variant(Int64,String)", Elems: []ColumnType{"Int64", "String"}}, + { + Type: "Variant(Array(Int8), Map(String, String))", + Elems: []ColumnType{"Array(Int8)", "Map(String, String)"}, + }, + { + Type: "Variant(Enum8('a' = 1, 'b' = 2), String)", + Elems: []ColumnType{"Enum8('a' = 1, 'b' = 2)", "String"}, + }, + { + Type: `Variant(Enum8('a\'b, c' = 1), String)`, + Elems: []ColumnType{`Enum8('a\'b, c' = 1)`, "String"}, + }, + { + Type: "Tuple(a Int8, b Array(Tuple(c String, d Int8)))", + Elems: []ColumnType{"a Int8", "b Array(Tuple(c String, d Int8))"}, + }, + } { + assert.Equal(t, tt.Elems, tt.Type.Elems(), "%s", tt.Type) + } +} diff --git a/query_test.go b/query_test.go index 6908f776..67e73079 100644 --- a/query_test.go +++ b/query_test.go @@ -1053,6 +1053,68 @@ func TestClient_Query(t *testing.T) { require.Equal(t, []string{`{"x":"test1"}`, `{"y":"test2"}`}, data.Row(0)) }) }) + t.Run("InsertVariant", func(t *testing.T) { + t.Parallel() + conn := ConnOpt(t, Options{ + Settings: []Setting{ + {Key: "enable_variant_type", Value: "1"}, + }, + }) + if v := conn.ServerInfo(); v.Major < 24 { + t.Skipf("Skipping (Variant is supported since 24.1, got %d.%d)", v.Major, v.Minor) + } + + createTable := Query{ + Body: "CREATE TABLE variant_test_table (id Int64, c Variant(Int64, String)) ENGINE = MergeTree ORDER BY id", + } + require.NoError(t, conn.Do(ctx, createTable), "create table") + + var ( + ids proto.ColInt64 + ints proto.ColInt64 + strs proto.ColStr + ) + data := proto.NewVariant(&ints, &strs) + + ids.Append(1) + data.AppendDiscriminator(0) + ints.Append(42) + + ids.Append(2) + data.AppendDiscriminator(1) + strs.Append("foo") + + ids.Append(3) + data.AppendNull() + + insertQuery := Query{ + Body: "INSERT INTO variant_test_table VALUES", + Input: []proto.InputColumn{ + {Name: "id", Data: &ids}, + {Name: "c", Data: data}, + }, + } + require.NoError(t, conn.Do(ctx, insertQuery), "insert") + + t.Run("Read", func(t *testing.T) { + var ( + ints proto.ColInt64 + strs proto.ColStr + ) + data := proto.NewVariant(&ints, &strs) + require.NoError(t, conn.Do(ctx, Query{ + Body: "SELECT c FROM variant_test_table ORDER BY id", + Result: proto.Results{ + {Name: "c", Data: data}, + }, + })) + + require.Equal(t, 3, data.Rows()) + require.Equal(t, int64(42), ints.Row(data.RowOffset(0))) + require.Equal(t, "foo", strs.Row(data.RowOffset(1))) + require.True(t, data.RowIsNull(2)) + }) + }) } func TestClientCompression(t *testing.T) {