From 20b01995797dde640dd17f92fec754e3dbf6faef Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 18 Sep 2026 19:12:33 +0200 Subject: [PATCH] feat(05-02): add lagoon.Jsonable TEXT cast - Scanner/Valuer JSON-text column preserving SQL NULL vs empty array - Generic struct with gorm-ignored inner fields so GORM does not walk Data --- lagoon/jsonable.go | 97 ++++++++++++++++++++++++++++++++++++ lagoon/jsonable_test.go | 106 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 lagoon/jsonable.go create mode 100644 lagoon/jsonable_test.go diff --git a/lagoon/jsonable.go b/lagoon/jsonable.go new file mode 100644 index 0000000..a41efc0 --- /dev/null +++ b/lagoon/jsonable.go @@ -0,0 +1,97 @@ +package lagoon + +import ( + "bytes" + "database/sql/driver" + "encoding/json" + "fmt" + "reflect" +) + +// Jsonable is a JSON-text column (Winter $jsonable) stored as TEXT, not jsonb. +// A struct (not `type Jsonable[T] T`, which Go rejects) is required so Scan +// can set Valid independently of T's zero value: a nil []string (SQL NULL) +// is distinct from a non-nil empty slice (SQL '[]'). Inner fields are +// gorm:"-" so GORM uses Scanner/Valuer instead of walking them. +// +// Data is the payload (cannot be named Value: that name is driver.Valuer). +// Valid=false ↔ SQL NULL. Valid=true encodes Data with encoding/json. +// NullOnEmpty, when set on a slice/map value, stores SQL NULL instead of +// '[]'/'{}' so callers pick empty-vs-null per column (Pitfall 4). +type Jsonable[T any] struct { + Data T `gorm:"-"` + Valid bool `gorm:"-"` + NullOnEmpty bool `gorm:"-"` +} + +// Get returns the underlying payload. +func (j Jsonable[T]) Get() T { return j.Data } + +// Scan decodes src as JSON text. SQL NULL (and JSON null) leave Valid=false. +func (j *Jsonable[T]) Scan(src any) error { + if j == nil { + return fmt.Errorf("lagoon: jsonable scan on nil receiver") + } + if src == nil { + var zero T + j.Data = zero + j.Valid = false + return nil + } + var raw []byte + switch v := src.(type) { + case []byte: + raw = v + case string: + raw = []byte(v) + default: + return fmt.Errorf("lagoon: jsonable scan unsupported type %T", src) + } + raw = bytes.TrimSpace(raw) + if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { + var zero T + j.Data = zero + j.Valid = false + return nil + } + var val T + if err := json.Unmarshal(raw, &val); err != nil { + return fmt.Errorf("lagoon: jsonable scan: %w", err) + } + j.Data = val + j.Valid = true + return nil +} + +// Value returns JSON text or nil. It never returns a numeric driver.Value. +func (j Jsonable[T]) Value() (driver.Value, error) { + if !j.Valid { + return nil, nil + } + if j.NullOnEmpty && jsonableEmpty(j.Data) { + return nil, nil + } + b, err := json.Marshal(j.Data) + if err != nil { + return nil, err + } + if bytes.Equal(b, []byte("null")) { + return nil, nil + } + return string(b), nil +} + +// GormDataType stores jsonable columns as TEXT. +func (Jsonable[T]) GormDataType() string { return "text" } + +func jsonableEmpty(v any) bool { + rv := reflect.ValueOf(v) + switch rv.Kind() { + case reflect.Slice, reflect.Map, reflect.String: + return rv.Len() == 0 + case reflect.Ptr, reflect.Interface: + return rv.IsNil() + default: + return false + } +} diff --git a/lagoon/jsonable_test.go b/lagoon/jsonable_test.go new file mode 100644 index 0000000..d598f9b --- /dev/null +++ b/lagoon/jsonable_test.go @@ -0,0 +1,106 @@ +package lagoon + +import ( + "testing" +) + +func TestJsonableStringSliceNullAndEmpty(t *testing.T) { + var nullCol Jsonable[[]string] + if err := nullCol.Scan(nil); err != nil { + t.Fatal(err) + } + if nullCol.Valid || nullCol.Data != nil { + t.Fatalf("NULL scan: valid=%v data=%v", nullCol.Valid, nullCol.Data) + } + got, err := nullCol.Value() + if err != nil { + t.Fatal(err) + } + if got != nil { + t.Fatalf("NULL value = %#v, want nil", got) + } + + var emptyCol Jsonable[[]string] + if err := emptyCol.Scan("[]"); err != nil { + t.Fatal(err) + } + if !emptyCol.Valid { + t.Fatal("[] must be valid") + } + if emptyCol.Data == nil || len(emptyCol.Data) != 0 { + t.Fatalf("[] scan = %#v, want empty non-nil slice", emptyCol.Data) + } + got, err = emptyCol.Value() + if err != nil { + t.Fatal(err) + } + if got != "[]" { + t.Fatalf("[] value = %#v, want %q", got, "[]") + } + + var populated Jsonable[[]string] + if err := populated.Scan(`["https://img.discogs.com/a.jpg"]`); err != nil { + t.Fatal(err) + } + if !populated.Valid || len(populated.Data) != 1 || populated.Data[0] != "https://img.discogs.com/a.jpg" { + t.Fatalf("populated scan = %#v", populated.Data) + } +} + +func TestJsonableTrackEntryRoundTrip(t *testing.T) { + type TrackEntry = map[string]any + src := `[{"title":"Smells Like Teen Spirit","position":"A1"}]` + var col Jsonable[[]TrackEntry] + if err := col.Scan(src); err != nil { + t.Fatal(err) + } + if !col.Valid || len(col.Data) != 1 { + t.Fatalf("scan = %#v", col.Data) + } + title, _ := col.Data[0]["title"].(string) + if title != "Smells Like Teen Spirit" { + t.Fatalf("title = %#v", col.Data[0]["title"]) + } + if _, isStringSlice := any(col.Data).([]string); isStringSlice { + t.Fatal("tracklist must not collapse to []string") + } + got, err := col.Value() + if err != nil { + t.Fatal(err) + } + s, ok := got.(string) + if !ok { + t.Fatalf("Value() type %T, want string", got) + } + var round Jsonable[[]TrackEntry] + if err := round.Scan(s); err != nil { + t.Fatal(err) + } + title, _ = round.Data[0]["title"].(string) + if title != "Smells Like Teen Spirit" { + t.Fatalf("round-trip title = %#v", round.Data[0]["title"]) + } + + var nilCol Jsonable[[]TrackEntry] + if err := nilCol.Scan(nil); err != nil { + t.Fatal(err) + } + got, err = nilCol.Value() + if err != nil { + t.Fatal(err) + } + if got != nil { + t.Fatalf("NULL tracklist value = %#v", got) + } +} + +func TestJsonableValueNeverNumeric(t *testing.T) { + col := Jsonable[[]string]{Data: []string{"a"}, Valid: true} + got, err := col.Value() + if err != nil { + t.Fatal(err) + } + if _, ok := got.(string); !ok && got != nil { + t.Fatalf("Value() type %T, want string or nil", got) + } +}