Files
summercms/modules/cabana/relation_field.go
Jakub Zych 5e50b166ef refactor(10.2-01): nest framework packages under modules
- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
2026-09-28 02:21:02 +02:00

653 lines
21 KiB
Go

package cabana
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"net/http"
"reflect"
"sort"
"strings"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// FieldRelationContract binds one fields.yaml `type: relation` field to its
// related model (D-17, D-18). Framework code never guesses a plugin table,
// pivot or foreign key: every name below comes from the controller.
type FieldRelationContract struct {
// Field is the fields.yaml key ("genre", "artists").
Field string
// Kind is "belongsTo" or "belongsToMany".
Kind string
// NewRelated returns a pointer to the related model.
NewRelated func() any
// ForeignKey is the belongsTo column on the parent model.
ForeignKey string
// NewPivot returns a pointer to the belongsToMany join model.
NewPivot func() any
// ParentForeignKey and RelatedForeignKey are the pivot columns.
ParentForeignKey string
RelatedForeignKey string
// OrderColumn is an optional pivot column set to the submitted array index.
OrderColumn string
// LabelColumn is the physical label column on the related model. Empty
// means the field's nameFrom, mapped through pact.ListRelationColumnMapper
// when the controller implements it.
LabelColumn string
}
// FieldRelationProvider is implemented by admin controllers whose form
// declares `type: relation` fields.
type FieldRelationProvider interface {
AdminFieldRelations() []FieldRelationContract
}
// RelationOption is one relation choice or label: the related primary key and
// its label column value.
type RelationOption struct {
Value uint `json:"value"`
Label string `json:"label"`
}
// RecordMeta is the record envelope meta: display labels per relation field,
// in the same order as the ids in data.
type RecordMeta struct {
Labels map[string][]RelationOption `json:"labels"`
}
// RecordEnvelope is the show, create and update response.
type RecordEnvelope struct {
Data AdminRecord `json:"data"`
Meta RecordMeta `json:"meta"`
}
// RecordResult is one projected record plus its relation labels.
type RecordResult struct {
Data map[string]any
Meta RecordMeta
}
// CompiledFieldRelation is one relation field after activation checks.
type CompiledFieldRelation struct {
Contract FieldRelationContract
// LabelColumn is the resolved physical label column.
LabelColumn string
// Multiple is true for belongsToMany.
Multiple bool
// ReadOnly is true for a belongsTo whose foreign key is a protected fill
// key (D-26): it is shown with its label but never written and has no
// options endpoint.
ReadOnly bool
// Nullable is true when a belongsTo foreign key accepts null.
Nullable bool
}
const (
relationKindBelongsTo = "belongsTo"
relationKindBelongsToMany = "belongsToMany"
)
// compileFieldRelations binds every `type: relation` field to exactly one
// controller contract and marks the compiled form fields multiple or
// read-only. Any mismatch is a boot error naming plugin, controller and field.
func compileFieldRelations(pluginID string, ctl pact.AdminController, form *FormSchema) (map[string]*CompiledFieldRelation, error) {
out := map[string]*CompiledFieldRelation{}
fail := func(field string, err error) error {
return bootErr(pluginID, ctl.ID(), "config_form.yaml", fmt.Errorf("field %s: %w", field, err))
}
indexes := map[string]int{}
var order []string
if form != nil {
for i, field := range form.Fields {
if field.Type == "relation" {
indexes[field.Name] = i
order = append(order, field.Name)
}
}
}
provider, hasProvider := ctl.(FieldRelationProvider)
var contracts []FieldRelationContract
if hasProvider && provider != nil {
contracts = provider.AdminFieldRelations()
}
if len(order) == 0 && len(contracts) == 0 {
return out, nil
}
byField := map[string]FieldRelationContract{}
for _, contract := range contracts {
if _, dup := byField[contract.Field]; dup {
return nil, fail(contract.Field, errors.New("duplicate relation contract"))
}
byField[contract.Field] = contract
if _, ok := indexes[contract.Field]; !ok {
return nil, fail(contract.Field, errors.New("relation contract names a field that is not a relation field in fields.yaml"))
}
}
src, ok := ctl.(pact.AdminRecordSource)
if !ok || src == nil || src.NewRecord() == nil {
return nil, fail(order[0], errors.New("relation fields require an AdminRecordSource"))
}
parent := src.NewRecord()
for _, name := range order {
contract, declared := byField[name]
if !declared {
if !hasProvider {
return nil, fail(name, errors.New("type relation requires AdminFieldRelations on the controller"))
}
return nil, fail(name, errors.New("has no relation contract"))
}
field := &form.Fields[indexes[name]]
compiled, err := compileFieldRelation(ctl, *field, contract, parent)
if err != nil {
return nil, fail(name, err)
}
field.Multiple = compiled.Multiple
field.ReadOnly = compiled.ReadOnly
out[name] = compiled
}
return out, nil
}
func compileFieldRelation(ctl pact.AdminController, field FormField, contract FieldRelationContract, parent any) (*CompiledFieldRelation, error) {
if contract.NewRelated == nil || contract.NewRelated() == nil {
return nil, errors.New("relation contract requires a related model")
}
related := contract.NewRelated()
relatedCols := modelColumns(related)
if _, ok := relatedCols[primaryColumn(related)]; !ok {
return nil, fmt.Errorf("related model has no primary key column %s", primaryColumn(related))
}
label := strings.TrimSpace(contract.LabelColumn)
if label == "" {
label = field.NameFrom
if mapper, ok := ctl.(pact.ListRelationColumnMapper); ok && mapper != nil && label != "" {
if mapped, ok := mapper.ListRelationColumn(field.Name, label); ok && mapped != "" {
label = mapped
}
}
}
if !identifier(label) {
return nil, errors.New("relation needs nameFrom or a LabelColumn")
}
if _, ok := relatedCols[label]; !ok {
return nil, fmt.Errorf("related model is missing label column %s", label)
}
out := &CompiledFieldRelation{Contract: contract, LabelColumn: label}
parentCols := modelColumns(parent)
switch contract.Kind {
case relationKindBelongsTo:
if contract.NewPivot != nil || contract.ParentForeignKey != "" || contract.RelatedForeignKey != "" || contract.OrderColumn != "" {
return nil, errors.New("belongsTo cannot declare pivot columns")
}
if !identifier(contract.ForeignKey) {
return nil, errors.New("belongsTo requires a ForeignKey")
}
if _, ok := parentCols[contract.ForeignKey]; !ok {
return nil, fmt.Errorf("parent model is missing foreign key column %s", contract.ForeignKey)
}
fk, ok := structFieldByColumn(parent, contract.ForeignKey)
if !ok || !uintLike(fk.Type) {
return nil, fmt.Errorf("foreign key %s must be an unsigned integer column", contract.ForeignKey)
}
out.Nullable = fk.Type.Kind() == reflect.Pointer
out.ReadOnly = protectedFillKey(contract.ForeignKey)
case relationKindBelongsToMany:
if contract.ForeignKey != "" {
return nil, errors.New("belongsToMany cannot declare a ForeignKey")
}
if contract.NewPivot == nil || contract.NewPivot() == nil {
return nil, errors.New("belongsToMany requires a pivot model")
}
pivot := contract.NewPivot()
if reflect.TypeOf(pivot).Kind() != reflect.Pointer || reflect.TypeOf(pivot).Elem().Kind() != reflect.Struct {
return nil, errors.New("pivot model must be a struct pointer")
}
pivotCols := modelColumns(pivot)
for _, col := range []string{contract.ParentForeignKey, contract.RelatedForeignKey} {
if !identifier(col) {
return nil, errors.New("belongsToMany requires ParentForeignKey and RelatedForeignKey")
}
if _, ok := pivotCols[col]; !ok {
return nil, fmt.Errorf("pivot model is missing column %s", col)
}
}
if contract.ParentForeignKey == contract.RelatedForeignKey {
return nil, errors.New("pivot foreign keys must differ")
}
if contract.OrderColumn != "" {
if !identifier(contract.OrderColumn) {
return nil, fmt.Errorf("order column %q is not an identifier", contract.OrderColumn)
}
if _, ok := pivotCols[contract.OrderColumn]; !ok {
return nil, fmt.Errorf("pivot model is missing order column %s", contract.OrderColumn)
}
if contract.OrderColumn == contract.ParentForeignKey || contract.OrderColumn == contract.RelatedForeignKey {
return nil, errors.New("order column must not be a pivot foreign key")
}
}
out.Multiple = true
default:
return nil, fmt.Errorf("unknown relation kind %s (want belongsTo or belongsToMany)", contract.Kind)
}
return out, nil
}
func structFieldByColumn(model any, column string) (reflect.StructField, bool) {
t := reflect.TypeOf(model)
for t != nil && t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t == nil || t.Kind() != reflect.Struct {
return reflect.StructField{}, false
}
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if field.PkgPath == "" && gormColumn(field) == column {
return field, true
}
}
return reflect.StructField{}, false
}
func uintLike(t reflect.Type) bool {
if t.Kind() == reflect.Pointer {
t = t.Elem()
}
switch t.Kind() {
case reflect.Uint, reflect.Uint32, reflect.Uint64, reflect.Int, reflect.Int32, reflect.Int64:
return true
default:
return false
}
}
// writableFieldRelation returns a relation field that offers options and
// accepts values. Read-only and unknown fields are not found.
func writableFieldRelation(cc *CompiledController, name string) (*CompiledFieldRelation, bool) {
if cc == nil || !identifier(name) {
return nil, false
}
fr, ok := cc.FieldRelations[name]
if !ok || fr == nil || fr.ReadOnly {
return nil, false
}
return fr, true
}
// scopedRelationQuery is the one query that both serves options and
// revalidates submitted ids, so the options hook is never only cosmetic.
func scopedRelationQuery(ctx context.Context, db *gorm.DB, cc *CompiledController, field string, fr *CompiledFieldRelation) *gorm.DB {
q := db.WithContext(ctx).Model(fr.Contract.NewRelated())
if ext, ok := cc.Controller.(pact.RelationExtendOptionsQuery); ok && ext != nil {
if next := ext.RelationExtendOptionsQuery(ctx, field, q); next != nil {
q = next
}
}
return q
}
func labelExpression(db *gorm.DB, table, column string) string {
return "COALESCE(CAST(" + quotedIdent(db, table) + "." + quotedIdent(db, column) + " AS TEXT), '')"
}
// RelationOptions serves one page of a relation field's choices (D-17):
// scoped by RelationExtendOptionsQuery, searched case-insensitively on the
// label column, ordered by label then primary key.
func (s CRUDService) RelationOptions(ctx context.Context, cc *CompiledController, field string, in RelationQuery) ([]RelationOption, ListMeta, error) {
if s.DB == nil {
return nil, ListMeta{}, errors.New("cabana: database is not configured")
}
if ctx == nil {
ctx = context.Background()
}
fr, ok := writableFieldRelation(cc, field)
if !ok {
return nil, ListMeta{}, recordNotFound{}
}
page, per, err := normalizeRelationPage(in.Page, in.PerPage)
if err != nil {
return nil, ListMeta{}, err
}
related := fr.Contract.NewRelated()
table := tableName(related)
pk := primaryColumn(related)
q := scopedRelationQuery(ctx, s.DB, cc, field, fr)
label := labelExpression(q, table, fr.LabelColumn)
if term := strings.TrimSpace(in.Search); term != "" {
q = q.Where(label+" ILIKE ? ESCAPE '\\'", "%"+escapeLike(term)+"%")
}
var total int64
if err := q.Session(&gorm.Session{}).Count(&total).Error; err != nil {
return nil, ListMeta{}, lifecycleFailure(cc, err)
}
rows := make([]RelationOption, 0)
err = q.Select(quotedIdent(q, table) + "." + quotedIdent(q, pk) + " AS value, " + label + " AS label").
Order(label).
Order(clause.OrderByColumn{Column: clause.Column{Table: table, Name: pk}}).
Offset((page - 1) * per).Limit(per).
Scan(&rows).Error
if err != nil {
return nil, ListMeta{}, lifecycleFailure(cc, err)
}
last := 1
if total > 0 {
last = int((total + int64(per) - 1) / int64(per))
}
return rows, ListMeta{Page: page, PerPage: per, Total: total, LastPage: last}, nil
}
// relationValue is one present, writable relation key lifted from a body.
type relationValue struct {
field string
fr *CompiledFieldRelation
ids []uint
null bool
}
// liftRelationValues takes the writable relation keys present in body. Read
// only fields, fields outside the operation's context and absent keys are
// skipped. Shape errors are one validation_failed with every bad field.
func liftRelationValues(cc *CompiledController, body map[string]any, op string) ([]relationValue, error) {
if cc == nil || len(cc.FieldRelations) == 0 || body == nil {
return nil, nil
}
names := make([]string, 0, len(cc.FieldRelations))
for name := range cc.FieldRelations {
names = append(names, name)
}
sort.Strings(names)
details := map[string]any{}
var out []relationValue
for _, name := range names {
fr := cc.FieldRelations[name]
if fr == nil || fr.ReadOnly || !contextAllows(cc, name, op) {
continue
}
raw, present := body[name]
if !present {
continue
}
value := relationValue{field: name, fr: fr}
if fr.Multiple {
items, ok := raw.([]any)
if !ok {
details[name] = []string{"The " + name + " field must be a list of ids."}
continue
}
seen := map[uint]struct{}{}
ids := make([]uint, 0, len(items))
bad := ""
for _, item := range items {
id, err := relationID(item)
if err != nil {
bad = "The " + name + " field must be a list of integer ids."
break
}
if _, dup := seen[id]; dup {
bad = "The " + name + " field contains a duplicate id."
break
}
seen[id] = struct{}{}
ids = append(ids, id)
}
if bad != "" {
details[name] = []string{bad}
continue
}
value.ids = ids
} else if raw == nil {
if !fr.Nullable {
details[name] = []string{"The " + name + " field cannot be empty."}
continue
}
value.null = true
} else {
id, err := relationID(raw)
if err != nil {
details[name] = []string{"The " + name + " field must be an integer id."}
continue
}
value.ids = []uint{id}
}
out = append(out, value)
}
if len(details) > 0 {
return nil, &ValidationError{Details: details}
}
return out, nil
}
// relationID accepts a JSON integer only: no strings, booleans or fractions.
func relationID(v any) (uint, error) {
switch n := v.(type) {
case json.Number:
return asUint(n)
case float64:
if n != math.Trunc(n) {
return 0, errBadID
}
return asUint(n)
case int, int64, uint, uint32, uint64:
return asUint(n)
default:
return 0, errBadID
}
}
// checkRelationScope re-runs the scoped options query for every submitted id
// inside the save transaction. An id the query does not return (unknown or
// out of scope) is validation_failed on that field and rolls the save back.
func checkRelationScope(ctx context.Context, tx *gorm.DB, cc *CompiledController, values []relationValue) error {
details := map[string]any{}
for _, value := range values {
if len(value.ids) == 0 {
continue
}
related := value.fr.Contract.NewRelated()
table := tableName(related)
pk := primaryColumn(related)
var found []uint
err := scopedRelationQuery(ctx, tx, cc, value.field, value.fr).
Where(clause.IN{Column: clause.Column{Table: table, Name: pk}, Values: uintValues(value.ids)}).
Pluck(quotedIdent(tx, table)+"."+quotedIdent(tx, pk), &found).Error
if err != nil {
return lifecycleFailure(cc, err)
}
have := map[uint]struct{}{}
for _, id := range found {
have[id] = struct{}{}
}
for _, id := range value.ids {
if _, ok := have[id]; !ok {
details[value.field] = []string{"The selected " + value.field + " is invalid."}
break
}
}
}
if len(details) > 0 {
return &ValidationError{Details: details}
}
return nil
}
// assignBelongsTo writes validated belongsTo ids (or null) onto the parent's
// foreign key before the row write. Read-only keys never reach this point.
func assignBelongsTo(cc *CompiledController, model any, values []relationValue) error {
for _, value := range values {
if value.fr.Multiple || value.fr.ReadOnly || protectedFillKey(value.fr.Contract.ForeignKey) {
continue
}
var id any
if !value.null {
id = value.ids[0]
}
if err := setModelColumn(model, value.fr.Contract.ForeignKey, id); err != nil {
return lifecycleFailure(cc, err)
}
}
return nil
}
// syncBelongsToMany replaces the parent's pivot rows in submitted order: an
// explicit delete, then one bulk insert (Phase 5 join-table contract).
func syncBelongsToMany(ctx context.Context, tx *gorm.DB, cc *CompiledController, model any, values []relationValue) error {
parentPK := pkUint(model)
for _, value := range values {
if !value.fr.Multiple {
continue
}
c := value.fr.Contract
proto := c.NewPivot()
err := tx.WithContext(ctx).Unscoped().
Where(clause.Eq{Column: clause.Column{Name: c.ParentForeignKey}, Value: parentPK}).
Delete(proto).Error
if err != nil {
return lifecycleFailure(cc, err)
}
if len(value.ids) == 0 {
continue
}
rows := reflect.MakeSlice(reflect.SliceOf(reflect.TypeOf(proto).Elem()), 0, len(value.ids))
for i, id := range value.ids {
pivot := c.NewPivot()
if err := setModelColumn(pivot, c.ParentForeignKey, parentPK); err != nil {
return lifecycleFailure(cc, err)
}
if err := setModelColumn(pivot, c.RelatedForeignKey, id); err != nil {
return lifecycleFailure(cc, err)
}
if c.OrderColumn != "" {
if err := setModelColumn(pivot, c.OrderColumn, i); err != nil {
return lifecycleFailure(cc, err)
}
}
rows = reflect.Append(rows, reflect.ValueOf(pivot).Elem())
}
holder := reflect.New(rows.Type())
holder.Elem().Set(rows)
if err := tx.WithContext(ctx).Create(holder.Interface()).Error; err != nil {
return lifecycleFailure(cc, err)
}
}
return nil
}
// projectRelationFields adds every relation field's value to data (belongsTo
// id or null; belongsToMany ids in pivot order, then related id) and returns
// the labels in the same order. Read-only fields are included.
func projectRelationFields(ctx context.Context, tx *gorm.DB, cc *CompiledController, model any, data map[string]any) (RecordMeta, error) {
meta := RecordMeta{Labels: map[string][]RelationOption{}}
if cc == nil || len(cc.FieldRelations) == 0 {
return meta, nil
}
v := reflect.ValueOf(model)
for v.Kind() == reflect.Pointer {
v = v.Elem()
}
for name, fr := range cc.FieldRelations {
c := fr.Contract
var ids []uint
if fr.Multiple {
ids = []uint{}
q := tx.WithContext(ctx).Model(c.NewPivot()).
Where(clause.Eq{Column: clause.Column{Name: c.ParentForeignKey}, Value: pkUint(model)})
if c.OrderColumn != "" {
q = q.Order(clause.OrderByColumn{Column: clause.Column{Name: c.OrderColumn}})
}
if err := q.Order(clause.OrderByColumn{Column: clause.Column{Name: c.RelatedForeignKey}}).Pluck(c.RelatedForeignKey, &ids).Error; err != nil {
return RecordMeta{}, lifecycleFailure(cc, err)
}
data[name] = ids
} else {
data[name] = nil
if id, ok := foreignKeyValue(v, c.ForeignKey); ok {
data[name] = id
ids = []uint{id}
}
}
labels, err := relationLabels(ctx, tx, fr, ids)
if err != nil {
return RecordMeta{}, lifecycleFailure(cc, err)
}
meta.Labels[name] = labels
}
return meta, nil
}
func foreignKeyValue(v reflect.Value, column string) (uint, bool) {
field := fieldByColumn(v, column)
if !field.IsValid() {
return 0, false
}
for field.Kind() == reflect.Pointer {
if field.IsNil() {
return 0, false
}
field = field.Elem()
}
if !field.CanInterface() {
return 0, false
}
id, err := asUint(field.Interface())
if err != nil || id == 0 {
return 0, false
}
return id, true
}
func relationLabels(ctx context.Context, tx *gorm.DB, fr *CompiledFieldRelation, ids []uint) ([]RelationOption, error) {
out := make([]RelationOption, 0, len(ids))
if len(ids) == 0 {
return out, nil
}
related := fr.Contract.NewRelated()
table := tableName(related)
pk := primaryColumn(related)
var rows []RelationOption
err := tx.WithContext(ctx).Model(related).
Select(quotedIdent(tx, table) + "." + quotedIdent(tx, pk) + " AS value, " + labelExpression(tx, table, fr.LabelColumn) + " AS label").
Where(clause.IN{Column: clause.Column{Table: table, Name: pk}, Values: uintValues(ids)}).
Scan(&rows).Error
if err != nil {
return nil, err
}
byID := make(map[uint]string, len(rows))
for _, row := range rows {
byID[row.Value] = row.Label
}
for _, id := range ids {
if label, ok := byID[id]; ok {
out = append(out, RelationOption{Value: id, Label: label})
}
}
return out, nil
}
// fieldOptions serves GET /{vendor}/{plugin}/{controller}/fields/{field}/options.
func (s *service) fieldOptions(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
field := r.PathValue("field")
if _, ok := writableFieldRelation(cc, field); !ok {
writeNotFound(w, r)
return
}
svc, err := s.crud()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
q := r.URL.Query()
rows, meta, err := svc.RelationOptions(r.Context(), cc, field, RelationQuery{Search: q.Get("search"), Page: q.Get("page"), PerPage: q.Get("per_page")})
if err != nil {
writeCRUDError(w, err)
return
}
WriteData(w, http.StatusOK, rows, meta)
})
}