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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user