From 88a963c62ed1ac15f2728c512e7179a745e503c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muraru=20=C8=98tefan?= Date: Thu, 7 Aug 2025 15:08:20 +0300 Subject: [PATCH] feat[wip]: implement XLSX file type --- client.go | 5 + go.mod | 7 ++ go.sum | 17 +++ spec.go | 16 ++- xlsx/client.go | 17 +++ xlsx/read.go | 86 +++++++++++++++ xlsx/spec.go | 23 ++++ xlsx/spec_test.go | 25 +++++ xlsx/testdata/TestWriteRead-default.xlsx | Bin 0 -> 26727 bytes xlsx/write.go | 132 +++++++++++++++++++++++ xlsx/write_read_test.go | 114 ++++++++++++++++++++ 11 files changed, 440 insertions(+), 2 deletions(-) create mode 100644 xlsx/client.go create mode 100644 xlsx/read.go create mode 100644 xlsx/spec.go create mode 100644 xlsx/spec_test.go create mode 100644 xlsx/testdata/TestWriteRead-default.xlsx create mode 100644 xlsx/write.go create mode 100644 xlsx/write_read_test.go diff --git a/client.go b/client.go index cb2bffe0..e0d080ab 100644 --- a/client.go +++ b/client.go @@ -5,6 +5,7 @@ import ( jsonfile "github.com/cloudquery/filetypes/v4/json" "github.com/cloudquery/filetypes/v4/parquet" "github.com/cloudquery/filetypes/v4/types" + "github.com/cloudquery/filetypes/v4/xlsx" ) type Client struct { @@ -17,6 +18,7 @@ var ( _ types.FileType = (*csvfile.Client)(nil) _ types.FileType = (*jsonfile.Client)(nil) _ types.FileType = (*parquet.Client)(nil) + _ types.FileType = (*xlsx.Client)(nil) ) // NewClient creates a new client for the given spec @@ -49,6 +51,9 @@ func NewClient(spec *FileSpec) (*Client, error) { case FormatTypeParquet: client, err = parquet.NewClient(parquet.WithSpec(*spec.parquetSpec)) + case FormatTypeXLSX: + client, err = xlsx.NewClient() + default: // shouldn't be possible as Validate checks for type panic("unknown format " + spec.Format) diff --git a/go.mod b/go.mod index cc83cf47..66811509 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/invopop/jsonschema v0.13.0 github.com/stretchr/testify v1.10.0 github.com/wk8/go-ordered-map/v2 v2.1.8 + github.com/xuri/excelize/v2 v2.9.1 ) require ( @@ -38,11 +39,17 @@ require ( github.com/oapi-codegen/runtime v1.1.1 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/richardlehane/mscfb v1.0.4 // indirect + github.com/richardlehane/msoleps v1.0.4 // indirect github.com/rs/zerolog v1.34.0 // indirect github.com/samber/lo v1.49.1 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/thoas/go-funk v0.9.3 // indirect + github.com/tiendc/go-deepcopy v1.6.0 // indirect + github.com/xuri/efp v0.0.1 // indirect + github.com/xuri/nfp v0.0.1 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect + golang.org/x/crypto v0.39.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/mod v0.25.0 // indirect golang.org/x/net v0.41.0 // indirect diff --git a/go.sum b/go.sum index f60fe434..cc341d53 100644 --- a/go.sum +++ b/go.sum @@ -88,6 +88,11 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM= +github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk= +github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00= +github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= @@ -107,8 +112,16 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/thoas/go-funk v0.9.3 h1:7+nAEx3kn5ZJcnDm2Bh23N2yOtweO14bi//dvRtgLpw= github.com/thoas/go-funk v0.9.3/go.mod h1:+IWnUfUmFO1+WVYQWQtIJHeRRdaIyyYglZN7xzUPe4Q= +github.com/tiendc/go-deepcopy v1.6.0 h1:0UtfV/imoCwlLxVsyfUd4hNHnB3drXsfle+wzSCA5Wo= +github.com/tiendc/go-deepcopy v1.6.0/go.mod h1:toXoeQoUqXOOS/X4sKuiAoSk6elIdqc0pN7MTgOOo2I= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= +github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= +github.com/xuri/excelize/v2 v2.9.1 h1:VdSGk+rraGmgLHGFaGG9/9IWu1nj4ufjJ7uwMDtj8Qw= +github.com/xuri/excelize/v2 v2.9.1/go.mod h1:x7L6pKz2dvo9ejrRuD8Lnl98z4JLt0TGAwjhW+EiP8s= +github.com/xuri/nfp v0.0.1 h1:MDamSGatIvp8uOmDP8FnmjuQpu90NzdJxo7242ANR9Q= +github.com/xuri/nfp v0.0.1/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= @@ -127,8 +140,12 @@ go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFh go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= +golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= diff --git a/spec.go b/spec.go index f0c559c3..f44f98c8 100644 --- a/spec.go +++ b/spec.go @@ -9,6 +9,7 @@ import ( "github.com/cloudquery/filetypes/v4/csv" jsonfile "github.com/cloudquery/filetypes/v4/json" "github.com/cloudquery/filetypes/v4/parquet" + "github.com/cloudquery/filetypes/v4/xlsx" ) type FormatType string @@ -17,6 +18,7 @@ const ( FormatTypeCSV = "csv" FormatTypeJSON = "json" FormatTypeParquet = "parquet" + FormatTypeXLSX = "xlsx" ) // Compression type. @@ -41,6 +43,7 @@ type FileSpec struct { csvSpec *csv.CSVSpec jsonSpec *jsonfile.JSONSpec parquetSpec *parquet.ParquetSpec + xlsxSpec *xlsx.Spec } func (s *FileSpec) SetDefaults() { @@ -51,6 +54,8 @@ func (s *FileSpec) SetDefaults() { s.jsonSpec.SetDefaults() case FormatTypeParquet: s.parquetSpec.SetDefaults() + case FormatTypeXLSX: + s.xlsxSpec.SetDefaults() } } @@ -68,10 +73,14 @@ func (s *FileSpec) Validate() error { return s.jsonSpec.Validate() case FormatTypeParquet: if s.Compression != CompressionTypeNone { - return errors.New("compression is not supported for parquet format") // This won't work even if we wanted to, because parquet writer prematurely closes the file handle + return fmt.Errorf("compression is not supported for the %s format", s.Format) } - return s.parquetSpec.Validate() + case FormatTypeXLSX: + if s.Compression != CompressionTypeNone { + return fmt.Errorf("compression is not supported for the %s format", s.Format) + } + return s.xlsxSpec.Validate() default: return fmt.Errorf("unknown format %s", s.Format) } @@ -96,6 +105,9 @@ func (s *FileSpec) UnmarshalSpec() error { case FormatTypeParquet: s.parquetSpec = &parquet.ParquetSpec{} return dec.Decode(s.parquetSpec) + case FormatTypeXLSX: + s.xlsxSpec = &xlsx.Spec{} + return dec.Decode(s.xlsxSpec) default: return fmt.Errorf("unknown format %s", s.Format) } diff --git a/xlsx/client.go b/xlsx/client.go new file mode 100644 index 00000000..35431e2c --- /dev/null +++ b/xlsx/client.go @@ -0,0 +1,17 @@ +package xlsx + +type Options func(*Client) + +// Client is a csv client. +type Client struct { +} + +func NewClient(options ...Options) (*Client, error) { + c := &Client{} + + for _, option := range options { + option(c) + } + + return c, nil +} diff --git a/xlsx/read.go b/xlsx/read.go new file mode 100644 index 00000000..1424d422 --- /dev/null +++ b/xlsx/read.go @@ -0,0 +1,86 @@ +package xlsx + +import ( + "bytes" + "fmt" + + "github.com/apache/arrow-go/v18/arrow" + "github.com/apache/arrow-go/v18/arrow/array" + "github.com/apache/arrow-go/v18/arrow/memory" + "github.com/cloudquery/filetypes/v4/types" + "github.com/cloudquery/plugin-sdk/v4/schema" + "github.com/goccy/go-json" + "github.com/xuri/excelize/v2" +) + +func (cl *Client) Read(r types.ReaderAtSeeker, table *schema.Table, res chan<- arrow.Record) error { + file, err := excelize.OpenReader(r) + if err != nil { + return fmt.Errorf("failed to open xlsx reader: %w", err) + } + + sheetName := "data" + rows, err := file.GetRows(sheetName) + if err != nil { + return fmt.Errorf("failed to get rows from sheet %s: %w", sheetName, err) + } + + for _, row := range rows { + rb := array.NewRecordBuilder(memory.DefaultAllocator, table.ToArrowSchema()) + for i, field := range rb.Fields() { + err := appendValue(field, row[i]) + if err != nil { + return fmt.Errorf("failed to read from sheet %s: %w", table.Name, err) + } + } + res <- rb.NewRecord() + } + return nil +} + +func appendValue(builder array.Builder, value any) error { + if value == nil { + builder.AppendNull() + return nil + } + switch bldr := builder.(type) { + case array.ListLikeBuilder: + lst := value.([]any) + if lst == nil { + bldr.AppendNull() + return nil + } + bldr.Append(true) + valBuilder := bldr.ValueBuilder() + for _, v := range lst { + if err := appendValue(valBuilder, v); err != nil { + return err + } + } + return nil + case *array.StructBuilder: + m := value.(map[string]any) + bldr.Append(true) + bldrType := bldr.Type().(*arrow.StructType) + for k, v := range m { + idx, _ := bldrType.FieldIdx(k) + fieldBldr := bldr.FieldBuilder(idx) + if err := appendValue(fieldBldr, v); err != nil { + return err + } + } + return nil + case *array.MonthIntervalBuilder, *array.DayTimeIntervalBuilder, *array.MonthDayNanoIntervalBuilder: + b, err := json.Marshal(value) + if err != nil { + return err + } + dec := json.NewDecoder(bytes.NewReader(b)) + return bldr.UnmarshalOne(dec) + case *array.Int8Builder, *array.Int16Builder, *array.Int32Builder, *array.Int64Builder: + return bldr.AppendValueFromString(fmt.Sprintf("%d", int64(value.(float64)))) + case *array.Uint8Builder, *array.Uint16Builder, *array.Uint32Builder, *array.Uint64Builder: + return bldr.AppendValueFromString(fmt.Sprintf("%d", uint64(value.(float64)))) + } + return builder.AppendValueFromString(fmt.Sprintf("%v", value)) +} diff --git a/xlsx/spec.go b/xlsx/spec.go new file mode 100644 index 00000000..258a9838 --- /dev/null +++ b/xlsx/spec.go @@ -0,0 +1,23 @@ +package xlsx + +import ( + "github.com/invopop/jsonschema" +) + +type Spec struct{} + +func (Spec) JSONSchema() *jsonschema.Schema { + properties := jsonschema.NewProperties() + return &jsonschema.Schema{ + Description: "CloudQuery XLSX file output spec.", + Properties: properties, + Type: "object", + AdditionalProperties: jsonschema.FalseSchema, // "additionalProperties": false + } +} + +func (s *Spec) SetDefaults() {} + +func (s *Spec) Validate() error { + return nil +} diff --git a/xlsx/spec_test.go b/xlsx/spec_test.go new file mode 100644 index 00000000..fcd6230e --- /dev/null +++ b/xlsx/spec_test.go @@ -0,0 +1,25 @@ +package xlsx + +import ( + "testing" + + "github.com/cloudquery/codegen/jsonschema" + "github.com/stretchr/testify/require" +) + +func TestSpec_JSONSchema(t *testing.T) { + schema, err := jsonschema.Generate(Spec{}) + require.NoError(t, err) + + jsonschema.TestJSONSchema(t, string(schema), []jsonschema.TestCase{ + { + Name: "empty", + Spec: `{}`, + }, + { + Name: "extra keyword", + Err: true, + Spec: `{"extra":true}`, + }, + }) +} diff --git a/xlsx/testdata/TestWriteRead-default.xlsx b/xlsx/testdata/TestWriteRead-default.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..081df403c7fd21e7f0b59b6abf29ace748fd3a6a GIT binary patch literal 26727 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 literal 0 HcmV?d00001 diff --git a/xlsx/write.go b/xlsx/write.go new file mode 100644 index 00000000..f0918e27 --- /dev/null +++ b/xlsx/write.go @@ -0,0 +1,132 @@ +package xlsx + +import ( + "fmt" + "io" + + "github.com/apache/arrow-go/v18/arrow" + "github.com/apache/arrow-go/v18/arrow/array" + "github.com/apache/arrow-go/v18/arrow/memory" + "github.com/cloudquery/filetypes/v4/types" + "github.com/cloudquery/plugin-sdk/v4/schema" + "github.com/xuri/excelize/v2" +) + +const ( + defaultSheetName = "data" +) + +type Handle struct { + w io.Writer + schema *arrow.Schema + + idx int + file *excelize.File +} + +var _ types.Handle = (*Handle)(nil) + +func (cl *Client) WriteHeader(w io.Writer, t *schema.Table) (types.Handle, error) { + file := excelize.NewFile() + + err := file.SetSheetName("Sheet1", defaultSheetName) + if err != nil { + return nil, fmt.Errorf("failed to create new sheet: %w", err) + } + + var cells []any + for _, name := range t.Columns.Names() { + cells = append(cells, name) + } + + if err = file.SetSheetRow(defaultSheetName, "A1", &cells); err != nil { + return nil, fmt.Errorf("failed to set header row: %w", err) + } + + return &Handle{ + w: w, + schema: convertSchema(t.ToArrowSchema()), + idx: 2, + file: file, + }, nil +} + +func (h *Handle) WriteContent(records []arrow.Record) error { + for _, record := range records { + record := h.castToString(record) + for i := 0; i < int(record.NumRows()); i++ { + cellname, err := excelize.CoordinatesToCellName(1, h.idx) + if err != nil { + return fmt.Errorf("failed to convert coordinates to cell name: %w", err) + } + var cells []any + for j := 0; j < int(record.NumCols()); j++ { + cells = append(cells, record.Column(j).GetOneForMarshal(i)) + } + h.idx += 1 + if err := h.file.SetSheetRow(defaultSheetName, cellname, &cells); err != nil { + return fmt.Errorf("failed to set row in stream writer: %w", err) + } + } + } + + return nil +} + +func (h *Handle) WriteFooter() error { + return h.file.Write(h.w) +} + +func convertSchema(sch *arrow.Schema) *arrow.Schema { + oldFields := sch.Fields() + fields := make([]arrow.Field, len(oldFields)) + copy(fields, oldFields) + for i, f := range fields { + if !isTypeSupported(f.Type) { + fields[i].Type = arrow.BinaryTypes.String + } + } + + md := sch.Metadata() + newSchema := arrow.NewSchema(fields, &md) + return newSchema +} + +func isTypeSupported(t arrow.DataType) bool { + switch t.(type) { + case *arrow.BooleanType, + *arrow.Int8Type, *arrow.Int16Type, *arrow.Int32Type, *arrow.Int64Type, + *arrow.Uint8Type, *arrow.Uint16Type, *arrow.Uint32Type, *arrow.Uint64Type, + *arrow.Float32Type, *arrow.Float64Type, + *arrow.StringType, + *arrow.TimestampType, + *arrow.Date32Type, *arrow.Date64Type, + *arrow.Decimal128Type, *arrow.Decimal256Type, + *arrow.BinaryType: + return true + } + + return false +} + +func (h *Handle) castToString(rec arrow.Record) arrow.Record { + cols := make([]arrow.Array, h.schema.NumFields()) + for c := 0; c < h.schema.NumFields(); c++ { + col := rec.Column(c) + if isTypeSupported(col.DataType()) { + cols[c] = col + continue + } + + sb := array.NewStringBuilder(memory.DefaultAllocator) + for i := 0; i < col.Len(); i++ { + if col.IsNull(i) { + sb.AppendNull() + continue + } + sb.Append(col.ValueStr(i)) + } + cols[c] = sb.NewArray() + } + return array.NewRecord(h.schema, cols, rec.NumRows()) +} diff --git a/xlsx/write_read_test.go b/xlsx/write_read_test.go new file mode 100644 index 00000000..52fe26ca --- /dev/null +++ b/xlsx/write_read_test.go @@ -0,0 +1,114 @@ +package xlsx + +import ( + "bufio" + "bytes" + "io" + "testing" + "time" + + "github.com/apache/arrow-go/v18/arrow" + "github.com/bradleyjkemp/cupaloy/v2" + "github.com/cloudquery/filetypes/v4/types" + "github.com/cloudquery/plugin-sdk/v4/plugin" + "github.com/cloudquery/plugin-sdk/v4/schema" + "github.com/stretchr/testify/require" +) + +func TestWriteRead(t *testing.T) { + cases := []struct { + name string + options []Options + outputCount int + }{ + {name: "default", outputCount: 2}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + table := schema.TestTable("test", schema.TestSourceOptions{}) + sourceName := "test-source" + syncTime := time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC) + opts := schema.GenTestDataOptions{ + SourceName: sourceName, + SyncTime: syncTime, + MaxRows: 2, + StableTime: time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC), + } + tg := schema.NewTestDataGenerator(0) + record := tg.Generate(table, opts) + + cl, err := NewClient(tc.options...) + if err != nil { + t.Fatal(err) + } + + var b bytes.Buffer + writer := bufio.NewWriter(&b) + reader := bufio.NewReader(&b) + + if err := types.WriteAll(cl, writer, table, []arrow.Record{record}); err != nil { + t.Fatal(err) + } + writer.Flush() + + rawBytes, err := io.ReadAll(reader) + if err != nil { + t.Fatal(err) + } + snap := cupaloy.New( + cupaloy.SnapshotFileExtension(".xlsx"), + cupaloy.SnapshotSubdirectory("testdata"), + ) + snap.SnapshotT(t, string(rawBytes)) + + byteReader := bytes.NewReader(rawBytes) + + ch := make(chan arrow.Record) + var readErr error + go func() { + readErr = cl.Read(byteReader, table, ch) + close(ch) + }() + received := make([]arrow.Record, 0, tc.outputCount) + for got := range ch { + received = append(received, got) + } + require.Empty(t, plugin.RecordsDiff(table.ToArrowSchema(), []arrow.Record{record}, received)) + require.NoError(t, readErr) + require.Equalf(t, tc.outputCount, len(received), "got %d row(s), want %d", len(received), tc.outputCount) + }) + } +} + +func BenchmarkWrite(b *testing.B) { + table := schema.TestTable("test", schema.TestSourceOptions{}) + sourceName := "test-source" + syncTime := time.Now().UTC().Round(time.Second) + opts := schema.GenTestDataOptions{ + SourceName: sourceName, + SyncTime: syncTime, + MaxRows: 1000, + } + tg := schema.NewTestDataGenerator(0) + record := tg.Generate(table, opts) + + cl, err := NewClient() + if err != nil { + b.Fatal(err) + } + var buf bytes.Buffer + writer := bufio.NewWriter(&buf) + b.ResetTimer() + for i := 0; i < b.N; i++ { + if err := types.WriteAll(cl, writer, table, []arrow.Record{record}); err != nil { + b.Fatal(err) + } + + err = writer.Flush() + if err != nil { + b.Fatal(err) + } + buf.Reset() + } +}