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:
| Generated | Purpose |
|---|---|
Item | Response model |
ItemCreate | Create request body |
ItemUpdate | Update request body (all fields optional) |
ItemList | List response model |
item_router() | Axum router with all endpoints |
CRUDResource impl | Database 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
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:
| Endpoint | What it does |
|---|---|
GET /tasks | List all tasks |
GET /tasks/:id | Get one task |
POST /tasks | Create a task |
PUT /tasks/:id | Update a task |
DELETE /tasks/:id | Delete 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:
- Exclude the ID from create requests
- 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
| Attribute | Effect |
|---|---|
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
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
| Field | on_create | on_update | Behavior |
|---|---|---|---|
created_at | Utc::now() | - | Set once when created |
updated_at | Utc::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
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
| Suffix | Meaning | Example |
|---|---|---|
| (none) | equals | {"priority":5} |
_gt | greater than | {"priority_gt":5} |
_gte | greater than or equal | {"priority_gte":5} |
_lt | less than | {"priority_lt":5} |
_lte | less than or equal | {"priority_lte":5} |
_neq | not 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
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
What if you have 10,000 tasks? You don’t want to load them all at once.
The Range Parameter
# 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
Filtering requires exact values. But what if you want to find tasks containing “meeting” anywhere in the title?
Enable Fulltext Search
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.
| Database | Search Method |
|---|---|
| PostgreSQL | Native fulltext with to_tsvector |
| MySQL | FULLTEXT index |
| SQLite | LIKE 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
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 Target | Effect |
|---|---|
one | Hidden from GET /users/:id responses |
list | Hidden from GET /users responses |
one, list | Hidden 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
| Attribute | Effect | Test |
|---|---|---|
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:
ScopeCondition: a middleware-injected filter that restricts which rows are returnedexclude(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:
- Hides the field from API responses when a scope is active: public users never see
is_privatein the JSON - 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 /:idif 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:
- Field stripping: The scoped types (
VehicleScopedList) omitexclude(scoped)fields from the JSON. - SQL-level row filtering: Every child batch query includes the child’s
ScopeFilterable::scope_condition()as an additionalWHEREclause. This applies on bothget_one_scopedandget_all_scopedhandlers, at every depth whendepth > 1: the scoped batch loader recurses viaget_one_scoped, propagating the scope through grandchildren and beyond. Private rows never leave Postgres. - In-memory defense in depth:
From<ListModel> for ScopedListstill runsScopeFilterable::is_scope_visible()over each child as a belt-and-suspenders guard, so a customread::many::bodyhook 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
| Attribute | Effect |
|---|---|
exclude(scoped) | Field hidden from response when scoped |
ScopeCondition::new(condition) | Filter rows in list/get_one |
| Scope + write request | Confines existing and resulting rows inside a transaction |
| Scope + filter on excluded column | Filter silently ignored |
| Scope + join fields | Child 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
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?
Loading Related Data
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
| Attribute | When 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
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.
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
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}
| Part | Options | Meaning |
|---|---|---|
| operation | create, update, delete, read | Which action |
| cardinality | one, many | Single item or batch |
| phase | pre, body, transform, post | When 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
// 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
// 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
// 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
prehook runs- Default operation (or
bodyif specified) transformhook runs (modifies the result)posthook runs
If pre returns an error, nothing else runs.
Note: When using
?partial=truefor 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
| Operator | SQL | Example |
|---|---|---|
| (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 type | SQL type | Accepted value |
|---|---|---|
NaiveDate | DATE | 2024-01-15 |
NaiveDateTime | TIMESTAMP | 2024-01-15T10:30:00, 2024-01-15 10:30:00, or RFC 3339 (normalised to UTC) |
DateTime<Utc>, DateTime<FixedOffset> | TIMESTAMPTZ | RFC 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>(¶ms)?;
// 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:
- Pagination: Always paginate filtered results
- Indexes: Index frequently filtered columns
- Caching: Cache common filter results
- Limits: Set maximum result limits
Filtering on Related Entities (Join Filtering)
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:
| Operator | Example |
|---|---|
| (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), returns400 Bad Request. Underlegacy()/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
- Learn about Sorting
- Configure Pagination
- Enable Fulltext Search
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_byparameter takes priority oversortif both are provided.
Sort Directions
| Direction | Description |
|---|---|
ASC | Ascending (A-Z, 0-9, oldest first) |
DESC | Descending (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:
| Database | NULL Position |
|---|---|
| PostgreSQL | NULLS LAST (default) |
| MySQL | NULLS FIRST (in ASC) |
| SQLite | NULLS 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>(¶ms);
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);
Sorting by Related Entity Columns (Join Sorting)
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
- Configure Pagination
- Enable Fulltext Search
- Learn about Filtering
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
| Header | Format | Description |
|---|---|---|
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:
| Limit | Value | Purpose |
|---|---|---|
| Min page size | 1 | Keep a page from being empty by construction |
| Max page size | 1,000 | Prevent memory exhaustion |
| Max offset | 1,000,000 | Prevent 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(¶ms);
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) = ¶ms.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
- Learn about Fulltext Search
- Configure Filtering
- Set up Sorting
Fulltext Search
CRUDCrate provides case-insensitive substring search across multiple fields.
Enabling Fulltext Search
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
Single Phrase Search
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
| Database | Recommended Index |
|---|---|
| PostgreSQL | Optional: a pg_trgm GIN index (gin_trgm_ops) accelerates ILIKE '%term%' scans |
| MySQL | Standard B-tree on searched columns |
| SQLite | Standard indexes on searched columns |
Query Optimization
- Limit results: Always paginate search results
- Use filters: Narrow results before fulltext search
- Cache common searches: For popular queries
Example: Optimized Search
# 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 Search
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
- Configure Relationships
- Learn about Filtering
- Set up Error Handling
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:
- 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. - 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. - It returns SeaORM
ModelExvalues, 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
- Use
join(one)by default: Only load in detail views - Limit depth: Prevent deep recursive loading
- 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
- Learn about Field Exclusion
- Configure Default Values
- Set up Error Handling
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
| Target | Model | HTTP Method |
|---|---|---|
one | Response | GET /items/:id |
create | Create | POST /items |
update | Update | PUT /items/:id |
list | List | GET /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:
- Field is not excluded from that model
- Field has correct visibility (
pub) - 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
- Configure Default Values
- Set up Error Handling
- Learn about Relationships
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
| Feature | Database Default | CRUDCrate Default |
|---|---|---|
| Where | SQL schema | Rust code |
| When | DB insert time | Before insert |
| Visibility | Not in API model | Part of workflow |
| Complexity | Limited SQL | Full 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
- Set up Error Handling
- Learn about Relationships
- Implement Custom Operations
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_atkeeps the value it was first stored with.- A field carrying
on_updateadvances when the row does change, and not on anUnchangedpass. - 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_keyrefuses 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. upsertis a function you call, not a generated route. It is for an ingest path you write, not for the public API surface.
See also
- Default Values for
on_createandon_update - Struct Attributes
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
| Error | Status | When |
|---|---|---|
NotFound | 404 | Resource doesn’t exist |
BadRequest | 400 | Invalid input format |
Unauthorized | 401 | Missing/invalid auth |
Forbidden | 403 | Insufficient permissions |
Conflict | 409 | Duplicate key, constraint violation |
ValidationFailed | 422 | Business rule validation failed |
Database | 500 | Database error (sanitized) |
Internal | 500 | Other 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
- Implement Validation
- Configure Security
- Learn about Custom Operations
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:
200if every item succeeded.207 Multi-Statusif some succeeded and some failed.400 Bad Requestif 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:
| Preset | Strict filter parsing | Strict scope propagation | Expose deleted IDs | Body limit |
|---|---|---|---|---|
secure() (0.9.0 default) | yes | yes | no | 2 MiB |
react_admin() | no | yes | yes | 2 MiB |
legacy() (pre-0.9.0) | no | no | yes | 2 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
axum::Extension<SecurityProfile>on the request, if present.CRUDResource::security_profile()for the resource being served (the derive attribute generates this).- 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.
- Rate limiting:
tower-governor, or a customtower::Layer. - CORS:
tower_http::cors::CorsLayer. - TLS termination: terminate at a reverse proxy in production, or
use
axum-serverwithrustlsfor in-process TLS. - Response security headers:
tower_http::set_header::SetResponseHeaderLayerforX-Content-Type-Options,X-Frame-Options,Strict-Transport-Security, and friends.
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)orexclude(one). - DB credentials sourced from environment, not source.
-
cargo auditruns 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
| Concern | Where it lives |
|---|---|
| Confine reads to a tenant | ScopeCondition injected by middleware |
| Scope across a join | Column::Fk.in_subquery(parent_ids_for_tenant) (typed, not raw SQL) |
| Let tenants write their own data | Inject the scope on writes too |
| Confine writes to a tenant | One condition, checked inside the write transaction |
| Never serve unscoped by accident | #[crudcrate(require_scope)] |
| Hide a column from scoped reads | exclude(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
- Configure Multi-Database Support
- Set up Security
- Learn about Custom Operations
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_alluse the standard join-loading codegen (batch loading, scoped child queries, FK resolution). Thebefore_get_one/after_get_oneandbefore_get_all/after_get_allhooks still fire around the join-loaded body. -
fetch_one/fetch_alloverrides are bypassed when joins are present. Join loading needs the raw SeaORMModelformodel.find_related(), whichfetch_onedoesn’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_scopedare 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
- CRUDOperations API reference: full trait definition and execution order
- Lifecycle Hooks: per-attribute hook alternative
- Security: SecurityProfile configuration
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
| Operation | Description |
|---|---|
create::one | Create single item (POST /items) |
create::many | Batch create (POST /items/batch) |
update::one | Update single item (PUT /items/:id) |
update::many | Batch update (PATCH /items/batch) |
delete::one | Delete single item (DELETE /items/:id) |
delete::many | Bulk delete (DELETE /items with body) |
read::one | Get single item (GET /items/:id) |
read::all | List 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
| Feature | Hooks | CRUDOperations |
|---|---|---|
| Configuration | Attribute | Trait impl |
| Scope | Per-operation | All operations |
| State | Stateless functions | Can hold state |
| Testing | Function mocking | Trait mocking |
| Use when | Simple cases | Complex 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:
- Attribute hook (
create::one::pre) CRUDOperations::before_create- Database operation (or
create::one::bodyreplacement) - Attribute hook (
create::one::transform) CRUDOperations::after_create- 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::preruns once per item, notcreate::many::preonce 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
- Learn about Validation
- Configure Security
- Set up Performance Optimization
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
- Configure Security
- Set up Performance Optimization
- Learn about Multi-Database Support
Multi-Database Support
CRUDCrate works with PostgreSQL, MySQL, and SQLite, with database-specific optimizations.
Supported Databases
| Database | Support Level | Fulltext Search | Typical Use |
|---|---|---|---|
| PostgreSQL | Full | GIN + tsvector | Complex queries, high concurrency |
| MySQL | Full | FULLTEXT index | Traditional deployments |
| SQLite | Full | LIKE fallback | Development, 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
tsvectorquery optimization- Array operations for
INclauses
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 AGAINSTqueries- Optimized
LIKEpatterns
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
- Develop with SQLite
- Test with PostgreSQL locally
- 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
- Set up Security
- Configure Performance Optimization
- Learn about Custom Operations
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
CRUDResourcetrait - 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:
CRUDResourcetrait 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
| Aspect | Compile-Time (Macros) | Runtime (Library) |
|---|---|---|
| When | cargo build | Request handling |
| What | Code generation | Query execution |
| Errors | Compilation errors | HTTP error responses |
| Cost | Build time | Request latency |
| Examples | Missing attributes, type mismatches | Invalid 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
- Understand The Entity Model
- Learn about Generated Models
- Explore the CRUDResource Trait
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
| Derive | Purpose | Required? |
|---|---|---|
Clone | Sea-ORM requirement | Yes |
Debug | Debugging output | Recommended |
DeriveEntityModel | Sea-ORM entity generation | Yes |
Serialize | JSON response serialization | Yes |
Deserialize | JSON request deserialization | Yes |
EntityToModels | CRUDCrate generation | Yes |
Naming Conventions
CRUDCrate follows these naming conventions:
| Your Code | Generated |
|---|---|
pub struct Model | Product (from table name) |
Table: products | Response: 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
- Learn about Generated Models
- Understand the Attribute System
- Configure Relationships
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
| Attribute | Level | Description |
|---|---|---|
generate_router | Struct | Generate Axum router |
routes(...) | Struct | The route families the router mounts |
api_struct | Struct | Custom struct name |
name_singular | Struct | Singular resource name |
name_plural | Struct | Plural resource name |
operations | Struct | Custom operations trait |
description | Struct | OpenAPI description |
fulltext_language | Struct | Fulltext search language |
primary_key | Field | Mark as primary key |
exclude(...) | Field | Exclude from models |
filterable | Field | Enable filtering |
sortable | Field | Enable sorting |
fulltext | Field | Include in fulltext search |
on_create | Field | Default value on create |
on_update | Field | Default value on update |
non_db_attr | Field | Non-database field |
join(...) | Field | Relationship loading config |
Next Steps
- Learn about Filtering
- Configure Relationships
- Implement Custom Operations
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:
| Model | Purpose | Used In |
|---|---|---|
User | Full response | GET /users/:id |
UserCreate | Create request | POST /users |
UserUpdate | Update request | PUT /users/:id |
UserList | List response | GET /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,schemaand 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:
| Field | exclude(one) | exclude(create) | exclude(update) | exclude(list) |
|---|---|---|---|---|
id | In response | Not in create | Not in update | In list |
email | In response | In create | In update | In list |
password | Not in response | In create | In update | Not in list |
created_at | In response | Not in create | Not in update | In 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 inget_oneonlyjoin(all)- Load inget_alltoo (can be expensive)join(one, all)- Load in both
Next Steps
- Understand the CRUDResource Trait
- Learn about Field Exclusion
- Configure Relationships
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>(¶ms)?;
let (offset, limit) = parse_pagination(¶ms);
let order = parse_sorting::<Entity>(¶ms);
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
- Learn about Attributes for configuration
- Implement Custom Operations
- Configure Error Handling
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 columnCustomerId(SeaORM Column enum) /customer_id(field name) - Parent struct
VehiclePart→ FK columnVehiclePartId/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_idinstead ofcustomer_id) are not yet supported via attributes. If your FK name doesn’t follow the convention, use a customread::many::bodyhook 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()withjoin(one): Per-item queries (single entity, no batching needed)
Note: Batch loading currently requires UUID primary keys, consistent with the
CRUDResourcetrait 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 forone, list. Does not touchcreateorupdate. Unrelated tojoin(all), whereallmeans the list endpoint.scoped- Scoped response models (whenScopeConditionis 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 responsesall- Load in list responsesdepth = N- Maximum recursion depth (default: 5)relation = "Variant"- Variant of the child’sRelationenum to join through. Needed when the child has more than one foreign key to this entity, sinceRelatedcan only be implemented once per pairfk_column = "Column"- Child column variant holding the foreign key, bypassing runtime resolution from theRelationDefpath = "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
| Attribute | Combinable With |
|---|---|
primary_key | exclude, on_create |
exclude | All except join targets conflict |
filterable | sortable, fulltext, exclude |
sortable | filterable, fulltext, exclude |
fulltext | filterable, sortable, exclude |
on_create | on_update, exclude(create) |
on_update | on_create, exclude(update) |
non_db_attr | join, join_filterable, join_sortable (required) |
join | non_db_attr (required), join_filterable, join_sortable |
join_filterable | non_db_attr, join, join_sortable |
join_sortable | non_db_attr, join, join_filterable |
See Also
- Struct Attributes Reference
- Hiding Sensitive Data
- Relationships Tutorial
- Auto-Generating IDs
- Adding Timestamps
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:
| Suffix | SQL | Example |
|---|---|---|
_eq | = | ?status_eq=active |
_ne | != | ?status_ne=deleted |
_gt | > | ?age_gt=18 |
_gte | >= | ?age_gte=18 |
_lt | < | ?price_lt=100 |
_lte | <= | ?price_lte=100 |
_like | LIKE | ?name_like=john |
_in | IN | ?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
q (Fulltext Search)
# 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]
Search
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>(¶ms)?;
parse_pagination
Extract offset and limit from parameters.
use crudcrate::filtering::parse_pagination;
let (offset, limit) = parse_pagination(¶ms);
// Default: (0, 20)
parse_sorting
Extract column and order from parameters.
use crudcrate::filtering::parse_sorting;
let (column, order) = parse_sorting::<Entity>(¶ms);
// 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 connectionid- Primary key value
Returns:
Ok(Self)- The record as response modelErr(ApiError::NotFound)- Record doesn’t existErr(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 connectioncondition- Sea-ORM condition for filteringorder- Tuple of (Column, Order) for sortingoffset- Number of records to skiplimit- Maximum records to return
Returns:
Ok(Vec<ListModel>)- List of recordsErr(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 connectiondata- Create model with field values
Returns:
Ok(Self)- Created record as response modelErr(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 connectionid- Primary key of record to updatedata- Update model (only Some fields are updated)
Returns:
Ok(Self)- Updated record as response modelErr(ApiError::NotFound)- Record doesn’t existErr(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 connectionid- Primary key of record to delete
Returns:
Ok(())- Record deletedErr(ApiError::NotFound)- Record doesn’t existErr(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 connectionids- List of primary keys (max 100)
Returns:
Ok(u64)- Number of records deletedErr(ApiError::BadRequest)- More than 100 IDs providedErr(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 connectioncondition- 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>(¶ms)?;
let (offset, limit) = parse_pagination(¶ms);
let order = parse_sorting::<Entity>(¶ms);
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>::CreateModelUpdateModel=<Self::Resource as CRUDResource>::UpdateModelListModel=<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_onewith non-existent IDupdateon non-existent recorddeleteon 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 Type | Status | Body |
|---|---|---|
NotFound | 404 | {"error": "Not found"} |
BadRequest(msg) | 400 | {"error": "{msg}"} |
Unauthorized | 401 | {"error": "Unauthorized"} |
Forbidden | 403 | {"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_joinThen 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_joinThis 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?
| Feature | CRUDCrate | Manual Axum | Diesel | Other ORMs |
|---|---|---|---|---|
| Code Generation | Derive macro | Manual | Manual | Varies |
| Filtering | Built-in | Manual | Manual | Varies |
| Pagination | Built-in | Manual | Manual | Varies |
| Relationships | Automatic | Manual | Manual | Varies |
| Type Safety | Full | Full | Full | Varies |
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:
- Attributes: Configure behavior with
#[crudcrate(...)] - CRUDOperations: Implement hooks for business logic
- 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);
Filtering & Search
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).
Can I combine filters and search?
Yes:
GET /items?filter={"status":"active"}&q=urgent&sort=["created_at","DESC"]
Relationships
How do I load related entities?
- Define Sea-ORM relations
- 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:
#[sea_orm(ignore)]is present#[crudcrate(non_db_attr)]is present- Sea-ORM
Relatedtrait is implemented join(...)specifiesoneand/orall
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?
- Add database indexes on filtered/sorted fields
- Use pagination (built-in limits: 1000 items max)
- Exclude heavy fields from lists:
#[crudcrate(exclude(list))] - 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:
- Database has related records
- Sea-ORM
Relatedtrait is implemented - Join is configured:
join(one)orjoin(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:
on_createandon_updateare set- 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:
- Start with one entity
- Keep existing handlers for others
- 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
- Getting Started: First Steps Tutorial
- Examples: Minimal Example
- Reference: Field Attributes
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 byEntityToModelsand forwarded to the generated API and List models, so a column whose Rust type says less than the value it holds (a jsonbserde_json::Valueover 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 sameupsert_keylookup asupsert. A key already stored is returned exactly as it stands and reportedRegistrationStatus::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.RegistrationStatusis its own enum rather than a thirdUpsertStatusvariant, 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_allbodies 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.ResourceIdcarries what a tuple cannot implement itself, rendering a key into a not-found message and decomposing it into the column values a query binds, andCRUDResource::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 anINlist, 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 everyCRUDResourcemethod are unaffected. -
A registration key may declare the predicate it is unique under. Where the unique index backing
upsert_keyis partial,upsert_where = <expr>names itsWHEREclause, the same SeaORM expressionOnConflict::target_cond_wheretakes, 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 asCRUDResource::upsert_predicate(), defaulting to every row. Naming it without anupsert_keyis a compile error. Seecargo run --example partial_upsert.
Fixed
- Two callers registering one key concurrently no longer fail on the unique
index.
upsertread 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, reportingUpdatedorUnchangedagainst it.
0.13.0 - 2026-09-09
Changed
upserttakes 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. Seedocs/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. Seedocs/MIGRATION_0.13.md.
Fixed
- A nullable field on a List, Response, scoped or API model is
requiredin the OpenAPI document. utoipa reads everyOption<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 carryingskip_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
&DatabaseTransactionas 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>takingdb: &C. Seedocs/MIGRATION_0.12.md. CRUDOperations::after_beginruns immediately after everyBEGINa 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::upsertfinds or inserts a row on an alternate unique key inside one transaction and reportsCreated,UpdatedorUnchanged, 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_comparableandapply_on_updateare 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, whichBatchResult(per-item success-or-error) cannot express.#[crudcrate(routes(read, update))]names the route familiesrouter()mounts, out ofcreate,read,updateanddelete. 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
CRUDResourceandCRUDOperationstraits use nativeasync fninstead ofasync-trait. Animplof either drops its#[async_trait]attribute, andasync-traitis no longer a dependency ofcrudcrateorcrudcrate-derive. The traits declare each method as returningimpl Future + Send, so code generic over the resource spawns or boxes an operation’s future as it did underasync-trait. Animplwritesasync fnas before. - A write lifecycle holds one pooled connection from
BEGINtoCOMMIT, hooks included. A hook that opens its own connection from the same pool rather than using thedbit is handed can exhaust the pool under concurrent writes; seedocs/MIGRATION_0.12.md. - The single-row
create,updateanddeletelifecycles 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_manyanddelete_manyrun 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=trueendpoints are unaffected: they run each row on the connection and still report per-item outcomes with207 Multi-Status.
Fixed
- A registration whose entity maintains its own timestamps reported
Updatedon every pass and rewrotecreated_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 carryingon_updateadvances only when the row does change.
0.11.1 - 2026-09-07
Fixed
CRUDOperations::create_manyandupdate_manyrun the single-rowcreate/updatelifecycle per item instead of delegating toCRUDResource, which the derive generates as a call back into the operations struct: an entity declaringoperations = Xwithout overriding both recursed until the stack overflowed on every batch request.CRUDOperations::perform_delete_manydeletes one row at a time throughdelete, sobefore_deleteandafter_deletefire 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)]andSecurityProfile::max_child_rows_per_relationcap the child rows one join field may load per request; exceeding the cap returns413 Payload Too Large(newApiError::PayloadTooLarge). Unlimited unless set.join(relation = "Variant")selects the child’sRelationvariant for foreign key resolution.
Changed
build_filter_exprandbuild_comparison_exprreturnResult<Option<Expr>, ApiError>instead ofOption<Expr>. Hand-writtenresolve_joined_filtersimplementations add?at the call site; generated code is updated. Seedocs/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 emptyINlist 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 abovei64::MAXbind as before. - Array filters (
filter={"created_at":["...","..."]}) bound every element as text, so anINlist 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 returns400 Bad Requestrather than dropping the clause and returning unfiltered rows. build_comparison_exprbound every non-UUID string as text, so a hand-writtenresolve_joined_filterscomparing 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 providedCRUDResource::pk_value(&model), which the derive overrides with the actual field. - Generated routers named
tracing::info!without qualification, so a crate without a directtracingdependency failed to compile. Generated code now goes throughcrudcrate::tracing. - Column variant idents for field names with a digit boundary (
is_2fa_enabled) were built withconvert_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
useitems thatcrud_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 needsCRUDResourcein scope at the call site. Scheduled for removal in the next breaking release: -
UuidIdResult, unused sincedelete_manybecame 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. Useparse_sorting. -
ValidationErrors: generated validation returns the singularValidationError. -
DefaultCRUDOperations: never constructed by generated code. -
BatchResult::all_succeeded: checkfailed.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::databaseandcrudcrate::relationshipsmodules, which contain no items.
Removed
- The empty, hidden
crudcrate::routesmodule. - The
spring-rsfeature and its optionalspringandspring-webdependencies. 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_filterablecolumns and_likeuse a case-insensitiveLIKE, and typed columns bind typed values. New publicbuild_filter_exprexposes that dispatch for custom resolvers. - Boolean filters with
_neqnow negate; previously the suffix was ignored. crudcrate::core::crud_operations(the handler macros, no nameable items) is nowcrudcrate::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_rangeandparse_sortingat the crate root instead of through thefilter,models,paginationandsortalias modules. The aliases remain exported. - Generated code is pinned by expansion snapshots in
crudcrate-derive/tests/expand, andtest_suitecarries a compile-only test naming everycrudcrate::path generated code emits.
0.10.1 - 2026-08-20
Fixed
get_one_scopedignoredread::one::body. The scoped get-one handler had no custom-body branch, so a resource combining aScopeConditionwith 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 andnon_db_attrfields, the router did not. When such a field was the only exclusion, theScopedList/ScopedOnestructs existed but the router served the plain models, so the supposedly excluded field still reached scoped callers. Both sites now share one predicate. require_scopewrite 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 aScopeConditionis 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.mdshowed aValidationFailed(Vec<ValidationError>)tuple variant and never mentioned theValidatabletrait, the mechanism the handlers actually invoke. The page now documentsValidatableand the realApiError::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 ownsea-ormdependency in the same step; crudcrate 0.9.x remains available for SeaORM 1.x. Seedocs/MIGRATION_0.10.md. - Batch create uses a single multi-row
INSERT ... RETURNINGon 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=truebehaviour are unchanged. Thesqlitefeature now enables SeaORM’ssqlite-use-returning-for-3_35. Behaviour change: customActiveModelBehavior::before_save/after_saveimplementations 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](inlineHasMany/HasOne/BelongsTorelation fields) can deriveEntityToModelsdirectly. The derive attaches to the scalarModeland ignores the generatedModelExcompanion, so relation wrapper fields never leak into Create/Update/List models. crudcrate::table_column_ref(table, column)builds a table-qualifiedColumnReffor customresolve_joined_filtersimplementations, replacing theColumnRef::TableColumnvariant removed in SeaQuery 1.0.- Guided compile errors for misplaced dense-format relations. A
HasMany/HasOne/BelongsTofield 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 declarerust-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>Createand<Name>Updatereject 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_ormre-export. Applications can reference the SeaORM version crudcrate builds against (ie.use crudcrate::sea_orm;) instead of pinning a matchingsea-ormdependency themselves.
Fixed
- List pagination could repeat or skip a row.
get_allordered by the requested sort column alone, so rows tied on that column had no defined order and twoLIMIT/OFFSETqueries 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 defaultget_all, the derive-generatedget_all/get_all_scoped, the joined (dot-notation) sort, andCRUDOperations::fetch_all. A customget::all::bodyoverride supplies its own ordering and is unaffected. - List models dropped field attributes.
<Name>Listwas 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 whenNonecame back as an explicitnullin a list.serde,schemaand doc attributes now carry over to<Name>List,<Name>ScopedListand<Name>ScopedResponse, matching<Name>Response, which already did this. per_page=0produced an empty page.parse_paginationclamped 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 to1..=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
COLUMNconstants were likewise evaluated and skipped; generated code keeps using theColumnenum 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 inUPPER(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-ORMColumnTypeand bound as a typed parameter, so the backend compares natively. Text and enum columns keep case-insensitive matching. Requires thewith-chronoandwith-rust_decimalSea-ORM features (now enabled by default).
0.9.2 - 2026-06-10
Added
- Non-UUID primary keys.
CRUDResourceis now generic over the entity’s primary-key value type viacrudcrate::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
Validatableinvocation. When a Create/Update model implementscrudcrate::validation::Validatable, the generated create/update/batch handlers now callvalidate()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-covresults to Codecov, so backend-specific code paths are measured (previously only the SQLite job reported coverage).
Changed
- BREAKING (trait signatures).
CRUDResourceandCRUDOperationsmethods that tookid: uuid::Uuid/Vec<uuid::Uuid>now takecrudcrate::PrimaryKeyType<Self>/Vec<PrimaryKeyType<Self>>, includingget_one,get_one_scoped,update,delete,delete_many,update_manyand theCRUDOperationsbefore_*/after_*/perform_*/fetch_*hooks. For UUID resourcesPrimaryKeyType<Self>resolves toUuid, so runtime behavior and the generated routes are unchanged. Migration: downstream code that overrides any of these trait methods/hooks must change theUuidparameter/return types tocrudcrate::PrimaryKeyType<Self>(or the concrete PK type). Code that only derivesEntityToModelsneeds no change. POST /{resource}/batch?partial=truenow builds each item withcreate_manysemantics, so partial and all-or-nothing batch create return the same (flat) response shape and run the samecreate::manyhooks. SinglePOST /{resource}still applies join loading +read::one::transform.delete_manyde-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 derivingEqnow also derivesPartialEq.
Fixed
- LIKE wildcard escaping was a no-op on SQLite for the fulltext LIKE-fallback
and joined
_likepaths: escaping used a backslash but emitted noESCAPEclause, so user-supplied%/_stayed active wildcards. These paths now use the!escape convention with an explicitESCAPE '!'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
rangepagination overflow on a hugeendvalue (now uses saturating arithmetic). - Scoped
get_onemasked 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 viafind_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]}builtid IN ('1','3')(string literals), which PostgreSQL rejects asoperator does not exist: integer = text; SQLite’s loose typing hid it. Array values are now bound as their native JSON type. - Generated
depth > 1join code required atracingdependency in the downstream crate (it emitted unqualifiedtracing::warn!). The generated calls are now qualified ascrudcrate::tracing::warn!, so consumers no longer need to depend ontracingdirectly. - Examples: corrected
error_handlingprinted messages to matchApiErroroutput, fixed the misleadingrecursive_join/recursive_join_5_levelsdepth claims, trimmedjoined_filterboilerplate, 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=trueresponse serialized per-item not-found errors (embedding the submitted UUIDs), re-creating the enumeration oracle that profile is meant to close. It now returnsfailed_countinstead of the per-itemfailedlist.
Known limitations
- Joined-filter sub-queries and
Vec<Child>batch loading apply no per-relation row cap; they are bounded only byMAX_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_filterablematching on MySQL/SQLite is ASCII-only (Rust-sideto_uppercase()vs the database’sUPPER()); 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_conditionand the fulltext search functions used?as the bind placeholder inExpr::cust_with_valuestemplates. 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, producingtype "escape" does not exist. Fixed by using$1for Postgres and?for MySQL/SQLite. -
build_like_conditionmissingESCAPEclause. The LIKE condition forlike_filterablefields used sea-query’s.like()which never emitted anESCAPEclause. Rewritten to useExpr::cust_with_valueswithESCAPE '!', matching the fulltext functions. -
Fulltext search on non-text columns. The fallback LIKE search path (when
fulltext_searchable_columns()is empty) appliedUPPER(col) LIKE ...to all searchable columns including booleans. Postgres and MySQL rejectUPPER(boolean). Fixed by casting columns toTEXT(Postgres/SQLite) orCHAR(MySQL) beforeUPPER(). -
LIKE escape character conflicts with Postgres string quoting. Switched
escape_like_wildcardsfrom backslash to!as the escape character. Backslash inside single-quoted SQL strings is ambiguous across backends (Postgresstandard_conforming_strings).
Changed
-
Removed dead codegen helpers (
runtime_fk_*functions) fromcrudcrate-derive. -
Resolved clippy warnings across the workspace (collapsible ifs, duplicate match arms, missing error docs,
unwrapafteris_some). -
Updated trybuild snapshot for rustc 1.96.
0.9.0 - 2026-05-19
Security
-
SecurityProfileconfig struct + presets. Newcrudcrate::SecurityProfilebundles 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(), andlegacy(). 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 aCRUDResource::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 fromSecurityProfile::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 wiredDefaultBodyLimit::disable()up the tree. -
Scope-propagation side-channel guard. Under
secure()profile, joined filters (?filter={"vehicles.color":"..."}) on a child entity that has noexclude(scoped)scope condition are rejected with400 Bad Requestwhen the request carries aScopeCondition. Prevents parent-existence side-channels via unscoped child columns. -
Strict filter parsing. Under
secure()profile, a malformed?filter=...value returns400instead 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 pinSecurityProfile::react_admin()orlegacy(). -
Fulltext SQL bind parameterization. The Postgres / MySQL / SQLite fulltext condition builders now route the user query value through
Expr::cust_with_valuesso 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 rawSimpleExpr::Custom(format!(...))was removed everywhere user input could reach it.
Fixed
-
Join loading with
operationsattribute. Entities using#[crudcrate(operations = MyOps)]for create/update/delete hooks had their join loading silently bypassed onget_oneandget_all; the codegen delegated entirely toCRUDOperationswhich does plain queries with no relation loading. The operations path now falls through to the standard join-loading codegen when the entity hasjoin(...)fields, withbefore_get_one/after_get_oneandbefore_get_all/after_get_allhooks wrapping the join-loaded body.get_one_scopedandget_all_scopedare 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
RelationDefat runtime instead of guessing from the struct name convention. Joins with non-standard FK names (eg.author_refinstead ofauthor_id) now load correctly.
Changed
-
Replaced unmaintained
impls = "1"(no release since 2019) with an inlinecrudcrate::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 andrust_decimalpatches. -
url-escape(unmaintained dev dep) replaced withpercent-encoding.
Documentation
README.md: added security caveat for themysqlfeature, which pulls inrsa 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 defaultget_all_handlernow resolves eachJoinedFilterinto a sub-query on the child table (with the child’sScopeFilterable::scope_condition()applied), collects matching parent-FK values, and addsid IN (...)to the main condition. Query shape: one extraSELECT parent_fk FROM child WHERE ...per joined-filter field plus the usual list + count queries: no JOIN, noDISTINCT. Backed bytest_suite/tests/joined_filter_http_test.rsand a runnablecargo run --example joined_filter. -
New
CRUDResource::resolve_joined_filterstrait 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 declaresjoin(..., filterable(...))on anyVec<Child>field. -
New public helper
crudcrate::build_comparison_expr. Translates a column +FilterOperator+serde_json::Valueinto anOption<SimpleExpr>for use in custom filter resolvers.
Changed
-
crudcrate::filtering::ParsedFilters::joined_filtersis 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 theVec<Child>-only limitation. Removed the stale “requires a customread::many::bodyhook” note.docs/src/features/relationships.mdmigrated from the deprecatedjoin_filterable(...)/join_sortable(...)syntax to the currentfilterable(...)/sortable(...)insidejoin(...).
0.8.0 - 2026-04-17
Security
- Atomic scope check in
get_one: Scopedget_onerequests now verify the scope condition in a single query (ID + scope filter), eliminating a TOCTOU race where a separatetotal_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 actualRelationDeffrom 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 bothget_one_scopedandget_all_scoped, and at every depth whendepth > 1. The scoped batch loader applies each child’sScopeFilterable::scope_condition()to itsEntity::find().filter(FK in parent_ids)query, and recurses viaget_one_scoped(notget_one) for nested children. The in-memoryScopeFilterable::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_scopeattribute: New#[crudcrate(require_scope)]struct-level attribute. When set, read handlers return HTTP 500 if noScopeConditionmiddleware 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))]withOption<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. -
ScopeConditionfor auth-aware query filtering: NewScopeConditiontype that can be injected via AxumExtensionto add conditions to read queries. Auth-system-agnostic: users write middleware to convert their auth state into aScopeCondition. When present,get_all_handlermerges the condition into the query filter, andget_one_handlerverifies 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 withScopeConditionfor public/filtered API endpoints. -
fk_columnjoin parameter: Optionalfk_column = "ColumnName"injoin(...)attributes for entities where the FK column doesn’t follow the{StructName}Idconvention. 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 asea_orm::Conditionmatching an entity’sexclude(scoped)fields. Auto-generated by the derive macro. Enables SQL-level scope filtering for join queries. -
get_one_scoped/get_all_scoped: NewCRUDResourcetrait methods with scope-aware query variants. Default implementations delegate to the non-scopedget_one/get_all(safe for resources withoutjoin(all)children). The derive macro overrides both with SQL-level child-scope propagation.get_all_handlerdispatches toget_all_scopedwhenever aScopeConditionextension 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 fromRelated<E>monomorphization.
Changed
depth = 0is now a compile error: Usedepth = 1for shallow loading. Previouslydepth = 0could 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 explicitdepthis set. Previously, these silently caused infinite recursion at runtime via SeaORM’sRelation::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
depthand joins withdepth > 5now 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::ActiveEnumare 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 theActiveEnumtrait 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
transformphase 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=truequery parameter for batch endpoints- Returns HTTP 207 Multi-Status when some items succeed and some fail
- Response includes
succeededandfailedarrays 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 /batchandPATCH /batchfor 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()andfn 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
DateTimeWithTimeZonetochrono::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-ormfrom 1.1.17 to 1.1.19 - Batch operation limit checking now uses
Self::batch_limit()method (configurable per-resource) BATCH_LIMITandMAX_PAGE_SIZEchanged 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
Uuidvalues tois_in()instead of stringified values, fixing incorrect query generation for UUID column arrays max_page_size()trait method now enforced in HTTP pagination handlerdelete_many()returns only actually-deleted IDsupdate_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 viatracing::warn! to_snake_casein 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 errordelete_many()trait default had no batch limit check (now enforcesbatch_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 insidejoin(...)attribute- Query:
?filter={"vehicles.make":"BMW"} - All standard operators supported (
_gt,_gte,_lt,_lte,_neq) - Single-level joins only (nested paths like
vehicles.parts.namenot supported)
- Join Sorting: Sort by related entity columns using dot-notation syntax
sortable("col1", "col2")nested insidejoin(...)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)]
- Operations:
- Batch operations:
create_manyandupdate_manywith hook support ApiErrorerror 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
CRUDOperationstrait - Improved test coverage across modules
Changed
- Major codebase refactoring (38% size reduction)
- Removed
index_analysismodule - Simplified
relation_validator.rs - Consolidated join/recursion handling
- Modular
codegen/structure
- Removed
- Handler code generation refactored for hook flow
- Replace
eprintln!withtracingfor 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_analysismodule: Database index recommendations moved to external tooling (pgAdmin, MySQL Workbench, etc.)register_crud_analyser!macro: No longer needed without index analysisattributes.rs: Dead code (IDE autocomplete hints only, never used at runtime)join_strategies/module: Consolidated intocodegen/joins/field_analyzer.rs: Reorganized intofields/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
tracingfor 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_joinexample demonstrating nested relationship loading - Debug output functionality for procedural macros with
debug_outputattribute
Changed
- derive: Removed requirement for
EqandPartialEqderives 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()andget_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-deriveand 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_resourceandanalyse_all_registered_modelsfunctions- Database-specific index recommendations with priority-based output
Changed
- BREAKING (if still using CRUDResource manually): Added required
TABLE_NAMEconstant toCRUDResourcetrait. This does not affectEntityToModelfunctionality. - Made
validate_field_valuefunction 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=falsecompatibility withnon_db_attr - Testing: Comprehensive test suite for
use_target_modelsfunctionality 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
Listmodel 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
getAllquery 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, andClonederives 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_modelsfunctionality for better cross-model integration
Fixed
- derive: Fixed ActiveModel generation when create model excludes keys
- derive: Fixed
create_model=falsecompatibility withnon_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()andanalyze_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_URLenvironment 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 = falsewith 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_routerattribute inEntityToModelsmacro - 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
EntityToModelsmacro to automatically generate router functions - Improved documentation with comprehensive non-DB field examples
- Router generation now fully automated with zero boilerplate
- derive: Enhanced
ToCreateModelandToUpdateModelwith new trait system - derive: Added
MergeIntoActiveModeltrait 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, andupdate_oneinCRUDResourcetrait - New
MergeIntoActiveModeltrait 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
MergeIntoActiveModeltrait
Dependencies
- derive: Updated to 0.1.5 with
IntoActiveModeltrait forUpdateModeland improved trait derivations
0.2.5 - 2025-04-04
Added
- Export
serde_withfor better serialization support - Enhanced error responses in API endpoints
- Documentation for query parameters
Changed
- Renamed
openapi.rstoroutes.rsfor 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_onehandler - 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
ToCreateModelandToUpdateModelderive macros, field-level attribute support for CRUD customization, and integration with Sea-ORM ActiveModel system