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.
#[derive(umbral::orm::Model, /* … */)]struct Account { id: i64, #[umbral(unique, trim, lowercase)] email: String, display_name: String, // stored verbatim}trimstrips leading/trailing whitespace.lowercaselowercases 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
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.
#[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-emitsCREATE EXTENSION IF NOT EXISTS citext(needs a role withCREATEprivilege — pre-create the extension once if your runtime role is restricted). - SQLite → the column gets
COLLATE NOCASE.
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.mdandplanning/gaps3.md#34 / #35 for the design rationale.