- 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
718 lines
27 KiB
Go
718 lines
27 KiB
Go
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))
|
|
}
|
|
}
|