package lagoon import ( "context" "encoding/json" "errors" "fmt" "strings" "time" "git.golem15.com/golem15/summercms/modules/lagoon/attach" "gorm.io/gorm" "gorm.io/gorm/clause" ) // DeferredFileType is the slave_type of a deferred binding that points at a // system_files row: the table name, never a PHP class string. const DeferredFileType = "system_files" // DeferredBinding is one row of WinterCMS's deferred_bindings table: a // pending bind (IsBind true) or unbind of the slave SlaveType/SlaveID to the // MasterField relation of a MasterType record that one admin's form session // (SessionKey, BackendUserID) has not saved yet. PivotData holds the // DeferredEnvelope JSON or is nil. type DeferredBinding struct { ID uint `gorm:"column:id;primaryKey"` MasterType string `gorm:"column:master_type"` MasterField string `gorm:"column:master_field"` SlaveType string `gorm:"column:slave_type"` SlaveID string `gorm:"column:slave_id"` PivotData *string `gorm:"column:pivot_data"` SessionKey string `gorm:"column:session_key"` IsBind bool `gorm:"column:is_bind"` BackendUserID uint `gorm:"column:backend_user_id"` CreatedAt time.Time `gorm:"column:created_at"` UpdatedAt time.Time `gorm:"column:updated_at"` } // TableName is deferred_bindings. func (DeferredBinding) TableName() string { return "deferred_bindings" } // DeferredKey identifies one admin's pending work on one master type: the // form's session key, the backend admin who owns it and the master record's // morph type (MorphType). Every deferred-binding operation is scoped by all // three, so a session key used by another admin, or against another model, // matches nothing. type DeferredKey struct { SessionKey string AdminID uint MasterType string } func (k DeferredKey) validate() error { if strings.TrimSpace(k.SessionKey) == "" { return fmt.Errorf("lagoon: deferred binding session key is empty") } if k.AdminID == 0 { return fmt.Errorf("lagoon: deferred binding admin id is zero") } if strings.TrimSpace(k.MasterType) == "" { return fmt.Errorf("lagoon: deferred binding master type is empty") } return nil } // DeferredEnvelope is the framework's pivot_data shape, // {"created":true,"pivot":{...}}. Created marks a slave row that was created // under deferral, which PurgeDeferred deletes with an expired binding; Pivot // holds the pivot values of a deferred belongsToMany link. A binding without // the envelope (nil pivot_data, or JSON without these keys) is a plain link. type DeferredEnvelope struct { Created bool `json:"created,omitempty"` Pivot map[string]any `json:"pivot,omitempty"` } // Envelope decodes the binding's pivot_data. Nil or empty pivot_data is the // zero envelope (a plain link); invalid JSON is an error. func (b DeferredBinding) Envelope() (DeferredEnvelope, error) { var env DeferredEnvelope if b.PivotData == nil || strings.TrimSpace(*b.PivotData) == "" { return env, nil } if err := json.Unmarshal([]byte(*b.PivotData), &env); err != nil { return DeferredEnvelope{}, fmt.Errorf("lagoon: deferred binding %d pivot_data: %w", b.ID, err) } return env, nil } // MorphType is the master_type or slave_type string of model: its // attach.Owner MorphName when it implements attach.Owner, else its GORM table // name under db's naming strategy. An empty result is an error. func MorphType(db *gorm.DB, model any) (string, error) { if model == nil { return "", fmt.Errorf("lagoon: morph type of a nil model") } if owner, ok := model.(attach.Owner); ok { if name := strings.TrimSpace(owner.MorphName()); name != "" { return name, nil } return "", fmt.Errorf("lagoon: morph type of %T is empty", model) } if db == nil { return "", fmt.Errorf("lagoon: gorm db is nil") } stmt := &gorm.Statement{DB: db} if err := stmt.Parse(model); err != nil { return "", fmt.Errorf("lagoon: morph type of %T: %w", model, err) } if stmt.Schema == nil || strings.TrimSpace(stmt.Schema.Table) == "" { return "", fmt.Errorf("lagoon: morph type of %T is empty", model) } return stmt.Schema.Table, nil } func deferredSession(ctx context.Context, tx *gorm.DB) *gorm.DB { if ctx == nil { ctx = context.Background() } return tx.Session(&gorm.Session{NewDB: true, Context: ctx}) } func checkDeferredArgs(tx *gorm.DB, key DeferredKey, field, slaveType, slaveID string) error { if tx == nil { return fmt.Errorf("lagoon: gorm db is nil") } if err := key.validate(); err != nil { return err } if strings.TrimSpace(field) == "" { return fmt.Errorf("lagoon: deferred binding field is empty") } if strings.TrimSpace(slaveType) == "" { return fmt.Errorf("lagoon: deferred binding slave type is empty") } if strings.TrimSpace(slaveID) == "" { return fmt.Errorf("lagoon: deferred binding slave id is empty") } return nil } // findBinding is WinterCMS's DeferredBinding::findBindingRecord, scoped to // the owning admin and locked for the rest of the transaction. func findBinding(ctx context.Context, tx *gorm.DB, key DeferredKey, field, slaveType, slaveID string) (*DeferredBinding, error) { var row DeferredBinding err := deferredSession(ctx, tx). Clauses(clause.Locking{Strength: "UPDATE"}). Where("master_type = ? AND master_field = ? AND slave_type = ? AND slave_id = ? AND session_key = ? AND backend_user_id = ?", key.MasterType, field, slaveType, slaveID, key.SessionKey, key.AdminID). Order("id"). Take(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } if err != nil { return nil, fmt.Errorf("lagoon: find deferred binding: %w", err) } return &row, nil } func insertBinding(ctx context.Context, tx *gorm.DB, key DeferredKey, field, slaveType, slaveID string, bind bool, env *DeferredEnvelope) error { row := DeferredBinding{ MasterType: key.MasterType, MasterField: field, SlaveType: slaveType, SlaveID: slaveID, SessionKey: key.SessionKey, IsBind: bind, BackendUserID: key.AdminID, } if env != nil && (env.Created || len(env.Pivot) > 0) { raw, err := json.Marshal(env) if err != nil { return fmt.Errorf("lagoon: deferred binding envelope: %w", err) } s := string(raw) row.PivotData = &s } if err := deferredSession(ctx, tx).Create(&row).Error; err != nil { return fmt.Errorf("lagoon: insert deferred binding: %w", err) } return nil } func deleteBinding(ctx context.Context, tx *gorm.DB, id uint) error { if err := deferredSession(ctx, tx).Where("id = ?", id).Delete(&DeferredBinding{}).Error; err != nil { return fmt.Errorf("lagoon: delete deferred binding: %w", err) } return nil } // DeferredBind records that slaveType/slaveID is to be bound to the field // relation of key's unsaved master, with env (nil for a plain link) as its // pivot_data. It ports WinterCMS's DeferredBinding::beforeCreate: a second // bind of the same slave in the same session writes nothing, and a bind // that meets a pending unbind of the same slave cancels it (the unbind row // is deleted and no bind row is written). An empty session key, a zero // AdminID or an empty MasterType is an error. func DeferredBind(ctx context.Context, tx *gorm.DB, key DeferredKey, field, slaveType, slaveID string, env *DeferredEnvelope) error { if err := checkDeferredArgs(tx, key, field, slaveType, slaveID); err != nil { return err } existing, err := findBinding(ctx, tx, key, field, slaveType, slaveID) if err != nil { return err } if existing != nil { if existing.IsBind { return nil } return deleteBinding(ctx, tx, existing.ID) } return insertBinding(ctx, tx, key, field, slaveType, slaveID, true, env) } // DeferredUnbind records that slaveType/slaveID is to be unbound from the // field relation of key's master. A second unbind writes nothing. An unbind // that meets a pending bind of the same slave cancels the pair: the bind // row is deleted, no unbind row is written, and the cancelled bind is // returned so the caller can remove the slave it created (a pending upload // or a child created under deferral). Otherwise it returns nil. func DeferredUnbind(ctx context.Context, tx *gorm.DB, key DeferredKey, field, slaveType, slaveID string) (*DeferredBinding, error) { if err := checkDeferredArgs(tx, key, field, slaveType, slaveID); err != nil { return nil, err } existing, err := findBinding(ctx, tx, key, field, slaveType, slaveID) if err != nil { return nil, err } if existing != nil { if !existing.IsBind { return nil, nil } if err := deleteBinding(ctx, tx, existing.ID); err != nil { return nil, err } return existing, nil } return nil, insertBinding(ctx, tx, key, field, slaveType, slaveID, false, nil) } // DeferredBindings returns key's bindings whose master_field is one of // fields, in id order, locked FOR UPDATE so two saves with the same session // key are serialized. No fields means no bindings. func DeferredBindings(ctx context.Context, tx *gorm.DB, key DeferredKey, fields []string) ([]DeferredBinding, error) { if tx == nil { return nil, fmt.Errorf("lagoon: gorm db is nil") } if err := key.validate(); err != nil { return nil, err } if len(fields) == 0 { return nil, nil } var rows []DeferredBinding err := deferredSession(ctx, tx). Clauses(clause.Locking{Strength: "UPDATE"}). Where("session_key = ? AND backend_user_id = ? AND master_type = ? AND master_field IN ?", key.SessionKey, key.AdminID, key.MasterType, fields). Order("id"). Find(&rows).Error if err != nil { return nil, fmt.Errorf("lagoon: deferred bindings: %w", err) } return rows, nil } // DeferredForget deletes exactly the bindings with the given ids, after // their work has been applied. The caller passes only ids it read for its // own key through DeferredBindings. func DeferredForget(ctx context.Context, tx *gorm.DB, ids []uint) error { if tx == nil { return fmt.Errorf("lagoon: gorm db is nil") } if len(ids) == 0 { return nil } if err := deferredSession(ctx, tx).Where("id IN ?", ids).Delete(&DeferredBinding{}).Error; err != nil { return fmt.Errorf("lagoon: forget deferred bindings: %w", err) } return nil } // DeferredSlaves returns a subquery selecting the slave_id values of key's // bindings for field and slaveType in one direction (bind true for pending // binds, false for pending unbinds), for use as CAST(pk AS TEXT) IN (?) in // a list query (WinterCMS's withDeferred). slave_id is a text column, so the // primary key must be cast to text for the comparison. func DeferredSlaves(tx *gorm.DB, key DeferredKey, field, slaveType string, bind bool) *gorm.DB { return tx.Session(&gorm.Session{NewDB: true}). Model(&DeferredBinding{}). Select("slave_id"). Where("session_key = ? AND backend_user_id = ? AND master_type = ? AND master_field = ? AND slave_type = ? AND is_bind = ?", key.SessionKey, key.AdminID, key.MasterType, field, slaveType, bind) }