Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Relationships & Joins

CRUDCrate automatically loads related entities based on your configuration.

Defining Relationships

Step 1: Sea-ORM Relation Definition

// In your entity file
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
    // Has Many: Post has many Comments
    #[sea_orm(has_many = "super::comment::Entity")]
    Comments,

    // Belongs To: Post belongs to User (author)
    #[sea_orm(
        belongs_to = "super::user::Entity",
        from = "Column::AuthorId",
        to = "super::user::Column::Id"
    )]
    Author,
}

// Implement Related trait for each relationship
impl Related<super::comment::Entity> for Entity {
    fn to() -> RelationDef {
        Relation::Comments.def()
    }
}

impl Related<super::user::Entity> for Entity {
    fn to() -> RelationDef {
        Relation::Author.def()
    }
}

Step 2: Add Join Fields to Model

#[derive(EntityToModels)]
pub struct Model {
    #[sea_orm(primary_key)]
    #[crudcrate(primary_key)]
    pub id: i32,

    pub title: String,

    #[crudcrate(filterable)]
    pub author_id: i32,

    // Many relationship (Vec)
    #[sea_orm(ignore)]
    #[crudcrate(non_db_attr, join(one, all))]
    pub comments: Vec<super::comment::Comment>,

    // One relationship (Option)
    #[sea_orm(ignore)]
    #[crudcrate(non_db_attr, join(one))]
    pub author: Option<super::user::User>,
}

Join Configuration

join(one)

Load relationship only in get_one (single item) responses:

#[crudcrate(non_db_attr, join(one))]
pub comments: Vec<Comment>,
# GET /posts/1 - Comments loaded
{
  "id": 1,
  "title": "Hello World",
  "comments": [
    {"id": 1, "content": "Great post!"},
    {"id": 2, "content": "Thanks!"}
  ]
}

# GET /posts - Comments NOT loaded
[
  {"id": 1, "title": "Hello World"},
  {"id": 2, "title": "Another Post"}
]

join(all)

Load relationship in both get_one and get_all responses:

#[crudcrate(non_db_attr, join(one, all))]
pub author: Option<User>,
# GET /posts - Authors loaded in list
[
  {
    "id": 1,
    "title": "Hello World",
    "author": {"id": 5, "name": "Alice"}
  }
]

Caution: Loading relationships in lists can be expensive. Use selectively.

depth Parameter

Limit recursive loading depth:

// Load Author, but don't load Author's Posts (which would load Posts' Authors, etc.)
#[crudcrate(non_db_attr, join(one, depth = 1))]
pub author: Option<User>,

// Allow 2 levels: Post → Comments → Author
#[crudcrate(non_db_attr, join(one, depth = 2))]
pub comments: Vec<Comment>,

Default: When not specified, recursion is limited to 5 levels for safety.

filterable and sortable inside join(...)

Enable filtering and sorting on columns from related entities:

#[sea_orm(ignore)]
#[crudcrate(
    non_db_attr,
    join(one, all, depth = 1, filterable("make", "year"), sortable("year", "mileage"))
)]
pub vehicles: Vec<Vehicle>,

This enables dot-notation queries:

# Filter by related entity columns
GET /customers?filter={"vehicles.make":"BMW","vehicles.year_gte":2020}

# Sort by related entity columns
GET /customers?sort=["vehicles.year","DESC"]

Joined filters are resolved automatically by the default handler via scoped sub-queries on the child table. Only whitelisted columns may be filtered or sorted; invalid columns are silently ignored for security.

See Filtering and Sorting for detailed documentation.

Relationship Types

Has Many (One-to-Many)

// Post has many Comments
#[sea_orm(ignore)]
#[crudcrate(non_db_attr, join(one))]
pub comments: Vec<Comment>,
{
  "id": 1,
  "comments": [
    {"id": 1, "content": "First"},
    {"id": 2, "content": "Second"}
  ]
}

Belongs To (Many-to-One)

// Comment belongs to User
#[sea_orm(ignore)]
#[crudcrate(non_db_attr, join(one, all))]
pub user: Option<User>,
{
  "id": 1,
  "content": "Great post!",
  "user": {"id": 5, "name": "Alice"}
}

Has One (One-to-One)

// User has one Profile
#[sea_orm(ignore)]
#[crudcrate(non_db_attr, join(one))]
pub profile: Option<Profile>,
{
  "id": 5,
  "name": "Alice",
  "profile": {"bio": "Developer", "avatar": "..."}
}

Recursive Relationships

For self-referencing entities:

// Category can have child categories
#[derive(EntityToModels)]
#[sea_orm(table_name = "categories")]
pub struct Model {
    #[sea_orm(primary_key)]
    #[crudcrate(primary_key)]
    pub id: i32,

    pub name: String,

    pub parent_id: Option<i32>,

    #[sea_orm(ignore)]
    #[crudcrate(non_db_attr, join(one, depth = 1))]
    pub children: Vec<Category>,
}
{
  "id": 1,
  "name": "Electronics",
  "children": [
    {"id": 2, "name": "Phones", "children": []},
    {"id": 3, "name": "Laptops", "children": []}
  ]
}

