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 _, mf := range modelFields(t) { if gormColumn(mf.Field) == column { return mf.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) }) }