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
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
43 changes: 35 additions & 8 deletions struct2csv.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: ")",
}
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
78 changes: 78 additions & 0 deletions struct2csv_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import (
"sort"
"strings"
"testing"
"time"

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

type Tags struct {
Expand Down Expand Up @@ -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{
Expand Down