feat(05-01): implement Fill, hook interfaces, and cascade helper
- Allow-list copy with once-per-key non-production dropped-key log - Winter-named Has* hook interfaces; WithSoftDeleteCascade names the tx
This commit is contained in:
131
lagoon/fill.go
Normal file
131
lagoon/fill.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package lagoon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// HasFillable is the Go form of Eloquent $fillable: the model's backstop
|
||||
// allow-list for mass assignment (D-05).
|
||||
type HasFillable interface {
|
||||
Fillable() []string
|
||||
}
|
||||
|
||||
// HasHidden is the Go form of Eloquent $hidden: column names that must not
|
||||
// appear in an accidental JSON marshal (D-08).
|
||||
type HasHidden interface {
|
||||
Hidden() []string
|
||||
}
|
||||
|
||||
var droppedKeys sync.Map
|
||||
|
||||
// Fill copies requested keys onto model only when they are also in allowed.
|
||||
// Unknown and non-fillable keys are dropped with no error (D-06). In
|
||||
// non-production, each type+key pair is logged once.
|
||||
func Fill(model any, allowed []string, requested map[string]any, production bool) error {
|
||||
if model == nil {
|
||||
return fmt.Errorf("lagoon: fill model is nil")
|
||||
}
|
||||
rv := reflect.ValueOf(model)
|
||||
if rv.Kind() != reflect.Ptr || rv.IsNil() {
|
||||
return fmt.Errorf("lagoon: fill model must be a non-nil pointer")
|
||||
}
|
||||
rv = rv.Elem()
|
||||
if rv.Kind() != reflect.Struct {
|
||||
return fmt.Errorf("lagoon: fill model must point to a struct")
|
||||
}
|
||||
rt := rv.Type()
|
||||
typeName := rt.String()
|
||||
for key, val := range requested {
|
||||
if !allowListed(key, allowed) {
|
||||
logDroppedKeyOnce(production, typeName, key)
|
||||
continue
|
||||
}
|
||||
field, ok := fieldByColumn(rv, rt, key)
|
||||
if !ok {
|
||||
logDroppedKeyOnce(production, typeName, key)
|
||||
continue
|
||||
}
|
||||
if err := setField(field, val); err != nil {
|
||||
return fmt.Errorf("lagoon: fill %s: %w", key, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func logDroppedKeyOnce(production bool, typeName, key string) {
|
||||
if production {
|
||||
return
|
||||
}
|
||||
k := typeName + "." + key
|
||||
if _, loaded := droppedKeys.LoadOrStore(k, struct{}{}); loaded {
|
||||
return
|
||||
}
|
||||
slog.Warn("lagoon: dropped non-fillable key", "type", typeName, "key", key)
|
||||
}
|
||||
|
||||
func fieldByColumn(rv reflect.Value, rt reflect.Type, column string) (reflect.Value, bool) {
|
||||
for i := 0; i < rt.NumField(); i++ {
|
||||
f := rt.Field(i)
|
||||
if !f.IsExported() {
|
||||
continue
|
||||
}
|
||||
if gormColumn(f.Tag.Get("gorm")) == column {
|
||||
return rv.Field(i), true
|
||||
}
|
||||
}
|
||||
return reflect.Value{}, false
|
||||
}
|
||||
|
||||
func gormColumn(tag string) string {
|
||||
for _, part := range strings.Split(tag, ";") {
|
||||
part = strings.TrimSpace(part)
|
||||
if after, ok := strings.CutPrefix(part, "column:"); ok {
|
||||
return after
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func setField(field reflect.Value, val any) error {
|
||||
if !field.CanSet() {
|
||||
return fmt.Errorf("field cannot be set")
|
||||
}
|
||||
if val == nil {
|
||||
if field.Kind() == reflect.Ptr {
|
||||
field.Set(reflect.Zero(field.Type()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
src := reflect.ValueOf(val)
|
||||
if field.Kind() == reflect.Ptr {
|
||||
elemType := field.Type().Elem()
|
||||
converted, err := convertValue(src, elemType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ptr := reflect.New(elemType)
|
||||
ptr.Elem().Set(converted)
|
||||
field.Set(ptr)
|
||||
return nil
|
||||
}
|
||||
converted, err := convertValue(src, field.Type())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
field.Set(converted)
|
||||
return nil
|
||||
}
|
||||
|
||||
func convertValue(src reflect.Value, destType reflect.Type) (reflect.Value, error) {
|
||||
if src.Type().AssignableTo(destType) {
|
||||
return src, nil
|
||||
}
|
||||
if src.Type().ConvertibleTo(destType) {
|
||||
return src.Convert(destType), nil
|
||||
}
|
||||
return reflect.Value{}, fmt.Errorf("cannot assign %s to %s", src.Type(), destType)
|
||||
}
|
||||
48
lagoon/lifecycle.go
Normal file
48
lagoon/lifecycle.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package lagoon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// HasBeforeValidate is the Winter beforeValidate hook. GORM does not
|
||||
// dispatch this name; later validation wiring type-asserts it.
|
||||
type HasBeforeValidate interface {
|
||||
BeforeValidate(tx *gorm.DB) error
|
||||
}
|
||||
|
||||
// HasBeforeCreate matches GORM's native BeforeCreate hook so a model
|
||||
// needs no adapter. GORM dispatches it by method name.
|
||||
type HasBeforeCreate interface {
|
||||
BeforeCreate(tx *gorm.DB) error
|
||||
}
|
||||
|
||||
// HasBeforeSave matches GORM's native BeforeSave hook so a model
|
||||
// needs no adapter. GORM dispatches it by method name.
|
||||
type HasBeforeSave interface {
|
||||
BeforeSave(tx *gorm.DB) error
|
||||
}
|
||||
|
||||
// HasBeforeDelete matches GORM's native BeforeDelete hook so a model
|
||||
// needs no adapter. GORM dispatches it by method name.
|
||||
type HasBeforeDelete interface {
|
||||
BeforeDelete(tx *gorm.DB) error
|
||||
}
|
||||
|
||||
// HasAfterDelete matches GORM's native AfterDelete hook so a model
|
||||
// needs no adapter. GORM dispatches it by method name.
|
||||
type HasAfterDelete interface {
|
||||
AfterDelete(tx *gorm.DB) error
|
||||
}
|
||||
|
||||
// WithSoftDeleteCascade names the "Collection cascades to Album" pattern
|
||||
// (DATA-03). Callers invoke it from their own BeforeDelete method: GORM
|
||||
// already runs BeforeDelete inside the parent Delete transaction, so this
|
||||
// does not open a new transaction. A cascade error aborts the parent delete.
|
||||
func WithSoftDeleteCascade(tx *gorm.DB, cascade func(tx *gorm.DB) error) error {
|
||||
if tx == nil {
|
||||
return fmt.Errorf("lagoon: soft-delete cascade tx is nil")
|
||||
}
|
||||
return cascade(tx)
|
||||
}
|
||||
Reference in New Issue
Block a user