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

CRUDCrate

CRUDCrate

REST APIs from your database models. One derive macro. That’s it.

use crudcrate::EntityToModels;
use sea_orm::entity::prelude::*;

#[derive(Clone, Debug, DeriveEntityModel, EntityToModels)]
#[crudcrate(generate_router)]
#[sea_orm(table_name = "todos")]
pub struct Model {
    #[sea_orm(primary_key)]
    #[crudcrate(primary_key)]
    pub id: i32,

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

    pub completed: bool,
}

That’s it. You now have:

GET    /todos           # List with filtering, sorting, pagination
GET    /todos/:id       # Get one
POST   /todos           # Create
PUT    /todos/:id       # Update
DELETE /todos/:id       # Delete
DELETE /todos           # Bulk delete

Try It Now

git clone https://github.com/evanjt/crudcrate
cd crudcrate/crudcrate
cargo run --example minimal

Then visit:

  • API: http://localhost:3000/todo
  • Docs: http://localhost:3000/docs (interactive OpenAPI)

Install

cargo add crudcrate

Or add to Cargo.toml:

[dependencies]
crudcrate = "0.15"

Quick Example

use axum::Router;
use crudcrate::EntityToModels;
use sea_orm::{entity::prelude::*, Database};

#[derive(Clone, Debug, DeriveEntityModel, EntityToModels)]
#[crudcrate(generate_router)]
#[sea_orm(table_name = "items")]
pub struct Model {
    #[sea_orm(primary_key)]
    #[crudcrate(primary_key)]
    pub id: i32,

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

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

impl ActiveModelBehavior for ActiveModel {}

#[tokio::main]
async fn main() {
    let db = Database::connect("sqlite::memory:").await.unwrap();

    let app = Router::new()
        .merge(item_router())
        .layer(axum::Extension(db));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Run it:

cargo run

Test it:

# Create
curl -X POST http://localhost:3000/items \
  -H "Content-Type: application/json" \
  -d '{"name": "My Item"}'

# List all
curl http://localhost:3000/items

# Filter
curl 'http://localhost:3000/items?filter={"name":"My Item"}'

# Sort
curl 'http://localhost:3000/items?sort=["name","DESC"]'

Features

Mark fields to enable capabilities:

#[crudcrate(filterable)]     // Enable filtering on this field
#[crudcrate(sortable)]       // Enable sorting on this field
#[crudcrate(fulltext)]       // Include in fulltext search
#[crudcrate(exclude(create))] // Don't include in create requests

Auto-generate values:

#[crudcrate(on_create = Uuid::new_v4())]      // Generate ID
#[crudcrate(on_create = chrono::Utc::now())]  // Set timestamp
#[crudcrate(on_update = chrono::Utc::now())]  // Update timestamp

Load relationships:

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

What Gets Generated

From your entity, CRUDCrate generates:

GeneratedPurpose
ItemResponse model
ItemCreateCreate request body
ItemUpdateUpdate request body (all fields optional)
ItemListList response model
item_router()Axum router with all endpoints
CRUDResource implDatabase operations

Learn

Tutorial Start here. Learn CRUDCrate step by step. First Steps

Examples See complete, working code. Minimal Example

Reference All attributes and options. Field Attributes


Requirements

  • Rust 1.85+
  • Sea-ORM 2.0+
  • Axum 0.8+

CRUDCrate works with PostgreSQL, MySQL, and SQLite.


License

MIT License - use it however you want.

Your First API

📋 See test

Let’s build a task manager. We’ll start simple and add features as we need them.

Setup

cargo new taskmanager
cd taskmanager

Add dependencies to Cargo.toml:

[package]
name = "taskmanager"
version = "0.1.0"
edition = "2021"

[dependencies]
crudcrate = "0.15"
sea-orm = { version = "2.0", features = ["runtime-tokio-rustls", "sqlx-sqlite"] }
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }

The Simplest Task

Replace src/main.rs:

use axum::Router;
use crudcrate::EntityToModels;
use sea_orm::{entity::prelude::*, Database, DatabaseConnection};

#[derive(Clone, Debug, DeriveEntityModel, EntityToModels)]
#[crudcrate(generate_router)]
#[sea_orm(table_name = "tasks")]
pub struct Model {
    #[sea_orm(primary_key)]
    #[crudcrate(primary_key)]
    pub id: i32,

    pub title: String,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

impl ActiveModelBehavior for ActiveModel {}

#[tokio::main]
async fn main() {
    let db: DatabaseConnection = Database::connect("sqlite::memory:")
        .await
        .expect("Database connection failed");

    // Create table
    db.execute(sea_orm::Statement::from_string(
        db.get_database_backend(),
        "CREATE TABLE tasks (id INTEGER PRIMARY KEY, title TEXT NOT NULL)".to_owned(),
    ))
    .await
    .expect("Table creation failed");

    let app = Router::new()
        .merge(task_router())
        .layer(axum::Extension(db));

    println!("Task Manager running at http://localhost:3000");
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Run It

cargo run

Try It

# Create a task
curl -X POST http://localhost:3000/tasks \
  -H "Content-Type: application/json" \
  -d '{"id": 1, "title": "Learn CRUDCrate"}'

# List tasks
curl http://localhost:3000/tasks

# Get one task
curl http://localhost:3000/tasks/1

# Update it
curl -X PUT http://localhost:3000/tasks/1 \
  -H "Content-Type: application/json" \
  -d '{"title": "Master CRUDCrate"}'

# Delete it
curl -X DELETE http://localhost:3000/tasks/1

That’s it. You have a working REST API.


What We Got

From those few lines, CRUDCrate generated:

EndpointWhat it does
GET /tasksList all tasks
GET /tasks/:idGet one task
POST /tasksCreate a task
PUT /tasks/:idUpdate a task
DELETE /tasks/:idDelete a task

Plus request/response models, error handling, and JSON serialization.


Try the Minimal Example

Don’t want to type all this? Run the included example:

git clone https://github.com/evanjt/crudcrate
cd crudcrate/crudcrate
cargo run --example minimal

Then visit:

  • API: http://localhost:3000/todo
  • Docs: http://localhost:3000/docs (interactive OpenAPI)

But Wait…

Did you notice we had to specify "id": 1 when creating? That’s annoying. Users shouldn’t have to pick their own IDs.

Next: Let’s fix that - make IDs generate automatically.

Auto-Generating IDs

In the last chapter, users had to specify their own IDs. That’s not ideal - what if two users pick the same ID?

Let’s make IDs generate automatically using UUIDs.

The Problem

# User has to pick an ID - what if 1 is already taken?
curl -X POST http://localhost:3000/tasks \
  -d '{"id": 1, "title": "My task"}'

The Solution

Two changes:

  1. Exclude the ID from create requests
  2. Auto-generate it with on_create
use uuid::Uuid;

#[derive(Clone, Debug, DeriveEntityModel, EntityToModels)]
#[crudcrate(generate_router)]
#[sea_orm(table_name = "tasks")]
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    #[crudcrate(
        primary_key,
        exclude(create, update),      // Users can't set or change ID
        on_create = Uuid::new_v4()    // Generate UUID automatically
    )]
    pub id: Uuid,

    pub title: String,
}

Add uuid to your Cargo.toml:

uuid = { version = "1.0", features = ["v4", "serde"] }

Update your table:

CREATE TABLE tasks (
    id TEXT PRIMARY KEY,
    title TEXT NOT NULL
)

Now It Works

# No ID needed!
curl -X POST http://localhost:3000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Buy groceries"}'

Response - ID is generated:

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "title": "Buy groceries"
}

What exclude Does

AttributeEffect
exclude(create)Field not in POST request body
exclude(update)Field not in PUT request body
exclude(create, update)Field managed by the system, not users

What on_create Does

📋 See test

The expression is evaluated when inserting a new record:

on_create = Uuid::new_v4()      // Generate UUID
on_create = chrono::Utc::now()  // Current timestamp
on_create = 0                   // Default number
on_create = false               // Default boolean

Our Task Model Now

pub struct Model {
    #[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
    pub id: Uuid,

    pub title: String,
}

But when was this task created? When was it last updated? We have no idea.

Next: Let’s add timestamps to track when things happen.

Adding Timestamps

We want to know when tasks were created and when they were last modified.

Add the Fields

use chrono::{DateTime, Utc};
use uuid::Uuid;

#[derive(Clone, Debug, DeriveEntityModel, EntityToModels)]
#[crudcrate(generate_router)]
#[sea_orm(table_name = "tasks")]
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    #[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
    pub id: Uuid,

    pub title: String,

    #[crudcrate(exclude(create, update), on_create = chrono::Utc::now())]
    pub created_at: DateTime<Utc>,

    #[crudcrate(exclude(create, update), on_create = chrono::Utc::now(), on_update = chrono::Utc::now())]
    pub updated_at: DateTime<Utc>,
}

Add chrono to Cargo.toml:

chrono = { version = "0.4", features = ["serde"] }

Update your table:

CREATE TABLE tasks (
    id TEXT PRIMARY KEY,
    title TEXT NOT NULL,
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL
)

What’s Happening

📋 See test 📋 See test

Fieldon_createon_updateBehavior
created_atUtc::now()-Set once when created
updated_atUtc::now()Utc::now()Set on create, updated on every change

Both are exclude(create, update) so users can’t manually set them.

Try It

# Create
curl -X POST http://localhost:3000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Learn timestamps"}'

Response:

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "title": "Learn timestamps",
  "created_at": "2024-01-15T10:30:00Z",
  "updated_at": "2024-01-15T10:30:00Z"
}
# Update (wait a few seconds first)
curl -X PUT http://localhost:3000/tasks/550e8400-e29b-41d4-a716-446655440000 \
  -H "Content-Type: application/json" \
  -d '{"title": "Master timestamps"}'

Response - notice updated_at changed:

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "title": "Master timestamps",
  "created_at": "2024-01-15T10:30:00Z",
  "updated_at": "2024-01-15T10:30:45Z"
}

Our Task Model Now

pub struct Model {
    #[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
    pub id: Uuid,

    pub title: String,

    #[crudcrate(exclude(create, update), on_create = chrono::Utc::now())]
    pub created_at: DateTime<Utc>,

    #[crudcrate(exclude(create, update), on_create = chrono::Utc::now(), on_update = chrono::Utc::now())]
    pub updated_at: DateTime<Utc>,
}

Now we have proper task tracking. But as we add more tasks, how do we find specific ones?

Next: Finding tasks - filter by field values.

Finding Tasks

📋 See test

We have lots of tasks now. Let’s add a way to filter them.

Add a Status Field

First, let’s give tasks a completed status:

pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    #[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
    pub id: Uuid,

    pub title: String,

    #[crudcrate(filterable)]  // <-- Enable filtering
    pub completed: bool,

    #[crudcrate(exclude(create, update), on_create = chrono::Utc::now())]
    pub created_at: DateTime<Utc>,

    #[crudcrate(exclude(create, update), on_create = chrono::Utc::now(), on_update = chrono::Utc::now())]
    pub updated_at: DateTime<Utc>,
}

Update your table:

CREATE TABLE tasks (
    id TEXT PRIMARY KEY,
    title TEXT NOT NULL,
    completed BOOLEAN NOT NULL DEFAULT 0,
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL
)

Filter by Status

The filterable attribute enables filtering on that field:

# Get incomplete tasks
curl 'http://localhost:3000/tasks?filter={"completed":false}'

# Get completed tasks
curl 'http://localhost:3000/tasks?filter={"completed":true}'

Add More Filterable Fields

Let’s add priority:

pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    #[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
    pub id: Uuid,

    #[crudcrate(filterable)]  // Can filter by title
    pub title: String,

    #[crudcrate(filterable)]  // Can filter by completed
    pub completed: bool,

    #[crudcrate(filterable)]  // Can filter by priority
    pub priority: i32,

    #[crudcrate(exclude(create, update), on_create = chrono::Utc::now())]
    pub created_at: DateTime<Utc>,

    #[crudcrate(exclude(create, update), on_create = chrono::Utc::now(), on_update = chrono::Utc::now())]
    pub updated_at: DateTime<Utc>,
}

Filter Examples

# Exact match
curl 'http://localhost:3000/tasks?filter={"priority":5}'

# Multiple conditions (AND)
curl 'http://localhost:3000/tasks?filter={"completed":false,"priority":5}'

# Greater than
curl 'http://localhost:3000/tasks?filter={"priority_gt":3}'

# Less than or equal
curl 'http://localhost:3000/tasks?filter={"priority_lte":5}'

# Range
curl 'http://localhost:3000/tasks?filter={"priority_gte":3,"priority_lte":7}'

Available Operators

📋 See test

SuffixMeaningExample
(none)equals{"priority":5}
_gtgreater than{"priority_gt":5}
_gtegreater than or equal{"priority_gte":5}
_ltless than{"priority_lt":5}
_lteless than or equal{"priority_lte":5}
_neqnot equal{"priority_neq":5}

Security Note

Only fields marked filterable can be filtered. Trying to filter on other fields is silently ignored:

# This filter is ignored - created_at is not filterable
curl 'http://localhost:3000/tasks?filter={"created_at":"2024-01-01"}'

Our Task Model Now

pub struct Model {
    #[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
    pub id: Uuid,

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

    #[crudcrate(filterable)]
    pub completed: bool,

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

    #[crudcrate(exclude(create, update), on_create = chrono::Utc::now())]
    pub created_at: DateTime<Utc>,

    #[crudcrate(exclude(create, update), on_create = chrono::Utc::now(), on_update = chrono::Utc::now())]
    pub updated_at: DateTime<Utc>,
}

We can find tasks, but they come back in random order. Let’s fix that.

Next: Sorting results - order tasks by any field.

Sorting Results

📋 See test

Tasks come back in database order. Let’s control the order.

Enable Sorting

Add sortable to fields you want to sort by:

pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    #[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
    pub id: Uuid,

    #[crudcrate(filterable, sortable)]  // Can filter AND sort
    pub title: String,

    #[crudcrate(filterable)]
    pub completed: bool,

    #[crudcrate(filterable, sortable)]  // Can filter AND sort
    pub priority: i32,

    #[crudcrate(sortable, exclude(create, update), on_create = chrono::Utc::now())]  // Sortable!
    pub created_at: DateTime<Utc>,

    #[crudcrate(exclude(create, update), on_create = chrono::Utc::now(), on_update = chrono::Utc::now())]
    pub updated_at: DateTime<Utc>,
}

Sort Syntax

# Sort by priority, highest first
curl 'http://localhost:3000/tasks?sort=["priority","DESC"]'

# Sort by creation date, newest first
curl 'http://localhost:3000/tasks?sort=["created_at","DESC"]'

# Sort by title alphabetically
curl 'http://localhost:3000/tasks?sort=["title","ASC"]'

Combine with Filtering

# Incomplete tasks, highest priority first
curl 'http://localhost:3000/tasks?filter={"completed":false}&sort=["priority","DESC"]'

# High priority tasks, newest first
curl 'http://localhost:3000/tasks?filter={"priority_gte":8}&sort=["created_at","DESC"]'

Pagination

📋 See test

What if you have 10,000 tasks? You don’t want to load them all at once.

The Range Parameter

📋 See test

# First 10 tasks (items 0-9)
curl 'http://localhost:3000/tasks?range=[0,9]'

# Next 10 tasks (items 10-19)
curl 'http://localhost:3000/tasks?range=[10,19]'

# Tasks 50-74 (25 tasks)
curl 'http://localhost:3000/tasks?range=[50,74]'

Response Headers

CRUDCrate tells you about pagination in the response headers:

Content-Range: tasks 0-9/150

This means: “Returning tasks 0-9 out of 150 total.”

Combine Everything

# Incomplete tasks, highest priority first, first page
curl 'http://localhost:3000/tasks?filter={"completed":false}&sort=["priority","DESC"]&range=[0,19]'

Safety Limits

CRUDCrate caps results at 1000 items per request. This prevents accidentally loading your entire database.


Our Task Model Now

pub struct Model {
    #[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
    pub id: Uuid,

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

    #[crudcrate(filterable)]
    pub completed: bool,

    #[crudcrate(filterable, sortable)]
    pub priority: i32,

    #[crudcrate(sortable, exclude(create, update), on_create = chrono::Utc::now())]
    pub created_at: DateTime<Utc>,

    #[crudcrate(exclude(create, update), on_create = chrono::Utc::now(), on_update = chrono::Utc::now())]
    pub updated_at: DateTime<Utc>,
}

Filtering is precise - you need to know the exact value. But what if you want to find tasks containing “meeting” somewhere in the title?

Next: Searching text - find tasks by keywords.

Searching Text

📋 See test

Filtering requires exact values. But what if you want to find tasks containing “meeting” anywhere in the title?

Add fulltext to searchable fields:

pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    #[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
    pub id: Uuid,

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

    #[crudcrate(fulltext)]  // Also searchable
    pub description: Option<String>,

    #[crudcrate(filterable)]
    pub completed: bool,

    #[crudcrate(filterable, sortable)]
    pub priority: i32,

    #[crudcrate(sortable, exclude(create, update), on_create = chrono::Utc::now())]
    pub created_at: DateTime<Utc>,

    #[crudcrate(exclude(create, update), on_create = chrono::Utc::now(), on_update = chrono::Utc::now())]
    pub updated_at: DateTime<Utc>,
}

Update your table:

CREATE TABLE tasks (
    id TEXT PRIMARY KEY,
    title TEXT NOT NULL,
    description TEXT,
    completed BOOLEAN NOT NULL DEFAULT 0,
    priority INTEGER NOT NULL DEFAULT 0,
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL
)

Search with q

# Find tasks with "meeting" in title or description
curl 'http://localhost:3000/tasks?q=meeting'

# Multiple words
curl 'http://localhost:3000/tasks?q=team+meeting'

Combine with Other Features

# Search incomplete tasks, sorted by priority
curl 'http://localhost:3000/tasks?q=meeting&filter={"completed":false}&sort=["priority","DESC"]'

# Search with pagination
curl 'http://localhost:3000/tasks?q=urgent&range=[0,9]'

How It Works

CRUDCrate searches all fulltext fields together. A match in any field returns the result.

DatabaseSearch Method
PostgreSQLNative fulltext with to_tsvector
MySQLFULLTEXT index
SQLiteLIKE pattern matching

Our Task Model Now

pub struct Model {
    #[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
    pub id: Uuid,

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

    #[crudcrate(fulltext)]
    pub description: Option<String>,

    #[crudcrate(filterable)]
    pub completed: bool,

    #[crudcrate(filterable, sortable)]
    pub priority: i32,

    #[crudcrate(sortable, exclude(create, update), on_create = chrono::Utc::now())]
    pub created_at: DateTime<Utc>,

    #[crudcrate(exclude(create, update), on_create = chrono::Utc::now(), on_update = chrono::Utc::now())]
    pub updated_at: DateTime<Utc>,
}

Now let’s say you add user accounts. Users have passwords, but you definitely don’t want to return password hashes in API responses.

Next: Hiding data - keep sensitive fields out of responses.

Hiding Sensitive Data

📋 See test

Let’s add users to our task manager. Users have passwords, but we never want to expose password hashes.

A User Entity

#[derive(Clone, Debug, DeriveEntityModel, EntityToModels)]
#[crudcrate(generate_router)]
#[sea_orm(table_name = "users")]
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    #[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
    pub id: Uuid,

    #[crudcrate(filterable)]
    pub email: String,

    pub name: String,

    #[crudcrate(exclude(one, list))]  // Never return this
    pub password_hash: String,

    #[crudcrate(exclude(create, update), on_create = chrono::Utc::now())]
    pub created_at: DateTime<Utc>,
}

What exclude(one, list) Does

Exclude TargetEffect
oneHidden from GET /users/:id responses
listHidden from GET /users responses
one, listHidden from all GET responses

Test It

# Create a user (password_hash is accepted in POST)
curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -d '{"email": "[email protected]", "name": "Alice", "password_hash": "hashed123"}'

Response - no password_hash:

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "email": "[email protected]",
  "name": "Alice",
  "created_at": "2024-01-15T10:30:00Z"
}
# List users - no password_hash
curl http://localhost:3000/users

# Get one user - no password_hash
curl http://localhost:3000/users/550e8400-e29b-41d4-a716-446655440000

All Exclude Options

AttributeEffectTest
exclude(create)Not in POST body📋 See test
exclude(update)Not in PUT body📋 See test
exclude(one)Not in GET /:id response📋 See test
exclude(list)Not in GET / response📋 See test

You can combine them:

// Auto-generated, never returned
#[crudcrate(exclude(create, update, one, list), on_create = Uuid::new_v4())]
pub internal_id: Uuid,

// Can create, can't update, hidden from lists
#[crudcrate(exclude(update, list))]
pub secret_code: String,

Excluding from Lists Only

Sometimes you want full data in detail views but not in lists:

#[crudcrate(exclude(list))]  // Show in GET /:id, hide in GET /
pub full_description: String,

This is useful for large fields that you don’t need in list views.


Summary

Our entities now have proper data protection:

Task - filtering, sorting, search, timestamps User - hidden password_hash

But tasks should belong to users. How do we connect them?

Next: Relationships - connect tasks to users.

Public & Private Endpoints

Our task manager has users, tasks, and relationships. Now let’s add a public API that anyone can access without authentication, while keeping sensitive records hidden.

The Problem

You want one set of routes that serves both:

  • Admins (authenticated): see everything, full CRUD
  • Public (unauthenticated): read-only, private records hidden

crudcrate’s scoping system handles this with two features:

  1. ScopeCondition: a middleware-injected filter that restricts which rows are returned
  2. exclude(scoped): hides fields from the response when a scope is active

Adding a Privacy Field

Add an is_private field to your entity:

#[derive(Clone, Debug, DeriveEntityModel, EntityToModels)]
#[crudcrate(generate_router)]
#[sea_orm(table_name = "tasks")]
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    #[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
    pub id: Uuid,

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

    pub description: Option<String>,

    #[crudcrate(filterable, exclude(scoped))]
    pub is_private: bool,
}

exclude(scoped) does two things:

  1. Hides the field from API responses when a scope is active: public users never see is_private in the JSON
  2. Strips the field from filters/sorting: public users can’t probe it via ?filter={"is_private":true}

Writing Scope Middleware

A scope is just Axum middleware that injects a ScopeCondition into the request. You decide when to inject it, typically when the user isn’t authenticated:

use axum::{extract::Request, middleware::Next, response::Response};
use crudcrate::ScopeCondition;
use sea_orm::{ColumnTrait, Condition};

async fn scope_tasks(mut req: Request, next: Next) -> Response {
    if !is_admin(&req) {
        // Only show public tasks
        req.extensions_mut().insert(ScopeCondition::new(
            Condition::all().add(task::Column::IsPrivate.eq(false)),
        ));
    }
    next.run(req).await
}

When ScopeCondition is present, crudcrate automatically:

  • Filters list queries (GET /) to only return matching rows
  • Returns 404 for GET /:id if the record doesn’t pass the condition
  • Confines writes to matching rows inside a transaction
  • Uses the scoped response model (without exclude(scoped) fields)
  • Returns correct pagination counts reflecting the filtered total

When ScopeCondition is not present (admin requests), everything works normally: full CRUD, all fields visible.

A scope does not grant or refuse write permission. Use read_only_router() or method authorization for a public tier that must not accept writes.

Mounting the Routes

Apply the scope middleware to your router. Layer it after your auth middleware so the auth status is available:

use axum::{middleware::from_fn, Router};

let app = Router::new()
    .nest(
        "/api/tasks",
        Task::router(&db)
            .layer(from_fn(scope_tasks))      // Check scope based on auth
            .layer(keycloak_pass_layer)        // Auth (passthrough mode)
            .into(),
    );

What Happens

Public user (no auth token):

# List: only public tasks, no is_private in response
curl http://localhost:3000/api/tasks
# [{"id": "...", "title": "Public task", "description": "..."}]

# Private task: 404
curl http://localhost:3000/api/tasks/private-uuid
# {"error": "task not found"}

# Write: allowed when the resulting row passes the scope
curl -X POST http://localhost:3000/api/tasks -d '{"title": "hack"}'
# 201 Created

# Filter on is_private: silently ignored
curl 'http://localhost:3000/api/tasks?filter={"is_private":true}'
# Returns same results as without filter

Admin (valid auth token):

# List: all tasks, is_private visible
curl -H "Authorization: Bearer TOKEN" http://localhost:3000/api/tasks
# [{"id": "...", "title": "Public task", "is_private": false},
#  {"id": "...", "title": "Secret task", "is_private": true}]

# Full CRUD works
curl -X POST -H "Authorization: Bearer TOKEN" \
  http://localhost:3000/api/tasks \
  -d '{"title": "New task", "is_private": true}'
# 201 Created

Scoping with Relationships

If your entities have parent-child relationships, you probably want privacy to cascade. For example, if an area is private, all sites in that area should be hidden too.

Add Expr::cust() subqueries to your scope condition:

async fn scope_sites(mut req: Request, next: Next) -> Response {
    if !is_admin(&req) {
        req.extensions_mut().insert(ScopeCondition::new(
            Condition::all()
                .add(site::Column::IsPrivate.eq(false))
                .add(Expr::cust(
                    "(area_id IS NULL OR area_id NOT IN \
                     (SELECT id FROM areas WHERE is_private = true))"
                )),
        ));
    }
    next.run(req).await
}

Warning: Expr::cust() passes raw SQL directly to the database. Never interpolate user input into the string; this creates SQL injection vulnerabilities. Use only static strings or Sea-ORM’s typed column API for dynamic conditions.

Scoping with Joins

If your entity has join() fields (nested children in the response), exclude(scoped) propagates automatically through joins.

// Parent: Customer
#[crudcrate(filterable, exclude(scoped))]
pub is_private: bool,

#[crudcrate(non_db_attr, join(one, all))]
pub vehicles: Vec<Vehicle>,

// Child: Vehicle
#[crudcrate(filterable, exclude(scoped))]
pub is_private: bool,

When a scoped request fetches a customer, the response looks like:

{
  "id": "...",
  "name": "Alice",
  "vehicles": [
    {"id": "...", "make": "Toyota", "model": "Corolla", "year": 2020}
  ]
}

No is_private on the customer or on any nested vehicle. crudcrate generates CustomerScopedList with vehicles: Vec<VehicleScopedList>; the scoped types cascade through every join level.

How join scoping works

Two layers protect joined children on scoped requests:

  1. Field stripping: The scoped types (VehicleScopedList) omit exclude(scoped) fields from the JSON.
  2. SQL-level row filtering: Every child batch query includes the child’s ScopeFilterable::scope_condition() as an additional WHERE clause. This applies on both get_one_scoped and get_all_scoped handlers, at every depth when depth > 1: the scoped batch loader recurses via get_one_scoped, propagating the scope through grandchildren and beyond. Private rows never leave Postgres.
  3. In-memory defense in depth: From<ListModel> for ScopedList still runs ScopeFilterable::is_scope_visible() over each child as a belt-and-suspenders guard, so a custom read::many::body hook that bypasses the SQL filter still strips private rows before serialisation.

For automatic filtering to work, the child entity must have at least one exclude(scoped) boolean field (the derive macro generates the required ScopeFilterable::scope_condition() from those fields). If it doesn’t, the child’s scope_condition() returns None and every child row is returned, identical to unscoped behaviour.

Quick Reference

AttributeEffect
exclude(scoped)Field hidden from response when scoped
ScopeCondition::new(condition)Filter rows in list/get_one
Scope + write requestConfines existing and resulting rows inside a transaction
Scope + filter on excluded columnFilter silently ignored
Scope + join fieldsChild entities use scoped types too

Entities Without exclude(scoped)

If a child entity doesn’t use exclude(scoped), crudcrate generates a type alias (type ChildScopedList = ChildList) so parent joins still compile. No action needed; it just works.


Next: Custom Logic - Hooks - add validation and side effects to your endpoints.

Relationships

📋 See test

Tasks should belong to users. Let’s connect them.

Add User ID to Tasks

// task.rs
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    #[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
    pub id: Uuid,

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

    #[crudcrate(fulltext)]
    pub description: Option<String>,

    #[crudcrate(filterable)]
    pub completed: bool,

    #[crudcrate(filterable, sortable)]
    pub priority: i32,

    #[crudcrate(filterable)]  // Filter tasks by user
    pub user_id: Uuid,

    #[crudcrate(sortable, exclude(create, update), on_create = chrono::Utc::now())]
    pub created_at: DateTime<Utc>,

    #[crudcrate(exclude(create, update), on_create = chrono::Utc::now(), on_update = chrono::Utc::now())]
    pub updated_at: DateTime<Utc>,
}

Now you can filter tasks by user:

curl 'http://localhost:3000/tasks?filter={"user_id":"550e8400-e29b-41d4-a716-446655440000"}'

But what if you want to see the user details along with the task?

Add a join field to include the user in task responses:

// task.rs
pub struct Model {
    // ... existing fields ...

    #[crudcrate(filterable)]
    pub user_id: Uuid,

    // Load the user with the task
    #[sea_orm(ignore)]
    #[crudcrate(non_db_attr, join(one, all))]
    pub user: Option<super::user::User>,

    // ... timestamps ...
}

Define the Relation

Sea-ORM needs to know how entities relate:

// task.rs
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
    #[sea_orm(
        belongs_to = "super::user::Entity",
        from = "Column::UserId",
        to = "super::user::Column::Id"
    )]
    User,
}

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

Response Now Includes User

curl http://localhost:3000/tasks/550e8400-e29b-41d4-a716-446655440000
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "title": "Learn relationships",
  "completed": false,
  "priority": 5,
  "user_id": "660f9511-f30c-42e5-b827-557766551111",
  "user": {
    "id": "660f9511-f30c-42e5-b827-557766551111",
    "email": "[email protected]",
    "name": "Alice",
    "created_at": "2024-01-15T10:00:00Z"
  },
  "created_at": "2024-01-15T10:30:00Z",
  "updated_at": "2024-01-15T10:30:00Z"
}

Join Options

AttributeWhen Data is Loaded
join(one)Only in GET /:id (detail view)
join(all)Only in GET / (list view)
join(one, all)Both endpoints

For performance, use join(one) when the related data is only needed in detail views:

// Only load user in detail view, not in lists
#[crudcrate(non_db_attr, join(one))]
pub user: Option<User>,

The Other Direction

Users can have tasks too. Add to user.rs:

// user.rs
pub struct Model {
    // ... existing fields ...

    // User's tasks (only in detail view)
    #[sea_orm(ignore)]
    #[crudcrate(non_db_attr, join(one))]
    pub tasks: Vec<super::task::Task>,
}

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

impl Related<super::task::Entity> for Entity {
    fn to() -> RelationDef {
        Relation::Tasks.def()
    }
}
curl http://localhost:3000/users/660f9511-f30c-42e5-b827-557766551111
{
  "id": "660f9511-f30c-42e5-b827-557766551111",
  "email": "[email protected]",
  "name": "Alice",
  "tasks": [
    {"id": "...", "title": "Learn relationships", "completed": false},
    {"id": "...", "title": "Build something cool", "completed": false}
  ],
  "created_at": "2024-01-15T10:00:00Z"
}

Depth Limit

📋 See test

To prevent infinite recursion (user → tasks → user → tasks → …), use depth:

#[crudcrate(non_db_attr, join(one, depth = 1))]
pub tasks: Vec<Task>,

Default max depth is 5. Self-referencing relations (like categories with subcategories) are automatically limited to depth 1.

📋 See test


Try the Recursive Join Example

See relationships in action with a complete example:

cd crudcrate/crudcrate
cargo run --example recursive_join

This demonstrates Customer → Vehicle → Parts relationships with automatic loading.

  • API: http://localhost:3000/customers
  • Docs: http://localhost:3000/docs

Summary

You now have a complete task manager with:

  • Auto-generated UUIDs
  • Timestamps
  • Filtering, sorting, pagination
  • Full-text search
  • Hidden sensitive fields
  • Related data loading

But what if you need custom logic? Like validating input or sending notifications?

Next: Custom logic - add validation and side effects.

Custom Logic with Hooks

📋 See test

Sometimes you need custom logic: validate input, send notifications, or log events. Hooks let you run code before or after CRUD operations.

The Hook System

Hooks use this syntax: {operation}::{cardinality}::{phase}

PartOptionsMeaning
operationcreate, update, delete, readWhich action
cardinalityone, manySingle item or batch
phasepre, body, transform, postWhen to run

Example: Validate Task Title

Let’s require task titles to be at least 3 characters:

use crudcrate::errors::ApiError;

#[derive(Clone, Debug, DeriveEntityModel, EntityToModels)]
#[crudcrate(
    generate_router,
    create::one::pre = validate_task  // Run before create
)]
#[sea_orm(table_name = "tasks")]
pub struct Model {
    // ... fields ...
}

async fn validate_task<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    _db: &C,
    data: &mut TaskCreate,
) -> Result<(), ApiError> {
    if data.title.len() < 3 {
        return Err(ApiError::BadRequest("Title must be at least 3 characters".into()));
    }
    Ok(())
}

Now:

# This fails
curl -X POST http://localhost:3000/tasks \
  -d '{"title": "Hi"}'
# Error: "Title must be at least 3 characters"

# This works
curl -X POST http://localhost:3000/tasks \
  -d '{"title": "Hello"}'

Hook Phases

pre - Before the Operation

Validate or modify input. Return Err to cancel the operation.

#[crudcrate(create::one::pre = validate_task)]

async fn validate_task<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    data: &mut TaskCreate,  // Can modify!
) -> Result<(), ApiError> {
    // Validate
    if data.title.is_empty() {
        return Err(ApiError::BadRequest("Title required".into()));
    }

    // Or modify
    data.title = data.title.trim().to_string();

    Ok(())
}

post - After the Operation

Run side effects like notifications or logging. The operation already succeeded.

#[crudcrate(create::one::post = notify_created)]

async fn notify_created<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    _db: &C,
    task: &Task,  // The created task
) -> Result<(), ApiError> {
    println!("New task created: {}", task.title);
    // Send email, update analytics, etc.
    Ok(())
}

body - Replace the Operation

Completely replace the default behavior. Use for soft deletes or custom logic.

#[crudcrate(delete::one::body = soft_delete)]

async fn soft_delete<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    id: Uuid,
) -> Result<(), ApiError> {
    // Instead of deleting, set deleted_at
    let mut task: ActiveModel = Entity::find_by_id(id)
        .one(db)
        .await?
        .ok_or(ApiError::NotFound)?
        .into();

    task.deleted_at = Set(Some(chrono::Utc::now()));
    task.update(db).await?;

    Ok(())
}

transform: Modify Results

Transform hooks receive the operation result and return a modified version. Use for enrichment, decoration, or data transformation.

#[crudcrate(read::one::transform = enrich_with_metadata)]

async fn enrich_with_metadata<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    mut task: Task,  // Takes ownership, returns modified
) -> Result<Task, ApiError> {
    // Add computed fields, enrich from other sources, etc.
    task.comment_count = count_comments(db, task.id).await?;
    Ok(task)
}

Transform runs after the operation (or body replacement) but before post hooks.

Multiple Hooks

Combine hooks for complete workflows:

#[derive(Clone, Debug, DeriveEntityModel, EntityToModels)]
#[crudcrate(
    generate_router,
    create::one::pre = validate_task,
    create::one::post = log_created,
    update::one::pre = validate_task,
    update::one::post = log_updated,
    delete::one::pre = check_can_delete,
)]
#[sea_orm(table_name = "tasks")]
pub struct Model {
    // ...
}

Hook Function Signatures

Create

📋 See test

// Pre: can modify input
async fn create_pre<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(db: &C, data: &mut TaskCreate) -> Result<(), ApiError>;

// Post: receives created item
async fn create_post<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(db: &C, task: &Task) -> Result<(), ApiError>;

// Body: replace create logic
async fn create_body<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(db: &C, data: TaskCreate) -> Result<Task, ApiError>;

// Transform: modify result before returning
async fn create_transform<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(db: &C, task: Task) -> Result<Task, ApiError>;

Update

📋 See test

// Pre: receives id and can modify input
async fn update_pre<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(db: &C, id: Uuid, data: &mut TaskUpdate) -> Result<(), ApiError>;

