test(09-05): add failing tests for scoped record lifecycle

- Record routes must enforce permission before ids or bodies and return D-10 envelopes
- Create, update, and delete run Before and After hooks once inside the transaction
- Out-of-scope and missing records are indistinguishable, and hook failure rolls back
This commit is contained in:
Jakub Zych
2026-09-24 19:38:23 +02:00
parent 578bdc8d5d
commit 94814d980b
5 changed files with 604 additions and 4 deletions

View File

@@ -126,6 +126,11 @@ func (s CRUDService) Update(ctx context.Context, cc *CompiledController, id any,
return s.save(ctx, cc, id, in, true) return s.save(ctx, cc, id, in, true)
} }
// Delete removes one scoped record. The lifecycle is added with the record routes.
func (s CRUDService) Delete(ctx context.Context, cc *CompiledController, id any) (BulkResult, error) {
return BulkResult{}, errors.New("cabana: delete is not implemented")
}
func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, in RecordInput, update bool) (map[string]any, error) { func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, in RecordInput, update bool) (map[string]any, error) {
if s.DB == nil { if s.DB == nil {
return nil, errors.New("cabana: database is not configured") return nil, errors.New("cabana: database is not configured")

View File

@@ -0,0 +1,496 @@
package cabana
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync/atomic"
"testing"
"git.golem15.com/golem15/summercms/backpack"
"git.golem15.com/golem15/summercms/bouncer"
"git.golem15.com/golem15/summercms/pact"
"gorm.io/gorm"
)
type scopeKey struct{}
type hookController struct {
log *[]string
perms []string
trackScope bool
fail string
queries atomic.Int32
allocs atomic.Int32
}
func (c *hookController) ID() string { return "acme.demo.records" }
func (c *hookController) ModelName() string { return "Record" }
func (c *hookController) ConfigDir() string { return "controllers/records" }
func (c *hookController) RequiredPermissions() []string {
if c == nil || c.perms == nil {
return nil
}
return c.perms
}
func (c *hookController) NewRecord() any {
if c != nil {
c.allocs.Add(1)
}
return &crudRow{}
}
func (c *hookController) FormExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB {
if c != nil {
c.queries.Add(1)
}
if c == nil || !c.trackScope || db == nil {
return db
}
scope, _ := ctx.Value(scopeKey{}).(uint)
return db.Where("scope_id = ?", scope)
}
func (c *hookController) FormBeforeCreate(ctx context.Context, model any) error {
recordHook(ctx, "form_before_create")
if c.trackScope {
if row, ok := model.(*crudRow); ok {
if scope, ok := ctx.Value(scopeKey{}).(uint); ok {
row.ScopeID = scope
}
}
}
return c.hookErr("form_before_create")
}
func (c *hookController) FormAfterCreate(ctx context.Context, model any) error {
recordHook(ctx, "form_after_create")
return c.hookErr("form_after_create")
}
func (c *hookController) FormBeforeUpdate(ctx context.Context, model any) error {
recordHook(ctx, "form_before_update")
return c.hookErr("form_before_update")
}
func (c *hookController) FormAfterUpdate(ctx context.Context, model any) error {
recordHook(ctx, "form_after_update")
return c.hookErr("form_after_update")
}
func (c *hookController) FormBeforeDelete(ctx context.Context, model any) error {
recordHook(ctx, "form_before_delete")
return c.hookErr("form_before_delete")
}
func (c *hookController) FormAfterDelete(ctx context.Context, model any) error {
recordHook(ctx, "form_after_delete")
return c.hookErr("form_after_delete")
}
func (c *hookController) hookErr(name string) error {
if c != nil && c.fail == name {
return errHookBoom
}
return nil
}
var errHookBoom = errString("secret-hook-boom")
type errString string
func (e errString) Error() string { return string(e) }
func TestCRUDRecordRoutes(t *testing.T) {
cap := &captureRouter{}
(&service{}).mount(cap)
for _, want := range []string{
"POST /_admin/api/v1/{vendor}/{plugin}/{controller}",
"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}",
"PUT /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}",
"DELETE /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}",
} {
mw, ok := cap.middleware[want]
if !ok {
t.Fatalf("missing %s in %v", want, cap.routes)
}
if !containsString(mw, "backend") {
t.Fatalf("%s middleware=%v, want backend before the handler", want, mw)
}
}
_, httpSvc, _, db, hooks := hookFixture(t)
ctx := principalCtx(hooks, superUser())
created := crudCall(httpSvc, http.MethodPost, "", []byte(`{"name":"Ada","note":"n"}`), ctx)
if created.Code != http.StatusCreated {
t.Fatalf("create status=%d body=%s", created.Code, created.Body.String())
}
body := decodeData(t, created.Body.Bytes())
if body["name"] != "Ada" || body["note"] != "n" || body["id"] == nil {
t.Fatalf("create data=%#v", body)
}
blank := crudCall(httpSvc, http.MethodPost, "", []byte(`{"name":""}`), ctx)
if blank.Code != http.StatusUnprocessableEntity || !strings.Contains(blank.Body.String(), `"validation_failed"`) || !strings.Contains(blank.Body.String(), "The name field is required.") {
t.Fatalf("blank create=%d %s", blank.Code, blank.Body.String())
}
id := uintString(body["id"])
shown := crudCall(httpSvc, http.MethodGet, id, nil, ctx)
if shown.Code != http.StatusOK || decodeData(t, shown.Body.Bytes())["name"] != "Ada" {
t.Fatalf("show=%d %s", shown.Code, shown.Body.String())
}
missing := crudCall(httpSvc, http.MethodGet, "999999", nil, ctx)
if missing.Code != http.StatusNotFound || !strings.Contains(missing.Body.String(), `"not_found"`) {
t.Fatalf("missing show=%d %s", missing.Code, missing.Body.String())
}
bad := crudCall(httpSvc, http.MethodGet, "nope", nil, ctx)
if bad.Code != http.StatusUnprocessableEntity || !strings.Contains(bad.Body.String(), `"validation_failed"`) {
t.Fatalf("bad id=%d %s", bad.Code, bad.Body.String())
}
updated := crudCall(httpSvc, http.MethodPut, id, []byte(`{"name":"Bea"}`), ctx)
if updated.Code != http.StatusOK || decodeData(t, updated.Body.Bytes())["name"] != "Bea" {
t.Fatalf("update=%d %s", updated.Code, updated.Body.String())
}
*hooks.log = nil
deleted := crudCall(httpSvc, http.MethodDelete, id, nil, ctx)
if deleted.Code != http.StatusOK || deletedCount(t, deleted.Body.Bytes()) != 1 {
t.Fatalf("delete=%d %s", deleted.Code, deleted.Body.String())
}
if strings.Join(*hooks.log, ",") != "form_before_delete,before_delete,after_delete,form_after_delete" {
t.Fatalf("delete hooks=%v", *hooks.log)
}
*hooks.log = nil
again := crudCall(httpSvc, http.MethodDelete, id, nil, ctx)
if again.Code != http.StatusOK || deletedCount(t, again.Body.Bytes()) != 0 || len(*hooks.log) != 0 {
t.Fatalf("repeat delete=%d %s hooks=%v", again.Code, again.Body.String(), *hooks.log)
}
if n := countCrud(t, db); n != 0 {
t.Fatalf("rows=%d after delete", n)
}
}
func TestCRUDPermissions(t *testing.T) {
_, httpSvc, _, db, hooks := hookFixture(t)
hooks.perms = []string{"acme.demo.records"}
denied := &bouncer.Principal{ID: 4, PermissionGrants: map[string]bool{"other.code": true}}
unauth := crudCall(httpSvc, http.MethodPost, "", []byte(`{`), principalCtx(hooks, nil))
if unauth.Code != http.StatusUnauthorized || !strings.Contains(unauth.Body.String(), `"unauthenticated"`) {
t.Fatalf("unauth=%d %s", unauth.Code, unauth.Body.String())
}
forbidden := crudCall(httpSvc, http.MethodPost, "", []byte(`{`), principalCtx(hooks, denied))
if forbidden.Code != http.StatusForbidden || !strings.Contains(forbidden.Body.String(), `"forbidden"`) {
t.Fatalf("forbidden=%d %s", forbidden.Code, forbidden.Body.String())
}
badID := crudCall(httpSvc, http.MethodGet, "nope", nil, principalCtx(hooks, denied))
if badID.Code != http.StatusForbidden {
t.Fatalf("bad id before permission=%d %s", badID.Code, badID.Body.String())
}
if hooks.allocs.Load() != 0 || hooks.queries.Load() != 0 {
t.Fatalf("allocs=%d queries=%d, permission did not run first", hooks.allocs.Load(), hooks.queries.Load())
}
if n := countCrud(t, db); n != 0 {
t.Fatalf("denied request persisted %d rows", n)
}
ok := crudCall(httpSvc, http.MethodPost, "", []byte(`{"name":"Ada"}`), principalCtx(hooks, superUser()))
if ok.Code != http.StatusCreated {
t.Fatalf("superuser create=%d %s", ok.Code, ok.Body.String())
}
}
func TestCRUDScope(t *testing.T) {
_, httpSvc, _, db, hooks := hookFixture(t)
hooks.trackScope = true
in := &crudRow{Name: "In", ScopeID: 1}
out := &crudRow{Name: "Out", ScopeID: 2}
if err := db.Create(in).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(out).Error; err != nil {
t.Fatal(err)
}
ctx := principalCtx(hooks, superUser())
ctx = context.WithValue(ctx, scopeKey{}, uint(1))
shown := crudCall(httpSvc, http.MethodGet, idString(in.ID), nil, ctx)
if shown.Code != http.StatusOK || decodeData(t, shown.Body.Bytes())["name"] != "In" {
t.Fatalf("in scope show=%d %s", shown.Code, shown.Body.String())
}
if _, ok := decodeData(t, shown.Body.Bytes())["scope_id"]; ok {
t.Fatalf("show leaked scope_id: %s", shown.Body.String())
}
missing := crudCall(httpSvc, http.MethodGet, "999999", nil, ctx)
hidden := crudCall(httpSvc, http.MethodGet, idString(out.ID), nil, ctx)
if missing.Code != http.StatusNotFound || hidden.Code != http.StatusNotFound || missing.Body.String() != hidden.Body.String() {
t.Fatalf("missing=%d %s hidden=%d %s", missing.Code, missing.Body.String(), hidden.Code, hidden.Body.String())
}
hacked := crudCall(httpSvc, http.MethodPut, idString(out.ID), []byte(`{"name":"hacked"}`), ctx)
if hacked.Code != http.StatusNotFound || hacked.Body.String() != missing.Body.String() {
t.Fatalf("out of scope update=%d %s", hacked.Code, hacked.Body.String())
}
if got := loadCrud(t, db, "Out"); got.Name != "Out" || got.ScopeID != 2 {
t.Fatalf("out of scope row changed: %+v", got)
}
*hooks.log = nil
gone := crudCall(httpSvc, http.MethodDelete, "999999", nil, ctx)
hiddenDelete := crudCall(httpSvc, http.MethodDelete, idString(out.ID), nil, ctx)
if gone.Code != http.StatusOK || hiddenDelete.Code != http.StatusOK || deletedCount(t, gone.Body.Bytes()) != 0 || deletedCount(t, hiddenDelete.Body.Bytes()) != 0 || gone.Body.String() != hiddenDelete.Body.String() {
t.Fatalf("delete missing=%d %s hidden=%d %s", gone.Code, gone.Body.String(), hiddenDelete.Code, hiddenDelete.Body.String())
}
if len(*hooks.log) != 0 {
t.Fatalf("delete hooks ran for absent rows: %v", *hooks.log)
}
if got := loadCrud(t, db, "Out"); got.ScopeID != 2 {
t.Fatalf("out of scope delete removed %+v", got)
}
}
func TestCRUDHooks(t *testing.T) {
svc, _, cc, _, hooks := hookFixture(t)
ctx := principalCtx(hooks, superUser())
rec, err := svc.Create(ctx, cc, RecordInput{Body: map[string]any{"name": "Ada", "note": "n"}})
if err != nil {
t.Fatalf("create err=%v", err)
}
if got := strings.Join(*hooks.log, ","); got != "before_validate,form_before_create,before_save,before_create,after_create,after_save,form_after_create" {
t.Fatalf("create hooks=%s", got)
}
if countHooks(*hooks.log, "form_before_create") != 1 || countHooks(*hooks.log, "before_create") != 1 || countHooks(*hooks.log, "form_after_create") != 1 {
t.Fatalf("create hook repeated: %v", *hooks.log)
}
*hooks.log = nil
if _, err := svc.Update(ctx, cc, rec["id"], RecordInput{Body: map[string]any{"name": "Bea"}}); err != nil {
t.Fatalf("update err=%v", err)
}
if got := strings.Join(*hooks.log, ","); got != "before_validate,form_before_update,before_save,before_update,after_update,after_save,form_after_update" {
t.Fatalf("update hooks=%s", got)
}
*hooks.log = nil
if _, err := svc.Delete(ctx, cc, rec["id"]); err != nil {
t.Fatalf("delete err=%v", err)
}
if got := strings.Join(*hooks.log, ","); got != "form_before_delete,before_delete,after_delete,form_after_delete" {
t.Fatalf("delete hooks=%s", got)
}
}
func TestCRUDRollback(t *testing.T) {
svc, httpSvc, cc, db, hooks := hookFixture(t)
ctx := principalCtx(hooks, superUser())
hooks.fail = "form_after_create"
_, err := svc.Create(ctx, cc, RecordInput{Body: map[string]any{"name": "Ada"}})
if err == nil {
t.Fatal("after-create hook failure was ignored")
}
if strings.Contains(err.Error(), "secret-hook-boom") || !strings.Contains(err.Error(), "acme.demo.records") {
t.Fatalf("hook error=%v, want opaque controller context", err)
}
rec := httptest.NewRecorder()
writeCRUDError(rec, err)
if rec.Code != http.StatusInternalServerError || strings.Contains(rec.Body.String(), "secret-hook-boom") || strings.Contains(rec.Body.String(), "acme.demo.records") {
t.Fatalf("http hook error=%d %s", rec.Code, rec.Body.String())
}
if n := countCrud(t, db); n != 0 {
t.Fatalf("rows=%d after failed create", n)
}
hooks.fail = ""
created, err := svc.Create(ctx, cc, RecordInput{Body: map[string]any{"name": "Ada"}})
if err != nil {
t.Fatal(err)
}
hooks.fail = "form_before_update"
if _, err := svc.Update(ctx, cc, created["id"], RecordInput{Body: map[string]any{"name": "hacked"}}); err == nil || strings.Contains(err.Error(), "secret-hook-boom") {
t.Fatalf("update hook err=%v", err)
}
if got := loadCrud(t, db, "Ada"); got.Name != "Ada" {
t.Fatalf("failed update committed %+v", got)
}
hooks.fail = "form_after_update"
if _, err := svc.Update(ctx, cc, created["id"], RecordInput{Body: map[string]any{"note": "later"}}); err == nil {
t.Fatal("after-update hook failure was ignored")
}
if got := loadCrud(t, db, "Ada"); got.Note != "n" && got.Note != "" {
t.Fatalf("failed after-update committed note %q", got.Note)
}
if got := loadCrud(t, db, "Ada"); got.Note == "later" {
t.Fatal("after-update wrote note")
}
*hooks.log = nil
failCtx := context.WithValue(ctx, failDeleteKey{}, true)
if _, err := svc.Delete(failCtx, cc, created["id"]); err == nil || strings.Contains(err.Error(), "secret-hook-boom") {
t.Fatalf("delete hook err=%v", err)
}
if !containsString(*hooks.log, "before_delete") {
t.Fatalf("delete lifecycle did not run: %v", *hooks.log)
}
if got := loadCrud(t, db, "Ada"); got.Name != "Ada" {
t.Fatalf("failed delete removed %+v", got)
}
_ = httpSvc
}
type captureRouter struct {
prefix string
mw []string
routes []string
middleware map[string][]string
}
func (c *captureRouter) Group(prefix string, middleware []string, fn func(pact.Router)) {
c.GroupRaw(prefix, middleware, fn)
}
func (c *captureRouter) GroupRaw(prefix string, middleware []string, fn func(pact.Router)) {
if c.middleware == nil {
c.middleware = map[string][]string{}
}
child := &captureRouter{
prefix: c.prefix + prefix,
mw: append(append([]string{}, c.mw...), middleware...),
middleware: c.middleware,
}
fn(child)
c.routes = append(c.routes, child.routes...)
}
func (c *captureRouter) Get(path string, _ http.HandlerFunc, _ ...string) { c.add("GET", path) }
func (c *captureRouter) Post(path string, _ http.HandlerFunc, _ ...string) {
c.add("POST", path)
}
func (c *captureRouter) Put(path string, _ http.HandlerFunc, _ ...string) { c.add("PUT", path) }
func (c *captureRouter) Patch(path string, _ http.HandlerFunc, _ ...string) {
c.add("PATCH", path)
}
func (c *captureRouter) Delete(path string, _ http.HandlerFunc, _ ...string) {
c.add("DELETE", path)
}
func (c *captureRouter) Where(string, string) {}
func (c *captureRouter) WhereIn(string, ...string) {}
func (c *captureRouter) add(method, path string) {
full := method + " " + c.prefix + path
c.routes = append(c.routes, full)
if c.middleware == nil {
c.middleware = map[string][]string{}
}
c.middleware[full] = append([]string{}, c.mw...)
}
func hookFixture(t *testing.T) (CRUDService, *service, *CompiledController, *gorm.DB, *hookController) {
t.Helper()
crudSvc, cc, db := crudFixture(t)
hooks := &hookController{log: &[]string{}}
cc.Controller = hooks
app := backpack.New(nil)
if err := app.Publish(db); err != nil {
t.Fatal(err)
}
httpSvc := &service{app: app, reg: &Registry{byID: map[string]*CompiledController{
cc.Controller.ID(): cc,
}}}
return crudSvc, httpSvc, cc, db, hooks
}
func principalCtx(hooks *hookController, principal *bouncer.Principal) context.Context {
ctx := context.WithValue(context.Background(), hookSinkKey{}, hooks.log)
if principal != nil {
ctx = bouncer.WithUser(ctx, principal)
}
return ctx
}
func superUser() *bouncer.Principal {
return &bouncer.Principal{ID: 1, IsSuperuser: true}
}
func crudCall(svc *service, method, id string, body []byte, ctx context.Context) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, "/", bytes.NewReader(body))
req.SetPathValue("vendor", "acme")
req.SetPathValue("plugin", "demo")
req.SetPathValue("controller", "records")
if id != "" {
req.SetPathValue("id", id)
}
if ctx != nil {
req = req.WithContext(ctx)
}
rec := httptest.NewRecorder()
switch method {
case http.MethodPost:
svc.create(rec, req)
case http.MethodGet:
svc.show(rec, req)
case http.MethodPut:
svc.update(rec, req)
case http.MethodDelete:
svc.deleteRecord(rec, req)
default:
rec.Code = http.StatusNotImplemented
}
return rec
}
func decodeData(t *testing.T, raw []byte) map[string]any {
t.Helper()
var body struct {
Data map[string]any `json:"data"`
Meta map[string]any `json:"meta"`
}
if err := jsonUnmarshal(raw, &body); err != nil {
t.Fatalf("json: %v body=%s", err, raw)
}
if body.Meta == nil {
t.Fatalf("meta is null: %s", raw)
}
return body.Data
}
func deletedCount(t *testing.T, raw []byte) float64 {
t.Helper()
var body struct {
Data struct {
Deleted float64 `json:"deleted"`
} `json:"data"`
}
if err := jsonUnmarshal(raw, &body); err != nil {
t.Fatalf("json: %v body=%s", err, raw)
}
return body.Data.Deleted
}
func jsonUnmarshal(raw []byte, dest any) error {
return json.Unmarshal(raw, dest)
}
func uintString(id any) string {
switch n := id.(type) {
case uint:
return idString(n)
case float64:
return idString(uint(n))
case int:
return idString(uint(n))
default:
return ""
}
}
func idString(id uint) string {
return strconv.FormatUint(uint64(id), 10)
}
func countHooks(log []string, name string) int {
n := 0
for _, item := range log {
if item == name {
n++
}
}
return n
}
func containsString(items []string, want string) bool {
for _, item := range items {
if item == want {
return true
}
}
return false
}

