navree
Documentation

Casts and accessors

Store fields in another form: enums, JSON, fixed-scale decimals, formatted dates, encrypted and hashed secrets, binary ids, vectors, your own casts and fields across several columns; computed values with accessors, and mutators.

On this page

A field's Rust type and its column don't have to match. A cast converts between them: a Stage enum stored as 'won', a struct stored as JSON, a token stored encrypted. You choose one with #[model(cast = ..)], and the ORM applies it everywhere the field goes: reading rows, saving, dirty tracking, mass assignment, serialization, and conditions, so Deal::STAGE.eq(Stage::Won) binds 'won'.

Rust

A cast on an Option<T> field casts the inner value, and NULL stays None. Fields without a cast are read and written as their type (the field types).

Built-in casts

cast =Field typeStored as
"string_enum"a unit enumits serde name, as text
"int_enum"a Copy enum with Into<i64> and TryFrom<i64> (a #[repr] enum)an integer
"json"any Serialize + DeserializeOwned type: structs, Vec, maps, sets of enumsJSON
"decimal(N)"Decimala decimal, rounded half away from zero to N places (at most 28) when written
"datetime_format(\"..\")"DateTime<Utc>, DateTime<FixedOffset>, NaiveDateTime, NaiveDate, NaiveTimetext in that format (checked when the model compiles), and serialized in it too
"unix_timestamp", "unix_timestamp_ms"DateTime<Utc>, NaiveDateTimeinteger seconds or milliseconds
"encrypted"String, Vec<u8>an encrypted payload
"encrypted_json"any serde typeencrypted JSON
"hashed"Stringan Argon2id hash
"binary_uuid", "binary_ulid"Uuid, Ulid16 bytes; ULIDs big-endian, so the bytes sort by time
"vector"Vec<f32>a vector: pgvector's vector on PostgreSQL (with the postgres feature), little-endian f32 bytes on MySQL 9, MariaDB 11.7 and SQLite (their vector format, and what sqlite-vec reads)

A string enum reads and writes the names its serde attributes give it (#[serde(rename_all = "snake_case")]), and a name the enum doesn't have is an OrmError::Cast naming the column. Values a cast rounds or normalizes (decimals, Unix timestamps) are stored as the record holds them after a save, so the record and the row agree.

Encrypted and hashed fields

"encrypted" encrypts a field with the app's key (AES-256-GCM, navree-crypto's Encrypter, from CryptoModule and APP_KEY), so the database only ever holds the payload:

Rust
  • The record holds the plain value: you read and assign integration.api_token as text. Because of that, a serialized record holds it too, so hide encrypted fields from responses (hidden(..), serialization).
  • Every encryption of the same text differs, so encrypted fields are compared by their plain value for dirty tracking. A payload written under a previous key (after a key rotation, with the old key in app.previous_keys) reads fine and is rewritten with the current key the next time the record is saved.
  • #[model(encrypter = "name")] picks a named encrypter for the model's encrypted fields.
  • An encrypted column can't be searched by value: there is no condition on its plain text.

"hashed" stores an Argon2id hash. Assign the plain value: it is hashed before the record is written, off the async threads, and the record holds the hash after the save. A value that already is an Argon2 hash is kept as it is; a bcrypt or scrypt hash, or a malformed Argon2 string, is refused. Check a password against the stored hash with navree-crypto's Hasher::verify.

Both need the app's services. Queries and saves through &Db or &Uow find them in the executor's app. Code with a pool opened by hand, such as a script or a test, sets them with a CastContext:

Rust

A cast that misses its service fails with an error saying what to register. CastContext::set_default installs a context for the whole process.

Your own casts

A cast implements Cast<T>: how the stored form becomes the field, and back.

Rust

Stored is any type the ORM reads and writes directly (text, an integer, JSON, ...). get and set are all a cast must write; the trait's other methods refine how the field is treated, each with a default:

MethodFor
CONTEXT, get_with, set_withCasts that need app services (an encrypter, the clock, the configuration) from the CastContext.
eq_stored, sameDirty tracking: when two values count as the same.
from_jsonMass assignment: the field from untyped input.
serializeThe value in serialized records.
increment, decrementrecord.increment_cast(..).
prepareAsynchronous work before a write (hashing).
is_staleStored values to rewrite at the next save (old encryption keys).

Types that pick their own cast

A value type can name its cast once, with Castable, so fields of that type only write #[model(cast)]:

Rust

A cast with const generic parameters takes them in the attribute: #[model(cast(Scaled, 100))] is Scaled<100>. A type can also implement Field itself, which makes it usable in rows, plucks and untyped conditions too.

Transforming values on the way in

An inbound cast changes a value when it is written and reads it back as stored: trimming, lower-casing, slugifying.

Rust

The transformation runs whenever the field is encoded (saves, dirty checks, conditions), and the record holds the transformed value after a save. Make it idempotent: transforming a transformed value must change nothing.

One field, several columns

A MultiColumnCast stores one field in several columns, such as money in an amount and a currency column:

Rust

Each column gets its own typed constant, its own dirty tracking and its own key in fill; the record serializes the field under its own name. An Option<Money> field is None when every column is NULL. Two to eight columns, as a tuple in the order of columns(..).

Accessors

An accessor is a computed value of a model that serialized records can include. Mark methods #[accessor] in an #[accessors] block:

Rust

An accessor returns any Serialize type and stays a plain method (person.initials()). appends(..) adds it to every serialized record; a name there that isn't an accessor is a compile error. record.append(["display_name"]) adds one to a single record. #[accessor(name = "..")] renames its key, and schema = Type gives its OpenAPI schema.

Mutators

A mutator runs when a field is set from input: fill, set_attribute, and the setter the derive generates. Name a function fn(&mut Model, T) with set_with:

Rust

Assigning the field directly (person.full_name = ..) bypasses the mutator: it is plain Rust.

Query-time casts

Extra columns a query selects (select_raw, aggregates) are read by type when you ask for them (record.extra::<Decimal>("gross")). To convert one as it is read, so it also serializes in that form, name its cast on the query:

Rust

ExtraCast has Bool, Integer, Float, Decimal (with an optional scale), String, Json, DateTime, Date, Uuid and Custom(fn), which smooths over databases that return a sum as a decimal or a comparison as 0/1.