Models & Relationships

  1. #[derive(Model)]
  2. created_at / updated_at: #[timestamps]
  3. Relationships
    1. belongs_to / has_one
    2. has_many
    3. belongs_to_many (many-to-many, through a pivot table)
  4. Eager loading (avoiding N+1)
  5. Next

#[derive(Model)]

use larust_support::orm::sqlx;
use larust_support::Model;

#[derive(Model, sqlx::FromRow)]
#[table("posts")]
#[belongs_to(User, foreign_key = "user_id")]
#[has_many(Comment, foreign_key = "post_id")]
pub struct Post {
    #[primary_key]
    pub id: i64,
    pub user_id: i64,
    pub title: String,
    pub content: String,
}

#[table("posts")] names the table explicitly - there’s no automatic pluralization-of-the-struct-name convention to fight with when it guesses wrong. #[primary_key] marks the id field (any i64 field; the column name itself is whatever you named the field). sqlx::FromRow is a real, separate derive you always add alongside Model - see Coming from Rust for why it can’t be folded into Model itself.

This generates, for every model:

pub struct NewPost {          // every field except the primary key
    pub user_id: i64,
    pub title: String,
    pub content: String,
}

impl Post {
    pub async fn create(data: NewPost) -> Result<Post, AppError>;
    pub async fn find(id: i64) -> Result<Option<Post>, AppError>;
    pub async fn update(id: i64, data: NewPost) -> Result<Post, AppError>;
    pub async fn delete(id: i64) -> Result<(), AppError>;
    pub fn query() -> QueryBuilder<Post>;
}

…plus a real FromRequestParts impl, which is what makes route model binding work - a handler parameter typed post: Post resolves by primary key from a {post} path segment automatically, 404ing if nothing matches.

let post = Post::create(NewPost {
    user_id: user.id,
    title: "Hello".to_string(),
    content: "...".to_string(),
}).await?;

let post = Post::update(post.id, NewPost { user_id: post.user_id, title: "Edited".to_string(), content: post.content }).await?;

Post::delete(post.id).await?;

Every field on NewPost mirrors Post’s own real, typed fields - there’s no dynamic attribute bag, so an update always carries every column’s full value (matching the reference app’s own convention of building a NewPost from the existing row’s other fields when only one is actually changing, as PostController::update/ProfileController both do).

created_at / updated_at: #[timestamps]

Laravel’s $table->timestamps() plus Eloquent’s default auto-touch behavior:

#[derive(Model, sqlx::FromRow)]
#[table("posts")]
#[timestamps]
pub struct Post {
    #[primary_key]
    pub id: i64,
    pub title: String,
    pub created_at: i64,
    pub updated_at: i64,
}
-- migration
CREATE TABLE posts (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    created_at INTEGER NOT NULL,
    updated_at INTEGER NOT NULL
);

Both columns are i64 - Unix seconds, the same convention every other framework-owned timestamp column already uses (larust-queue’s jobs table, larust-notifications’ own table), not a SQL DATETIME/ TIMESTAMP type. create() stamps both to the same value; update() re-stamps updated_at only, leaving created_at untouched. Neither field appears on the generated NewPost - exactly like the primary key, they’re never something you set yourself:

let post = Post::create(NewPost {
    title: "Hello".to_string(),
    // no created_at/updated_at here - #[timestamps] owns both
}).await?;
assert_eq!(post.created_at, post.updated_at); // just-created: identical

let post = Post::update(post.id, NewPost { title: "Edited".to_string() }).await?;
// post.updated_at just moved forward; post.created_at didn't

#[timestamps] is opt-in, not automatic on every model - this framework requires an explicit #[primary_key] even though id would be an equally obvious convention, so a model without the attribute is completely unaffected; add both fields and the attribute only where you actually want the behavior. xr convert already emits exactly this created_at INTEGER/updated_at INTEGER shape for a Laravel migration’s $table->timestamps() - add #[timestamps] to the matching #[derive(Model)] struct afterward to wire up the same auto-population Eloquent had.

Relationships

Four kinds, declared as attributes on the struct - each generates both a lazy per-instance accessor and a batch eager-loader.

belongs_to / has_one

#[belongs_to(User, foreign_key = "user_id")]   // this row's own user_id points at the related row
struct Post { ... }

#[has_one(Profile, foreign_key = "user_id")]   // the *related* row's foreign key points back at this one
struct User { ... }

Generates post.user() -> Result<Option<User>, AppError> / user.profile() -> Result<Option<Profile>, AppError> - the difference between the two is only which side owns the foreign key, exactly like Laravel’s own belongsTo/hasOne.

has_many

#[has_many(Comment, foreign_key = "post_id")]
struct Post { ... }

Generates post.comments() -> Result<Vec<Comment>, AppError>. The default method name is the related type’s name, pluralized and snake-cased (Commentcomments) - override it with method = "replies" if you need a different name (e.g. two has_many relations to the same related type on one model).

belongs_to_many (many-to-many, through a pivot table)

#[belongs_to_many(
    Tag,
    through = "post_tag",
    foreign_key = "post_id",
    related_pivot_key = "tag_id"
)]
struct Post { ... }

Generates four methods:

post.tags().await?;                 // Vec<Tag>, via an INNER JOIN through post_tag
post.attach_tag(tag_id).await?;     // insert one pivot row - a no-op if already attached, not a UNIQUE error
post.detach_tag(tag_id).await?;     // remove one pivot row
post.sync_tags(vec![id1, id2]).await?;  // replace the full set in one transaction

attach_*/detach_*/sync_* are named after the singular/plural related type name respectively - attach_tag/detach_tag/sync_tags for Tag, following from the same naming rule as has_many.

Deleting a row doesn’t automatically clean up its pivot rows - if a Post is deleted, its post_tag rows aren’t cascade-deleted for you unless your migration’s own REFERENCES ... ON DELETE CASCADE says so. The reference app’s PostController::destroy shows the explicit alternative: a plain DELETE FROM post_tag WHERE post_id = ? run before Post::delete(...).

Eager loading (avoiding N+1)

Every relationship’s batch loader takes a slice of rows and returns a lookup map, not a mutated collection - a deliberate difference from Laravel’s ::with(...), which attaches results back onto each model in place:

let posts = Post::query().paginate(20).await?;
let authors: HashMap<i64, User> = Post::load_user(&posts).await?;   // one query, not one per post

for post in &posts {
    let author = authors.get(&post.user_id);   // no query here
}

Why a map instead of mutation: a generated Post struct has no field to attach a User onto (adding one would mean every Post value carries an Option<User> slot whether or not it was ever loaded), and Rust’s own ownership rules make “mutate this vec of structs to attach borrowed related data” considerably more awkward than in a dynamically-typed language. A HashMap keyed by id, looked up per row in your own template or handler code, is the one that actually fits real Rust ergonomics.

load_* deduplicates ids before querying (several rows sharing one author only queries that author once) - the same discipline PostController::index’s own eager-loaded author list is built and verified against in this framework’s own test suite (2 queries for a whole page of posts + authors, not N+1, checked directly rather than assumed).

Next

Digging Deeper covers everything else an app tends to need - auth, mail, queues, and the reactive @wire/@live components that make Larust more than “Laravel’s directory layout with a Rust compiler bolted on.”