- 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
271 lines
8.5 KiB
Go
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)
|
|
}
|