diff --git a/README.md b/README.md index f7667ba..3469446 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,14 @@ type Record struct { Height float32 `flat:"-"` // ignored } -ch := make(chan Record) +... + +goflat.MarshalSliceToWriter[Record](ctx,inputCh,csvWriter,options) ... -goflat.MarshalSliceToWriter[Record](ctx,ch,csvWriter,options) +goflat.UnmarshalToChan[Record](ctx,csvReader,options,outputCh) + ``` Will result in: diff --git a/helper_test.go b/helper_test.go new file mode 100644 index 0000000..0308175 --- /dev/null +++ b/helper_test.go @@ -0,0 +1,41 @@ +package goflat_test + +import ( + "context" + "testing" + "time" + + "github.com/google/go-cmp/cmp" +) + +func assertChannel[T any](t *testing.T, ch <-chan T, expected []T, cmpOpts ...cmp.Option) { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + var got []T + + go func() { + defer cancel() + + for { + select { + case <-ctx.Done(): + return + case v, ok := <-ch: + if !ok { + return + } + + got = append(got, v) + } + } + }() + + t.Cleanup(func() { + <-ctx.Done() + + if diff := cmp.Diff(expected, got, cmpOpts...); diff != "" { + t.Errorf("(-expected,+got):\n%s", diff) + } + }) +} diff --git a/marshal.go b/marshal.go index ee631de..3e7b892 100644 --- a/marshal.go +++ b/marshal.go @@ -53,7 +53,7 @@ func MarshalChannelToWriter[T any](ctx context.Context, inputCh <-chan T, writer select { case <-ctx.Done(): - return ctx.Err() //nolint:wrapcheck // No need here. + return context.Cause(ctx) //nolint:wrapcheck // Fine here. case value, channelHasValue = <-inputCh: } diff --git a/reflect.go b/reflect.go index b54fe92..52cfe80 100644 --- a/reflect.go +++ b/reflect.go @@ -7,6 +7,25 @@ import ( "strings" ) +// Options is used to configure the marshalling and unmarshalling processes. +type Options struct { + headersFromStruct 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 +} + type structFactory[T any] struct { structType reflect.Type pointer bool diff --git a/unmarshal.go b/unmarshal.go index 7da2db8..c85297d 100644 --- a/unmarshal.go +++ b/unmarshal.go @@ -10,25 +10,6 @@ import ( "golang.org/x/sync/errgroup" ) -// Options is used to configure the marshalling and unmarshalling processes. -type Options struct { - headersFromStruct 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 // input string into the type itself. type Unmarshaller interface { @@ -103,3 +84,31 @@ func UnmarshalToSlice[T any](ctx context.Context, reader *csv.Reader, opts Optio return slice, nil } + +// UnmarshalToCallback unamrshals a CSV file invoking a callback function on +// each row. +func UnmarshalToCallback[T any](ctx context.Context, reader *csv.Reader, opts Options, callback func(T) error) error { + g, ctx := errgroup.WithContext(ctx) //nolint:varnamelen // Fine here. + + ch := make(chan T) //nolint:varnamelen // Fine here. + + g.Go(func() error { + for v := range ch { + if err := callback(v); err != nil { + return fmt.Errorf("callback: %w", err) + } + } + + return nil + }) + + g.Go(func() error { + return UnmarshalToChannel(ctx, reader, opts, ch) + }) + + if err := g.Wait(); err != nil { + return fmt.Errorf("wait: %w", err) + } + + return nil +} diff --git a/unmarshal_test.go b/unmarshal_test.go index 1d9c7fe..058b8fa 100644 --- a/unmarshal_test.go +++ b/unmarshal_test.go @@ -4,7 +4,6 @@ import ( "context" "embed" "testing" - "time" "github.com/google/go-cmp/cmp" @@ -12,15 +11,17 @@ import ( ) func TestUnmarshal(t *testing.T) { - t.Run("error empty", testUnmarshalErrorEmpty) + t.Run("error", testUnmarshalError) t.Run("success", testUnmarshalSuccess) - t.Run("success ignore empty", testUnmarshalSuccessIgnoreEmpty) - t.Run("success pointer", testUnmarshalSuccessPointer) } //go:embed testdata var testdata embed.FS +func testUnmarshalError(t *testing.T) { + t.Run("empty", testUnmarshalErrorEmpty) +} + func testUnmarshalErrorEmpty(t *testing.T) { file, err := testdata.Open("testdata/unmarshal/success empty.csv") if err != nil { @@ -57,6 +58,14 @@ func testUnmarshalErrorEmpty(t *testing.T) { } func testUnmarshalSuccess(t *testing.T) { + t.Run("full", testUnmarshalSuccessFull) + t.Run("ignore empty", testUnmarshalSuccessIgnoreEmpty) + t.Run("pointer", testUnmarshalSuccessPointer) + t.Run("slice", testUnmarshalSuccessSlice) + t.Run("callback", testUnmarshalSuccessCallback) +} + +func testUnmarshalSuccessFull(t *testing.T) { file, err := testdata.Open("testdata/unmarshal/success.csv") if err != nil { t.Fatalf("open test file: %v", err) @@ -225,34 +234,122 @@ func testUnmarshalSuccessPointer(t *testing.T) { } } -func assertChannel[T any](t *testing.T, ch <-chan T, expected []T, cmpOpts ...cmp.Option) { - t.Helper() +func testUnmarshalSuccessSlice(t *testing.T) { + file, err := testdata.Open("testdata/unmarshal/success.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: 1.78, + }, + { + FirstName: "Elaine", + LastName: "Marley", + Age: 20, + Height: 1.6, + }, + { + FirstName: "LeChuck", + LastName: "", + Age: 100, + Height: 2.01, + }, + } + + 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, + } + + got, err := goflat.UnmarshalToSlice[record](ctx, csvReader, options) + if err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if diff := cmp.Diff(expected, got, cmp.AllowUnexported(record{})); diff != "" { + t.Errorf("(-expected,+got):\n%s", diff) + } +} + +func testUnmarshalSuccessCallback(t *testing.T) { + file, err := testdata.Open("testdata/unmarshal/success.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: 1.78, + }, + { + FirstName: "Elaine", + LastName: "Marley", + Age: 20, + Height: 1.6, + }, + { + FirstName: "LeChuck", + LastName: "", + Age: 100, + Height: 2.01, + }, + } - ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) - var got []T + ctx := context.Background() - go func() { - defer cancel() + csvReader, err := goflat.DetectReader(file) + if err != nil { + t.Fatalf("detect reader: %v", err) + } - for { - select { - case <-ctx.Done(): - return - case v, ok := <-ch: - if !ok { - return - } + options := goflat.Options{ + ErrorIfTaglessField: true, + ErrorIfDuplicateHeaders: true, + ErrorIfMissingHeaders: true, + } - got = append(got, v) - } - } - }() + var got []record - t.Cleanup(func() { - <-ctx.Done() + err = goflat.UnmarshalToCallback(ctx, csvReader, options, func(r record) error { + got = append(got, r) - if diff := cmp.Diff(expected, got, cmpOpts...); diff != "" { - t.Errorf("(-expected,+got):\n%s", diff) - } + return nil }) + if err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if diff := cmp.Diff(expected, got, cmp.AllowUnexported(record{})); diff != "" { + t.Errorf("(-expected,+got):\n%s", diff) + } }