diff --git a/reflect.go b/reflect.go index 53aff71..b54fe92 100644 --- a/reflect.go +++ b/reflect.go @@ -13,6 +13,7 @@ type structFactory[T any] struct { columnMap map[int]int columnValues []any columnNames []string + options Options } // FieldTag is the tag that must be used in the struct fields so that goflat can @@ -45,6 +46,7 @@ func newFactory[T any](headers []string, options Options) (*structFactory[T], er columnMap: make(map[int]int, len(headers)), columnValues: make([]any, t.NumField()), columnNames: make([]string, t.NumField()), + options: options, } covered := make([]bool, len(headers)) @@ -56,7 +58,7 @@ func newFactory[T any](headers []string, options Options) (*structFactory[T], er factory.columnValues[i] = fieldV.Interface() v, ok := fieldT.Tag.Lookup(FieldTag) - if !ok && options.Strict { + if !ok && options.ErrorIfTaglessField { return nil, fmt.Errorf("field %q breaks strict mode: %w", fieldT.Name, ErrTaglessField) } @@ -114,6 +116,10 @@ func (s *structFactory[T]) unmarshal(record []string) (T, error) { continue } + if column == "" && s.options.UnmarshalIgnoreEmpty { + continue + } + columnBaseValue := s.columnValues[mappedIndex] // special case diff --git a/reflect_internal_test.go b/reflect_internal_test.go index 8924e88..94ef898 100644 --- a/reflect_internal_test.go +++ b/reflect_internal_test.go @@ -24,7 +24,7 @@ func testReflectError(t *testing.T) { } func testReflectErrorTaglessStrict(t *testing.T) { - f, err := newFactory[s1]([]string{}, Options{Strict: true}) + f, err := newFactory[s1]([]string{}, Options{ErrorIfTaglessField: true}) if f != nil { t.Errorf("expected nil, got %v", f) } @@ -44,7 +44,7 @@ func testReflectErrorMissing(t *testing.T) { headers := []string{"name"} got, err := newFactory[foo](headers, Options{ - Strict: true, + ErrorIfTaglessField: true, ErrorIfMissingHeaders: true, }) if got != nil { @@ -66,7 +66,7 @@ func testReflectErrorDuplicate(t *testing.T) { headers := []string{"name", "age", "name"} got, err := newFactory[foo](headers, Options{ - Strict: true, + ErrorIfTaglessField: true, ErrorIfDuplicateHeaders: true, }) if got != nil { @@ -93,11 +93,13 @@ func testReflectSuccessDuplicate(t *testing.T) { headers := []string{"name", "age", "name"} - got, err := newFactory[foo](headers, Options{ - Strict: true, + options := Options{ + ErrorIfTaglessField: true, ErrorIfDuplicateHeaders: false, ErrorIfMissingHeaders: true, - }) + } + + got, err := newFactory[foo](headers, options) if err != nil { t.Errorf("expected no error, got %v", err) } @@ -107,7 +109,9 @@ func testReflectSuccessDuplicate(t *testing.T) { columnMap: map[int]int{0: 0, 1: 1}, columnValues: []any{"", int(0)}, columnNames: []string{"name", "age"}, + options: options, } + comparers := []cmp.Option{ cmp.AllowUnexported(structFactory[foo]{}), cmp.Comparer(func(a, b structFactory[foo]) bool { @@ -119,6 +123,14 @@ func testReflectSuccessDuplicate(t *testing.T) { return false } + if a.pointer != b.pointer { + return false + } + + if a.options != b.options { + return false + } + return true }), } @@ -137,11 +149,13 @@ func testReflectSuccessSimple(t *testing.T) { headers := []string{"name", "age"} - got, err := newFactory[foo](headers, Options{ - Strict: true, + options := Options{ + ErrorIfTaglessField: true, ErrorIfDuplicateHeaders: true, ErrorIfMissingHeaders: true, - }) + } + + got, err := newFactory[foo](headers, options) if err != nil { t.Errorf("expected no error, got %v", err) } @@ -149,6 +163,7 @@ func testReflectSuccessSimple(t *testing.T) { expected := &structFactory[foo]{ structType: reflect.TypeOf(foo{}), columnMap: map[int]int{0: 0, 1: 1}, + options: options, } comparers := []cmp.Option{ cmp.AllowUnexported(structFactory[foo]{}), @@ -161,6 +176,14 @@ func testReflectSuccessSimple(t *testing.T) { return false } + if a.pointer != b.pointer { + return false + } + + if a.options != b.options { + return false + } + return true }), } @@ -177,11 +200,13 @@ func testReflectSuccessSubsetStruct(t *testing.T) { headers := []string{"col1", "col2", "col3"} - got, err := newFactory[foo](headers, Options{ - Strict: false, + options := Options{ + ErrorIfTaglessField: false, ErrorIfDuplicateHeaders: false, ErrorIfMissingHeaders: false, - }) + } + + got, err := newFactory[foo](headers, options) if err != nil { t.Errorf("expected no error, got %v", err) } @@ -190,6 +215,7 @@ func testReflectSuccessSubsetStruct(t *testing.T) { structType: reflect.TypeOf(foo{}), columnMap: map[int]int{1: 0}, columnValues: []any{float32(0)}, + options: options, } comparers := []cmp.Option{ cmp.AllowUnexported(structFactory[foo]{}), @@ -202,6 +228,14 @@ func testReflectSuccessSubsetStruct(t *testing.T) { return false } + if a.pointer != b.pointer { + return false + } + + if a.options != b.options { + return false + } + return true }), } @@ -218,11 +252,13 @@ func testReflectSuccessPointer(t *testing.T) { headers := []string{"name"} - got, err := newFactory[*foo](headers, Options{ - Strict: true, + options := Options{ + ErrorIfTaglessField: true, ErrorIfDuplicateHeaders: true, ErrorIfMissingHeaders: true, - }) + } + + got, err := newFactory[*foo](headers, options) if err != nil { t.Errorf("expected no error, got %v", err) } @@ -230,6 +266,8 @@ func testReflectSuccessPointer(t *testing.T) { expected := &structFactory[*foo]{ structType: reflect.TypeOf(foo{}), columnMap: map[int]int{0: 0}, + pointer: true, + options: options, } comparers := []cmp.Option{ cmp.AllowUnexported(structFactory[*foo]{}), @@ -242,6 +280,14 @@ func testReflectSuccessPointer(t *testing.T) { return false } + if a.pointer != b.pointer { + return false + } + + if a.options != b.options { + return false + } + return true }), } diff --git a/testdata/unmarshal/success empty.csv b/testdata/unmarshal/success empty.csv new file mode 100644 index 0000000..1d3f45b --- /dev/null +++ b/testdata/unmarshal/success empty.csv @@ -0,0 +1,4 @@ +first_name,last_name,age,height +Guybrush,Threepwood,28, +Elaine,Marley,,1.60 +LeChuck,,, diff --git a/unmarshal.go b/unmarshal.go index 1a03071..4dcc35d 100644 --- a/unmarshal.go +++ b/unmarshal.go @@ -13,15 +13,20 @@ import ( // Options is used to configure the marshalling and unmarshalling processes. type Options struct { headersFromStruct bool - // Strict causes goflat to error out if any struct field is missing the - // `flat` tag. - Strict bool + // ErrorIfTaglessField causes goflat to error out if any struct field is + // missing the `flat` tag. + ErrorIfTaglessField bool // ErrorIfDuplicateHeaders causes goflat to error out if two struct fields // share the same `flat` tag value. ErrorIfDuplicateHeaders bool // ErrorIfMissingHeaders causes goflat to error out at unmarshalling time if // a header has no struct field with a corresponding `flat` tag. ErrorIfMissingHeaders bool + // UnmarshalIgnoreEmpty causes the unmarshaller to skip any column which is + // an empty string. This is useful for instance if you have integer values + // and you are okay with empty string mapping to the zero value (0). For the + // same reason this will cause booleans to be false if the column is empty. + UnmarshalIgnoreEmpty bool } // Unmarshaller can be used to tell goflat to use custom logic to convert the diff --git a/unmarshal_test.go b/unmarshal_test.go index d4ffe0d..1d9c7fe 100644 --- a/unmarshal_test.go +++ b/unmarshal_test.go @@ -12,13 +12,50 @@ import ( ) func TestUnmarshal(t *testing.T) { + t.Run("error empty", testUnmarshalErrorEmpty) t.Run("success", testUnmarshalSuccess) + t.Run("success ignore empty", testUnmarshalSuccessIgnoreEmpty) t.Run("success pointer", testUnmarshalSuccessPointer) } //go:embed testdata var testdata embed.FS +func testUnmarshalErrorEmpty(t *testing.T) { + file, err := testdata.Open("testdata/unmarshal/success empty.csv") + if err != nil { + t.Fatalf("open test file: %v", err) + } + + type record struct { + FirstName string `flat:"first_name"` + LastName string `flat:"last_name"` + Age int `flat:"age"` + Height float32 `flat:"height"` + } + + channel := make(chan record) + assertChannel(t, channel, nil, cmp.AllowUnexported(record{})) + + ctx := context.Background() + + csvReader, err := goflat.DetectReader(file) + if err != nil { + t.Fatalf("detect reader: %v", err) + } + + options := goflat.Options{ + ErrorIfTaglessField: true, + ErrorIfDuplicateHeaders: true, + ErrorIfMissingHeaders: true, + } + + err = goflat.UnmarshalToChannel(ctx, csvReader, options, channel) + if err == nil { + t.Fatalf("expected error, got nil") + } +} + func testUnmarshalSuccess(t *testing.T) { file, err := testdata.Open("testdata/unmarshal/success.csv") if err != nil { @@ -64,9 +101,66 @@ func testUnmarshalSuccess(t *testing.T) { } options := goflat.Options{ - Strict: true, + ErrorIfTaglessField: true, + ErrorIfDuplicateHeaders: true, + ErrorIfMissingHeaders: true, + } + + err = goflat.UnmarshalToChannel(ctx, csvReader, options, channel) + if err != nil { + t.Fatalf("unmarshal: %v", err) + } +} + +func testUnmarshalSuccessIgnoreEmpty(t *testing.T) { + file, err := testdata.Open("testdata/unmarshal/success empty.csv") + if err != nil { + t.Fatalf("open test file: %v", err) + } + + type record struct { + FirstName string `flat:"first_name"` + LastName string `flat:"last_name"` + Age int `flat:"age"` + Height float32 `flat:"height"` + } + + expected := []record{ + { + FirstName: "Guybrush", + LastName: "Threepwood", + Age: 28, + Height: 0, + }, + { + FirstName: "Elaine", + LastName: "Marley", + Age: 0, + Height: 1.6, + }, + { + FirstName: "LeChuck", + LastName: "", + Age: 0, + Height: 0, + }, + } + + channel := make(chan record) + assertChannel(t, channel, expected, cmp.AllowUnexported(record{})) + + ctx := context.Background() + + csvReader, err := goflat.DetectReader(file) + if err != nil { + t.Fatalf("detect reader: %v", err) + } + + options := goflat.Options{ + ErrorIfTaglessField: true, ErrorIfDuplicateHeaders: true, ErrorIfMissingHeaders: true, + UnmarshalIgnoreEmpty: true, } err = goflat.UnmarshalToChannel(ctx, csvReader, options, channel) @@ -120,7 +214,7 @@ func testUnmarshalSuccessPointer(t *testing.T) { } options := goflat.Options{ - Strict: true, + ErrorIfTaglessField: true, ErrorIfDuplicateHeaders: true, ErrorIfMissingHeaders: true, }