- 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
372 lines
11 KiB
Go
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)
|
|
}
|