Files
summercms/modules/cabana/record_events.go
Jakub Zych ff62906301 feat(32-02): record events from every admin write path
- Delete and each bulk-delete row fire RecordDeleted after commit
- tree reorder fires RecordSaved per moved record (parent/sort in model types)
- relation-manager child create/update/delete fire events naming the
  controller that edits the child's model
- TestRecordEvents covers create, update, translated slug, no-op, rollback
  (validation, FormBeforeUpdate, extension Save), failing listener, reorder,
  child writes, delete and bulk delete; extension Save sees the changes
- docs: Record events in extending.md, admin-controllers notes, cabana README
2026-10-26 22:35:03 +01:00

372 lines
11 KiB
Go

package cabana
import (
"context"
"reflect"
"strconv"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/towel"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
// EmitRecordSaved fires ev as a *pact.RecordSaved through the application's
// event bus once tx commits, for plugin code that writes records outside the
// admin forms (its own API handlers, console imports, record actions). tx is
// the write's lagoon.Transaction handle: the event is buffered until that
// transaction commits and dropped when it rolls back. With a nil tx, or
// outside a transaction, it fires at once, so call it after the write has
// committed. A missing application or bus is a no-op and a listener's error
// is logged: the write that caused the event never fails because of it.
func EmitRecordSaved(ctx context.Context, app *backpack.App, tx *gorm.DB, ev pact.RecordSaved) {
if app == nil || app.Events == nil {
return
}
if ctx == nil {
ctx = context.Background()
}
ev.Changes = append([]pact.RecordChange(nil), ev.Changes...)
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
event := ev
if err := app.Events.Fire(ctx, &event); err != nil {
moduleLog.WarnContext(ctx, "cabana: record saved listener failed", "controller", ev.Controller, "model", ev.Model, "id", ev.ID, "error", err)
}
})
}
// EmitRecordDeleted fires ev as a *pact.RecordDeleted after tx commits, with
// the rules of EmitRecordSaved.
func EmitRecordDeleted(ctx context.Context, app *backpack.App, tx *gorm.DB, ev pact.RecordDeleted) {
if app == nil || app.Events == nil {
return
}
if ctx == nil {
ctx = context.Background()
}
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
event := ev
if err := app.Events.Fire(ctx, &event); err != nil {
moduleLog.WarnContext(ctx, "cabana: record deleted listener failed", "controller", ev.Controller, "model", ev.Model, "id", ev.ID, "error", err)
}
})
}
// emitRecordSaved fires the admin save of model through controller cc after
// tx commits.
func emitRecordSaved(ctx context.Context, app *backpack.App, tx *gorm.DB, cc *CompiledController, model any, created bool, changes []pact.RecordChange) {
if app == nil || app.Events == nil {
return
}
locale, _ := towel.Locale(ctx)
EmitRecordSaved(ctx, app, tx, pact.RecordSaved{
Controller: controllerID(cc),
Model: recordMorphType(tx, model),
ID: recordIDText(model),
Locale: locale,
Created: created,
Changes: changes,
})
}
// emitRecordDeleted fires the admin delete of model through controller cc
// after tx commits.
func emitRecordDeleted(ctx context.Context, app *backpack.App, tx *gorm.DB, cc *CompiledController, model any) {
if app == nil || app.Events == nil {
return
}
EmitRecordDeleted(ctx, app, tx, pact.RecordDeleted{
Controller: controllerID(cc),
Model: recordMorphType(tx, model),
ID: recordIDText(model),
})
}
// controllerForRecord is the ID of the one admin controller whose records
// have model's type, "" when none or several do. A nil registry knows none.
func (r *Registry) controllerForRecord(model any) string {
if r == nil || model == nil {
return ""
}
want := reflect.TypeOf(model)
found := ""
for id, cc := range r.byID {
src, ok := cc.Controller.(pact.AdminRecordSource)
if !ok || src == nil {
continue
}
record := src.NewRecord()
if record == nil || reflect.TypeOf(record) != want {
continue
}
if found != "" {
return ""
}
found = id
}
return found
}
// reorderEvents are the RecordSaved events of a reorder save: one per node
// whose parent or sort value changed, with the change in the model's column
// types (a nil parent at the top).
func reorderEvents(cc *CompiledController, morph string, locked []AdminReorderNode, nodes []AdminReorderNodeInput) []pact.RecordSaved {
ro := cc.reorder
proto := cc.Controller.(pact.AdminRecordSource).NewRecord()
before := make(map[uint]AdminReorderNode, len(locked))
for _, n := range locked {
before[n.ID] = n
}
var out []pact.RecordSaved
for _, node := range nodes {
old, ok := before[uint(node.ID)]
if !ok {
continue
}
var changes []pact.RecordChange
if !ro.flat {
var prev, next any
if old.ParentID != nil {
prev = columnValue(proto, ro.parentColumn, *old.ParentID)
}
if node.ParentID != nil {
next = columnValue(proto, ro.parentColumn, uint(*node.ParentID))
}
if !sameChangeValue(prev, next) {
changes = append(changes, pact.RecordChange{Attribute: ro.parentColumn, Old: prev, New: next})
}
}
if old.SortOrder != node.SortOrder {
changes = append(changes, pact.RecordChange{
Attribute: ro.sortColumn,
Old: columnValue(proto, ro.sortColumn, old.SortOrder),
New: columnValue(proto, ro.sortColumn, node.SortOrder),
})
}
if len(changes) == 0 {
continue
}
out = append(out, pact.RecordSaved{
Controller: controllerID(cc),
Model: morph,
ID: strconv.FormatUint(node.ID, 10),
Changes: changes,
})
}
return out
}
// columnValue converts v to the type model's column holds (a pointer
// column's element type), so reorder changes match save changes. A column
// the model does not have, or a type v does not convert to, keeps v.
func columnValue(model any, column string, v any) any {
rv := reflect.ValueOf(model)
for rv.Kind() == reflect.Pointer {
if rv.IsNil() {
return v
}
rv = rv.Elem()
}
field := fieldByColumn(rv, column)
if !field.IsValid() {
return v
}
typ := field.Type()
for typ.Kind() == reflect.Pointer {
typ = typ.Elem()
}
val := reflect.ValueOf(v)
if !val.IsValid() || !val.Type().ConvertibleTo(typ) {
return v
}
return val.Convert(typ).Interface()
}
// recordMorphType is lagoon.MorphType, logged and "" when it fails.
func recordMorphType(db *gorm.DB, model any) string {
morph, err := lagoon.MorphType(db, model)
if err != nil {
moduleLog.Warn("cabana: record event morph type", "error", err)
return ""
}
return morph
}
// recordSnapshot holds a record's column values and, for the translatable
// fields a save submits, their stored non-default locale values, taken
// before the save changes them, so the save can report what it changed.
type recordSnapshot struct {
columns []string
values map[string]any
translations map[string]map[string]any // field -> locale -> old value (nil when missing)
}
// snapshotColumns lists the columns a change is reported for: every column
// except the primary key and the timestamps GORM maintains itself.
func snapshotColumns(db *gorm.DB, model any) ([]*schema.Field, error) {
stmt := &gorm.Statement{DB: db}
if err := stmt.Parse(model); err != nil {
return nil, err
}
out := []*schema.Field{}
for _, name := range stmt.Schema.DBNames {
field := stmt.Schema.FieldsByDBName[name]
if field == nil || field.PrimaryKey || field.AutoCreateTime != 0 || field.AutoUpdateTime != 0 || !field.Readable {
continue
}
out = append(out, field)
}
return out, nil
}
// takeRecordSnapshot reads model's column values and, through writer, the
// stored values of the submitted translatable fields in their non-default
// locales. A model that GORM cannot parse snapshots nothing.
func takeRecordSnapshot(ctx context.Context, tx *gorm.DB, writer TranslationWriter, model any, translations []mlValue) (*recordSnapshot, error) {
snap := &recordSnapshot{values: map[string]any{}, translations: map[string]map[string]any{}}
fields, err := snapshotColumns(tx, model)
if err != nil {
moduleLog.Warn("cabana: record event snapshot", "error", err)
return snap, nil
}
rv := reflect.ValueOf(model)
for _, field := range fields {
value, _ := field.ValueOf(ctx, rv)
snap.columns = append(snap.columns, field.DBName)
snap.values[field.DBName] = changeValue(value)
}
if writer == nil || len(translations) == 0 {
return snap, nil
}
def, err := writer.DefaultLocale(ctx, tx)
if err != nil {
return nil, err
}
for _, item := range translations {
for locale := range item.values {
if locale == def {
continue
}
text, ok, err := writer.TranslatedExact(ctx, tx, model, item.field, locale)
if err != nil {
return nil, err
}
if snap.translations[item.field] == nil {
snap.translations[item.field] = map[string]any{}
}
if ok {
snap.translations[item.field][locale] = text
} else {
snap.translations[item.field][locale] = nil
}
}
}
return snap, nil
}
// recordChanges compares model after the save with the snapshot taken
// before it: one change per column whose value differs, then one per
// submitted translatable field and non-default locale whose text differs.
// On create (snap nil) it lists every column the save wrote a value to and
// every submitted non-empty translation, each with a nil Old.
func recordChanges(ctx context.Context, tx *gorm.DB, writer TranslationWriter, model any, snap *recordSnapshot, translations []mlValue) ([]pact.RecordChange, error) {
changes := []pact.RecordChange{}
fields, err := snapshotColumns(tx, model)
if err != nil {
moduleLog.Warn("cabana: record event changes", "error", err)
return changes, nil
}
rv := reflect.ValueOf(model)
for _, field := range fields {
value, zero := field.ValueOf(ctx, rv)
next := changeValue(value)
if snap == nil {
if zero || next == nil {
continue
}
changes = append(changes, pact.RecordChange{Attribute: field.DBName, New: next})
continue
}
prev, known := snap.values[field.DBName]
if known && sameChangeValue(prev, next) {
continue
}
changes = append(changes, pact.RecordChange{Attribute: field.DBName, Old: prev, New: next})
}
if writer == nil || len(translations) == 0 {
return changes, nil
}
def, err := writer.DefaultLocale(ctx, tx)
if err != nil {
return nil, err
}
for _, item := range translations {
for _, locale := range sortedLocales(item.values) {
if locale == def {
continue
}
next := item.values[locale]
var prev any
if snap != nil {
prev = snap.translations[item.field][locale]
}
old, _ := prev.(string)
if old == next {
continue
}
changes = append(changes, pact.RecordChange{Attribute: item.field, Locale: locale, Old: prev, New: next})
}
}
return changes, nil
}
// changeValue is a column value as a change reports it: pointers are
// dereferenced (a nil pointer is nil) and slices and maps are copied, so a
// later write to the model does not reach the reported value.
func changeValue(value any) any {
rv := reflect.ValueOf(value)
for rv.IsValid() && rv.Kind() == reflect.Pointer {
if rv.IsNil() {
return nil
}
rv = rv.Elem()
}
if !rv.IsValid() {
return nil
}
switch rv.Kind() {
case reflect.Slice:
if rv.IsNil() {
return nil
}
out := reflect.MakeSlice(rv.Type(), rv.Len(), rv.Len())
reflect.Copy(out, rv)
return out.Interface()
case reflect.Map:
if rv.IsNil() {
return nil
}
out := reflect.MakeMapWithSize(rv.Type(), rv.Len())
iter := rv.MapRange()
for iter.Next() {
out.SetMapIndex(iter.Key(), iter.Value())
}
return out.Interface()
}
return rv.Interface()
}
// sameChangeValue compares two change values; times compare as instants.
func sameChangeValue(a, b any) bool {
ta, okA := a.(time.Time)
tb, okB := b.(time.Time)
if okA && okB {
return ta.Equal(tb)
}
return reflect.DeepEqual(a, b)
}