Skip to content

Commit fa24b46

Browse files
committed
refactor: drop gocsv to stdlib encoding/csv
1 parent d1fd7d3 commit fa24b46

6 files changed

Lines changed: 256 additions & 17 deletions

File tree

cmd/tuple/read.go

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,18 +48,57 @@ type readResponse struct {
4848
}
4949

5050
type readResponseCSVDTO struct {
51-
UserType string `csv:"user_type"`
52-
UserID string `csv:"user_id"`
53-
UserRelation string `csv:"user_relation,omitempty"`
54-
Relation string `csv:"relation"`
55-
ObjectType string `csv:"object_type"`
56-
ObjectID string `csv:"object_id"`
57-
ConditionName string `csv:"condition_name,omitempty"`
58-
ConditionContext string `csv:"condition_context,omitempty"`
51+
UserType string
52+
UserID string
53+
UserRelation string
54+
Relation string
55+
ObjectType string
56+
ObjectID string
57+
ConditionName string
58+
ConditionContext string
5959
}
6060

61-
func (r readResponse) toCsvDTO() ([]readResponseCSVDTO, error) {
62-
readResponseDTO := make([]readResponseCSVDTO, 0, len(r.simple))
61+
type readResponseCSVDTOList []readResponseCSVDTO
62+
63+
var readResponseCSVHeaders = []string{
64+
"user_type",
65+
"user_id",
66+
"user_relation",
67+
"relation",
68+
"object_type",
69+
"object_id",
70+
"condition_name",
71+
"condition_context",
72+
}
73+
74+
func (dto readResponseCSVDTO) MarshalCSV() ([]string, error) {
75+
return []string{
76+
dto.UserType,
77+
dto.UserID,
78+
dto.UserRelation,
79+
dto.Relation,
80+
dto.ObjectType,
81+
dto.ObjectID,
82+
dto.ConditionName,
83+
dto.ConditionContext,
84+
}, nil
85+
}
86+
87+
func (l readResponseCSVDTOList) CSVHeaders() []string {
88+
return readResponseCSVHeaders
89+
}
90+
91+
func (l readResponseCSVDTOList) CSVRecords() []output.CSVMarshaler {
92+
records := make([]output.CSVMarshaler, len(l))
93+
for i, dto := range l {
94+
records[i] = dto
95+
}
96+
97+
return records
98+
}
99+
100+
func (r readResponse) toCsvDTO() (readResponseCSVDTOList, error) {
101+
readResponseDTO := make(readResponseCSVDTOList, 0, len(r.simple))
63102

64103
for _, readRes := range r.simple {
65104
// Handle Condition

cmd/tuple/read_test.go

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"time"
88

99
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
1011

1112
openfga "github.com/openfga/go-sdk"
1213
"github.com/openfga/go-sdk/client"
@@ -438,7 +439,7 @@ func TestReadResponseCSVDTOParser(t *testing.T) {
438439

439440
testCases := []struct {
440441
readRes readResponse
441-
expected []readResponseCSVDTO
442+
expected readResponseCSVDTOList
442443
}{
443444
{
444445
readRes: readResponse{
@@ -460,7 +461,7 @@ func TestReadResponseCSVDTOParser(t *testing.T) {
460461
},
461462
},
462463
},
463-
expected: []readResponseCSVDTO{
464+
expected: readResponseCSVDTOList{
464465
{
465466
UserType: "user",
466467
UserID: "anne",
@@ -487,6 +488,45 @@ func TestReadResponseCSVDTOParser(t *testing.T) {
487488
}
488489
}
489490

491+
func TestReadResponseCSVDTOListMarshalCSV(t *testing.T) {
492+
t.Parallel()
493+
494+
list := readResponseCSVDTOList{
495+
{
496+
UserType: "user",
497+
UserID: "anne",
498+
Relation: "reader",
499+
ObjectType: "document",
500+
ObjectID: "secret.doc",
501+
ConditionName: "inOfficeIP",
502+
ConditionContext: `{"ip_addr":"10.0.0.1"}`,
503+
},
504+
{
505+
UserType: "user",
506+
UserID: "john",
507+
Relation: "writer",
508+
ObjectType: "document",
509+
ObjectID: "abc.doc",
510+
},
511+
}
512+
513+
assert.Equal(t, readResponseCSVHeaders, list.CSVHeaders())
514+
515+
rows := make([][]string, 0, len(list))
516+
517+
for _, record := range list.CSVRecords() {
518+
row, err := record.MarshalCSV()
519+
require.NoError(t, err)
520+
521+
rows = append(rows, row)
522+
}
523+
524+
assert.Equal(t, [][]string{
525+
{"user", "anne", "", "reader", "document", "secret.doc", "inOfficeIP", `{"ip_addr":"10.0.0.1"}`},
526+
{"user", "john", "", "writer", "document", "abc.doc", "", ""},
527+
}, rows)
528+
}
529+
490530
func toPointer[T any](p T) *T {
491531
return &p
492532
}

go.mod

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ go 1.25.7
55
toolchain go1.26.4
66

77
require (
8-
github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1
98
github.com/hashicorp/go-multierror v1.1.1
109
github.com/mattn/go-isatty v0.0.22
1110
github.com/muesli/mango-cobra v1.3.0

go.sum

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,6 @@ github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF
7474
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
7575
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
7676
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
77-
github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1 h1:FWNFq4fM1wPfcK40yHE5UO3RUdSNPaBC+j3PokzA6OQ=
78-
github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1/go.mod h1:5YoVOkjYAQumqlV356Hj3xeYh4BdZuLE0/nRkf2NKkI=
7977
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
8078
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
8179
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=

internal/output/marshal.go

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@ limitations under the License.
1818
package output
1919

2020
import (
21+
"bytes"
22+
"encoding/csv"
2123
"encoding/json"
24+
"errors"
2225
"fmt"
2326
"os"
2427

25-
"github.com/gocarina/gocsv"
2628
"gopkg.in/yaml.v3"
2729

2830
"github.com/mattn/go-isatty"
@@ -74,7 +76,7 @@ func (prt *csvPrinter) DisplayColor(data any) error {
7476
}
7577

7678
func (prt *csvPrinter) DisplayNoColor(data any) error {
77-
b, err := gocsv.MarshalBytes(data)
79+
b, err := marshalCSV(data)
7880
if err != nil {
7981
return fmt.Errorf("unable to marshal CSV with error: %w", err)
8082
}
@@ -84,6 +86,53 @@ func (prt *csvPrinter) DisplayNoColor(data any) error {
8486
return nil
8587
}
8688

89+
var errNotCSVMarshaler = errors.New("type does not implement output.CSVRecordSet")
90+
91+
// CSVMarshaler is implemented by a type that can render itself as a single CSV record.
92+
type CSVMarshaler interface {
93+
MarshalCSV() ([]string, error)
94+
}
95+
96+
// CSVRecordSet is implemented by a collection that can render itself as CSV:
97+
// a header row followed by one record per element.
98+
type CSVRecordSet interface {
99+
CSVHeaders() []string
100+
CSVRecords() []CSVMarshaler
101+
}
102+
103+
func marshalCSV(data any) ([]byte, error) {
104+
recordSet, ok := data.(CSVRecordSet)
105+
if !ok {
106+
return nil, fmt.Errorf("cannot marshal %T to csv: %w", data, errNotCSVMarshaler)
107+
}
108+
109+
buffer := &bytes.Buffer{}
110+
writer := csv.NewWriter(buffer)
111+
112+
if err := writer.Write(recordSet.CSVHeaders()); err != nil {
113+
return nil, fmt.Errorf("failed to write csv header: %w", err)
114+
}
115+
116+
for _, record := range recordSet.CSVRecords() {
117+
row, err := record.MarshalCSV()
118+
if err != nil {
119+
return nil, fmt.Errorf("failed to marshal csv record: %w", err)
120+
}
121+
122+
if err := writer.Write(row); err != nil {
123+
return nil, fmt.Errorf("failed to write csv record: %w", err)
124+
}
125+
}
126+
127+
writer.Flush()
128+
129+
if err := writer.Error(); err != nil {
130+
return nil, fmt.Errorf("failed to flush csv: %w", err)
131+
}
132+
133+
return buffer.Bytes(), nil
134+
}
135+
87136
func (prt *yamlPrinter) DisplayColor(data any) error {
88137
return prt.DisplayNoColor(data)
89138
}

internal/output/marshal_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package output
2+
3+
import (
4+
"errors"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
type fakeCSVRow struct {
12+
fields []string
13+
err error
14+
}
15+
16+
func (r fakeCSVRow) MarshalCSV() ([]string, error) {
17+
return r.fields, r.err
18+
}
19+
20+
type fakeCSVRecordSet struct {
21+
headers []string
22+
records []CSVMarshaler
23+
}
24+
25+
func (f fakeCSVRecordSet) CSVHeaders() []string {
26+
return f.headers
27+
}
28+
29+
func (f fakeCSVRecordSet) CSVRecords() []CSVMarshaler {
30+
return f.records
31+
}
32+
33+
func recordSet(headers []string, rows [][]string) fakeCSVRecordSet {
34+
records := make([]CSVMarshaler, len(rows))
35+
for i, row := range rows {
36+
records[i] = fakeCSVRow{fields: row}
37+
}
38+
39+
return fakeCSVRecordSet{headers: headers, records: records}
40+
}
41+
42+
func TestMarshalCSV(t *testing.T) {
43+
t.Parallel()
44+
45+
headers := []string{"user_type", "user_id", "relation", "object_type", "object_id", "condition_context"}
46+
47+
tests := []struct {
48+
name string
49+
records [][]string
50+
expected string
51+
}{
52+
{
53+
name: "no records writes only headers",
54+
records: nil,
55+
expected: "user_type,user_id,relation,object_type,object_id,condition_context\n",
56+
},
57+
{
58+
name: "single record",
59+
records: [][]string{
60+
{"user", "john", "writer", "document", "abc.doc", ""},
61+
},
62+
expected: "user_type,user_id,relation,object_type,object_id,condition_context\n" +
63+
"user,john,writer,document,abc.doc,\n",
64+
},
65+
{
66+
name: "multiple records",
67+
records: [][]string{
68+
{"user", "anne", "reader", "document", "x", ""},
69+
{"group", "eng", "owner", "repo", "y", ""},
70+
},
71+
expected: "user_type,user_id,relation,object_type,object_id,condition_context\n" +
72+
"user,anne,reader,document,x,\n" +
73+
"group,eng,owner,repo,y,\n",
74+
},
75+
{
76+
name: "values with commas, quotes and newlines are escaped",
77+
records: [][]string{
78+
{"user", "a,b", "say \"hi\"", "doc", "line\nbreak", `{"ip_addr":"10.0.0.1"}`},
79+
},
80+
expected: "user_type,user_id,relation,object_type,object_id,condition_context\n" +
81+
"user,\"a,b\",\"say \"\"hi\"\"\",doc,\"line\nbreak\",\"{\"\"ip_addr\"\":\"\"10.0.0.1\"\"}\"\n",
82+
},
83+
}
84+
85+
for _, test := range tests {
86+
t.Run(test.name, func(t *testing.T) {
87+
t.Parallel()
88+
89+
got, err := marshalCSV(recordSet(headers, test.records))
90+
require.NoError(t, err)
91+
assert.Equal(t, test.expected, string(got))
92+
})
93+
}
94+
}
95+
96+
func TestMarshalCSVNotARecordSet(t *testing.T) {
97+
t.Parallel()
98+
99+
_, err := marshalCSV([]string{"a", "b"})
100+
assert.ErrorIs(t, err, errNotCSVMarshaler)
101+
}
102+
103+
func TestMarshalCSVRecordError(t *testing.T) {
104+
t.Parallel()
105+
106+
sentinel := errors.New("boom")
107+
set := fakeCSVRecordSet{
108+
headers: []string{"col"},
109+
records: []CSVMarshaler{fakeCSVRow{err: sentinel}},
110+
}
111+
112+
_, err := marshalCSV(set)
113+
assert.ErrorIs(t, err, sentinel)
114+
}

0 commit comments

Comments
 (0)