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
This commit is contained in:
652
modules/cabana/relation_field.go
Normal file
652
modules/cabana/relation_field.go
Normal file
@@ -0,0 +1,652 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user