feat(09-10): add typed relation manager
This commit is contained in:
@@ -69,6 +69,7 @@ type CompiledController struct {
|
||||
Controller pact.AdminController
|
||||
List *ListSchema
|
||||
Form *FormSchema
|
||||
Relations map[string]*CompiledRelation
|
||||
Writable []WritableField
|
||||
}
|
||||
|
||||
|
||||
138
cabana/http.go
138
cabana/http.go
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strconv"
|
||||
@@ -127,6 +128,8 @@ func (s *service) mount(r pact.Router) {
|
||||
constrainController(g)
|
||||
g.Get("/{vendor}/{plugin}/{controller}/schema/form", s.formSchema)
|
||||
constrainController(g)
|
||||
g.Get("/{vendor}/{plugin}/{controller}/schema/relation/{name}", s.relationSchema)
|
||||
constrainRelation(g)
|
||||
g.Get("/{vendor}/{plugin}/{controller}", s.list)
|
||||
constrainController(g)
|
||||
g.Post("/{vendor}/{plugin}/{controller}", s.create)
|
||||
@@ -139,6 +142,14 @@ func (s *service) mount(r pact.Router) {
|
||||
constrainController(g)
|
||||
g.Delete("/{vendor}/{plugin}/{controller}/{id}", s.deleteRecord)
|
||||
constrainController(g)
|
||||
g.Get("/{vendor}/{plugin}/{controller}/{id}/relations/{name}", s.relationLinked)
|
||||
constrainRelation(g)
|
||||
g.Get("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates", s.relationCandidates)
|
||||
constrainRelation(g)
|
||||
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", s.relationLink)
|
||||
constrainRelation(g)
|
||||
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", s.relationUnlink)
|
||||
constrainRelation(g)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -148,6 +159,129 @@ func constrainController(g pact.Router) {
|
||||
g.Where("controller", "[A-Za-z0-9_-]+")
|
||||
}
|
||||
|
||||
func constrainRelation(g pact.Router) {
|
||||
constrainController(g)
|
||||
g.Where("name", "[A-Za-z_][A-Za-z0-9_]*")
|
||||
}
|
||||
|
||||
func (s *service) relationSchema(w http.ResponseWriter, r *http.Request) {
|
||||
s.protect(w, r, func(cc *CompiledController) {
|
||||
cr, err := relationOf(cc, r.PathValue("name"))
|
||||
if err != nil {
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
}
|
||||
tr := s.translator()
|
||||
view := cr.Schema.Localize(r.Context(), tr)
|
||||
meta := map[string]any{}
|
||||
if locale := schemaLocale(r.Context(), tr); locale != "" {
|
||||
meta["locale"] = locale
|
||||
}
|
||||
WriteData(w, http.StatusOK, view, meta)
|
||||
})
|
||||
}
|
||||
|
||||
func relationQueryFromRequest(r *http.Request) RelationQuery {
|
||||
q := r.URL.Query()
|
||||
return RelationQuery{Search: q.Get("search"), Sort: q.Get("sort"), Dir: q.Get("dir"), Page: q.Get("page"), PerPage: q.Get("per_page")}
|
||||
}
|
||||
|
||||
func (s *service) relationLinked(w http.ResponseWriter, r *http.Request) {
|
||||
s.relationList(w, r, false)
|
||||
}
|
||||
|
||||
func (s *service) relationCandidates(w http.ResponseWriter, r *http.Request) {
|
||||
s.relationList(w, r, true)
|
||||
}
|
||||
|
||||
func (s *service) relationList(w http.ResponseWriter, r *http.Request, candidates bool) {
|
||||
s.protect(w, r, func(cc *CompiledController) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
}
|
||||
svc, err := s.relations()
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
var result *RelationResult
|
||||
if candidates {
|
||||
result, err = svc.Candidates(r.Context(), cc, r.PathValue("name"), id, relationQueryFromRequest(r))
|
||||
} else {
|
||||
result, err = svc.Linked(r.Context(), cc, r.PathValue("name"), id, relationQueryFromRequest(r))
|
||||
}
|
||||
if err != nil {
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
}
|
||||
WriteData(w, http.StatusOK, result.Data, map[string]any{"page": result.Meta.Page, "per_page": result.Meta.PerPage, "total": result.Meta.Total, "last_page": result.Meta.LastPage})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *service) relationLink(w http.ResponseWriter, r *http.Request) {
|
||||
s.relationMutation(w, r, true)
|
||||
}
|
||||
|
||||
func (s *service) relationUnlink(w http.ResponseWriter, r *http.Request) {
|
||||
s.relationMutation(w, r, false)
|
||||
}
|
||||
|
||||
func (s *service) relationMutation(w http.ResponseWriter, r *http.Request, link bool) {
|
||||
s.protect(w, r, func(cc *CompiledController) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
}
|
||||
in, err := decodeRelationMutation(r)
|
||||
if err != nil {
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
}
|
||||
svc, err := s.relations()
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
var result RelationMutationResult
|
||||
if link {
|
||||
result, err = svc.Link(r.Context(), cc, r.PathValue("name"), id, in)
|
||||
} else {
|
||||
result, err = svc.Unlink(r.Context(), cc, r.PathValue("name"), id, in)
|
||||
}
|
||||
if err != nil {
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
}
|
||||
WriteData(w, http.StatusOK, result, nil)
|
||||
})
|
||||
}
|
||||
|
||||
func decodeRelationMutation(r *http.Request) (RelationMutationInput, error) {
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.UseNumber()
|
||||
dec.DisallowUnknownFields()
|
||||
var in RelationMutationInput
|
||||
if err := dec.Decode(&in); err != nil {
|
||||
return RelationMutationInput{}, relationInvalid("body", "The request body is invalid.")
|
||||
}
|
||||
var trailing any
|
||||
if err := dec.Decode(&trailing); err != io.EOF {
|
||||
return RelationMutationInput{}, relationInvalid("body", "The request body is invalid.")
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
|
||||
func (s *service) relations() (RelationService, error) {
|
||||
db, err := s.db()
|
||||
if err != nil {
|
||||
return RelationService{}, err
|
||||
}
|
||||
return RelationService{DB: db}, nil
|
||||
}
|
||||
|
||||
func (s *service) formSchema(w http.ResponseWriter, r *http.Request) {
|
||||
s.protect(w, r, func(cc *CompiledController) {
|
||||
if cc.Form == nil {
|
||||
@@ -373,7 +507,7 @@ func (s *service) protect(w http.ResponseWriter, r *http.Request, fn func(*Compi
|
||||
fn(cc)
|
||||
}
|
||||
|
||||
func projectRow(row any, cols []ListColumn) map[string]any {
|
||||
func projectRow(row any, controller pact.AdminController, cols []ListColumn) map[string]any {
|
||||
v := reflect.ValueOf(row)
|
||||
for v.Kind() == reflect.Pointer {
|
||||
if v.IsNil() {
|
||||
@@ -387,7 +521,7 @@ func projectRow(row any, cols []ListColumn) map[string]any {
|
||||
}
|
||||
for _, col := range cols {
|
||||
if col.Relation != "" {
|
||||
if value, ok := relatedSelect(v, col); ok {
|
||||
if value, ok := relatedSelect(v, controller, col); ok {
|
||||
out[col.Key] = value
|
||||
}
|
||||
continue
|
||||
|
||||
@@ -111,7 +111,7 @@ func ExecuteList(ctx context.Context, db *gorm.DB, cc *CompiledController, in Li
|
||||
q = next
|
||||
}
|
||||
}
|
||||
q, err = applyListSearch(q, cc.List, model, in.Search)
|
||||
q, err = applyListSearch(q, cc.Controller, cc.List, model, in.Search)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -123,7 +123,7 @@ func ExecuteList(ctx context.Context, db *gorm.DB, cc *CompiledController, in Li
|
||||
if err := q.Session(&gorm.Session{}).Count(&total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q = applyListOrder(q, cc.List, model, sortCol, sortDesc)
|
||||
q = applyListOrder(q, cc.Controller, cc.List, model, sortCol, sortDesc)
|
||||
for _, col := range cc.List.Columns {
|
||||
if col.Relation == "" {
|
||||
continue
|
||||
@@ -140,7 +140,7 @@ func ExecuteList(ctx context.Context, db *gorm.DB, cc *CompiledController, in Li
|
||||
values := slice.Elem()
|
||||
data := make([]map[string]any, 0, values.Len())
|
||||
for i := 0; i < values.Len(); i++ {
|
||||
data = append(data, projectRow(values.Index(i).Addr().Interface(), cc.List.Columns))
|
||||
data = append(data, projectRow(values.Index(i).Addr().Interface(), cc.Controller, cc.List.Columns))
|
||||
}
|
||||
paged := lagoon.Paginate(data, page, per, total)
|
||||
return &ListResult{
|
||||
@@ -223,7 +223,7 @@ func normalizeSort(schema *ListSchema, in ListInput) (string, bool, error) {
|
||||
return match.Key, dir == "desc", nil
|
||||
}
|
||||
|
||||
func applyListOrder(db *gorm.DB, schema *ListSchema, model any, column string, desc bool) *gorm.DB {
|
||||
func applyListOrder(db *gorm.DB, controller pact.AdminController, schema *ListSchema, model any, column string, desc bool) *gorm.DB {
|
||||
table := tableName(model)
|
||||
pk := primaryColumn(model)
|
||||
if column != "" {
|
||||
@@ -238,7 +238,7 @@ func applyListOrder(db *gorm.DB, schema *ListSchema, model any, column string, d
|
||||
continue
|
||||
}
|
||||
orderTable = field
|
||||
orderColumn = col.Select
|
||||
orderColumn = listRelationColumn(controller, col.Relation, col.Select)
|
||||
db = db.Joins(field)
|
||||
break
|
||||
}
|
||||
@@ -253,7 +253,7 @@ func applyListOrder(db *gorm.DB, schema *ListSchema, model any, column string, d
|
||||
return db
|
||||
}
|
||||
|
||||
func applyListSearch(db *gorm.DB, schema *ListSchema, model any, term string) (*gorm.DB, error) {
|
||||
func applyListSearch(db *gorm.DB, controller pact.AdminController, schema *ListSchema, model any, term string) (*gorm.DB, error) {
|
||||
term = strings.TrimSpace(term)
|
||||
if term == "" {
|
||||
return db, nil
|
||||
@@ -277,12 +277,13 @@ func applyListSearch(db *gorm.DB, schema *ListSchema, model any, term string) (*
|
||||
column := col.Key
|
||||
if col.Relation != "" {
|
||||
field, ok := relationFieldName(model, col.Relation)
|
||||
if !ok || !identifier(col.Select) {
|
||||
physical := listRelationColumn(controller, col.Relation, col.Select)
|
||||
if !ok || !identifier(physical) {
|
||||
return nil, listInvalid("search", "is not searchable")
|
||||
}
|
||||
// GORM aliases the joined association with the Go field name.
|
||||
table = field
|
||||
column = col.Select
|
||||
column = physical
|
||||
if _, done := joined[field]; !done {
|
||||
db = db.Joins(field)
|
||||
joined[field] = struct{}{}
|
||||
@@ -496,7 +497,7 @@ func primaryColumn(model any) string {
|
||||
return "id"
|
||||
}
|
||||
|
||||
func relatedSelect(row reflect.Value, col ListColumn) (any, bool) {
|
||||
func relatedSelect(row reflect.Value, controller pact.AdminController, col ListColumn) (any, bool) {
|
||||
for row.Kind() == reflect.Pointer {
|
||||
if row.IsNil() {
|
||||
return nil, false
|
||||
@@ -523,9 +524,21 @@ func relatedSelect(row reflect.Value, col ListColumn) (any, bool) {
|
||||
if field.Kind() != reflect.Struct {
|
||||
return nil, false
|
||||
}
|
||||
selected := fieldByColumn(field, col.Select)
|
||||
selected := fieldByColumn(field, listRelationColumn(controller, col.Relation, col.Select))
|
||||
if !selected.IsValid() || !selected.CanInterface() {
|
||||
return nil, false
|
||||
}
|
||||
return selected.Interface(), true
|
||||
}
|
||||
|
||||
func listRelationColumn(controller pact.AdminController, relation, column string) string {
|
||||
mapper, ok := controller.(pact.ListRelationColumnMapper)
|
||||
if !ok || mapper == nil {
|
||||
return column
|
||||
}
|
||||
mapped, ok := mapper.ListRelationColumn(relation, column)
|
||||
if !ok || mapped == "" {
|
||||
return column
|
||||
}
|
||||
return mapped
|
||||
}
|
||||
|
||||
@@ -53,11 +53,16 @@ func compileRegistry(items []controllerRef) (*Registry, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
relations, err := compileRelations(item.plugin.ID(), item.ctl, assets.AdminFS(), form)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
compiled := &CompiledController{
|
||||
PluginID: item.plugin.ID(),
|
||||
Controller: item.ctl,
|
||||
List: list,
|
||||
Form: form,
|
||||
Relations: relations,
|
||||
}
|
||||
if err := BindWritableFields(compiled); err != nil {
|
||||
return nil, err
|
||||
|
||||
815
cabana/relation.go
Normal file
815
cabana/relation.go
Normal file
@@ -0,0 +1,815 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/pact"
|
||||
"git.golem15.com/golem15/summercms/phrasebook"
|
||||
"github.com/goccy/go-yaml"
|
||||
"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
|
||||
}
|
||||
|
||||
// RelationContract binds one compiled relation schema to target and pivot models.
|
||||
type RelationContract struct {
|
||||
Name string
|
||||
NewRelated func() any
|
||||
NewPivot func() any
|
||||
ParentForeignKey string
|
||||
RelatedForeignKey string
|
||||
Columns map[string]string
|
||||
HookPivotColumns []string
|
||||
ExcludedRelatedIDs func(parent any) ([]uint, error)
|
||||
}
|
||||
|
||||
// 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"`
|
||||
View RelationPanel `json:"view"`
|
||||
Manage RelationPanel `json:"manage"`
|
||||
}
|
||||
|
||||
// CompiledRelation combines trusted YAML with model-owned metadata.
|
||||
type CompiledRelation struct {
|
||||
Schema *RelationSchema
|
||||
Contract RelationContract
|
||||
RequiredPermissions []string
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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 }
|
||||
|
||||
func (s RelationSchema) MarshalJSON() ([]byte, error) {
|
||||
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)
|
||||
return &out
|
||||
}
|
||||
|
||||
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"`
|
||||
View relationPanelDocument `yaml:"view"`
|
||||
Manage relationPanelDocument `yaml:"manage"`
|
||||
}
|
||||
|
||||
type relationPanelDocument struct {
|
||||
List struct {
|
||||
Columns yaml.MapSlice `yaml:"columns"`
|
||||
} `yaml:"list"`
|
||||
ToolbarButtons string `yaml:"toolbarButtons"`
|
||||
ShowSearch bool `yaml:"showSearch"`
|
||||
}
|
||||
|
||||
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 := map[string]FormField{}
|
||||
if form != nil {
|
||||
for _, field := range form.Fields {
|
||||
if field.Type == "relation-manager" {
|
||||
fields[field.Relation] = field
|
||||
}
|
||||
}
|
||||
}
|
||||
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 := decodeStrict(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))
|
||||
}
|
||||
orderRelationColumns(raw, &view)
|
||||
orderRelationColumns(raw, &manage)
|
||||
out[name] = &CompiledRelation{
|
||||
Schema: &RelationSchema{Name: name, Label: doc.Label, View: view, Manage: manage},
|
||||
Contract: contract,
|
||||
RequiredPermissions: append([]string(nil), requiredOf(ctl)...),
|
||||
}
|
||||
}
|
||||
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 orderRelationColumns(raw []byte, panel *RelationPanel) {
|
||||
if panel == nil || len(panel.List.Columns) < 2 {
|
||||
return
|
||||
}
|
||||
source := string(raw)
|
||||
sort.SliceStable(panel.List.Columns, func(i, j int) bool {
|
||||
left := strings.Index(source, "\n "+panel.List.Columns[i].Key+":")
|
||||
right := strings.Index(source, "\n "+panel.List.Columns[j].Key+":")
|
||||
if left < 0 {
|
||||
left = len(source)
|
||||
}
|
||||
if right < 0 {
|
||||
right = len(source)
|
||||
}
|
||||
return left < right
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 part != "link" && part != "unlink" {
|
||||
return nil, fmt.Errorf("unsupported relation action %s", part)
|
||||
}
|
||||
if !view && part == "unlink" {
|
||||
return nil, fmt.Errorf("manage panel cannot declare unlink")
|
||||
}
|
||||
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 {
|
||||
if contract.NewRelated == nil || contract.NewRelated() == nil || contract.NewPivot == nil || contract.NewPivot() == nil {
|
||||
return fmt.Errorf("relation %s requires target and pivot models", contract.Name)
|
||||
}
|
||||
if !identifier(contract.ParentForeignKey) || !identifier(contract.RelatedForeignKey) {
|
||||
return fmt.Errorf("relation %s has invalid pivot foreign keys", contract.Name)
|
||||
}
|
||||
pivotCols := modelColumns(contract.NewPivot())
|
||||
if _, ok := pivotCols[contract.ParentForeignKey]; !ok {
|
||||
return fmt.Errorf("relation %s pivot is missing %s", contract.Name, contract.ParentForeignKey)
|
||||
}
|
||||
if _, ok := pivotCols[contract.RelatedForeignKey]; !ok {
|
||||
return fmt.Errorf("relation %s pivot is missing %s", contract.Name, contract.RelatedForeignKey)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
for _, col := range contract.HookPivotColumns {
|
||||
if !identifier(col) {
|
||||
return fmt.Errorf("relation %s has invalid hook column %s", contract.Name, col)
|
||||
}
|
||||
if _, ok := pivotCols[col]; !ok || protectedPivotColumn(col, contract) {
|
||||
return fmt.Errorf("relation %s has invalid hook column %s", contract.Name, col)
|
||||
}
|
||||
}
|
||||
src, ok := ctl.(pact.AdminRecordSource)
|
||||
if !ok || src == nil || src.NewRecord() == nil {
|
||||
return fmt.Errorf("relation %s owner has no record source", contract.Name)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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 := newWritableModel(cc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := loadRecord(ctx, tx, cc, parent, ownerID); err != nil {
|
||||
return err
|
||||
}
|
||||
q, target, err := relationBaseQuery(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) {
|
||||
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)
|
||||
} 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
|
||||
}
|
||||
|
||||
func normalizeRelationQuery(schema *RelationSchema, candidates bool, in RelationQuery) (int, int, string, bool, error) {
|
||||
page, per := 1, 20
|
||||
var err error
|
||||
if in.Page != "" {
|
||||
page, err = parsePositive(in.Page)
|
||||
if err != nil {
|
||||
return 0, 0, "", false, relationInvalid("page", "must be a positive integer")
|
||||
}
|
||||
}
|
||||
if in.PerPage != "" {
|
||||
per, err = parsePositive(in.PerPage)
|
||||
if err != nil || per > 100 {
|
||||
return 0, 0, "", false, relationInvalid("per_page", "must be between 1 and 100")
|
||||
}
|
||||
}
|
||||
panel := schema.View
|
||||
if candidates {
|
||||
panel = schema.Manage
|
||||
}
|
||||
sortKey := in.Sort
|
||||
if sortKey == "" && len(panel.List.Columns) > 0 {
|
||||
sortKey = panel.List.Columns[0].Key
|
||||
}
|
||||
if sortKey != "" {
|
||||
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 inserts only currently eligible targets and never restamps existing rows.
|
||||
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
|
||||
}
|
||||
var result RelationMutationResult
|
||||
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
parent, err := newWritableModel(cc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := loadRecord(ctx, tx, cc, parent, ownerID); err != nil {
|
||||
return err
|
||||
}
|
||||
ownerPK := pkUint(parent)
|
||||
pending, err := pendingRelationIDs(tx, cr, ownerPK, ids)
|
||||
if err != nil || len(pending) == 0 {
|
||||
return err
|
||||
}
|
||||
q, target, err := relationBaseQuery(ctx, tx, cc, cr, parent, true)
|
||||
if err != nil {
|
||||
return 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 err
|
||||
}
|
||||
if holder.Elem().Len() != len(pending) {
|
||||
return relationInvalid("ids", "contains an ineligible target")
|
||||
}
|
||||
for i := 0; i < holder.Elem().Len(); i++ {
|
||||
related := holder.Elem().Index(i).Addr().Interface()
|
||||
pivot := cr.Contract.NewPivot()
|
||||
if err := setModelColumn(pivot, cr.Contract.ParentForeignKey, ownerPK); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := setModelColumn(pivot, cr.Contract.RelatedForeignKey, pkUint(related)); err != nil {
|
||||
return err
|
||||
}
|
||||
values := map[string]any{}
|
||||
if hook, ok := cc.Controller.(pact.RelationBeforeLink); ok && hook != nil {
|
||||
if err := hook.RelationBeforeLink(ctx, relation, parent, related, values); err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
}
|
||||
for key, value := range values {
|
||||
if !stringSliceHasValue(cr.Contract.HookPivotColumns, key) || protectedPivotColumn(key, cr.Contract) {
|
||||
return lifecycleFailure(cc, fmt.Errorf("relation hook wrote protected pivot column"))
|
||||
}
|
||||
if err := setModelColumn(pivot, key, value); err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(pivot).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result.Linked++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Unlink deletes explicit pivot models so their lifecycle hooks run.
|
||||
func (s RelationService) Unlink(ctx context.Context, cc *CompiledController, relation string, ownerID uint, in RelationMutationInput) (RelationMutationResult, error) {
|
||||
ids, err := normalizeIDs(in.IDs)
|
||||
if err != nil {
|
||||
return RelationMutationResult{}, err
|
||||
}
|
||||
cr, err := relationOf(cc, relation)
|
||||
if err != nil {
|
||||
return RelationMutationResult{}, err
|
||||
}
|
||||
var result RelationMutationResult
|
||||
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
parent, err := newWritableModel(cc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := loadRecord(ctx, tx, cc, parent, ownerID); err != nil {
|
||||
return err
|
||||
}
|
||||
proto := cr.Contract.NewPivot()
|
||||
t := reflect.TypeOf(proto)
|
||||
holder := reflect.New(reflect.SliceOf(t.Elem()))
|
||||
q := tx.WithContext(ctx).Model(proto).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where(clause.Eq{Column: clause.Column{Name: cr.Contract.ParentForeignKey}, Value: pkUint(parent)}).
|
||||
Where(clause.IN{Column: clause.Column{Name: cr.Contract.RelatedForeignKey}, Values: uintValues(ids)}).
|
||||
Order(clause.OrderByColumn{Column: clause.Column{Name: cr.Contract.RelatedForeignKey}})
|
||||
if err := q.Find(holder.Interface()).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for i := 0; i < holder.Elem().Len(); i++ {
|
||||
if err := tx.WithContext(ctx).Delete(holder.Elem().Index(i).Addr().Interface()).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result.Removed++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
189
cabana/relation_test.go
Normal file
189
cabana/relation_test.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func TestRelationSchema(t *testing.T) {
|
||||
ctl := relationTestController{}
|
||||
fsys := relationTestFS(validRelationYAML)
|
||||
form, err := CompileForm("acme.demo", ctl, fsys)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
relations, err := compileRelations("acme.demo", ctl, fsys, form)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
compiled := relations["editors"]
|
||||
if compiled == nil || compiled.Contract.ParentForeignKey != "owner_id" || compiled.Contract.RelatedForeignKey != "target_id" {
|
||||
t.Fatalf("compiled=%#v", compiled)
|
||||
}
|
||||
raw, err := json.Marshal(compiled.Schema)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(raw)
|
||||
for _, want := range []string{`"name":"editors"`, `"key":"username"`, `"key":"email"`, `"toolbarButtons":["link","unlink"]`, `"showSearch":true`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("schema missing %s: %s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Index(got, `"key":"username"`) >= strings.Index(got, `"key":"email"`) {
|
||||
t.Fatalf("column order drifted: %s", got)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
yaml string
|
||||
provider relationTestController
|
||||
want string
|
||||
}{
|
||||
{name: "unknown key", yaml: strings.Replace(validRelationYAML, "showSearch: true", "showSearch: true\n conditions: unsafe", 1), want: "conditions"},
|
||||
{name: "unsupported action", yaml: strings.Replace(validRelationYAML, "link|unlink", "link|execute", 1), want: "unsupported relation action execute"},
|
||||
{name: "missing relation", yaml: "other:\n label: other\n view:\n list:\n columns: {}\n manage:\n list:\n columns: {}\n", want: "has no relation-manager field"},
|
||||
{name: "bad target column", yaml: validRelationYAML, provider: relationTestController{badColumn: true}, want: "target is missing column missing"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
probe := tc.provider
|
||||
form, err := CompileForm("acme.demo", probe, relationTestFS(tc.yaml))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = compileRelations("acme.demo", probe, relationTestFS(tc.yaml), form)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("err=%v want=%q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelationEmpty(t *testing.T) {
|
||||
raw, err := json.Marshal(RelationSchema{Name: "empty"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(raw)
|
||||
if strings.Count(got, `"columns":[]`) != 2 || strings.Count(got, `"toolbarButtons":[]`) != 2 || strings.Contains(got, "null") {
|
||||
t.Fatalf("empty relation collections=%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelationSingle(t *testing.T) {
|
||||
schema := RelationSchema{Name: "editors", View: RelationPanel{List: RelationList{Columns: []RelationColumn{{Key: "email", Label: "email", Searchable: true, Sortable: true}}}}}
|
||||
raw, err := json.Marshal(schema)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Count(string(raw), `"key":"email"`) != 1 {
|
||||
t.Fatalf("single column=%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelationStablePages(t *testing.T) {
|
||||
schema := &RelationSchema{View: RelationPanel{List: RelationList{Columns: []RelationColumn{{Key: "username", Sortable: true}, {Key: "email", Sortable: true}}}}}
|
||||
page, per, key, desc, err := normalizeRelationQuery(schema, false, RelationQuery{Page: "2", PerPage: "20", Sort: "username", Dir: "asc"})
|
||||
if err != nil || page != 2 || per != 20 || key != "username" || desc {
|
||||
t.Fatalf("normalized=%d/%d %q desc=%v err=%v", page, per, key, desc, err)
|
||||
}
|
||||
if _, _, _, _, err := normalizeRelationQuery(schema, false, RelationQuery{Sort: "Username"}); err == nil {
|
||||
t.Fatal("case-changed sort survived")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelationCandidateExclusions(t *testing.T) {
|
||||
contract := relationTestController{}.AdminRelationContracts()[0]
|
||||
ids, err := contract.ExcludedRelatedIDs(&relationTestOwner{OwnerID: 41})
|
||||
if err != nil || len(ids) != 1 || ids[0] != 41 {
|
||||
t.Fatalf("excluded=%v err=%v", ids, err)
|
||||
}
|
||||
if _, err := contract.ExcludedRelatedIDs(&relationTestTarget{}); err == nil {
|
||||
t.Fatal("wrong parent type survived")
|
||||
}
|
||||
}
|
||||
|
||||
const validRelationYAML = `editors:
|
||||
label: demo.editors
|
||||
view:
|
||||
list:
|
||||
columns:
|
||||
username:
|
||||
label: demo.username
|
||||
email:
|
||||
label: demo.email
|
||||
toolbarButtons: link|unlink
|
||||
showSearch: true
|
||||
manage:
|
||||
list:
|
||||
columns:
|
||||
username:
|
||||
label: demo.username
|
||||
email:
|
||||
label: demo.email
|
||||
showSearch: true
|
||||
`
|
||||
|
||||
func relationTestFS(relation string) fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"controllers/owners/config_form.yaml": {Data: []byte("name: owners\nform: ~/plugins/acme/demo/models/owner/fields.yaml\nmodelClass: Owner\n")},
|
||||
"controllers/owners/config_relation.yaml": {Data: []byte(relation)},
|
||||
"models/owner/fields.yaml": {Data: []byte("fields:\n editors:\n type: relation-manager\n relation: editors\n")},
|
||||
}
|
||||
}
|
||||
|
||||
type relationTestController struct{ badColumn bool }
|
||||
|
||||
func (relationTestController) ID() string { return "acme.demo.owners" }
|
||||
func (relationTestController) ModelName() string { return "Owner" }
|
||||
func (relationTestController) ConfigDir() string { return "controllers/owners" }
|
||||
func (relationTestController) NewRecord() any { return &relationTestOwner{} }
|
||||
func (c relationTestController) AdminRelationContracts() []RelationContract {
|
||||
columns := map[string]string{"username": "username", "email": "email"}
|
||||
if c.badColumn {
|
||||
columns["email"] = "missing"
|
||||
}
|
||||
return []RelationContract{{
|
||||
Name: "editors", NewRelated: func() any { return &relationTestTarget{} }, NewPivot: func() any { return &relationTestPivot{} },
|
||||
ParentForeignKey: "owner_id", RelatedForeignKey: "target_id", Columns: columns,
|
||||
HookPivotColumns: []string{"role"},
|
||||
ExcludedRelatedIDs: func(parent any) ([]uint, error) {
|
||||
owner, ok := parent.(*relationTestOwner)
|
||||
if !ok {
|
||||
return nil, &relationTestError{}
|
||||
}
|
||||
return []uint{owner.OwnerID}, nil
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
type relationTestOwner struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
OwnerID uint `gorm:"column:owner_id"`
|
||||
Editors []relationTestTarget `gorm:"many2many:relation_test_pivots"`
|
||||
}
|
||||
|
||||
func (*relationTestOwner) TableName() string { return "relation_test_owners" }
|
||||
|
||||
type relationTestTarget struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Username string `gorm:"column:username"`
|
||||
Email string `gorm:"column:email"`
|
||||
}
|
||||
|
||||
func (*relationTestTarget) TableName() string { return "relation_test_targets" }
|
||||
|
||||
type relationTestPivot struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
OwnerID uint `gorm:"column:owner_id"`
|
||||
TargetID uint `gorm:"column:target_id"`
|
||||
Role string `gorm:"column:role"`
|
||||
}
|
||||
|
||||
func (*relationTestPivot) TableName() string { return "relation_test_pivots" }
|
||||
|
||||
type relationTestError struct{}
|
||||
|
||||
func (*relationTestError) Error() string { return "wrong relation parent" }
|
||||
@@ -192,6 +192,13 @@ type ListExtendQuery interface {
|
||||
ListExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB
|
||||
}
|
||||
|
||||
// ListRelationColumnMapper maps a source-schema relation column onto the
|
||||
// physical column exposed by the related Go model. It supports legacy admin
|
||||
// schemas whose public field names no longer match the database schema.
|
||||
type ListRelationColumnMapper interface {
|
||||
ListRelationColumn(relation, column string) (string, bool)
|
||||
}
|
||||
|
||||
// FormExtendQuery optionally narrows admin form record lookup.
|
||||
type FormExtendQuery interface {
|
||||
FormExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB
|
||||
|
||||
Reference in New Issue
Block a user