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:
Jakub Zych
2026-10-05 10:58:38 +02:00
parent f50d9b8f10
commit df5cace852
39 changed files with 1115 additions and 84 deletions

View File

@@ -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, &current).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