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 } }