navree
Documentation

Models

#[derive(Model)]: tables, keys, columns and timestamps; creating, reading, updating and deleting records; dirty tracking; and mass assignment from untyped input.

On this page

A model maps a table to a Rust struct. #[derive(Model)] reads the struct and writes everything the ORM needs: the table and key, a decoder for rows, the values to write, and one typed column constant per field. There is no reflection at runtime and no schema file: the struct is the source of truth.

src/modules/deals/models/deal.rs

A model must be Clone, Send, Sync, Serialize and 'static. Derive Default as well: relationships, mass assignment and the find-or-create methods build records from it, and ..Default::default() keeps struct literals short.

Tables and columns

AttributeDefaultMeaning
table = "deals"the struct's name in snake case, plural (Deal → deals, Company → companies, Person → people)The table.
primary_key = "code"idThe key column. A model has one.
key_type = "uuid_v4"by the key's type (below)How the key gets a value.
timestampsoffKeep created_at and updated_at (below).
soft_deletesoffSet deleted_at instead of deleting, and hide trashed rows from queries. See soft deletes.
connection = "analytics"the default connectionRun the model's queries on a named connection.
per_page = 2515The page size of paginate when the request names none.

Other attributes belong to other pages: global_scopes(..) to scopes; touches, morph_name, with and with_count to relationships and eager loading; fillable, guarded and unguarded to mass assignment; hidden, visible, appends and resource to serialization; observers, dispatches and broadcasts to events; prunable to pruning; factory to factories; route_key to route model binding; workspace, subject and policy to workspaces and access.

Each field is a column of the same name. Field attributes change that:

Rust

#[model(cast = ..)] stores a field in another form, such as an enum by its name or a struct as JSON; casts lists them. Without a cast, a field's type must be one the ORM reads and writes directly:

Rust typeTypical column
boolboolean
i8 to i64, u8 to u64integers
f32, f64float, double
Decimal (navree::orm::Decimal)decimal(p, s)
Stringstring, text
Vec<u8>binary
Uuid, Uliduuid, ulid
NaiveDate, NaiveTime, NaiveDateTimedate, time, date_time
DateTime<Utc>, DateTime<FixedOffset>timestamp_tz
serde_json::Valuejson, jsonb
url::Urlstring
Option<T> of any of thesea nullable column; NULL is None

Reading is exact first and tolerant where databases differ: an integer column of any width reads into any integer type that holds it, a decimal sum reads as an integer, and SQLite's text dates and JSON read as dates and JSON.

Column constants

The derive adds one constant per column, named after the field in capitals and typed like it:

Rust

A condition takes a value of the column's type, so Deal::AMOUNT_CENTS.eq("ten") is a compile error. A nullable column takes plain values (Deal::CLOSED_AT.eq(now)) and None. Columns are qualified with their table in SQL, so joins never confuse them. The query builder lists every operator.

Mistakes caught at compile time

The derive refuses a model it can't map, with a message at the field and a fix: a missing key field, an Option key, a cast on the key, two fields with the same column, a timestamp column without its field, a soft-delete column that isn't an Option, or an unknown attribute (with the closest one suggested).

Primary keys

The key's Rust type decides how it gets a value:

Key typekey_typeValue
Uuiduuid_v7A UUID v7, generated when the record is inserted with a nil key. UUID v7s start with a timestamp, so keys sort by creation time and keep inserts at the end of the index.
Uuiduuid_v4A random UUID.
Ulid (navree::orm::Ulid)ulidA ULID, generated on insert and stored as its 26-character text.
i16, i32, i64, u32, u64intAssigned by the database (identity, AUTO_INCREMENT). A record whose key is 0 inserts without it and reads the new key back.
String, anything elsemanualSet by your code. Inserting an empty one fails with OrmError::MissingKey.
Rust

Navree's own tables use UUID v7 keys. A generated key is known before the INSERT, doesn't reveal how many records there are, and lets cursor pagination page by key.

A Uuid field is stored as a native UUID on PostgreSQL and as its 16 bytes on MySQL, MariaDB and SQLite. On MySQL its column must be a 16-byte binary, not the schema builder's uuid column, which is CHAR(36) there; use the same binary on MariaDB (UUID columns on MySQL).

Timestamps

With timestamps, the ORM sets created_at and updated_at on insert when they are empty, and updated_at on every update that changes something, unless you changed it yourself. Times come from the app's dyn Clock, so a test that fixes the clock sees fixed timestamps, and are cut to microseconds so the stored and in-memory values agree on every database.

The columns may have other names, or be left out: timestamps(created_at = "inserted_at", updated_at = false). A timestamp field is a DateTime<Utc>, a DateTime<FixedOffset>, a NaiveDateTime, an i64 (Unix seconds) or an Option of one of them.

Creating records

create inserts a model and returns its record:

Rust

The record holds the key and timestamps the insert filled in. To insert in two steps, wrap the model in a Record and save it:

Rust

save inserts a new record and updates a stored one. Both run the model events, and through a unit of work they record a change event for the audit log. save returns false when a saving or creating observer canceled it.

To insert many rows in one statement, without events, use the bulk writes of the query builder.

Reading records

Rust

Deal::query() starts a query of every record, Deal::where_(..) one with a condition; the query builder does the rest. A query of a model with soft deletes leaves trashed records out.

Records