// Post: receives updated item
async fn update_post<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(db: &C, task: &Task) -> Result<(), ApiError>;

Delete

📋 See test

// Pre: can prevent deletion
async fn delete_pre<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(db: &C, id: Uuid) -> Result<(), ApiError>;

// Post: runs after deletion
async fn delete_post<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(db: &C, id: Uuid) -> Result<(), ApiError>;

// Body: replace delete logic
async fn delete_body<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(db: &C, id: Uuid) -> Result<(), ApiError>;

Execution Order

  1. pre hook runs
  2. Default operation (or body if specified)
  3. transform hook runs (modifies the result)
  4. post hook runs

If pre returns an error, nothing else runs.

Note: When using ?partial=true for batch operations, items are processed individually using single-item hooks (create::one::*, etc.), not batch hooks (create::many::*).


Complete Example

use chrono::{DateTime, Utc};
use crudcrate::{EntityToModels, errors::ApiError};
use sea_orm::entity::prelude::*;
use uuid::Uuid;

#[derive(Clone, Debug, DeriveEntityModel, EntityToModels)]
#[crudcrate(
    generate_router,
    create::one::pre = validate_task,
    create::one::post = log_create,
    update::one::pre = validate_task,
    delete::one::pre = check_delete_permission,
)]
#[sea_orm(table_name = "tasks")]
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    #[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
    pub id: Uuid,

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

    #[crudcrate(filterable)]
    pub completed: bool,

    #[crudcrate(filterable, sortable)]
    pub priority: i32,

    #[crudcrate(sortable, exclude(create, update), on_create = chrono::Utc::now())]
    pub created_at: DateTime<Utc>,

    #[crudcrate(exclude(create, update), on_create = chrono::Utc::now(), on_update = chrono::Utc::now())]
    pub updated_at: DateTime<Utc>,
}

async fn validate_task<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    _db: &C,
    data: &mut TaskCreate,
) -> Result<(), ApiError> {
    if data.title.trim().is_empty() {
        return Err(ApiError::BadRequest("Title cannot be empty".into()));
    }
    if data.title.len() > 200 {
        return Err(ApiError::BadRequest("Title too long (max 200)".into()));
    }
    // Normalize the title
    data.title = data.title.trim().to_string();
    Ok(())
}

async fn log_create<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    _db: &C,
    task: &Task,
) -> Result<(), ApiError> {
    tracing::info!("Task created: {} ({})", task.title, task.id);
    Ok(())
}

async fn check_delete_permission<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    id: Uuid,
) -> Result<(), ApiError> {
    let task = Entity::find_by_id(id)
        .one(db)
        .await?
        .ok_or(ApiError::NotFound)?;

    if task.completed {
        return Err(ApiError::BadRequest("Cannot delete completed tasks".into()));
    }
    Ok(())
}

You Did It!

You’ve built a complete task manager with:

  • Auto-generated UUIDs
  • Auto-managed timestamps
  • Filtering, sorting, pagination
  • Full-text search
  • Hidden sensitive fields
  • Related data loading
  • Custom validation and logic

What’s next?

Filtering

CRUDCrate filters list endpoints via JSON in the ?filter= query parameter.

Enabling Filtering

Mark fields as filterable:

#[derive(EntityToModels)]
pub struct Model {
    #[crudcrate(filterable)]
    pub status: String,

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

    #[crudcrate(filterable)]
    pub created_at: DateTimeUtc,

    // Not filterable
    pub description: String,
}

Filter Syntax

JSON Filter Format (React Admin Compatible)

All filtering uses the JSON filter query parameter:

# Exact match
GET /items?filter={"status":"active"}

# Multiple conditions (AND)
GET /items?filter={"status":"active","priority":5}

# Null check
GET /items?filter={"deleted_at":null}

# Array for IN queries
GET /items?filter={"status":["active","pending","review"]}

Comparison Operators

Use field name suffixes within the JSON filter for comparisons:

# Not equals
GET /items?filter={"status_neq":"inactive"}

# Greater than
GET /items?filter={"priority_gt":3}

# Greater than or equal
GET /items?filter={"priority_gte":3}

# Less than
GET /items?filter={"priority_lt":10}

# Less than or equal
GET /items?filter={"priority_lte":10}

Supported Operators

OperatorSQLExample
(none)={"status":"active"}
_neq!={"status_neq":"deleted"}
_gt>{"priority_gt":5}
_gte>={"priority_gte":5}
_lt<{"priority_lt":10}
_lte<={"priority_lte":10}
(array)IN{"status":["a","b","c"]}

Arrays

Every element of an array filter is parsed against the column’s type, so an IN list binds native values rather than text:

GET /items?filter={"priority":[1,3,5]}
GET /items?filter={"created_at":["2024-01-15T10:30:00Z","2024-02-01T00:00:00Z"]}

All elements must parse or the request is rejected with 400 Bad Request (a dropped clause would silently return unfiltered rows). Send decimals as strings (["10.50"]) to avoid going through a float.

An empty list matches nothing:

GET /items?filter={"priority":[]}   # returns []

Type-Specific Filtering

Strings

# Exact match (case-insensitive)
GET /items?filter={"name":"John"}

# Multiple values (IN)
GET /items?filter={"status":["active","pending"]}

Numbers

# Exact
GET /items?filter={"quantity":10}

# Range (combine multiple operators)
GET /items?filter={"quantity_gte":5,"quantity_lte":20}

# Comparison
GET /items?filter={"price_gt":100}

Booleans

# Exact match
GET /items?filter={"active":true}
GET /items?filter={"active":false}

Dates

The accepted format depends on the column’s type:

Rust typeSQL typeAccepted value
NaiveDateDATE2024-01-15
NaiveDateTimeTIMESTAMP2024-01-15T10:30:00, 2024-01-15 10:30:00, or RFC 3339 (normalised to UTC)
DateTime<Utc>, DateTime<FixedOffset>TIMESTAMPTZRFC 3339 only: 2024-01-15T10:30:00Z
# Date column
GET /items?filter={"published_on":"2024-01-15"}

# Timestamp column, RFC 3339 required
GET /items?filter={"created_at":"2024-01-15T10:30:00Z"}

# One calendar year, as a half-open range
GET /items?filter={"created_at_gte":"2024-01-01T00:00:00Z","created_at_lt":"2025-01-01T00:00:00Z"}

Use _lt against the start of the next period rather than _lte against a bare date: created_at_lte=2024-12-31 would mean <= 2024-12-31T00:00:00Z and exclude the rest of 31 December.

A scalar value that does not parse for the column’s type causes that clause to be ignored, so the response is unfiltered on that field.

Enums

#[derive(EnumIter, DeriveActiveEnum)]
pub enum Status {
    #[sea_orm(string_value = "pending")]
    Pending,
    #[sea_orm(string_value = "active")]
    Active,
}

// In entity
#[crudcrate(filterable)]
pub status: Status,
# Filter by enum value (use the string_value)
GET /items?filter={"status":"active"}
GET /items?filter={"status":["pending","active"]}

UUIDs

# Exact match
GET /items?filter={"user_id":"550e8400-e29b-41d4-a716-446655440000"}

# Multiple UUIDs
GET /items?filter={"user_id":["uuid1","uuid2","uuid3"]}

Optional Fields (Null Checks)

# Field is null
GET /items?filter={"deleted_at":null}

Note: Checking for “not null” requires custom filtering logic via lifecycle hooks.

Complex Filters

Combining Conditions

All conditions in the JSON filter are combined with AND:

# status = "active" AND priority >= 5
GET /items?filter={"status":"active","priority_gte":5}

Security

SQL Injection Prevention

All filters are parameterized. User input is never interpolated into SQL:

// User provides: {"name": "'; DROP TABLE users; --"}

// CRUDCrate generates parameterized query:
// SELECT * FROM items WHERE name = $1
// With parameter: "'; DROP TABLE users; --"

// Safe! The value is treated as data, not SQL

Field Validation

Only fields marked filterable can be filtered:

#[crudcrate(filterable)]
pub status: String,  // Allowed

pub secret: String,  // Not filterable - filter will be ignored

For security, unknown or non-filterable fields are silently ignored rather than causing errors. This prevents information disclosure about your schema.

Programmatic Filtering

Use filters directly in code:

use crudcrate::filtering::{apply_filters, FilterOptions};
use sea_orm::Condition;

async fn custom_handler(
    Query(params): Query<FilterOptions>,
    Extension(db): Extension<DatabaseConnection>,
) -> Result<Json<Vec<Item>>, ApiError> {
    // Build condition from query params
    let condition = apply_filters::<Entity>(&params)?;

    // Add additional conditions
    let condition = condition.add(Column::Deleted.eq(false));

    // Use with Sea-ORM
    let items = Entity::find()
        .filter(condition)
        .all(&db)
        .await?;

    Ok(Json(items.into_iter().map(Into::into).collect()))
}

Building Conditions Manually

use sea_orm::Condition;

let condition = Condition::all()
    .add(Column::Status.eq("active"))
    .add(Column::Priority.gte(5))
    .add(Column::DeletedAt.is_null());

let items = Entity::find()
    .filter(condition)
    .all(&db)
    .await?;

Performance Tips

Index Your Filtered Fields

-- PostgreSQL
CREATE INDEX idx_items_status ON items(status);
CREATE INDEX idx_items_created_at ON items(created_at);

-- Composite index for common filter combinations
CREATE INDEX idx_items_status_priority ON items(status, priority);

Limit Filter Complexity

Complex filters can impact performance. Consider:

  1. Pagination: Always paginate filtered results
  2. Indexes: Index frequently filtered columns
  3. Caching: Cache common filter results
  4. Limits: Set maximum result limits

CRUDCrate supports filtering parents by columns on their related children using dot-notation syntax. The built-in handler resolves each joined filter into a sub-query on the child table and intersects matching parent IDs with the main query; no custom hook required.

Enabling Join Filtering

Declare the whitelisted child columns with filterable(...) inside join(...) on the parent’s relationship field:

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

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

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

Dot-Notation Syntax

Filter with relation.column and the standard operator suffixes:

# Customers whose vehicles include at least one BMW
GET /customers?filter={"vehicles.make":"BMW"}

# Customers with at least one vehicle built in 2020 or later
GET /customers?filter={"vehicles.year_gte":2020}

# Intersection of two joined filters on the same relation
GET /customers?filter={"vehicles.make":"Toyota","vehicles.year_gte":2018}

# Combine with main-entity filters
GET /customers?filter={"name":"Alice","vehicles.make":"BMW"}

Supported Operators

All standard operator suffixes work on joined columns:

OperatorExample
(none){"vehicles.make":"BMW"}
_neq{"vehicles.make_neq":"BMW"}
_gt{"vehicles.year_gt":2019}
_gte{"vehicles.year_gte":2020}
_lt{"vehicles.year_lt":2020}
_lte{"vehicles.year_lte":2020}

How It’s Resolved

For every joined filter in a request the handler runs one sub-query on the child entity and adds its result to the main condition:

SELECT * FROM customers
 WHERE id IN (
     SELECT customer_id
       FROM vehicles
      WHERE make = 'BMW'
        AND <child scope_condition()>  -- e.g. is_private = false
   )
   AND <main-entity filters>
   AND <parent scope_condition if middleware scope is active>

Two to three queries per request: one per joined-filter field plus the main list query plus the count query, both of which reuse the augmented condition. No row multiplication, no DISTINCT, no JOIN in the main query.

Scope Safety

When the child entity declares exclude(scoped) privacy flags (booleans), the generated code applies the child’s ScopeFilterable::scope_condition() to the sub-query. A parent cannot be surfaced through a private child.

When you use middleware-injected scope (dynamic ScopeCondition from request extensions, e.g. tenant_id from a verified JWT claim), the injected condition is applied to the parent query. Transitive safety relies on the standard schema invariant that a child belongs to the parent referenced by its FK. If your schema allows children to reference parents from a different tenant, filter those rows out at the middleware level or add an explicit privacy flag on the child.

Security (Whitelist Validation)

Only columns explicitly listed in filterable(...) can be filtered:

// Only make and year can be filtered
#[crudcrate(join(one, all, filterable("make", "year")))]
pub vehicles: Vec<Vehicle>,
# Allowed: year is in filterable
GET /customers?filter={"vehicles.year":2020}

# Ignored: model is NOT in filterable
GET /customers?filter={"vehicles.model":"Civic"}

# Ignored: unknown join field
GET /customers?filter={"fake.column":"value"}

Silent drop (rather than 400) is deliberate. It prevents:

  • SQL injection via crafted dot-notation
  • Schema discovery through filter probing
  • Accidental exposure of internal columns

Runnable Example

The joined_filter example seeds three customers with distinct vehicle makes and demonstrates each variation end-to-end:

cargo run --example joined_filter

# in another shell:
curl 'http://localhost:3000/customers'
curl 'http://localhost:3000/customers?filter=%7B%22vehicles.make%22%3A%22BMW%22%7D'
curl 'http://localhost:3000/customers?filter=%7B%22vehicles.year_gte%22%3A2020%7D'

The end-to-end behaviour described on this page is backed by test_suite/tests/joined_filter_http_test.rs (run with cargo test --manifest-path test_suite/Cargo.toml --test joined_filter_http_test).

Limitations

Single-level joins: nested paths (vehicles.parts.name) are rejected by the parser.

Vec<Child> only: joined filtering currently applies to has_many relationships (Vec<Child> fields). Option<Child> (belongs_to) fields that declare joined filterable columns are silently ignored, because the FK is on the parent and the sub-query direction is reversed.

FK column naming convention: the child’s FK column must follow the convention {parent_struct_name}_id (snake_case) unless overridden via #[crudcrate(join(..., fk_column = "..."))].

LIKE-Filterable Fields (Partial Matching)

For fields that need partial/substring matching instead of exact equality, implement like_filterable_columns() in your CRUDResource trait:

impl CRUDResource for YourEntity {
    // ... other methods ...

    fn like_filterable_columns() -> Vec<&'static str> {
        vec!["title", "description", "name"]
    }
}

When a field is in this list, filters will use case-insensitive LIKE '%value%' matching:

# With title in like_filterable_columns():
GET /items?filter={"title":"urgent"}
# Matches: "This is urgent", "URGENT: Please review", "Not so urgent task"

This is useful for fields where users expect partial matching behavior.

Error Handling

How CRUDCrate handles bad filter input depends on the active SecurityProfile:

  • Invalid JSON: under secure() (the 0.9.0 default), returns 400 Bad Request. Under legacy() / react_admin(), the filter is silently dropped and the unfiltered list is returned.
  • Unknown fields: silently ignored under every profile, so callers can’t probe the schema by trial.
  • Invalid values: the offending field’s filter is skipped; other fields still apply.
  • Malformed operator suffix: falls back to equality on the base column.

Next Steps

Sorting

CRUDCrate supports flexible sorting with multiple format options.

Enabling Sorting

Mark fields as sortable:

#[derive(EntityToModels)]
pub struct Model {
    #[crudcrate(sortable)]
    pub name: String,

    #[crudcrate(sortable)]
    pub created_at: DateTimeUtc,

    #[crudcrate(sortable)]
    pub priority: i32,

    // Not sortable
    pub description: String,
}

Sort Syntax

JSON Array Format (React Admin)

# Sort by single field with direction
GET /items?sort=["created_at","DESC"]

# Default order is ASC when direction omitted
GET /items?sort=["name"]

REST Query Parameters

Two REST-style formats are supported:

# Using sort_by + order (preferred)
GET /items?sort_by=created_at&order=DESC

# Using sort + order (alternative)
GET /items?sort=created_at&order=DESC

# Default order is ASC
GET /items?sort_by=name

Note: The sort_by parameter takes priority over sort if both are provided.

Sort Directions

DirectionDescription
ASCAscending (A-Z, 0-9, oldest first)
DESCDescending (Z-A, 9-0, newest first)

Case-insensitive: ASC, asc, Asc all work.

Examples by Type

Strings

# A to Z
GET /items?sort=["name","ASC"]

# Z to A
GET /items?sort=["name","DESC"]

Numbers

# Lowest to highest
GET /items?sort=["priority","ASC"]

# Highest to lowest
GET /items?sort=["priority","DESC"]

Dates

# Oldest first
GET /items?sort=["created_at","ASC"]

# Newest first (common for feeds)
GET /items?sort=["created_at","DESC"]

Booleans

# false first (0), then true (1)
GET /items?sort=["is_active","ASC"]

# true first (1), then false (0)
GET /items?sort=["is_active","DESC"]

Default Sort Order

When no sort is specified, CRUDCrate uses the primary key:

// Default: ORDER BY id ASC
GET /items

Configure a different default in your handler or operations.

Multiple Column Sorting

For multi-column sorting, implement a custom handler:

use sea_orm::{Order, QueryOrder};

async fn list_items(
    Query(params): Query<FilterOptions>,
    Extension(db): Extension<DatabaseConnection>,
) -> Result<Json<Vec<ItemList>>, ApiError> {
    let query = Entity::find()
        .order_by(Column::Priority, Order::Desc)  // Primary sort
        .order_by(Column::CreatedAt, Order::Desc); // Secondary sort

    let items = query.all(&db).await?;
    Ok(Json(items.into_iter().map(Into::into).collect()))
}

Null Handling

Null values sort based on database:

DatabaseNULL Position
PostgreSQLNULLS LAST (default)
MySQLNULLS FIRST (in ASC)
SQLiteNULLS FIRST

For consistent behavior, consider using COALESCE in custom queries.

Programmatic Sorting

Use sorting in code:

use crudcrate::filtering::{parse_sorting, FilterOptions};
use sea_orm::{Order, QueryOrder};

async fn custom_list(
    Query(params): Query<FilterOptions>,
    Extension(db): Extension<DatabaseConnection>,
) -> Result<Json<Vec<Item>>, ApiError> {
    let (column, order) = parse_sorting::<Entity>(&params);

    let items = Entity::find()
        .order_by(column, order)
        .all(&db)
        .await?;

    Ok(Json(items.into_iter().map(Into::into).collect()))
}

Manual Sorting

use sea_orm::{Order, QueryOrder};

let items = Entity::find()
    .order_by(Column::Priority, Order::Desc)
    .order_by(Column::Name, Order::Asc)
    .all(&db)
    .await?;

Security

Field Validation

Only sortable fields can be used:

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

pub secret: String,  // Not sortable - ignored

Invalid sort fields are silently ignored (falls back to default).

No SQL Injection

Sort fields are validated against entity columns:

# Attempted injection
GET /items?sort=["name; DROP TABLE items;--","ASC"]

# Result: field not found, uses default sort
# No SQL executed with injected content

Performance Tips

Index Sorted Fields

-- Index for sorted field
CREATE INDEX idx_items_created_at ON items(created_at);

-- Composite index for filter + sort
CREATE INDEX idx_items_status_created
    ON items(status, created_at DESC);

Consider Sort + Filter Combinations

The most efficient queries have indexes covering both filter and sort:

# Common query pattern
GET /items?filter={"status":"active"}&sort=["created_at","DESC"]
-- Optimal index
CREATE INDEX idx_items_status_created
    ON items(status, created_at DESC);

CRUDCrate supports sorting by columns from related entities using dot-notation syntax.

Enabling Join Sorting

Use the sortable(...) parameter inside join(...) to specify which columns from a related entity can be used for sorting:

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

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

    // Vehicles relationship with sortable columns
    #[sea_orm(ignore)]
    #[crudcrate(
        non_db_attr,
        join(one, all, depth = 1, sortable("year", "mileage"))
    )]
    pub vehicles: Vec<Vehicle>,
}

Dot-Notation Syntax

Sort using relation.column format:

# Sort customers by their vehicle's year (newest first)
GET /customers?sort=["vehicles.year","DESC"]

# Sort by vehicle mileage (lowest first)
GET /customers?sort=["vehicles.mileage","ASC"]

# REST format also supported
GET /customers?sort_by=vehicles.year&order=DESC

Combining with Filtering

# Filter by vehicle make, sort by vehicle year
GET /customers?filter={"vehicles.make":"BMW"}&sort=["vehicles.year","DESC"]

# Filter by customer name, sort by vehicle mileage
GET /customers?filter={"name":"John"}&sort=["vehicles.mileage","ASC"]

Security (Whitelist Validation)

Only columns explicitly listed in sortable(...) can be sorted:

// Only year and mileage can be sorted
#[crudcrate(join(one, all, sortable("year", "mileage")))]
pub vehicles: Vec<Vehicle>,
# Allowed - year is in sortable
GET /customers?sort=["vehicles.year","DESC"]

# Falls back to default - make is NOT in sortable
GET /customers?sort=["vehicles.make","ASC"]

Invalid sort fields silently fall back to the default sort column.

Combining Filterable and Sortable

You can use both filterable(...) and sortable(...) together inside join(...):

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

Limitations

Single-level joins only: Join sorting supports direct relationships only. Nested paths like vehicles.parts.price are not supported; only single-level paths like vehicles.year.

# Supported - single level
GET /customers?sort=["vehicles.year","DESC"]

# Not supported - nested path
GET /customers?sort=["vehicles.parts.price","ASC"]

Implementation Notes

Join sorting is validated and parsed automatically. Like join filtering, full automatic query execution for join sorts requires a custom read::many::body hook. The built-in handler validates and parses join sorts but falls back to the default sort column.

Common Patterns

Newest First (Default for Lists)

#[crudcrate(sortable, exclude(create, update))]
pub created_at: DateTimeUtc,
GET /items?sort=["created_at","DESC"]

Priority Queue

#[crudcrate(sortable, filterable)]
pub priority: i32,
# High priority first, then by date
GET /items?sort=["priority","DESC"]

Alphabetical Lists

#[crudcrate(sortable, fulltext)]
pub name: String,
GET /items?sort=["name","ASC"]

Error Handling

Invalid sort parameters don’t cause errors - they fall back to defaults:

# Invalid field - falls back to default
GET /items?sort=["nonexistent","ASC"]

# Invalid format - falls back to default
GET /items?sort=not-an-array

This design prevents client errors from breaking functionality.

Next Steps

Pagination

CRUDCrate provides secure pagination with multiple format support.

Pagination Formats

React Admin Format (Range)

# First 10 items (0-9)
GET /items?range=[0,9]

# Items 10-19
GET /items?range=[10,19]

# Items 50-74 (25 items)
GET /items?range=[50,74]

Standard Page Format

# Page 1 with 20 items per page
GET /items?page=1&per_page=20

# Page 3 with 10 items per page
GET /items?page=3&per_page=10

Response Headers

CRUDCrate adds RFC 7233 compliant headers:

HTTP/1.1 200 OK
Content-Range: items 0-9/42
HeaderFormatDescription
Content-Range{resource} {start}-{end}/{total}Current range and total count

Parsing Content-Range

// JavaScript example
const contentRange = response.headers.get('Content-Range');
const match = contentRange.match(/(\w+) (\d+)-(\d+)\/(\d+)/);
const [_, resource, start, end, total] = match;

console.log(`Showing ${start}-${end} of ${total} ${resource}`);
// "Showing 0-9 of 42 items"

Security Limits

CRUDCrate enforces limits to prevent abuse:

LimitValuePurpose
Min page size1Keep a page from being empty by construction
Max page size1,000Prevent memory exhaustion
Max offset1,000,000Prevent excessive DB load
# Requesting too many items
GET /items?per_page=10000

# Response: limited to 1000 items
Content-Range: items 0-999/5000

per_page=0 is clamped up to 1, so a client loop that pages until it sees fewer rows than it asked for terminates instead of spinning on empty responses.

Stable Ordering

Paging with LIMIT/OFFSET only returns each row once if the sort produces a total order. A sort column with duplicate values (a status, a category, a timestamp shared by several rows) leaves the tied rows in whatever order the engine picks, which can differ between the two queries that serve two pages, so a row can appear twice or not at all.

CRUDCrate appends the primary key as a secondary sort key whenever the requested sort column is not the primary key:

-- GET /items?sort=["status","ASC"]&range=[0,9]
ORDER BY status ASC, id ASC LIMIT 10 OFFSET 0

This applies to plain sorts, joined (dot-notation) sorts, and any resource using the default get_all / fetch_all implementations. A custom get::all::body override is your own query, so it needs its own tiebreaker.

Examples

Basic Pagination

# First page
GET /items?range=[0,19]
# Content-Range: items 0-19/150

# Second page
GET /items?range=[20,39]
# Content-Range: items 20-39/150

# Last page (partial)
GET /items?range=[140,159]
# Content-Range: items 140-149/150

With Filtering

# Filter + paginate
GET /items?filter={"status":"active"}&range=[0,9]
# Content-Range: items 0-9/42  (42 active items total)

With Sorting

# Sort + paginate
GET /items?sort=["created_at","DESC"]&range=[0,9]
# Returns newest 10 items

Combined

# Filter + sort + paginate
GET /items?filter={"status":"active"}&sort=["priority","DESC"]&range=[0,9]
# Returns top 10 highest priority active items

Programmatic Pagination

Using FilterOptions

use crudcrate::filtering::{parse_pagination, FilterOptions};

async fn list_handler(
    Query(params): Query<FilterOptions>,
    Extension(db): Extension<DatabaseConnection>,
) -> Result<(HeaderMap, Json<Vec<ItemList>>), ApiError> {
    // Parse pagination from query params
    let (offset, limit) = parse_pagination(&params);

    let items = Entity::find()
        .offset(offset)
        .limit(limit)
        .all(&db)
        .await?;

    // Calculate Content-Range
    let total = Entity::find().count(&db).await?;
    let end = (offset + items.len() as u64).saturating_sub(1);

    let mut headers = HeaderMap::new();
    headers.insert(
        "Content-Range",
        format!("items {}-{}/{}", offset, end, total).parse().unwrap()
    );

    Ok((headers, Json(items.into_iter().map(Into::into).collect())))
}

Manual Pagination

use sea_orm::QuerySelect;

// Direct offset/limit
let items = Entity::find()
    .offset(20)
    .limit(10)
    .all(&db)
    .await?;

Using Paginator

use sea_orm::PaginatorTrait;

// Sea-ORM's built-in paginator
let paginator = Entity::find()
    .filter(condition)
    .paginate(&db, 20);  // 20 items per page

// Get specific page
let items = paginator.fetch_page(2).await?;  // Page 2 (0-indexed)

// Get total count
let total = paginator.num_items().await?;
let pages = paginator.num_pages().await?;

Offset vs Cursor Pagination

CRUDCrate uses offset pagination by default. For large datasets, consider cursor pagination:

Offset Pagination (Default)

  • Simple to implement
  • Random page access
  • Inconsistent with concurrent writes
  • Slow for large offsets

Cursor Pagination (Custom Implementation)

  • Consistent with concurrent writes
  • Fast for any position
  • No random page access
  • Requires ordered, unique field

Example cursor pagination:

async fn list_with_cursor(
    Query(params): Query<CursorParams>,
    Extension(db): Extension<DatabaseConnection>,
) -> Result<Json<CursorResponse<Item>>, ApiError> {
    let limit = params.limit.unwrap_or(20).min(100);

    let mut query = Entity::find()
        .order_by(Column::CreatedAt, Order::Desc);

    // If cursor provided, filter to items after cursor
    if let Some(cursor) = &params.cursor {
        let cursor_time = parse_cursor(cursor)?;
        query = query.filter(Column::CreatedAt.lt(cursor_time));
    }

    let items: Vec<Model> = query
        .limit(limit + 1)  // Fetch one extra to check for more
        .all(&db)
        .await?;

    let has_more = items.len() > limit as usize;
    let items: Vec<Item> = items.into_iter()
        .take(limit as usize)
        .map(Into::into)
        .collect();

    let next_cursor = if has_more {
        items.last().map(|i| encode_cursor(&i.created_at))
    } else {
        None
    };

    Ok(Json(CursorResponse {
        items,
        next_cursor,
    }))
}

Performance Tips

For Large Tables

-- Ensure indexes on sorted columns
CREATE INDEX idx_items_created_at ON items(created_at DESC);

-- For filtered + sorted pagination
CREATE INDEX idx_items_status_created
    ON items(status, created_at DESC);

Total Count Optimization

Counting can be slow on large tables:

// Option 1: Cached counts
let total = get_cached_count_or_query(&db).await?;

// Option 2: Approximate counts (PostgreSQL)
// SELECT reltuples FROM pg_class WHERE relname = 'items';

// Option 3: Skip count for infinite scroll
let items = Entity::find()
    .limit(limit + 1)  // Fetch extra to detect more
    .all(&db)
    .await?;
let has_more = items.len() > limit;

Empty Results

When no items match:

GET /items?filter={"status":"nonexistent"}&range=[0,9]

# Response
HTTP/1.1 200 OK
Content-Range: items 0-0/0
[]

Next Steps

Fulltext Search

CRUDCrate provides case-insensitive substring search across multiple fields.

Mark fields to include in search:

#[derive(EntityToModels)]
pub struct Model {
    #[crudcrate(fulltext)]
    pub title: String,

    #[crudcrate(fulltext)]
    pub description: String,

    #[crudcrate(fulltext)]
    pub tags: String,

    // Not searchable
    pub internal_code: String,
}

Search Syntax

Use the q parameter:

# Simple search
GET /items?q=rust programming

# Search with other parameters
GET /items?q=async&filter={"status":"published"}&sort=["created_at","DESC"]

How It Works

The search term is matched as a substring of the concatenated searchable fields. There is no fuzzy or similarity matching: a typo (“progamming”) does not match “programming”. The term is bound as a query parameter, and LIKE wildcards in it (%, _) are escaped with ! so they match literally.

PostgreSQL

Uses ILIKE for case-insensitive matching:

-- Generated query (simplified)
SELECT * FROM items
WHERE (COALESCE(title::text, '') || ' ' || COALESCE(description::text, ''))
    ILIKE '%rust programming%' ESCAPE '!'

MySQL & SQLite

Uses UPPER(...) LIKE with the pattern uppercased on the Rust side:

-- Generated query (simplified)
SELECT * FROM items
WHERE UPPER(CAST(title AS TEXT) || ' ' || CAST(description AS TEXT))
    LIKE '%RUST PROGRAMMING%' ESCAPE '!'

The uppercasing of the search pattern is ASCII-reliable; non-ASCII case folding can differ between Rust and the database collation on these backends.

The query is treated as a single phrase, matching records where the concatenated fields contain the search string.

Search Behavior

The entire query is treated as a single search term:

GET /items?q=rust programming

# Matches items containing the phrase "rust programming"
# Does NOT split into separate "rust" AND "programming" terms

Case Insensitivity

All searches are case-insensitive:

GET /items?q=RUST
GET /items?q=rust
GET /items?q=Rust

# All return the same results

Partial Matching (Substring)

All databases support substring matching via LIKE:

GET /items?q=rust

# Matches: "rust", "rusty", "trustworthy", "Rust Programming"

Combining with Filters

Search works with other query parameters:

# Search within active items
GET /items?q=tutorial&filter={"status":"active"}

# Search + sort + paginate
GET /items?q=rust&sort=["created_at","DESC"]&range=[0,9]

Performance Tips

Index Strategy

DatabaseRecommended Index
PostgreSQLOptional: a pg_trgm GIN index (gin_trgm_ops) accelerates ILIKE '%term%' scans
MySQLStandard B-tree on searched columns
SQLiteStandard indexes on searched columns

Query Optimization

  1. Limit results: Always paginate search results
  2. Use filters: Narrow results before fulltext search
  3. Cache common searches: For popular queries
# Slow: fulltext search on all items
GET /items?q=rust

# Fast: filter first, then search
GET /items?q=rust&filter={"category":"programming"}&range=[0,19]

Highlighting Results

For search result highlighting, implement post-processing:

fn highlight_matches(text: &str, query: &str) -> String {
    let terms: Vec<&str> = query.split_whitespace().collect();
    let mut result = text.to_string();

    for term in terms {
        let pattern = regex::Regex::new(&format!("(?i)({})", regex::escape(term))).unwrap();
        result = pattern.replace_all(&result, "<mark>$1</mark>").to_string();
    }

    result
}

Empty or whitespace-only queries return all items:

GET /items?q=
GET /items?q=

# Both return unfiltered results (with pagination)

Special Characters

Search queries are sanitized:

# Special characters are escaped
GET /items?q=c++
GET /items?q=node.js
GET /items?q=user@email

# All work safely

Next Steps

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

Field Exclusion

Control which fields appear in which models using the exclude attribute.

Basic Syntax

#[crudcrate(exclude(target1, target2, ...))]
pub field_name: FieldType,

Exclusion Targets

TargetModelHTTP Method
oneResponseGET /items/:id
createCreatePOST /items
updateUpdatePUT /items/:id
listListGET /items

Common Patterns

Auto-Generated IDs

// Don't let clients set the ID
#[crudcrate(primary_key, exclude(create, update))]
pub id: Uuid,

Result:

  • Appears in responses
  • Not in create request model
  • Not in update request model

Timestamps

// Managed by the system
#[crudcrate(exclude(create, update), on_create = chrono::Utc::now())]
pub created_at: DateTimeUtc,

#[crudcrate(exclude(create, update), on_create = chrono::Utc::now(), on_update = chrono::Utc::now())]
pub updated_at: DateTimeUtc,

Sensitive Data

// Never expose password
#[crudcrate(exclude(one, list))]
pub password_hash: String,

// Never expose in responses
#[crudcrate(exclude(one, list))]
pub api_secret: String,

Result:

  • Not in any response
  • Can be set on create
  • Can be updated

Expensive Fields

// Large content not needed in lists
#[crudcrate(exclude(list))]
pub full_content: String,

// Computed field expensive to load
#[crudcrate(exclude(list))]
pub statistics: Json,

Result:

  • In single item response
  • Not in list response
  • Can be set/updated

Internal Fields

// Internal tracking, never expose
#[crudcrate(exclude(one, create, update, list))]
pub internal_score: f64,

Result:

  • Not in any model
  • Field only used internally in database

Read-Only Computed Fields

// Computed by database trigger
#[crudcrate(exclude(create, update))]
pub view_count: i32,

// Calculated field
#[crudcrate(exclude(create, update))]
pub full_name: String,

Generated Models Comparison

Given this entity:

pub struct Model {
    #[crudcrate(primary_key, exclude(create, update))]
    pub id: i32,

    #[crudcrate(filterable)]
    pub email: String,

    #[crudcrate(exclude(one, list))]
    pub password_hash: String,

    pub display_name: Option<String>,

    #[crudcrate(exclude(list))]
    pub bio: Option<String>,

    #[crudcrate(exclude(create, update))]
    pub created_at: DateTimeUtc,
}

Response Model (one)

pub struct User {
    pub id: i32,
    pub email: String,
    // password_hash: excluded
    pub display_name: Option<String>,
    pub bio: Option<String>,
    pub created_at: DateTimeUtc,
}

Create Model (create)

pub struct UserCreate {
    // id: excluded
    pub email: String,
    pub password_hash: String,
    pub display_name: Option<String>,
    pub bio: Option<String>,
    // created_at: excluded
}

Update Model (update)

pub struct UserUpdate {
    // id: excluded
    pub email: Option<String>,
    pub password_hash: Option<String>,
    pub display_name: Option<Option<String>>,
    pub bio: Option<Option<String>>,
    // created_at: excluded
}

List Model (list)

pub struct UserList {
    pub id: i32,
    pub email: String,
    // password_hash: excluded
    pub display_name: Option<String>,
    // bio: excluded
    pub created_at: DateTimeUtc,
}

Visual Reference

Field            │ Response │ Create │ Update │ List
─────────────────┼──────────┼────────┼────────┼──────
id               │   Yes    │   No   │   No   │ Yes
email            │   Yes    │  Yes   │  Yes   │ Yes
password_hash    │   No     │  Yes   │  Yes   │ No
display_name     │   Yes    │  Yes   │  Yes   │ Yes
bio              │   Yes    │  Yes   │  Yes   │ No
created_at       │   Yes    │   No   │   No   │ Yes

Combining with Other Attributes

// Primary key with auto-generation
#[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
pub id: Uuid,