Self-referencing fields load one level: each child is returned with an empty children. Omitting depth on a self-referencing field is the same as depth = 1; any value above 1 is a compile error.

Complete Example

// user.rs
#[derive(Clone, Debug, DeriveEntityModel, Serialize, Deserialize, EntityToModels)]
#[crudcrate(generate_router)]
#[sea_orm(table_name = "users")]
pub struct Model {
    #[sea_orm(primary_key)]
    #[crudcrate(primary_key, exclude(create, update))]
    pub id: i32,

    #[crudcrate(filterable, sortable)]
    pub name: String,

    pub email: String,

    #[sea_orm(ignore)]
    #[crudcrate(non_db_attr, join(one))]
    pub posts: Vec<super::post::Post>,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
    #[sea_orm(has_many = "super::post::Entity")]
    Posts,
}

impl Related<super::post::Entity> for Entity {
    fn to() -> RelationDef {
        Relation::Posts.def()
    }
}
// post.rs
#[derive(Clone, Debug, DeriveEntityModel, Serialize, Deserialize, EntityToModels)]
#[crudcrate(generate_router)]
#[sea_orm(table_name = "posts")]
pub struct Model {
    #[sea_orm(primary_key)]
    #[crudcrate(primary_key, exclude(create, update))]
    pub id: i32,

    #[crudcrate(filterable, sortable, fulltext)]
    pub title: String,

    pub content: String,

    #[crudcrate(filterable)]
    pub author_id: i32,

    // Load author in detail and list views
    #[sea_orm(ignore)]
    #[crudcrate(non_db_attr, join(one, all, depth = 1))]
    pub author: Option<super::user::User>,

    // Load comments only in detail view
    #[sea_orm(ignore)]
    #[crudcrate(non_db_attr, join(one, depth = 2))]
    pub comments: Vec<super::comment::Comment>,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
    #[sea_orm(
        belongs_to = "super::user::Entity",
        from = "Column::AuthorId",
        to = "super::user::Column::Id"
    )]
    Author,

    #[sea_orm(has_many = "super::comment::Entity")]
    Comments,
}

impl Related<super::user::Entity> for Entity {
    fn to() -> RelationDef {
        Relation::Author.def()
    }
}

impl Related<super::comment::Entity> for Entity {
    fn to() -> RelationDef {
        Relation::Comments.def()
    }
}

Performance Considerations

Batch Loading (N+1 Query Fix)

CRUDCrate automatically uses batch loading for get_all() with join(all) or join(one, all) fields. Instead of issuing one query per parent entity (N+1), it loads all related entities in a single query:

-- 1. Fetch all parent entities
SELECT * FROM posts LIMIT 20;

-- 2. Batch-load ALL related entities for the page (one query per join field)
SELECT * FROM users WHERE id IN (5, 12, 7, ...);
SELECT * FROM comments WHERE post_id IN (1, 2, 3, ...);

This reduces query count from N+1 to 2 for depth=1 joins (1 for parents + 1 per join field). Results are grouped by parent ID in memory.

Depth > 1: Joins with depth > 1 fall back to per-item get_one() calls for nested children. For example, depth = 2 loads level-1 children via batch, then loads level-2 grandchildren per parent.

get_one() behavior: Single-item get_one() with join(one) uses per-item queries. This is acceptable since it’s a single entity lookup.

Batch loading works with any primary key type supported by CRUDResource (UUID, integer, string).

Why not SeaORM 2.0’s Entity Loader?

SeaORM 2.0 ships an Entity Loader (Entity::load().with(...)) that batch-loads relations, including nested ones. CRUDCrate keeps its own join loading instead, for three reasons:

  1. The loader only exists for entities using the new dense format (#[sea_orm::model]) or the transitional #[sea_orm::compact_model]; CRUDCrate cannot require either of your entities.
  2. Its with(...) mechanism selects which relations to populate but has no hook for per-child filter conditions, so scoped access (exclude(scoped) child scopes) cannot be enforced inside the loader’s queries.
  3. It returns SeaORM ModelEx values, not CRUDCrate API structs, so each child would still need conversion and its own nested join handling.

At depth 1 both approaches issue the same number of queries. This decision is revisited as the loader API evolves.

Optimization Strategies

  1. Use join(one) by default: Only load in detail views
  2. Limit depth: Prevent deep recursive loading
  3. Add indexes: On foreign key columns
-- Index foreign keys
CREATE INDEX idx_posts_author_id ON posts(author_id);
CREATE INDEX idx_comments_post_id ON comments(post_id);

When to Use join(all)

Use for:

  • Small reference data (categories, tags)
  • Essential context (author names in list)
  • Data needed for display

Avoid for:

  • Large collections (comments, history)
  • Deep hierarchies
  • Optional/rare data

Circular Reference Prevention

CRUDCrate detects and warns about potential infinite loops:

Warning: Potential circular reference detected in Post.author -> User.posts -> Post
Consider using depth limit: join(one, depth = 1)

Always use depth limits when entities reference each other.

Next Steps