Files
summercms/modules/lagoon/order_test.go
Jakub Zych 037dc53030 feat(lagoon): per-query collation for OrderBy, drop the database locale check
- lagoon.OrderBy takes variadic lagoon.OrderOption values; lagoon.Collate(name)
  emits a validated, double-quoted COLLATE clause (e.g. "pl-x-icu")
- remove the exported CheckLocale and the ICU pl-PL check from Open and Use
- framework test containers and per-test databases are plain PostgreSQL
- lagoon README, root README and docs pages drop the locale requirement;
  queries-and-pagination gains a "Sorting with a collation" section
  backed by ExampleCollate
2026-10-01 09:51:03 +02:00

220 lines
6.2 KiB
Go

package lagoon
import (
"context"
"fmt"
"reflect"
"strings"
"testing"
"gorm.io/gorm"
)
func TestOrderClauseAllowList(t *testing.T) {
allowed := []string{"acme_blog_posts.title", "items.title"}
got, err := orderClause("acme_blog_posts.title", "asc", allowed, orderOptions{})
if err != nil {
t.Fatal(err)
}
if got != "acme_blog_posts.title ASC" {
t.Fatalf("got %q", got)
}
if strings.Contains(strings.ToLower(got), "collate") {
t.Fatalf("must not emit COLLATE: %q", got)
}
got, err = orderClause("items.title", "DESC", allowed, orderOptions{})
if err != nil {
t.Fatal(err)
}
if got != "items.title DESC" {
t.Fatalf("got %q", got)
}
if strings.Contains(strings.ToLower(got), "collate") {
t.Fatalf("must not emit COLLATE: %q", got)
}
if _, err := orderClause("acme_blog_posts.title;drop table x", "asc", allowed, orderOptions{}); err == nil {
t.Fatal("want reject unknown column")
}
if _, err := orderClause("acme_blog_posts.title", "ascending", allowed, orderOptions{}); err == nil {
t.Fatal("want reject unknown direction")
}
if _, err := OrderBy(nil, "items.title", "asc", allowed); err == nil {
t.Fatal("want nil db error")
}
if _, err := orderClause("items.title", "asc", nil, orderOptions{}); err == nil {
t.Fatal("empty allow-list must reject")
}
}
func TestOrderClauseCollation(t *testing.T) {
allowed := []string{"items.title"}
opts := resolveOrderOptions([]OrderOption{Collate("pl-x-icu")})
got, err := orderClause("items.title", "asc", allowed, opts)
if err != nil {
t.Fatal(err)
}
if got != `items.title COLLATE "pl-x-icu" ASC` {
t.Fatalf("got %q", got)
}
got, err = orderClause("items.title", "DESC", allowed, opts)
if err != nil {
t.Fatal(err)
}
if got != `items.title COLLATE "pl-x-icu" DESC` {
t.Fatalf("got %q", got)
}
}
func TestCollateAcceptsValidNames(t *testing.T) {
allowed := []string{"items.title"}
for _, name := range []string{
"pl-x-icu",
"und-x-icu",
"en-US-x-icu",
"C",
"POSIX",
"ucs_basic",
"default",
"sr_RS.utf8@latin",
strings.Repeat("a", 63),
} {
got, err := orderClause("items.title", "asc", allowed, resolveOrderOptions([]OrderOption{Collate(name)}))
if err != nil {
t.Fatalf("%q: %v", name, err)
}
want := `items.title COLLATE "` + name + `" ASC`
if got != want {
t.Fatalf("%q: got %q want %q", name, got, want)
}
}
}
func TestCollateRejectsInvalidNames(t *testing.T) {
allowed := []string{"items.title"}
db := &gorm.DB{}
for _, name := range []string{
"",
strings.Repeat("a", 64),
"pl x icu",
`pl-x-icu"`,
`pl-x-icu" ASC, (SELECT 1) --`,
"pl;DROP TABLE x",
`pl\x-icu`,
"pl\x00x-icu",
"pł-x-icu",
"-pl-x-icu",
".pl-x-icu",
} {
want := fmt.Sprintf("lagoon: order collation %q is not a valid collation name", name)
_, err := orderClause("items.title", "asc", allowed, resolveOrderOptions([]OrderOption{Collate(name)}))
if err == nil || err.Error() != want {
t.Fatalf("%q: got %v want %s", name, err, want)
}
q, err := OrderBy(db, "items.title", "asc", allowed, Collate(name))
if q != nil || err == nil || err.Error() != want {
t.Fatalf("OrderBy %q: got (%v, %v)", name, q, err)
}
}
if db.Statement != nil {
t.Fatal("rejected collation must not touch the GORM handle")
}
}
func TestCollateOptionOrdering(t *testing.T) {
allowed := []string{"items.title"}
got, err := orderClause("items.title", "asc", allowed, resolveOrderOptions([]OrderOption{Collate("C"), Collate("pl-x-icu")}))
if err != nil {
t.Fatal(err)
}
if got != `items.title COLLATE "pl-x-icu" ASC` {
t.Fatalf("last Collate must win: %q", got)
}
got, err = orderClause("items.title", "asc", allowed, resolveOrderOptions(nil))
if err != nil {
t.Fatal(err)
}
if got != "items.title ASC" {
t.Fatalf("no options must keep the plain clause: %q", got)
}
// Column and direction are checked before the collation.
bad := resolveOrderOptions([]OrderOption{Collate(`bad"name`)})
_, err = orderClause("items.other", "asc", allowed, bad)
if err == nil || !strings.Contains(err.Error(), "order column") {
t.Fatalf("column check must run first: %v", err)
}
_, err = orderClause("items.title", "sideways", allowed, bad)
if err == nil || !strings.Contains(err.Error(), "order direction") {
t.Fatalf("direction check must run second: %v", err)
}
}
func TestCollatePolishOrderOnLibcDatabase(t *testing.T) {
admin := lagoonDB(t)
ctx := t.Context()
const name = "lagoon_collate_libc"
if _, err := admin.ExecContext(ctx, `CREATE DATABASE `+name+` TEMPLATE template0 ENCODING 'UTF8' LOCALE_PROVIDER libc LOCALE 'C'`); err != nil && !strings.Contains(err.Error(), "already exists") {
t.Fatalf("create libc database: %v", err)
}
dsn, err := dsnWithDB(lagoonDSN, name)
if err != nil {
t.Fatal(err)
}
sqlDB, gdb, err := Open(ctx, dsn)
if err != nil {
t.Fatalf("Open must accept a libc 'C' database: %v", err)
}
t.Cleanup(func() {
_ = sqlDB.Close()
_, _ = admin.ExecContext(context.Background(), `DROP DATABASE IF EXISTS `+name+` WITH (FORCE)`)
})
if _, err := Use(ctx, sqlDB); err != nil {
t.Fatalf("Use must accept a libc 'C' database: %v", err)
}
if _, err := sqlDB.ExecContext(ctx, `CREATE TABLE words (name text)`); err != nil {
t.Fatal(err)
}
if _, err := sqlDB.ExecContext(ctx, `INSERT INTO words (name) VALUES ('Zebra'), ('Łoś'), ('Lis'), ('Mysz')`); err != nil {
t.Fatal(err)
}
allowed := []string{"words.name"}
scan := func(opts ...OrderOption) ([]string, error) {
q, err := OrderBy(gdb.Table("words"), "words.name", "asc", allowed, opts...)
if err != nil {
return nil, err
}
var names []string
if err := q.Pluck("name", &names).Error; err != nil {
return nil, err
}
return names, nil
}
plain, err := scan()
if err != nil {
t.Fatal(err)
}
if want := []string{"Lis", "Mysz", "Zebra", "Łoś"}; !reflect.DeepEqual(plain, want) {
t.Fatalf("plain order on libc 'C': got %v want %v", plain, want)
}
polish, err := scan(Collate("pl-x-icu"))
if err != nil {
t.Fatal(err)
}
if want := []string{"Lis", "Łoś", "Mysz", "Zebra"}; !reflect.DeepEqual(polish, want) {
t.Fatalf("pl-x-icu order: got %v want %v", polish, want)
}
_, err = scan(Collate("no-such-collation-x"))
if err == nil || !strings.Contains(err.Error(), "no-such-collation-x") {
t.Fatalf("unknown collation must reach Postgres as an identifier: %v", err)
}
}