View File

@@ -75,10 +75,8 @@ func (crudRow) Fillable() []string { return []string{"name", "note"} }
func (crudRow) Rules() map[string]string { return crudRuleSet } func (crudRow) Rules() map[string]string { return crudRuleSet }
func (r *crudRow) BeforeValidate(tx *gorm.DB) error { func (r *crudRow) BeforeValidate(tx *gorm.DB) error {
ctx := context.Background() ctx := txContext(tx)
if tx != nil && tx.Statement != nil && tx.Statement.Context != nil { recordHook(ctx, "before_validate")
ctx = tx.Statement.Context
}
if clear, _ := ctx.Value(clearNameKey{}).(bool); clear { if clear, _ := ctx.Value(clearNameKey{}).(bool); clear {
if r.Name == "" { if r.Name == "" {
return errors.New("fill did not run before validate") return errors.New("fill did not run before validate")
@@ -88,6 +86,71 @@ func (r *crudRow) BeforeValidate(tx *gorm.DB) error {
return nil return nil
} }
func (r *crudRow) BeforeSave(tx *gorm.DB) error {
recordHook(txContext(tx), "before_save")
return nil
}
func (r *crudRow) BeforeCreate(tx *gorm.DB) error {
recordHook(txContext(tx), "before_create")
return nil
}
func (r *crudRow) AfterCreate(tx *gorm.DB) error {
recordHook(txContext(tx), "after_create")
return nil
}
func (r *crudRow) AfterSave(tx *gorm.DB) error {
recordHook(txContext(tx), "after_save")
return nil
}
func (r *crudRow) BeforeUpdate(tx *gorm.DB) error {
recordHook(txContext(tx), "before_update")
return nil
}
func (r *crudRow) AfterUpdate(tx *gorm.DB) error {
recordHook(txContext(tx), "after_update")
return nil
}
func (r *crudRow) BeforeDelete(tx *gorm.DB) error {
ctx := txContext(tx)
recordHook(ctx, "before_delete")
if fail, _ := ctx.Value(failDeleteKey{}).(bool); fail {
return errors.New("secret-hook-boom")
}
return nil
}
func (r *crudRow) AfterDelete(tx *gorm.DB) error {
recordHook(txContext(tx), "after_delete")
return nil
}
type hookSinkKey struct{}
type failDeleteKey struct{}
func txContext(tx *gorm.DB) context.Context {
if tx != nil && tx.Statement != nil && tx.Statement.Context != nil {
return tx.Statement.Context
}
return context.Background()
}
func recordHook(ctx context.Context, name string) {
if ctx == nil {
return
}
sink, _ := ctx.Value(hookSinkKey{}).(*[]string)
if sink == nil {
return
}
*sink = append(*sink, name)
}
type crudNoRules struct { type crudNoRules struct {
ID uint `gorm:"column:id;primaryKey"` ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"` Name string `gorm:"column:name"`

View File

@@ -165,6 +165,22 @@ func (s *service) translator() *phrasebook.Translator {
return tr return tr
} }
func (s *service) show(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
}
func (s *service) create(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
}
func (s *service) update(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
}
func (s *service) deleteRecord(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
}
func (s *service) list(w http.ResponseWriter, r *http.Request) { func (s *service) list(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) { s.protect(w, r, func(cc *CompiledController) {
db, err := s.db() db, err := s.db()

View File

@@ -207,6 +207,26 @@ type FormBeforeUpdate interface {
FormBeforeUpdate(ctx context.Context, model any) error FormBeforeUpdate(ctx context.Context, model any) error
} }
// FormAfterCreate optionally runs after insert, inside the same transaction.
type FormAfterCreate interface {
FormAfterCreate(ctx context.Context, model any) error
}
// FormAfterUpdate optionally runs after update, inside the same transaction.
type FormAfterUpdate interface {
FormAfterUpdate(ctx context.Context, model any) error
}
// FormBeforeDelete optionally rejects a record before delete.
type FormBeforeDelete interface {
FormBeforeDelete(ctx context.Context, model any) error
}
// FormAfterDelete optionally runs after delete, inside the same transaction.
type FormAfterDelete interface {
FormAfterDelete(ctx context.Context, model any) error
}
// RelationExtendManageQuery optionally narrows relation-manager candidates. // RelationExtendManageQuery optionally narrows relation-manager candidates.
type RelationExtendManageQuery interface { type RelationExtendManageQuery interface {
RelationExtendManageQuery(ctx context.Context, relation string, db *gorm.DB) *gorm.DB RelationExtendManageQuery(ctx context.Context, relation string, db *gorm.DB) *gorm.DB