feat(12.2-03): add hasMany relation contracts, relation forms and child create
- RelationContract gains Kind (empty is belongsToMany) and ForeignKey, with kind-aware boot checks
- manage.form, view.form and pivot.form compile against the related or pivot model; $/ paths resolve inside the plugin
- view toolbarButtons accept create|update|delete|link|unlink, each the capability of its routes
- POST .../relations/{name}/records creates a child through the manage form; the server sets the hasMany key
- relation schema carries kind, deferrable and the localized forms; 17 new relation message keys in en and pl
This commit is contained in:
223
modules/cabana/relation_child.go
Normal file
223
modules/cabana/relation_child.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// relationParent is the parent record of a relation route, loaded through
|
||||
// FormExtendQuery.
|
||||
type relationParent struct {
|
||||
model any
|
||||
id uint
|
||||
}
|
||||
|
||||
// loadParent loads the parent record of a relation route through
|
||||
// loadRecord (FormExtendQuery, FOR UPDATE). A missing or hidden parent, and
|
||||
// id 0, are recordNotFound.
|
||||
func (s RelationService) loadParent(ctx context.Context, tx *gorm.DB, cc *CompiledController, ownerID uint) (*relationParent, error) {
|
||||
parent, err := newWritableModel(cc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ownerID == 0 {
|
||||
return nil, recordNotFound{}
|
||||
}
|
||||
if err := loadRecord(ctx, tx, cc, parent, castPK(parent, ownerID)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &relationParent{model: parent, id: ownerID}, nil
|
||||
}
|
||||
|
||||
// fillChild fills and validates a child of a relation form like the
|
||||
// controller save does: the body is projected through the form's writable
|
||||
// fields for op, filled with lagoon.Fill, checked by BeforeValidate, the
|
||||
// model rules merged with the form's required flags, and the datepicker
|
||||
// bounds. A failure is a 422 ValidationError.
|
||||
func (s RelationService) fillChild(ctx context.Context, tx *gorm.DB, form *CompiledController, model any, body map[string]any, op string) error {
|
||||
projected := projectOperation(form, body, op)
|
||||
if err := lagoon.Fill(model, fillAllowed(form, model, op), projected, false); err != nil {
|
||||
var typed *lagoon.FillTypeError
|
||||
if errors.As(err, &typed) {
|
||||
return &ValidationError{Details: fillTypeDetails(typed.Key)}
|
||||
}
|
||||
return &CapabilityError{ControllerID: controllerID(form)}
|
||||
}
|
||||
if hook, ok := model.(lagoon.HasBeforeValidate); ok && hook != nil {
|
||||
if err := hook.BeforeValidate(tx); err != nil {
|
||||
return &CapabilityError{ControllerID: controllerID(form)}
|
||||
}
|
||||
}
|
||||
rules := mergedRules(form, model, op)
|
||||
msgs, err := lagoon.Validate(ctx, tx, model, rules, valuesForRules(model, rules), nil)
|
||||
if err != nil {
|
||||
return &CapabilityError{ControllerID: controllerID(form)}
|
||||
}
|
||||
for field, extra := range dateBoundDetails(ctx, s.tr, form, model, op) {
|
||||
if msgs == nil {
|
||||
msgs = map[string][]string{}
|
||||
}
|
||||
msgs[field] = append(msgs[field], extra...)
|
||||
}
|
||||
if len(msgs) > 0 {
|
||||
return &ValidationError{Details: validationDetails(msgs)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateChild creates a related record through the relation's manage form
|
||||
// (D-11, D-16) and attaches it to the parent: a hasMany child gets the
|
||||
// parent's key in its ForeignKey (set by the server, never from the body),
|
||||
// a belongsToMany record gets a pivot row (RelationBeforeLink stamps its
|
||||
// hook columns). The parent is loaded through FormExtendQuery. The child is
|
||||
// filled and validated like a controller save, and the controller's
|
||||
// optional pact.RelationBeforeCreate and pact.RelationAfterCreate hooks run
|
||||
// around the insert; any failure rolls the whole create back.
|
||||
func (s RelationService) CreateChild(ctx context.Context, cc *CompiledController, relation string, ownerID uint, in RecordInput) (RecordResult, error) {
|
||||
if s.DB == nil {
|
||||
return RecordResult{}, errors.New("cabana: database is not configured")
|
||||
}
|
||||
cr, err := relationOf(cc, relation)
|
||||
if err != nil {
|
||||
return RecordResult{}, err
|
||||
}
|
||||
if cr.child == nil {
|
||||
return RecordResult{}, &CapabilityError{ControllerID: controllerID(cc)}
|
||||
}
|
||||
var result RecordResult
|
||||
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
|
||||
ctx = withTx(ctx, tx)
|
||||
parent, err := s.loadParent(ctx, tx, cc, ownerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
child, err := newWritableModel(cr.child)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cr.hasMany() {
|
||||
if err := setModelColumn(child, cr.Contract.ForeignKey, parent.id); err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
}
|
||||
if err := s.fillChild(ctx, tx, cr.child, child, in.Body, "create"); err != nil {
|
||||
return err
|
||||
}
|
||||
if hook, ok := cc.Controller.(pact.RelationBeforeCreate); ok && hook != nil {
|
||||
if err := hook.RelationBeforeCreate(ctx, cr.Contract.Name, parent.model, child); err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(child).Error; err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
if !cr.hasMany() {
|
||||
if err := insertPivot(ctx, tx, cc, cr, parent.model, child, nil); err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
}
|
||||
if hook, ok := cc.Controller.(pact.RelationAfterCreate); ok && hook != nil {
|
||||
if err := hook.RelationAfterCreate(ctx, cr.Contract.Name, parent.model, child); err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
}
|
||||
result, err = projectFullRecord(ctx, tx, cr.child, child)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return RecordResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// relationButton resolves the route's relation and refuses (403) a route
|
||||
// whose toolbar button the view panel does not declare. An unknown relation
|
||||
// is 404. It writes the response and returns nil on refusal.
|
||||
func (s *service) relationButton(w http.ResponseWriter, r *http.Request, cc *CompiledController, button string) *CompiledRelation {
|
||||
cr, err := relationOf(cc, r.PathValue("name"))
|
||||
if err != nil {
|
||||
writeCRUDError(w, err)
|
||||
return nil
|
||||
}
|
||||
if !cr.allows(button) {
|
||||
if principal, _ := bouncer.User(r.Context()); principal != nil {
|
||||
s.logAuth(r, "denied", principal.ID)
|
||||
}
|
||||
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
|
||||
return nil
|
||||
}
|
||||
return cr
|
||||
}
|
||||
|
||||
// decodeCappedObject decodes a JSON object body capped at
|
||||
// http.body_limits.default_bytes; trailing data is refused.
|
||||
func (s *service) decodeCappedObject(w http.ResponseWriter, r *http.Request) (map[string]any, error) {
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, s.jsonCap()))
|
||||
dec.UseNumber()
|
||||
var body map[string]any
|
||||
if err := dec.Decode(&body); err != nil {
|
||||
var tooBig *http.MaxBytesError
|
||||
if errors.As(err, &tooBig) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, invalidBody()
|
||||
}
|
||||
var trailing any
|
||||
if err := dec.Decode(&trailing); err != io.EOF {
|
||||
return nil, invalidBody()
|
||||
}
|
||||
if body == nil {
|
||||
body = map[string]any{}
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// writeRelationError maps a relation child route failure: a body past the
|
||||
// cap is 413 payload_too_large, everything else as writeCRUDError.
|
||||
func writeRelationError(w http.ResponseWriter, err error) {
|
||||
var tooBig *http.MaxBytesError
|
||||
if errors.As(err, &tooBig) {
|
||||
WriteError(w, http.StatusRequestEntityTooLarge, "payload_too_large", msgPayloadTooLarge)
|
||||
return
|
||||
}
|
||||
writeCRUDError(w, err)
|
||||
}
|
||||
|
||||
// relationChildCreate serves POST .../{id}/relations/{name}/records.
|
||||
func (s *service) relationChildCreate(w http.ResponseWriter, r *http.Request) {
|
||||
s.protect(w, r, func(cc *CompiledController) {
|
||||
cr := s.relationButton(w, r, cc, "create")
|
||||
if cr == nil {
|
||||
return
|
||||
}
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
}
|
||||
body, err := s.decodeCappedObject(w, r)
|
||||
if err != nil {
|
||||
writeRelationError(w, err)
|
||||
return
|
||||
}
|
||||
svc, err := s.relations()
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
rec, err := svc.CreateChild(r.Context(), cc, cr.Contract.Name, id, RecordInput{Body: body})
|
||||
if err != nil {
|
||||
writeRelationError(w, err)
|
||||
return
|
||||
}
|
||||
WriteData(w, http.StatusCreated, rec.Data, rec.Meta)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user