Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 50 additions & 13 deletions cmd/tuple/read.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,18 +48,53 @@ 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 (l readResponseCSVDTOList) CSVHeaders() []string {
return readResponseCSVHeaders
}

func (l readResponseCSVDTOList) CSVRecords() [][]string {
rows := make([][]string, len(l))
for i, dto := range l {
rows[i] = []string{
dto.UserType,
dto.UserID,
dto.UserRelation,
dto.Relation,
dto.ObjectType,
dto.ObjectID,
dto.ConditionName,
dto.ConditionContext,
}
}

return rows
}

func (r readResponse) toCsvDTO() (readResponseCSVDTOList, error) {
readResponseDTO := make(readResponseCSVDTOList, 0, len(r.simple))

for _, readRes := range r.simple {
// Handle Condition
Expand Down Expand Up @@ -199,10 +234,12 @@ var readCmd = &cobra.Command{
dataPrinter := output.NewUniPrinter(outputFormat)

if outputFormat == "csv" {
data, _ := response.toCsvDTO()

err := dataPrinter.Display(data)
data, err := response.toCsvDTO()
if err != nil {
return fmt.Errorf("failed to convert tuples to csv: %w", err)
}

if err := dataPrinter.Display(data); err != nil {
return fmt.Errorf("failed to display csv: %w", err)
}

Expand Down
34 changes: 32 additions & 2 deletions cmd/tuple/read_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,7 @@ func TestReadResponseCSVDTOParser(t *testing.T) {

testCases := []struct {
readRes readResponse
expected []readResponseCSVDTO
expected readResponseCSVDTOList
}{
{
readRes: readResponse{
Expand All @@ -460,7 +460,7 @@ func TestReadResponseCSVDTOParser(t *testing.T) {
},
},
},
expected: []readResponseCSVDTO{
expected: readResponseCSVDTOList{
{
UserType: "user",
UserID: "anne",
Expand All @@ -487,6 +487,36 @@ 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",
},
}

assert.Equal(t, readResponseCSVHeaders, list.CSVHeaders())

assert.Equal(t, [][]string{
{"user", "anne", "", "reader", "document", "secret.doc", "inOfficeIP", `{"ip_addr":"10.0.0.1"}`},
{"user", "john", "", "writer", "document", "abc.doc", "", ""},
}, list.CSVRecords())
}

func toPointer[T any](p T) *T {
return &p
}
Expand Down
1 change: 0 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ go 1.25.7
toolchain go1.26.4

require (
github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1
github.com/mattn/go-isatty v0.0.22
github.com/muesli/mango-cobra v1.3.0
github.com/muesli/roff v0.1.0
Expand Down
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
36 changes: 34 additions & 2 deletions internal/output/marshal.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ limitations under the License.
package output

import (
"bytes"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"os"

"github.com/gocarina/gocsv"
"gopkg.in/yaml.v3"

"github.com/mattn/go-isatty"
Expand Down Expand Up @@ -74,7 +76,12 @@ func (prt *csvPrinter) DisplayColor(data any) error {
}

func (prt *csvPrinter) DisplayNoColor(data any) error {
b, err := gocsv.MarshalBytes(data)
recordSet, ok := data.(CSVRecordSet)
if !ok {
return fmt.Errorf("cannot marshal %T to csv: %w", data, errNotCSVRecordSet)
}

b, err := marshalCSV(recordSet)
if err != nil {
return fmt.Errorf("unable to marshal CSV with error: %w", err)
}
Expand All @@ -84,6 +91,31 @@ func (prt *csvPrinter) DisplayNoColor(data any) error {
return nil
}

var errNotCSVRecordSet = errors.New("type does not implement output.CSVRecordSet")

// CSVRecordSet is implemented by a collection that can render itself as CSV:
// a header row followed by one record per row.
type CSVRecordSet interface {
CSVHeaders() []string
CSVRecords() [][]string
}

func marshalCSV(recordSet CSVRecordSet) ([]byte, error) {
buffer := &bytes.Buffer{}
writer := csv.NewWriter(buffer)

if err := writer.Write(recordSet.CSVHeaders()); err != nil {
return nil, fmt.Errorf("failed to write csv header: %w", err)
}

// WriteAll flushes internally and returns any write or flush error.
if err := writer.WriteAll(recordSet.CSVRecords()); err != nil {
return nil, fmt.Errorf("failed to write csv records: %w", err)
}

return buffer.Bytes(), nil
}

func (prt *yamlPrinter) DisplayColor(data any) error {
return prt.DisplayNoColor(data)
}
Expand Down
87 changes: 87 additions & 0 deletions internal/output/marshal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package output

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

type fakeCSVRecordSet struct {
headers []string
records [][]string
}

func (f fakeCSVRecordSet) CSVHeaders() []string {
return f.headers
}

func (f fakeCSVRecordSet) CSVRecords() [][]string {
return f.records
}

func recordSet(headers []string, rows [][]string) fakeCSVRecordSet {
return fakeCSVRecordSet{headers: headers, records: rows}
}

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()

got, err := marshalCSV(recordSet(headers, test.records))
require.NoError(t, err)
assert.Equal(t, test.expected, string(got))
})
}
}

func TestCSVPrinterNotARecordSet(t *testing.T) {
t.Parallel()

prt := &csvPrinter{}
err := prt.DisplayNoColor([]string{"a", "b"})
assert.ErrorIs(t, err, errNotCSVRecordSet)
}