refactor(10.2-01): nest framework packages under modules
- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
This commit is contained in:
97
modules/lagoon/jsonable.go
Normal file
97
modules/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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user