From 969a5f522a972312062a4d52f2cbacc1366bdce3 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 18 Sep 2026 18:46:37 +0200 Subject: [PATCH] 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 --- lagoon/fill.go | 131 ++++++++++++++++++++++++++++++++++++++++++++ lagoon/lifecycle.go | 48 ++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 lagoon/fill.go create mode 100644 lagoon/lifecycle.go diff --git a/lagoon/fill.go b/lagoon/fill.go new file mode 100644 index 0000000..ec8a413 --- /dev/null +++ b/lagoon/fill.go @@ -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) +} diff --git a/lagoon/lifecycle.go b/lagoon/lifecycle.go new file mode 100644 index 0000000..731c39e --- /dev/null +++ b/lagoon/lifecycle.go @@ -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) +}