Files
summercms/modules/cabana/relation.go
Jakub Zych fe9e8baaf1 feat(12.2-03): defer relation work on unsaved records and add child file routes
- record id 0 with X-Session-Key manages deferrable relations: create, link, unlink, delete and pivot edits are held in deferred_bindings
- the record's create save applies relation bindings with the file bindings; an ineligible link is a 422 on the relation-manager field
- child forms upload files through .../records/{child}/files/{field} keyed by X-Child-Session-Key; the child save commits them
- boot refuses a deferrable relation with create whose related model no plugin lists in Models()
2026-10-02 19:08:16 +02:00

1368 lines
49 KiB
Go

package cabana
import (
"context"
"encoding/json"
"errors"
"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"
)
// AdminRelationContractProvider declares model-owned relation metadata.
// Framework code never guesses a plugin pivot table or foreign key.
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
// 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"`
Label string `json:"label"`
Searchable bool `json:"searchable"`
Sortable bool `json:"sortable"`
}
// RelationList is one view/manage list declaration.
type RelationList struct {
Columns []RelationColumn `json:"columns"`
}
// RelationPanel is one side of a relation manager.
type RelationPanel struct {
List RelationList `json:"list"`
ToolbarButtons []string `json:"toolbarButtons"`
ShowSearch bool `json:"showSearch"`
}
// RelationSchema is the cached locale-neutral relation contract.
type RelationSchema struct {
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.
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.
type RelationQuery struct {
Search string
Sort string
Dir string
Page string
PerPage string
}
// RelationResult is a stable allocated page.
type RelationResult struct {
Data []map[string]any
Meta ListMeta
}
// RelationMutationInput is the only accepted relation write payload.
type RelationMutationInput struct {
IDs []any `json:"ids"`
// Pivot holds pivot form values for a belongsToMany link of exactly one
// id (D-14). Only the relation's pivot.form fields are accepted; the
// pivot foreign keys, timestamps and hook columns never are.
Pivot map[string]any `json:"pivot,omitempty"`
}
// RelationMutationResult reports inserted/deleted pivot rows.
type RelationMutationResult struct {
Linked int `json:"linked,omitempty"`
Removed int `json:"removed,omitempty"`
}
// RelationService executes compiled relation reads and writes.
type RelationService struct {
DB *gorm.DB
// SessionKey is the parent form's session key (X-Session-Key). With
// it, owner id 0 is the record being created in that session: relation
// work on it is held in deferred_bindings until the record's first save
// (D-03). Without it, id 0 is not found.
SessionKey string
// 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 {
keys := localizeMessages[relationMessageKeys, RelationMessages](context.Background(), nil, s.relationMessageKeySet())
s.Messages = &keys
}
type alias RelationSchema
out := alias(s)
ensureRelationArrays(&out.View)
ensureRelationArrays(&out.Manage)
return json.Marshal(out)
}
func ensureRelationArrays(p *RelationPanel) {
if p.List.Columns == nil {
p.List.Columns = []RelationColumn{}
}
if p.ToolbarButtons == nil {
p.ToolbarButtons = []string{}
}
}
// Localize returns a request-local copy without mutating the cached keys.
func (s *RelationSchema) Localize(ctx context.Context, tr *phrasebook.Translator) *RelationSchema {
if s == nil {
return nil
}
out := *s
out.Label = translateKey(ctx, tr, s.Label)
out.View = localizeRelationPanel(ctx, tr, s.View)
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...)
for i := range out.List.Columns {
out.List.Columns[i].Label = translateKey(ctx, tr, out.List.Columns[i].Label)
}
out.ToolbarButtons = append([]string(nil), src.ToolbarButtons...)
ensureRelationArrays(&out)
return out
}
type relationRoot struct {
Relations yaml.MapSlice `yaml:",inline"`
}
type relationDocument struct {
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"`
Sortable *bool `yaml:"sortable"`
}
func compileRelations(pluginID string, ctl pact.AdminController, fsys fs.FS, form *FormSchema) (map[string]*CompiledRelation, error) {
// fields maps each relation-manager relation to its form field index.
fields := map[string]int{}
if form != nil {
for i, field := range form.Fields {
if field.Type == "relation-manager" {
fields[field.Relation] = i
}
}
}
if len(fields) == 0 {
return map[string]*CompiledRelation{}, nil
}
provider, ok := ctl.(AdminRelationContractProvider)
if !ok || provider == nil {
return nil, bootErr(pluginID, ctl.ID(), "config_relation.yaml", errors.New("relation-manager requires AdminRelationContracts"))
}
contracts := map[string]RelationContract{}
for _, contract := range provider.AdminRelationContracts() {
if !identifier(contract.Name) {
return nil, bootErr(pluginID, ctl.ID(), "config_relation.yaml", fmt.Errorf("relation contract name %q is not an identifier", contract.Name))
}
if _, dup := contracts[contract.Name]; dup {
return nil, bootErr(pluginID, ctl.ID(), "config_relation.yaml", fmt.Errorf("duplicate relation contract %s", contract.Name))
}
if err := validateRelationContract(ctl, contract); err != nil {
return nil, bootErr(pluginID, ctl.ID(), "config_relation.yaml", err)
}
contracts[contract.Name] = contract
}
dir := strings.Trim(strings.TrimSpace(ctl.ConfigDir()), "/")
file := dir + "/config_relation.yaml"
raw, err := readAsset(fsys, file)
if err != nil {
return nil, bootErr(pluginID, ctl.ID(), file, err)
}
var root yaml.MapSlice
if err := decodeStrictOrdered(raw, &root); err != nil {
return nil, bootErr(pluginID, ctl.ID(), file, err)
}
out := map[string]*CompiledRelation{}
for _, item := range root {
name, ok := item.Key.(string)
if !ok || !identifier(name) {
return nil, bootErr(pluginID, ctl.ID(), file, fmt.Errorf("relation name %v is not an identifier", item.Key))
}
if _, dup := out[name]; dup {
return nil, bootErr(pluginID, ctl.ID(), file, fmt.Errorf("duplicate relation %s", name))
}
if _, declared := fields[name]; !declared {
return nil, bootErr(pluginID, ctl.ID(), file, fmt.Errorf("relation %s has no relation-manager field", name))
}
contract, declared := contracts[name]
if !declared {
return nil, bootErr(pluginID, ctl.ID(), file, fmt.Errorf("relation %s has no model contract", name))
}
encoded, err := yaml.Marshal(item.Value)
if err != nil {
return nil, bootErr(pluginID, ctl.ID(), file, err)
}
var doc relationDocument
if err := decodeStrict(encoded, &doc); err != nil {
return nil, bootErr(pluginID, ctl.ID(), file, fmt.Errorf("relation %s: %w", name, err))
}
view, err := compileRelationPanel(doc.View, contract, true)
if err != nil {
return nil, bootErr(pluginID, ctl.ID(), file, fmt.Errorf("relation %s view: %w", name, err))
}
manage, err := compileRelationPanel(doc.Manage, contract, false)
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, 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
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 {
return nil, bootErr(pluginID, ctl.ID(), file, fmt.Errorf("relation %s is missing", name))
}
}
for name := range contracts {
if out[name] == nil {
return nil, bootErr(pluginID, ctl.ID(), file, fmt.Errorf("relation contract %s is orphaned", name))
}
}
return out, nil
}
func compileRelationPanel(doc relationPanelDocument, contract RelationContract, view bool) (RelationPanel, error) {
cols := make([]RelationColumn, 0, len(doc.List.Columns))
seen := map[string]struct{}{}
for _, item := range doc.List.Columns {
key, ok := item.Key.(string)
if !ok || !identifier(key) {
return RelationPanel{}, fmt.Errorf("column %v is not an identifier", item.Key)
}
if _, dup := seen[key]; dup {
return RelationPanel{}, fmt.Errorf("duplicate column %s", key)
}
seen[key] = struct{}{}
if _, ok := contract.Columns[key]; !ok {
return RelationPanel{}, fmt.Errorf("column %s is not in the model contract", key)
}
encoded, err := yaml.Marshal(item.Value)
if err != nil {
return RelationPanel{}, err
}
var spec relationColumnDocument
if err := decodeStrict(encoded, &spec); err != nil {
return RelationPanel{}, fmt.Errorf("column %s: %w", key, err)
}
searchable := doc.ShowSearch
if spec.Searchable != nil {
searchable = *spec.Searchable
}
sortable := true
if spec.Sortable != nil {
sortable = *spec.Sortable
}
cols = append(cols, RelationColumn{Key: key, Label: spec.Label, Searchable: searchable, Sortable: sortable})
}
buttons, err := compileRelationButtons(doc.ToolbarButtons, view)
if err != nil {
return RelationPanel{}, err
}
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
}
parts := strings.Split(raw, "|")
out := make([]string, 0, len(parts))
seen := map[string]struct{}{}
for _, part := range parts {
part = strings.TrimSpace(part)
if !slices.Contains(relationButtons, part) {
return nil, fmt.Errorf("unsupported relation action %s", part)
}
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)
}
seen[part] = struct{}{}
out = append(out, part)
}
return out, nil
}
func validateRelationContract(ctl pact.AdminController, contract RelationContract) error {
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 {
if !identifier(logical) || !identifier(physical) {
return fmt.Errorf("relation %s has invalid column mapping", contract.Name)
}
if _, ok := targetCols[physical]; !ok {
return fmt.Errorf("relation %s target is missing column %s", contract.Name, physical)
}
}
src, ok := ctl.(pact.AdminRecordSource)
if !ok || src == nil || src.NewRecord() == nil {
return fmt.Errorf("relation %s owner has no record source", contract.Name)
}
fieldName, ok := relationFieldName(src.NewRecord(), contract.Name)
if !ok {
return fmt.Errorf("relation %s is not on the owner model", contract.Name)
}
ownerType := reflect.TypeOf(src.NewRecord())
for ownerType.Kind() == reflect.Pointer {
ownerType = ownerType.Elem()
}
field, _ := ownerType.FieldByName(fieldName)
targetType := reflect.TypeOf(contract.NewRelated())
for targetType.Kind() == reflect.Pointer {
targetType = targetType.Elem()
}
fieldType := field.Type
for fieldType.Kind() == reflect.Pointer || fieldType.Kind() == reflect.Slice {
fieldType = fieldType.Elem()
}
if fieldType != targetType {
return fmt.Errorf("relation %s target model does not match owner field", contract.Name)
}
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":
return true
default:
return false
}
}
// Linked returns the current relation rows.
func (s RelationService) Linked(ctx context.Context, cc *CompiledController, relation string, ownerID uint, in RelationQuery) (*RelationResult, error) {
return s.query(ctx, cc, relation, ownerID, in, false)
}
// Candidates returns target rows eligible for linking.
func (s RelationService) Candidates(ctx context.Context, cc *CompiledController, relation string, ownerID uint, in RelationQuery) (*RelationResult, error) {
return s.query(ctx, cc, relation, ownerID, in, true)
}
func (s RelationService) query(ctx context.Context, cc *CompiledController, relation string, ownerID uint, in RelationQuery, candidates bool) (*RelationResult, error) {
if s.DB == nil {
return nil, errors.New("cabana: database is not configured")
}
cr, err := relationOf(cc, relation)
if err != nil {
return nil, err
}
page, per, sortKey, desc, err := normalizeRelationQuery(cr.Schema, candidates, in)
if err != nil {
return nil, err
}
var result *RelationResult
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
if err != nil {
return err
}
q, target, err := relationQuery(ctx, tx, cc, cr, parent, candidates)
if err != nil {
return err
}
panel := cr.Schema.View
if candidates {
panel = cr.Schema.Manage
}
q = applyRelationSearch(q, cr, panel.List.Columns, in.Search)
var total int64
if err := q.Session(&gorm.Session{}).Count(&total).Error; err != nil {
return err
}
q = applyRelationOrder(q, cr, target, panel.List.Columns, sortKey, desc)
rows := make([]map[string]any, 0)
selects := relationSelects(tx, cr, target, panel.List.Columns)
if err := q.Select(strings.Join(selects, ", ")).Offset((page - 1) * per).Limit(per).Scan(&rows).Error; err != nil {
return err
}
last := 0
if total > 0 {
last = int((total + int64(per) - 1) / int64(per))
}
result = &RelationResult{Data: rows, Meta: ListMeta{Page: page, PerPage: per, Total: total, LastPage: last}}
return nil
})
return result, err
}
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())
pk := primaryColumn(target)
ownerPK := pkUint(parent)
q := tx.WithContext(ctx).Model(target)
if candidates {
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: targetTable, Name: pk}, Values: uintValues(excluded)}))
}
}
notExists := "NOT EXISTS (SELECT 1 FROM " + quotedIdent(tx, pivotTable) + " p WHERE p." + quotedIdent(tx, cr.Contract.ParentForeignKey) + " = ? AND p." + quotedIdent(tx, cr.Contract.RelatedForeignKey) + " = " + quotedIdent(tx, targetTable) + "." + quotedIdent(tx, pk) + ")"
q = q.Where(notExists, ownerPK)
var err error
if q, err = excludePendingCreated(tx, q, target); err != nil {
return nil, nil, lifecycleFailure(cc, err)
}
} else {
join := "JOIN " + quotedIdent(tx, pivotTable) + " p ON p." + quotedIdent(tx, cr.Contract.RelatedForeignKey) + " = " + quotedIdent(tx, targetTable) + "." + quotedIdent(tx, pk)
q = q.Joins(join).Where("p."+quotedIdent(tx, cr.Contract.ParentForeignKey)+" = ?", ownerPK)
}
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)}))
}
}
q, err := excludePendingCreated(tx, q, target)
if err != nil {
return nil, nil, lifecycleFailure(cc, err)
}
return q.Where(clause.Eq{Column: fk, Value: nil}), target, nil
}
// excludePendingCreated drops from a candidate query every row that a
// pending form session created (a live bind whose envelope says created,
// D-22): another parent must not adopt a child before the session that
// created it saves or the purge removes it (Pitfall 8).
func excludePendingCreated(tx *gorm.DB, q *gorm.DB, target any) (*gorm.DB, error) {
morph, err := lagoon.MorphType(tx, target)
if err != nil {
return nil, err
}
column := quotedIdent(tx, tableName(target)) + "." + quotedIdent(tx, primaryColumn(target))
pending := "NOT EXISTS (SELECT 1 FROM " + quotedIdent(tx, "deferred_bindings") + " b WHERE b.slave_type = ? AND b.is_bind AND b.slave_id = CAST(" + column + " AS TEXT) AND b.pivot_data LIKE ?)"
return q.Where(pending, morph, `{"created":true%`), 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) {
page, per := 1, 20
var err error
if rawPage != "" {
page, err = parsePositive(rawPage)
if err != nil {
return 0, 0, relationInvalid("page", "must be a positive integer")
}
}
if rawPerPage != "" {
per, err = parsePositive(rawPerPage)
if err != nil || per > 100 {
return 0, 0, relationInvalid("per_page", "must be between 1 and 100")
}
}
return page, per, nil
}
func normalizeRelationQuery(schema *RelationSchema, candidates bool, in RelationQuery) (int, int, string, bool, error) {
page, per, err := normalizeRelationPage(in.Page, in.PerPage)
if err != nil {
return 0, 0, "", false, err
}
panel := schema.View
if candidates {
panel = schema.Manage
}
sortKey := in.Sort
if sortKey == "" {
// No explicit sort: use the first sortable column, or none (the
// primary key then orders the page). Only a sort the client names is
// validated, so a panel whose first column is not sortable still loads.
for _, col := range panel.List.Columns {
if col.Sortable {
sortKey = col.Key
break
}
}
} else {
valid := false
for _, col := range panel.List.Columns {
if col.Key == sortKey && col.Sortable {
valid = true
break
}
}
if !valid {
return 0, 0, "", false, relationInvalid("sort", "is not a sortable column")
}
}
dir := in.Dir
if dir == "" {
dir = "asc"
}
if dir != "asc" && dir != "desc" {
return 0, 0, "", false, relationInvalid("dir", "must be asc or desc")
}
return page, per, sortKey, dir == "desc", nil
}
func parsePositive(raw string) (int, error) {
n := 0
for _, r := range raw {
if r < '0' || r > '9' {
return 0, errors.New("not positive")
}
n = n*10 + int(r-'0')
}
if n < 1 {
return 0, errors.New("not positive")
}
return n, nil
}
func relationInvalid(field, message string) *ValidationError {
return &ValidationError{Details: map[string]any{field: []string{message}}}
}
func applyRelationSearch(q *gorm.DB, cr *CompiledRelation, cols []RelationColumn, term string) *gorm.DB {
term = strings.TrimSpace(term)
if term == "" {
return q
}
targetTable := tableName(cr.Contract.NewRelated())
parts := make([]string, 0)
args := make([]any, 0)
seen := map[string]struct{}{}
for _, col := range cols {
physical := cr.Contract.Columns[col.Key]
if !col.Searchable || physical == "" {
continue
}
if _, dup := seen[physical]; dup {
continue
}
seen[physical] = struct{}{}
parts = append(parts, quotedIdent(q, targetTable)+"."+quotedIdent(q, physical)+" ILIKE ?")
args = append(args, "%"+term+"%")
}
if len(parts) == 0 {
return q
}
return q.Where("("+strings.Join(parts, " OR ")+")", args...)
}
func applyRelationOrder(q *gorm.DB, cr *CompiledRelation, target any, cols []RelationColumn, sortKey string, desc bool) *gorm.DB {
table := tableName(target)
pk := primaryColumn(target)
if sortKey != "" {
q = q.Order(clause.OrderByColumn{Column: clause.Column{Table: table, Name: cr.Contract.Columns[sortKey]}, Desc: desc})
}
if cr.Contract.Columns[sortKey] != pk {
q = q.Order(clause.OrderByColumn{Column: clause.Column{Table: table, Name: pk}})
}
return q
}
func relationSelects(db *gorm.DB, cr *CompiledRelation, target any, cols []RelationColumn) []string {
table := tableName(target)
out := []string{quotedIdent(db, table) + "." + quotedIdent(db, primaryColumn(target)) + " AS " + quotedIdent(db, "id")}
for _, col := range cols {
out = append(out, quotedIdent(db, table)+"."+quotedIdent(db, cr.Contract.Columns[col.Key])+" AS "+quotedIdent(db, col.Key))
}
return out
}
// Link links eligible related records to the parent and never restamps
// existing links. On a belongsToMany it writes pivot rows (with the pivot
// form's values when the body carries a pivot object for one id); on a
// hasMany it sets each child's ForeignKey to the parent's key through the
// child model. Records already linked to this parent are skipped; any other
// id that is not a candidate fails the whole link with 422 on ids.
func (s RelationService) Link(ctx context.Context, cc *CompiledController, relation string, ownerID uint, in RelationMutationInput) (RelationMutationResult, error) {
ids, err := normalizeIDs(in.IDs)
if err != nil {
return RelationMutationResult{}, err
}
cr, err := relationOf(cc, relation)
if err != nil {
return RelationMutationResult{}, err
}
if err := checkPivotInput(cr, ids, in.Pivot); err != nil {
return RelationMutationResult{}, err
}
var result RelationMutationResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
if err != nil {
return err
}
if parent.unsaved() {
result.Linked, err = s.linkDeferred(ctx, tx, cc, cr, parent, ids, in.Pivot)
return err
}
result.Linked, err = s.linkRelated(ctx, tx, cc, cr, parent.model, ids, in.Pivot, relationInvalid("ids", "contains an ineligible target"))
return err
})
return result, err
}
// linkRelated links ids to the saved parent inside tx (the shared link
// path of the link route and of the deferred commit). Eligibility is the
// candidate query: RelationExtendManageQuery, ExcludedRelatedIDs, not linked
// yet, and not a child created in a pending session. An ineligible id
// fails the link with the ineligible error.
func (s RelationService) linkRelated(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent any, ids []uint, pivot map[string]any, ineligible error) (int, error) {
ownerPK := pkUint(parent)
var pending []uint
var err error
if cr.hasMany() {
pending, err = pendingChildIDs(ctx, tx, cr, ownerPK, ids)
} else {
pending, err = pendingRelationIDs(tx, cr, ownerPK, ids)
}
if err != nil || len(pending) == 0 {
return 0, err
}
q, target, err := relationBaseQuery(ctx, tx, cc, cr, parent, true)
if err != nil {
return 0, err
}
q = q.Clauses(clause.Locking{Strength: "UPDATE"}).Where(clause.IN{Column: clause.Column{Table: tableName(target), Name: primaryColumn(target)}, Values: uintValues(pending)})
holder := reflect.New(reflect.SliceOf(reflect.TypeOf(target).Elem()))
if err := q.Order(clause.OrderByColumn{Column: clause.Column{Table: tableName(target), Name: primaryColumn(target)}}).Find(holder.Interface()).Error; err != nil {
return 0, err
}
if holder.Elem().Len() != len(pending) {
return 0, ineligible
}
linked := 0
for i := 0; i < holder.Elem().Len(); i++ {
related := holder.Elem().Index(i).Addr().Interface()
if cr.hasMany() {
if err := setModelColumn(related, cr.Contract.ForeignKey, ownerPK); err != nil {
return 0, lifecycleFailure(cc, err)
}
if err := tx.WithContext(ctx).Save(related).Error; err != nil {
return 0, lifecycleFailure(cc, err)
}
linked++
continue
}
var row any
if pivot != nil {
row = cr.Contract.NewPivot()
if err := s.fillPivot(ctx, tx, cr, row, pivot); err != nil {
return 0, err
}
}
if err := insertPivot(ctx, tx, cc, cr, parent, related, row); err != nil {
return 0, err
}
linked++
}
return linked, nil
}
// boundIDs are the ids among ids that the unsaved parent's session binds.
func boundIDs(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, parent *relationParent, ids []uint) ([]uint, error) {
target := cr.Contract.NewRelated()
pk := primaryColumn(target)
column := quotedIdent(tx, tableName(target)) + "." + quotedIdent(tx, pk)
var bound []uint
err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(target).
Where(clause.IN{Column: clause.Column{Table: tableName(target), Name: pk}, Values: uintValues(ids)}).
Where("CAST("+column+" AS TEXT) IN (?)", parent.boundSlaves(tx, cr)).
Order(clause.OrderByColumn{Column: clause.Column{Table: tableName(target), Name: pk}}).
Pluck(pk, &bound).Error
return bound, err
}
// linkDeferred links ids to an unsaved parent (D-03): each eligible
// candidate (checked now against the zero-key parent, and again by the
// parent's first save) is bound to the session, with the whitelisted and
// validated pivot values in the bind's envelope. Ids already bound are
// skipped.
func (s RelationService) linkDeferred(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent *relationParent, ids []uint, pivot map[string]any) (int, error) {
bound, err := boundIDs(ctx, tx, cr, parent, ids)
if err != nil {
return 0, err
}
pending := make([]uint, 0, len(ids))
for _, id := range ids {
if !slices.Contains(bound, id) {
pending = append(pending, id)
}
}
if len(pending) == 0 {
return 0, nil
}
q, target, err := relationQuery(ctx, tx, cc, cr, parent, true)
if err != nil {
return 0, err
}
var eligible []uint
pk := primaryColumn(target)
err = q.Clauses(clause.Locking{Strength: "UPDATE"}).
Where(clause.IN{Column: clause.Column{Table: tableName(target), Name: pk}, Values: uintValues(pending)}).
Order(clause.OrderByColumn{Column: clause.Column{Table: tableName(target), Name: pk}}).
Pluck(pk, &eligible).Error
if err != nil {
return 0, err
}
if len(eligible) != len(pending) {
return 0, relationInvalid("ids", "contains an ineligible target")
}
var env *lagoon.DeferredEnvelope
if pivot != nil {
if err := s.fillPivot(ctx, tx, cr, cr.Contract.NewPivot(), pivot); err != nil {
return 0, err
}
env = &lagoon.DeferredEnvelope{Pivot: ProjectWritableFields(cr.pivot, pivot)}
}
for _, id := range eligible {
if err := lagoon.DeferredBind(ctx, tx, *parent.key, cr.Contract.Name, parent.morph, uitoa(id), env); err != nil {
return 0, lifecycleFailure(cc, err)
}
}
return len(eligible), nil
}
// unlinkDeferred cancels the unsaved parent's pending binds of ids; a child
// the session created is deleted through its model. Ids not bound in the
// session are ignored.
func (s RelationService) unlinkDeferred(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent *relationParent, ids []uint) (int, error) {
bound, err := boundIDs(ctx, tx, cr, parent, ids)
if err != nil {
return 0, err
}
for _, id := range bound {
cancelled, err := lagoon.DeferredUnbind(ctx, tx, *parent.key, cr.Contract.Name, parent.morph, uitoa(id))
if err != nil {
return 0, lifecycleFailure(cc, err)
}
if cancelled == nil {
continue
}
env, err := cancelled.Envelope()
if err != nil {
return 0, lifecycleFailure(cc, err)
}
if !env.Created {
continue
}
child := cr.Contract.NewRelated()
err = tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Clauses(clause.Locking{Strength: "UPDATE"}).
Where(clause.Eq{Column: clause.Column{Name: primaryColumn(child)}, Value: castPK(child, id)}).Take(child).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
continue
}
if err != nil {
return 0, err
}
if err := tx.WithContext(ctx).Delete(child).Error; err != nil {
return 0, lifecycleFailure(cc, err)
}
}
return len(bound), nil
}
// pendingChildIDs drops the ids a hasMany parent already owns.
func pendingChildIDs(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, ownerID uint, ids []uint) ([]uint, error) {
target := cr.Contract.NewRelated()
var owned []uint
err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(target).
Where(clause.Eq{Column: clause.Column{Name: cr.Contract.ForeignKey}, Value: ownerID}).
Where(clause.IN{Column: clause.Column{Name: primaryColumn(target)}, Values: uintValues(ids)}).
Pluck(primaryColumn(target), &owned).Error
if err != nil {
return nil, err
}
pending := make([]uint, 0, len(ids))
for _, id := range ids {
if !slices.Contains(owned, id) {
pending = append(pending, id)
}
}
return pending, nil
}
// checkPivotInput validates a link body's pivot object before any query
// (D-14): it needs a pivot form, exactly one id, and only pivot form keys.
func checkPivotInput(cr *CompiledRelation, ids []uint, pivot map[string]any) error {
if pivot == nil {
return nil
}
if cr.pivot == nil {
return relationInvalid("pivot", "This relation has no pivot form.")
}
if len(ids) != 1 {
return relationInvalid("ids", "A link with pivot values takes exactly one id.")
}
return checkPivotKeys(cr, pivot)
}
// checkPivotKeys refuses every key that is not a writable pivot form field.
func checkPivotKeys(cr *CompiledRelation, values map[string]any) error {
details := map[string]any{}
for key := range values {
if !slices.ContainsFunc(cr.pivot.Writable, func(f WritableField) bool { return f.Name == key }) {
details[key] = []string{"The " + key + " field is not a pivot field."}
}
}
if len(details) > 0 {
return &ValidationError{Details: details}
}
return nil
}
// fillPivot fills a pivot model from pivot form values (D-14): only the
// pivot form's writable fields are filled (the form is the whitelist, the
// pivot model needs no Fillable), then the form's required flags, the pivot
// model's rules for those fields and the datepicker bounds are checked.
// The pivot foreign keys, timestamps and hook columns are never in the form.
func (s RelationService) fillPivot(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, pivot any, values map[string]any) error {
if err := checkPivotKeys(cr, values); err != nil {
return err
}
form := cr.pivot
allowed := make([]string, 0, len(form.Writable))
for _, field := range form.Writable {
allowed = append(allowed, field.FillKey)
}
projected := ProjectWritableFields(form, values)
if err := lagoon.Fill(pivot, allowed, projected, false); err != nil {
var typed *lagoon.FillTypeError
if errors.As(err, &typed) {
return &ValidationError{Details: fillTypeDetails(typed.Key)}
}
return &CapabilityError{ControllerID: controllerID(form)}
}
rules := map[string]string{}
for key, rule := range mergedRules(form, pivot, "") {
if slices.Contains(allowed, key) {
rules[key] = rule
}
}
msgs, err := lagoon.Validate(ctx, tx, pivot, rules, valuesForRules(pivot, rules), nil)
if err != nil {
return &CapabilityError{ControllerID: controllerID(form)}
}
for field, extra := range dateBoundDetails(ctx, s.tr, form, pivot, "") {
if msgs == nil {
msgs = map[string][]string{}
}
msgs[field] = append(msgs[field], extra...)
}
if len(msgs) > 0 {
return &ValidationError{Details: validationDetails(msgs)}
}
return nil
}
// 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 detaches related records from the parent: on a belongsToMany it
// deletes this parent's pivot rows through the pivot model, on a hasMany it
// sets each owned child's ForeignKey to NULL through the child model. Ids
// that are not linked to this parent are ignored.
func (s RelationService) Unlink(ctx context.Context, cc *CompiledController, relation string, ownerID uint, in RelationMutationInput) (RelationMutationResult, error) {
ids, err := normalizeIDs(in.IDs)
if err != nil {
return RelationMutationResult{}, err
}
if in.Pivot != nil {
return RelationMutationResult{}, relationInvalid("pivot", "Unlink takes no pivot values.")
}
cr, err := relationOf(cc, relation)
if err != nil {
return RelationMutationResult{}, err
}
var result RelationMutationResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
if err != nil {
return err
}
if parent.unsaved() {
result.Removed, err = s.unlinkDeferred(ctx, tx, cc, cr, parent, ids)
return err
}
result.Removed, err = s.unlinkRelated(ctx, tx, cc, cr, parent.model, ids)
return err
})
return result, err
}
// unlinkRelated detaches ids from the saved parent inside tx (the shared
// unlink path of the unlink route and of the deferred commit).
func (s RelationService) unlinkRelated(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent any, ids []uint) (int, error) {
if cr.hasMany() {
target := cr.Contract.NewRelated()
holder := reflect.New(reflect.SliceOf(reflect.TypeOf(target).Elem()))
err := tx.WithContext(ctx).Model(target).Clauses(clause.Locking{Strength: "UPDATE"}).
Where(clause.Eq{Column: clause.Column{Name: cr.Contract.ForeignKey}, Value: pkUint(parent)}).
Where(clause.IN{Column: clause.Column{Name: primaryColumn(target)}, Values: uintValues(ids)}).
Order(clause.OrderByColumn{Column: clause.Column{Name: primaryColumn(target)}}).
Find(holder.Interface()).Error
if err != nil {
return 0, err
}
for i := 0; i < holder.Elem().Len(); i++ {
child := holder.Elem().Index(i).Addr().Interface()
if err := setModelColumn(child, cr.Contract.ForeignKey, nil); err != nil {
return 0, lifecycleFailure(cc, err)
}
if err := tx.WithContext(ctx).Save(child).Error; err != nil {
return 0, lifecycleFailure(cc, err)
}
}
return holder.Elem().Len(), nil
}
rows, err := lockPivotRows(ctx, tx, cr, pkUint(parent), ids)
if err != nil {
return 0, err
}
for _, row := range rows {
if err := tx.WithContext(ctx).Delete(row).Error; err != nil {
return 0, err
}
}
return len(rows), nil
}
// lockPivotRows loads this parent's pivot rows for the related ids FOR
// UPDATE, in related id order.
func lockPivotRows(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, ownerPK uint, ids []uint) ([]any, error) {
proto := cr.Contract.NewPivot()
holder := reflect.New(reflect.SliceOf(reflect.TypeOf(proto).Elem()))
err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(proto).Clauses(clause.Locking{Strength: "UPDATE"}).
Where(clause.Eq{Column: clause.Column{Name: cr.Contract.ParentForeignKey}, Value: ownerPK}).
Where(clause.IN{Column: clause.Column{Name: cr.Contract.RelatedForeignKey}, Values: uintValues(ids)}).
Order(clause.OrderByColumn{Column: clause.Column{Name: cr.Contract.RelatedForeignKey}}).
Find(holder.Interface()).Error
if err != nil {
return nil, err
}
out := make([]any, holder.Elem().Len())
for i := range out {
out[i] = holder.Elem().Index(i).Addr().Interface()
}
return out, nil
}
func relationOf(cc *CompiledController, name string) (*CompiledRelation, error) {
if cc == nil || !identifier(name) || cc.Relations == nil || cc.Relations[name] == nil {
return nil, recordNotFound{}
}
return cc.Relations[name], nil
}
func pendingRelationIDs(tx *gorm.DB, cr *CompiledRelation, ownerID uint, ids []uint) ([]uint, error) {
var existing []uint
err := tx.Table(tableName(cr.Contract.NewPivot())).
Where(clause.Eq{Column: clause.Column{Name: cr.Contract.ParentForeignKey}, Value: ownerID}).
Where(clause.IN{Column: clause.Column{Name: cr.Contract.RelatedForeignKey}, Values: uintValues(ids)}).
Pluck(cr.Contract.RelatedForeignKey, &existing).Error
if err != nil {
return nil, err
}
seen := map[uint]struct{}{}
for _, id := range existing {
seen[id] = struct{}{}
}
pending := make([]uint, 0, len(ids)-len(seen))
for _, id := range ids {
if _, ok := seen[id]; !ok {
pending = append(pending, id)
}
}
return pending, nil
}
func uintValues(ids []uint) []any {
out := make([]any, len(ids))
for i, id := range ids {
out[i] = id
}
return out
}
func stringSliceHasValue(values []string, key string) bool {
for _, value := range values {
if value == key {
return true
}
}
return false
}
func setModelColumn(model any, column string, value any) error {
v := reflect.ValueOf(model)
if v.Kind() != reflect.Pointer || v.IsNil() {
return errors.New("cabana: pivot model must be a pointer")
}
v = v.Elem()
if v.Kind() != reflect.Struct {
return errors.New("cabana: pivot model must be a struct pointer")
}
field := fieldByColumn(v, column)
if !field.IsValid() || !field.CanSet() {
return fmt.Errorf("cabana: pivot column %s is not writable", column)
}
return assignReflect(field, value)
}
func assignReflect(dst reflect.Value, value any) error {
if value == nil {
if dst.Kind() == reflect.Pointer {
dst.Set(reflect.Zero(dst.Type()))
return nil
}
return errors.New("cabana: nil pivot value")
}
src := reflect.ValueOf(value)
if src.Type().AssignableTo(dst.Type()) {
dst.Set(src)
return nil
}
if dst.Kind() == reflect.Pointer {
if src.Kind() == reflect.Pointer && src.Type().AssignableTo(dst.Type()) {
dst.Set(src)
return nil
}
ptr := reflect.New(dst.Type().Elem())
if src.Type().AssignableTo(dst.Type().Elem()) {
ptr.Elem().Set(src)
dst.Set(ptr)
return nil
}
if src.Type().ConvertibleTo(dst.Type().Elem()) {
ptr.Elem().Set(src.Convert(dst.Type().Elem()))
dst.Set(ptr)
return nil
}
}
if src.Type().ConvertibleTo(dst.Type()) {
dst.Set(src.Convert(dst.Type()))
return nil
}
return fmt.Errorf("cabana: pivot value %T is not assignable to %s", value, dst.Type())
}