From 7d01c30c295ef1e2c54d8d1fe254798f4eada9e7 Mon Sep 17 00:00:00 2001 From: Anurag Bandyopadhyay Date: Thu, 11 Jun 2026 19:33:25 +0530 Subject: [PATCH 1/3] refactor: drop gocsv to stdlib encoding/csv --- cmd/tuple/read.go | 58 +++++++++++++----- cmd/tuple/read_test.go | 42 ++++++++++++- go.mod | 1 - go.sum | 2 - internal/output/marshal.go | 42 +++++++++---- internal/output/marshal_test.go | 105 ++++++++++++++++++++++++++++++++ 6 files changed, 218 insertions(+), 32 deletions(-) create mode 100644 internal/output/marshal_test.go diff --git a/cmd/tuple/read.go b/cmd/tuple/read.go index 3f3b8118..3d245bfc 100644 --- a/cmd/tuple/read.go +++ b/cmd/tuple/read.go @@ -21,6 +21,7 @@ import ( "encoding/json" "errors" "fmt" + "os" "strings" openfga "github.com/openfga/go-sdk" @@ -48,18 +49,44 @@ type readResponse struct { } type readResponseCSVDTO struct { - UserType string `csv:"user_type"` - UserID string `csv:"user_id"` - UserRelation string `csv:"user_relation,omitempty"` - Relation string `csv:"relation"` - ObjectType string `csv:"object_type"` - ObjectID string `csv:"object_id"` - ConditionName string `csv:"condition_name,omitempty"` - ConditionContext string `csv:"condition_context,omitempty"` + UserType string + UserID string + UserRelation string + Relation string + ObjectType string + ObjectID string + ConditionName string + ConditionContext string } -func (r readResponse) toCsvDTO() ([]readResponseCSVDTO, error) { - readResponseDTO := make([]readResponseCSVDTO, 0, len(r.simple)) +type readResponseCSVDTOList []readResponseCSVDTO + +var readResponseCSVHeaders = []string{ + "user_type", + "user_id", + "user_relation", + "relation", + "object_type", + "object_id", + "condition_name", + "condition_context", +} + +func (dto readResponseCSVDTO) MarshalCSV() ([]string, error) { + return []string{ + dto.UserType, + dto.UserID, + dto.UserRelation, + dto.Relation, + dto.ObjectType, + dto.ObjectID, + dto.ConditionName, + dto.ConditionContext, + }, nil +} + +func (r readResponse) toCsvDTO() (readResponseCSVDTOList, error) { + readResponseDTO := make(readResponseCSVDTOList, 0, len(r.simple)) for _, readRes := range r.simple { // Handle Condition @@ -196,19 +223,22 @@ var readCmd = &cobra.Command{ simpleOutput, _ := cmd.Flags().GetBool("simple-output") outputFormat, _ := cmd.Flags().GetString("output-format") - dataPrinter := output.NewUniPrinter(outputFormat) if outputFormat == "csv" { - data, _ := response.toCsvDTO() - - err := dataPrinter.Display(data) + records, err := response.toCsvDTO() if err != nil { + return fmt.Errorf("failed to convert response to csv: %w", err) + } + + if err := output.MarshalCSV(records, os.Stdout, readResponseCSVHeaders...); err != nil { return fmt.Errorf("failed to display csv: %w", err) } return nil } + dataPrinter := output.NewUniPrinter(outputFormat) + var data any data = *response.complete diff --git a/cmd/tuple/read_test.go b/cmd/tuple/read_test.go index 54007c08..8fcab8ec 100644 --- a/cmd/tuple/read_test.go +++ b/cmd/tuple/read_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" openfga "github.com/openfga/go-sdk" "github.com/openfga/go-sdk/client" @@ -438,7 +439,7 @@ func TestReadResponseCSVDTOParser(t *testing.T) { testCases := []struct { readRes readResponse - expected []readResponseCSVDTO + expected readResponseCSVDTOList }{ { readRes: readResponse{ @@ -460,7 +461,7 @@ func TestReadResponseCSVDTOParser(t *testing.T) { }, }, }, - expected: []readResponseCSVDTO{ + expected: readResponseCSVDTOList{ { UserType: "user", UserID: "anne", @@ -487,6 +488,43 @@ func TestReadResponseCSVDTOParser(t *testing.T) { } } +func TestReadResponseCSVDTOListMarshalCSV(t *testing.T) { + t.Parallel() + + list := readResponseCSVDTOList{ + { + UserType: "user", + UserID: "anne", + Relation: "reader", + ObjectType: "document", + ObjectID: "secret.doc", + ConditionName: "inOfficeIP", + ConditionContext: `{"ip_addr":"10.0.0.1"}`, + }, + { + UserType: "user", + UserID: "john", + Relation: "writer", + ObjectType: "document", + ObjectID: "abc.doc", + }, + } + + rows := make([][]string, 0, len(list)) + + for _, record := range list { + row, err := record.MarshalCSV() + require.NoError(t, err) + + rows = append(rows, row) + } + + assert.Equal(t, [][]string{ + {"user", "anne", "", "reader", "document", "secret.doc", "inOfficeIP", `{"ip_addr":"10.0.0.1"}`}, + {"user", "john", "", "writer", "document", "abc.doc", "", ""}, + }, rows) +} + func toPointer[T any](p T) *T { return &p } diff --git a/go.mod b/go.mod index f48b781e..01fcafdf 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.25.7 toolchain go1.26.4 require ( - github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1 github.com/hashicorp/go-multierror v1.1.1 github.com/mattn/go-isatty v0.0.22 github.com/muesli/mango-cobra v1.3.0 diff --git a/go.sum b/go.sum index c9c9bf3b..9f6e4a69 100644 --- a/go.sum +++ b/go.sum @@ -74,8 +74,6 @@ github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1 h1:FWNFq4fM1wPfcK40yHE5UO3RUdSNPaBC+j3PokzA6OQ= -github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1/go.mod h1:5YoVOkjYAQumqlV356Hj3xeYh4BdZuLE0/nRkf2NKkI= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= diff --git a/internal/output/marshal.go b/internal/output/marshal.go index 828d0ad6..3875a82d 100644 --- a/internal/output/marshal.go +++ b/internal/output/marshal.go @@ -18,11 +18,12 @@ limitations under the License. package output import ( + "encoding/csv" "encoding/json" "fmt" + "io" "os" - "github.com/gocarina/gocsv" "gopkg.in/yaml.v3" "github.com/mattn/go-isatty" @@ -38,9 +39,6 @@ type Printer interface { // jsonPrinter implements the Printer interface for JSON output. type jsonPrinter struct{} -// csvPrinter implements the Printer interface for CSV output. -type csvPrinter struct{} - // yamlPrinter implements the Printer interface for YAML output. type yamlPrinter struct{} @@ -69,17 +67,37 @@ func (prt *jsonPrinter) DisplayColor(data any) error { return nil } -func (prt *csvPrinter) DisplayColor(data any) error { - return prt.DisplayNoColor(data) +// CSVMarshaler is implemented by a value that can encode itself as a CSV record. +type CSVMarshaler interface { + MarshalCSV() ([]string, error) } -func (prt *csvPrinter) DisplayNoColor(data any) error { - b, err := gocsv.MarshalBytes(data) - if err != nil { - return fmt.Errorf("unable to marshal CSV with error: %w", err) +// MarshalCSV writes an optional header row followed by one record per element to w. +func MarshalCSV[T CSVMarshaler](records []T, w io.Writer, header ...string) error { + writer := csv.NewWriter(w) + + if len(header) > 0 { + if err := writer.Write(header); err != nil { + return fmt.Errorf("failed to write csv header: %w", err) + } } - fmt.Println(string(b)) + for _, record := range records { + row, err := record.MarshalCSV() + if err != nil { + return fmt.Errorf("failed to marshal csv record: %w", err) + } + + if err := writer.Write(row); err != nil { + return fmt.Errorf("failed to write csv record: %w", err) + } + } + + writer.Flush() + + if err := writer.Error(); err != nil { + return fmt.Errorf("failed to flush csv: %w", err) + } return nil } @@ -115,8 +133,6 @@ func NewUniPrinter(outputFormat string) *UniPrinter { switch outputFormat { case "yaml": uniPrinter.Printer = &yamlPrinter{} - case "csv": - uniPrinter.Printer = &csvPrinter{} default: uniPrinter.Printer = &jsonPrinter{} } diff --git a/internal/output/marshal_test.go b/internal/output/marshal_test.go new file mode 100644 index 00000000..ecdb80fd --- /dev/null +++ b/internal/output/marshal_test.go @@ -0,0 +1,105 @@ +package output + +import ( + "bytes" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeCSVRow struct { + fields []string + err error +} + +func (r fakeCSVRow) MarshalCSV() ([]string, error) { + return r.fields, r.err +} + +func rows(values [][]string) []fakeCSVRow { + records := make([]fakeCSVRow, len(values)) + for i, v := range values { + records[i] = fakeCSVRow{fields: v} + } + + return records +} + +func TestMarshalCSV(t *testing.T) { + t.Parallel() + + headers := []string{"user_type", "user_id", "relation", "object_type", "object_id", "condition_context"} + + tests := []struct { + name string + records [][]string + expected string + }{ + { + name: "no records writes only headers", + records: nil, + expected: "user_type,user_id,relation,object_type,object_id,condition_context\n", + }, + { + name: "single record", + records: [][]string{ + {"user", "john", "writer", "document", "abc.doc", ""}, + }, + expected: "user_type,user_id,relation,object_type,object_id,condition_context\n" + + "user,john,writer,document,abc.doc,\n", + }, + { + name: "multiple records", + records: [][]string{ + {"user", "anne", "reader", "document", "x", ""}, + {"group", "eng", "owner", "repo", "y", ""}, + }, + expected: "user_type,user_id,relation,object_type,object_id,condition_context\n" + + "user,anne,reader,document,x,\n" + + "group,eng,owner,repo,y,\n", + }, + { + name: "values with commas, quotes and newlines are escaped", + records: [][]string{ + {"user", "a,b", "say \"hi\"", "doc", "line\nbreak", `{"ip_addr":"10.0.0.1"}`}, + }, + expected: "user_type,user_id,relation,object_type,object_id,condition_context\n" + + "user,\"a,b\",\"say \"\"hi\"\"\",doc,\"line\nbreak\",\"{\"\"ip_addr\"\":\"\"10.0.0.1\"\"}\"\n", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + + err := MarshalCSV(rows(test.records), &buf, headers...) + require.NoError(t, err) + assert.Equal(t, test.expected, buf.String()) + }) + } +} + +func TestMarshalCSVNoHeader(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + + err := MarshalCSV(rows([][]string{{"user", "john"}}), &buf) + require.NoError(t, err) + assert.Equal(t, "user,john\n", buf.String()) +} + +func TestMarshalCSVRecordError(t *testing.T) { + t.Parallel() + + sentinel := errors.New("boom") + + var buf bytes.Buffer + + err := MarshalCSV([]fakeCSVRow{{err: sentinel}}, &buf, "col") + assert.ErrorIs(t, err, sentinel) +} From 2fe637a5739571eef035aa62bb2ffd634a168bd0 Mon Sep 17 00:00:00 2001 From: Anurag Bandyopadhyay Date: Mon, 22 Jun 2026 22:22:28 +0530 Subject: [PATCH 2/3] refactor: buffer csv output to a single stdout write --- cmd/tuple/read.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cmd/tuple/read.go b/cmd/tuple/read.go index 3f6e98b7..16559cd4 100644 --- a/cmd/tuple/read.go +++ b/cmd/tuple/read.go @@ -17,6 +17,7 @@ limitations under the License. package tuple import ( + "bytes" "context" "encoding/json" "errors" @@ -228,7 +229,12 @@ var readCmd = &cobra.Command{ return fmt.Errorf("failed to convert response to csv: %w", err) } - if err := output.MarshalCSV(records, os.Stdout, readResponseCSVHeaders...); err != nil { + var buf bytes.Buffer + if err := output.MarshalCSV(records, &buf, readResponseCSVHeaders...); err != nil { + return fmt.Errorf("failed to marshal csv: %w", err) + } + + if _, err := os.Stdout.Write(buf.Bytes()); err != nil { return fmt.Errorf("failed to display csv: %w", err) } From 8ff0a61176b8ebdccf03996aec8a82d1d3adb169 Mon Sep 17 00:00:00 2001 From: Anurag Bandyopadhyay Date: Mon, 22 Jun 2026 22:41:29 +0530 Subject: [PATCH 3/3] refactor: stream csv output via bufio writer --- cmd/tuple/read.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/tuple/read.go b/cmd/tuple/read.go index 16559cd4..3360b1ca 100644 --- a/cmd/tuple/read.go +++ b/cmd/tuple/read.go @@ -17,7 +17,7 @@ limitations under the License. package tuple import ( - "bytes" + "bufio" "context" "encoding/json" "errors" @@ -229,12 +229,12 @@ var readCmd = &cobra.Command{ return fmt.Errorf("failed to convert response to csv: %w", err) } - var buf bytes.Buffer - if err := output.MarshalCSV(records, &buf, readResponseCSVHeaders...); err != nil { + writer := bufio.NewWriter(os.Stdout) + if err := output.MarshalCSV(records, writer, readResponseCSVHeaders...); err != nil { return fmt.Errorf("failed to marshal csv: %w", err) } - if _, err := os.Stdout.Write(buf.Bytes()); err != nil { + if err := writer.Flush(); err != nil { return fmt.Errorf("failed to display csv: %w", err) }