// Filterable but not in create/update
#[crudcrate(filterable, exclude(create, update), on_create = chrono::Utc::now())]
pub created_at: DateTimeUtc,

// Sortable in lists but not in create
#[crudcrate(sortable, exclude(create))]
pub view_count: i32,

Best Practices

1. Always Exclude Auto-Generated Fields

#[crudcrate(primary_key, exclude(create, update))]
pub id: Uuid,

#[crudcrate(exclude(create, update))]
pub created_at: DateTimeUtc,

2. Never Expose Secrets

#[crudcrate(exclude(one, list))]
pub password_hash: String,

#[crudcrate(exclude(one, list))]
pub api_key: String,

#[crudcrate(exclude(one, list))]
pub refresh_token: Option<String>,

3. Optimize List Responses

// Large text fields
#[crudcrate(exclude(list))]
pub content: String,

// Expensive computed fields
#[crudcrate(exclude(list))]
pub statistics: Json,

// Relationships (use join instead)
#[sea_orm(ignore)]
#[crudcrate(non_db_attr, join(one))]  // Not in list
pub comments: Vec<Comment>,

4. Document Your Exclusions

/// User password hash - never exposed in API responses
#[crudcrate(exclude(one, list))]
pub password_hash: String,

/// Created timestamp - managed by system
#[crudcrate(exclude(create, update), on_create = chrono::Utc::now())]
pub created_at: DateTimeUtc,

Troubleshooting

Field Not Appearing

Check that:

  1. Field is not excluded from that model
  2. Field has correct visibility (pub)
  3. Entity compiles without errors

Client Sending Excluded Fields

Excluded fields in requests are silently ignored:

# Trying to set id on create
POST /users
{"id": 999, "email": "[email protected]"}

# Result: id is ignored, auto-generated
{"id": 1, "email": "[email protected]"}

Partial Updates Not Working

For optional fields, remember the double-Option pattern:

pub struct UserUpdate {
    pub bio: Option<Option<String>>,
    // None = don't change
    // Some(None) = set to null
    // Some(Some(value)) = set to value
}

Next Steps

Default Values

Automatically set field values on create and update operations.

Syntax

// Set on create only
#[crudcrate(on_create = expression)]
pub field: Type,

// Set on update only
#[crudcrate(on_update = expression)]
pub field: Type,

// Set on both
#[crudcrate(on_create = expr1, on_update = expr2)]
pub field: Type,

Common Patterns

UUID Primary Keys

#[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
pub id: Uuid,

Timestamps

// Created timestamp - set once
#[crudcrate(exclude(create, update), on_create = chrono::Utc::now())]
pub created_at: DateTimeUtc,

// Updated timestamp - set on every change
#[crudcrate(exclude(create, update), on_create = chrono::Utc::now(), on_update = chrono::Utc::now())]
pub updated_at: DateTimeUtc,

Default Status

#[crudcrate(on_create = Status::Pending)]
pub status: Status,

#[crudcrate(on_create = "draft".to_string())]
pub status: String,

Counters

#[crudcrate(exclude(create, update), on_create = 0)]
pub view_count: i32,

#[crudcrate(exclude(create, update), on_create = 0.0)]
pub rating: f64,

Boolean Flags

#[crudcrate(on_create = false)]
pub is_verified: bool,

#[crudcrate(on_create = true)]
pub is_active: bool,

Expression Types

Literals

#[crudcrate(on_create = 0)]
pub count: i32,

#[crudcrate(on_create = "default")]
pub category: String,

#[crudcrate(on_create = true)]
pub active: bool,

Function Calls

#[crudcrate(on_create = Uuid::new_v4())]
pub id: Uuid,

#[crudcrate(on_create = chrono::Utc::now())]
pub created_at: DateTimeUtc,

Method Chains

#[crudcrate(on_create = "pending".to_string())]
pub status: String,

#[crudcrate(on_create = Vec::new())]
pub tags: Vec<String>,

Enum Variants

#[crudcrate(on_create = Status::Pending)]
pub status: Status,

#[crudcrate(on_create = Priority::Normal)]
pub priority: Priority,

Constants

const DEFAULT_LIMIT: i32 = 100;

#[crudcrate(on_create = DEFAULT_LIMIT)]
pub rate_limit: i32,

Complete Example

use chrono::{DateTime, Utc};
use crudcrate::EntityToModels;
use sea_orm::entity::prelude::*;
use uuid::Uuid;

#[derive(Clone, Debug, EnumIter, DeriveActiveEnum, Serialize, Deserialize)]
#[sea_orm(rs_type = "String", db_type = "String(StringLen::N(20))")]
pub enum ArticleStatus {
    #[sea_orm(string_value = "draft")]
    Draft,
    #[sea_orm(string_value = "review")]
    Review,
    #[sea_orm(string_value = "published")]
    Published,
    #[sea_orm(string_value = "archived")]
    Archived,
}

#[derive(Clone, Debug, DeriveEntityModel, Serialize, Deserialize, EntityToModels)]
#[crudcrate(generate_router)]
#[sea_orm(table_name = "articles")]
pub struct Model {
    // Auto-generated UUID
    #[sea_orm(primary_key, auto_increment = false)]
    #[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
    pub id: Uuid,

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

    pub content: String,

    // Default to Draft status
    #[crudcrate(filterable, on_create = ArticleStatus::Draft)]
    pub status: ArticleStatus,

    // Start with zero views
    #[crudcrate(sortable, exclude(create, update), on_create = 0)]
    pub view_count: i32,

    // Track creation time
    #[crudcrate(sortable, exclude(create, update), on_create = chrono::Utc::now())]
    pub created_at: DateTime<Utc>,

    // Track all modifications
    #[crudcrate(
        exclude(create, update),
        on_create = chrono::Utc::now(),
        on_update = chrono::Utc::now()
    )]
    pub updated_at: DateTime<Utc>,

    // Publish date (null until published)
    #[crudcrate(sortable)]
    pub published_at: Option<DateTime<Utc>>,
}

How It Works

on_create

When creating a new record:

// User provides
let create_data = ArticleCreate {
    title: "My Article".into(),
    content: "Content here...".into(),
    // status, view_count, created_at, updated_at: not provided
};

// CRUDCrate generates ActiveModel with defaults
let active_model = ActiveModel {
    id: Set(Uuid::new_v4()),           // on_create
    title: Set(create_data.title),
    content: Set(create_data.content),
    status: Set(ArticleStatus::Draft), // on_create
    view_count: Set(0),                // on_create
    created_at: Set(chrono::Utc::now()), // on_create
    updated_at: Set(chrono::Utc::now()), // on_create
    published_at: NotSet,
};

on_update

When updating an existing record:

// User provides partial update
let update_data = ArticleUpdate {
    title: Some("Updated Title".into()),
    content: None,  // Don't change
    // ...
};

// CRUDCrate applies updates + on_update defaults
active_model.title = Set("Updated Title".into());
active_model.updated_at = Set(chrono::Utc::now());  // on_update
// Other fields: unchanged

Combining with exclude

Common pattern: exclude from client input, provide default

// Client can't set, but has default
#[crudcrate(exclude(create, update), on_create = Uuid::new_v4())]
pub id: Uuid,

// Client can't set these timestamps
#[crudcrate(exclude(create, update), on_create = chrono::Utc::now())]
pub created_at: DateTimeUtc,

Client Override

If a field is NOT excluded, client can override the default:

// Default status, but client CAN override
#[crudcrate(on_create = ArticleStatus::Draft)]
pub status: ArticleStatus,
# Uses default (Draft)
POST /articles
{"title": "My Article", "content": "..."}

# Client overrides default
POST /articles
{"title": "My Article", "content": "...", "status": "published"}

To prevent client override, combine with exclude:

// Always Draft on create, client cannot override
#[crudcrate(exclude(create), on_create = ArticleStatus::Draft)]
pub status: ArticleStatus,

Complex Defaults with Operations

For complex default logic, use CRUDOperations:

pub struct ArticleOperations;

impl CRUDOperations for ArticleOperations {
    type Resource = Article;

    async fn before_create<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        &self,
        db: &C,
        data: &mut ArticleCreate,
    ) -> Result<(), ApiError> {
        // Complex default logic
        if data.slug.is_none() {
            data.slug = Some(slugify(&data.title));
        }

        // Validate uniqueness
        if slug_exists(db, &data.slug.as_ref().unwrap()).await? {
            data.slug = Some(format!("{}-{}", data.slug.unwrap(), Uuid::new_v4()));
        }

        Ok(())
    }
}

Database Defaults vs CRUDCrate Defaults

FeatureDatabase DefaultCRUDCrate Default
WhereSQL schemaRust code
WhenDB insert timeBefore insert
VisibilityNot in API modelPart of workflow
ComplexityLimited SQLFull Rust

Use database defaults for:

  • Simple values
  • Database-specific functions
  • Constraints

Use CRUDCrate defaults for:

  • Rust-generated values (UUIDs)
  • Complex logic
  • Values visible in API flow

Next Steps

Registration (Upsert)

Find or insert a row on an alternate unique key, and say what it did.

A source system that re-sends its own content every cycle needs the write to be idempotent, and needs the answer keyed by what it holds rather than by position in the request. crudcrate::upsert does both in one transaction.

Declaring the key

Name the columns the source identifies its rows by:

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, EntityToModels)]
#[sea_orm(table_name = "curves")]
#[crudcrate(api_struct = "Curve", upsert_key(source_system, source_key))]
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    #[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
    pub id: Uuid,

    pub source_system: String,
    pub source_key: String,
    pub slope: f64,
}

The key is an alternate identity, not the primary key. The row keeps its own id, which is what the rest of your API refers to.

A key that is unique only sometimes

Where the unique index backing the key is partial, name its predicate too. The find is narrowed by it, so a row the index does not cover is not the key’s row:

#[crudcrate(
    api_struct = "Episode",
    upsert_key(site_id, parameter_id),
    upsert_where = Column::ResolvedAt.is_null()
)]

against

CREATE UNIQUE INDEX episodes_open_uniq
    ON episodes (site_id, parameter_id) WHERE resolved_at IS NULL;

An open episode registers against itself. Once it is resolved it leaves the index, so the next registration of that slot opens a second episode rather than reopening the closed one, and the history of the first survives.

What you write is a SeaORM expression, the same value OnConflict::target_cond_where takes, so the predicate is the index’s own WHERE clause rather than a syntax of this crate’s. Naming it without an upsert_key is a compile error: there is no find for it to narrow.

Registering a row

use crudcrate::{upsert, UpsertStatus};

let (curve, status) = upsert::<Curve, _>(&db, CurveCreate {
    source_system: "cnet".to_string(),
    source_key: "DOC:plate-7".to_string(),
    slope: 1.5,
}.into()).await?;

match status {
    UpsertStatus::Created => "the key was not stored here before",
    UpsertStatus::Updated => "the key was stored and content differed",
    UpsertStatus::Unchanged => "the key was stored and content already read as sent",
};

The key columns are read from the active model. For keys excluded from create and update requests, convert the create model first, then set the key columns from the authenticated caller before calling upsert. On Unchanged no write happens.

What counts as content

The columns compared and written are the ones the create model can carry, minus the primary key. A field the entity maintains itself is not part of what a source sends, so it is neither compared nor overwritten:

  • created_at keeps the value it was first stored with.
  • A field carrying on_update advances when the row does change, and not on an Unchanged pass.
  • A field the active model leaves unset is not compared and not written.

Reporting a batch

UpsertOutcome reports one item keyed by what the sender holds:

pub struct UpsertOutcome<K, I, S = UpsertStatus> {
    pub key: K,
    pub id: Option<I>,
    pub status: S,
    pub note: Option<String>,
}

status is generic so a caller with refusals of its own (a row whose owner does not exist here, a row it kept against what the source sent) reports them in the same list rather than as failures. BatchResult, which is per-item success-or-error, cannot express an item that was stored and still has something to report.

Caveats

  • Add a unique index on the key columns. Nothing generates one. Without it, two registrations racing on the same key can both find nothing and both insert.
  • A resource declaring no upsert_key refuses the call rather than guessing a key, as does an active model that leaves a key column unset.
  • A partial index needs upsert_where. Without it the find matches rows the index does not cover, so a registration updates a row the index would have let it insert beside.
  • upsert is a function you call, not a generated route. It is for an ingest path you write, not for the public API surface.

See also

Error Handling

CRUDCrate provides comprehensive error handling with security-conscious responses.

ApiError Types

pub enum ApiError {
    /// Resource not found (404)
    NotFound,

    /// Invalid request data (400)
    BadRequest(String),

    /// Authentication required (401)
    Unauthorized,

    /// Permission denied (403)
    Forbidden,

    /// Resource conflict (409)
    Conflict(String),

    /// Validation failed (422)
    ValidationFailed(Vec<ValidationError>),

    /// Database error (500) - details logged, not exposed
    Database(String),

    /// Generic internal error (500)
    Internal(String),
}

HTTP Status Codes

ErrorStatusWhen
NotFound404Resource doesn’t exist
BadRequest400Invalid input format
Unauthorized401Missing/invalid auth
Forbidden403Insufficient permissions
Conflict409Duplicate key, constraint violation
ValidationFailed422Business rule validation failed
Database500Database error (sanitized)
Internal500Other server error

Response Format

// NotFound
{
  "error": "Not found"
}

// BadRequest
{
  "error": "Invalid filter field: unknown_field"
}

// ValidationFailed
{
  "error": "Validation failed",
  "details": [
    {"field": "email", "message": "Invalid email format"},
    {"field": "age", "message": "Must be at least 18"}
  ]
}

// Database (sanitized)
{
  "error": "Database error"
}

Using ApiError

In Handlers

use crudcrate::ApiError;

async fn get_item(
    Path(id): Path<i32>,
    Extension(db): Extension<DatabaseConnection>,
) -> Result<Json<Item>, ApiError> {
    let item = Entity::find_by_id(id)
        .one(&db)
        .await
        .map_err(|e| ApiError::Database(e.to_string()))?
        .ok_or(ApiError::NotFound)?;

    Ok(Json(item.into()))
}

In CRUDOperations

impl CRUDOperations for MyOperations {
    type Resource = Item;

    async fn before_create<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        &self,
        db: &C,
        data: &mut ItemCreate,
    ) -> Result<(), ApiError> {
        // Validation
        if data.name.is_empty() {
            return Err(ApiError::ValidationFailed(vec![
                ValidationError::new("name", "Name cannot be empty")
            ]));
        }

        // Permission check
        if !user_can_create(&data) {
            return Err(ApiError::Forbidden);
        }

        // Uniqueness check
        if name_exists(db, &data.name).await? {
            return Err(ApiError::Conflict("Name already exists".into()));
        }

        Ok(())
    }
}

Validation Errors

For detailed field-level errors:

use crudcrate::ValidationError;

fn validate_user(data: &UserCreate) -> Result<(), ApiError> {
    let mut errors = Vec::new();

    if data.email.is_empty() {
        errors.push(ValidationError::new("email", "Email is required"));
    } else if !data.email.contains('@') {
        errors.push(ValidationError::new("email", "Invalid email format"));
    }

    if data.password.len() < 8 {
        errors.push(ValidationError::new("password", "Password must be at least 8 characters"));
    }

    if data.age < 18 {
        errors.push(ValidationError::new("age", "Must be at least 18 years old"));
    }

    if errors.is_empty() {
        Ok(())
    } else {
        Err(ApiError::ValidationFailed(errors))
    }
}

Response:

{
  "error": "Validation failed",
  "details": [
    {"field": "email", "message": "Invalid email format"},
    {"field": "password", "message": "Password must be at least 8 characters"}
  ]
}

Error Conversion

ApiError implements From for common error types:

// Sea-ORM errors
impl From<DbErr> for ApiError {
    fn from(err: DbErr) -> Self {
        // Log full error internally
        eprintln!("Database error: {}", err);
        // Return sanitized response
        ApiError::Database("Database error".into())
    }
}

// Use with ? operator
async fn create_item(data: ItemCreate, db: &DatabaseConnection) -> Result<Item, ApiError> {
    let active_model: ActiveModel = data.into();
    let result = active_model.insert(db).await?;  // Auto-converts DbErr
    Ok(result.into())
}

Security Considerations

Never Expose Internal Details

// Bad: exposes internal error
Err(ApiError::Database(format!("SQL error: {}", e)))

// Good: log internally, sanitize response
eprintln!("Database error: {}", e);
Err(ApiError::Database("Database error".into()))

Handle Unique Constraint Violations

async fn create_user(data: UserCreate, db: &DatabaseConnection) -> Result<User, ApiError> {
    let result = Entity::insert(data.into())
        .exec(db)
        .await;

    match result {
        Ok(res) => { /* ... */ },
        Err(DbErr::RecordNotInserted) => {
            Err(ApiError::Conflict("User with this email already exists".into()))
        },
        Err(e) => {
            eprintln!("Insert error: {}", e);
            Err(ApiError::Database("Failed to create user".into()))
        }
    }
}

Rate Limit Errors

async fn check_rate_limit(user_id: i32) -> Result<(), ApiError> {
    // Implementation
    if is_rate_limited(user_id) {
        return Err(ApiError::BadRequest("Too many requests. Please wait.".into()));
    }
    Ok(())
}

Logging with Tracing

Integrate with the tracing crate:

use tracing::{error, warn, info};

impl CRUDOperations for MyOperations {
    async fn before_delete<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        &self,
        db: &C,
        id: i32,
    ) -> Result<(), ApiError> {
        let item = Entity::find_by_id(id).one(db).await?;

        match item {
            Some(item) => {
                info!(id = %id, name = %item.name, "Deleting item");
                Ok(())
            },
            None => {
                warn!(id = %id, "Attempted to delete non-existent item");
                Err(ApiError::NotFound)
            }
        }
    }
}

Custom Error Types

Extend ApiError for domain-specific errors:

#[derive(Debug)]
pub enum DomainError {
    InsufficientBalance(f64),
    ItemOutOfStock(String),
    OrderExpired,
}

impl From<DomainError> for ApiError {
    fn from(err: DomainError) -> Self {
        match err {
            DomainError::InsufficientBalance(amount) => {
                ApiError::BadRequest(format!("Insufficient balance. Need ${:.2} more", amount))
            },
            DomainError::ItemOutOfStock(item) => {
                ApiError::Conflict(format!("Item '{}' is out of stock", item))
            },
            DomainError::OrderExpired => {
                ApiError::BadRequest("Order has expired".into())
            },
        }
    }
}

Global Error Handler

Configure Axum for consistent error handling:

use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, body) = match self {
            ApiError::NotFound => (
                StatusCode::NOT_FOUND,
                json!({"error": "Not found"})
            ),
            ApiError::BadRequest(msg) => (
                StatusCode::BAD_REQUEST,
                json!({"error": msg})
            ),
            ApiError::ValidationFailed(errors) => (
                StatusCode::UNPROCESSABLE_ENTITY,
                json!({
                    "error": "Validation failed",
                    "details": errors
                })
            ),
            // ... other variants
        };

        (status, Json(body)).into_response()
    }
}

Testing Errors

#[tokio::test]
async fn test_not_found() {
    let app = create_test_app().await;

    let response = app
        .oneshot(Request::get("/items/999999").body(Body::empty()).unwrap())
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::NOT_FOUND);

    let body: Value = parse_body(response).await;
    assert_eq!(body["error"], "Not found");
}

#[tokio::test]
async fn test_validation_error() {
    let app = create_test_app().await;

    let response = app
        .oneshot(
            Request::post("/users")
                .header("Content-Type", "application/json")
                .body(Body::from(r#"{"email": "invalid"}"#))
                .unwrap()
        )
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
}

Next Steps

Security Best Practices

This page covers the security knobs CRUDCrate provides, and points at upstream Axum / tower-http / axum-server for the layers it intentionally does not provide (authentication, CORS, TLS, rate limiting, response headers).

Built-in Protections

SQL injection prevention

All queries use Sea-ORM’s parameterized expression builders, so user input never reaches the SQL string:

let condition = Column::Email.eq(user_input);
// Renders as `WHERE email = $1` with `user_input` bound as a parameter.

The fulltext-search builders route the query value through Expr::cust_with_values, so the LIKE pattern is bound rather than interpolated.

Pagination limits

const MAX_PAGE_SIZE: u64 = 1000;
const MAX_OFFSET: u64 = 1_000_000;

Override per resource with #[crudcrate(max_page_size = 500)], or by implementing CRUDResource::max_page_size() for runtime sources (env vars, config).

Overflow protection

Pagination math uses saturating arithmetic. page=u64::MAX and per_page=u64::MAX resolve to safe values instead of panicking.

Field value and search query length limits

const MAX_FIELD_VALUE_LENGTH: usize = 10_000;   // 10 KB per filter value
const MAX_SEARCH_QUERY_LENGTH: usize = 10_000;  // 10 KB for {"q": ...}

Oversized values are truncated before they reach the query builder.

LIKE wildcard escaping

% and _ in filter values are escaped, so {"name": "%admin%"} matches the literal string rather than every row containing admin.

Filter clause count limit

const MAX_FILTER_CLAUSES: usize = 100;

Requests with more than 100 filter keys return 400 Bad Request. Comparison-operator suffixes count separately: {"year_gte": 2020, "year_lte": 2024} is two clauses on one field.

CRUDCrate deliberately does not silently drop over-limit filters: an unfiltered response is a worse failure mode than a rejected request, because a caller relying on the filter would see data it did not ask for.

Batch operation limits

#[crudcrate(batch_limit = 500)]   // default 100
pub struct Model { /* ... */ }

Override at runtime by implementing CRUDResource::batch_limit() (for example, reading from an environment variable).

Child row cap

Vec<Child> joins load every matching child row for the parents on the page. Cap that per join field and per request with max_child_rows; a request that would exceed it returns 413 Payload Too Large. The cap is unlimited unless set.

#[crudcrate(generate_router, max_child_rows = 500)]

The same value is max_child_rows_per_relation on SecurityProfile. It is read from the resource’s security_profile() during join loading, so a request-time Extension does not change it. Joined-filter sub-queries are not affected: the database evaluates them without returning child rows.

Partial-success mode

POST /resource/batch?partial=true (and the equivalents for PATCH and DELETE) returns 207 Multi-Status with a succeeded / failed split instead of failing the whole batch.

Each item is processed through the single-item hooks (create::one::*, etc.) and commits independently. There is no shared transaction. Error strings in the failed array use the sanitized ApiError display output, not the raw DB error.

HTTP status codes:

  • 200 if every item succeeded.
  • 207 Multi-Status if some succeeded and some failed.
  • 400 Bad Request if every item failed.

Header injection prevention

Resource names embedded in Content-Range response headers are filtered to ASCII non-control characters, so a malicious table-name substring cannot inject extra headers.

The ApiError to Response translation also sanitizes the user-derived prefix in DbErr::RecordNotFound messages: only an alphanumeric/underscore identifier in the first word is kept, capped at 64 characters. Anything else falls back to a generic Resource prefix.

Error sanitization

Internal DB errors are logged via tracing but the client only sees a generic message:

Internal log: SQLSTATE[42P01]: relation "users" does not exist
Client response: "A database error occurred"

SecurityProfile

SecurityProfile bundles the runtime defaults that vary between deployments: filter strictness, scope propagation, deleted-ID exposure, and request body size limit. Three presets cover the common cases:

PresetStrict filter parsingStrict scope propagationExpose deleted IDsBody limit
secure() (0.9.0 default)yesyesno2 MiB
react_admin()noyesyes2 MiB
legacy() (pre-0.9.0)nonoyes2 MiB

Per-resource override

#[derive(EntityToModels, /* ... */)]
#[crudcrate(api_struct = "Customer", security_profile = "react_admin")]
pub struct Model { /* ... */ }

Global override

use axum::Extension;
use crudcrate::SecurityProfile;

let app = Router::new()
    .merge(Customer::router(&db))
    .merge(Article::router(&db))
    .layer(Extension(SecurityProfile::secure()));

The Extension wins over the per-resource attribute, so a global layer can tighten or loosen individual resources without touching each impl CRUDResource.

Custom profile

Use struct-update syntax to mix fields:

let p = SecurityProfile {
    expose_deleted_ids: true,
    ..SecurityProfile::secure()
};

Resolution order

  1. axum::Extension<SecurityProfile> on the request, if present.
  2. CRUDResource::security_profile() for the resource being served (the derive attribute generates this).
  3. The trait default, which is SecurityProfile::secure() in 0.9.0.

Build-time caveat for max_request_body_bytes

max_request_body_bytes is applied via Axum’s DefaultBodyLimit layer when the router is built. Changing it via an Extension at request time has no effect on the limit. The other three fields work fully at request time.

To raise or lower the body limit, set it via the per-resource derive attribute, or wrap the router yourself:

use axum::extract::DefaultBodyLimit;

let app = Router::new()
    .nest("/uploads", Upload::router(&db))
    .layer(DefaultBodyLimit::max(10 * 1024 * 1024));

See MIGRATION_0.9.md for the upgrade path from legacy() and the per-flag rationale.

Authentication

CRUDCrate does not authenticate callers. The generated routers are open: any request that reaches them is processed. Wrap them with an Axum middleware layer (or an upstream reverse proxy) that authenticates the caller before the handler runs.

The scoped_access example combines auth middleware with row-level scoping end to end.

For middleware patterns (JWT, API key, session cookie), see the Axum middleware docs.

Authorization

Authorization lives in the hook system. Use before_get_all for row-level filtering and before_* hooks for per-operation checks:

async fn before_get_all<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    &self,
    _db: &C,
    condition: &mut Condition,
) -> Result<(), ApiError> {
    let user = current_user();
    if !user.is_admin {
        *condition = condition.clone().add(Column::AuthorId.eq(user.id));
    }
    Ok(())
}

async fn before_update<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    &self,
    db: &C,
    id: Uuid,
    _data: &mut ArticleUpdate,
) -> Result<(), ApiError> {
    let user = current_user();
    let article = Entity::find_by_id(id)
        .one(db)
        .await?
        .ok_or(ApiError::NotFound)?;
    if article.author_id != user.id && !user.is_admin {
        return Err(ApiError::Forbidden);
    }
    Ok(())
}

For declarative scope filtering tied to an Extension, see the scoping tutorial.

Prevent mass assignment

Strip protected fields in before_update:

async fn before_update<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    &self,
    _db: &C,
    _id: Uuid,
    data: &mut UserUpdate,
) -> Result<(), ApiError> {
    data.is_admin = None;
    data.password_reset_token = None;
    Ok(())
}

Layers CRUDCrate does not provide

Use the appropriate Axum / tower-http / axum-server layer for each. CRUDCrate intentionally does not wrap these.

Logging

Log security-relevant events through tracing. The hook system is the natural attachment point for #[instrument]:

#[instrument(skip(self, db))]
async fn before_delete<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    &self,
    db: &C,
    id: Uuid,
) -> Result<(), ApiError> {
    info!(article_id = %id, "delete attempted");
    Ok(())
}

A route-mount info log is emitted at startup with the resource name, table, batch_limit, max_page_size, and the active security defaults. It renders only when a tracing_subscriber is installed.

Security checklist

  • Auth middleware wraps every route that needs to be authenticated.
  • before_* hooks enforce authorization on mutations.
  • SecurityProfile::secure() (or stricter) is the active profile.
  • Rate limiting layer is in place.
  • CORS restricted to allowed origins.
  • HTTPS terminated at the load balancer or in-process.
  • Security response headers set.
  • Sensitive fields excluded via exclude(list) or exclude(one).
  • DB credentials sourced from environment, not source.
  • cargo audit runs in CI.
  • Dependencies updated regularly.

Next Steps

Multi-Tenant Scoping

The scoping tutorial showed a public vs. private split: an unauthenticated tier that is read-only and hides private rows. Multi-tenancy is a different shape of the same primitive:

  • Every caller is authenticated, and each one is confined to their own tenant’s rows.
  • Scoped callers usually still need to create and update within their tenant.

You build it from the same ScopeCondition, with two differences from the public/private case: the condition is per-tenant (a value, not an is_private boolean), and you have to be deliberate about writes.

Apply the condition to every method

A ScopeCondition filters reads and confines writes. Inject it on every request from a tenant:

use axum::{extract::Request, middleware::Next, response::Response};
use crudcrate::ScopeCondition;
use sea_orm::{ColumnTrait, Condition};

async fn scope_tenant(mut request: Request, next: Next) -> Response {
    if let Some(tenant_id) = current_tenant(&request) {
        request.extensions_mut().insert(ScopeCondition::new(
            Condition::all().add(widget::Column::TenantId.eq(tenant_id)),
        ));
    }
    next.run(request).await
}

A caller without an extension can read and write every row. Authenticate callers and decide which methods they may use before injecting the condition. Use read_only_router() for a public mount that must not accept writes.

A per-tenant condition across a join (typed, injection-safe)

Often the tenant_id lives on a parent table and the entity you’re serving only has a foreign key to it. A Condition works on the entity’s own columns, but it can hold a subquery, so you scope the child by the set of parent ids the tenant owns in one statement, with no extra round-trip.

The tutorial used Expr::cust() (raw SQL, with a SQL-injection warning). Prefer the typed builder when you can. It can’t be injected into and it survives column renames:

use sea_orm::{ColumnTrait, Condition, EntityTrait, QueryFilter, QuerySelect, QueryTrait};

// `gadget` only has `widget_id`; the tenant lives on `widget`.
fn gadget_scope(tenant_id: Uuid) -> Condition {
    let widgets_in_tenant = widget::Entity::find()
        .select_only()
        .column(widget::Column::Id)
        .filter(widget::Column::TenantId.eq(tenant_id))
        .into_query(); // -> sea_query::SelectStatement

    Condition::all().add(gadget::Column::WidgetId.in_subquery(widgets_in_tenant))
}

Rows whose foreign key is NULL (an unassigned child) drop out of an IN (subquery) by construction, usually exactly what you want for a scoped caller.

Confining writes

An update or delete selects the existing row under the condition and locks it for the transaction. An excluded row returns 404. Creates and updates check their resulting rows before commit; a row outside the condition returns 403 and rolls the transaction back. This also catches an update that changes the row’s tenant.

Batches are atomic by default. With ?partial=true, each item has its own transaction and an excluded item is reported as a failure. Custom resource hooks run inside the same transaction as the scope checks. Hooks remain responsible for any additional rows they write and for external side effects that a database rollback cannot undo.

The derive implements CRUDResource::resource_id() to identify a created row for the check. A manual resource implementation must provide it for scoped creates and updates; the default fails closed. Direct CRUDResource calls remain unscoped; use crudcrate::scope write helpers when composing a scoped operation outside the generated handlers.

Fail closed with REQUIRE_SCOPE

For an entity that must never be served without a scope (a misrouted handler would otherwise leak every tenant’s rows), set REQUIRE_SCOPE. The generated read handlers then return 500 when no ScopeCondition is present, instead of silently returning everything:

#[crudcrate(require_scope, generate_router)]
pub struct Widget { /* ... */ }

Leave it off for entities that have a legitimate unscoped (admin) caller; there, the absence of the extension is the intended “see everything” path.

REQUIRE_SCOPE governs reads only. Writes with an extension are confined; writes without one remain unrestricted. It does not replace authentication or method permissions.

Quick reference

ConcernWhere it lives
Confine reads to a tenantScopeCondition injected by middleware
Scope across a joinColumn::Fk.in_subquery(parent_ids_for_tenant) (typed, not raw SQL)
Let tenants write their own dataInject the scope on writes too
Confine writes to a tenantOne condition, checked inside the write transaction
Never serve unscoped by accident#[crudcrate(require_scope)]
Hide a column from scoped readsexclude(scoped) on the field (see the tutorial)

See also: Public & Private Endpoints for the read-only/public tier and exclude(scoped), and Security Best Practices.

Performance Optimization

Optimize your CRUDCrate API for production workloads.

Database Indexing

Index Filtered Fields

-- Single column indexes for filterable fields
CREATE INDEX idx_articles_status ON articles(status);
CREATE INDEX idx_articles_author_id ON articles(author_id);
CREATE INDEX idx_articles_created_at ON articles(created_at);

-- Composite index for common filter combinations
CREATE INDEX idx_articles_status_created ON articles(status, created_at DESC);

Index Sorted Fields

-- DESC index for newest-first queries
CREATE INDEX idx_articles_created_at_desc ON articles(created_at DESC);

-- Composite for filter + sort
CREATE INDEX idx_articles_author_created ON articles(author_id, created_at DESC);

Fulltext Indexes

-- PostgreSQL GIN index
CREATE INDEX idx_articles_search ON articles USING GIN(
    to_tsvector('english', coalesce(title, '') || ' ' || coalesce(content, ''))
);

-- MySQL FULLTEXT index
ALTER TABLE articles ADD FULLTEXT INDEX idx_articles_fulltext (title, content);

Query Optimization

Use Selective Filters

# Full table scan
GET /articles

# Filtered query uses index
GET /articles?filter={"status":"published"}

Limit Result Size

# Always paginate
GET /articles?range=[0,19]

# Built-in limit: max 1000 items per request

Avoid Deep Joins

// Deep recursion
#[crudcrate(non_db_attr, join(one, all, depth = 10))]
pub comments: Vec<Comment>,

// Limited depth
#[crudcrate(non_db_attr, join(one, depth = 2))]
pub comments: Vec<Comment>,

Connection Pooling

Configure Sea-ORM connection pool:

use sea_orm::{Database, ConnectOptions};

let mut opt = ConnectOptions::new(database_url);
opt.max_connections(100)
   .min_connections(5)
   .connect_timeout(Duration::from_secs(8))
   .acquire_timeout(Duration::from_secs(8))
   .idle_timeout(Duration::from_secs(8))
   .max_lifetime(Duration::from_secs(8))
   .sqlx_logging(false);  // Disable query logging in production

let db = Database::connect(opt).await?;

Caching Strategies

Response Caching

use axum::http::header;
use tower_http::set_header::SetResponseHeaderLayer;

// Cache static-ish data
let app = Router::new()
    .route("/categories", get(list_categories))
    .layer(SetResponseHeaderLayer::if_not_present(
        header::CACHE_CONTROL,
        HeaderValue::from_static("public, max-age=300")  // 5 minutes
    ));

Query Caching with Redis

use redis::AsyncCommands;

async fn get_articles_cached(
    db: &DatabaseConnection,
    redis: &redis::Client,
    filter: &FilterOptions,
) -> Result<Vec<Article>, ApiError> {
    let cache_key = format!("articles:{}", hash_filter(filter));

    // Try cache first
    if let Ok(mut conn) = redis.get_async_connection().await {
        if let Ok(cached) = conn.get::<_, String>(&cache_key).await {
            if let Ok(articles) = serde_json::from_str(&cached) {
                return Ok(articles);
            }
        }
    }

    // Cache miss - query database
    let articles = Article::get_all(db, /* ... */).await?;

    // Store in cache
    if let Ok(mut conn) = redis.get_async_connection().await {
        let _ = conn.set_ex::<_, _, ()>(
            &cache_key,
            serde_json::to_string(&articles).unwrap(),
            300  // 5 minute TTL
        ).await;
    }

    Ok(articles)
}

Count Caching

Counting large tables is expensive:

// Cache total counts
async fn get_total_count_cached(
    db: &DatabaseConnection,
    redis: &redis::Client,
    entity: &str,
) -> u64 {
    let cache_key = format!("count:{}", entity);

    if let Ok(mut conn) = redis.get_async_connection().await {
        if let Ok(count) = conn.get::<_, u64>(&cache_key).await {
            return count;
        }
    }

    // Cache miss
    let count = Entity::find().count(db).await.unwrap_or(0);

    // Cache for 60 seconds
    if let Ok(mut conn) = redis.get_async_connection().await {
        let _ = conn.set_ex::<_, _, ()>(&cache_key, count, 60).await;
    }

    count
}

Pagination Optimization

Keyset Pagination

For large datasets, use cursor-based pagination:

// Instead of OFFSET (slow for large values)
// Use WHERE id > last_id (fast with index)

