feat(12.2-03): defer relation work on unsaved records and add child file routes

- record id 0 with X-Session-Key manages deferrable relations: create, link, unlink, delete and pivot edits are held in deferred_bindings
- the record's create save applies relation bindings with the file bindings; an ineligible link is a 422 on the relation-manager field
- child forms upload files through .../records/{child}/files/{field} keyed by X-Child-Session-Key; the child save commits them
- boot refuses a deferrable relation with create whose related model no plugin lists in Models()
This commit is contained in:
Jakub Zych
2026-10-02 19:08:16 +02:00
parent afb05b6ee4
commit fe9e8baaf1
16 changed files with 3643 additions and 436 deletions

View File

@@ -108,6 +108,13 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
if err := checkFileLimits(reg, uploadBytes); err != nil {
return nil, err
}
var gdb *gorm.DB
if app != nil {
gdb, _ = app.Lookup[*gorm.DB]()
}
if err := checkDeferredModels(reg, plugins, gdb); err != nil {
return nil, err
}
if err := compileContributions(reg, plugins); err != nil {
return nil, err
}
@@ -291,6 +298,23 @@ func (s *service) mount(r pact.Router) {
constrainChild(g)
g.Put("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}", requireAjax(s.relationPivotUpdate))
constrainChild(g)
// File routes of a relation child form (D-17): {child} 0 is the
// child being created in the X-Child-Session-Key session.
const childFiles = "/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}"
g.Get(childFiles, s.relationChildFileList)
constrainChildFile(g)
g.Post(childFiles, requireAjax(s.relationChildFileUpload))
constrainChildFile(g)
g.Post(childFiles+"/reorder", requireAjax(s.relationChildFileReorder))
constrainChildFile(g)
g.Put(childFiles+"/{file}", requireAjax(s.relationChildFileUpdate))
constrainChildFileID(g)
g.Delete(childFiles+"/{file}", requireAjax(s.relationChildFileRemove))
constrainChildFileID(g)
g.Get(childFiles+"/{file}/download", s.relationChildFileDownload)
constrainChildFileID(g)
g.Get(childFiles+"/{file}/thumb", s.relationChildFileThumb)
constrainChildFileID(g)
// File routes of `type: fileupload` fields (D-09). {id} 0 is the
// record being created in the X-Session-Key session.
g.Post("/{vendor}/{plugin}/{controller}/{id}/files/{field}", requireAjax(s.fileUpload))
@@ -335,6 +359,16 @@ func constrainChild(g pact.Router) {
g.Where("child", "[0-9]+")
}
func constrainChildFile(g pact.Router) {
constrainChild(g)
g.Where("field", "[A-Za-z_][A-Za-z0-9_]*")
}
func constrainChildFileID(g pact.Router) {
constrainChildFile(g)
g.Where("file", "[0-9]+")
}
func constrainFile(g pact.Router) {
constrainController(g)
g.Where("field", "[A-Za-z_][A-Za-z0-9_]*")
@@ -506,9 +540,8 @@ func (s *service) relationList(w http.ResponseWriter, r *http.Request, candidate
writeCRUDError(w, err)
return
}
svc, err := s.relations()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
svc, ok := s.relationsFor(w, r)
if !ok {
return
}
var result *RelationResult
@@ -559,9 +592,8 @@ func (s *service) relationMutation(w http.ResponseWriter, r *http.Request, link
writeCRUDError(w, err)
return
}
svc, err := s.relations()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
svc, ok := s.relationsFor(w, r)
if !ok {
return
}
var result RelationMutationResult
@@ -601,6 +633,25 @@ func (s *service) relations() (RelationService, error) {
return RelationService{DB: db, bucket: s.bucket(), tr: s.translator()}, nil
}
// relationsFor is the relation service of one request, carrying its
// X-Session-Key: with it, record id 0 is the record being created in that
// session. A malformed key is a 422; it writes the response and returns
// false on failure.
func (s *service) relationsFor(w http.ResponseWriter, r *http.Request) (RelationService, bool) {
key, _, err := sessionKeyFrom(r)
if err != nil {
writeCRUDError(w, err)
return RelationService{}, false
}
svc, err := s.relations()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return RelationService{}, false
}
svc.SessionKey = key
return svc, true
}
func (s *service) formSchema(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
if cc.Form == nil {