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
This commit is contained in:
Jakub Zych
2026-10-01 09:51:03 +02:00
parent 565ce982d9
commit 037dc53030
22 changed files with 344 additions and 168 deletions

View File

@@ -7,33 +7,100 @@ import (
"gorm.io/gorm"
)
// OrderBy appends a database-default ORDER BY for an allow-listed qualified
// column. Identifiers and directions are never taken from untrusted input:
// column must match allowed exactly, and dir must be asc or desc. No COLLATE
// is emitted; Postgres ICU pl-PL is the database default (CheckLocale).
func OrderBy(db *gorm.DB, column, dir string, allowed []string) (*gorm.DB, error) {
// maxCollationName is PostgreSQL's identifier limit (NAMEDATALEN-1).
const maxCollationName = 63
// OrderOption changes how OrderBy builds its ORDER BY clause.
type OrderOption func(*orderOptions)
type orderOptions struct {
collation string
hasCollation bool
}
// Collate sorts the column with the named PostgreSQL collation, for example
// the ICU collation pl-x-icu for Polish alphabetical order. ICU collations
// exist in any ICU-enabled PostgreSQL server, so they need no database
// setup. The name is validated and emitted as a double-quoted identifier;
// an invalid name makes OrderBy return an error.
func Collate(name string) OrderOption {
return func(o *orderOptions) {
o.collation = name
o.hasCollation = true
}
}
// OrderBy appends an ORDER BY for an allow-listed qualified column.
// Identifiers and directions are never taken from untrusted input: column
// must match allowed exactly, and dir must be asc or desc. No COLLATE is
// emitted unless Collate is passed; without it, text sorts by the
// database's default collation.
func OrderBy(db *gorm.DB, column, dir string, allowed []string, opts ...OrderOption) (*gorm.DB, error) {
if db == nil {
return nil, fmt.Errorf("lagoon: gorm db is nil")
}
clause, err := orderClause(column, dir, allowed)
clause, err := orderClause(column, dir, allowed, resolveOrderOptions(opts))
if err != nil {
return nil, err
}
return db.Order(clause), nil
}
func orderClause(column, dir string, allowed []string) (string, error) {
func resolveOrderOptions(opts []OrderOption) orderOptions {
var o orderOptions
for _, opt := range opts {
if opt != nil {
opt(&o)
}
}
return o
}
func orderClause(column, dir string, allowed []string, opts orderOptions) (string, error) {
if !allowListed(column, allowed) {
return "", fmt.Errorf("lagoon: order column %q is not allow-listed", column)
}
var direction string
switch strings.ToLower(strings.TrimSpace(dir)) {
case "asc":
return column + " ASC", nil
direction = "ASC"
case "desc":
return column + " DESC", nil
direction = "DESC"
default:
return "", fmt.Errorf("lagoon: order direction %q is not allow-listed", dir)
}
if !opts.hasCollation {
return column + " " + direction, nil
}
if !validCollationName(opts.collation) {
return "", fmt.Errorf("lagoon: order collation %q is not a valid collation name", opts.collation)
}
return column + " COLLATE " + quoteCollation(opts.collation) + " " + direction, nil
}
// validCollationName accepts 1-63 ASCII bytes: letters, digits and
// underscore first, then letters, digits, '_', '-', '.' and '@'. The set
// excludes the double quote, so a valid name cannot leave its identifier.
func validCollationName(name string) bool {
if len(name) == 0 || len(name) > maxCollationName {
return false
}
for i := 0; i < len(name); i++ {
c := name[i]
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '_':
case i > 0 && (c == '-' || c == '.' || c == '@'):
default:
return false
}
}
return true
}
// quoteCollation wraps a validated collation name as a PostgreSQL quoted
// identifier.
func quoteCollation(name string) string {
return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
}
func allowListed(column string, allowed []string) bool {