async fn list_articles_keyset(
    db: &DatabaseConnection,
    after_id: Option<Uuid>,
    limit: u64,
) -> Result<Vec<Article>, ApiError> {
    let mut query = Entity::find()
        .order_by(Column::Id, Order::Asc);

    if let Some(id) = after_id {
        query = query.filter(Column::Id.gt(id));
    }

    let articles = query
        .limit(limit)
        .all(db)
        .await?;

    Ok(articles.into_iter().map(Into::into).collect())
}

Skip Count for Infinite Scroll

async fn list_articles_no_count(
    db: &DatabaseConnection,
    offset: u64,
    limit: u64,
) -> Result<(Vec<Article>, bool), ApiError> {
    // Fetch one extra to check for more
    let articles = Entity::find()
        .offset(offset)
        .limit(limit + 1)
        .all(db)
        .await?;

    let has_more = articles.len() > limit as usize;
    let articles: Vec<Article> = articles
        .into_iter()
        .take(limit as usize)
        .map(Into::into)
        .collect();

    Ok((articles, has_more))
}

List Optimization

Exclude Heavy Fields from Lists

// Full content not needed in lists
#[crudcrate(exclude(list))]
pub content: String,

// Relationships only in detail view
#[crudcrate(non_db_attr, join(one))]  // NOT join(all)
pub comments: Vec<Comment>,

Select Only Needed Columns

// Custom list handler with column selection
async fn list_articles_optimized(
    Query(params): Query<FilterOptions>,
    Extension(db): Extension<DatabaseConnection>,
) -> Result<Json<Vec<ArticleListItem>>, ApiError> {
    let articles = Entity::find()
        .select_only()
        .column(Column::Id)
        .column(Column::Title)
        .column(Column::Excerpt)
        .column(Column::CreatedAt)
        // Omit content, relationships
        .into_model::<ArticleListItem>()
        .all(&db)
        .await?;

    Ok(Json(articles))
}

Async Best Practices

Batch Database Operations

// Sequential queries
for id in ids {
    let item = Entity::find_by_id(id).one(db).await?;
    results.push(item);
}

// Batch query
let items = Entity::find()
    .filter(Column::Id.is_in(ids))
    .all(db)
    .await?;

Parallel Independent Queries

use tokio::join;

async fn get_article_with_stats(
    db: &DatabaseConnection,
    id: Uuid,
) -> Result<ArticleWithStats, ApiError> {
    // Run queries in parallel
    let (article, comment_count, view_count) = join!(
        Entity::find_by_id(id).one(db),
        comment::Entity::find().filter(comment::Column::ArticleId.eq(id)).count(db),
        get_view_count(id),
    );

    let article = article?.ok_or(ApiError::NotFound)?;

    Ok(ArticleWithStats {
        article: article.into(),
        comment_count: comment_count?,
        view_count: view_count?,
    })
}

Monitoring

Query Logging

// Enable in development
let mut opt = ConnectOptions::new(database_url);
opt.sqlx_logging(true)
   .sqlx_logging_level(tracing::log::LevelFilter::Debug);

Slow Query Detection

use tracing::{info, warn};
use std::time::Instant;

async fn timed_query<T, F, Fut>(name: &str, f: F) -> T
where
    F: FnOnce() -> Fut,
    Fut: std::future::Future<Output = T>,
{
    let start = Instant::now();
    let result = f().await;
    let elapsed = start.elapsed();

    if elapsed > Duration::from_millis(100) {
        warn!(query = name, elapsed_ms = elapsed.as_millis(), "Slow query");
    } else {
        info!(query = name, elapsed_ms = elapsed.as_millis(), "Query completed");
    }

    result
}

Performance Checklist

  • Indexes on all filtered columns
  • Indexes on all sorted columns
  • Composite indexes for common query patterns
  • Fulltext indexes for search fields
  • Connection pool properly sized
  • Pagination enforced
  • Heavy fields excluded from lists
  • Join depth limited
  • Query caching for hot paths
  • Count caching for large tables
  • Query logging enabled (dev) / disabled (prod)
  • Slow query monitoring

Next Steps

Custom Operations

The CRUDOperations trait is an alternative to per-attribute hooks. Implement it on a unit struct, override only the methods you need, and wire it in with #[crudcrate(operations = MyOps)].

When to use operations vs per-attribute hooks

Use operations when you want a single struct that owns all customization for a resource: validation, authorization, side effects. It reads like a controller.

Use per-attribute hooks (#[crudcrate(create::one::pre = validate)]) when you only need one or two hooks and prefer keeping them close to the model definition.

The two systems are alternatives. When operations is set, per-attribute hooks on the same resource are ignored.

Setup

1. Define the operations struct

use crudcrate::{CRUDOperations, ApiError};
use uuid::Uuid;

pub struct AssetOps;

impl CRUDOperations for AssetOps {
    type Resource = Asset;

    async fn before_create<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        &self,
        db: &C,
        data: &<Asset as crudcrate::traits::CRUDResource>::CreateModel,
    ) -> Result<(), ApiError> {
        // Validate, authorize, log: whatever you need
        Ok(())
    }

    async fn before_delete<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        &self,
        _db: &C,
        id: Uuid,
    ) -> Result<(), ApiError> {
        // Clean up S3 before the row is deleted
        delete_from_s3(id).await
            .map_err(|e| ApiError::internal(format!("S3 cleanup failed: {e}"), None))?;
        Ok(())
    }

    // All other methods use default no-op implementations
}

db in a write hook is the transaction the operation opened, and the lifecycle holds that pooled connection until it commits. Query on db rather than on a connection taken from app state, which would hold two connections per request and can exhaust the pool under concurrent writes. A call out to another service, like the S3 cleanup above, runs with the connection held for as long as it takes; where that matters, make the call before the operation or after it returns.

2. Register with the entity

#[derive(EntityToModels)]
#[crudcrate(generate_router, operations = AssetOps)]
pub struct Model {
    // ...
}

Three levels of customization

Level 1: Lifecycle hooks

before_* and after_* methods run around the default core logic.

async fn before_create<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(&self, db: &C, data: &CreateModel) -> Result<(), ApiError>;
async fn after_create<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(&self, db: &C, entity: &mut Self::Resource) -> Result<(), ApiError>;

async fn before_get_one<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(&self, db: &C, id: Uuid) -> Result<(), ApiError>;
async fn after_get_one<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(&self, db: &C, entity: &mut Self::Resource) -> Result<(), ApiError>;

before_create and before_update take immutable references to the input data. They’re for validation and authorization, not transformation. To transform input before insertion, override perform_create (level 2).

after_* hooks take &mut references; use them for enrichment (computed fields, view counts, etc.).

Level 2: Core logic overrides

Replace the default DB query or mutation while keeping lifecycle hooks around it.

async fn fetch_one<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(&self, db: &C, id: Uuid) -> Result<Self::Resource, ApiError> {
    // Custom query, eg. select specific columns, add joins
    // ...
}

async fn perform_create<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(&self, db: &C, data: CreateModel) -> Result<Self::Resource, ApiError> {
    // Custom insertion logic, eg. transform data before insert
    // ...
}

Level 3: Full operation overrides

Replace the entire operation including lifecycle hooks. The default implementations orchestrate before_* → core_logic → after_*, so overriding here means you take full control.

async fn delete<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(&self, db: &C, id: Uuid) -> Result<Uuid, ApiError> {
    // Completely custom: S3 cleanup, cascade, audit, everything
    let asset = Asset::get_one(db, id).await?;
    delete_from_s3(&asset.s3_key).await?;
    Asset::delete(db, id).await
}

Interaction with joins

Entities can have both operations and join(...) fields. When they do:

  • get_one / get_all use the standard join-loading codegen (batch loading, scoped child queries, FK resolution). The before_get_one / after_get_one and before_get_all / after_get_all hooks still fire around the join-loaded body.

  • fetch_one / fetch_all overrides are bypassed when joins are present. Join loading needs the raw SeaORM Model for model.find_related(), which fetch_one doesn’t return (it returns the converted API struct). If you need full control over both the fetch query and join loading, use per-attribute hooks (read::one::body) instead.

  • get_one_scoped / get_all_scoped are generated with scope-aware join loading, same as entities without operations.

Common patterns

Validation

async fn before_create<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    &self,
    _db: &C,
    data: &ArticleCreate,
) -> Result<(), ApiError> {
    if data.title.trim().len() < 5 {
        return Err(ApiError::bad_request("Title must be at least 5 characters"));
    }
    Ok(())
}

Authorization

async fn before_update<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    &self,
    db: &C,
    id: Uuid,
    _data: &ArticleUpdate,
) -> Result<(), ApiError> {
    let article = Entity::find_by_id(id)
        .one(db)
        .await?
        .ok_or_else(|| ApiError::not_found("article", Some(id.to_string())))?;

    let user = get_current_user();
    if article.author_id != user.id && !user.is_admin {
        return Err(ApiError::forbidden("Not the author"));
    }
    Ok(())
}

Enrichment via after hooks

async fn after_get_one<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    &self,
    db: &C,
    entity: &mut Article,
) -> Result<(), ApiError> {
    entity.view_count = get_view_count(db, entity.id).await?;
    Ok(())
}

Cascading deletes

async fn before_delete<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    &self,
    db: &C,
    id: Uuid,
) -> Result<(), ApiError> {
    comment::Entity::delete_many()
        .filter(comment::Column::ArticleId.eq(id))
        .exec(db)
        .await?;
    Ok(())
}

See also

Lifecycle Hooks

Configure operation hooks directly through attributes for simple cases.

Overview

Lifecycle hooks provide attribute-based customization without implementing CRUDOperations:

#[derive(EntityToModels)]
#[crudcrate(
    generate_router,
    create::one::pre = validate_article,
    create::one::post = notify_subscribers,
    update::one::pre = check_permissions,
    delete::one::pre = archive_instead_of_delete,
)]
pub struct Model {
    // ...
}

Hook Types

Pre Hooks (::pre)

Execute before the database operation:

#[crudcrate(create::one::pre = validate_article)]

// Hook function signature
async fn validate_article<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    data: &mut ArticleCreate,
) -> Result<(), ApiError> {
    if data.title.is_empty() {
        return Err(ApiError::ValidationFailed(vec![
            ValidationError::new("title", "Title is required")
        ]));
    }
    Ok(())
}

Post Hooks (::post)

Execute after the database operation succeeds:

#[crudcrate(create::one::post = notify_subscribers)]

// Hook function signature
async fn notify_subscribers<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    created: &Article,
) -> Result<(), ApiError> {
    send_notifications(created.author_id).await;
    Ok(())
}

Body Replacement (::body)

Replace the entire operation logic:

#[crudcrate(delete::one::body = soft_delete)]

// Completely replaces the delete handler
async fn soft_delete<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    id: Uuid,
) -> Result<(), ApiError> {
    // Instead of deleting, mark as deleted
    let mut article: ActiveModel = Entity::find_by_id(id)
        .one(db)
        .await?
        .ok_or(ApiError::NotFound)?
        .into();

    article.deleted_at = Set(Some(chrono::Utc::now()));
    article.update(db).await?;

    Ok(())
}

Transform Hooks (::transform)

Modify the result after the operation (or body replacement) but before the post hook:

#[crudcrate(read::one::transform = enrich_article)]

// Hook function signature
async fn enrich_article<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    article: Article,  // Takes ownership
) -> Result<Article, ApiError> {
    // Enrich, decorate, or transform the result
    Ok(article)
}

Transform hooks take ownership of the result and return a modified version. Supported for all operations (create, read, update, delete) in both one and many cardinality.

For many operations, the transform receives and returns Vec<T>:

async fn transform_articles<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    articles: Vec<Article>,
) -> Result<Vec<Article>, ApiError> {
    // Transform the batch
    Ok(articles)
}

Available Operations

OperationDescription
create::oneCreate single item (POST /items)
create::manyBatch create (POST /items/batch)
update::oneUpdate single item (PUT /items/:id)
update::manyBatch update (PATCH /items/batch)
delete::oneDelete single item (DELETE /items/:id)
delete::manyBulk delete (DELETE /items with body)
read::oneGet single item (GET /items/:id)
read::allList items (GET /items)

Function Signatures

Create Hooks

// Pre hook - can modify the input
async fn create_pre<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    data: &mut ArticleCreate,
) -> Result<(), ApiError>;

// Post hook - receives the created entity
async fn create_post<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    created: &Article,
) -> Result<(), ApiError>;

// Body replacement - full control
async fn create_body<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    data: ArticleCreate,
) -> Result<Article, ApiError>;

// Transform - modify the result
async fn create_transform<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    created: Article,
) -> Result<Article, ApiError>;

Update Hooks

// Pre hook
async fn update_pre<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    id: Uuid,
    data: &mut ArticleUpdate,
) -> Result<(), ApiError>;

// Post hook
async fn update_post<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    updated: &Article,
) -> Result<(), ApiError>;

// Body replacement
async fn update_body<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    id: Uuid,
    data: ArticleUpdate,
) -> Result<Article, ApiError>;

// Transform - modify the result
async fn update_transform<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    updated: Article,
) -> Result<Article, ApiError>;

Delete Hooks

// Pre hook
async fn delete_pre<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    id: Uuid,
) -> Result<(), ApiError>;

// Post hook
async fn delete_post<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    id: Uuid,
) -> Result<(), ApiError>;

// Body replacement
async fn delete_body<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    id: Uuid,
) -> Result<(), ApiError>;

Get Hooks

// Pre hook for get_one
async fn get_one_pre<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    id: Uuid,
) -> Result<(), ApiError>;

// Pre hook for get_all - can modify the condition
async fn get_all_pre<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    condition: &mut Condition,
) -> Result<(), ApiError>;

Examples

Input Validation

#[crudcrate(create::one::pre = validate_user)]

async fn validate_user<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    _db: &C,
    data: &mut UserCreate,
) -> Result<(), ApiError> {
    let mut errors = Vec::new();

    // Email validation
    if !data.email.contains('@') {
        errors.push(ValidationError::new("email", "Invalid email format"));
    }

    // Password strength
    if data.password.len() < 8 {
        errors.push(ValidationError::new("password", "Must be at least 8 characters"));
    }

    if errors.is_empty() {
        Ok(())
    } else {
        Err(ApiError::ValidationFailed(errors))
    }
}

Password Hashing

#[crudcrate(create::one::pre = hash_password)]

async fn hash_password<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    _db: &C,
    data: &mut UserCreate,
) -> Result<(), ApiError> {
    // Hash the password before storing
    data.password_hash = bcrypt::hash(&data.password, bcrypt::DEFAULT_COST)
        .map_err(|_| ApiError::Internal("Failed to hash password".into()))?;

    // Clear the plain password
    data.password.clear();

    Ok(())
}

Soft Delete

#[crudcrate(delete::one::body = soft_delete_article)]

async fn soft_delete_article<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    id: Uuid,
) -> Result<(), ApiError> {
    let article = Entity::find_by_id(id)
        .one(db)
        .await?
        .ok_or(ApiError::NotFound)?;

    let mut active: ActiveModel = article.into();
    active.deleted_at = Set(Some(chrono::Utc::now()));
    active.update(db).await?;

    Ok(())
}

Row-Level Security

#[crudcrate(get::all::pre = filter_by_tenant)]

async fn filter_by_tenant<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    _db: &C,
    condition: &mut Condition,
) -> Result<(), ApiError> {
    let tenant_id = get_current_tenant_id();

    *condition = condition.clone().add(Column::TenantId.eq(tenant_id));

    Ok(())
}

Audit Trail

#[crudcrate(
    create::one::post = log_create,
    update::one::post = log_update,
    delete::one::post = log_delete,
)]

async fn log_create<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    created: &Article,
) -> Result<(), ApiError> {
    save_audit_log(db, "CREATE", "Article", &created.id.to_string()).await
}

async fn log_update<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    updated: &Article,
) -> Result<(), ApiError> {
    save_audit_log(db, "UPDATE", "Article", &updated.id.to_string()).await
}

async fn log_delete<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    id: Uuid,
) -> Result<(), ApiError> {
    save_audit_log(db, "DELETE", "Article", &id.to_string()).await
}

Cascade Updates

#[crudcrate(update::one::post = update_search_index)]

async fn update_search_index<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    _db: &C,
    updated: &Article,
) -> Result<(), ApiError> {
    // Update search engine
    search_client::update_document("articles", updated.id, updated).await;
    Ok(())
}

Authorization Check

#[crudcrate(
    update::one::pre = check_edit_permission,
    delete::one::pre = check_delete_permission,
)]

async fn check_edit_permission<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    id: Uuid,
    _data: &mut ArticleUpdate,
) -> Result<(), ApiError> {
    let article = Entity::find_by_id(id).one(db).await?.ok_or(ApiError::NotFound)?;
    let user = get_current_user();

    if article.author_id != user.id && !user.is_admin {
        return Err(ApiError::Forbidden);
    }

    Ok(())
}

async fn check_delete_permission<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    id: Uuid,
) -> Result<(), ApiError> {
    let article = Entity::find_by_id(id).one(db).await?.ok_or(ApiError::NotFound)?;
    let user = get_current_user();

    if !user.is_admin {
        return Err(ApiError::Forbidden);
    }

    Ok(())
}

Hooks vs CRUDOperations

FeatureHooksCRUDOperations
ConfigurationAttributeTrait impl
ScopePer-operationAll operations
StateStateless functionsCan hold state
TestingFunction mockingTrait mocking
Use whenSimple casesComplex logic

When to Use Hooks

  • Simple validation
  • Data transformation
  • Logging/notifications
  • Single operation customization

When to Use CRUDOperations

  • Complex authorization logic
  • Shared state across operations
  • Database-dependent validation
  • Multiple operations need same logic

Combining Both

You can use both hooks and CRUDOperations:

#[derive(EntityToModels)]
#[crudcrate(
    generate_router,
    operations = ArticleOperations,
    create::one::pre = validate_article,  // Runs before CRUDOperations::before_create
)]
pub struct Model { }

Execution order:

  1. Attribute hook (create::one::pre)
  2. CRUDOperations::before_create
  3. Database operation (or create::one::body replacement)
  4. Attribute hook (create::one::transform)
  5. CRUDOperations::after_create
  6. Attribute hook (create::one::post)

Hooks and Partial Success Mode

When using ?partial=true on batch endpoints, items are processed individually using single-item hooks (create::one::*, update::one::*, delete::one::*), not batch hooks (create::many::*, etc.). Each item commits independently with no shared transaction.

This means:

  • create::one::pre runs once per item, not create::many::pre once for the batch
  • A failing item does not roll back previously succeeded items
  • Transform hooks run per-item, receiving a single entity (not a Vec)

See Security for more details on partial success behavior.

Next Steps

Validation

Validate input data before it reaches your database.

The primary mechanism is the Validatable trait. The generated CRUD handlers call it automatically on every create and update, including the batch variants. Hook-based validation remains available for checks that need database access.

The Validatable Trait

pub trait Validatable {
    fn validate(&self) -> Result<(), ValidationError>;
}

Implement it on your Create or Update model. The generated create/create_many/update/update_many implementations invoke validate() before any database write. A failure maps to HTTP 422.

No registration is needed. The generated code probes for the impl via autoref specialisation (crudcrate::validation::__auto), so models without a Validatable impl get a no-op and are unaffected.

Implementing for Create Models

use crudcrate::validation::{Validatable, ValidationError};

fn name_is_valid(name: &str) -> Result<(), ValidationError> {
    if name.trim().is_empty() {
        return Err(ValidationError::new("name", "Name is required"));
    }
    if name.chars().count() < 3 {
        return Err(ValidationError::new("name", "Name must be at least 3 characters"));
    }
    Ok(())
}

impl Validatable for ProductCreate {
    fn validate(&self) -> Result<(), ValidationError> {
        name_is_valid(&self.name)?;
        if self.price <= 0 {
            return Err(ValidationError::new("price", "Price must be positive"));
        }
        Ok(())
    }
}

Implementing for Update Models

Update models double-wrap every column as Option<Option<T>>. The outer None means the field was absent from the request. The inner None means “set to NULL”. Validate a value only when it is actually present:

impl Validatable for ProductUpdate {
    fn validate(&self) -> Result<(), ValidationError> {
        if let Some(Some(name)) = &self.name {
            name_is_valid(name)?;
        }
        Ok(())
    }
}

Response Format

A failed validate() returns HTTP 422 with the field and message joined as one string per error:

{
  "error": "Validation failed",
  "details": ["name: Name must be at least 3 characters"]
}

Built-in Validators

The crudcrate::validation::validators module ships helpers for common patterns. Length limits count characters, not UTF-8 bytes, so multibyte values within the character limit are not rejected.

use crudcrate::validation::validators;

impl Validatable for UserCreate {
    fn validate(&self) -> Result<(), ValidationError> {
        validators::validate_required("name", &self.name)?;
        validators::validate_length("name", &self.name, Some(2), Some(100))?;
        validators::validate_email("email", &self.email)?;
        validators::validate_range("age", self.age, Some(18), Some(150))?;
        Ok(())
    }
}

Hook-Based Validation

Validatable is synchronous and sees only the payload. For checks that need the database, or to report several errors at once, use a lifecycle hook and construct the error with ApiError::validation_failed, which takes a Vec<String>:

#[crudcrate(create::one::pre = validate_user)]

async fn validate_user<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    data: &mut UserCreate,
) -> Result<(), ApiError> {
    let mut errors = Vec::new();

    if data.name.trim().is_empty() {
        errors.push("name: Name is required".to_string());
    }
    if !data.email.contains('@') {
        errors.push("email: Invalid email format".to_string());
    }

    if !errors.is_empty() {
        return Err(ApiError::validation_failed(errors));
    }

    check_email_unique(db, &data.email).await?;
    Ok(())
}

The same construction works inside CRUDOperations::before_create and before_update.

Database Validation

Uniqueness Check

async fn check_email_unique<C: sea_orm::ConnectionTrait>(
    db: &C,
    email: &str,
) -> Result<(), ApiError> {
    let exists = Entity::find()
        .filter(Column::Email.eq(email))
        .count(db)
        .await?;

    if exists > 0 {
        return Err(ApiError::validation_failed(vec![
            "email: Email already in use".to_string(),
        ]));
    }
    Ok(())
}

Foreign Key Existence

async fn check_category_exists<C: sea_orm::ConnectionTrait>(
    db: &C,
    category_id: i32,
) -> Result<(), ApiError> {
    let exists = category::Entity::find_by_id(category_id)
        .count(db)
        .await?;

    if exists == 0 {
        return Err(ApiError::validation_failed(vec![
            "category_id: Category does not exist".to_string(),
        ]));
    }
    Ok(())
}

Using the validator Crate

The validator crate’s derive works alongside Validatable. Convert its errors in your impl:

use validator::Validate;

#[derive(Validate, Deserialize)]
pub struct UserCreate {
    #[validate(length(min = 2, max = 100))]
    pub name: String,

    #[validate(email)]
    pub email: String,
}

impl Validatable for UserCreate {
    fn validate(&self) -> Result<(), crudcrate::validation::ValidationError> {
        Validate::validate(self).map_err(|errors| {
            let first = errors
                .field_errors()
                .into_iter()
                .next()
                .map(|(field, errs)| {
                    let message = errs
                        .first()
                        .and_then(|e| e.message.as_ref())
                        .map_or_else(|| "Invalid".to_string(), ToString::to_string);
                    (field.to_string(), message)
                })
                .unwrap_or_else(|| ("input".to_string(), "Invalid".to_string()));
            crudcrate::validation::ValidationError::new(first.0, first.1)
        })
    }
}

For reporting every error at once, run the validator derive from a hook instead and collect into ApiError::validation_failed.

Next Steps

Multi-Database Support

CRUDCrate works with PostgreSQL, MySQL, and SQLite, with database-specific optimizations.

Supported Databases

DatabaseSupport LevelFulltext SearchTypical Use
PostgreSQLFullGIN + tsvectorComplex queries, high concurrency
MySQLFullFULLTEXT indexTraditional deployments
SQLiteFullLIKE fallbackDevelopment, embedded apps

Configuration

PostgreSQL

[dependencies]
sea-orm = { version = "2.0", features = ["runtime-tokio-rustls", "sqlx-postgres"] }
let db = Database::connect("postgres://user:pass@localhost/mydb").await?;

Optimizations:

  • GIN indexes for fulltext
  • tsvector query optimization
  • Array operations for IN clauses

MySQL

[dependencies]
sea-orm = { version = "2.0", features = ["runtime-tokio-rustls", "sqlx-mysql"] }
let db = Database::connect("mysql://user:pass@localhost/mydb").await?;

Optimizations:

  • FULLTEXT indexes
  • MATCH AGAINST queries
  • Optimized LIKE patterns

SQLite

[dependencies]
sea-orm = { version = "2.0", features = ["runtime-tokio-rustls", "sqlx-sqlite"] }
// File-based
let db = Database::connect("sqlite:./data.db").await?;

// In-memory (for testing)
let db = Database::connect("sqlite::memory:").await?;

Limitations:

  • No native fulltext (uses LIKE)
  • Single-writer limitation
  • Limited concurrent access

Database-Specific Features

Fulltext Search

PostgreSQL (recommended):

-- Create tsvector column and index
ALTER TABLE articles ADD COLUMN search_vector tsvector;

CREATE INDEX idx_articles_search ON articles
    USING GIN(search_vector);

-- Update trigger
CREATE FUNCTION articles_search_trigger() RETURNS trigger AS $$
BEGIN
    NEW.search_vector :=
        setweight(to_tsvector('english', coalesce(NEW.title, '')), 'A') ||
        setweight(to_tsvector('english', coalesce(NEW.content, '')), 'B');
    RETURN NEW;
END $$ LANGUAGE plpgsql;

CREATE TRIGGER articles_search_update
    BEFORE INSERT OR UPDATE ON articles
    FOR EACH ROW
    EXECUTE FUNCTION articles_search_trigger();

MySQL:

ALTER TABLE articles
    ADD FULLTEXT INDEX idx_articles_fulltext (title, content);

SQLite:

-- For better fulltext, use FTS5
CREATE VIRTUAL TABLE articles_fts USING fts5(
    title, content, content=articles, content_rowid=id
);

JSON Types

PostgreSQL has native JSONB:

use sea_orm::prelude::Json;

pub metadata: Json,  // Uses JSONB in PostgreSQL

MySQL uses JSON type:

pub metadata: Json,  // Uses JSON in MySQL

SQLite stores as TEXT:

pub metadata: String,  // Store JSON as string, parse in application

UUID Types

PostgreSQL has native UUID:

use uuid::Uuid;

#[sea_orm(primary_key, column_type = "Uuid")]
pub id: Uuid,

MySQL/SQLite store as CHAR(36) or BINARY(16):

#[sea_orm(primary_key, column_type = "String(StringLen::N(36))")]
pub id: Uuid,

Migration Strategy

Development → Production

  1. Develop with SQLite
  2. Test with PostgreSQL locally
  3. Deploy to PostgreSQL in production
fn get_database_url() -> String {
    match std::env::var("RUST_ENV").as_deref() {
        Ok("production") => std::env::var("DATABASE_URL").unwrap(),
        _ => "sqlite::memory:".to_string(),
    }
}

Database Detection

use sea_orm::DatabaseBackend;

fn get_backend(db: &DatabaseConnection) -> DatabaseBackend {
    db.get_database_backend()
}

// Use in queries
match db.get_database_backend() {
    DatabaseBackend::Postgres => {
        // PostgreSQL-specific query
    },
    DatabaseBackend::MySql => {
        // MySQL-specific query
    },
    DatabaseBackend::Sqlite => {
        // SQLite-specific query
    },
}

Connection Strings

PostgreSQL

# Basic
postgres://user:password@host:5432/database

# With SSL
postgres://user:password@host:5432/database?sslmode=require

# With connection pool settings
postgres://user:password@host:5432/database?pool_max=10

MySQL

# Basic
mysql://user:password@host:3306/database

# With charset
mysql://user:password@host:3306/database?charset=utf8mb4

SQLite

# File
sqlite:./path/to/database.db

# In-memory
sqlite::memory:

# In-memory with shared cache
sqlite:file::memory:?cache=shared

Testing Across Databases

#[cfg(test)]
mod tests {
    use sea_orm::Database;

    async fn setup_test_db() -> DatabaseConnection {
        // Use SQLite for fast tests
        let db = Database::connect("sqlite::memory:").await.unwrap();

        // Run migrations
        Migrator::up(&db, None).await.unwrap();

        db
    }

    #[tokio::test]
    async fn test_create_article() {
        let db = setup_test_db().await;
        // Test code using SQLite
    }
}

// Integration tests with real database
#[cfg(test)]
mod integration_tests {
    #[tokio::test]
    #[ignore] // Run with: cargo test -- --ignored
    async fn test_postgres_specific() {
        let db = Database::connect(&std::env::var("TEST_DATABASE_URL").unwrap())
            .await
            .unwrap();
        // PostgreSQL-specific tests
    }
}

Performance by Database

PostgreSQL Strengths

  • Excellent for complex queries
  • Native fulltext search (GIN + tsvector)
  • JSONB operations
  • Concurrent access
  • Advanced indexing (GIN, BRIN, partial)

MySQL Strengths

  • Widely deployed
  • Good FULLTEXT support
  • Simpler replication
  • Broad hosting support

SQLite Strengths

  • Zero configuration
  • Embedded deployment
  • Fast for small datasets
  • Perfect for testing

Recommendations

For Production

PostgreSQL is recommended for:

  • Complex filtering
  • Fulltext search
  • JSON data
  • High concurrency

For Development

SQLite is recommended for:

  • Quick setup
  • Unit tests
  • Local development
  • Prototyping

Migration Path

Development: SQLite (fast, easy setup)
    ↓
Staging: PostgreSQL (production-like)
    ↓
Production: PostgreSQL (optimized)

Next Steps

How It Works

Understanding CRUDCrate’s architecture helps you use it effectively and extend it when needed.

The Big Picture

┌─────────────────────────────────────────────────────────────────┐
│                    Your Sea-ORM Entity                          │
│   #[derive(DeriveEntityModel, EntityToModels)]                  │
│   pub struct Model { ... }                                      │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                 CRUDCrate Proc Macros                           │
│  (Compile-time code generation)                                 │
│                                                                 │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐        │
│  │ Create   │  │ Update   │  │ List     │  │ Response │        │
│  │ Model    │  │ Model    │  │ Model    │  │ Model    │        │
│  └──────────┘  └──────────┘  └──────────┘  └──────────┘        │
│                                                                 │
│  ┌──────────────────────────────────────────────────────┐      │
│  │              CRUDResource Implementation              │      │
│  │  (get_one, get_all, create, update, delete)          │      │
│  └──────────────────────────────────────────────────────┘      │
│                                                                 │
│  ┌──────────────────────────────────────────────────────┐      │
│  │              Axum Router (optional)                   │      │
│  │  GET /items, POST /items, PUT /items/:id, etc.       │      │
│  └──────────────────────────────────────────────────────┘      │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                 CRUDCrate Runtime                               │
│  (Query parsing, filtering, pagination, error handling)         │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                     Sea-ORM                                     │
│  (Database abstraction, queries, migrations)                    │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│              PostgreSQL / MySQL / SQLite                        │
└─────────────────────────────────────────────────────────────────┘

Two Crates, One System

CRUDCrate consists of two crates:

1. crudcrate-derive (Procedural Macros)

This crate runs at compile time. It:

  • Parses your entity struct and #[crudcrate(...)] attributes
  • Generates model structs (Create, Update, List, Response)
  • Implements the CRUDResource trait
  • Optionally generates an Axum router

The generated code is type-safe and verified by the Rust compiler.

2. crudcrate (Runtime Library)

This crate runs at runtime. It provides:

  • CRUDResource trait definition
  • Query parameter parsing (FilterOptions)
  • SQL condition building (filtering, sorting)
  • Pagination utilities
  • Error handling (ApiError)

Code Generation Flow

When you write:

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

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

CRUDCrate generates (conceptually):

// 1. Response model (your struct name without "Model")
pub struct Item {
    pub id: i32,
    pub name: String,
}

// 2. Create model (excludes `id`)
pub struct ItemCreate {
    pub name: String,
}

// 3. Update model (all optional)
pub struct ItemUpdate {
    pub name: Option<String>,
}

// 4. List model
pub struct ItemList {
    pub id: i32,
    pub name: String,
}

// 5. CRUDResource implementation
impl CRUDResource for Item {
    type EntityType = Entity;
    type CreateModel = ItemCreate;
    type UpdateModel = ItemUpdate;
    type ListModel = ItemList;

    async fn get_one<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(db: &C, id: i32) -> Result<Self, ApiError> {
        // Generated query logic
    }

    async fn get_all<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        db: &C,
        condition: Condition,
        order: (Column, Order),
        offset: u64,
        limit: u64,
    ) -> Result<Vec<Self::ListModel>, ApiError> {
        // Generated query logic with filtering
    }

    async fn create<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(db: &C, data: ItemCreate) -> Result<Self, ApiError> {
        // Generated insert logic
    }

    async fn update<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(db: &C, id: i32, data: ItemUpdate) -> Result<Self, ApiError> {
        // Generated update logic
    }

    async fn delete<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(db: &C, id: i32) -> Result<(), ApiError> {
        // Generated delete logic
    }
}

// 6. Router (if generate_router enabled)
pub fn item_router() -> Router {
    Router::new()
        .route("/items", get(list_handler).post(create_handler))
        .route("/items/:id", get(get_handler).put(update_handler).delete(delete_handler))
}

Request Flow

Here’s what happens when a request hits your API:

HTTP Request: GET /items?filter={"name":"test"}&sort=["id","DESC"]&range=[0,9]
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  1. Axum Router                                                 │
│     - Matches route /items                                      │
│     - Extracts query parameters                                 │
│     - Calls list_handler                                        │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  2. Query Parsing (CRUDCrate Runtime)                           │
│     - FilterOptions::from_query_params()                        │
│     - Parses filter JSON: {"name": "test"}                      │
│     - Parses sort: ["id", "DESC"]                               │
│     - Parses range: [0, 9] → offset=0, limit=10                │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  3. Condition Building                                          │
│     - apply_filters() converts JSON to Condition                │
│     - Validates "name" is marked filterable                     │
│     - Builds: Column::Name.eq("test")                          │
│     - SQL injection prevention applied                          │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  4. CRUDResource::get_all()                                     │
│     - Entity::find()                                            │
│     - .filter(condition)                                        │
│     - .order_by(Column::Id, Order::Desc)                       │
│     - .offset(0).limit(10)                                      │
│     - .all(db).await                                            │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  5. Sea-ORM                                                     │
│     - Builds SQL: SELECT * FROM items                           │
│                   WHERE name = $1                               │
│                   ORDER BY id DESC                              │
│                   LIMIT 10 OFFSET 0                             │
│     - Executes against database                                 │
│     - Returns Vec<Model>                                        │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  6. Response Building                                           │
│     - Convert Vec<Model> to Vec<ItemList>                       │
│     - Add Content-Range header                                  │
│     - Serialize to JSON                                         │
│     - Return HTTP 200 with body                                 │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
HTTP Response: 200 OK
Content-Range: items 0-9/42
[{"id": 1, "name": "test"}, ...]

Compile-Time vs Runtime

AspectCompile-Time (Macros)Runtime (Library)
Whencargo buildRequest handling
WhatCode generationQuery execution
ErrorsCompilation errorsHTTP error responses
CostBuild timeRequest latency
ExamplesMissing attributes, type mismatchesInvalid filters, DB errors

Extension Points

CRUDCrate provides hooks at multiple levels:

1. Attribute Configuration

Configure behavior at compile time:

#[crudcrate(filterable, exclude(list))]
pub field: String,

2. CRUDOperations Trait

Add business logic:

impl CRUDOperations for MyOps {
    async fn before_create(&self, data: &mut CreateModel) -> Result<(), ApiError> {
        // Validation, transformation
    }
}

3. Lifecycle Hooks

Per-operation customization:

#[crudcrate(
    create::one::pre = validate_fn,
    create::one::post = notify_fn,
)]

4. Full Handler Override

Complete control when needed:

async fn custom_create(
    Extension(db): Extension<DatabaseConnection>,
    Json(data): Json<ItemCreate>,
) -> Result<Json<Item>, ApiError> {
    // Your custom logic
}

