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) } }