- relationSubquery reads the pivot, key and table names from GORM's parsed relationship (Schema.Relationships.Relations), never from a convention - list search matches every relation column through a correlated EXISTS, so the count query gains no JOIN and belongsToMany search stops failing - sorting a hasMany or belongsToMany column orders by a correlated string_agg subquery before the primary-key tiebreak - polymorphic relations and unknown related columns answer 422 - DryRun smoke test TestListRelationSearchSmoke needs no Docker
195 lines
6.3 KiB
Go
195 lines
6.3 KiB
Go
package cabana
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
pgdriver "gorm.io/driver/postgres"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
)
|
|
|
|
// The relation fixtures cover every relation type a list column can name:
|
|
// belongsTo (Author), belongsToMany through a pivot (Tags) and hasMany (Notes).
|
|
|
|
type relAuthor struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
Name string `gorm:"column:name"`
|
|
}
|
|
|
|
func (relAuthor) TableName() string { return "cabana_rel_authors" }
|
|
|
|
type relTag struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
Name string `gorm:"column:name"`
|
|
}
|
|
|
|
func (relTag) TableName() string { return "cabana_rel_tags" }
|
|
|
|
type relNote struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
PostID uint `gorm:"column:post_id"`
|
|
Body string `gorm:"column:body"`
|
|
}
|
|
|
|
func (relNote) TableName() string { return "cabana_rel_notes" }
|
|
|
|
type relPost struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
Title string `gorm:"column:title"`
|
|
AuthorID *uint `gorm:"column:author_id"`
|
|
Author relAuthor `gorm:"foreignKey:AuthorID"`
|
|
Tags []relTag `gorm:"many2many:cabana_rel_post_tags"`
|
|
Notes []relNote `gorm:"foreignKey:PostID"`
|
|
}
|
|
|
|
func (relPost) TableName() string { return "cabana_rel_posts" }
|
|
|
|
type relController struct{}
|
|
|
|
func (relController) ID() string { return "acme.demo.widgets" }
|
|
func (relController) ModelName() string { return "Widget" }
|
|
func (relController) ConfigDir() string { return "controllers/widgets" }
|
|
func (relController) NewRecord() any { return &relPost{} }
|
|
|
|
var _ pact.AdminRecordSource = relController{}
|
|
|
|
const relListConfig = `modelClass: Widget
|
|
list: ~/plugins/acme/demo/models/widget/columns.yaml
|
|
recordsPerPage: 20
|
|
showSorting: true
|
|
`
|
|
|
|
const relColumns = `columns:
|
|
title:
|
|
label: Title
|
|
searchable: true
|
|
tags:
|
|
label: Tags
|
|
relation: tags
|
|
select: name
|
|
searchable: true
|
|
sortable: true
|
|
notes:
|
|
label: Notes
|
|
relation: notes
|
|
select: body
|
|
searchable: true
|
|
author:
|
|
label: Author
|
|
relation: author
|
|
select: name
|
|
searchable: true
|
|
sortable: true
|
|
`
|
|
|
|
// sqlRecorder is a GORM logger that keeps every statement GORM traces, with
|
|
// its bound values inlined, so DryRun tests can assert on the SQL shape.
|
|
type sqlRecorder struct {
|
|
mu sync.Mutex
|
|
sqls []string
|
|
}
|
|
|
|
func (r *sqlRecorder) LogMode(logger.LogLevel) logger.Interface { return r }
|
|
func (r *sqlRecorder) Info(context.Context, string, ...any) {}
|
|
func (r *sqlRecorder) Warn(context.Context, string, ...any) {}
|
|
func (r *sqlRecorder) Error(context.Context, string, ...any) {}
|
|
func (r *sqlRecorder) Trace(_ context.Context, _ time.Time, fc func() (string, int64), _ error) {
|
|
sql, _ := fc()
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.sqls = append(r.sqls, sql)
|
|
}
|
|
|
|
// statements returns the recorded statements and forgets them.
|
|
func (r *sqlRecorder) statements() []string {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
out := r.sqls
|
|
r.sqls = nil
|
|
return out
|
|
}
|
|
|
|
// statement returns the first recorded statement starting with prefix.
|
|
func (r *sqlRecorder) statement(t *testing.T, prefix string, all []string) string {
|
|
t.Helper()
|
|
for _, sql := range all {
|
|
if strings.HasPrefix(sql, prefix) {
|
|
return sql
|
|
}
|
|
}
|
|
t.Fatalf("no statement starting with %q in %q", prefix, all)
|
|
return ""
|
|
}
|
|
|
|
// openDryRunDB opens a Postgres-dialect GORM handle that never connects:
|
|
// DryRun builds every statement without executing it, and the DSN points at
|
|
// a port nothing listens on, so a test that needs no Docker can still assert
|
|
// on the SQL that ExecuteList would send.
|
|
func openDryRunDB(t *testing.T) (*gorm.DB, *sqlRecorder) {
|
|
t.Helper()
|
|
rec := &sqlRecorder{}
|
|
db, err := gorm.Open(pgdriver.New(pgdriver.Config{DSN: "host=127.0.0.1 port=1 user=cabana dbname=cabana sslmode=disable"}),
|
|
&gorm.Config{DryRun: true, DisableAutomaticPing: true, Logger: rec})
|
|
if err != nil {
|
|
t.Fatalf("dry run db: %v", err)
|
|
}
|
|
return db, rec
|
|
}
|
|
|
|
// newDryRunListService builds a service whose app publishes db and whose
|
|
// registry holds the compiled list of ctl as acme.demo.widgets, the
|
|
// controller controllerRequest targets.
|
|
func newDryRunListService(t *testing.T, db *gorm.DB, ctl pact.AdminController, config, columns string) *service {
|
|
t.Helper()
|
|
schema, err := CompileList("acme.demo", ctl, filterFS(config, columns, ""))
|
|
if err != nil {
|
|
t.Fatalf("schema: %v", err)
|
|
}
|
|
app := backpack.New(nil)
|
|
if err := app.Publish(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return &service{app: app, reg: &Registry{byID: map[string]*CompiledController{
|
|
"acme.demo.widgets": {Controller: ctl, List: schema},
|
|
}}}
|
|
}
|
|
|
|
func TestListRelationSearchSmoke(t *testing.T) {
|
|
db, rec := openDryRunDB(t)
|
|
svc := newDryRunListService(t, db, relController{}, relListConfig, relColumns)
|
|
|
|
decodeList(t, getList(t, svc, "search=fungi"), http.StatusOK)
|
|
count := rec.statement(t, "SELECT count(*)", rec.statements())
|
|
for _, want := range []string{
|
|
`EXISTS (SELECT 1 FROM "cabana_rel_post_tags" AS "cabana_p0" JOIN "cabana_rel_tags" AS "cabana_r0" ON "cabana_p0"."rel_tag_id" = "cabana_r0"."id"`,
|
|
`WHERE "cabana_p0"."rel_post_id" = "cabana_rel_posts"."id" AND LOWER(CAST("cabana_r0"."name" AS TEXT)) LIKE '%fungi%'`,
|
|
`EXISTS (SELECT 1 FROM "cabana_rel_notes" AS "cabana_r1" WHERE "cabana_r1"."post_id" = "cabana_rel_posts"."id"`,
|
|
`EXISTS (SELECT 1 FROM "cabana_rel_authors" AS "cabana_r2" WHERE "cabana_r2"."id" = "cabana_rel_posts"."author_id"`,
|
|
} {
|
|
if !strings.Contains(count, want) {
|
|
t.Fatalf("count statement lacks %s:\n%s", want, count)
|
|
}
|
|
}
|
|
if strings.Contains(count, "JOIN \"cabana_rel_tags\" \"Tags\"") || strings.Contains(count, "LEFT JOIN") {
|
|
t.Fatalf("count statement joins a relation:\n%s", count)
|
|
}
|
|
|
|
decodeList(t, getList(t, svc, "sort=tags&dir=desc"), http.StatusOK)
|
|
page := rec.statement(t, "SELECT * FROM", rec.statements())
|
|
order := page[strings.Index(page, "ORDER BY"):]
|
|
if !strings.Contains(order, `ORDER BY (SELECT string_agg(CAST("cabana_r0"."name" AS TEXT), ', ' ORDER BY CAST("cabana_r0"."name" AS TEXT)) FROM "cabana_rel_post_tags" AS "cabana_p0"`) ||
|
|
!strings.Contains(order, `) DESC,"cabana_rel_posts"."id"`) {
|
|
t.Fatalf("to-many sort is not a string_agg subquery with the id tiebreak:\n%s", page)
|
|
}
|
|
if strings.Contains(page, "LEFT JOIN") {
|
|
t.Fatalf("to-many sort joins the relation:\n%s", page)
|
|
}
|
|
}
|