diff --git a/docs/backend/relation-manager.md b/docs/backend/relation-manager.md index dae8703..49c0e10 100644 --- a/docs/backend/relation-manager.md +++ b/docs/backend/relation-manager.md @@ -236,4 +236,4 @@ A relation's `messages` block overrides the relation manager's copy, each key a ## Relations in lists -A list column can show a related value with `relation` and `select` in `columns.yaml`; see [Lists and filters](lists-and-filters.md). A controller that maps a relation column to a physical column itself implements `pact.ListRelationColumnMapper`. +A list column can show a related value with `relation` and `select` in `columns.yaml`; see [Lists and filters](lists-and-filters.md). On a to-many relation the column joins the selected value of every related record with `, `, as WinterCMS does, and stays empty when there are none. A controller that maps a relation column to a physical column itself implements `pact.ListRelationColumnMapper`. diff --git a/modules/cabana/model_fields_test.go b/modules/cabana/model_fields_test.go index 3d88ea3..bbb84d9 100644 --- a/modules/cabana/model_fields_test.go +++ b/modules/cabana/model_fields_test.go @@ -84,3 +84,41 @@ func TestFieldByColumnTagBeatsGoName(t *testing.T) { t.Fatalf("a tagged field matched by its Go name: %v", got) } } + +type listTag struct { + ID uint `gorm:"primarykey"` + Name *string `gorm:"column:name"` +} + +type listTagged struct { + ID uint `gorm:"primarykey"` + Tags []listTag `gorm:"many2many:tagged_tags"` + Pointers []*listTag `gorm:"many2many:tagged_pointer_tags"` +} + +// TestProjectRowJoinsToManyRelation pins a to-many relation column (relation: +// tags, select: name): the list shows every related record's selected value +// joined with ", " like Winter, and leaves the key unset when there are none. +func TestProjectRowJoinsToManyRelation(t *testing.T) { + news, guides := "News", "Guides" + row := &listTagged{ + ID: 3, + Tags: []listTag{{ID: 1, Name: &news}, {ID: 2, Name: &guides}, {ID: 4}}, + Pointers: []*listTag{nil, {ID: 2, Name: &guides}}, + } + cols := []ListColumn{ + {Key: "tags", Relation: "tags", Select: "name"}, + {Key: "pointers", Relation: "pointers", Select: "name"}, + } + projected := projectRow(row, nil, cols) + if projected["tags"] != "News, Guides" { + t.Fatalf("tags = %#v, want %q", projected["tags"], "News, Guides") + } + if projected["pointers"] != "Guides" { + t.Fatalf("pointers = %#v, want %q", projected["pointers"], "Guides") + } + empty := projectRow(&listTagged{ID: 5}, nil, cols) + if _, ok := empty["tags"]; ok { + t.Fatalf("empty relation projected %#v, want the key unset", empty) + } +} diff --git a/modules/cabana/query.go b/modules/cabana/query.go index d2d8785..53c91be 100644 --- a/modules/cabana/query.go +++ b/modules/cabana/query.go @@ -580,16 +580,48 @@ func relatedSelect(row reflect.Value, controller pact.AdminController, col ListC } field = field.Elem() } + column := listRelationColumn(controller, col.Relation, col.Select) + if field.Kind() == reflect.Slice { + return relatedSelectMany(field, column) + } if field.Kind() != reflect.Struct { return nil, false } - selected := fieldByColumn(field, listRelationColumn(controller, col.Relation, col.Select)) + selected := fieldByColumn(field, column) if !selected.IsValid() || !selected.CanInterface() { return nil, false } return selected.Interface(), true } +// relatedSelectMany joins the selected column of every related record with +// ", ", as Winter's list does for a to-many relation column. No related +// records leave the key unset, so the list shows its empty placeholder. +func relatedSelectMany(records reflect.Value, column string) (any, bool) { + values := make([]string, 0, records.Len()) + for i := 0; i < records.Len(); i++ { + record := records.Index(i) + for record.Kind() == reflect.Pointer && !record.IsNil() { + record = record.Elem() + } + if record.Kind() != reflect.Struct { + continue + } + selected := fieldByColumn(record, column) + for selected.IsValid() && selected.Kind() == reflect.Pointer && !selected.IsNil() { + selected = selected.Elem() + } + if !selected.IsValid() || !selected.CanInterface() || selected.Kind() == reflect.Pointer { + continue + } + values = append(values, fmt.Sprint(selected.Interface())) + } + if len(values) == 0 { + return nil, false + } + return strings.Join(values, ", "), true +} + func listRelationColumn(controller pact.AdminController, relation, column string) string { mapper, ok := controller.(pact.ListRelationColumnMapper) if !ok || mapper == nil {