package cabana import ( "context" "fmt" "net/http" "net/url" "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() return newFilteredListService(t, db, ctl, config, columns, "") } // newFilteredListService is newDryRunListService with a config_filter.yaml. func newFilteredListService(t *testing.T, db *gorm.DB, ctl pact.AdminController, config, columns, filters string) *service { t.Helper() schema, err := CompileList("acme.demo", ctl, filterFS(config, columns, filters)) 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) } } // Matrix fixtures (Phase 16.1 plan 03). Each case lists one model through a // generic controller, so the relation under test is always cabana_r0. // relLabeledPost names its pivot and both pivot columns explicitly; the SQL // must use exactly these names, never a guessed convention (D-02). type relLabeledPost struct { ID uint `gorm:"column:id;primaryKey"` Title string `gorm:"column:title"` Labels []relTag `gorm:"many2many:cabana_rel_labelings;joinForeignKey:labeled_post;joinReferences:label_ref"` } func (relLabeledPost) TableName() string { return "cabana_rel_labeled_posts" } // relPerson references itself through a pivot. type relPerson struct { ID uint `gorm:"column:id;primaryKey"` Name string `gorm:"column:name"` Friends []relPerson `gorm:"many2many:cabana_rel_friendships"` } func (relPerson) TableName() string { return "cabana_rel_people" } // relSoftTag is a soft-deleting related model. type relSoftTag struct { ID uint `gorm:"column:id;primaryKey"` Name string `gorm:"column:name"` DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"` } func (relSoftTag) TableName() string { return "cabana_rel_soft_tags" } type relSoftPost struct { ID uint `gorm:"column:id;primaryKey"` Title string `gorm:"column:title"` Tags []relSoftTag `gorm:"many2many:cabana_rel_soft_post_tags"` } func (relSoftPost) TableName() string { return "cabana_rel_soft_posts" } // relComment is the morph side of a polymorphic hasMany. type relComment struct { ID uint `gorm:"column:id;primaryKey"` CommentableID uint `gorm:"column:commentable_id"` CommentableType string `gorm:"column:commentable_type"` Body string `gorm:"column:body"` } func (relComment) TableName() string { return "cabana_rel_comments" } type relProfile struct { ID uint `gorm:"column:id;primaryKey"` PostID uint `gorm:"column:post_id"` Bio string `gorm:"column:bio"` } func (relProfile) TableName() string { return "cabana_rel_profiles" } type relMorphPost struct { ID uint `gorm:"column:id;primaryKey"` Title string `gorm:"column:title"` Comments []relComment `gorm:"polymorphic:Commentable"` Profile relProfile `gorm:"foreignKey:PostID"` } func (relMorphPost) TableName() string { return "cabana_rel_morph_posts" } // relScopedPost carries the user_id a ListExtendQuery narrows on and a // filter scope adding an unqualified id IN predicate. type relScopedPost struct { ID uint `gorm:"column:id;primaryKey"` UserID uint `gorm:"column:user_id"` Title string `gorm:"column:title"` Tags []relTag `gorm:"many2many:cabana_rel_scoped_post_tags"` } func (relScopedPost) TableName() string { return "cabana_rel_scoped_posts" } func (relScopedPost) FilterScopes() []string { return []string{"filterByIds"} } func (relScopedPost) FilterOptions(string) []pact.Option { return []pact.Option{{Value: "3", Label: "Three"}} } func (relScopedPost) FilterScope(name string, db *gorm.DB, value any) *gorm.DB { if db == nil || name != "filterByIds" { return db } return db.Where("id IN (?)", []any{value}) } // relModelController lists whatever model newRecord returns. type relModelController struct{ newRecord func() any } func (relModelController) ID() string { return "acme.demo.widgets" } func (relModelController) ModelName() string { return "Widget" } func (relModelController) ConfigDir() string { return "controllers/widgets" } func (c relModelController) NewRecord() any { return c.newRecord() } // relScopedController narrows the list to one user with an unqualified // predicate, as host controllers do. type relScopedController struct{ relModelController } func (relScopedController) ListExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB { return db.Where("user_id = ?", 7) } var ( _ pact.AdminRecordSource = relModelController{} _ pact.ListExtendQuery = relScopedController{} _ pact.FilterScope = relScopedPost{} ) // relOneColumn is a columns.yaml with an unsearchable own column and one // relation column, so the relation is the only subquery (alias n = 0). func relOneColumn(own, relation, sel string) string { return `columns: ` + own + `: label: Own ` + relation + `: label: Related relation: ` + relation + ` select: ` + sel + ` searchable: true sortable: true ` } // boundStatement is one statement as GORM sends it: placeholders and the // bound values, before the logger inlines them. type boundStatement struct { sql string vars []any } // bindRecorder records statements with their placeholders and bound values // through GORM's ParamsFilter hook, so a test can prove a value is bound and // never part of the SQL text. type bindRecorder struct { mu sync.Mutex bound []boundStatement } func (r *bindRecorder) LogMode(logger.LogLevel) logger.Interface { return r } func (r *bindRecorder) Info(context.Context, string, ...any) {} func (r *bindRecorder) Warn(context.Context, string, ...any) {} func (r *bindRecorder) Error(context.Context, string, ...any) {} func (r *bindRecorder) Trace(_ context.Context, _ time.Time, fc func() (string, int64), _ error) { fc() } func (r *bindRecorder) ParamsFilter(_ context.Context, sql string, params ...any) (string, []any) { r.mu.Lock() defer r.mu.Unlock() r.bound = append(r.bound, boundStatement{sql: sql, vars: append([]any(nil), params...)}) return sql, params } func (r *bindRecorder) take() []boundStatement { r.mu.Lock() defer r.mu.Unlock() out := r.bound r.bound = nil return out } func openBindDryRunDB(t *testing.T) (*gorm.DB, *bindRecorder) { t.Helper() rec := &bindRecorder{} 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 } // withoutSubqueries drops every parenthesized (SELECT ...) group, leaving // the outer statement, so a test can tell an outer JOIN from one inside an // EXISTS or string_agg subquery. func withoutSubqueries(sql string) string { for { start := strings.Index(sql, "(SELECT ") if start < 0 { return sql } depth := 0 end := -1 for i := start; i < len(sql) && end < 0; i++ { switch sql[i] { case '(': depth++ case ')': depth-- if depth == 0 { end = i } } } if end < 0 { return sql } sql = sql[:start] + "(subquery)" + sql[end+1:] } } // pageStatement is the recorded statement that loads the page of records. func pageStatement(t *testing.T, all []string) string { t.Helper() for _, sql := range all { if strings.HasPrefix(sql, "SELECT ") && strings.Contains(sql, " LIMIT ") { return sql } } t.Fatalf("no page statement in %q", all) return "" } const relTagsPivotSearch = `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%' ESCAPE '\')` const relTagsAgg = `(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" 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")` const relNotesAgg = `(SELECT string_agg(CAST("cabana_r0"."body" AS TEXT), ', ' ORDER BY CAST("cabana_r0"."body" AS TEXT)) FROM "cabana_rel_notes" AS "cabana_r0" WHERE "cabana_r0"."post_id" = "cabana_rel_posts"."id")` // TestListRelationSQL pins the SQL the relation subquery builder sends for // every relation type (D-02): names come from GORM's parsed relationship, // search is a correlated EXISTS with no outer JOIN, to-many sort orders by a // string_agg subquery and to-one sort keeps the association join. func TestListRelationSQL(t *testing.T) { model := func(newRecord func() any) relModelController { return relModelController{newRecord} } post := model(func() any { return &relPost{} }) cases := []struct { name string ctl pact.AdminController columns string query string // count asserts on the count statement (search), else on the page // statement (sort). count bool want []string without []string }{ { name: "belongsToMany search goes through the pivot", ctl: post, columns: relOneColumn("title", "tags", "name"), query: "search=fungi", count: true, want: []string{`SELECT count(*) FROM "cabana_rel_posts" WHERE ` + relTagsPivotSearch}, }, { name: "belongsToMany with custom pivot keys uses exactly the tagged names", ctl: model(func() any { return &relLabeledPost{} }), columns: relOneColumn("title", "labels", "name"), query: "search=fungi", count: true, want: []string{ `FROM "cabana_rel_labelings" AS "cabana_p0" JOIN "cabana_rel_tags" AS "cabana_r0" ON "cabana_p0"."label_ref" = "cabana_r0"."id"`, `WHERE "cabana_p0"."labeled_post" = "cabana_rel_labeled_posts"."id" AND LOWER(CAST("cabana_r0"."name" AS TEXT)) LIKE '%fungi%'`, }, without: []string{"rel_labeled_post_id", "rel_tag_id", "cabana_rel_labeled_post_tags"}, }, { name: "hasMany search correlates the related foreign key to the main primary key", ctl: post, columns: relOneColumn("title", "notes", "body"), query: "search=fungi", count: true, want: []string{`EXISTS (SELECT 1 FROM "cabana_rel_notes" AS "cabana_r0" WHERE "cabana_r0"."post_id" = "cabana_rel_posts"."id" AND LOWER(CAST("cabana_r0"."body" AS TEXT)) LIKE '%fungi%'`}, }, { name: "hasOne search correlates like hasMany", ctl: model(func() any { return &relMorphPost{} }), columns: relOneColumn("title", "profile", "bio"), query: "search=fungi", count: true, want: []string{`EXISTS (SELECT 1 FROM "cabana_rel_profiles" AS "cabana_r0" WHERE "cabana_r0"."post_id" = "cabana_rel_morph_posts"."id" AND LOWER(CAST("cabana_r0"."bio" AS TEXT)) LIKE '%fungi%'`}, }, { name: "belongsTo search correlates the related primary key to the main foreign key", ctl: post, columns: relOneColumn("title", "author", "name"), query: "search=fungi", count: true, want: []string{`EXISTS (SELECT 1 FROM "cabana_rel_authors" AS "cabana_r0" WHERE "cabana_r0"."id" = "cabana_rel_posts"."author_id" AND LOWER(CAST("cabana_r0"."name" AS TEXT)) LIKE '%fungi%'`}, }, { name: "self-referencing belongsToMany gets distinct aliases", ctl: model(func() any { return &relPerson{} }), columns: relOneColumn("name", "friends", "name"), query: "search=fungi", count: true, want: []string{ `SELECT count(*) FROM "cabana_rel_people" WHERE EXISTS (SELECT 1 FROM "cabana_rel_friendships" AS "cabana_p0" JOIN "cabana_rel_people" AS "cabana_r0" ON "cabana_p0"."friend_id" = "cabana_r0"."id"`, `WHERE "cabana_p0"."rel_person_id" = "cabana_rel_people"."id" AND LOWER(CAST("cabana_r0"."name" AS TEXT)) LIKE '%fungi%'`, }, without: []string{`"cabana_rel_people"."name"`}, }, { name: "a soft-deleting related model skips deleted rows", ctl: model(func() any { return &relSoftPost{} }), columns: relOneColumn("title", "tags", "name"), query: "search=fungi", count: true, want: []string{`WHERE "cabana_p0"."rel_soft_post_id" = "cabana_rel_soft_posts"."id" AND "cabana_r0"."deleted_at" IS NULL AND LOWER(CAST("cabana_r0"."name" AS TEXT)) LIKE '%fungi%'`}, }, { name: "a related model without soft delete adds no deleted_at", ctl: post, columns: relOneColumn("title", "tags", "name"), query: "search=fungi", count: true, without: []string{"deleted_at"}, }, { name: "belongsToMany sort ascending orders by string_agg with the id tiebreak", ctl: post, columns: relOneColumn("title", "tags", "name"), query: "sort=tags&dir=asc", want: []string{`SELECT * FROM "cabana_rel_posts" ORDER BY ` + relTagsAgg + `,"cabana_rel_posts"."id" LIMIT 20`}, without: []string{"JOIN \"cabana_rel_tags\" \"Tags\"", "LEFT JOIN"}, }, { name: "belongsToMany sort descending orders by string_agg with the id tiebreak", ctl: post, columns: relOneColumn("title", "tags", "name"), query: "sort=tags&dir=desc", want: []string{`ORDER BY ` + relTagsAgg + ` DESC,"cabana_rel_posts"."id" LIMIT 20`}, without: []string{"LEFT JOIN"}, }, { name: "hasMany sort orders by string_agg over the related rows", ctl: post, columns: relOneColumn("title", "notes", "body"), query: "sort=notes&dir=desc", want: []string{`ORDER BY ` + relNotesAgg + ` DESC,"cabana_rel_posts"."id" LIMIT 20`}, without: []string{"LEFT JOIN"}, }, { name: "belongsTo sort keeps the association join", ctl: post, columns: relOneColumn("title", "author", "name"), query: "sort=author&dir=desc", want: []string{ `FROM "cabana_rel_posts" LEFT JOIN "cabana_rel_authors" "Author" ON "cabana_rel_posts"."author_id" = "Author"."id" ORDER BY "Author"."name" DESC,"cabana_rel_posts"."id" LIMIT 20`, }, without: []string{"string_agg"}, }, { name: "belongsTo sort ascending", ctl: post, columns: relOneColumn("title", "author", "name"), query: "sort=author&dir=asc", want: []string{`ORDER BY "Author"."name","cabana_rel_posts"."id" LIMIT 20`}, }, { name: "hasOne sort keeps the association join", ctl: model(func() any { return &relMorphPost{} }), columns: relOneColumn("title", "profile", "bio"), query: "sort=profile&dir=asc", want: []string{`LEFT JOIN "cabana_rel_profiles" "Profile" ON "cabana_rel_morph_posts"."id" = "Profile"."post_id" ORDER BY "Profile"."bio","cabana_rel_morph_posts"."id" LIMIT 20`}, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { db, rec := openDryRunDB(t) svc := newDryRunListService(t, db, tc.ctl, relListConfig, tc.columns) decodeList(t, getList(t, svc, tc.query), http.StatusOK) all := rec.statements() var sql string if tc.count { sql = rec.statement(t, "SELECT count(*)", all) if outer := withoutSubqueries(sql); strings.Contains(outer, "JOIN") { t.Fatalf("count statement has an outer JOIN:\n%s", sql) } } else { sql = pageStatement(t, all) } for _, want := range tc.want { if !strings.Contains(sql, want) { t.Fatalf("statement lacks\n %s\nin\n %s", want, sql) } } for _, bad := range tc.without { if strings.Contains(sql, bad) { t.Fatalf("statement holds %s:\n%s", bad, sql) } } }) } t.Run("refusals answer 422 on the parameter", func(t *testing.T) { morph := model(func() any { return &relMorphPost{} }) refusals := []struct { name string ctl pact.AdminController columns string query string param string }{ {"polymorphic hasMany search", morph, relOneColumn("title", "comments", "body"), "search=fungi", "search"}, {"polymorphic hasMany sort", morph, relOneColumn("title", "comments", "body"), "sort=comments&dir=asc", "sort"}, {"select column missing from the related schema, search", post, relOneColumn("title", "tags", "colour"), "search=fungi", "search"}, {"select column missing from the related schema, sort", post, relOneColumn("title", "tags", "colour"), "sort=tags&dir=desc", "sort"}, {"select column missing on a belongsTo", post, relOneColumn("title", "author", "colour"), "search=fungi", "search"}, } for _, rc := range refusals { t.Run(rc.name, func(t *testing.T) { db, rec := openDryRunDB(t) svc := newDryRunListService(t, db, rc.ctl, relListConfig, rc.columns) body := decodeList(t, getList(t, svc, rc.query), http.StatusUnprocessableEntity) if body.Error.Code != "validation_failed" { t.Fatalf("code = %q", body.Error.Code) } if _, ok := body.Error.Details[rc.param]; !ok || len(body.Error.Details) != 1 { t.Fatalf("details = %v, want only %s", body.Error.Details, rc.param) } for _, sql := range rec.statements() { if strings.Contains(sql, "LIMIT") { t.Fatalf("a refused list still loaded a page: %s", sql) } } }) } }) t.Run("the search value is bound and LIKE-escaped, never in the SQL text", func(t *testing.T) { db, rec := openBindDryRunDB(t) svc := newDryRunListService(t, db, relController{}, relListConfig, relColumns) term := `O'Neil 100%_X` decodeList(t, getList(t, svc, url.Values{"search": {term}}.Encode()), http.StatusOK) bound := rec.take() var count *boundStatement for i := range bound { for _, bad := range []string{"O'Neil", "o'neil", "100%", "neil"} { if strings.Contains(bound[i].sql, bad) { t.Fatalf("search text in the SQL: %s", bound[i].sql) } } if strings.HasPrefix(bound[i].sql, "SELECT count(*)") { count = &bound[i] } } if count == nil { t.Fatalf("no count statement in %v", bound) } want := `%o'neil 100\%\_x%` // title, tags, notes and author: one placeholder each. if len(count.vars) != 4 { t.Fatalf("vars = %#v, want 4", count.vars) } for _, v := range count.vars { if v != want { t.Fatalf("bound value = %#v, want %q", v, want) } } for n := 1; n <= 4; n++ { if !strings.Contains(count.sql, fmt.Sprintf("LIKE $%d ESCAPE '\\'", n)) { t.Fatalf("placeholder $%d missing:\n%s", n, count.sql) } } }) t.Run("ListExtendQuery and a scope filter stay unambiguous without an outer JOIN", func(t *testing.T) { db, rec := openDryRunDB(t) ctl := relScopedController{model(func() any { return &relScopedPost{} })} config := relListConfig + "filter: config_filter.yaml\n" columns := `columns: title: label: Title searchable: true tags: label: Tags relation: tags select: name searchable: true ` filters := `scopes: ids: label: Ids modelClass: Widget nameFrom: title scope: filterByIds ` svc := newFilteredListService(t, db, ctl, config, columns, filters) decodeList(t, getList(t, svc, url.Values{"search": {"fungi"}, "filter[ids]": {"3"}}.Encode()), http.StatusOK) count := rec.statement(t, "SELECT count(*)", rec.statements()) for _, want := range []string{ `SELECT count(*) FROM "cabana_rel_scoped_posts" WHERE user_id = 7 AND (`, `EXISTS (SELECT 1 FROM "cabana_rel_scoped_post_tags" AS "cabana_p0" JOIN "cabana_rel_tags" AS "cabana_r0"`, `AND id IN (3)`, } { if !strings.Contains(count, want) { t.Fatalf("count statement lacks %s:\n%s", want, count) } } if outer := withoutSubqueries(count); strings.Contains(outer, "JOIN") { t.Fatalf("count statement has an outer JOIN:\n%s", count) } }) } // TestListRelationSearchPostgres runs relation search and sort on a real // Postgres (D-02): a post matching through two tags is counted and listed // once. It shares the package's testcontainers database and fails, not // skips, without Docker. func TestListRelationSearchPostgres(t *testing.T) { _, db := newListService(t) if err := db.Migrator().DropTable("cabana_rel_post_tags", &relNote{}, &relPost{}, &relTag{}, &relAuthor{}); err != nil { t.Fatal(err) } if err := db.AutoMigrate(&relAuthor{}, &relTag{}, &relNote{}, &relPost{}); err != nil { t.Fatal(err) } author := uint(1) if err := db.Create(&relAuthor{ID: author, Name: "Ada"}).Error; err != nil { t.Fatal(err) } posts := []relPost{ {ID: 1, Title: "First", AuthorID: &author, Tags: []relTag{{ID: 1, Name: "Fungi atlas"}, {ID: 2, Name: "fungi notes"}}}, {ID: 2, Title: "Second", Tags: []relTag{{ID: 3, Name: "Birds"}}, Notes: []relNote{{ID: 1, Body: "a spore print"}}}, } if err := db.Create(&posts).Error; err != nil { t.Fatal(err) } svc := newDryRunListService(t, db, relController{}, relListConfig, relColumns) body := decodeList(t, getList(t, svc, "search=fungi"), http.StatusOK) if body.Meta.Total != 1 || !sameIDs(rowIDs(body.Data), []int{1}) { t.Fatalf("search=fungi total=%d ids=%v, want 1 and [1]", body.Meta.Total, rowIDs(body.Data)) } body = decodeList(t, getList(t, svc, "sort=tags&dir=asc"), http.StatusOK) if got := rowIDs(body.Data); len(got) != 2 || got[0] != 2 || got[1] != 1 { t.Fatalf("sort=tags asc ids=%v, want [2 1]", got) } body = decodeList(t, getList(t, svc, "sort=tags&dir=desc"), http.StatusOK) if got := rowIDs(body.Data); len(got) != 2 || got[0] != 1 || got[1] != 2 { t.Fatalf("sort=tags desc ids=%v, want [1 2]", got) } body = decodeList(t, getList(t, svc, "search=spore"), http.StatusOK) if body.Meta.Total != 1 || !sameIDs(rowIDs(body.Data), []int{2}) { t.Fatalf("search=spore total=%d ids=%v, want 1 and [2]", body.Meta.Total, rowIDs(body.Data)) } body = decodeList(t, getList(t, svc, "search=ada"), http.StatusOK) if body.Meta.Total != 1 || !sameIDs(rowIDs(body.Data), []int{1}) { t.Fatalf("search=ada total=%d ids=%v, want 1 and [1]", body.Meta.Total, rowIDs(body.Data)) } }