diff --git a/database/csvx/doc.go b/database/csvx/doc.go index bd796cd..06bbe03 100644 --- a/database/csvx/doc.go +++ b/database/csvx/doc.go @@ -4,6 +4,7 @@ // can be read from a sheet, written to a file, and scanned from a database. // // It also provides generic Parse/Serialize functions for CSV, TSV, JSON, -// and JSONL, delimiter-specific string and integer slices, plus text -// normalization helpers (NormalizeEmail, NormalizePhone). +// and JSONL, delimiter-specific string and integer slices, and permissive +// text/list value types, plus normalization helpers (NormalizeEmail, +// NormalizePhone). package csvx diff --git a/database/csvx/listish.go b/database/csvx/listish.go new file mode 100644 index 0000000..4470cbc --- /dev/null +++ b/database/csvx/listish.go @@ -0,0 +1,26 @@ +package csvx + +import ( + "strings" + "unicode" +) + +// Listish is a list encoded as comma- or whitespace-delimited text. +type Listish []string + +// UnmarshalCSV parses comma- or whitespace-delimited values. +func (s *Listish) UnmarshalCSV(data []byte) error { + if len(data) == 0 { + *s = Listish{} + return nil + } + parts := strings.FieldsFunc(string(data), func(r rune) bool { + return r == ',' || unicode.IsSpace(r) + }) + if len(parts) == 0 { + *s = Listish{} + return nil + } + *s = Listish(parts) + return nil +} diff --git a/database/csvx/listish_test.go b/database/csvx/listish_test.go new file mode 100644 index 0000000..b82f7dc --- /dev/null +++ b/database/csvx/listish_test.go @@ -0,0 +1,31 @@ +package csvx + +import ( + "reflect" + "testing" +) + +func TestListishUnmarshalCSV(t *testing.T) { + tests := []struct { + name string + in string + want Listish + }{ + {name: "comma", in: "admin,hr,pm", want: Listish{"admin", "hr", "pm"}}, + {name: "whitespace", in: "admin hr\tpm", want: Listish{"admin", "hr", "pm"}}, + {name: "mixed", in: " admin, hr\npm ", want: Listish{"admin", "hr", "pm"}}, + {name: "empty", in: " , \t", want: Listish{}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got Listish + if err := got.UnmarshalCSV([]byte(tt.in)); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("got %#v, want %#v", got, tt.want) + } + }) + } +} diff --git a/database/csvx/textish.go b/database/csvx/textish.go new file mode 100644 index 0000000..4882d0c --- /dev/null +++ b/database/csvx/textish.go @@ -0,0 +1,60 @@ +package csvx + +import ( + "database/sql/driver" + "encoding/json" + "fmt" + "strings" +) + +// Textish is text with common explicit-empty words normalized to an empty value. +type Textish string + +func (t Textish) Empty() bool { return string(t) == "" } + +func (t Textish) String() string { return string(t) } + +func normalizeTextish(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "none", "disable", "disabled": + return "" + default: + return strings.TrimSpace(value) + } +} + +func (t Textish) Value() (driver.Value, error) { return string(t), nil } + +func (t *Textish) Scan(value any) error { + if value == nil { + *t = "" + return nil + } + switch v := value.(type) { + case string: + *t = Textish(normalizeTextish(v)) + case []byte: + *t = Textish(normalizeTextish(string(v))) + default: + return fmt.Errorf("csvx.Textish: cannot scan %T", value) + } + return nil +} + +func (t Textish) MarshalCSV() ([]byte, error) { return []byte(t), nil } + +func (t *Textish) UnmarshalCSV(data []byte) error { + *t = Textish(normalizeTextish(string(data))) + return nil +} + +func (t Textish) MarshalJSON() ([]byte, error) { return json.Marshal(string(t)) } + +func (t *Textish) UnmarshalJSON(data []byte) error { + var value string + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *t = Textish(normalizeTextish(value)) + return nil +} diff --git a/database/csvx/textish_test.go b/database/csvx/textish_test.go new file mode 100644 index 0000000..afa12dd --- /dev/null +++ b/database/csvx/textish_test.go @@ -0,0 +1,22 @@ +package csvx + +import "testing" + +func TestTextishNormalizesExplicitEmptyValues(t *testing.T) { + for _, value := range []string{"", "none", "disable", "disabled", " NONE "} { + var got Textish + if err := got.UnmarshalCSV([]byte(value)); err != nil { + t.Fatal(err) + } + if !got.Empty() { + t.Fatalf("Textish(%q) = %q, want empty", value, got) + } + } + var got Textish + if err := got.UnmarshalCSV([]byte("https://hooks.example.test/enrich")); err != nil { + t.Fatal(err) + } + if got.String() != "https://hooks.example.test/enrich" { + t.Fatalf("Textish URL = %q", got) + } +}