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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user