Performance Characteristics

  • Zero runtime overhead for generated code (no reflection)
  • Compile-time type checking catches errors early
  • Database-native features used when available (indexes, fulltext)
  • Parameterized queries prevent SQL injection without string escaping
  • Pagination limits prevent DoS attacks (max 1000 items)

Next Steps

The Entity Model

Your Sea-ORM entity is the foundation. CRUDCrate extends it with attributes to control API behavior.

Anatomy of a CRUDCrate Entity

use crudcrate::EntityToModels;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(
    Clone,              // Required by Sea-ORM
    Debug,              // Useful for debugging
    PartialEq,          // Optional, for comparisons
    DeriveEntityModel,  // Sea-ORM: generates Entity, Column, etc.
    Serialize,          // For JSON responses
    Deserialize,        // For JSON requests
    EntityToModels,     // CRUDCrate: generates CRUD infrastructure
)]
#[crudcrate(generate_router)]       // CRUDCrate struct-level attribute
#[sea_orm(table_name = "products")] // Sea-ORM table mapping
pub struct Model {
    // Primary key - required
    #[sea_orm(primary_key, auto_increment = false)]
    #[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
    pub id: Uuid,

    // Regular field with filtering
    #[crudcrate(filterable, sortable)]
    pub name: String,

    // Optional field
    pub description: Option<String>,

    // Excluded from responses
    #[crudcrate(exclude(one, list))]
    pub internal_notes: String,

    // Auto-managed timestamps
    #[crudcrate(exclude(create, update), on_create = chrono::Utc::now())]
    pub created_at: DateTimeUtc,
}

// Required by Sea-ORM
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

impl ActiveModelBehavior for ActiveModel {}

Required Derives

DerivePurposeRequired?
CloneSea-ORM requirementYes
DebugDebugging outputRecommended
DeriveEntityModelSea-ORM entity generationYes
SerializeJSON response serializationYes
DeserializeJSON request deserializationYes
EntityToModelsCRUDCrate generationYes

Naming Conventions

CRUDCrate follows these naming conventions:

Your CodeGenerated
pub struct ModelProduct (from table name)
Table: productsResponse: Product
Create: ProductCreate
Update: ProductUpdate
List: ProductList
Router: product_router()

Custom Naming

Override with api_struct:

#[crudcrate(api_struct = "Item")]
pub struct Model {
    // ...
}

// Generates: Item, ItemCreate, ItemUpdate, ItemList

Field Types

CRUDCrate handles common Rust and Sea-ORM types:

Scalar Types

pub id: i32,           // Integers
pub id: i64,
pub price: f64,        // Floats
pub name: String,      // Strings
pub active: bool,      // Booleans
pub id: Uuid,          // UUIDs

Optional Types

pub description: Option<String>,  // Nullable in DB
pub parent_id: Option<i32>,       // Optional foreign key

Date/Time Types

use sea_orm::prelude::*;

pub created_at: DateTime,           // Without timezone
pub updated_at: DateTimeUtc,        // With UTC timezone
pub deleted_at: Option<DateTimeUtc>, // Soft delete

Enums

#[derive(EnumIter, DeriveActiveEnum, Serialize, Deserialize)]
#[sea_orm(rs_type = "String", db_type = "String(StringLen::N(20))")]
pub enum Status {
    #[sea_orm(string_value = "pending")]
    Pending,
    #[sea_orm(string_value = "active")]
    Active,
    #[sea_orm(string_value = "archived")]
    Archived,
}

// In your entity:
#[crudcrate(filterable)]
pub status: Status,

JSON Types

use sea_orm::prelude::Json;

pub metadata: Json,                 // Arbitrary JSON
pub tags: Vec<String>,              // JSON array (with proper DB type)

Primary Key Configuration

Every entity needs exactly one primary key:

// Integer auto-increment
#[sea_orm(primary_key)]
#[crudcrate(primary_key, exclude(create, update))]
pub id: i32,

// UUID (recommended)
#[sea_orm(primary_key, auto_increment = false)]
#[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
pub id: Uuid,

// String ID
#[sea_orm(primary_key, auto_increment = false)]
#[crudcrate(primary_key)]
pub slug: String,  // Client provides the ID

Relationships

Define relationships for join loading:

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

    #[sea_orm(
        belongs_to = "super::user::Entity",
        from = "Column::AuthorId",
        to = "super::user::Column::Id"
    )]
    Author,
}

// Implement Related trait
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()
    }
}

// In your Model struct, add join fields
#[sea_orm(ignore)]
#[crudcrate(non_db_attr, join(one, all))]
pub comments: Vec<Comment>,

#[sea_orm(ignore)]
#[crudcrate(non_db_attr, join(one))]
pub author: Option<User>,

Complete Example

use crudcrate::EntityToModels;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use uuid::Uuid;

#[derive(Clone, Debug, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)]
#[sea_orm(rs_type = "String", db_type = "String(StringLen::N(20))")]
pub enum PostStatus {
    #[sea_orm(string_value = "draft")]
    Draft,
    #[sea_orm(string_value = "published")]
    Published,
    #[sea_orm(string_value = "archived")]
    Archived,
}

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize, EntityToModels)]
#[crudcrate(generate_router, name_singular = "post", name_plural = "posts")]
#[sea_orm(table_name = "posts")]
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    #[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
    pub id: Uuid,

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

    #[crudcrate(fulltext)]
    pub content: String,

    pub excerpt: Option<String>,

    #[crudcrate(filterable)]
    pub status: PostStatus,

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

    #[crudcrate(sortable, filterable)]
    pub published_at: Option<DateTimeUtc>,

    #[crudcrate(sortable, exclude(create, update), on_create = chrono::Utc::now())]
    pub created_at: DateTimeUtc,

    #[crudcrate(exclude(create, update), on_create = chrono::Utc::now(), on_update = chrono::Utc::now())]
    pub updated_at: DateTimeUtc,

    // Relationships
    #[sea_orm(ignore)]
    #[crudcrate(non_db_attr, join(one, all, depth = 1))]
    pub author: Option<super::user::User>,

    #[sea_orm(ignore)]
    #[crudcrate(non_db_attr, join(one))]
    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()
    }
}

impl ActiveModelBehavior for ActiveModel {}

Next Steps

Attribute System

CRUDCrate uses attributes to configure code generation. This guide covers all available attributes.

Attribute Syntax

Attributes use the #[crudcrate(...)] syntax:

// Struct-level
#[crudcrate(generate_router, api_struct = "Item")]
pub struct Model {
    // Field-level
    #[crudcrate(primary_key, filterable, sortable)]
    pub id: i32,
}

Struct-Level Attributes

Applied to the struct definition:

generate_router

Generates an Axum router function:

#[crudcrate(generate_router)]
pub struct Model { }

// Generates:
pub fn model_router() -> Router { }

routes

Names the route families router() mounts. Omit it for every route, which is the default:

#[crudcrate(generate_router, routes(read, update))]
pub struct Model { }

// Mounts GET /, GET /{id}, PATCH /{id} and PATCH /batch.
// POST and DELETE are not routes at all, so the OpenAPI document does not advertise them.

The families are create, read, update and delete, each covering its single-row and batch routes. Use it for a table whose rows are a projection of something else: rows a migration or a registry inserts have no legitimate create or delete, and a handler that refuses one still advertises the operation.

api_struct

Override the generated struct name:

#[crudcrate(api_struct = "Product")]
pub struct Model { }

// Generates: Product, ProductCreate, ProductUpdate, ProductList
// Instead of: Model, ModelCreate, etc.

name_singular / name_plural

Override resource names (for routing and Content-Range headers):

#[crudcrate(name_singular = "person", name_plural = "people")]
pub struct Model { }

// Routes: /people, /people/:id
// Content-Range: people 0-9/100

operations

Specify a custom CRUDOperations implementation:

#[crudcrate(operations = MyOperations)]
pub struct Model { }

// MyOperations must implement CRUDOperations

description

Add OpenAPI description:

#[crudcrate(description = "User accounts for the application")]
pub struct Model { }

fulltext_language

Set fulltext search language (PostgreSQL):

#[crudcrate(fulltext_language = "spanish")]
pub struct Model { }

// Uses Spanish stemming for fulltext search

Field-Level Attributes

Applied to individual fields:

primary_key

Marks the primary key field:

#[crudcrate(primary_key)]
pub id: i32,

// Required: exactly one field must have this

exclude(...)

Exclude field from specific models:

// Exclude from single model
#[crudcrate(exclude(create))]
pub id: i32,

// Exclude from multiple models
#[crudcrate(exclude(one, list))]
pub password: String,

// Available targets:
// - one: Response model (GET /items/:id)
// - create: Create model (POST /items)
// - update: Update model (PUT /items/:id)
// - list: List model (GET /items)

filterable

Enable filtering on this field:

#[crudcrate(filterable)]
pub status: String,

// Allows: GET /items?filter={"status":"active"}
// Allows: GET /items?status_eq=active
// Allows: GET /items?status_ne=inactive

sortable

Enable sorting on this field:

#[crudcrate(sortable)]
pub created_at: DateTimeUtc,

// Allows: GET /items?sort=["created_at","DESC"]
// Allows: GET /items?sort=created_at&order=desc

fulltext

Include in fulltext search:

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

#[crudcrate(fulltext)]
pub description: String,

// Allows: GET /items?q=search terms
// Searches across all fulltext fields

on_create

Default value when creating:

#[crudcrate(on_create = Uuid::new_v4())]
pub id: Uuid,

#[crudcrate(on_create = chrono::Utc::now())]
pub created_at: DateTimeUtc,

#[crudcrate(on_create = "pending".to_string())]
pub status: String,

// Expression is evaluated at insert time

on_update

Default value when updating:

#[crudcrate(on_update = chrono::Utc::now())]
pub updated_at: DateTimeUtc,

// Expression is evaluated on every update

non_db_attr

Marks non-database fields (for relationships):

#[sea_orm(ignore)]  // Sea-ORM: ignore in queries
#[crudcrate(non_db_attr)]  // CRUDCrate: not a DB column
pub related_items: Vec<Item>,

join(...)

Configure relationship loading:

// Load in get_one only
#[crudcrate(non_db_attr, join(one))]
pub comments: Vec<Comment>,

// Load in both get_one and get_all
#[crudcrate(non_db_attr, join(one, all))]
pub author: Option<User>,

// Limit recursion depth
#[crudcrate(non_db_attr, join(one, all, depth = 2))]
pub nested: Vec<Nested>,

// Full syntax
join(
    one,          // Include in get_one response
    all,          // Include in get_all response
    depth = 3,    // Max recursion depth (default: unlimited up to 5)
)

Lifecycle Hooks

Hook into CRUD operations:

#[crudcrate(
    // Pre-operation hooks (before DB operation)
    create::one::pre = validate_create,
    update::one::pre = validate_update,
    delete::one::pre = check_delete_permission,

    // Post-operation hooks (after DB operation)
    create::one::post = send_welcome_email,
    update::one::post = invalidate_cache,
    delete::one::post = cleanup_related,

    // Full body replacement
    create::one::body = custom_create_handler,
)]
pub struct Model { }

// Hook function signatures:
async fn validate_create<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(db: &C, data: &mut ModelCreate) -> Result<(), ApiError>;
async fn send_welcome_email<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(db: &C, created: &Model) -> Result<(), ApiError>;
async fn custom_create_handler<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(db: &C, data: ModelCreate) -> Result<Model, ApiError>;

Combining Attributes

Multiple attributes on one field:

#[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
pub id: Uuid,

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

#[crudcrate(non_db_attr, join(one, all, depth = 1))]
pub author: Option<User>,

Complete Example

use crudcrate::EntityToModels;
use sea_orm::entity::prelude::*;

#[derive(Clone, Debug, DeriveEntityModel, Serialize, Deserialize, EntityToModels)]
#[crudcrate(
    generate_router,
    api_struct = "Article",
    name_singular = "article",
    name_plural = "articles",
    operations = ArticleOperations,
    create::one::post = notify_subscribers,
)]
#[sea_orm(table_name = "articles")]
pub struct Model {
    // Primary key with auto-generation
    #[sea_orm(primary_key, auto_increment = false)]
    #[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
    pub id: Uuid,

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

    // Searchable content, excluded from lists
    #[crudcrate(fulltext, exclude(list))]
    pub content: String,

    // Short summary for lists
    pub summary: Option<String>,

    // Filterable status enum
    #[crudcrate(filterable)]
    pub status: ArticleStatus,

    // Author relationship
    #[crudcrate(filterable)]
    pub author_id: Uuid,

    // Sortable publication date
    #[crudcrate(sortable, filterable)]
    pub published_at: Option<DateTimeUtc>,

    // Auto-managed timestamps
    #[crudcrate(sortable, exclude(create, update), on_create = chrono::Utc::now())]
    pub created_at: DateTimeUtc,

    #[crudcrate(exclude(create, update), on_create = chrono::Utc::now(), on_update = chrono::Utc::now())]
    pub updated_at: DateTimeUtc,

    // Loaded relationships
    #[sea_orm(ignore)]
    #[crudcrate(non_db_attr, join(one))]
    pub author: Option<User>,

    #[sea_orm(ignore)]
    #[crudcrate(non_db_attr, join(one))]
    pub comments: Vec<Comment>,
}

Attribute Reference Table

AttributeLevelDescription
generate_routerStructGenerate Axum router
routes(...)StructThe route families the router mounts
api_structStructCustom struct name
name_singularStructSingular resource name
name_pluralStructPlural resource name
operationsStructCustom operations trait
descriptionStructOpenAPI description
fulltext_languageStructFulltext search language
primary_keyFieldMark as primary key
exclude(...)FieldExclude from models
filterableFieldEnable filtering
sortableFieldEnable sorting
fulltextFieldInclude in fulltext search
on_createFieldDefault value on create
on_updateFieldDefault value on update
non_db_attrFieldNon-database field
join(...)FieldRelationship loading config

Next Steps

Generated Models

CRUDCrate generates four model types from your entity. Each serves a specific purpose in your API.

Overview

From this entity:

#[derive(EntityToModels)]
#[sea_orm(table_name = "users")]
pub struct Model {
    #[crudcrate(primary_key, exclude(create, update))]
    pub id: i32,

    #[crudcrate(filterable)]
    pub email: String,

    #[crudcrate(exclude(one, list))]
    pub password_hash: String,

    pub display_name: Option<String>,

    #[crudcrate(exclude(create, update), on_create = chrono::Utc::now())]
    pub created_at: DateTimeUtc,
}

CRUDCrate generates:

ModelPurposeUsed In
UserFull responseGET /users/:id
UserCreateCreate requestPOST /users
UserUpdateUpdate requestPUT /users/:id
UserListList responseGET /users

Response Model (User)

The main response model, returned from get_one:

// Generated
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct User {
    pub id: i32,
    pub email: String,
    // password_hash excluded via exclude(one)
    pub display_name: Option<String>,
    pub created_at: DateTimeUtc,
}

Characteristics:

  • Includes all fields except those with exclude(one)
  • Used for single-item responses
  • Can include loaded relationships

Create Model (UserCreate)

Used for POST requests:

// Generated
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UserCreate {
    // id excluded via exclude(create)
    pub email: String,
    pub password_hash: String,  // NOT excluded from create
    pub display_name: Option<String>,
    // created_at excluded via exclude(create)
}

Characteristics:

  • Excludes primary keys (usually auto-generated)
  • Excludes timestamp fields
  • Includes fields the client should provide

Update Model (UserUpdate)

Used for PUT requests:

// Generated
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UserUpdate {
    // id excluded via exclude(update)
    pub email: Option<String>,
    pub password_hash: Option<String>,
    pub display_name: Option<Option<String>>,  // Double Option for nullable fields
    // created_at excluded via exclude(update)
}

Characteristics:

  • All fields are Option<T> for partial updates
  • Nullable fields become Option<Option<T>>
  • Only provided fields are updated

Double Option Explained

For nullable database fields:

// In entity
pub bio: Option<String>,

// In UserUpdate
pub bio: Option<Option<String>>,

// Usage:
// None = don't change
// Some(None) = set to NULL
// Some(Some("text")) = set to "text"
// Don't change bio
{}

// Set bio to null
{"bio": null}

// Set bio to value
{"bio": "Hello world"}

List Model (UserList)

Used for GET collection responses:

// Generated
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UserList {
    pub id: i32,
    pub email: String,
    // password_hash excluded via exclude(list)
    pub display_name: Option<String>,
    pub created_at: DateTimeUtc,
}

Characteristics:

  • Often identical to Response model
  • Can exclude expensive fields (e.g., large text, relationships)
  • Optimized for list performance
  • Keeps each field’s serde, schema and doc attributes, so a field renamed or skipped when null serializes the same way it does in the Response model

Field Exclusion Matrix

Control which fields appear in which models:

Fieldexclude(one)exclude(create)exclude(update)exclude(list)
idIn responseNot in createNot in updateIn list
emailIn responseIn createIn updateIn list
passwordNot in responseIn createIn updateNot in list
created_atIn responseNot in createNot in updateIn list

Example:

// Never expose password
#[crudcrate(exclude(one, list))]
pub password_hash: String,

// Client can't set ID or timestamps
#[crudcrate(primary_key, exclude(create, update))]
pub id: Uuid,

#[crudcrate(exclude(create, update))]
pub created_at: DateTimeUtc,

// Exclude expensive content from lists
#[crudcrate(exclude(list))]
pub full_content: String,

Model Traits

All generated models implement:

// Serialization
impl Serialize for User { }
impl Deserialize for User { }

// Cloning
impl Clone for User { }

// Debug output
impl Debug for User { }

Additionally:

// Response model implements From<Sea-ORM Model>
impl From<Model> for User { }

// Create model implements Into<ActiveModel>
impl From<UserCreate> for ActiveModel { }

// Update model implements merge
impl MergeIntoActiveModel<ActiveModel> for UserUpdate { }

Conversions

Entity to Response

let db_model: Model = Entity::find_by_id(1).one(db).await?;
let response: User = db_model.into();

Create to ActiveModel

let create_data: UserCreate = /* from request */;
let active_model: ActiveModel = create_data.into();
let result = active_model.insert(db).await?;

Update Merge

let update_data: UserUpdate = /* from request */;
let mut active_model: ActiveModel = existing.into();

// Only updates fields that were provided
update_data.merge_into(&mut active_model);

let result = active_model.update(db).await?;

Customization

Custom Model Names

#[crudcrate(api_struct = "Item")]
pub struct Model { }

// Generates: Item, ItemCreate, ItemUpdate, ItemList

Additional Derives

The generated models use standard derives. For additional traits, implement them manually:

// In your code
impl Default for UserCreate {
    fn default() -> Self {
        Self {
            email: String::new(),
            password_hash: String::new(),
            display_name: None,
        }
    }
}

Validation

Add validation in CRUDOperations:

impl CRUDOperations for UserOps {
    async fn before_create(&self, data: &mut UserCreate) -> Result<(), ApiError> {
        if !data.email.contains('@') {
            return Err(ApiError::ValidationFailed(vec![
                ValidationError::new("email", "Invalid email format")
            ]));
        }
        Ok(())
    }
}

Relationships in Models

Join fields appear in response models:

// Entity definition
#[sea_orm(ignore)]
#[crudcrate(non_db_attr, join(one, all))]
pub posts: Vec<Post>,

// Generated in User response model
pub struct User {
    pub id: i32,
    pub email: String,
    pub posts: Vec<Post>,  // Loaded automatically
}

Control when relationships load:

  • join(one) - Load in get_one only
  • join(all) - Load in get_all too (can be expensive)
  • join(one, all) - Load in both

Next Steps

The CRUDResource Trait

CRUDResource is the core trait that powers all CRUD operations. CRUDCrate generates its implementation, but understanding it helps you customize behavior.

Trait Definition

pub trait CRUDResource: Sized + Send + Sync {
    /// The Sea-ORM entity type
    type EntityType: EntityTrait;

    /// Model for creating new records
    type CreateModel: DeserializeOwned + Send + Sync;

    /// Model for updating existing records
    type UpdateModel: DeserializeOwned + Send + Sync;

    /// Model for list responses (can differ from Self)
    type ListModel: Serialize + Send + Sync;

    /// The primary key type
    type PrimaryKey: Send + Sync;

    /// Get a single record by ID
    async fn get_one<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        db: &C,
        id: Self::PrimaryKey,
    ) -> Result<Self, ApiError>;

    /// Get all records with filtering, sorting, and pagination
    async fn get_all<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        db: &C,
        condition: Condition,
        order: (Self::EntityType::Column, Order),
        offset: u64,
        limit: u64,
    ) -> Result<Vec<Self::ListModel>, ApiError>;

    /// Create a new record
    async fn create<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        db: &C,
        data: Self::CreateModel,
    ) -> Result<Self, ApiError>;

    /// Update an existing record
    async fn update<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        db: &C,
        id: Self::PrimaryKey,
        data: Self::UpdateModel,
    ) -> Result<Self, ApiError>;

    /// Delete a record
    async fn delete<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        db: &C,
        id: Self::PrimaryKey,
    ) -> Result<(), ApiError>;

    /// Delete multiple records
    async fn delete_many<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        db: &C,
        ids: Vec<Self::PrimaryKey>,
    ) -> Result<u64, ApiError>;

    /// Get total count matching condition
    async fn total_count<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        db: &C,
        condition: &Condition,
    ) -> u64;
}

Generated Implementation

For this entity:

#[derive(EntityToModels)]
#[sea_orm(table_name = "tasks")]
pub struct Model {
    #[crudcrate(primary_key)]
    pub id: i32,
    pub title: String,
}

CRUDCrate generates:

impl CRUDResource for Task {
    type EntityType = Entity;
    type CreateModel = TaskCreate;
    type UpdateModel = TaskUpdate;
    type ListModel = TaskList;
    type PrimaryKey = i32;

    async fn get_one<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        db: &C,
        id: i32,
    ) -> Result<Self, ApiError> {
        let model = Entity::find_by_id(id)
            .one(db)
            .await
            .map_err(ApiError::from)?
            .ok_or(ApiError::NotFound)?;

        Ok(model.into())
    }

    async fn get_all<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        db: &C,
        condition: Condition,
        order: (Column, Order),
        offset: u64,
        limit: u64,
    ) -> Result<Vec<TaskList>, ApiError> {
        let models = Entity::find()
            .filter(condition)
            .order_by(order.0, order.1)
            .offset(offset)
            .limit(limit)
            .all(db)
            .await
            .map_err(ApiError::from)?;

        Ok(models.into_iter().map(|m| m.into()).collect())
    }

    async fn create<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        db: &C,
        data: TaskCreate,
    ) -> Result<Self, ApiError> {
        let active_model: ActiveModel = data.into();
        let model = active_model.insert(db).await.map_err(ApiError::from)?;
        Ok(model.into())
    }

    async fn update<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        db: &C,
        id: i32,
        data: TaskUpdate,
    ) -> Result<Self, ApiError> {
        let existing = Entity::find_by_id(id)
            .one(db)
            .await
            .map_err(ApiError::from)?
            .ok_or(ApiError::NotFound)?;

        let mut active_model: ActiveModel = existing.into();
        data.merge_into(&mut active_model);

        let model = active_model.update(db).await.map_err(ApiError::from)?;
        Ok(model.into())
    }

    async fn delete<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        db: &C,
        id: i32,
    ) -> Result<(), ApiError> {
        let result = Entity::delete_by_id(id)
            .exec(db)
            .await
            .map_err(ApiError::from)?;

        if result.rows_affected == 0 {
            return Err(ApiError::NotFound);
        }

        Ok(())
    }

    async fn delete_many<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        db: &C,
        ids: Vec<i32>,
    ) -> Result<u64, ApiError> {
        // Safety limit: max 100 items per request
        if ids.len() > 100 {
            return Err(ApiError::BadRequest(
                "Cannot delete more than 100 items at once".into()
            ));
        }

        let result = Entity::delete_many()
            .filter(Column::Id.is_in(ids))
            .exec(db)
            .await
            .map_err(ApiError::from)?;

        Ok(result.rows_affected)
    }

    async fn total_count<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        db: &C,
        condition: &Condition,
    ) -> u64 {
        Entity::find()
            .filter(condition.clone())
            .count(db)
            .await
            .unwrap_or(0)
    }
}

Using CRUDResource Directly

You can call trait methods directly:

use crudcrate::CRUDResource;

// Get one
let task = Task::get_one(&db, 42).await?;

// Get all with filtering
let condition = Condition::all()
    .add(Column::Status.eq("active"));
let tasks = Task::get_all(&db, condition, (Column::Id, Order::Asc), 0, 10).await?;

// Create
let new_task = Task::create(&db, TaskCreate {
    title: "New task".into(),
}).await?;

// Update
let updated = Task::update(&db, 42, TaskUpdate {
    title: Some("Updated title".into()),
}).await?;

// Delete
Task::delete(&db, 42).await?;

// Count
let count = Task::total_count(&db, &Condition::all()).await;

Relationship Loading

When entities have relationships, get_one loads them:

// Entity with relationship
#[sea_orm(ignore)]
#[crudcrate(non_db_attr, join(one))]
pub comments: Vec<Comment>,

// Generated get_one includes relationship loading
async fn get_one<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(db: &C, id: i32) -> Result<Self, ApiError> {
    let model = Entity::find_by_id(id)
        .one(db)
        .await?
        .ok_or(ApiError::NotFound)?;

    // Load related comments
    let comments = model
        .find_related(comment::Entity)
        .all(db)
        .await?
        .into_iter()
        .map(|c| c.into())
        .collect();

    Ok(Task {
        id: model.id,
        title: model.title,
        comments,  // Loaded!
    })
}

Custom Operations with CRUDOperations

Extend behavior without reimplementing the trait:

#[derive(EntityToModels)]
#[crudcrate(generate_router, operations = TaskOperations)]
pub struct Model { /* ... */ }

pub struct TaskOperations;

impl CRUDOperations for TaskOperations {
    type Resource = Task;

    /// Called before create
    async fn before_create<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        &self,
        db: &C,
        data: &mut TaskCreate,
    ) -> Result<(), ApiError> {
        // Validate or transform
        data.title = data.title.trim().to_string();
        Ok(())
    }

    /// Called after create
    async fn after_create<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        &self,
        db: &C,
        created: &Task,
    ) -> Result<(), ApiError> {
        // Send notification, update cache, etc.
        Ok(())
    }

    /// Called before delete
    async fn before_delete<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        &self,
        db: &C,
        id: i32,
    ) -> Result<(), ApiError> {
        // Check permissions, cascade deletes, etc.
        Ok(())
    }
}

Handler Integration

The generated router uses CRUDResource methods:

// Generated handler (simplified)
async fn get_handler(
    Path(id): Path<i32>,
    Extension(db): Extension<DatabaseConnection>,
) -> Result<Json<Task>, ApiError> {
    let item = Task::get_one(&db, id).await?;
    Ok(Json(item))
}

async fn list_handler(
    Query(params): Query<FilterOptions>,
    Extension(db): Extension<DatabaseConnection>,
) -> Result<(HeaderMap, Json<Vec<TaskList>>), ApiError> {
    let condition = apply_filters::<Entity>(&params)?;
    let (offset, limit) = parse_pagination(&params);
    let order = parse_sorting::<Entity>(&params);

    let items = Task::get_all(&db, condition, order, offset, limit).await?;
    let total = Task::total_count(&db, &condition).await;

    let headers = calculate_content_range("tasks", offset, items.len(), total);

    Ok((headers, Json(items)))
}

Type Associations

The trait’s associated types connect everything:

impl CRUDResource for Task {
    // Links to Sea-ORM entity for database operations
    type EntityType = Entity;

    // Request model for POST /tasks
    type CreateModel = TaskCreate;

    // Request model for PUT /tasks/:id
    type UpdateModel = TaskUpdate;

    // Response model for GET /tasks
    type ListModel = TaskList;

    // Type of the primary key
    type PrimaryKey = i32;
}

This enables:

  • Type-safe column references (Entity::Column)
  • Correct serialization/deserialization
  • Compile-time verification of operations

Error Handling

All methods return Result<T, ApiError>:

async fn get_one<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(db: &C, id: i32) -> Result<Self, ApiError> {
    Entity::find_by_id(id)
        .one(db)
        .await
        .map_err(ApiError::from)?  // Database error → 500
        .ok_or(ApiError::NotFound)  // Not found → 404
}

Database errors are logged internally but return sanitized messages to clients.

Next Steps

Struct Attributes Reference

Complete reference for #[crudcrate(...)] attributes on structs.

Syntax

#[derive(EntityToModels)]
#[crudcrate(attribute1, attribute2 = value, ...)]
pub struct Model { }

Attributes

generate_router

Generates an Axum router function for all CRUD operations.

#[crudcrate(generate_router)]
pub struct Model { }

// Generates:
pub fn model_router() -> Router { }

Type: Flag (no value)


routes

Names the route families router() mounts, out of create, read, update and delete. Each family covers its single-row and batch routes. An operation left out is not a route at all, so the OpenAPI document does not advertise it.

#[crudcrate(generate_router, routes(read, update))]
pub struct Model { }

// Mounts GET /, GET /{id}, PATCH /{id} and PATCH /batch.

Omit the attribute for every route, which is the default. Use it for a table whose rows are a projection of something else, where a create or delete has no legitimate meaning.

Type: List of identifiers


upsert_key

Names the alternate unique key a source system registers rows under, in the order the index declares them. Enables crudcrate::upsert.

#[crudcrate(upsert_key(source_system, source_key))]
pub struct Model { }

Type: List of field names


upsert_where

The predicate the upsert_key is unique under, where the index backing it is partial. A SeaORM expression, the same value OnConflict::target_cond_where takes; the registration’s find adds it, so a row the index does not cover is never resolved as the key’s row. Without it the key is read as unique over every row. Naming it without an upsert_key is a compile error.

#[crudcrate(upsert_key(site_id, parameter_id), upsert_where = Column::ResolvedAt.is_null())]
pub struct Model { }

Type: Expression


api_struct

Override the name of generated API structs.

#[crudcrate(api_struct = "Product")]
pub struct Model { }

// Generates: Product, ProductCreate, ProductUpdate, ProductList

Type: String literal Default: Derives from table name (e.g., “products” → “Product”)


name_singular

Override the singular resource name for routing and headers.

#[crudcrate(name_singular = "person")]
pub struct Model { }

// Used in: Content-Range header
// Content-Range: person 0-9/100

Type: String literal Default: Lowercase struct name


name_plural

Override the plural resource name for routing.

#[crudcrate(name_plural = "people")]
pub struct Model { }

// Routes: GET /people, POST /people, etc.

Type: String literal Default: {name_singular}s


operations

Specify a custom CRUDOperations implementation.

#[crudcrate(operations = MyOperations)]
pub struct Model { }

// MyOperations must implement CRUDOperations trait
pub struct MyOperations;

impl CRUDOperations for MyOperations {
    type Resource = Model;
    // ...
}

Type: Type path Default: DefaultCRUDOperations<Self>


description

Add description for OpenAPI documentation.

#[crudcrate(description = "Blog articles with comments")]
pub struct Model { }

Type: String literal Default: None


fulltext_language

Set language for PostgreSQL fulltext search.

#[crudcrate(fulltext_language = "spanish")]
pub struct Model { }

Type: String literal Default: "english" Options: "english", "spanish", "german", "french", "simple", etc.


batch_limit

Set the maximum number of items for batch create/update/delete operations.

#[crudcrate(batch_limit = 500)]
pub struct Model { }

Type: Integer Default: 100 Runtime override: Implement fn batch_limit() -> usize on your CRUDResource impl for dynamic values (env vars, config).


max_page_size

Set the maximum items per page for pagination.

#[crudcrate(max_page_size = 500)]
pub struct Model { }

Type: Integer Default: 1000 Runtime override: Implement fn max_page_size() -> u64 on your CRUDResource impl for dynamic values.


max_child_rows

Caps the number of child rows one join field may load in a single request. Exceeding it returns 413 Payload Too Large instead of an unbounded response.

#[crudcrate(max_child_rows = 500)]   // default unlimited

Type: Integer Runtime override: Set max_child_rows_per_relation on the SecurityProfile returned by fn security_profile() on your CRUDResource impl.


deny_unknown_fields

Reject a create or update payload that carries a key the input model does not accept, instead of ignoring it.

#[crudcrate(deny_unknown_fields)]
pub struct Model {
    #[crudcrate(exclude(create))]
    pub is_public: Option<bool>,
}

// POST { "name": "x", "is_public": true } -> 422, is_public is not a create field

By default serde ignores keys with no matching field, so a value excluded from the Create or Update model is dropped and the caller gets a 201/200 reporting the stored value rather than the one it sent. The flag adds #[serde(deny_unknown_fields)] to the generated <Name>Create and <Name>Update structs, turning that silent drop into a rejection.

Use it when clients should learn that a field is server-controlled. Leave it off when clients round-trip a whole record back into an update, since read-only fields like created_at would then be rejected too.

Type: Flag (no value) Default: off (unknown keys ignored)


Lifecycle Hook Attributes

create::one::pre

Function called before create operation.

#[crudcrate(create::one::pre = validate_create)]

async fn validate_create<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    data: &mut ModelCreate,
) -> Result<(), ApiError> { }

create::one::post

Function called after successful create.

#[crudcrate(create::one::post = notify_created)]

async fn notify_created<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    created: &Model,
) -> Result<(), ApiError> { }

create::one::body

Replace entire create logic.

#[crudcrate(create::one::body = custom_create)]

async fn custom_create<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    data: ModelCreate,
) -> Result<Model, ApiError> { }

update::one::pre

Function called before update operation.

#[crudcrate(update::one::pre = check_update_permission)]

async fn check_update_permission<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    id: PrimaryKeyType,
    data: &mut ModelUpdate,
) -> Result<(), ApiError> { }

update::one::post

Function called after successful update.

#[crudcrate(update::one::post = invalidate_cache)]

async fn invalidate_cache<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    updated: &Model,
) -> Result<(), ApiError> { }

update::one::body

Replace entire update logic.

#[crudcrate(update::one::body = custom_update)]

async fn custom_update<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    id: PrimaryKeyType,
    data: ModelUpdate,
) -> Result<Model, ApiError> { }

delete::one::pre

Function called before delete operation.

#[crudcrate(delete::one::pre = check_delete_permission)]

async fn check_delete_permission<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    id: PrimaryKeyType,
) -> Result<(), ApiError> { }

delete::one::post

Function called after successful delete.

#[crudcrate(delete::one::post = cleanup_related)]

async fn cleanup_related<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    id: PrimaryKeyType,
) -> Result<(), ApiError> { }

delete::one::body

Replace entire delete logic (e.g., for soft delete).

#[crudcrate(delete::one::body = soft_delete)]

async fn soft_delete<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    id: PrimaryKeyType,
) -> Result<(), ApiError> { }

get::one::pre

Function called before get_one operation.

#[crudcrate(get::one::pre = check_view_permission)]

async fn check_view_permission<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    id: PrimaryKeyType,
) -> Result<(), ApiError> { }

get::all::pre

Function called before get_all operation, can modify condition.

#[crudcrate(get::all::pre = filter_by_tenant)]

async fn filter_by_tenant<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    condition: &mut Condition,
) -> Result<(), ApiError> { }

Complete Example

#[derive(EntityToModels)]
#[crudcrate(
    generate_router,
    api_struct = "Article",
    name_singular = "article",
    name_plural = "articles",
    operations = ArticleOperations,
    description = "Blog articles with comments",
    fulltext_language = "english",
    create::one::pre = validate_article,
    create::one::post = index_for_search,
    update::one::pre = check_edit_permission,
    delete::one::body = soft_delete_article,
    get::all::pre = filter_published_only,
)]
#[sea_orm(table_name = "articles")]
pub struct Model {
    // ...
}

Foreign Key Naming Convention

When using join() for batch loading in get_all(), CRUDCrate derives the foreign key column name from the parent struct name using PascalCase convention:

  • Parent struct Customer → FK column CustomerId (SeaORM Column enum) / customer_id (field name)
  • Parent struct VehiclePart → FK column VehiclePartId / vehicle_part_id

