diff --git a/README.md b/README.md index 99535fd..56bc736 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,48 @@ 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: + +```go +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 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 +``` + +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 f7b18bb..19a05b5 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,6 +177,13 @@ func (e *Encoder) getColNames(v interface{}) []string { if name == "" { continue } + + // 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: @@ -304,6 +316,21 @@ func (e *Encoder) marshalStruct(str interface{}, child bool) ([]string, bool) { continue } vF := val.Field(i) + + 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}) + value := "" + if len(values) > 0 { + value = values[0].String() + } + cols = append(cols, value) + } + continue + } + tmp, ok := e.marshal(vF, child) if !ok { // wasn't a supported kind, skip @@ -483,7 +510,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 d34962a..dff878b 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,81 @@ func TestIgnoreTags(t *testing.T) { } } +type DateStruct struct { + Name string + Start time.Time `handler:"ConvertTime"` + End time.Time `handler:"ConvertTime"` +} + +func (DateStruct) ConvertTime(t time.Time) string { + return t.UTC().String() +} + +func TestHandlerTagActive(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("handler") + names, err := enc.GetColNames(date) + assert.NoError(t, err) + assert.Equal(t, []string{"Name", "Start", "End"}, names) + + vals, ok := enc.marshalStruct(date, false) + 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) { tsts := []BaseSliceTypes{ BaseSliceTypes{