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
This commit is contained in:
97
lagoon/jsonable.go
Normal file
97
lagoon/jsonable.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
106
lagoon/jsonable_test.go
Normal file
106
lagoon/jsonable_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user