Your related entity’s SeaORM model must have a matching foreign key field. For example, if Customer has vehicles: Vec<Vehicle>, the Vehicle model must have a customer_id: Uuid field and a Column::CustomerId variant.

Note: Custom FK names (e.g., owner_id instead of customer_id) are not yet supported via attributes. If your FK name doesn’t follow the convention, use a custom read::many::body hook to implement the loading logic.

Batch Loading Query Behavior

For get_all() with join(all) or join(one, all), CRUDCrate uses batch loading:

  • Depth=1 joins: 2 queries total (1 for parents + 1 per join field using WHERE fk IN (...))
  • Depth > 1 joins: Additional per-item queries for nested children (falls back to get_one() calls)
  • get_one() with join(one): Per-item queries (single entity, no batching needed)

Note: Batch loading currently requires UUID primary keys, consistent with the CRUDResource trait contract.

See Security for partial success and batch limit configuration.

See Also

Field Attributes Reference

Complete reference for #[crudcrate(...)] attributes on fields.

Syntax

#[crudcrate(attribute1, attribute2 = value, ...)]
pub field_name: FieldType,

Core Attributes

primary_key

Marks the field as the primary key.

#[crudcrate(primary_key)]
pub id: i32,

Required: Yes (exactly one field) Type: Flag


exclude(...)

Exclude field from specific generated models.

#[crudcrate(exclude(create, update))]
pub id: Uuid,

#[crudcrate(exclude(one, list))]
pub password_hash: String,

Type: List of targets Targets:

  • one - Response model (GET /items/:id)
  • create - Create model (POST /items)
  • update - Update model (PUT /items/:id)
  • list - List model (GET /items)
  • all - Shorthand for one, list. Does not touch create or update. Unrelated to join(all), where all means the list endpoint.
  • scoped - Scoped response models (when ScopeCondition is active). Also strips the field from filterable/sortable lists in scoped context. See Public & Private Endpoints.

filterable

Enable filtering on this field.

#[crudcrate(filterable)]
pub status: String,

Type: Flag Effect: Allows ?filter={"status":"value"} and ?status_eq=value


sortable

Enable sorting on this field.

#[crudcrate(sortable)]
pub created_at: DateTimeUtc,

Type: Flag Effect: Allows ?sort=["created_at","DESC"]


fulltext

Include field in fulltext search.

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

#[crudcrate(fulltext)]
pub content: String,

Type: Flag Effect: Field included when using ?q=search+terms


Default Value Attributes

on_create

Set default value when creating new records.

#[crudcrate(on_create = Uuid::new_v4())]
pub id: Uuid,

#[crudcrate(on_create = chrono::Utc::now())]
pub created_at: DateTimeUtc,

#[crudcrate(on_create = "pending".to_string())]
pub status: String,

#[crudcrate(on_create = 0)]
pub view_count: i32,

Type: Rust expression When: Evaluated during create operation


on_update

Set default value when updating records.

#[crudcrate(on_update = chrono::Utc::now())]
pub updated_at: DateTimeUtc,

Type: Rust expression When: Evaluated during every update operation


Relationship Attributes

non_db_attr

Marks field as non-database (for computed or relationship fields).

#[sea_orm(ignore)]
#[crudcrate(non_db_attr)]
pub comments: Vec<Comment>,

Type: Flag Required: Yes, when using join(...)


join(...)

Configure relationship loading.

// Load in get_one only
#[crudcrate(non_db_attr, join(one))]
pub comments: Vec<Comment>,

// Load in both get_one and get_all
#[crudcrate(non_db_attr, join(one, all))]
pub author: Option<User>,

// Limit recursion depth
#[crudcrate(non_db_attr, join(one, all, depth = 2))]
pub nested: Vec<Nested>,

// Two foreign keys to the same parent: pick the relation variant
#[crudcrate(non_db_attr, join(one, all, relation = "Author"))]
pub authored: Vec<Document>,
#[crudcrate(non_db_attr, join(one, all, relation = "Reviewer"))]
pub reviewed: Vec<Document>,

Type: Configuration Parameters:

  • one - Load in single-item responses
  • all - Load in list responses
  • depth = N - Maximum recursion depth (default: 5)
  • relation = "Variant" - Variant of the child’s Relation enum to join through. Needed when the child has more than one foreign key to this entity, since Related can only be implemented once per pair
  • fk_column = "Column" - Child column variant holding the foreign key, bypassing runtime resolution from the RelationDef
  • path = "crate::module" - Module of the child entity when it cannot be derived from the field type

join_filterable(...)

Enable filtering on columns from related entities using dot-notation.

#[sea_orm(ignore)]
#[crudcrate(
    non_db_attr,
    join(one, all),
    join_filterable("make", "year", "color")
)]
pub vehicles: Vec<Vehicle>,

Type: List of column names Effect: Enables ?filter={"vehicles.make":"BMW","vehicles.year_gte":2020} Security: Only listed columns can be filtered - unlisted columns are silently ignored


join_sortable(...)

Enable sorting on columns from related entities using dot-notation.

#[sea_orm(ignore)]
#[crudcrate(
    non_db_attr,
    join(one, all),
    join_sortable("year", "mileage")
)]
pub vehicles: Vec<Vehicle>,

Type: List of column names Effect: Enables ?sort=["vehicles.year","DESC"] Security: Only listed columns can be sorted - unlisted columns fall back to default sort


Common Patterns

Auto-Generated ID

#[sea_orm(primary_key, auto_increment = false)]
#[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
pub id: Uuid,

Managed Timestamps

#[crudcrate(sortable, exclude(create, update), on_create = chrono::Utc::now())]
pub created_at: DateTimeUtc,

#[crudcrate(exclude(create, update), on_create = chrono::Utc::now(), on_update = chrono::Utc::now())]
pub updated_at: DateTimeUtc,

Sensitive Data

#[crudcrate(exclude(one, list))]
pub password_hash: String,

#[crudcrate(exclude(one, list))]
pub api_secret: String,

Searchable Content

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

#[crudcrate(fulltext, exclude(list))]
pub content: String,

Foreign Key with Relation

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

#[sea_orm(ignore)]
#[crudcrate(non_db_attr, join(one, all, depth = 1))]
pub author: Option<User>,

Relationship with Join Filtering/Sorting

// Enable filtering and sorting on related entity columns
#[sea_orm(ignore)]
#[crudcrate(
    non_db_attr,
    join(one, all, depth = 1),
    join_filterable("make", "year", "color"),
    join_sortable("year", "mileage")
)]
pub vehicles: Vec<Vehicle>,

Enables queries like:

  • ?filter={"vehicles.make":"BMW"}
  • ?sort=["vehicles.year","DESC"]

Computed Field (Read-Only)

#[crudcrate(sortable, exclude(create, update))]
pub view_count: i32,

Complete Example

#[derive(EntityToModels)]
#[crudcrate(generate_router)]
#[sea_orm(table_name = "articles")]
pub struct Model {
    // Primary key with auto-generation
    #[sea_orm(primary_key, auto_increment = false)]
    #[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
    pub id: Uuid,

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

    // Searchable content, excluded from lists
    #[crudcrate(fulltext, exclude(list))]
    pub content: String,

    // Optional summary for lists
    pub summary: Option<String>,

    // Filterable status
    #[crudcrate(filterable)]
    pub status: ArticleStatus,

    // Foreign key with relationship
    #[crudcrate(filterable)]
    pub author_id: Uuid,

    // Read-only view counter
    #[crudcrate(sortable, exclude(create, update), on_create = 0)]
    pub view_count: i32,

    // Auto-managed timestamps
    #[crudcrate(sortable, exclude(create, update), on_create = chrono::Utc::now())]
    pub created_at: DateTimeUtc,

    #[crudcrate(exclude(create, update), on_create = chrono::Utc::now(), on_update = chrono::Utc::now())]
    pub updated_at: DateTimeUtc,

    // Relationships
    #[sea_orm(ignore)]
    #[crudcrate(non_db_attr, join(one, all, depth = 1))]
    pub author: Option<User>,

    #[sea_orm(ignore)]
    #[crudcrate(non_db_attr, join(one))]
    pub comments: Vec<Comment>,

    // Relationship with join filtering/sorting
    #[sea_orm(ignore)]
    #[crudcrate(
        non_db_attr,
        join(one, all, depth = 1),
        join_filterable("tag_name"),
        join_sortable("tag_name")
    )]
    pub tags: Vec<Tag>,
}

Attribute Compatibility

AttributeCombinable With
primary_keyexclude, on_create
excludeAll except join targets conflict
filterablesortable, fulltext, exclude
sortablefilterable, fulltext, exclude
fulltextfilterable, sortable, exclude
on_createon_update, exclude(create)
on_updateon_create, exclude(update)
non_db_attrjoin, join_filterable, join_sortable (required)
joinnon_db_attr (required), join_filterable, join_sortable
join_filterablenon_db_attr, join, join_sortable
join_sortablenon_db_attr, join, join_filterable

See Also

Query Parameters Reference

Complete reference for all supported query parameters.

FilterOptions Struct

pub struct FilterOptions {
    pub filter: Option<String>,      // JSON filter object
    pub sort: Option<String>,        // Sort specification
    pub order: Option<String>,       // Sort order (ASC/DESC)
    pub range: Option<String>,       // Pagination range [start, end]
    pub page: Option<u64>,           // Page number
    pub per_page: Option<u64>,       // Items per page
    pub q: Option<String>,           // Fulltext search query
    // Plus dynamic field-specific filters
}

Filtering Parameters

filter (JSON Object)

Filter by exact field values.

# Single field
?filter={"status":"active"}

# Multiple fields (AND)
?filter={"status":"active","priority":5}

# Null check
?filter={"deleted_at":null}

Field-Specific Operators

# Exact match (default)
?status=active
?status_eq=active

# Not equal
?status_ne=deleted

# Greater than
?priority_gt=5

# Greater than or equal
?priority_gte=5

# Less than
?priority_lt=10

# Less than or equal
?priority_lte=10

# Contains (LIKE)
?title_like=urgent

# In list
?status_in=active,pending,review

Operator Reference:

SuffixSQLExample
_eq=?status_eq=active
_ne!=?status_ne=deleted
_gt>?age_gt=18
_gte>=?age_gte=18
_lt<?price_lt=100
_lte<=?price_lte=100
_likeLIKE?name_like=john
_inIN?status_in=a,b,c

Sorting Parameters

sort (JSON Array - React Admin)

# Field and direction
?sort=["created_at","DESC"]

# Field only (defaults to ASC)
?sort=["name"]

sort + order (Standard)

?sort=created_at&order=DESC
?sort=name&order=ASC

Combined Format

?sort=created_at_desc
?sort=name_asc

Order Values:

  • ASC / asc - Ascending (A-Z, 0-9, oldest)
  • DESC / desc - Descending (Z-A, 9-0, newest)

Pagination Parameters

range (React Admin)

# Items 0-9 (first 10)
?range=[0,9]

# Items 20-29
?range=[20,29]

# Items 0-99 (first 100)
?range=[0,99]

page + per_page (Standard)

# Page 1, 20 per page
?page=1&per_page=20

# Page 5, 50 per page
?page=5&per_page=50

Limits:

  • Maximum per_page: 1,000
  • Maximum offset: 1,000,000

Search Parameters

# Search all fulltext fields
?q=meeting notes

# Combined with filters
?q=urgent&filter={"status":"open"}

Response Headers

Content-Range

Content-Range: items 0-19/150

Format: {resource} {start}-{end}/{total}

Complete Examples

Basic List

GET /articles

Filtered List

GET /articles?filter={"status":"published","author_id":5}

Sorted List

GET /articles?sort=["created_at","DESC"]

Paginated List

GET /articles?range=[0,19]
GET /articles?q=rust programming

Combined Query

GET /articles?filter={"status":"published"}&sort=["views","DESC"]&range=[0,9]&q=tutorial

Complex Filter

GET /articles?status=published&views_gte=1000&created_at_gte=2024-01-01

Parsing Functions

apply_filters

Build Sea-ORM condition from query parameters.

use crudcrate::filtering::apply_filters;

let condition = apply_filters::<Entity>(&params)?;

parse_pagination

Extract offset and limit from parameters.

use crudcrate::filtering::parse_pagination;

let (offset, limit) = parse_pagination(&params);
// Default: (0, 20)

parse_sorting

Extract column and order from parameters.

use crudcrate::filtering::parse_sorting;

let (column, order) = parse_sorting::<Entity>(&params);
// Default: (primary_key_column, Order::Asc)

build_fulltext_condition

Build fulltext search condition.

use crudcrate::filtering::build_fulltext_condition;

let condition = build_fulltext_condition(
    query,
    &["title", "content"],
    db.get_database_backend()
);

URL Encoding

Special characters in values must be URL-encoded:

# Space → %20 or +
?q=hello+world
?q=hello%20world

# Brackets → %5B %5D
?range=%5B0,9%5D

# Curly braces → %7B %7D
?filter=%7B"status":"active"%7D

# Comma in value → %2C
?tags_in=one%2Ctwo%2Cthree

Error Responses

Invalid Filter Field

GET /articles?unknown_field=value
{"error": "Invalid filter field: unknown_field"}

Invalid Filter Value

GET /articles?priority_gte=not-a-number
{"error": "Invalid filter value for field 'priority': expected number"}

Invalid Sort Field

Invalid sort fields are silently ignored (falls back to default).

Invalid Range Format

GET /articles?range=invalid

Falls back to default pagination.

See Also

CRUDResource API Reference

The CRUDResource trait is the core abstraction for CRUD operations.

Trait Definition

The trait declares each async method as fn ... -> impl Future<Output = ...> + Send; an impl writes async fn as shown here, and the compiler checks that its future is Send.

pub trait CRUDResource: Sized + Send + Sync {
    /// The Sea-ORM entity type
    type EntityType: EntityTrait;

    /// Model for creating new records
    type CreateModel: DeserializeOwned + Send + Sync;

    /// Model for updating existing records
    type UpdateModel: DeserializeOwned + Send + Sync;

    /// Model for list responses
    type ListModel: Serialize + Send + Sync;

    /// The primary key type
    type PrimaryKey: Send + Sync;

    /// Get a single record by ID
    async fn get_one<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        db: &C,
        id: Self::PrimaryKey,
    ) -> Result<Self, ApiError>;

    /// Get all records with filtering, sorting, and pagination
    async fn get_all<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        db: &C,
        condition: Condition,
        order: (Self::EntityType::Column, Order),
        offset: u64,
        limit: u64,
    ) -> Result<Vec<Self::ListModel>, ApiError>;

    /// Create a new record
    async fn create<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        db: &C,
        data: Self::CreateModel,
    ) -> Result<Self, ApiError>;

    /// Update an existing record
    async fn update<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        db: &C,
        id: Self::PrimaryKey,
        data: Self::UpdateModel,
    ) -> Result<Self, ApiError>;

    /// Delete a single record
    async fn delete<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        db: &C,
        id: Self::PrimaryKey,
    ) -> Result<(), ApiError>;

    /// Delete multiple records
    async fn delete_many<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        db: &C,
        ids: Vec<Self::PrimaryKey>,
    ) -> Result<u64, ApiError>;

    /// Get total count matching condition
    async fn total_count<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        db: &C,
        condition: &Condition,
    ) -> u64;
}

Associated Types

EntityType

The Sea-ORM entity for database operations.

type EntityType = entity::Entity;

CreateModel

Request model for creating records. Excludes auto-generated fields.

type CreateModel = ArticleCreate;

// Generated struct
pub struct ArticleCreate {
    pub title: String,
    pub content: String,
    // id, created_at excluded
}

UpdateModel

Request model for updating records. All fields optional.

type UpdateModel = ArticleUpdate;

// Generated struct
pub struct ArticleUpdate {
    pub title: Option<String>,
    pub content: Option<String>,
}

ListModel

Response model for list operations. May exclude expensive fields.

type ListModel = ArticleList;

// Generated struct
pub struct ArticleList {
    pub id: Uuid,
    pub title: String,
    // content excluded from list
}

PrimaryKey

Type of the primary key.

type PrimaryKey = Uuid;
// or
type PrimaryKey = i32;

Methods

get_one

Retrieve a single record by primary key.

async fn get_one<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    id: Self::PrimaryKey,
) -> Result<Self, ApiError>;

Parameters:

  • db - Database connection
  • id - Primary key value

Returns:

  • Ok(Self) - The record as response model
  • Err(ApiError::NotFound) - Record doesn’t exist
  • Err(ApiError::Database) - Database error

Example:

let article = Article::get_one(&db, article_id).await?;

get_all

Retrieve multiple records with filtering, sorting, and pagination.

async fn get_all<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    condition: Condition,
    order: (Self::EntityType::Column, Order),
    offset: u64,
    limit: u64,
) -> Result<Vec<Self::ListModel>, ApiError>;

Parameters:

  • db - Database connection
  • condition - Sea-ORM condition for filtering
  • order - Tuple of (Column, Order) for sorting
  • offset - Number of records to skip
  • limit - Maximum records to return

Returns:

  • Ok(Vec<ListModel>) - List of records
  • Err(ApiError::Database) - Database error

Example:

use sea_orm::{Condition, Order};

let condition = Condition::all()
    .add(Column::Status.eq("published"));

let articles = Article::get_all(
    &db,
    condition,
    (Column::CreatedAt, Order::Desc),
    0,   // offset
    20,  // limit
).await?;

create

Create a new record.

async fn create<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    data: Self::CreateModel,
) -> Result<Self, ApiError>;

Parameters:

  • db - Database connection
  • data - Create model with field values

Returns:

  • Ok(Self) - Created record as response model
  • Err(ApiError::Database) - Database error (e.g., constraint violation)

Example:

let new_article = Article::create(&db, ArticleCreate {
    title: "Hello World".into(),
    content: "My first post".into(),
}).await?;

update

Update an existing record.

async fn update<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    id: Self::PrimaryKey,
    data: Self::UpdateModel,
) -> Result<Self, ApiError>;

Parameters:

  • db - Database connection
  • id - Primary key of record to update
  • data - Update model (only Some fields are updated)

Returns:

  • Ok(Self) - Updated record as response model
  • Err(ApiError::NotFound) - Record doesn’t exist
  • Err(ApiError::Database) - Database error

Example:

let updated = Article::update(&db, article_id, ArticleUpdate {
    title: Some("New Title".into()),
    content: None,  // Don't change
}).await?;

delete

Delete a single record.

async fn delete<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    id: Self::PrimaryKey,
) -> Result<(), ApiError>;

Parameters:

  • db - Database connection
  • id - Primary key of record to delete

Returns:

  • Ok(()) - Record deleted
  • Err(ApiError::NotFound) - Record doesn’t exist
  • Err(ApiError::Database) - Database error

Example:

Article::delete(&db, article_id).await?;

delete_many

Delete multiple records by IDs.

async fn delete_many<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    ids: Vec<Self::PrimaryKey>,
) -> Result<u64, ApiError>;

Parameters:

  • db - Database connection
  • ids - List of primary keys (max 100)

Returns:

  • Ok(u64) - Number of records deleted
  • Err(ApiError::BadRequest) - More than 100 IDs provided
  • Err(ApiError::Database) - Database error

Example:

let deleted_count = Article::delete_many(&db, vec![id1, id2, id3]).await?;

total_count

Get count of records matching a condition.

async fn total_count<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    condition: &Condition,
) -> u64;

Parameters:

  • db - Database connection
  • condition - Sea-ORM condition for filtering

Returns:

  • Count of matching records (0 on error)

Example:

let condition = Condition::all()
    .add(Column::Status.eq("published"));

let count = Article::total_count(&db, &condition).await;

Registration methods

Generated from #[crudcrate(upsert_key(...))] and used by crudcrate::upsert. A resource without the attribute keeps the defaults, and upsert refuses rather than guessing a key.

upsert_key

The alternate unique key a source system registers rows under, in the order the index declares them. Empty by default.

fn upsert_key() -> &'static [<Self::EntityType as EntityTrait>::Column];

upsert_predicate

The predicate upsert_key is unique under, for a key backed by a partial index. Defaults to Condition::all(), which matches every row. Generated from #[crudcrate(upsert_where = ...)].

fn upsert_predicate() -> Condition;

upsert_comparable

The columns a registration compares and writes: the create model’s stored columns, minus the primary key. Defaults to the key alone.

fn upsert_comparable() -> &'static [<Self::EntityType as EntityTrait>::Column];

apply_on_update

Applies the entity’s on_update expressions to a row a registration is about to change, so a field the entity maintains itself advances even though no source sends it. Defaults to doing nothing.

fn apply_on_update(model: &mut Self::ActiveModelType);

Usage in Custom Handlers

use crudcrate::CRUDResource;

async fn custom_list_handler(
    Query(params): Query<FilterOptions>,
    Extension(db): Extension<DatabaseConnection>,
) -> Result<Json<Vec<ArticleList>>, ApiError> {
    let condition = apply_filters::<Entity>(&params)?;
    let (offset, limit) = parse_pagination(&params);
    let order = parse_sorting::<Entity>(&params);

    let articles = Article::get_all(&db, condition, order, offset, limit).await?;

    Ok(Json(articles))
}

async fn custom_create_handler(
    Extension(db): Extension<DatabaseConnection>,
    Json(data): Json<ArticleCreate>,
) -> Result<Json<Article>, ApiError> {
    // Additional validation
    if data.title.is_empty() {
        return Err(ApiError::BadRequest("Title required".into()));
    }

    let article = Article::create(&db, data).await?;

    Ok(Json(article))
}

See Also

CRUDOperations API Reference

The CRUDOperations trait provides three levels of customization for CRUD behavior. Wire it in with #[crudcrate(operations = MyOps)].

Trait definition

All methods have default no-op implementations. Override only what you need. The trait declares each method as fn ... -> impl Future<Output = ...> + Send; an impl writes async fn as shown here, and the compiler checks that its future is Send.

pub trait CRUDOperations: Send + Sync {
    type Resource: CRUDResource;

    // --- Level 1: Lifecycle hooks ---

    async fn after_begin<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(&self, db: &C) -> Result<(), ApiError>;

    async fn before_get_one<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(&self, db: &C, id: Uuid) -> Result<(), ApiError>;
    async fn after_get_one<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(&self, db: &C, entity: &mut Self::Resource) -> Result<(), ApiError>;

    async fn before_get_all<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        &self, db: &C, condition: &Condition,
        order_column: <Self::Resource as CRUDResource>::ColumnType,
        order_direction: &Order, offset: u64, limit: u64,
    ) -> Result<(), ApiError>;
    async fn after_get_all<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        &self, db: &C,
        entities: &mut Vec<<Self::Resource as CRUDResource>::ListModel>,
    ) -> Result<(), ApiError>;

    async fn before_create<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(&self, db: &C, data: &CreateModel) -> Result<(), ApiError>;
    async fn after_create<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(&self, db: &C, entity: &mut Self::Resource) -> Result<(), ApiError>;

    async fn before_update<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(&self, db: &C, id: Uuid, data: &UpdateModel) -> Result<(), ApiError>;
    async fn after_update<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(&self, db: &C, entity: &mut Self::Resource) -> Result<(), ApiError>;

    async fn before_delete<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(&self, db: &C, id: Uuid) -> Result<(), ApiError>;
    async fn after_delete<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(&self, db: &C, id: Uuid) -> Result<(), ApiError>;

    async fn before_delete_many<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(&self, db: &C, ids: &[Uuid]) -> Result<(), ApiError>;
    async fn after_delete_many<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(&self, db: &C, ids: &[Uuid]) -> Result<(), ApiError>;

    // --- Level 2: Core logic overrides ---

    async fn fetch_one<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(&self, db: &C, id: Uuid) -> Result<Self::Resource, ApiError>;
    async fn fetch_all<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(&self, db: &C, condition: &Condition, ...) -> Result<Vec<ListModel>, ApiError>;
    async fn perform_create<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(&self, db: &C, data: CreateModel) -> Result<Self::Resource, ApiError>;
    async fn perform_update<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(&self, db: &C, id: Uuid, data: UpdateModel) -> Result<Self::Resource, ApiError>;
    async fn perform_delete<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(&self, db: &C, id: Uuid) -> Result<Uuid, ApiError>;
    async fn perform_delete_many<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(&self, db: &C, ids: Vec<Uuid>) -> Result<Vec<Uuid>, ApiError>;

    // --- Level 3: Full operation overrides ---

    async fn get_one<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(&self, db: &C, id: Uuid) -> Result<Self::Resource, ApiError>;
    async fn get_all<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(&self, db: &C, condition: &Condition, ...) -> Result<Vec<ListModel>, ApiError>;
    async fn create<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(&self, db: &C, data: CreateModel) -> Result<Self::Resource, ApiError>;
    async fn update<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(&self, db: &C, id: Uuid, data: UpdateModel) -> Result<Self::Resource, ApiError>;
    async fn delete<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(&self, db: &C, id: Uuid) -> Result<Uuid, ApiError>;
    async fn delete_many<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(&self, db: &C, ids: Vec<Uuid>) -> Result<Vec<Uuid>, ApiError>;
    async fn create_many<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(&self, db: &C, data: Vec<CreateModel>) -> Result<Vec<Self::Resource>, ApiError>;
    async fn update_many<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(&self, db: &C, updates: Vec<(Uuid, UpdateModel)>) -> Result<Vec<Self::Resource>, ApiError>;
}

Type aliases used above for brevity:

  • CreateModel = <Self::Resource as CRUDResource>::CreateModel
  • UpdateModel = <Self::Resource as CRUDResource>::UpdateModel
  • ListModel = <Self::Resource as CRUDResource>::ListModel

Customization levels

Level 1: Lifecycle hooks

before_* and after_* methods. Called around the default core logic. after_begin runs immediately after every BEGIN a write issues, before any other hook on that transaction, which is where session state such as SET LOCAL belongs. db in every write hook is that transaction; use it rather than a second connection from the pool, which the lifecycle holds one of until it commits. Use for validation, authorization, logging, enrichment.

before_create and before_update receive immutable references to the input data. To transform input, use per-attribute hooks (create::one::pre) or override perform_create/perform_update instead.

Level 2: Core logic

fetch_one, fetch_all, perform_create, perform_update, perform_delete, perform_delete_many. Replace the default DB query or mutation while keeping the lifecycle hooks around it.

Caveat: fetch_one and fetch_all are bypassed when the entity has join(...) fields, because join loading requires the raw SeaORM Model to call find_related(). The before_*/after_* hooks still fire normally. If you need full control over both the fetch and join loading, use per-attribute hooks (read::one::body) instead.

Level 3: Full operation override

get_one, get_all, create, update, delete, delete_many, create_many, update_many. Replace the entire operation including lifecycle hooks. The default implementations orchestrate before_* → core_logic → after_*.

Execution order

get_one

before_get_one(db, id)
    ↓
fetch_one(db, id)          ← or join-loading codegen when joins exist
    ↓
after_get_one(db, &mut entity)

get_all

before_get_all(db, condition, order_column, &order_direction, offset, limit)
    ↓
fetch_all(db, ...)         ← or batch-loading codegen when joins exist
    ↓
after_get_all(db, &mut entities)

The write lifecycles run inside a transaction the operation opens. db in every one of their hooks is that transaction, so a hook’s own writes commit with the row and roll back with it.

create

BEGIN
    ↓
after_begin(txn)
    ↓
before_create(txn, &data)
    ↓
perform_create(txn, data)
    ↓
after_create(txn, &mut entity)
    ↓
COMMIT

update

BEGIN
    ↓
after_begin(txn)
    ↓
before_update(txn, id, &data)
    ↓
perform_update(txn, id, data)
    ↓
after_update(txn, &mut entity)
    ↓
COMMIT

delete

BEGIN
    ↓
after_begin(txn)
    ↓
before_delete(txn, id)
    ↓
perform_delete(txn, id)
    ↓
after_delete(txn, id)
    ↓
COMMIT

Anything that returns an error rolls the transaction back, so a refusal in after_create leaves no row behind.

create_many, update_many, delete_many

One transaction encloses the batch, and each row’s own lifecycle is a savepoint within it. A failure at any row leaves none of the batch written. The ?partial=true endpoints instead run each row on the connection and report per-item outcomes.

BEGIN
    ↓
after_begin(txn)
    ↓
before_delete_many(txn, &ids)
    ↓
perform_delete_many(txn, ids)
    ↓
after_delete_many(txn, &deleted)
    ↓
COMMIT

create_many and update_many run the single-row lifecycle per item after their own after_begin, so each row’s savepoint runs after_begin again.

See also

Error Types Reference

Complete reference for CRUDCrate error types and HTTP responses.

ApiError Enum

pub enum ApiError {
    NotFound,
    BadRequest(String),
    Unauthorized,
    Forbidden,
    Conflict(String),
    ValidationFailed(Vec<ValidationError>),
    Database(String),
    Internal(String),
}

Error Types

NotFound

Resource doesn’t exist.

ApiError::NotFound

HTTP Status: 404 Response:

{"error": "Not found"}

When Used:

  • get_one with non-existent ID
  • update on non-existent record
  • delete on non-existent record

BadRequest

Invalid request data or parameters.

ApiError::BadRequest("Invalid filter syntax".into())

HTTP Status: 400 Response:

{"error": "Invalid filter syntax"}

When Used:

  • Invalid query parameters
  • Malformed JSON body
  • Invalid filter field
  • Too many items in bulk operation

Unauthorized

Authentication required or failed.

ApiError::Unauthorized

HTTP Status: 401 Response:

{"error": "Unauthorized"}

When Used:

  • Missing authentication
  • Invalid/expired token
  • Custom auth checks in hooks

Forbidden

User lacks permission.

ApiError::Forbidden

HTTP Status: 403 Response:

{"error": "Forbidden"}

When Used:

  • Authorization checks in hooks
  • Row-level security violations
  • Role-based access denial

Conflict

Resource conflict (usually uniqueness violation).

ApiError::Conflict("Email already in use".into())

HTTP Status: 409 Response:

{"error": "Email already in use"}

When Used:

  • Unique constraint violations
  • Concurrent modification conflicts
  • State machine transitions

ValidationFailed

Input validation errors with field details.

ApiError::ValidationFailed(vec![
    ValidationError::new("email", "Invalid format"),
    ValidationError::new("password", "Too short"),
])

HTTP Status: 422 Response:

{
  "error": "Validation failed",
  "details": [
    {"field": "email", "message": "Invalid format"},
    {"field": "password", "message": "Too short"}
  ]
}

When Used:

  • Input validation in hooks
  • Business rule violations
  • Data format errors

Database

Database operation failed (details sanitized).

ApiError::Database("Database error".into())

HTTP Status: 500 Response:

{"error": "Database error"}

Note: The actual error is logged server-side, not exposed to clients.

When Used:

  • Connection failures
  • Query errors
  • Constraint violations (when not caught as Conflict)

Internal

Generic server error.

ApiError::Internal("Unexpected error".into())

HTTP Status: 500 Response:

{"error": "Unexpected error"}

When Used:

  • Unhandled errors
  • Infrastructure failures
  • Configuration errors

ValidationError

pub struct ValidationError {
    pub field: String,
    pub message: String,
}

impl ValidationError {
    pub fn new(field: &str, message: &str) -> Self {
        Self {
            field: field.to_string(),
            message: message.to_string(),
        }
    }
}

Creating Errors

use crudcrate::{ApiError, ValidationError};

// Not found
return Err(ApiError::NotFound);

// Bad request
return Err(ApiError::BadRequest("Invalid parameter".into()));

// Unauthorized
return Err(ApiError::Unauthorized);

// Forbidden
return Err(ApiError::Forbidden);

// Conflict
return Err(ApiError::Conflict("Duplicate entry".into()));

// Validation failed
return Err(ApiError::ValidationFailed(vec![
    ValidationError::new("email", "Required"),
    ValidationError::new("age", "Must be positive"),
]));

// Database error (use with caution)
return Err(ApiError::Database("Connection failed".into()));

// Internal error
return Err(ApiError::Internal("Configuration error".into()));

Error Conversion

ApiError implements From for common types:

// From sea_orm::DbErr
impl From<DbErr> for ApiError {
    fn from(err: DbErr) -> Self {
        eprintln!("Database error: {}", err);
        ApiError::Database("Database error".into())
    }
}

// Usage with ? operator
async fn get_item(db: &DatabaseConnection, id: i32) -> Result<Item, ApiError> {
    let item = Entity::find_by_id(id)
        .one(db)
        .await?  // DbErr automatically converted
        .ok_or(ApiError::NotFound)?;
    Ok(item.into())
}

HTTP Response Mapping

Error TypeStatusBody
NotFound404{"error": "Not found"}
BadRequest(msg)400{"error": "{msg}"}
Unauthorized401{"error": "Unauthorized"}
Forbidden403{"error": "Forbidden"}
Conflict(msg)409{"error": "{msg}"}
ValidationFailed(errs)422{"error": "...", "details": [...]}
Database(msg)500{"error": "{msg}"}
Internal(msg)500{"error": "{msg}"}

Usage in Hooks

async fn before_create<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    &self,
    db: &C,
    data: &mut ArticleCreate,
) -> Result<(), ApiError> {
    // Validation
    let mut errors = Vec::new();

    if data.title.is_empty() {
        errors.push(ValidationError::new("title", "Required"));
    }

    if data.title.len() > 200 {
        errors.push(ValidationError::new("title", "Max 200 characters"));
    }

    if !errors.is_empty() {
        return Err(ApiError::ValidationFailed(errors));
    }

    // Uniqueness check
    let exists = Entity::find()
        .filter(Column::Slug.eq(&data.slug))
        .count(db)
        .await?;

    if exists > 0 {
        return Err(ApiError::Conflict("Article with this slug exists".into()));
    }

    Ok(())
}

async fn before_update<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    &self,
    db: &C,
    id: Uuid,
    _data: &mut ArticleUpdate,
) -> Result<(), ApiError> {
    let article = Entity::find_by_id(id)
        .one(db)
        .await?
        .ok_or(ApiError::NotFound)?;

    let user = get_current_user();

    if article.author_id != user.id {
        return Err(ApiError::Forbidden);
    }

    Ok(())
}

Custom Error Extension

Create domain-specific errors:

pub enum DomainError {
    InsufficientFunds(f64),
    OrderExpired,
    InventoryDepleted(String),
}

impl From<DomainError> for ApiError {
    fn from(err: DomainError) -> Self {
        match err {
            DomainError::InsufficientFunds(needed) => {
                ApiError::BadRequest(format!("Insufficient funds. Need ${:.2}", needed))
            }
            DomainError::OrderExpired => {
                ApiError::BadRequest("Order has expired".into())
            }
            DomainError::InventoryDepleted(item) => {
                ApiError::Conflict(format!("'{}' is out of stock", item))
            }
        }
    }
}

// Usage
fn process_order(order: &Order) -> Result<(), ApiError> {
    if order.is_expired() {
        return Err(DomainError::OrderExpired.into());
    }
    Ok(())
}

See Also

Minimal Example

The simplest CRUDCrate API - under 60 lines of code.

Run It Now

git clone https://github.com/evanjt/crudcrate
cd crudcrate/crudcrate
cargo run --example minimal

Then visit:

  • API: http://localhost:3000/todo
  • Docs: http://localhost:3000/docs (interactive OpenAPI)

The Code

use axum::Router;
use crudcrate::EntityToModels;
use sea_orm::{entity::prelude::*, Database, DatabaseConnection};
use uuid::Uuid;

#[derive(Clone, Debug, DeriveEntityModel, EntityToModels)]
#[crudcrate(generate_router)]
#[sea_orm(table_name = "items")]
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    #[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
    pub id: Uuid,

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

    pub description: Option<String>,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

impl ActiveModelBehavior for ActiveModel {}

