From 12081c18d1f37becd319668530418197168da3d7 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sat, 26 Sep 2026 21:33:43 +0200 Subject: [PATCH] feat(09-10): add typed relation manager --- cabana/contracts.go | 1 + cabana/http.go | 138 ++++++- cabana/query.go | 33 +- cabana/registry.go | 5 + cabana/relation.go | 815 ++++++++++++++++++++++++++++++++++++++++ cabana/relation_test.go | 189 ++++++++++ pact/capabilities.go | 7 + 7 files changed, 1176 insertions(+), 12 deletions(-) create mode 100644 cabana/relation.go create mode 100644 cabana/relation_test.go diff --git a/cabana/contracts.go b/cabana/contracts.go index 945a33e..048fb9c 100644 --- a/cabana/contracts.go +++ b/cabana/contracts.go @@ -69,6 +69,7 @@ type CompiledController struct { Controller pact.AdminController List *ListSchema Form *FormSchema + Relations map[string]*CompiledRelation Writable []WritableField } diff --git a/cabana/http.go b/cabana/http.go index 2167498..2199e20 100644 --- a/cabana/http.go +++ b/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 diff --git a/cabana/query.go b/cabana/query.go index 8109d43..b826413 100644 --- a/cabana/query.go +++ b/cabana/query.go @@ -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 +} diff --git a/cabana/registry.go b/cabana/registry.go index 54275db..c510044 100644 --- a/cabana/registry.go +++ b/cabana/registry.go @@ -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 diff --git a/cabana/relation.go b/cabana/relation.go new file mode 100644 index 0000000..4004acd --- /dev/null +++ b/cabana/relation.go @@ -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()) +} diff --git a/cabana/relation_test.go b/cabana/relation_test.go new file mode 100644 index 0000000..8a292ec --- /dev/null +++ b/cabana/relation_test.go @@ -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" } diff --git a/pact/capabilities.go b/pact/capabilities.go index 5046e78..32ec2f8 100644 --- a/pact/capabilities.go +++ b/pact/capabilities.go @@ -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