This version is in beta. Some features may change before release.

Normalized & case-insensitive fields

Canonicalize a string column with

Normalized fields

Some string columns should be stored in a canonical form: an email is the same account whether the user types Dave@Example.com or dave@example.com, and a leading space in a username is almost always an accident. #[umbral(trim)] and #[umbral(lowercase)] declare that invariant on the field, and the framework applies it on write.

Code
rust
#[derive(umbral::orm::Model, /* … */)]
struct Account {
id: i64,
#[umbral(unique, trim, lowercase)]
email: String,
display_name: String, // stored verbatim
}
  • trim strips leading/trailing whitespace.
  • lowercase lowercases the value.

They combine (#[umbral(trim, lowercase)]) — trim runs first, so a value that trims to empty is treated as blank. Both are string-only: applying them to a non-String / Option<String> field is a compile error.

Case-insensitive uniqueness, for free

Pair lowercase with unique: because every stored row is already lowercased, the ordinary UNIQUE constraint enforces case-insensitive uniqueness with no special index. A second signup of DAVE@example.com collides with the existing dave@example.com instead of creating a twin account.

Where it applies

Note

Normalization runs on the dynamic write path only — REST create/update (insert_json / update_json) and admin form-submit — exactly like auto_now. The typed Model::objects().create(instance) path is caller-controlled: it stores the struct field as-is, so normalize it yourself before creating if you build the struct by hand.

This mirrors how the built-in AuthUser works: username / email carry #[umbral(trim, lowercase)] so admin and REST writes canonicalize, while the create_user helper normalizes the typed path itself.

Reading back

Normalization is a write-time transform — it changes what's stored, not what's queried. To match a stored value in a filter, normalize your query term the same way (email.eq(input.trim().to_lowercase())).

case_insensitive: preserve the original casing

lowercase throws the original casing away. When you need to keep it — a Group.name displayed as Editors but where editors mustn't be creatable as a second row — use #[umbral(case_insensitive)] instead. It makes the column itself case-insensitive at the database level: =, UNIQUE, and ORDER BY all fold case, while storage keeps whatever case was written.

Code
rust
#[derive(umbral::orm::Model, /* … */)]
struct Group {
id: i64,
#[umbral(case_insensitive, unique)]
name: String, // stores "Editors"; "editors" collides; WHERE name = 'EDITORS' finds it
}

Unlike trim / lowercase (write-path transforms), this is schema-level and backend-specific:

  • Postgres → the column is citext, and the migration auto-emits CREATE EXTENSION IF NOT EXISTS citext (needs a role with CREATE privilege — pre-create the extension once if your runtime role is restricted).
  • SQLite → the column gets COLLATE NOCASE.
Warning

SQLite's COLLATE NOCASE folds ASCII A–Z only — non-ASCII letters (Å, Turkish İ) compare case-sensitively. A boot check warns when case_insensitive is used on SQLite. Postgres citext folds per the database collation. If you need Unicode-correct folding and don't need to preserve casing, #[umbral(lowercase)] (Rust's to_lowercase) is Unicode-aware.

Like #[umbral(unique)], it applies at CREATE TABLE. Toggling it on a column that already exists needs a hand-written migration (Postgres ALTER … TYPE citext, SQLite a table rebuild) — the autodetector doesn't emit it.

Which to reach for: lowercase + unique when you're happy to canonicalize (simpler, portable, Unicode-correct); case_insensitive when the original casing must survive for display.

See also

  • Privileged fields — the other default-deny write-path guard.
  • arch.md and planning/gaps3.md #34 / #35 for the design rationale.
ormnormalizationtrimlowercasecase-insensitivecitextuniqueness