#[tokio::main]
async fn main() {
    let db: DatabaseConnection = Database::connect("sqlite::memory:")
        .await
        .expect("Failed to connect");

    setup_database(&db).await;

    let app = Router::new()
        .merge(item_router())
        .layer(axum::Extension(db));

    println!("Running at http://localhost:3000");
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

async fn setup_database(db: &DatabaseConnection) {
    db.execute(sea_orm::Statement::from_string(
        db.get_database_backend(),
        "CREATE TABLE items (id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT)"
            .to_owned(),
    ))
    .await
    .expect("Failed to create table");
}

Dependencies

[package]
name = "minimal-api"
version = "0.1.0"
edition = "2021"

[dependencies]
crudcrate = "0.15"
sea-orm = { version = "2.0", features = ["runtime-tokio-rustls", "sqlx-sqlite"] }
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
uuid = { version = "1.0", features = ["v4", "serde"] }

Run

cargo run

Test

# Create
curl -X POST http://localhost:3000/items \
  -H "Content-Type: application/json" \
  -d '{"name": "Test", "description": "A test item"}'

# List all
curl http://localhost:3000/items

# Filter
curl 'http://localhost:3000/items?filter={"name":"Test"}'

# Sort
curl 'http://localhost:3000/items?sort=["name","DESC"]'

# Get one (use ID from create response)
curl http://localhost:3000/items/{id}

# Update
curl -X PUT http://localhost:3000/items/{id} \
  -H "Content-Type: application/json" \
  -d '{"name": "Updated"}'

# Delete
curl -X DELETE http://localhost:3000/items/{id}

What You Get

From ~35 lines:

  • 6 REST endpoints
  • UUID generation
  • Filtering on name
  • Sorting on name
  • Pagination with Content-Range headers
  • JSON serialization
  • Error handling

Without CRUDCrate, this would require 500+ lines of handlers, models, and parsing logic.


Next: See the Todo App for a more complete example with timestamps and status tracking.

Todo Application Example

A practical todo app with filtering, sorting, and search.

Run It Now

The minimal example includes a todo entity:

git clone https://github.com/evanjt/crudcrate
cd crudcrate/crudcrate
cargo run --example minimal

Then visit:

  • API: http://localhost:3000/todo
  • Docs: http://localhost:3000/docs

Entity Definition

use crudcrate::EntityToModels;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use uuid::Uuid;

#[derive(Clone, Debug, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)]
#[sea_orm(rs_type = "String", db_type = "String(StringLen::N(15))")]
pub enum Priority {
    #[sea_orm(string_value = "low")]
    Low,
    #[sea_orm(string_value = "medium")]
    Medium,
    #[sea_orm(string_value = "high")]
    High,
}

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize, EntityToModels)]
#[crudcrate(generate_router)]
#[sea_orm(table_name = "todos")]
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    #[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
    pub id: Uuid,

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

    #[crudcrate(fulltext)]
    pub description: Option<String>,

    #[crudcrate(filterable)]
    pub completed: bool,

    #[crudcrate(filterable, sortable)]
    pub priority: Priority,

    #[crudcrate(filterable, sortable)]
    pub due_date: Option<DateTimeUtc>,

    #[crudcrate(sortable, exclude(create, update), on_create = chrono::Utc::now())]
    pub created_at: DateTimeUtc,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

impl ActiveModelBehavior for ActiveModel {}

Usage Examples

Create Todos

# High priority task
curl -X POST http://localhost:3000/todos \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Review pull request",
    "description": "Check the new authentication feature",
    "completed": false,
    "priority": "high"
  }'

# Medium priority with due date
curl -X POST http://localhost:3000/todos \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Update documentation",
    "completed": false,
    "priority": "medium",
    "due_date": "2024-12-31T23:59:59Z"
  }'

Filter by Status

# Incomplete tasks
curl "http://localhost:3000/todos?filter={\"completed\":false}"

# Completed tasks
curl "http://localhost:3000/todos?filter={\"completed\":true}"

Filter by Priority

# High priority only
curl "http://localhost:3000/todos?filter={\"priority\":\"high\"}"

# High and medium
curl "http://localhost:3000/todos?priority_in=high,medium"

Sort by Due Date

# Earliest due first
curl "http://localhost:3000/todos?sort=[\"due_date\",\"ASC\"]"

# Latest due first
curl "http://localhost:3000/todos?sort=[\"due_date\",\"DESC\"]"

Search

# Search title and description
curl "http://localhost:3000/todos?q=authentication"

Combined Queries

# Incomplete high-priority, sorted by due date
curl "http://localhost:3000/todos?filter={\"completed\":false,\"priority\":\"high\"}&sort=[\"due_date\",\"ASC\"]"

Mark Complete

curl -X PUT http://localhost:3000/todos/{id} \
  -H "Content-Type: application/json" \
  -d '{"completed": true}'

React Admin Integration

// dataProvider.js
import { fetchUtils } from 'react-admin';

const apiUrl = 'http://localhost:3000';

export const dataProvider = {
  getList: (resource, params) => {
    const { page, perPage } = params.pagination;
    const { field, order } = params.sort;
    const range = [(page - 1) * perPage, page * perPage - 1];

    const query = {
      sort: JSON.stringify([field, order]),
      range: JSON.stringify(range),
      filter: JSON.stringify(params.filter),
    };

    const url = `${apiUrl}/${resource}?${fetchUtils.queryParameters(query)}`;

    return fetchUtils.fetchJson(url).then(({ headers, json }) => {
      const contentRange = headers.get('Content-Range');
      const total = parseInt(contentRange.split('/').pop(), 10);
      return { data: json, total };
    });
  },
  // ... other methods
};

Next: See the Blog Example for handling relationships between entities.

Blog with Comments Example

A blog API with posts, comments, and authors demonstrating relationships.

Note: This is a reference example. To see relationships in action, run:

cargo run --example recursive_join

Then visit http://localhost:3000/docs


Entities

User (Author)

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize, EntityToModels)]
#[crudcrate(generate_router)]
#[sea_orm(table_name = "users")]
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    #[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
    pub id: Uuid,

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

    #[crudcrate(filterable)]
    pub email: String,

    #[crudcrate(exclude(one, list))]
    pub password_hash: String,

    pub bio: Option<String>,

    #[crudcrate(sortable, exclude(create, update), on_create = chrono::Utc::now())]
    pub created_at: DateTimeUtc,

    // Relationship: User has many Posts
    #[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

#[derive(Clone, Debug, EnumIter, DeriveActiveEnum, Serialize, Deserialize)]
#[sea_orm(rs_type = "String", db_type = "String(StringLen::N(15))")]
pub enum PostStatus {
    #[sea_orm(string_value = "draft")]
    Draft,
    #[sea_orm(string_value = "published")]
    Published,
    #[sea_orm(string_value = "archived")]
    Archived,
}

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize, EntityToModels)]
#[crudcrate(generate_router)]
#[sea_orm(table_name = "posts")]
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    #[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
    pub id: Uuid,

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

    pub slug: String,

    #[crudcrate(fulltext, exclude(list))]
    pub content: String,

    pub excerpt: Option<String>,

    #[crudcrate(filterable)]
    pub status: PostStatus,

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

    #[crudcrate(sortable, filterable)]
    pub published_at: Option<DateTimeUtc>,

    #[crudcrate(sortable, exclude(create, update), on_create = chrono::Utc::now())]
    pub created_at: DateTimeUtc,

    // Author (belongs_to)
    #[sea_orm(ignore)]
    #[crudcrate(non_db_attr, join(one, all, depth = 1))]
    pub author: Option<super::user::User>,

    // Comments (has_many) - only in detail view
    #[sea_orm(ignore)]
    #[crudcrate(non_db_attr, join(one))]
    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() }
}

Comment

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize, EntityToModels)]
#[crudcrate(generate_router)]
#[sea_orm(table_name = "comments")]
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    #[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
    pub id: Uuid,

    #[crudcrate(fulltext)]
    pub content: String,

    #[crudcrate(filterable)]
    pub post_id: Uuid,

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

    #[crudcrate(sortable, exclude(create, update), on_create = chrono::Utc::now())]
    pub created_at: DateTimeUtc,

    // Comment author
    #[sea_orm(ignore)]
    #[crudcrate(non_db_attr, join(one, all, depth = 1))]
    pub author: Option<super::user::User>,
}

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

    #[sea_orm(
        belongs_to = "super::user::Entity",
        from = "Column::AuthorId",
        to = "super::user::Column::Id"
    )]
    Author,
}

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

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

Router Setup

let app = Router::new()
    .merge(user::user_router())
    .merge(post::post_router())
    .merge(comment::comment_router())
    .layer(Extension(db));

API Examples

Create a User

curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Alice",
    "email": "[email protected]",
    "password_hash": "hashed_password",
    "bio": "Software developer"
  }'

Create a Post

curl -X POST http://localhost:3000/posts \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Getting Started with Rust",
    "slug": "getting-started-with-rust",
    "content": "Full article content...",
    "excerpt": "Learn the basics of Rust programming",
    "status": "published",
    "author_id": "{user-id}",
    "published_at": "2024-01-15T10:00:00Z"
  }'

List Published Posts with Authors

curl "http://localhost:3000/posts?filter={\"status\":\"published\"}&sort=[\"published_at\",\"DESC\"]"

Get Post with Comments

curl "http://localhost:3000/posts/{post-id}"

# Response includes author and comments
{
  "id": "...",
  "title": "Getting Started with Rust",
  "author": {
    "id": "...",
    "name": "Alice"
  },
  "comments": [
    {
      "id": "...",
      "content": "Great article!",
      "author": {"name": "Bob"}
    }
  ]
}

Add a Comment

curl -X POST http://localhost:3000/comments \
  -H "Content-Type: application/json" \
  -d '{
    "content": "This really helped me understand Rust!",
    "post_id": "{post-id}",
    "author_id": "{user-id}"
  }'

Search Posts

curl "http://localhost:3000/posts?q=rust programming"

Next: See the E-commerce Example for complex entity relationships with custom operations.

E-commerce Orders Example

Order management with products, customers, and line items demonstrating deep joins.

Note: This is a reference example. To see multi-level relationships in action, run:

cargo run --example recursive_join

This shows Customer → Vehicle → Parts/Maintenance (similar to Order → Items → Products).


Entities

Product

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize, EntityToModels)]
#[crudcrate(generate_router)]
#[sea_orm(table_name = "products")]
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    #[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
    pub id: Uuid,

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

    #[crudcrate(fulltext)]
    pub description: Option<String>,

    #[crudcrate(filterable, sortable)]
    pub price: Decimal,

    #[crudcrate(filterable)]
    pub category: String,

    #[crudcrate(filterable, sortable)]
    pub stock_quantity: i32,

    #[crudcrate(filterable)]
    pub active: bool,

    #[crudcrate(sortable, exclude(create, update), on_create = chrono::Utc::now())]
    pub created_at: DateTimeUtc,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

impl ActiveModelBehavior for ActiveModel {}

Customer

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize, EntityToModels)]
#[crudcrate(generate_router)]
#[sea_orm(table_name = "customers")]
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    #[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
    pub id: Uuid,

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

    #[crudcrate(filterable)]
    pub email: String,

    pub phone: Option<String>,

    pub shipping_address: Option<String>,

    #[crudcrate(sortable, exclude(create, update), on_create = chrono::Utc::now())]
    pub created_at: DateTimeUtc,

    // Customer's orders
    #[sea_orm(ignore)]
    #[crudcrate(non_db_attr, join(one))]
    pub orders: Vec<super::order::Order>,
}

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

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

Order

#[derive(Clone, Debug, EnumIter, DeriveActiveEnum, Serialize, Deserialize)]
#[sea_orm(rs_type = "String", db_type = "String(StringLen::N(20))")]
pub enum OrderStatus {
    #[sea_orm(string_value = "pending")]
    Pending,
    #[sea_orm(string_value = "confirmed")]
    Confirmed,
    #[sea_orm(string_value = "shipped")]
    Shipped,
    #[sea_orm(string_value = "delivered")]
    Delivered,
    #[sea_orm(string_value = "cancelled")]
    Cancelled,
}

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize, EntityToModels)]
#[crudcrate(generate_router, operations = OrderOperations)]
#[sea_orm(table_name = "orders")]
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    #[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
    pub id: Uuid,

    #[crudcrate(filterable)]
    pub customer_id: Uuid,

    #[crudcrate(filterable, sortable)]
    pub status: OrderStatus,

    #[crudcrate(sortable)]
    pub total_amount: Decimal,

    pub shipping_address: String,

    pub notes: Option<String>,

    #[crudcrate(sortable, filterable, exclude(create, update), on_create = chrono::Utc::now())]
    pub created_at: DateTimeUtc,

    #[crudcrate(exclude(create, update), on_create = chrono::Utc::now(), on_update = chrono::Utc::now())]
    pub updated_at: DateTimeUtc,

    // Customer
    #[sea_orm(ignore)]
    #[crudcrate(non_db_attr, join(one, all, depth = 1))]
    pub customer: Option<super::customer::Customer>,

    // Line items
    #[sea_orm(ignore)]
    #[crudcrate(non_db_attr, join(one))]
    pub items: Vec<super::order_item::OrderItem>,
}

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

    #[sea_orm(has_many = "super::order_item::Entity")]
    Items,
}

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

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

Order Item

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize, EntityToModels)]
#[crudcrate(generate_router)]
#[sea_orm(table_name = "order_items")]
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    #[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
    pub id: Uuid,

    #[crudcrate(filterable)]
    pub order_id: Uuid,

    #[crudcrate(filterable)]
    pub product_id: Uuid,

    pub quantity: i32,

    pub unit_price: Decimal,

    pub total_price: Decimal,

    // Product details at time of order
    #[sea_orm(ignore)]
    #[crudcrate(non_db_attr, join(one, all, depth = 1))]
    pub product: Option<super::product::Product>,
}

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

    #[sea_orm(
        belongs_to = "super::product::Entity",
        from = "Column::ProductId",
        to = "super::product::Column::Id"
    )]
    Product,
}

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

Custom Operations

pub struct OrderOperations;

impl CRUDOperations for OrderOperations {
    type Resource = Order;

    async fn before_create<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        &self,
        db: &C,
        data: &mut OrderCreate,
    ) -> Result<(), ApiError> {
        // Calculate total from items
        // Validate stock availability
        Ok(())
    }

    async fn after_create<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        &self,
        db: &C,
        created: &Order,
    ) -> Result<(), ApiError> {
        // Update stock quantities
        // Send confirmation email
        Ok(())
    }

    async fn before_update<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        &self,
        db: &C,
        id: Uuid,
        data: &mut OrderUpdate,
    ) -> Result<(), ApiError> {
        let order = Entity::find_by_id(id).one(db).await?.ok_or(ApiError::NotFound)?;

        // Prevent changing cancelled orders
        if order.status == OrderStatus::Cancelled {
            return Err(ApiError::BadRequest("Cannot modify cancelled order".into()));
        }

        // Validate status transitions
        if let Some(new_status) = &data.status {
            if !is_valid_transition(&order.status, new_status) {
                return Err(ApiError::BadRequest("Invalid status transition".into()));
            }
        }

        Ok(())
    }
}

API Examples

Create Order

curl -X POST http://localhost:3000/orders \
  -H "Content-Type: application/json" \
  -d '{
    "customer_id": "{customer-id}",
    "status": "pending",
    "total_amount": "99.99",
    "shipping_address": "123 Main St, City, Country"
  }'

Get Order with Items

curl "http://localhost:3000/orders/{order-id}"

# Response
{
  "id": "...",
  "status": "confirmed",
  "customer": {"name": "John Doe", "email": "..."},
  "items": [
    {"product": {"name": "Widget"}, "quantity": 2, "total_price": "49.98"}
  ]
}

Filter Orders by Status

# Pending orders
curl "http://localhost:3000/orders?filter={\"status\":\"pending\"}"

# Customer's orders
curl "http://localhost:3000/orders?filter={\"customer_id\":\"{id}\"}"

Update Order Status

curl -X PUT http://localhost:3000/orders/{id} \
  -H "Content-Type: application/json" \
  -d '{"status": "shipped"}'

Ready to dive deeper? Check out the Tutorial for step-by-step learning, or the Reference for all available options.

Frequently Asked Questions

General

What is CRUDCrate?

CRUDCrate is a Rust library that generates complete REST APIs from Sea-ORM entities using derive macros. It eliminates boilerplate code for CRUD operations, filtering, sorting, pagination, and relationships.

How does it compare to other solutions?

FeatureCRUDCrateManual AxumDieselOther ORMs
Code GenerationDerive macroManualManualVaries
FilteringBuilt-inManualManualVaries
PaginationBuilt-inManualManualVaries
RelationshipsAutomaticManualManualVaries
Type SafetyFullFullFullVaries

What security features are included?

CRUDCrate includes:

  • SQL injection prevention
  • Pagination DoS protection
  • Comprehensive error handling
  • Proper logging integration

Installation

What are the minimum Rust version requirements?

Rust 1.70+ is required for stable proc-macro features.

Do I need to install Sea-ORM separately?

Yes. CRUDCrate works alongside Sea-ORM:

[dependencies]
crudcrate = "0.15"
sea-orm = { version = "2.0", features = ["runtime-tokio-rustls", "sqlx-postgres"] }

Usage

Can I use CRUDCrate with an existing Sea-ORM project?

Yes! Just add #[derive(EntityToModels)] and #[crudcrate(...)] attributes to your existing entities.

How do I customize the generated endpoints?

Three ways:

  1. Attributes: Configure behavior with #[crudcrate(...)]
  2. CRUDOperations: Implement hooks for business logic
  3. Custom Handlers: Replace entire handlers when needed

Can I have some entities without routers?

Yes. Only add generate_router when you want endpoints:

// With router
#[crudcrate(generate_router)]

// Without router (just models)
#[crudcrate()]

How do I add authentication?

Use Axum middleware:

let app = Router::new()
    .merge(protected_router())
    .layer(middleware::from_fn(auth_middleware));

See Security for details.

Can I have different auth for different routes?

Yes. Use nested routers:

let public = Router::new().merge(public_routes());

let protected = Router::new()
    .merge(admin_routes())
    .layer(admin_auth_layer);

let app = Router::new().merge(public).merge(protected);

Which fields can be filtered?

Only fields marked #[crudcrate(filterable)]:

#[crudcrate(filterable)]  // Can filter
pub status: String,

pub secret: String,  // Cannot filter

How does fulltext search work?

Fields marked #[crudcrate(fulltext)] are searched with ?q=:

GET /items?q=search terms

The query uses database-optimized search (GIN for Postgres, FULLTEXT for MySQL).

Yes:

GET /items?filter={"status":"active"}&q=urgent&sort=["created_at","DESC"]

Relationships

  1. Define Sea-ORM relations
  2. Add join field with #[crudcrate(non_db_attr, join(one))]
#[sea_orm(ignore)]
#[crudcrate(non_db_attr, join(one))]
pub comments: Vec<Comment>,

Why are relationships not loading?

Check that:

  1. #[sea_orm(ignore)] is present
  2. #[crudcrate(non_db_attr)] is present
  3. Sea-ORM Related trait is implemented
  4. join(...) specifies one and/or all

How do I prevent circular references?

Use depth limit:

#[crudcrate(non_db_attr, join(one, depth = 2))]

Performance

Is CRUDCrate slow?

No. Generated code has zero runtime overhead. All generation happens at compile time.

How do I optimize for large tables?

  1. Add database indexes on filtered/sorted fields
  2. Use pagination (built-in limits: 1000 items max)
  3. Exclude heavy fields from lists: #[crudcrate(exclude(list))]
  4. Limit join depth

Does loading relationships cause N+1 queries?

No: get_all() with join(all) uses batch loading that reduces N+1 queries to just 2 queries (1 for parents + 1 per join field) using WHERE parent_id IN (...). This applies to depth=1 joins; deeper joins (depth > 1) may issue additional queries for nested relations. Single-item get_one() uses per-item queries, which is acceptable for individual lookups.

Troubleshooting

Compilation error: “cannot find derive macro”

Import it:

use crudcrate::EntityToModels;

Error: “field not found” when filtering

The field must be marked filterable:

#[crudcrate(filterable)]
pub status: String,

Relationships return empty

Ensure:

  1. Database has related records
  2. Sea-ORM Related trait is implemented
  3. Join is configured: join(one) or join(one, all)

“Too many items” error on bulk delete

Built-in safety limit is 100 items. Split into multiple requests.

Timestamp fields not auto-updating

Check:

  1. on_create and on_update are set
  2. Field is excluded from update model: exclude(update)
#[crudcrate(exclude(create, update), on_create = chrono::Utc::now(), on_update = chrono::Utc::now())]
pub updated_at: DateTimeUtc,

Migration

Can I migrate from manual handlers?

Yes. CRUDCrate is additive. You can:

  1. Start with one entity
  2. Keep existing handlers for others
  3. Gradually migrate

How do I migrate to a new version?

Check the Changelog for breaking changes. Most updates are backward compatible.

Contributing

How can I contribute?

See the Contributing section of the README.

Where do I report bugs?

Open an issue on GitHub.

Is there a roadmap?

Planned features:

  • GraphQL support
  • OpenAPI generation
  • More database optimizations
  • Cursor-based pagination option

Learn More

Changelog

All notable changes to the crudcrate project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

0.15.0 - 2026-09-24

Added

  • #[schema(...)] on an entity field is accepted by EntityToModels and forwarded to the generated API and List models, so a column whose Rust type says less than the value it holds (a jsonb serde_json::Value over an array of strings) declares its shape where the schema is derived: #[schema(value_type = Vec<String>)]. Previously the attribute did not compile on the entity, and a consumer had to redeclare the wire shape by hand.
  • register_new, an insert-only registration on the same upsert_key lookup as upsert. A key already stored is returned exactly as it stands and reported RegistrationStatus::Kept; nothing is written over. For a row whose stored content is the arithmetic something else was already computed with, where re-registering the key must not move it. RegistrationStatus is its own enum rather than a third UpsertStatus variant, so no existing response or schema widens.

Fixed

  • A list’s tie-break is every key column, in the requested sort direction, in the derived get_all bodies as well as the default one. The derived bodies appended only the first key column, so a composite key did not order a page, and every body tie-broke ascending, so a descending walk was not the ascending one reversed. A descending page whose rows tie on the sort column now lists them in descending key order.

0.14.0 - 2026-09-11

Added

  • Composite primary keys. An entity may mark more than one field #[crudcrate(primary_key)]; the key’s value is the tuple SeaORM spells it as, in declaration order. ResourceId carries what a tuple cannot implement itself, rendering a key into a not-found message and decomposing it into the column values a query binds, and CRUDResource::id_columns() names the columns. Pagination tie-breaks on every key column, and a batch operation filters on whole keys: a single-column key stays an IN list, a composite key becomes an OR of per-key equality.

    No route that addresses one row by its id is mounted for a composite key. Those take the key through Path<PrimaryKeyType<Self>> against a one-segment /{id}, which a tuple cannot deserialize from, so the route would answer 500 to every call. Reads, the generated models and every CRUDResource method are unaffected.

  • A registration key may declare the predicate it is unique under. Where the unique index backing upsert_key is partial, upsert_where = <expr> names its WHERE clause, the same SeaORM expression OnConflict::target_cond_where takes, and the find is narrowed by it. A row the index does not cover is not the key’s row, so registering a slot whose previous row has left the index opens a new row instead of updating the old one. The predicate is exposed as CRUDResource::upsert_predicate(), defaulting to every row. Naming it without an upsert_key is a compile error. See cargo run --example partial_upsert.

Fixed

  • Two callers registering one key concurrently no longer fail on the unique index. upsert read the key and inserted where it read as absent, with nothing holding the key between the two, so under READ COMMITTED both transactions read absent and the second violated the constraint. The insert now runs inside a savepoint and the loser re-reads the row that won, reporting Updated or Unchanged against it.

0.13.0 - 2026-09-09

Changed

  • upsert takes the resource’s active model. Convert a create model with .into() and set any server-owned key columns before calling it. Registration keys can remain excluded from create and update requests. See docs/MIGRATION_0.13.md.
  • Scoped writes confine rows instead of refusing every request. Updates and deletes return 404 for excluded rows; creates and updates roll back with 403 when their resulting row leaves the condition. Batch checks share the write transaction, including custom hooks; partial batches confine each item independently. Write responses use scoped field exclusions. Mount read_only_router() or authorize methods separately when a caller must not write. See docs/MIGRATION_0.13.md.

Fixed

  • A nullable field on a List, Response, scoped or API model is required in the OpenAPI document. utoipa reads every Option<T> as not-required and nullable, so a value the API always sends as null and one a client may omit were described identically, and a generated client had to handle an absence that never happens. A field carrying skip_serializing_if, or declaring its own #[schema(required)], keeps what it declares; create and update models are requests and are untouched.

0.12.0 - 2026-09-08

Added

  • The CRUD stack takes the connection as a type parameter, so every operation and every hook accepts a &DatabaseTransaction as readily as a &DatabaseConnection. A caller composing its own transaction runs a crudcrate operation inside it, and a hook writing another resource writes on the same transaction as the row it guards. Trait methods and hook functions are declared <C: ConnectionTrait + TransactionTrait> taking db: &C. See docs/MIGRATION_0.12.md.
  • CRUDOperations::after_begin runs immediately after every BEGIN a write issues, before any other hook on that transaction: once for a single-row lifecycle, and in a batch once for the enclosing transaction and again for each row’s savepoint. It is where the session state a write needs is set (SET LOCAL), scoped to that transaction rather than to a pooled connection.
  • Registration: crudcrate::upsert finds or inserts a row on an alternate unique key inside one transaction and reports Created, Updated or Unchanged, so a source system re-sending its own content writes nothing when the content already stands. The key is declared with #[crudcrate(upsert_key(source_system, source_key))], which also generates the columns a registration compares and writes (the create model’s stored columns but the primary key); CRUDResource::upsert_key, upsert_comparable and apply_on_update are the trait surface behind it.
  • UpsertOutcome<K, I, S> reports one item of a registration batch keyed by what the sender holds, carrying a status, the row’s id and a note. It is generic over the status so a caller with refusals of its own reports them in the same list rather than as failures, which BatchResult (per-item success-or-error) cannot express.
  • #[crudcrate(routes(read, update))] names the route families router() mounts, out of create, read, update and delete. An operation left out is not a route, so the OpenAPI document does not advertise it. Omit the attribute for every route, which stays the default.

Changed

  • The CRUDResource and CRUDOperations traits use native async fn instead of async-trait. An impl of either drops its #[async_trait] attribute, and async-trait is no longer a dependency of crudcrate or crudcrate-derive. The traits declare each method as returning impl Future + Send, so code generic over the resource spawns or boxes an operation’s future as it did under async-trait. An impl writes async fn as before.
  • A write lifecycle holds one pooled connection from BEGIN to COMMIT, hooks included. A hook that opens its own connection from the same pool rather than using the db it is handed can exhaust the pool under concurrent writes; see docs/MIGRATION_0.12.md.
  • The single-row create, update and delete lifecycles each run in one transaction. A hook that fails after the write takes the write with it, where before the row stayed and the error was returned. Both the write and the hooks around it now succeed or fail together.
  • create_many, update_many and delete_many run their rows in one transaction, so a batch that fails partway leaves none of its rows. Each row’s own lifecycle is a savepoint within it. The ?partial=true endpoints are unaffected: they run each row on the connection and still report per-item outcomes with 207 Multi-Status.

Fixed

  • A registration whose entity maintains its own timestamps reported Updated on every pass and rewrote created_at. The columns a registration compares are those the create model can carry, so a field the entity manages is neither compared nor overwritten, and one carrying on_update advances only when the row does change.

0.11.1 - 2026-09-07

Fixed

  • CRUDOperations::create_many and update_many run the single-row create / update lifecycle per item instead of delegating to CRUDResource, which the derive generates as a call back into the operations struct: an entity declaring operations = X without overriding both recursed until the stack overflowed on every batch request.
  • CRUDOperations::perform_delete_many deletes one row at a time through delete, so before_delete and after_delete fire for every row of a batch. An id that is not there is skipped, as before.

0.11.0 - 2026-09-03

Added

  • #[crudcrate(max_child_rows = N)] and SecurityProfile::max_child_rows_per_relation cap the child rows one join field may load per request; exceeding the cap returns 413 Payload Too Large (new ApiError::PayloadTooLarge). Unlimited unless set.
  • join(relation = "Variant") selects the child’s Relation variant for foreign key resolution.

Changed

  • build_filter_expr and build_comparison_expr return Result<Option<Expr>, ApiError> instead of Option<Expr>. Hand-written resolve_joined_filters implementations add ? at the call site; generated code is updated. See docs/MIGRATION_0.11.md.

Fixed

  • An empty array filter (filter={"id":[]}) dropped the clause and returned every row. It now matches nothing, which is what an empty IN list asks for.
  • A numeric filter value against a date, time or uuid column (filter={"created_at_gte":5}) was bound as a number and rejected by the backend. The clause is now parsed against the column type and dropped when it does not fit. Numeric columns are unaffected: {"year_gte":3.5} and integers above i64::MAX bind as before.
  • Array filters (filter={"created_at":["...","..."]}) bound every element as text, so an IN list over a date, timestamp, decimal or float column was rejected by Postgres and compared lexically elsewhere. Elements are now parsed against the column’s SQL type, as scalar filters already were. An element that does not parse returns 400 Bad Request rather than dropping the clause and returning unfiltered rows.
  • build_comparison_expr bound every non-UUID string as text, so a hand-written resolve_joined_filters comparing a non-text child column had the same defect. It now routes by column type on both its scalar and array arms.
  • Join loading at depth 2 or more read related_model.id, so a child entity whose primary key field has another name failed to compile. Loaders now go through the new provided CRUDResource::pk_value(&model), which the derive overrides with the actual field.
  • Generated routers named tracing::info! without qualification, so a crate without a direct tracing dependency failed to compile. Generated code now goes through crudcrate::tracing.
  • Column variant idents for field names with a digit boundary (is_2fa_enabled) were built with convert_case (Is2FaEnabled) while Sea-ORM names them with heck (Is2faEnabled), so such entities failed to compile. All column idents now use heck.

Deprecated

  • Relying on the use items that crud_handlers! leaks into the calling module (StatusCode, Json, Path, …). Import them directly; the leak is removed in the next breaking release. The macro no longer needs CRUDResource in scope at the call site. Scheduled for removal in the next breaking release:

  • UuidIdResult, unused since delete_many became generic over the primary key type.

  • filtering::sort::generic_sort: parses every value as a JSON pair, so a plain column name falls back to the default column. Use parse_sorting.

  • ValidationErrors: generated validation returns the singular ValidationError.

  • DefaultCRUDOperations: never constructed by generated code.

  • BatchResult::all_succeeded: check failed.is_empty().

  • generate_crud_router!: collapses every response model to the resource type. Use #[crudcrate(generate_router)].

  • The five- and three-argument arms of crud_handlers!.

  • The crudcrate::database and crudcrate::relationships modules, which contain no items.

Removed

  • The empty, hidden crudcrate::routes module.
  • The spring-rs feature and its optional spring and spring-web dependencies. No code was gated on it; spring-web applications mount the generated axum router directly.

Changed

  • Joined filters (?filter={"vehicles.make":"bmw"}) go through the same value dispatch as main-entity filters: string equality folds case, enum columns are cast on Postgres, like_filterable columns and _like use a case-insensitive LIKE, and typed columns bind typed values. New public build_filter_expr exposes that dispatch for custom resolvers.
  • Boolean filters with _neq now negate; previously the suffix was ignored.
  • crudcrate::core::crud_operations (the handler macros, no nameable items) is now crudcrate::core::handler_macros. The macros are unchanged and still exported at the crate root.
  • The handler macros name apply_filters, parse_pagination, FilterOptions, calculate_content_range and parse_sorting at the crate root instead of through the filter, models, pagination and sort alias modules. The aliases remain exported.
  • Generated code is pinned by expansion snapshots in crudcrate-derive/tests/expand, and test_suite carries a compile-only test naming every crudcrate:: path generated code emits.

0.10.1 - 2026-08-20

Fixed

  • get_one_scoped ignored read::one::body. The scoped get-one handler had no custom-body branch, so a resource combining a ScopeCondition with a custom get-one body silently ran the default query instead. Whatever the body existed to do, extra filtering, joins, shaping, never happened on scoped requests, and rows the body would have refused were served. The handler now checks scope eligibility in SQL first, answers 404 when the scope excludes the row, then delegates to the custom body with the same pre/transform/post hooks as the unscoped path.
  • Scoped models were generated but never mounted for non-db exclusions. Model generation and router wiring used different definitions of “has scoped fields”: the models counted exclude(scoped) on join and non_db_attr fields, the router did not. When such a field was the only exclusion, the ScopedList/ScopedOne structs existed but the router served the plain models, so the supposedly excluded field still reached scoped callers. Both sites now share one predicate.
  • require_scope write semantics documented and pinned. The flag governs reads only: an unscoped read fails with 500, while writes are governed solely by scope presence (403 when a ScopeCondition is present, allowed when absent). That split is what lets an application mount its scope on safe methods only, so writes arrive unscoped deliberately. The trait doc, the multi-tenant guide and new enforcement tests now state the contract explicitly. No behaviour change.
  • Validation docs described an API that does not exist. docs/src/advanced/validation.md showed a ValidationFailed(Vec<ValidationError>) tuple variant and never mentioned the Validatable trait, the mechanism the handlers actually invoke. The page now documents Validatable and the real ApiError::validation_failed(Vec<String>) constructor.

0.10.0 - 2026-08-19

Changed

  • SeaORM 2.0. The workspace now builds against sea-orm = "2.0" (SeaQuery 1.0, SQLx 0.9). Applications must upgrade their own sea-orm dependency in the same step; crudcrate 0.9.x remains available for SeaORM 1.x. See docs/MIGRATION_0.10.md.
  • Batch create uses a single multi-row INSERT ... RETURNING on backends that support it (PostgreSQL, SQLite >= 3.35); MySQL keeps the per-row insert loop inside the transaction. Response order, all-or-nothing semantics, 409 duplicate-key mapping and ?partial=true behaviour are unchanged. The sqlite feature now enables SeaORM’s sqlite-use-returning-for-3_35. Behaviour change: custom ActiveModelBehavior::before_save/after_save implementations are no longer invoked per row during batch create on the RETURNING backends (they still run on MySQL, and single create never invoked them). Response order relies on the engine emitting RETURNING rows in VALUES order, which PostgreSQL and SQLite implement but do not formally guarantee.

Added

  • Dense entity format support. Entities written with SeaORM 2.0’s #[sea_orm::model] (inline HasMany/HasOne/BelongsTo relation fields) can derive EntityToModels directly. The derive attaches to the scalar Model and ignores the generated ModelEx companion, so relation wrapper fields never leak into Create/Update/List models.
  • crudcrate::table_column_ref(table, column) builds a table-qualified ColumnRef for custom resolve_joined_filters implementations, replacing the ColumnRef::TableColumn variant removed in SeaQuery 1.0.
  • Guided compile errors for misplaced dense-format relations. A HasMany/HasOne/BelongsTo field on a struct not processed by #[sea_orm::model] now produces a spanned error naming the fix instead of silently generating nothing. Both crates also declare rust-version = "1.85" so an old toolchain fails with a clear MSRV message.
  • #[crudcrate(deny_unknown_fields)]. Opt-in strict input models: the generated <Name>Create and <Name>Update reject a payload key they do not accept instead of ignoring it. Without it, a value sent for a field excluded from create or update is silently dropped and the response reports the stored value rather than the submitted one. Off by default, since clients that round-trip a full record into an update legitimately send read-only fields.
  • crudcrate::sea_orm re-export. Applications can reference the SeaORM version crudcrate builds against (ie. use crudcrate::sea_orm;) instead of pinning a matching sea-orm dependency themselves.

