feat(10-02): relation field options and relation saves with labels

- FieldRelationContract/FieldRelationProvider bind every type: relation
  field to a belongsTo foreign key or a belongsToMany pivot; activation
  fails naming plugin, controller and field on a missing or broken contract
- GET /{vendor}/{plugin}/{controller}/fields/{field}/options serves
  {value, label} pages scoped by pact.RelationExtendOptionsQuery, behind
  the controller permission; read-only and non-relation fields are 404
- Saves apply present relation keys after the Before hook: ids are
  revalidated through the same scoped query (422 and full rollback
  otherwise), belongsTo sets the foreign key, belongsToMany replaces pivot
  rows in submitted order with the order column set to the index
- Show, create and update return relation values in data and meta.labels
- A belongsTo on a protected fill key is read-only (D-26)
- One six-segment GET pattern dispatches relation lists and field options,
  which ServeMux cannot register side by side
- Admin OpenAPI documents the options route and RecordEnvelope
This commit is contained in:
Jakub Zych
2026-09-27 16:00:52 +02:00
parent e9b48d4720
commit fe04dbc89e
14 changed files with 1969 additions and 102 deletions

View File

@@ -119,11 +119,23 @@ func BindWritableFields(cc *CompiledController) error {
// Create persists a projected record after Fill and Validate.
func (s CRUDService) Create(ctx context.Context, cc *CompiledController, in RecordInput) (map[string]any, error) {
return s.save(ctx, cc, nil, in, false)
res, err := s.save(ctx, cc, nil, in, false)
return res.Data, err
}
// Update persists a projected change after Fill and Validate.
func (s CRUDService) Update(ctx context.Context, cc *CompiledController, id any, in RecordInput) (map[string]any, error) {
res, err := s.save(ctx, cc, id, in, true)
return res.Data, err
}
// CreateRecord is Create plus the relation labels of the saved record (D-18).
func (s CRUDService) CreateRecord(ctx context.Context, cc *CompiledController, in RecordInput) (RecordResult, error) {
return s.save(ctx, cc, nil, in, false)
}
// UpdateRecord is Update plus the relation labels of the saved record (D-18).
func (s CRUDService) UpdateRecord(ctx context.Context, cc *CompiledController, id any, in RecordInput) (RecordResult, error) {
return s.save(ctx, cc, id, in, true)
}
@@ -226,13 +238,22 @@ func (s CRUDService) BulkDelete(ctx context.Context, cc *CompiledController, in
// Show loads one scoped record. Missing and out-of-scope ids are identical.
func (s CRUDService) Show(ctx context.Context, cc *CompiledController, id any) (map[string]any, error) {
res, err := s.ShowRecord(ctx, cc, id)
if err != nil {
return nil, err
}
return res.Data, nil
}
// ShowRecord is Show plus the relation labels of the record (D-18).
func (s CRUDService) ShowRecord(ctx context.Context, cc *CompiledController, id any) (RecordResult, error) {
if s.DB == nil {
return nil, errors.New("cabana: database is not configured")
return RecordResult{}, errors.New("cabana: database is not configured")
}
if ctx == nil {
ctx = context.Background()
}
var result map[string]any
var result RecordResult
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
target, err := newWritableModel(cc)
@@ -246,35 +267,54 @@ func (s CRUDService) Show(ctx context.Context, cc *CompiledController, id any) (
if err := loadRecord(ctx, tx, cc, target, pk); err != nil {
return err
}
result = projectRecord(cc, target)
return nil
result, err = projectFullRecord(ctx, tx, cc, target)
return err
})
if err != nil {
return nil, err
return RecordResult{}, err
}
return result, nil
}
func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, in RecordInput, update bool) (map[string]any, error) {
// projectFullRecord is the D-18 record shape: scalar writable fields, relation
// values keyed by field name, and their labels.
func projectFullRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController, model any) (RecordResult, error) {
data := projectRecord(cc, model)
meta, err := projectRelationFields(ctx, tx, cc, model, data)
if err != nil {
return RecordResult{}, err
}
return RecordResult{Data: data, Meta: meta}, nil
}
func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, in RecordInput, update bool) (RecordResult, error) {
if s.DB == nil {
return nil, errors.New("cabana: database is not configured")
return RecordResult{}, errors.New("cabana: database is not configured")
}
if ctx == nil {
ctx = context.Background()
}
if _, err := newWritableModel(cc); err != nil {
return nil, err
return RecordResult{}, err
}
var result map[string]any
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
op := "create"
if update {
op = "update"
}
// Writable relation keys are lifted before scalar projection (which drops
// every nested value); only keys present in the body are applied.
relations, err := liftRelationValues(cc, in.Body, op)
if err != nil {
return RecordResult{}, err
}
var result RecordResult
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
target, err := newWritableModel(cc)
if err != nil {
return err
}
op := "create"
if update {
op = "update"
pk, err := coercePK(target, id)
if err != nil {
return err
@@ -301,19 +341,32 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
return &ValidationError{Details: validationDetails(msgs)}
}
if update {
if err := formBeforeUpdate(ctx, cc, target); err != nil {
return err
}
err = formBeforeUpdate(ctx, cc, target)
} else {
err = formBeforeCreate(ctx, cc, target)
}
if err != nil {
return err
}
// D-18: submitted ids pass the same scoped query as the options
// endpoint; belongsTo keys land before the row write, pivot rows after.
if err := checkRelationScope(ctx, tx, cc, relations); err != nil {
return err
}
if err := assignBelongsTo(cc, target, relations); err != nil {
return err
}
if update {
err = tx.Save(target).Error
} else {
if err := formBeforeCreate(ctx, cc, target); err != nil {
return err
}
err = tx.Create(target).Error
}
if err != nil {
return lifecycleFailure(cc, err)
}
if err := syncBelongsToMany(ctx, tx, cc, target, relations); err != nil {
return err
}
if update {
err = formAfterUpdate(ctx, cc, target)
} else {
@@ -322,11 +375,11 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
if err != nil {
return err
}
result = projectRecord(cc, target)
return nil
result, err = projectFullRecord(ctx, tx, cc, target)
return err
})
if err != nil {
return nil, err
return RecordResult{}, err
}
return result, nil
}