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