- 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
220 lines
6.2 KiB
Go
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)
|
|
}
|
|
}
|