Skip to content

Commit

Permalink
Merge pull request #2 from lzambarda/feature/support-empty-value
Browse files Browse the repository at this point in the history
feat: add flag to support empty columns
  • Loading branch information
lzambarda authored Dec 6, 2024
2 parents c7830d3 + 5437bec commit f06b373
Show file tree
Hide file tree
Showing 5 changed files with 176 additions and 21 deletions.
8 changes: 7 additions & 1 deletion reflect.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand All @@ -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)
}

Expand Down Expand Up @@ -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
Expand Down
76 changes: 61 additions & 15 deletions reflect_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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)
}
Expand All @@ -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 {
Expand All @@ -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
}),
}
Expand All @@ -137,18 +149,21 @@ 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)
}

expected := &structFactory[foo]{
structType: reflect.TypeOf(foo{}),
columnMap: map[int]int{0: 0, 1: 1},
options: options,
}
comparers := []cmp.Option{
cmp.AllowUnexported(structFactory[foo]{}),
Expand All @@ -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
}),
}
Expand All @@ -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)
}
Expand All @@ -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]{}),
Expand All @@ -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
}),
}
Expand All @@ -218,18 +252,22 @@ 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)
}

expected := &structFactory[*foo]{
structType: reflect.TypeOf(foo{}),
columnMap: map[int]int{0: 0},
pointer: true,
options: options,
}
comparers := []cmp.Option{
cmp.AllowUnexported(structFactory[*foo]{}),
Expand All @@ -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
}),
}
Expand Down
4 changes: 4 additions & 0 deletions testdata/unmarshal/success empty.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
first_name,last_name,age,height
Guybrush,Threepwood,28,
Elaine,Marley,,1.60
LeChuck,,,
11 changes: 8 additions & 3 deletions unmarshal.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
98 changes: 96 additions & 2 deletions unmarshal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -120,7 +214,7 @@ func testUnmarshalSuccessPointer(t *testing.T) {
}

options := goflat.Options{
Strict: true,
ErrorIfTaglessField: true,
ErrorIfDuplicateHeaders: true,
ErrorIfMissingHeaders: true,
}
Expand Down

0 comments on commit f06b373

Please sign in to comment.