- deferred_bindings migration set under summercms.deferred with backend_user_id - lagoon.DeferredBind/Unbind/Bindings/Forget/Slaves scoped by DeferredKey - lagoon.PurgeDeferred with SKIP LOCKED batches and after-commit blob deletes - attach.Store with the ported image guard, extension and MIME limits - attach.Relation, attach.HasRelations, attach.BlobKeys, File.ThumbKey - lagoon README and attachments docs
295 lines
11 KiB
Go
295 lines
11 KiB
Go
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)
|
|
}
|