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:
Jakub Zych
2026-10-02 18:37:13 +02:00
parent 0b4ef9c311
commit 48a5b8045a
23 changed files with 1621 additions and 100 deletions

View File

@@ -7,12 +7,14 @@ import (
"fmt"
"io/fs"
"reflect"
"slices"
"strings"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"github.com/goccy/go-yaml"
"gocloud.dev/blob"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
@@ -23,18 +25,46 @@ type AdminRelationContractProvider interface {
AdminRelationContracts() []RelationContract
}
// Relation contract kinds (RelationContract.Kind).
const (
// RelationBelongsToMany links related records through a pivot model.
// It is the kind of a contract whose Kind is empty.
RelationBelongsToMany = "belongsToMany"
// RelationHasMany owns related records through their ForeignKey column.
RelationHasMany = "hasMany"
)
// RelationContract binds one compiled relation schema to target and pivot models.
type RelationContract struct {
Name string
NewRelated func() any
NewPivot func() any
ParentForeignKey string
RelatedForeignKey string
Name string
// Kind is RelationBelongsToMany or RelationHasMany. The empty value is
// RelationBelongsToMany, so a contract written before hasMany existed
// keeps its pivot behaviour unchanged.
Kind string
NewRelated func() any
// NewPivot, ParentForeignKey, RelatedForeignKey and HookPivotColumns
// describe the pivot of a belongsToMany relation; a hasMany contract
// leaves them empty.
NewPivot func() any
ParentForeignKey string
RelatedForeignKey string
// ForeignKey is the related model's column that points at the parent
// (hasMany only). A pointer Go type (a nullable column) is needed for
// unlink and for managing the relation before the parent is saved.
ForeignKey string
Columns map[string]string
HookPivotColumns []string
ExcludedRelatedIDs func(parent any) ([]uint, error)
}
// kind is the contract's normalized kind.
func (c RelationContract) kind() string {
if c.Kind == "" {
return RelationBelongsToMany
}
return c.Kind
}
// RelationColumn is one source-ordered relation list column.
type RelationColumn struct {
Key string `json:"key"`
@@ -57,15 +87,35 @@ type RelationPanel struct {
// RelationSchema is the cached locale-neutral relation contract.
type RelationSchema struct {
Name string `json:"name"`
Label string `json:"label"`
View RelationPanel `json:"view"`
Manage RelationPanel `json:"manage"`
Name string `json:"name"`
Label string `json:"label"`
// Kind is the contract kind: belongsToMany or hasMany.
Kind string `json:"kind"`
// Deferrable is true when the relation can be managed on a record that
// is not saved yet: always for belongsToMany, and for a hasMany whose
// ForeignKey is nullable.
Deferrable bool `json:"deferrable"`
View RelationPanel `json:"view"`
Manage RelationPanel `json:"manage"`
// ManageForm is the child create and update form (manage.form, or the
// top-level form); ViewForm is the read-only preview form (view.form,
// or the top-level form); PivotForm edits pivot columns of a
// belongsToMany link (pivot.form). Each is omitted when not declared.
ManageForm []FormField `json:"manageForm,omitempty"`
ViewForm []FormField `json:"viewForm,omitempty"`
PivotForm []FormField `json:"pivotForm,omitempty"`
// Messages is the relation manager's copy (D-13); the cached schema
// carries each phrase key as its own form.
Messages *RelationMessages `json:"messages"`
messageKeys relationMessageKeys
// manageForm, viewForm and pivotForm are the compiled forms the field
// lists come from; relatedModel and pivotModel supply dropdown options.
manageForm *FormSchema
viewForm *FormSchema
pivotForm *FormSchema
relatedModel func() any
pivotModel func() any
}
// CompiledRelation combines trusted YAML with model-owned metadata.
@@ -73,6 +123,27 @@ type CompiledRelation struct {
Schema *RelationSchema
Contract RelationContract
RequiredPermissions []string
// kind is the normalized contract kind; deferrable mirrors
// Schema.Deferrable; fieldName is the relation-manager form field.
kind string
deferrable bool
fieldName string
// child is the related model's form (the manage form) compiled as a
// controller of its own: writable fields, file and date fields. view is
// the read-only form when it differs. pivot is the pivot form bound to
// the pivot model. Each is nil when not declared.
child *CompiledController
view *CompiledController
pivot *CompiledController
}
// hasMany reports whether the relation owns its children by foreign key.
func (cr *CompiledRelation) hasMany() bool { return cr != nil && cr.kind == RelationHasMany }
// allows reports whether the view panel declares the toolbar button.
func (cr *CompiledRelation) allows(button string) bool {
return cr != nil && cr.Schema != nil && slices.Contains(cr.Schema.View.ToolbarButtons, button)
}
// RelationQuery is the finite linked/candidate query contract.
@@ -102,7 +173,14 @@ type RelationMutationResult struct {
}
// RelationService executes compiled relation reads and writes.
type RelationService struct{ DB *gorm.DB }
type RelationService struct {
DB *gorm.DB
// bucket deletes the blobs of child files a save removes, after commit;
// tr localizes date bound messages. Both may be nil.
bucket *blob.Bucket
tr *phrasebook.Translator
}
func (s RelationSchema) MarshalJSON() ([]byte, error) {
if s.Messages == nil {
@@ -136,9 +214,30 @@ func (s *RelationSchema) Localize(ctx context.Context, tr *phrasebook.Translator
out.Manage = localizeRelationPanel(ctx, tr, s.Manage)
messages := localizeMessages[relationMessageKeys, RelationMessages](ctx, tr, s.relationMessageKeySet())
out.Messages = &messages
out.ManageForm = localizeRelationForm(ctx, tr, s.manageForm, s.relatedModel, s.ManageForm)
out.ViewForm = localizeRelationForm(ctx, tr, s.viewForm, s.relatedModel, s.ViewForm)
out.PivotForm = localizeRelationForm(ctx, tr, s.pivotForm, s.pivotModel, s.PivotForm)
return &out
}
// localizeRelationForm resolves a relation form's display strings, with the
// form's model as the dropdown options provider. A form that cannot be
// localized keeps its cached fields.
func localizeRelationForm(ctx context.Context, tr *phrasebook.Translator, form *FormSchema, model func() any, cached []FormField) []FormField {
if form == nil {
return cached
}
var provider pact.DropdownOptionsProvider
if model != nil {
provider = modelDropdownProvider(model())
}
view, err := form.Localize(ctx, tr, provider)
if err != nil {
return cached
}
return view.Fields
}
func localizeRelationPanel(ctx context.Context, tr *phrasebook.Translator, src RelationPanel) RelationPanel {
out := src
out.List.Columns = append([]RelationColumn(nil), src.List.Columns...)
@@ -155,20 +254,29 @@ type relationRoot struct {
}
type relationDocument struct {
Label string `yaml:"label"`
View relationPanelDocument `yaml:"view"`
Manage relationPanelDocument `yaml:"manage"`
Messages *relationMessageKeys `yaml:"messages"`
Label string `yaml:"label"`
// Form is WinterCMS's top-level form: the fallback of manage.form and
// view.form.
Form string `yaml:"form"`
View relationPanelDocument `yaml:"view"`
Manage relationPanelDocument `yaml:"manage"`
Pivot *relationPivotDocument `yaml:"pivot"`
Messages *relationMessageKeys `yaml:"messages"`
}
type relationPanelDocument struct {
List struct {
Columns yaml.MapSlice `yaml:"columns"`
} `yaml:"list"`
Form string `yaml:"form"`
ToolbarButtons string `yaml:"toolbarButtons"`
ShowSearch bool `yaml:"showSearch"`
}
type relationPivotDocument struct {
Form string `yaml:"form"`
}
type relationColumnDocument struct {
Label string `yaml:"label"`
Searchable *bool `yaml:"searchable"`
@@ -176,11 +284,12 @@ type relationColumnDocument struct {
}
func compileRelations(pluginID string, ctl pact.AdminController, fsys fs.FS, form *FormSchema) (map[string]*CompiledRelation, error) {
fields := map[string]FormField{}
// fields maps each relation-manager relation to its form field index.
fields := map[string]int{}
if form != nil {
for _, field := range form.Fields {
for i, field := range form.Fields {
if field.Type == "relation-manager" {
fields[field.Relation] = field
fields[field.Relation] = i
}
}
}
@@ -246,17 +355,26 @@ func compileRelations(pluginID string, ctl pact.AdminController, fsys fs.FS, for
if err != nil {
return nil, bootErr(pluginID, ctl.ID(), file, fmt.Errorf("relation %s manage: %w", name, err))
}
schema := &RelationSchema{Name: name, Label: doc.Label, View: view, Manage: manage}
schema := &RelationSchema{Name: name, Label: doc.Label, Kind: contract.kind(), View: view, Manage: manage}
if doc.Messages != nil {
schema.messageKeys = *doc.Messages
}
keys := localizeMessages[relationMessageKeys, RelationMessages](context.Background(), nil, schema.relationMessageKeySet())
schema.Messages = &keys
out[name] = &CompiledRelation{
cr := &CompiledRelation{
Schema: schema,
Contract: contract,
RequiredPermissions: append([]string(nil), requiredOf(ctl)...),
kind: contract.kind(),
deferrable: relationDeferrable(contract),
fieldName: form.Fields[fields[name]].Name,
}
schema.Deferrable = cr.deferrable
if err := compileRelationForms(pluginID, ctl, fsys, file, doc, cr); err != nil {
return nil, err
}
form.Fields[fields[name]].Deferrable = cr.deferrable
out[name] = cr
}
for name := range fields {
if out[name] == nil {
@@ -311,6 +429,11 @@ func compileRelationPanel(doc relationPanelDocument, contract RelationContract,
return RelationPanel{List: RelationList{Columns: cols}, ToolbarButtons: buttons, ShowSearch: doc.ShowSearch}, nil
}
// relationButtons are the WinterCMS RelationController toolbar buttons the
// view panel may declare (D-12). Each one is the capability of its routes:
// create, update (row edit), delete, link and unlink.
var relationButtons = []string{"create", "update", "delete", "link", "unlink"}
func compileRelationButtons(raw string, view bool) ([]string, error) {
if strings.TrimSpace(raw) == "" {
return []string{}, nil
@@ -320,11 +443,11 @@ func compileRelationButtons(raw string, view bool) ([]string, error) {
seen := map[string]struct{}{}
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "link" && part != "unlink" {
if !slices.Contains(relationButtons, part) {
return nil, fmt.Errorf("unsupported relation action %s", part)
}
if !view && part == "unlink" {
return nil, fmt.Errorf("manage panel cannot declare unlink")
if !view && part != "link" {
return nil, fmt.Errorf("manage panel cannot declare %s (only link)", part)
}
if _, dup := seen[part]; dup {
return nil, fmt.Errorf("duplicate relation action %s", part)
@@ -336,18 +459,17 @@ func compileRelationButtons(raw string, view bool) ([]string, error) {
}
func validateRelationContract(ctl pact.AdminController, contract RelationContract) error {
if contract.NewRelated == nil || contract.NewRelated() == nil || contract.NewPivot == nil || contract.NewPivot() == nil {
return fmt.Errorf("relation %s requires target and pivot models", contract.Name)
}
if !identifier(contract.ParentForeignKey) || !identifier(contract.RelatedForeignKey) {
return fmt.Errorf("relation %s has invalid pivot foreign keys", contract.Name)
}
pivotCols := modelColumns(contract.NewPivot())
if _, ok := pivotCols[contract.ParentForeignKey]; !ok {
return fmt.Errorf("relation %s pivot is missing %s", contract.Name, contract.ParentForeignKey)
}
if _, ok := pivotCols[contract.RelatedForeignKey]; !ok {
return fmt.Errorf("relation %s pivot is missing %s", contract.Name, contract.RelatedForeignKey)
switch contract.kind() {
case RelationBelongsToMany:
if err := validatePivotContract(contract); err != nil {
return err
}
case RelationHasMany:
if err := validateHasManyContract(contract); err != nil {
return err
}
default:
return fmt.Errorf("relation %s has unknown kind %q (want %s or %s)", contract.Name, contract.Kind, RelationBelongsToMany, RelationHasMany)
}
targetCols := modelColumns(contract.NewRelated())
for logical, physical := range contract.Columns {
@@ -358,14 +480,6 @@ func validateRelationContract(ctl pact.AdminController, contract RelationContrac
return fmt.Errorf("relation %s target is missing column %s", contract.Name, physical)
}
}
for _, col := range contract.HookPivotColumns {
if !identifier(col) {
return fmt.Errorf("relation %s has invalid hook column %s", contract.Name, col)
}
if _, ok := pivotCols[col]; !ok || protectedPivotColumn(col, contract) {
return fmt.Errorf("relation %s has invalid hook column %s", contract.Name, col)
}
}
src, ok := ctl.(pact.AdminRecordSource)
if !ok || src == nil || src.NewRecord() == nil {
return fmt.Errorf("relation %s owner has no record source", contract.Name)
@@ -393,6 +507,71 @@ func validateRelationContract(ctl pact.AdminController, contract RelationContrac
return nil
}
// validatePivotContract checks a belongsToMany contract: target and pivot
// models, both pivot foreign keys on the pivot model and the hook columns.
func validatePivotContract(contract RelationContract) error {
if contract.NewRelated == nil || contract.NewRelated() == nil || contract.NewPivot == nil || contract.NewPivot() == nil {
return fmt.Errorf("relation %s requires target and pivot models", contract.Name)
}
if contract.ForeignKey != "" {
return fmt.Errorf("relation %s: a belongsToMany contract cannot declare ForeignKey", contract.Name)
}
if !identifier(contract.ParentForeignKey) || !identifier(contract.RelatedForeignKey) {
return fmt.Errorf("relation %s has invalid pivot foreign keys", contract.Name)
}
pivotCols := modelColumns(contract.NewPivot())
if _, ok := pivotCols[contract.ParentForeignKey]; !ok {
return fmt.Errorf("relation %s pivot is missing %s", contract.Name, contract.ParentForeignKey)
}
if _, ok := pivotCols[contract.RelatedForeignKey]; !ok {
return fmt.Errorf("relation %s pivot is missing %s", contract.Name, contract.RelatedForeignKey)
}
for _, col := range contract.HookPivotColumns {
if !identifier(col) {
return fmt.Errorf("relation %s has invalid hook column %s", contract.Name, col)
}
if _, ok := pivotCols[col]; !ok || protectedPivotColumn(col, contract) {
return fmt.Errorf("relation %s has invalid hook column %s", contract.Name, col)
}
}
return nil
}
// validateHasManyContract checks a hasMany contract: no pivot fields, and a
// ForeignKey that is an integer column (or a pointer to one) of the related
// model.
func validateHasManyContract(contract RelationContract) error {
if contract.NewRelated == nil || contract.NewRelated() == nil {
return fmt.Errorf("relation %s requires a target model", contract.Name)
}
if contract.NewPivot != nil || contract.ParentForeignKey != "" || contract.RelatedForeignKey != "" || len(contract.HookPivotColumns) > 0 {
return fmt.Errorf("relation %s: a hasMany contract cannot declare NewPivot, ParentForeignKey, RelatedForeignKey or HookPivotColumns", contract.Name)
}
if !identifier(contract.ForeignKey) {
return fmt.Errorf("relation %s: hasMany needs a ForeignKey column", contract.Name)
}
field, ok := structFieldByColumn(contract.NewRelated(), contract.ForeignKey)
if !ok {
return fmt.Errorf("relation %s target is missing column %s", contract.Name, contract.ForeignKey)
}
if !uintLike(field.Type) {
return fmt.Errorf("relation %s: ForeignKey %s must be an integer column, found %s", contract.Name, contract.ForeignKey, field.Type)
}
return nil
}
// relationDeferrable reports whether a relation can be managed before its
// parent is saved: a belongsToMany always (the pivot row is written on
// save), a hasMany only with a nullable ForeignKey (the child is inserted
// with a NULL key first, as in WinterCMS).
func relationDeferrable(contract RelationContract) bool {
if contract.kind() != RelationHasMany {
return true
}
field, ok := structFieldByColumn(contract.NewRelated(), contract.ForeignKey)
return ok && field.Type.Kind() == reflect.Pointer
}
func protectedPivotColumn(col string, contract RelationContract) bool {
switch col {
case contract.ParentForeignKey, contract.RelatedForeignKey, "id", "created_at", "updated_at", "deleted_at":
@@ -463,6 +642,9 @@ func (s RelationService) query(ctx context.Context, cc *CompiledController, rela
}
func relationBaseQuery(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent any, candidates bool) (*gorm.DB, any, error) {
if cr.hasMany() {
return hasManyBaseQuery(ctx, tx, cc, cr, parent, candidates)
}
target := cr.Contract.NewRelated()
targetTable := tableName(target)
pivotTable := tableName(cr.Contract.NewPivot())
@@ -493,6 +675,33 @@ func relationBaseQuery(ctx context.Context, tx *gorm.DB, cc *CompiledController,
return q, target, nil
}
// hasManyBaseQuery is relationBaseQuery for a hasMany relation: the linked
// rows are the related rows whose ForeignKey is the parent's key.
func hasManyBaseQuery(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent any, candidates bool) (*gorm.DB, any, error) {
target := cr.Contract.NewRelated()
fk := clause.Column{Table: tableName(target), Name: cr.Contract.ForeignKey}
q := tx.WithContext(ctx).Model(target)
if !candidates {
return q.Where(clause.Eq{Column: fk, Value: pkUint(parent)}), target, nil
}
// Candidates are rows no parent owns yet: a NULL ForeignKey.
if ext, ok := cc.Controller.(pact.RelationExtendManageQuery); ok && ext != nil {
if next := ext.RelationExtendManageQuery(ctx, cr.Contract.Name, q); next != nil {
q = next
}
}
if cr.Contract.ExcludedRelatedIDs != nil {
excluded, err := cr.Contract.ExcludedRelatedIDs(parent)
if err != nil {
return nil, nil, lifecycleFailure(cc, err)
}
if len(excluded) > 0 {
q = q.Where(clause.Not(clause.IN{Column: clause.Column{Table: tableName(target), Name: primaryColumn(target)}, Values: uintValues(excluded)}))
}
}
return q.Where(clause.Eq{Column: fk, Value: nil}), target, nil
}
// normalizeRelationPage applies the Phase 9 relation paging limits: page is
// a positive integer (default 1), per_page is 1..100 (default 20).
func normalizeRelationPage(rawPage, rawPerPage string) (int, int, error) {
@@ -631,6 +840,9 @@ func (s RelationService) Link(ctx context.Context, cc *CompiledController, relat
if err != nil {
return RelationMutationResult{}, err
}
if cr.hasMany() {
return RelationMutationResult{}, recordNotFound{}
}
var result RelationMutationResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
@@ -660,28 +872,7 @@ func (s RelationService) Link(ctx context.Context, cc *CompiledController, relat
}
for i := 0; i < holder.Elem().Len(); i++ {
related := holder.Elem().Index(i).Addr().Interface()
pivot := cr.Contract.NewPivot()
if err := setModelColumn(pivot, cr.Contract.ParentForeignKey, ownerPK); err != nil {
return err
}
if err := setModelColumn(pivot, cr.Contract.RelatedForeignKey, pkUint(related)); err != nil {
return err
}
values := map[string]any{}
if hook, ok := cc.Controller.(pact.RelationBeforeLink); ok && hook != nil {
if err := hook.RelationBeforeLink(ctx, relation, parent, related, values); err != nil {
return lifecycleFailure(cc, err)
}
}
for key, value := range values {
if !stringSliceHasValue(cr.Contract.HookPivotColumns, key) || protectedPivotColumn(key, cr.Contract) {
return lifecycleFailure(cc, fmt.Errorf("relation hook wrote protected pivot column"))
}
if err := setModelColumn(pivot, key, value); err != nil {
return lifecycleFailure(cc, err)
}
}
if err := tx.WithContext(ctx).Create(pivot).Error; err != nil {
if err := insertPivot(ctx, tx, cc, cr, parent, related, nil); err != nil {
return err
}
result.Linked++
@@ -691,6 +882,37 @@ func (s RelationService) Link(ctx context.Context, cc *CompiledController, relat
return result, err
}
// insertPivot writes the pivot row linking related to the saved parent of a
// belongsToMany relation. A filled pivot model (from the pivot form) may be
// passed; RelationBeforeLink then stamps only its HookPivotColumns, and the
// two foreign keys are always set here.
func insertPivot(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent, related, pivot any) error {
if pivot == nil {
pivot = cr.Contract.NewPivot()
}
if err := setModelColumn(pivot, cr.Contract.ParentForeignKey, pkUint(parent)); err != nil {
return err
}
if err := setModelColumn(pivot, cr.Contract.RelatedForeignKey, pkUint(related)); err != nil {
return err
}
values := map[string]any{}
if hook, ok := cc.Controller.(pact.RelationBeforeLink); ok && hook != nil {
if err := hook.RelationBeforeLink(ctx, cr.Contract.Name, parent, related, values); err != nil {
return lifecycleFailure(cc, err)
}
}
for key, value := range values {
if !stringSliceHasValue(cr.Contract.HookPivotColumns, key) || protectedPivotColumn(key, cr.Contract) {
return lifecycleFailure(cc, fmt.Errorf("relation hook wrote protected pivot column"))
}
if err := setModelColumn(pivot, key, value); err != nil {
return lifecycleFailure(cc, err)
}
}
return tx.WithContext(ctx).Create(pivot).Error
}
// Unlink deletes explicit pivot models so their lifecycle hooks run.
func (s RelationService) Unlink(ctx context.Context, cc *CompiledController, relation string, ownerID uint, in RelationMutationInput) (RelationMutationResult, error) {
ids, err := normalizeIDs(in.IDs)
@@ -701,6 +923,9 @@ func (s RelationService) Unlink(ctx context.Context, cc *CompiledController, rel
if err != nil {
return RelationMutationResult{}, err
}
if cr.hasMany() {
return RelationMutationResult{}, recordNotFound{}
}
var result RelationMutationResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)