Fixed

  • List pagination could repeat or skip a row. get_all ordered by the requested sort column alone, so rows tied on that column had no defined order and two LIMIT/OFFSET queries serving two pages could place a tied row in both or neither. The primary key is now appended as a secondary sort key whenever the sort column is not the primary key, in the trait default get_all, the derive-generated get_all/get_all_scoped, the joined (dot-notation) sort, and CRUDOperations::fetch_all. A custom get::all::body override supplies its own ordering and is unaffected.
  • List models dropped field attributes. <Name>List was generated with the field name and type only, so #[serde(skip_serializing_if = ...)], #[serde(rename = ...)], #[schema(...)] and doc comments applied to the single-record response but not to entries in the collection response, e.g. a field skipped when None came back as an explicit null in a list. serde, schema and doc attributes now carry over to <Name>List, <Name>ScopedList and <Name>ScopedResponse, matching <Name>Response, which already did this.
  • per_page=0 produced an empty page. parse_pagination clamped only the upper bound, returning a limit of 0 that yields a page with no rows however many rows match (and a zero page size that some paginators reject outright). It now clamps to 1..=MAX_PAGE_SIZE.

Notes

  • SeaORM 2.0’s Entity Loader was evaluated and not adopted for join loading: it requires the dense entity format on user entities and offers no hook for per-child scope conditions. Typed COLUMN constants were likewise evaluated and skipped; generated code keeps using the Column enum paths. Rationale in the relationships documentation.

0.9.3 - 2026-07-15

Fixed

  • Comparison filters on non-text columns returned 500 on PostgreSQL. A _gte/_lte/_gt/_lt/_neq (or equality) filter whose value arrived as a JSON string wrapped the column in UPPER(col), which PostgreSQL rejects for date, timestamp, numeric, uuid and boolean columns (function upper(timestamp with time zone) does not exist); SQLite’s loose typing hid it, and even there the comparison ordered lexically ('9' > '10'). Comparison values are now parsed to the column’s Sea-ORM ColumnType and bound as a typed parameter, so the backend compares natively. Text and enum columns keep case-insensitive matching. Requires the with-chrono and with-rust_decimal Sea-ORM features (now enabled by default).

0.9.2 - 2026-06-10

Added

  • Non-UUID primary keys. CRUDResource is now generic over the entity’s primary-key value type via crudcrate::PrimaryKeyType<Self>, so integer (i32), string, and other Sea-ORM key types work end to end (CRUD, batch, filtering, sorting, pagination, relationship loading, and the generated Axum routes). UUID remains fully supported and unchanged.
  • Joined sorting. sort=["children.column","ASC|DESC"] now actually orders parent rows by the joined child column, via a correlated sub-query (ORDER BY (SELECT MIN(child.col) FROM child WHERE child.fk = parent.pk)). Previously the dot-notation sort was parsed but silently ignored.
  • Automatic Validatable invocation. When a Create/Update model implements crudcrate::validation::Validatable, the generated create/update/batch handlers now call validate() before any write (HTTP 422 on failure). Models that don’t implement it are unaffected (no-op).
  • Multi-backend coverage in CI. The PostgreSQL and MySQL CI jobs now upload cargo llvm-cov results to Codecov, so backend-specific code paths are measured (previously only the SQLite job reported coverage).

Changed

  • BREAKING (trait signatures). CRUDResource and CRUDOperations methods that took id: uuid::Uuid / Vec<uuid::Uuid> now take crudcrate::PrimaryKeyType<Self> / Vec<PrimaryKeyType<Self>>, including get_one, get_one_scoped, update, delete, delete_many, update_many and the CRUDOperations before_* / after_* / perform_* / fetch_* hooks. For UUID resources PrimaryKeyType<Self> resolves to Uuid, so runtime behavior and the generated routes are unchanged. Migration: downstream code that overrides any of these trait methods/hooks must change the Uuid parameter/return types to crudcrate::PrimaryKeyType<Self> (or the concrete PK type). Code that only derives EntityToModels needs no change.
  • POST /{resource}/batch?partial=true now builds each item with create_many semantics, so partial and all-or-nothing batch create return the same (flat) response shape and run the same create::many hooks. Single POST /{resource} still applies join loading + read::one::transform.
  • delete_many de-duplicates the returned ids so the reported count matches the rows actually deleted.
  • Self-referencing #[schema(no_recursion)] detection now uses an exact inner-type match (not a substring), and deriving Eq now also derives PartialEq.

Fixed

  • LIKE wildcard escaping was a no-op on SQLite for the fulltext LIKE-fallback and joined _like paths: escaping used a backslash but emitted no ESCAPE clause, so user-supplied %/_ stayed active wildcards. These paths now use the ! escape convention with an explicit ESCAPE '!' clause.
  • Panic on oversized multi-byte search queries. Fulltext truncation sliced at a fixed byte index and panicked when it landed mid-UTF-8-codepoint; it now snaps to a char boundary.
  • React-Admin range pagination overflow on a huge end value (now uses saturating arithmetic).
  • Scoped get_one masked all errors as 404, hiding DB/internal faults; it now propagates the real error (a scope miss is still 404).
  • Validation length/email helpers counted UTF-8 bytes, not characters, wrongly rejecting multibyte input within the advertised character limit.
  • Option<T> belongs_to relations were not loaded in list endpoints (the batch loader resolved the FK in the wrong direction); now resolved via find_related.
  • Duplicate-key inserts returned 500 instead of the documented 409 Conflict.
  • Foreign-key violations returned 500 instead of 409 Conflict; they are now mapped to 409 like unique-constraint violations (matching the documented response), across all backends.
  • Array filters on integer/boolean columns returned 500 on PostgreSQL. ?filter={"id":[1,3]} built id IN ('1','3') (string literals), which PostgreSQL rejects as operator does not exist: integer = text; SQLite’s loose typing hid it. Array values are now bound as their native JSON type.
  • Generated depth > 1 join code required a tracing dependency in the downstream crate (it emitted unqualified tracing::warn!). The generated calls are now qualified as crudcrate::tracing::warn!, so consumers no longer need to depend on tracing directly.
  • Examples: corrected error_handling printed messages to match ApiError output, fixed the misleading recursive_join/recursive_join_5_levels depth claims, trimmed joined_filter boilerplate, and listed all examples in the examples README.

Security

  • Partial batch-delete leaked row existence under the secure profile. With expose_deleted_ids = false, the ?partial=true response serialized per-item not-found errors (embedding the submitted UUIDs), re-creating the enumeration oracle that profile is meant to close. It now returns failed_count instead of the per-item failed list.

Known limitations

  • Joined-filter sub-queries and Vec<Child> batch loading apply no per-relation row cap; they are bounded only by MAX_FILTER_CLAUSES (100) and the parent page size. On very large child tables this is a query-amplification consideration for untrusted callers.
  • Case-insensitive fulltext and like_filterable matching on MySQL/SQLite is ASCII-only (Rust-side to_uppercase() vs the database’s UPPER()); PostgreSQL (ILIKE) is unaffected.
  • Join loading assumes the entity’s primary-key field is named id. The PK value type is generic, but a join target whose PK field has a different name will not compile.

0.9.1 - 2026-06-01

Fixed

  • LIKE queries broken on Postgres. build_like_condition and the fulltext search functions used ? as the bind placeholder in Expr::cust_with_values templates. Sea-query’s Postgres backend uses $ as its placeholder character, so the ? was passed through as a literal, and Postgres then parsed ? ESCAPE '!' as a JSONB operator followed by a type cast, producing type "escape" does not exist. Fixed by using $1 for Postgres and ? for MySQL/SQLite.

  • build_like_condition missing ESCAPE clause. The LIKE condition for like_filterable fields used sea-query’s .like() which never emitted an ESCAPE clause. Rewritten to use Expr::cust_with_values with ESCAPE '!', matching the fulltext functions.

  • Fulltext search on non-text columns. The fallback LIKE search path (when fulltext_searchable_columns() is empty) applied UPPER(col) LIKE ... to all searchable columns including booleans. Postgres and MySQL reject UPPER(boolean). Fixed by casting columns to TEXT (Postgres/SQLite) or CHAR (MySQL) before UPPER().

  • LIKE escape character conflicts with Postgres string quoting. Switched escape_like_wildcards from backslash to ! as the escape character. Backslash inside single-quoted SQL strings is ambiguous across backends (Postgres standard_conforming_strings).

Changed

  • Removed dead codegen helpers (runtime_fk_* functions) from crudcrate-derive.

  • Resolved clippy warnings across the workspace (collapsible ifs, duplicate match arms, missing error docs, unwrap after is_some).

  • Updated trybuild snapshot for rustc 1.96.

0.9.0 - 2026-05-19

Security

  • SecurityProfile config struct + presets. New crudcrate::SecurityProfile bundles the security-sensitive runtime defaults (strict filter parsing, scope propagation, deleted-ID exposure, and request body size) under one type with three presets: SecurityProfile::secure(), react_admin(), and legacy(). Override individual fields via Rust’s struct-update syntax: SecurityProfile { expose_deleted_ids: true, ..SecurityProfile::secure() }.

  • Per-resource override via derive attribute. #[crudcrate(security_profile = "secure" | "react_admin" | "legacy")] generates a CRUDResource::security_profile() impl that returns the named preset.

  • Global override via Axum extension. Apply .layer(Extension(SecurityProfile::secure())) on your router to override the per-resource setting at request time. Resolution order: Extension > CRUDResource::security_profile() > trait default.

  • Default profile flipped to secure(). New resources ship hardened defaults. See MIGRATION_0.9.md for the per-flag breakdown and opt-out instructions.

  • Explicit batch body limit. The generated router now applies an Axum DefaultBodyLimit::max(...) layer derived from SecurityProfile::max_request_body_bytes (default 2 MiB, matching axum-core’s baseline). Previous behavior relied on Axum’s implicit default and broke if any consumer wired DefaultBodyLimit::disable() up the tree.

  • Scope-propagation side-channel guard. Under secure() profile, joined filters (?filter={"vehicles.color":"..."}) on a child entity that has no exclude(scoped) scope condition are rejected with 400 Bad Request when the request carries a ScopeCondition. Prevents parent-existence side-channels via unscoped child columns.

  • Strict filter parsing. Under secure() profile, a malformed ?filter=... value returns 400 instead of silently dropping the filter and returning the unfiltered result.

  • Deleted-ID enumeration guard. Under secure() profile, batch delete responses return {"deleted": N} instead of the array of UUIDs that actually existed in the database, removing the existence-enumeration side-channel through the delete endpoint. react-admin frontends that rely on the ID array for cache invalidation should pin SecurityProfile::react_admin() or legacy().

  • Fulltext SQL bind parameterization. The Postgres / MySQL / SQLite fulltext condition builders now route the user query value through Expr::cust_with_values so the value is bound as a parameter rather than interpolated into the SQL string. Defense-in-depth: column names were already compile-time-known, but raw SimpleExpr::Custom(format!(...)) was removed everywhere user input could reach it.

Fixed

  • Join loading with operations attribute. Entities using #[crudcrate(operations = MyOps)] for create/update/delete hooks had their join loading silently bypassed on get_one and get_all; the codegen delegated entirely to CRUDOperations which does plain queries with no relation loading. The operations path now falls through to the standard join-loading codegen when the entity has join(...) fields, with before_get_one/after_get_one and before_get_all/after_get_all hooks wrapping the join-loaded body. get_one_scoped and get_all_scoped are also generated for this path (previously missing entirely).

  • FK column resolution in batch loading. The batch loader and join loader now resolve FK columns from the SeaORM RelationDef at runtime instead of guessing from the struct name convention. Joins with non-standard FK names (eg. author_ref instead of author_id) now load correctly.

Changed

  • Replaced unmaintained impls = "1" (no release since 2019) with an inline crudcrate::impls! macro. Same autoref-specialization semantics, 30 LOC, no behavior change.

  • Workspace dependencies bumped: axum 0.8.6 → 0.8.9, sea-orm 1.1.19 → 1.1.20, serde_json → 1.0.149, uuid → 1.23.1, tokio → 1.52.3, chrono → 0.4.44, tower-http → 0.6.11, utoipa → 5.5.0, plus proc-macro and rust_decimal patches.

  • url-escape (unmaintained dev dep) replaced with percent-encoding.

Documentation

  • README.md: added security caveat for the mysql feature, which pulls in rsa 0.9.10 (RUSTSEC-2023-0071, Marvin attack, no upstream fix).

0.8.1 - 2026-05-19

Security

  • Filter clause limit. Requests with more than 100 filter keys are rejected with 400 Bad Request (MAX_FILTER_CLAUSES = 100). Prevents query-planning DoS via oversized filter payloads.

  • DB error sanitization. Internal database error messages are stripped from client-facing responses. Only a generic prefix is returned; the full error is logged via tracing.

Added

  • Joined filters are now applied by the default handler. Requests like GET /customers?filter={"vehicles.make":"BMW"} previously parsed and whitelisted the filter but silently dropped it before hitting the database; users got unfiltered results. The default get_all_handler now resolves each JoinedFilter into a sub-query on the child table (with the child’s ScopeFilterable::scope_condition() applied), collects matching parent-FK values, and adds id IN (...) to the main condition. Query shape: one extra SELECT parent_fk FROM child WHERE ... per joined-filter field plus the usual list + count queries: no JOIN, no DISTINCT. Backed by test_suite/tests/joined_filter_http_test.rs and a runnable cargo run --example joined_filter.

  • New CRUDResource::resolve_joined_filters trait method. Takes the parsed condition plus the &[JoinedFilter] list and returns the augmented condition to use for both the list query and the count query. Default impl logs and returns the condition unchanged (backward compatible for non-derive users); the derive macro generates an override for every resource that declares join(..., filterable(...)) on any Vec<Child> field.

  • New public helper crudcrate::build_comparison_expr. Translates a column + FilterOperator + serde_json::Value into an Option<SimpleExpr> for use in custom filter resolvers.

Changed

  • crudcrate::filtering::ParsedFilters::joined_filters is now consumed by the handler (previously only populated by the parser and read by tests). No API change; the field was already public.

  • Pruned unused dependencies from the workspace.

Documentation

  • docs/src/features/filtering.md “Filtering on Related Entities” rewritten to describe the actual query shape, scope-safety guarantees, and the Vec<Child>-only limitation. Removed the stale “requires a custom read::many::body hook” note.
  • docs/src/features/relationships.md migrated from the deprecated join_filterable(...) / join_sortable(...) syntax to the current filterable(...) / sortable(...) inside join(...).

0.8.0 - 2026-04-17

Security

  • Atomic scope check in get_one: Scoped get_one requests now verify the scope condition in a single query (ID + scope filter), eliminating a TOCTOU race where a separate total_count() verification could see stale data between the fetch and the check.
  • FK column runtime validation: The derive macro generates #[cfg(test)] functions that verify convention-derived FK column names match the actual RelationDef from SeaORM at test time. Catches silent data mismatches from FK naming convention violations before they reach production.
  • SQL-level scope filtering for joins (all endpoints, all depths): Child entities with exclude(scoped) fields are now filtered at the SQL level (WHERE is_private = false) during join loading on both get_one_scoped and get_all_scoped, and at every depth when depth > 1. The scoped batch loader applies each child’s ScopeFilterable::scope_condition() to its Entity::find().filter(FK in parent_ids) query, and recurses via get_one_scoped (not get_one) for nested children. The in-memory ScopeFilterable::is_scope_visible() filter remains as defense-in-depth, but privacy is now enforced in the database, not just at serialisation time: private rows never leave Postgres on public endpoints.
  • require_scope attribute: New #[crudcrate(require_scope)] struct-level attribute. When set, read handlers return HTTP 500 if no ScopeCondition middleware is present; this catches misconfigured routes that should be scoped but aren’t.

Added

  • Struct-level join definitions: Join fields can now be defined at the struct level instead of on the SeaORM Model. This keeps the Model lightweight and avoids stack overflow when loading entities with heavy join types. The join field only exists on the generated API struct.

    #[crudcrate(
        api_struct = "Site",
        join(name = "replicates", result = "Vec<SiteReplicate>", one, all, depth = 1)
    )]
    pub struct Model { /* no replicates field here */ }

    Field-level joins with #[sea_orm(ignore)] + #[crudcrate(non_db_attr, join(...))] still work for backward compatibility.

  • SQL-level column exclusion for exclude(list): Fields marked #[crudcrate(exclude(list))] with Option<T> types are now skipped at the SQL level in list queries; the database never transfers the data. Previously, exclude(list) only removed the field from the response struct while still fetching all columns. This dramatically improves performance for entities with large fields (photos, blobs, documents). Benchmarked at 7x improvement (1,013 → 7,121 req/s) on an endpoint with base64 photo data.

  • ScopeCondition for auth-aware query filtering: New ScopeCondition type that can be injected via Axum Extension to add conditions to read queries. Auth-system-agnostic: users write middleware to convert their auth state into a ScopeCondition. When present, get_all_handler merges the condition into the query filter, and get_one_handler verifies the fetched record passes the condition. Write operations are unaffected.

    use crudcrate::ScopeCondition;
    let public = Article::read_only_router(&db)
        .layer(Extension(ScopeCondition(
            Condition::all().add(article::Column::IsPrivate.eq(false))
        )));
  • read_only_router() method: Generates a router with only GET endpoints (get_one + get_all), no create/update/delete. Use with ScopeCondition for public/filtered API endpoints.

  • fk_column join parameter: Optional fk_column = "ColumnName" in join(...) attributes for entities where the FK column doesn’t follow the {StructName}Id convention. The convention remains the default; this is an escape hatch for non-standard schemas.

    #[crudcrate(join(one, all, depth = 1, fk_column = "OwnerUuid"))]
    pub items: Vec<Item>,
  • ScopeFilterable::scope_condition(): New trait method that returns a sea_orm::Condition matching an entity’s exclude(scoped) fields. Auto-generated by the derive macro. Enables SQL-level scope filtering for join queries.

  • get_one_scoped / get_all_scoped: New CRUDResource trait methods with scope-aware query variants. Default implementations delegate to the non-scoped get_one / get_all (safe for resources without join(all) children). The derive macro overrides both with SQL-level child-scope propagation. get_all_handler dispatches to get_all_scoped whenever a ScopeCondition extension is present.

Fixed

  • Stack overflow with many joins: All join-loading futures are now Box::pinned, moving large async state off the stack. Prevents stack overflow in debug builds with many join fields.
  • Async state machine bloat in debug builds: All join-loading futures are wrapped in Box::pin, preventing debug-build async state machine bloat from Related<E> monomorphization.

Changed

  • depth = 0 is now a compile error: Use depth = 1 for shallow loading. Previously depth = 0 could cause infinite recursion at runtime.
  • Compile-time bidirectional relation detection: Joins targeting an entity that has a Related<Self> impl (bidirectional/cyclic relationship) now produce a compile error unless an explicit depth is set. Previously, these silently caused infinite recursion at runtime via SeaORM’s Relation::def() chain. The error message explains the cycle and suggests the fix.
  • Compile-time warnings for risky join depths: Self-referencing joins without an explicit depth and joins with depth > 5 now emit #[deprecated] warnings at compile time, guiding users to set safe depth values.

0.7.2 - 2026-03-27

Added

  • Automatic enum field detection: Fields with types implementing sea_orm::ActiveEnum are now detected at compile time; no #[crudcrate(enum_field)] annotation needed. Uses zero-cost compile-time trait detection (inherent impl trick) to check each field’s type.
  • Case-insensitive enum array filtering: Array/IN filters on enum fields now apply UPPER(CAST(col AS TEXT)) on Postgres, matching the case-insensitive behavior already used for single-value enum filters.

Deprecated

  • #[crudcrate(enum_field)]: No longer required. Enum fields are auto-detected from the ActiveEnum trait implementation. The attribute still works for backward compatibility but can be safely removed.

Fixed

  • Array/IN filtering on enum fields: process_array_filter() now handles enum fields by casting to TEXT and uppercasing on Postgres. Previously, array filters on enum columns could fail on native Postgres ENUM types or produce case-sensitive results.

0.7.1 - 2026-03-09

Added

  • Transform Hooks: New transform phase in hook system for result modification
    • Hook execution order: pre → body → transform → post
    • Transform hooks receive the result and return a modified version
    • Allows enriching, decorating, or transforming CRUD results before returning
    • Supported for all operations: create, read, update, delete (one and many)
    • Example: #[crudcrate(read::one::transform = enrich_with_metadata)]
  • Partial Success for Batch Operations: New ?partial=true query parameter for batch endpoints
    • Returns HTTP 207 Multi-Status when some items succeed and some fail
    • Response includes succeeded and failed arrays with indices and error messages
    • Available for: POST /batch, PATCH /batch, DELETE /batch
    • New types: BatchResult<T>, BatchFailure, BatchOptions
    • Note: Partial mode processes items individually using single-item hooks (create::one::*, etc.), not batch hooks (create::many::*). Each item commits independently with no shared transaction.
  • Batch Create/Update Endpoints: POST /batch and PATCH /batch for bulk operations
    • Transaction-based all-or-nothing semantics by default
    • Pre-validation for batch updates ensures true atomicity across all DB backends
  • Runtime-Configurable Limits: Override batch and pagination limits per-resource
    • #[crudcrate(batch_limit = 500)] - Max items for batch create/update/delete (default: 100)
    • #[crudcrate(max_page_size = 500)] - Max items per page (default: 1000)
    • Trait methods fn batch_limit() and fn max_page_size() can be overridden for runtime logic (env vars, config)
  • Security Startup Log: Info-level log message when mounting CRUD routes
    • Reports resource name, table, batch_limit, max_page_size, and enabled security defaults
    • Silent when no tracing subscriber is configured
  • Batch Loading for Joins (N+1 Query Fix): Optimized get_all() with joins
    • Reduced from N+1 queries to 2 queries for depth=1 joins (1 for parents + 1 per join field). Deeper joins (depth > 1) may issue additional queries to load nested relations.
    • Uses WHERE parent_id IN (...) with in-memory grouping
  • Documentation Test Links: New mdbook preprocessor linking documentation examples to test files
  • IDE Documentation: Comprehensive attribute reference in crate-level documentation

Changed

  • Documentation Overhaul: Complete restructure of tutorial documentation
    • New progressive tutorial: First Steps → Auto IDs → Timestamps → Filtering → Sorting → Search → Hiding Fields → Relationships → Hooks
    • Simplified navigation structure in SUMMARY.md
    • Enhanced examples with “Run It Now” sections
    • Net reduction of ~800 lines while covering more features
  • DateTimeWithTimeZone schema fix: All generated model structs (API, Create, Update, List, Response) now resolve DateTimeWithTimeZone to chrono::DateTime<chrono::FixedOffset> so utoipa’s ToSchema derive recognizes it as a DateTime type
  • Generated API struct derives now use fully qualified paths (serde::Serialize, utoipa::ToSchema, etc.) to avoid conflicts with user imports
  • Bumped sea-orm from 1.1.17 to 1.1.19
  • Batch operation limit checking now uses Self::batch_limit() method (configurable per-resource)
  • BATCH_LIMIT and MAX_PAGE_SIZE changed from associated constants to trait methods for runtime overridability
  • Batch loading uses .remove() from HashMap instead of .get().cloned(), moving data instead of copying

Fixed

  • UUID array filtering now passes native Uuid values to is_in() instead of stringified values, fixing incorrect query generation for UUID column arrays
  • max_page_size() trait method now enforced in HTTP pagination handler
  • delete_many() returns only actually-deleted IDs
  • update_many() removed redundant pre-validation queries outside the transaction (TOCTOU race)
  • Self-referencing join errors now logged via tracing::warn! instead of silently swallowed
  • Nested relation loading errors (get_one() fallbacks) now logged via tracing::warn!
  • to_snake_case in FK derivation now handles acronyms correctly
  • Batch loading uses PK field name from entity metadata instead of hardcoded id
  • update() trait default used plural instead of singular resource name in not-found error
  • delete_many() trait default had no batch limit check (now enforces batch_limit())
  • Broken cross-reference links in reference documentation
  • Clippy doc-markdown warnings

Removed

  • BatchUpdateItem<T>: Dead struct removed from public API
  • Dead code path: Unreachable self-referencing branch in batch loading
  • Documentation: Legacy tutorial structure replaced by progressive tutorials

0.7.0 - 2025-11-26

Security

  • Harden search queries with proper wildcard escaping
  • Improve input sanitization in filtering and pagination
  • Add pagination limits to prevent excessive queries

Added

  • Join Filtering: Filter by related entity columns using dot-notation syntax
    • filterable("col1", "col2") nested inside join(...) attribute
    • Query: ?filter={"vehicles.make":"BMW"}
    • All standard operators supported (_gt, _gte, _lt, _lte, _neq)
    • Single-level joins only (nested paths like vehicles.parts.name not supported)
  • Join Sorting: Sort by related entity columns using dot-notation syntax
    • sortable("col1", "col2") nested inside join(...) attribute
    • Query: ?sort=["vehicles.year","DESC"] or ?sort_by=vehicles.year&order=DESC
    • Single-level joins only (nested paths not supported)
  • Hook System: Attribute-based customization with {operation}::{cardinality}::{phase} syntax
    • Operations: create, read, update, delete
    • Cardinality: one (single), many (batch)
    • Phases: pre, body, post
    • Example: #[crudcrate(create::one::pre = validate_fn)]
  • Batch operations: create_many and update_many with hook support
  • ApiError error type: Consistent error handling with separate internal/client messages (fixes #3)
    • impl From<DbErr> for Sea-ORM error conversion with automatic internal logging
    • Internal errors logged via tracing, generic message sent to client
    • Custom errors: ApiError::custom(StatusCode::IM_A_TEAPOT, "client msg", Some("internal log"))
    • Variants: NotFound, BadRequest, Unauthorized, Forbidden, Conflict, ValidationFailed, Database, Internal, Custom
  • Lifecycle hooks in CRUDOperations trait
  • Improved test coverage across modules

Changed

  • Major codebase refactoring (38% size reduction)
    • Removed index_analysis module
    • Simplified relation_validator.rs
    • Consolidated join/recursion handling
    • Modular codegen/ structure
  • Handler code generation refactored for hook flow
  • Replace eprintln! with tracing for logging
  • Legacy fn_* attributes auto-map to new hook syntax

Fixed

  • Improved error handling in join path parsing
  • Fixed flaky tests with serial execution
  • All clippy::pedantic warnings resolved

Removed

  • index_analysis module: Database index recommendations moved to external tooling (pgAdmin, MySQL Workbench, etc.)
  • register_crud_analyser! macro: No longer needed without index analysis
  • attributes.rs: Dead code (IDE autocomplete hints only, never used at runtime)
  • join_strategies/ module: Consolidated into codegen/joins/
  • field_analyzer.rs: Reorganized into fields/ module
  • Redundant examples: minimal_debug.rs, minimal_spring.rs, test_router_only.rs
  • Verbose documentation: ~400 lines of excessive doc comments trimmed

Dependencies

  • Added serial_test = "3.2" for test isolation
  • Added tracing for structured logging

0.6.1 - 2025-11-03

Fixed

  • Global path resolution of joined structs
  • Restructuring of crudcrate-derive into smaller modules, bit by bit.

0.6.0 - 2025-10-31

Added

  • Recursive Join Loading: Multi-level relationship loading with #[crudcrate(join(one, all))] attribute
  • Cyclic dependency detection at compile-time with actionable error messages
  • Unlimited join depth support with default depth warnings for relationships > 3 levels
  • exclude() function-style syntax for model exclusion: #[crudcrate(exclude(create, update))]
  • The get one response is now its own model, allowing for exclusion of fields from get one/create/update responses
  • New recursive_join example demonstrating nested relationship loading
  • Debug output functionality for procedural macros with debug_output attribute

Changed

  • derive: Removed requirement for Eq and PartialEq derives on generated API structs
  • derive: Improved multi-pass code generation to handle cyclic dependencies

Fixed

  • Database test cleanup logic for PostgreSQL and MySQL backends
  • Relationship loading in get_one() and get_all() endpoints

Dependencies

  • derive: Updated with recursive join support, cyclic dependency detection, and enhanced attribute parsing

0.5.0 - 2025-08-28

Added

  • Spring-RS framework support with minimal example in /examples
  • Restored CRUD benchmarks from 0.4.5

Changed

  • Moved crudcrate-derive and examples into repository
  • Simplified framework architecture - removed redundant code generation paths
  • Refactored macro code generation by splitting helpers.rs into focused modules

Removed

  • BREAKING: Case-sensitive enum filtering functionality

0.4.5 - 2025-08-25

Fixed

  • Batch delete endpoints now returns the array of successfully deleted resource UUIDs, suitable for a react-admin batch delete response.

0.4.4 - 2025-08-20

Added

  • Index analysis system for database optimization recommendations
  • analyse_indexes_for_resource and analyse_all_registered_models functions
  • Database-specific index recommendations with priority-based output

Changed

  • BREAKING (if still using CRUDResource manually): Added required TABLE_NAME constant to CRUDResource trait. This does not affect EntityToModel functionality.
  • Made validate_field_value function const
  • Improved code organization with extracted helper functions

Fixed

  • All clippy warnings (pessimistic and pedantic)
  • Test compilation errors and naming inconsistencies
  • Documentation examples and missing trait implementations

0.4.3 - 2025-08-19

Added

  • Testing: Integration tests for create_model=false compatibility with non_db_attr
  • Testing: Comprehensive test suite for use_target_models functionality with cross-model referencing

Fixed

  • derive: Resolved lingering compilation errors from List model update
  • derive: Fixed test compatibility issues following List model integration
  • Filter system: Minor improvements to filtering logic consistency

Dependencies

  • derive: Updated to latest version with enhanced List model support and improved compatibility

0.4.2 - 2025-08-18

Added

  • List Model Support: New List model generation capability for customizing fields returned in list/getAll endpoints, similar to Create and Update models
  • Generated List model behavior with field deselection support
  • Built-in getAll query optimization to only return fields specified in List model
  • derive: Support for reserved field names using r# syntax (e.g., r#type)
  • derive: Enhanced target model usage with CRUDResource structs for cross-model referencing
  • derive: Automatic From<> trait generation for List structs from Sea-ORM DB models

Changed

  • derive: Improved trait compatibility by re-adding PartialEq, Eq, Debug, and Clone derives to models for Sea-ORM compatibility
  • derive: Route generation now uses root-level paths instead of prefixed routes for better user control
  • derive: Enhanced use_target_models functionality for better cross-model integration

Fixed

  • derive: Fixed ActiveModel generation when create model excludes keys
  • derive: Fixed create_model=false compatibility with non_db_attr
  • derive: Improved function linking in crudcrate function overrides
  • derive: Fixed trait signature for Condition in get_all operations
  • derive: Various clippy warnings resolved

Dependencies

  • derive: Updated to 0.2.6 with List model support, reserved field handling, and enhanced model generation capabilities

0.4.1 - 2025-08-05

Added

  • Index analysis functionality with analyze_indexes_for_resource() and analyze_and_display_indexes() methods
  • Full-text search support in filtering system with fulltext_searchable_columns() method
  • REST-standard pagination and query filters alongside React Admin compatibility
  • Multi-database testing support (SQLite, PostgreSQL, MySQL) via DATABASE_URL environment variable
  • Comprehensive benchmark suite with performance testing across database backends
  • Security integration tests for SQL injection protection
  • Coverage reporting with Codecov integration
  • Database feature flags for selective driver compilation (mysql, postgresql, sqlite)
  • Binary size optimization through conditional database driver inclusion

Changed

  • Enhanced filtering system with enum case insensitivity and improved edge case handling
  • Updated README with minimal examples and comprehensive testing documentation
  • Restructured test infrastructure to support multiple database backends
  • Improved error handling in filter parsing with better validation
  • Removed Clone requirement from generated API structs (Create/Update models)
  • Optimized trait methods to use references instead of owned values where possible
  • Sea-ORM dependency now uses default-features = false with selective feature enabling
  • Enhanced README with database feature selection examples

Fixed

  • Enum filtering now supports case-insensitive matching
  • Filter edge cases handle malformed JSON gracefully
  • PostgreSQL test isolation issues with race conditions during parallel execution
  • Clippy warnings resolved across codebase
  • derive: Improved integration tests and restructured codebase

Dependencies

  • derive: Updated to 0.2.1 with full-text search support and enhanced router generation capabilities
  • derive: Removed Clone derives from generated structs to reduce memory overhead

0.4.0 - 2025-07-17

Added

  • Enhanced Router Generation: Automatic router generation via generate_router attribute in EntityToModels macro
  • Non-Database Field Support: Complete support for non-DB fields using #[sea_orm(ignore)] + #[crudcrate(non_db_attr = true)] pattern
  • Single-File API Capability: Full CRUD API can now be implemented in under 60 lines of code
  • Documentation improvements for non-DB field usage with examples
  • derive: EntityToModels macro with complete entity-to-API generation and CRUDResource implementation
  • derive: Router generation capability integrated into EntityToModels
  • derive: Enhanced support for non-database fields with proper Sea-ORM integration
  • derive: Comprehensive integration tests and restructured codebase

Changed

  • Enhanced EntityToModels macro to automatically generate router functions
  • Improved documentation with comprehensive non-DB field examples
  • Router generation now fully automated with zero boilerplate
  • derive: Enhanced ToCreateModel and ToUpdateModel with new trait system
  • derive: Added MergeIntoActiveModel trait implementation

Fixed

  • derive: Test infrastructure improvements and better error handling in macro generation

0.3.3 - 2025-06-23

Fixed

  • Fix newline formatting in auto-generated OpenAPI documentation
  • Remove debug messages from production builds

Changed

  • Accept enum exact comparison in filter queries
  • Filter on integer columns support

0.3.2 - 2025-06-06

Changed

  • Bump dependencies including crudcrate-derive for improved into() casting support

Dependencies

  • derive: Updated to 0.1.6 with improved .into() casting support and enhanced field attribute handling

0.3.1 - 2025-05-12

Changed

  • Update lockfile and enhance filtering capabilities for enum and integer columns

0.3.0 - 2025-04-05

Added

  • Major: Default implementations for get_one, get_all, and update_one in CRUDResource trait
  • New MergeIntoActiveModel trait for improved update model handling
  • Enhanced derive macro integration with new trait system

Changed

  • Restructured core trait system for better usability
  • Updated derive macro to reference new MergeIntoActiveModel trait

Dependencies

  • derive: Updated to 0.1.5 with IntoActiveModel trait for UpdateModel and improved trait derivations

0.2.5 - 2025-04-04

Added

  • Export serde_with for better serialization support
  • Enhanced error responses in API endpoints
  • Documentation for query parameters

Changed

  • Renamed openapi.rs to routes.rs for better organization
  • Updated dependencies

0.2.4 - 2025-03-11

Added

  • Description string support in CRUDResource
  • Auto-populated summary and description for macro-generated endpoints
  • Enhanced OpenAPI documentation generation

Dependencies

  • derive: Updated to 0.1.4 with improved serialization support using exported serde_with

0.2.3 - 2025-03-07

Added

  • Comprehensive OpenAPI macro support
  • Better API documentation generation

Fixed

  • Improved error responses in endpoints

0.2.2 - 2025-03-06

Added

  • Documentation for query parameters

0.2.1 - 2025-03-05

Added

  • Description string support in CRUDResource
  • Auto-populated summary and description for macro-generated endpoints

0.2.0 - 2025-03-05

Changed

  • Breaking: Major refactor from route-based to macro-based approach
  • Introduced crud_handlers! macro for generating CRUD endpoints
  • Simplified API creation process significantly

Removed

  • Legacy route-based implementation

0.1.4 - 2025-03-03

Fixed

  • Fixed return type of delete_one handler
  • Applied clippy suggestions for performance improvements

0.1.3 - 2025-02-19

Changed

  • Update crudcrate-derive to allow non-db parameters in update/create models

Dependencies

  • derive: Updated to 0.1.3 with support for auxiliary attributes in structs that don’t relate to DB model

0.1.2 - 2025-02-18

Changed

  • Update proc macro to 0.1.2

Dependencies

  • derive: Updated to 0.1.2 with improved trait derivations (Clone instead of Copy where appropriate)

0.1.0 - 2025-02-18

Added

  • Initial release of crudcrate
  • Basic CRUD operation framework
  • Sea-ORM and Axum integration
  • OpenAPI documentation support
  • Move common functions and traits from existing API
  • Import proc-macros from crudcrate-derive

Dependencies

  • derive: Initial release (0.1.0) with ToCreateModel and ToUpdateModel derive macros, field-level attribute support for CRUD customization, and integration with Sea-ORM ActiveModel system