From 837922cf43444da0f27c8625baacfdb9212ae5c8 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 9 Oct 2026 22:47:57 +0200 Subject: [PATCH] test(cabana): relation subquery and locale order coverage - TestListRelationSQL: DryRun matrix for belongsToMany (incl. custom pivot keys and self-reference), hasMany, hasOne and belongsTo search and sort, soft delete, 422 refusals, bound and LIKE-escaped search values, no outer JOIN next to ListExtendQuery and scope predicates - TestListRelationSearchPostgres: exact total through two matching tags, string_agg sort order and hasMany search on Postgres - TestEnabledLocalesDefaultFirst: default-locale ordering cases --- modules/cabana/locale_order_test.go | 108 ++++++ modules/cabana/query_relation_test.go | 525 +++++++++++++++++++++++++- 2 files changed, 632 insertions(+), 1 deletion(-) diff --git a/modules/cabana/locale_order_test.go b/modules/cabana/locale_order_test.go index 8beb353..ccfc3f4 100644 --- a/modules/cabana/locale_order_test.go +++ b/modules/cabana/locale_order_test.go @@ -2,10 +2,12 @@ package cabana import ( "context" + "errors" "slices" "testing" "git.golem15.com/golem15/summercms/modules/backpack" + "gorm.io/gorm" ) // TestEnabledLocalesDefaultFirstSmoke pins D-03: the form schema lists the @@ -25,3 +27,109 @@ func TestEnabledLocalesDefaultFirstSmoke(t *testing.T) { t.Fatalf("enabledContentLocales = %v, want [pl en]", got) } } + +// failingDefaultWriter answers its enabled locales but fails DefaultLocale. +type failingDefaultWriter struct{ recordingWriter } + +func (w *failingDefaultWriter) DefaultLocale(context.Context, *gorm.DB) (string, error) { + return "", errors.New("default locale unavailable") +} + +// failingEnabledWriter fails EnabledLocales. +type failingEnabledWriter struct{ recordingWriter } + +func (w *failingEnabledWriter) EnabledLocales(context.Context, *gorm.DB) ([]string, error) { + return nil, errors.New("enabled locales unavailable") +} + +// TestEnabledLocalesDefaultFirst covers every ordering case of +// enabledContentLocales (D-03): the default moves to the front, the other +// codes keep the writer's order, and a missing or failing default leaves +// the writer's order alone. +func TestEnabledLocalesDefaultFirst(t *testing.T) { + cases := []struct { + name string + writer TranslationWriter + want []string + }{ + {"default not first", &recordingWriter{defaultLocale: "pl", enabled: []string{"en", "pl"}}, []string{"pl", "en"}}, + {"default already first", &recordingWriter{defaultLocale: "pl", enabled: []string{"pl", "en"}}, []string{"pl", "en"}}, + {"default absent from the list", &recordingWriter{defaultLocale: "fr", enabled: []string{"en", "pl"}}, []string{"en", "pl"}}, + {"empty default", &recordingWriter{defaultLocale: "", enabled: []string{"en", "pl"}}, []string{"en", "pl"}}, + {"DefaultLocale error keeps the order", &failingDefaultWriter{recordingWriter{defaultLocale: "pl", enabled: []string{"en", "pl"}}}, []string{"en", "pl"}}, + {"three locales keep their relative order", &recordingWriter{defaultLocale: "pl", enabled: []string{"en", "de", "pl"}}, []string{"pl", "en", "de"}}, + {"default in the middle", &recordingWriter{defaultLocale: "de", enabled: []string{"en", "de", "pl"}}, []string{"de", "en", "pl"}}, + {"single locale", &recordingWriter{defaultLocale: "pl", enabled: []string{"pl"}}, []string{"pl"}}, + {"EnabledLocales error", &failingEnabledWriter{recordingWriter{defaultLocale: "pl"}}, nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + db, _ := openDryRunDB(t) + app := backpack.New(nil) + if err := app.Publish(db); err != nil { + t.Fatal(err) + } + if err := app.Publish[TranslationWriter](tc.writer); err != nil { + t.Fatal(err) + } + svc := &service{app: app} + got := svc.enabledContentLocales(context.Background()) + if !slices.Equal(got, tc.want) { + t.Fatalf("enabledContentLocales = %v, want %v", got, tc.want) + } + }) + } + + t.Run("the writer's slice is not reordered in place", func(t *testing.T) { + db, _ := openDryRunDB(t) + app := backpack.New(nil) + if err := app.Publish(db); err != nil { + t.Fatal(err) + } + enabled := []string{"en", "de", "pl"} + if err := app.Publish[TranslationWriter](&sharedSliceWriter{defaultLocale: "pl", enabled: enabled}); err != nil { + t.Fatal(err) + } + svc := &service{app: app} + if got := svc.enabledContentLocales(context.Background()); !slices.Equal(got, []string{"pl", "en", "de"}) { + t.Fatalf("enabledContentLocales = %v", got) + } + if !slices.Equal(enabled, []string{"en", "de", "pl"}) { + t.Fatalf("writer slice changed to %v", enabled) + } + }) + + t.Run("no published writer", func(t *testing.T) { + db, _ := openDryRunDB(t) + app := backpack.New(nil) + if err := app.Publish(db); err != nil { + t.Fatal(err) + } + svc := &service{app: app} + if got := svc.enabledContentLocales(context.Background()); got != nil { + t.Fatalf("enabledContentLocales = %v, want nil", got) + } + }) + + t.Run("no app", func(t *testing.T) { + if got := (&service{}).enabledContentLocales(context.Background()); got != nil { + t.Fatalf("enabledContentLocales = %v, want nil", got) + } + }) +} + +// sharedSliceWriter returns its own enabled slice, not a copy, so a test can +// see whether the caller reorders it in place. +type sharedSliceWriter struct { + recordingWriter + defaultLocale string + enabled []string +} + +func (w *sharedSliceWriter) DefaultLocale(context.Context, *gorm.DB) (string, error) { + return w.defaultLocale, nil +} + +func (w *sharedSliceWriter) EnabledLocales(context.Context, *gorm.DB) ([]string, error) { + return w.enabled, nil +} diff --git a/modules/cabana/query_relation_test.go b/modules/cabana/query_relation_test.go index 5c5f404..ad7871a 100644 --- a/modules/cabana/query_relation_test.go +++ b/modules/cabana/query_relation_test.go @@ -2,7 +2,9 @@ package cabana import ( "context" + "fmt" "net/http" + "net/url" "strings" "sync" "testing" @@ -148,7 +150,13 @@ func openDryRunDB(t *testing.T) (*gorm.DB, *sqlRecorder) { // 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, "")) + 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) } @@ -192,3 +200,518 @@ func TestListRelationSearchSmoke(t *testing.T) { 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)) + } +}