A Record<M> is a model as the ORM knows it. It dereferences to the model, so you read and change fields directly, and it keeps:

  • whether it is stored (exists()) and whether its last save inserted it (was_recently_created());
  • the values it was read or saved with, for dirty tracking;
  • the extra columns its query selected, such as aggregates (extra::<T>("name"), extras());
  • its loaded relationships.

record.model() borrows the model and into_inner() takes it out. a.is(&b) tells whether two records are the same stored record (same key) and is_not the opposite. A record serializes as its model, followed by its extras and loaded relations (serialization).

Updating records

Change the fields, then save:

Rust

Only the columns that changed are written, and a record with no change writes nothing (its saving and saved events still run). update changes a stored record in a closure and saves it in one call; it returns false and changes nothing for a record that isn't stored:

Rust

To update many records in one statement, use the query builder's mass updates.

Counters and timestamps

increment and decrement change a numeric column in the database (SET amount_cents = amount_cents + 500, plus updated_at) and in the record, so concurrent increments don't overwrite each other:

Rust

increment_each(&db, [(column, amount), ..]) and decrement_each change several columns of the same type in one statement. touch sets updated_at to now and saves the record (with any other change); touch_column(&db, Deal::CLOSED_AT) sets another date-time column.

Finding or creating

These methods look a record up by the given values, then create or update it:

MethodDoes
first_or_create(exec, values, fill)The first record matching values, or a new one with values and what fill sets, inserted.
first_or_new(exec, values, fill)The same, but the new record is not saved.
create_or_first(exec, values, fill)Inserts first; when that breaks a unique constraint, returns the record that exists.
update_or_create(exec, values, fill)Updates the first match with fill and saves it, or creates a record with both.
Rust

Column::set(value) builds the values list, typed like conditions. first_or_create and update_or_create are safe under concurrency: when another request inserts the same record between the lookup and the insert, the unique violation is caught and the record that won is returned. Inside a transaction, the insert runs in a savepoint, so the failed attempt doesn't abort the transaction on PostgreSQL. This needs a unique index on the values columns.

The same methods exist on queries (Tag::query().first_or_create(..)) and on relations, which fill in the foreign key.

Reloading and copying

Rust

refresh and fresh read by key without global scopes, so a soft-deleted record reloads too; refresh fails with ModelNotFound when the row is gone, and also reloads the relations the record has loaded. refresh_for_update rereads the row and locks it until the unit of work ends (locking).

Deleting records

Rust

delete returns false for a record that isn't stored, or when a deleting observer canceled it. On a model with soft_deletes it sets deleted_at instead; force_delete removes the row. destroy loads the records with these keys and deletes them one by one, so each one's events run. Deleting by a query, in one statement, is a mass delete.

Dirty tracking

A record remembers its values as they were read or last saved, so it knows what you changed:

Rust

After a save, was_changed(), was_changed_on([..]), get_changes() (the new values) and get_previous() (the old ones) describe what that save wrote:

Rust

Values are compared as they are stored: decimals by value, floats within a small tolerance, date-times by instant, JSON by structure, and cast fields as their cast compares them (an encrypted field by its plain text, since every encryption differs). A new record is dirty in every column. get_original() returns the whole model as it was, sync_original() accepts the current values as the original ones, and sync_original_on([..]) some of them.

Mass assignment

Mass assignment sets many columns from untyped input, such as a JSON body, in one call. Because input decides which columns change, a model lists the columns it allows:

Rust
  • fillable(..) lists the columns fill may set. A JSON path such as "meta->source" allows one key inside a JSON column.
  • guarded(..) lists the columns it may not set instead; every other column is fillable.
  • A model that declares neither is totally guarded: fill fails with OrmError::MassAssignment, naming the model. force_fill still works.
  • #[model(fillable)] and #[model(guarded)] on a field add it to the list; #[model(unguarded)] turns guarding off for a model.

attrs! builds the input in code. fill sets what the model allows and leaves the rest out:

Rust

Only real columns can be filled: an unknown key, or a path into a column that isn't JSON, never reaches the model. Values convert to each field's type the way untyped input does (numbers, numeric strings, RFC 3339 dates), through the field's cast and mutator, and fill checks every key before it changes anything.

Attributes is the input type. It deserializes from a JSON object, keeping the order its keys were sent in, so a handler can read a body into it:

Rust

create_from creates a record from the model's Default filled with the input; force_create does the same unguarded; update_attributes fills a stored record and saves it. Attributes::from_json(value) and Attributes::from_serialize(&dto) build input from a JSON value or any serializable struct. A validated DTO is usually the better input: validate it, then copy its fields or fill from Attributes::from_serialize(&input).

set_attribute(Deal::CURRENCY, "EUR") sets one column from untyped input, unguarded, and set_json_path(Deal::META, "source", "referral") one value inside a JSON column, creating the objects on the way.

When a key is left out

By default fill leaves out the keys the model doesn't allow. With the strict mode prevent_discarding_attributes, on in development unless you turn it off (configuration), it fails with OrmError::MassAssignment instead, before changing anything, so a form that sends a field the model guards shows up at once. A DiscardPolicy can decide for the process (set_discard_policy) or for one task (with_discard_policy), and unguarded(future) turns guarding off while a future runs:

Rust

attrs! values are anything serde_json::json! accepts, so a Uuid or a variable works as well as a literal.