feat(12.1-02): writable foreign keys, locked relation options, invisible columns
- FieldRelationContract.WritableForeignKey makes a belongsTo field over a protected foreign key writable; the protected key list is unchanged - cabana.RelationLockProvider names related ids an administrator may not add or remove: options and labels carry locked, and a create or update that changes the locked subset is 403 before any row is written - columns.yaml invisible keeps a column searchable and out of the rows - a controller implementing pact.FilterOptions serves a scope filter's choices before the model - SPA: locked chips and options in RelationField, DataTable skips invisible columns - README, docs, OpenAPI document, TS types and dist updated
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/http"
|
||||
"reflect"
|
||||
@@ -39,6 +40,31 @@ type FieldRelationContract struct {
|
||||
// means the field's nameFrom, mapped through pact.ListRelationColumnMapper
|
||||
// when the controller implements it.
|
||||
LabelColumn string
|
||||
// WritableForeignKey declares a belongsTo foreign key that is a protected
|
||||
// fill key writable through this relation field.
|
||||
WritableForeignKey bool
|
||||
}
|
||||
|
||||
// RelationLock names the related records of one relation field that the
|
||||
// requesting administrator may not add or remove. IDs are related primary
|
||||
// keys. Message is a phrase key or text for the 403 a refused save answers;
|
||||
// it may be empty, and the admin then shows its own text.
|
||||
type RelationLock struct {
|
||||
IDs []uint
|
||||
Message string
|
||||
}
|
||||
|
||||
// RelationLockProvider is implemented by an admin controller that locks some
|
||||
// choices of its `type: relation` fields for some administrators (D-07). The
|
||||
// framework asks it per request with the request context: the principal is
|
||||
// read with bouncer.User, and inside a save the write transaction with
|
||||
// TxFromContext. The lock is enforced on save: a create or update whose
|
||||
// submitted value adds or removes a locked id, or replaces a belongsTo value
|
||||
// that is locked or by one that is locked, is answered 403 and writes
|
||||
// nothing. The `locked` flag on options and labels is a display aid only. A
|
||||
// field with no locks returns the zero RelationLock.
|
||||
type RelationLockProvider interface {
|
||||
AdminRelationLocks(ctx context.Context, field string) (RelationLock, error)
|
||||
}
|
||||
|
||||
// FieldRelationProvider is implemented by admin controllers whose form
|
||||
@@ -48,10 +74,13 @@ type FieldRelationProvider interface {
|
||||
}
|
||||
|
||||
// RelationOption is one relation choice or label: the related primary key and
|
||||
// its label column value.
|
||||
// its label column value. Locked is true when the controller's
|
||||
// RelationLockProvider names the record for the requesting administrator; the
|
||||
// key is omitted otherwise.
|
||||
type RelationOption struct {
|
||||
Value uint `json:"value"`
|
||||
Label string `json:"label"`
|
||||
Value uint `json:"value"`
|
||||
Label string `json:"label"`
|
||||
Locked bool `json:"locked,omitempty"`
|
||||
}
|
||||
|
||||
// RecordMeta is the record envelope meta: display labels per relation field,
|
||||
@@ -84,8 +113,8 @@ type CompiledFieldRelation struct {
|
||||
// Multiple is true for belongsToMany.
|
||||
Multiple bool
|
||||
// ReadOnly is true for a belongsTo whose foreign key is a protected fill
|
||||
// key (D-26): it is shown with its label but never written and has no
|
||||
// options endpoint.
|
||||
// key (D-26) and whose contract does not set WritableForeignKey: it is
|
||||
// shown with its label but never written and has no options endpoint.
|
||||
ReadOnly bool
|
||||
// Nullable is true when a belongsTo foreign key accepts null.
|
||||
Nullable bool
|
||||
@@ -199,8 +228,11 @@ func compileFieldRelation(ctl pact.AdminController, field FormField, contract Fi
|
||||
return nil, fmt.Errorf("foreign key %s must be an unsigned integer column", contract.ForeignKey)
|
||||
}
|
||||
out.Nullable = fk.Type.Kind() == reflect.Pointer
|
||||
out.ReadOnly = protectedFillKey(contract.ForeignKey)
|
||||
out.ReadOnly = protectedFillKey(contract.ForeignKey) && !contract.WritableForeignKey
|
||||
case relationKindBelongsToMany:
|
||||
if contract.WritableForeignKey {
|
||||
return nil, errors.New("WritableForeignKey is only valid on belongsTo")
|
||||
}
|
||||
if contract.ForeignKey != "" {
|
||||
return nil, errors.New("belongsToMany cannot declare a ForeignKey")
|
||||
}
|
||||
@@ -337,6 +369,11 @@ func (s CRUDService) RelationOptions(ctx context.Context, cc *CompiledController
|
||||
if err != nil {
|
||||
return nil, ListMeta{}, lifecycleFailure(cc, err)
|
||||
}
|
||||
locked, _, err := relationLocks(ctx, cc, field)
|
||||
if err != nil {
|
||||
return nil, ListMeta{}, err
|
||||
}
|
||||
markLocked(rows, locked)
|
||||
last := 1
|
||||
if total > 0 {
|
||||
last = int((total + int64(per) - 1) / int64(per))
|
||||
@@ -344,6 +381,115 @@ func (s CRUDService) RelationOptions(ctx context.Context, cc *CompiledController
|
||||
return rows, ListMeta{Page: page, PerPage: per, Total: total, LastPage: last}, nil
|
||||
}
|
||||
|
||||
// relationLocks asks the controller's RelationLockProvider for the locked
|
||||
// related ids of field, as a set, and the lock's message. A controller
|
||||
// without the provider has no locks. A provider error is a lifecycle failure.
|
||||
func relationLocks(ctx context.Context, cc *CompiledController, field string) (map[uint]bool, string, error) {
|
||||
if cc == nil || cc.Controller == nil {
|
||||
return nil, "", nil
|
||||
}
|
||||
provider, ok := cc.Controller.(RelationLockProvider)
|
||||
if !ok || provider == nil {
|
||||
return nil, "", nil
|
||||
}
|
||||
lock, err := provider.AdminRelationLocks(ctx, field)
|
||||
if err != nil {
|
||||
slog.Error("cabana: relation locks failed", "controller", controllerID(cc), "field", field, "error", err)
|
||||
return nil, "", lifecycleFailure(cc, err)
|
||||
}
|
||||
if len(lock.IDs) == 0 {
|
||||
return nil, lock.Message, nil
|
||||
}
|
||||
set := make(map[uint]bool, len(lock.IDs))
|
||||
for _, id := range lock.IDs {
|
||||
set[id] = true
|
||||
}
|
||||
return set, lock.Message, nil
|
||||
}
|
||||
|
||||
// markLocked flags the options whose id is in locked.
|
||||
func markLocked(options []RelationOption, locked map[uint]bool) {
|
||||
if len(locked) == 0 {
|
||||
return
|
||||
}
|
||||
for i := range options {
|
||||
if locked[options[i].Value] {
|
||||
options[i].Locked = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkRelationLocks refuses a save whose relation values change what the
|
||||
// controller's RelationLockProvider locks for the requesting administrator
|
||||
// (D-07; T-12.1-12). It runs inside the save's transaction after the scope
|
||||
// check and before any row or pivot write, on create and on update. For a
|
||||
// belongsToMany value the locked subset of the parent's current pivot rows
|
||||
// (none on create) and of the submitted ids must be equal as sets. For a
|
||||
// belongsTo value a change is refused when the current or the submitted id is
|
||||
// locked. Only relation fields present in the body are checked: an absent
|
||||
// field changes nothing. The refusal is a ForbiddenError (403) with the
|
||||
// lock's message, also on the field.
|
||||
func checkRelationLocks(ctx context.Context, tx *gorm.DB, cc *CompiledController, model any, values []relationValue) error {
|
||||
for _, value := range values {
|
||||
locked, message, err := relationLocks(ctx, cc, value.field)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(locked) == 0 {
|
||||
continue
|
||||
}
|
||||
c := value.fr.Contract
|
||||
changed := false
|
||||
if value.fr.Multiple {
|
||||
var current []uint
|
||||
if parentPK := pkUint(model); parentPK != 0 {
|
||||
err := tx.WithContext(ctx).Model(c.NewPivot()).
|
||||
Where(clause.Eq{Column: clause.Column{Name: c.ParentForeignKey}, Value: parentPK}).
|
||||
Pluck(c.RelatedForeignKey, ¤t).Error
|
||||
if err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
}
|
||||
before, after := map[uint]bool{}, map[uint]bool{}
|
||||
for _, id := range current {
|
||||
if locked[id] {
|
||||
before[id] = true
|
||||
}
|
||||
}
|
||||
for _, id := range value.ids {
|
||||
if locked[id] {
|
||||
after[id] = true
|
||||
}
|
||||
}
|
||||
changed = len(before) != len(after)
|
||||
for id := range before {
|
||||
if !after[id] {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
v := reflect.ValueOf(model)
|
||||
for v.Kind() == reflect.Pointer {
|
||||
v = v.Elem()
|
||||
}
|
||||
current, _ := foreignKeyValue(v, c.ForeignKey)
|
||||
var submitted uint
|
||||
if !value.null && len(value.ids) > 0 {
|
||||
submitted = value.ids[0]
|
||||
}
|
||||
changed = current != submitted && (locked[current] || locked[submitted])
|
||||
}
|
||||
if changed {
|
||||
detail := message
|
||||
if detail == "" {
|
||||
detail = "backend::lang.form.forbidden"
|
||||
}
|
||||
return &ForbiddenError{Message: message, Details: map[string]any{value.field: []string{detail}}}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// relationValue is one present, writable relation key lifted from a body.
|
||||
type relationValue struct {
|
||||
field string
|
||||
@@ -479,10 +625,13 @@ func checkRelationScope(ctx context.Context, tx *gorm.DB, cc *CompiledController
|
||||
}
|
||||
|
||||
// assignBelongsTo writes validated belongsTo ids (or null) onto the parent's
|
||||
// foreign key before the row write. Read-only keys never reach this point.
|
||||
// foreign key before the row write. Read-only keys never reach this point: a
|
||||
// protected foreign key is written only when its contract declares
|
||||
// WritableForeignKey, the same rule compileFieldRelation applies.
|
||||
func assignBelongsTo(cc *CompiledController, model any, values []relationValue) error {
|
||||
for _, value := range values {
|
||||
if value.fr.Multiple || value.fr.ReadOnly || protectedFillKey(value.fr.Contract.ForeignKey) {
|
||||
c := value.fr.Contract
|
||||
if value.fr.Multiple || value.fr.ReadOnly || (protectedFillKey(c.ForeignKey) && !c.WritableForeignKey) {
|
||||
continue
|
||||
}
|
||||
var id any
|
||||
@@ -577,6 +726,13 @@ func projectRelationFields(ctx context.Context, tx *gorm.DB, cc *CompiledControl
|
||||
if err != nil {
|
||||
return RecordMeta{}, lifecycleFailure(cc, err)
|
||||
}
|
||||
if len(labels) > 0 {
|
||||
locked, _, err := relationLocks(withTx(ctx, tx), cc, name)
|
||||
if err != nil {
|
||||
return RecordMeta{}, err
|
||||
}
|
||||
markLocked(labels, locked)
|
||||
}
|
||||
meta.Labels[name] = labels
|
||||
}
|
||||
return meta, nil
|
||||
|
||||
Reference in New Issue
Block a user