From 3bc5ca88811c56fe266ed6f2fa966df7a68ff032 Mon Sep 17 00:00:00 2001 From: Frank Bartnitzek Date: Fri, 27 Oct 2023 13:52:07 +0000 Subject: [PATCH 1/5] add optional handler-tag to convert arbitrary structs like time.Time --- struct2csv.go | 17 +++++++++++++++++ struct2csv_test.go | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/struct2csv.go b/struct2csv.go index f7b18bb..57cc351 100644 --- a/struct2csv.go +++ b/struct2csv.go @@ -172,6 +172,12 @@ func (e *Encoder) getColNames(v interface{}) []string { if name == "" { continue } + fieldHandler := tF.Tag.Get("handler") + if fieldHandler != "" { + cols = append(cols, name) + continue + } + vF := val.Field(i) switch vF.Kind() { case reflect.Struct: @@ -304,6 +310,17 @@ func (e *Encoder) marshalStruct(str interface{}, child bool) ([]string, bool) { continue } vF := val.Field(i) + + fieldHandler := tF.Tag.Get("handler") + if fieldHandler != "" { + method := reflect.ValueOf(str).MethodByName(fieldHandler) + if method.IsValid() { + values := method.Call([]reflect.Value{vF}) + cols = append(cols, values[0].String()) + } + continue + } + tmp, ok := e.marshal(vF, child) if !ok { // wasn't a supported kind, skip diff --git a/struct2csv_test.go b/struct2csv_test.go index d34962a..096c400 100644 --- a/struct2csv_test.go +++ b/struct2csv_test.go @@ -6,6 +6,9 @@ import ( "sort" "strings" "testing" + "time" + + "github.com/stretchr/testify/assert" ) type Tags struct { @@ -355,6 +358,37 @@ func TestIgnoreTags(t *testing.T) { } } +type DateStruct struct { + Name string + Start time.Time `json:"start" csv:"Start" handler:"ConvertTime"` + End time.Time `json:"end" csv:"End" handler:"ConvertTime"` +} + +func (DateStruct) ConvertTime(t time.Time) string { + return t.UTC().String() +} + +func TestHandlerTag(t *testing.T) { + testDate := time.Date(2023, 01, 02, 03, 04, 05, 0, time.UTC) + ts := DateStruct{ + Name: "test", + Start: testDate, + End: testDate.Add(time.Hour * 24), + } + + enc := New() + names, err := enc.GetColNames(ts) + assert.NoError(t, err) + assert.Len(t, names, 3) + assert.Equal(t, []string{"Name", "Start", "End"}, names) + + vals, ok := enc.marshalStruct(ts, false) + assert.True(t, ok) + assert.Len(t, vals, 3) + assert.Equal(t, "2023-01-02 03:04:05 +0000 UTC", vals[1]) + assert.Equal(t, "2023-01-03 03:04:05 +0000 UTC", vals[2]) +} + func TestMarshal(t *testing.T) { tsts := []BaseSliceTypes{ BaseSliceTypes{ From fcf7c13f826d6df0a502ac00abd89ead62b186c2 Mon Sep 17 00:00:00 2001 From: Frank Bartnitzek Date: Fri, 27 Oct 2023 13:59:09 +0000 Subject: [PATCH 2/5] update docs --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index 99535fd..07549d1 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,23 @@ Slices and arrays are a single column in the resulting CSV as slices can have a #### Structs Struct fields become their own column. If the struct is embedded, only its field name is used for the column name. This may lead to some ambiguity in column names. Options to either prefix the embedded struct's field name with the struct name, or with the full path to the struct, in the case of deeply nested embedded structs may be added in the future (pull requests supporting this are also welcome!) If the struct is part of a composite type, like a map or slice, it will be part of that column with its data nested, using separators as appropriate. +##### Custom Handler for Structs +You can configure custom handlers for a struct: + +```yaml +type DateStruct struct { + Name string + Start time.Time `json:"start" csv:"Start" handler:"ConvertTime"` + End time.Time `json:"end" csv:"End" handler:"ConvertTime"` +} + +func (DateStruct) ConvertTime(t time.Time) string { + return t.UTC().String() +} +``` + +The `ConvertTime` handler of the struct (not the field) will be called with the field's value. The first returned value will be used as the csv-value. + #### Pointers and nils Pointers are dereferenced. Struct field types using multiple, consecutive pointers, e.g. `**string`, are not supported. Struct fields with composite types support mulitple, non-consecutive pointers, for whatever reason, e.g. `*[]*string`, `*map[*string]*[]*string`, are supported. From 41edd2b15bab7a3aa82a528ede1010b57612f28d Mon Sep 17 00:00:00 2001 From: Frank Bartnitzek Date: Mon, 30 Oct 2023 11:15:48 +0000 Subject: [PATCH 3/5] use explicit handler tag --- struct2csv.go | 32 +++++++++++++++++++------------- struct2csv_test.go | 17 ++++++++--------- 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/struct2csv.go b/struct2csv.go index 57cc351..49ccf0e 100644 --- a/struct2csv.go +++ b/struct2csv.go @@ -77,18 +77,19 @@ func (sv stringValues) get(i int) string { return sv[i].String() } // Encoder handles encoding of a CSV from a struct. type Encoder struct { // Whether or not tags should be use for header (column) names; by default this is csv, - useTags bool - base int - tag string // The tag to use when tags are being used for headers; defaults to csv. - sepBeg string - sepEnd string - colNames []string + useTags bool + base int + tag string // The tag to use when tags are being used for headers; defaults to csv. + handlerTag string + sepBeg string + sepEnd string + colNames []string } // New returns an initialized Encoder. func New() *Encoder { return &Encoder{ - useTags: true, base: 10, tag: "csv", + useTags: true, base: 10, tag: "csv", handlerTag: "", sepBeg: "(", sepEnd: ")", } } @@ -128,6 +129,10 @@ func (e *Encoder) SetBase(i int) { e.base = i } +func (e *Encoder) SetHandlerTag(handlerTag string) { + e.handlerTag = handlerTag +} + // ColNames returns the encoder's saved column names as a copy. The // colNames field must be populated before using this. func (e *Encoder) ColNames() []string { @@ -172,12 +177,13 @@ func (e *Encoder) getColNames(v interface{}) []string { if name == "" { continue } - fieldHandler := tF.Tag.Get("handler") - if fieldHandler != "" { + + // fieldHandler columns are always included + if e.handlerTag != "" && tF.Tag.Get(e.handlerTag) != "" { cols = append(cols, name) continue } - + vF := val.Field(i) switch vF.Kind() { case reflect.Struct: @@ -311,8 +317,8 @@ func (e *Encoder) marshalStruct(str interface{}, child bool) ([]string, bool) { } vF := val.Field(i) - fieldHandler := tF.Tag.Get("handler") - if fieldHandler != "" { + if e.handlerTag != "" && tF.Tag.Get(e.handlerTag) != "" { + fieldHandler := tF.Tag.Get(e.handlerTag) method := reflect.ValueOf(str).MethodByName(fieldHandler) if method.IsValid() { values := method.Call([]reflect.Value{vF}) @@ -500,7 +506,7 @@ func supportedBaseKind(val reflect.Value) bool { } // sliceKind returns the Kind of the slice; e.g. reflect.Slice will be -//returned for [][]*int. +// returned for [][]*int. func sliceKind(val reflect.Value) reflect.Kind { switch val.Type().Elem().Kind() { case reflect.Ptr: diff --git a/struct2csv_test.go b/struct2csv_test.go index 096c400..b9c4d43 100644 --- a/struct2csv_test.go +++ b/struct2csv_test.go @@ -360,8 +360,8 @@ func TestIgnoreTags(t *testing.T) { type DateStruct struct { Name string - Start time.Time `json:"start" csv:"Start" handler:"ConvertTime"` - End time.Time `json:"end" csv:"End" handler:"ConvertTime"` + Start time.Time `handler:"ConvertTime"` + End time.Time `handler:"ConvertTime"` } func (DateStruct) ConvertTime(t time.Time) string { @@ -370,23 +370,22 @@ func (DateStruct) ConvertTime(t time.Time) string { func TestHandlerTag(t *testing.T) { testDate := time.Date(2023, 01, 02, 03, 04, 05, 0, time.UTC) - ts := DateStruct{ + date := DateStruct{ Name: "test", Start: testDate, End: testDate.Add(time.Hour * 24), } enc := New() - names, err := enc.GetColNames(ts) + enc.SetHandlerTag("handler") + names, err := enc.GetColNames(date) assert.NoError(t, err) - assert.Len(t, names, 3) assert.Equal(t, []string{"Name", "Start", "End"}, names) - vals, ok := enc.marshalStruct(ts, false) + vals, ok := enc.marshalStruct(date, false) assert.True(t, ok) - assert.Len(t, vals, 3) - assert.Equal(t, "2023-01-02 03:04:05 +0000 UTC", vals[1]) - assert.Equal(t, "2023-01-03 03:04:05 +0000 UTC", vals[2]) + assert.Equal(t, []string{"test", "2023-01-02 03:04:05 +0000 UTC", "2023-01-03 03:04:05 +0000 UTC"}, vals) +} } func TestMarshal(t *testing.T) { From 7f1a8a54c41be37b670ddcb950ce69c5e092ef4f Mon Sep 17 00:00:00 2001 From: Frank Bartnitzek Date: Mon, 30 Oct 2023 11:20:24 +0000 Subject: [PATCH 4/5] improve error-handling, add matching tests and update docs --- README.md | 29 ++++++++++++++++++++++++++-- struct2csv.go | 6 +++++- struct2csv_test.go | 47 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 07549d1..a15b34b 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,7 @@ Struct fields become their own column. If the struct is embedded, only its fiel ##### Custom Handler for Structs You can configure custom handlers for a struct: -```yaml +```go type DateStruct struct { Name string Start time.Time `json:"start" csv:"Start" handler:"ConvertTime"` @@ -173,9 +173,34 @@ type DateStruct struct { func (DateStruct) ConvertTime(t time.Time) string { return t.UTC().String() } + +func main() { + date := DateStruct{ + Name: "test", + Start: time.Now(), + End: time.Now().Add(time.Hour * 24), + } + + enc := New() + enc.SetHandlerTag("handler") + row, err := enc.GetRow(date) +} +``` + +The `ConvertTime` handler of the struct (not the field) will be called with the field's value. + +To enhance the flexibility: + + - multiple handlers per struct are supported (just one `ConvertTime` in the example) + - you need to explicitly set the handler-tag-name of the encoder (`handler` in the example) to avoid unexpected interference + +All handlers need to implement an interface like this: + +```go +func (s S) MyHandler(v interface{}) string ``` -The `ConvertTime` handler of the struct (not the field) will be called with the field's value. The first returned value will be used as the csv-value. +It needs to return a string, which will be used as the csv-value for the field. #### Pointers and nils Pointers are dereferenced. Struct field types using multiple, consecutive pointers, e.g. `**string`, are not supported. Struct fields with composite types support mulitple, non-consecutive pointers, for whatever reason, e.g. `*[]*string`, `*map[*string]*[]*string`, are supported. diff --git a/struct2csv.go b/struct2csv.go index 49ccf0e..19a05b5 100644 --- a/struct2csv.go +++ b/struct2csv.go @@ -322,7 +322,11 @@ func (e *Encoder) marshalStruct(str interface{}, child bool) ([]string, bool) { method := reflect.ValueOf(str).MethodByName(fieldHandler) if method.IsValid() { values := method.Call([]reflect.Value{vF}) - cols = append(cols, values[0].String()) + value := "" + if len(values) > 0 { + value = values[0].String() + } + cols = append(cols, value) } continue } diff --git a/struct2csv_test.go b/struct2csv_test.go index b9c4d43..dff878b 100644 --- a/struct2csv_test.go +++ b/struct2csv_test.go @@ -368,7 +368,7 @@ func (DateStruct) ConvertTime(t time.Time) string { return t.UTC().String() } -func TestHandlerTag(t *testing.T) { +func TestHandlerTagActive(t *testing.T) { testDate := time.Date(2023, 01, 02, 03, 04, 05, 0, time.UTC) date := DateStruct{ Name: "test", @@ -386,6 +386,51 @@ func TestHandlerTag(t *testing.T) { assert.True(t, ok) assert.Equal(t, []string{"test", "2023-01-02 03:04:05 +0000 UTC", "2023-01-03 03:04:05 +0000 UTC"}, vals) } + +func TestHandlerTagIgnored(t *testing.T) { + testDate := time.Date(2023, 01, 02, 03, 04, 05, 0, time.UTC) + date := DateStruct{ + Name: "test", + Start: testDate, + End: testDate.Add(time.Hour * 24), + } + + enc := New() + enc.SetHandlerTag("ignored") + names, err := enc.GetColNames(date) + assert.NoError(t, err) + assert.Equal(t, []string{"Name"}, names) + + vals, ok := enc.marshalStruct(date, false) + assert.True(t, ok) + assert.Equal(t, []string{"test"}, vals) +} + +type DateStructEmptyHandler struct { + Name string + Invalid time.Time `handler:"ConvertTimeInvalid"` +} + +func (DateStructEmptyHandler) ConvertTimeInvalid(t time.Time) { + return +} + +func TestHandlerTagInvalidHandler(t *testing.T) { + testDate := time.Date(2023, 01, 02, 03, 04, 05, 0, time.UTC) + date := DateStructEmptyHandler{ + Name: "test", + Invalid: testDate.Add(time.Hour), + } + + enc := New() + enc.SetHandlerTag("handler") + names, err := enc.GetColNames(date) + assert.NoError(t, err) + assert.Equal(t, []string{"Name", "Invalid"}, names) + + vals, ok := enc.marshalStruct(date, false) + assert.True(t, ok) + assert.Equal(t, []string{"test", ""}, vals) } func TestMarshal(t *testing.T) { From ad7bc7988453ba8797fc74ff5cbed94c3d762776 Mon Sep 17 00:00:00 2001 From: Frank Bartnitzek Date: Mon, 30 Oct 2023 11:23:49 +0000 Subject: [PATCH 5/5] fix markdown format --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a15b34b..56bc736 100644 --- a/README.md +++ b/README.md @@ -191,8 +191,8 @@ The `ConvertTime` handler of the struct (not the field) will be called with the To enhance the flexibility: - - multiple handlers per struct are supported (just one `ConvertTime` in the example) - - you need to explicitly set the handler-tag-name of the encoder (`handler` in the example) to avoid unexpected interference +* multiple handlers per struct are supported (just one `ConvertTime` in the example) +* you need to explicitly set the handler-tag-name of the encoder (`handler` in the example) to avoid unexpected interference All handlers need to implement an interface like this: