Files
summercms/modules/cabana/record_events.go
Jakub Zych 753fb5969a feat(32-02): record events and content descriptors (tracer)
- pact.RecordSaved/RecordDeleted/RecordChange, fired as pointers after commit
- pact.ContentDescriptor and request/result types; MenuItemType.Content
- AdminFormSave.Changes carries the owner's changes to extension Save
- cabana.EmitRecordSaved/EmitRecordDeleted; admin save snapshots columns and
  translated values and fires RecordSaved through lagoon.AfterCommit
- signpost validates Content and gains ContentTypes, ContentTypeForModel, Describe
2026-10-26 22:22:25 +01:00

271 lines
8.5 KiB
Go

package cabana
import (
"context"
"reflect"
"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),
})
}
// 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)
}