Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

CRUDCrate

CRUDCrate

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

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

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

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

    pub completed: bool,
}

That’s it. You now have:

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

Try It Now

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

Then visit:

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

Install

cargo add crudcrate

Or add to Cargo.toml:

[dependencies]
crudcrate = "0.1"

Quick Example

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

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

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

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

impl ActiveModelBehavior for ActiveModel {}

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

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

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

Run it:

cargo run

Test it:

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

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

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

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

Features

Mark fields to enable capabilities:

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

Auto-generate values:

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

Load relationships:

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

What Gets Generated

From your entity, CRUDCrate generates:

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

Learn

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

Examples See complete, working code. Minimal Example

Reference All attributes and options. Field Attributes


Requirements

  • Rust 1.70+
  • Sea-ORM 1.0+
  • Axum 0.7+

CRUDCrate works with PostgreSQL, MySQL, and SQLite.


License

MIT License - use it however you want.

Your First API

📋 See test

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

Setup

cargo new taskmanager
cd taskmanager

Add dependencies to Cargo.toml:

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

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

The Simplest Task

Replace src/main.rs:

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

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

    pub title: String,
}

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

impl ActiveModelBehavior for ActiveModel {}

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

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

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

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

Run It

cargo run

Try It

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

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

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

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

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

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


What We Got

From those few lines, CRUDCrate generated:

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

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


Try the Minimal Example

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

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

Then visit:

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

But Wait…

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

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

Auto-Generating IDs

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

Let’s make IDs generate automatically using UUIDs.

The Problem

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

The Solution

Two changes:

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

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

    pub title: String,
}

Add uuid to your Cargo.toml:

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

Update your table:

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

Now It Works

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

Response - ID is generated:

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

What exclude Does

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

What on_create Does

📋 See test

The expression is evaluated when inserting a new record:

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

Our Task Model Now

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

    pub title: String,
}

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

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

Adding Timestamps

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

Add the Fields

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

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

    pub title: String,

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

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

Add chrono to Cargo.toml:

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

Update your table:

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

What’s Happening

📋 See test 📋 See test

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

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

Try It

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

Response:

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

Response - notice updated_at changed:

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

Our Task Model Now

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

    pub title: String,

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

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

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

Next: Finding tasks - filter by field values.

Finding Tasks

📋 See test

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

Add a Status Field

First, let’s give tasks a completed status:

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

    pub title: String,

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

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

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

Update your table:

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

Filter by Status

The filterable attribute enables filtering on that field:

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

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

Add More Filterable Fields

Let’s add priority:

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

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

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

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

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

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

Filter Examples

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

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

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

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

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

Available Operators

📋 See test

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

Security Note

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

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

Our Task Model Now

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

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

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

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

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

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

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

Next: Sorting results - order tasks by any field.

Sorting Results

📋 See test

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

Enable Sorting

Add sortable to fields you want to sort by:

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

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

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

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

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

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

Sort Syntax

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

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

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

Combine with Filtering

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

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

Pagination

📋 See test

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

The Range Parameter

📋 See test

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

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

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

Response Headers

CRUDCrate tells you about pagination in the response headers:

Content-Range: tasks 0-9/150

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

Combine Everything

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

Safety Limits

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


Our Task Model Now

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

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

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

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

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

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

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

Next: Searching text - find tasks by keywords.

Searching Text

📋 See test

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

Add fulltext to searchable fields:

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

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

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

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

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

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

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

Update your table:

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

Search with q

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

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

Combine with Other Features

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

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

How It Works

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

DatabaseSearch Method
PostgreSQLNative fulltext with to_tsvector
MySQLFULLTEXT index
SQLiteLIKE pattern matching

Our Task Model Now

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

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

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

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

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

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

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

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

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

Hiding Sensitive Data

📋 See test

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

A User Entity

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

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

    pub name: String,

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

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

What exclude(one, list) Does

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

Test It

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

Response - no password_hash:

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

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

All Exclude Options

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

You can combine them:

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

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

Excluding from Lists Only

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

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

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


Summary

Our entities now have proper data protection:

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

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

Next: Relationships - connect tasks to users.

Public & Private Endpoints

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

The Problem

You want one set of routes that serves both:

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

crudcrate’s scoping system handles this with two features:

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

Adding a Privacy Field

Add an is_private field to your entity:

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

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

    pub description: Option<String>,

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

exclude(scoped) does two things:

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

Writing Scope Middleware

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

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

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

When ScopeCondition is present, crudcrate automatically:

  • Filters list queries (GET /) to only return matching rows
  • Returns 404 for GET /:id if the record doesn’t pass the condition
  • Blocks all writes (POST, PUT, DELETE) with 403 Forbidden
  • 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.

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 — blocked
curl -X POST http://localhost:3000/api/tasks -d '{"title": "hack"}'
# 403 Forbidden

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

Admin (valid auth token):

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

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

Scoping with Relationships

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

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

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

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

Scoping with Joins

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

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

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

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

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

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

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

How join scoping works

Two layers protect joined children on scoped requests:

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

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

Quick Reference

AttributeEffect
exclude(scoped)Field hidden from response when scoped
ScopeCondition::new(condition)Filter rows in list/get_one
Scope + write requestAutomatically returns 403 Forbidden
Scope + filter on excluded columnFilter silently ignored
Scope + join fieldsChild entities use scoped types too

Entities Without exclude(scoped)

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


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

Relationships

📋 See test

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

Add User ID to Tasks

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

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

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

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

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

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

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

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

Now you can filter tasks by user:

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

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

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

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

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

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

    // ... timestamps ...
}

Define the Relation

Sea-ORM needs to know how entities relate:

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

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

Response Now Includes User

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

Join Options

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

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

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

The Other Direction

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

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

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

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

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

Depth Limit

📋 See test

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

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

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

📋 See test


Try the Recursive Join Example

See relationships in action with a complete example:

cd crudcrate/crudcrate
cargo run --example recursive_join

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

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

Summary

You now have a complete task manager with:

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

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

Next: Custom logic - add validation and side effects.

Custom Logic with Hooks

📋 See test

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

The Hook System

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

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

Example: Validate Task Title

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

use crudcrate::errors::ApiError;

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

async fn validate_task(
    _db: &DatabaseConnection,
    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(
    db: &DatabaseConnection,
    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(
    _db: &DatabaseConnection,
    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(
    db: &DatabaseConnection,
    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(
    db: &DatabaseConnection,
    mut task: Task,  // Takes ownership, returns modified
) -> Result<Task, ApiError> {
    // Add computed fields, enrich from other sources, etc.
    task.comment_count = count_comments(db, task.id).await?;
    Ok(task)
}

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

Multiple Hooks

Combine hooks for complete workflows:

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

Hook Function Signatures

Create

📋 See test

// Pre: can modify input
async fn create_pre(db: &DatabaseConnection, data: &mut TaskCreate) -> Result<(), ApiError>;

// Post: receives created item
async fn create_post(db: &DatabaseConnection, task: &Task) -> Result<(), ApiError>;

// Body: replace create logic
async fn create_body(db: &DatabaseConnection, data: TaskCreate) -> Result<Task, ApiError>;

// Transform: modify result before returning
async fn create_transform(db: &DatabaseConnection, task: Task) -> Result<Task, ApiError>;

Update

📋 See test

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

// Post: receives updated item
async fn update_post(db: &DatabaseConnection, task: &Task) -> Result<(), ApiError>;

Delete

📋 See test

// Pre: can prevent deletion
async fn delete_pre(db: &DatabaseConnection, id: Uuid) -> Result<(), ApiError>;

// Post: runs after deletion
async fn delete_post(db: &DatabaseConnection, id: Uuid) -> Result<(), ApiError>;

// Body: replace delete logic
async fn delete_body(db: &DatabaseConnection, id: Uuid) -> Result<(), ApiError>;

Execution Order

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

If pre returns an error, nothing else runs.

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


Complete Example

use chrono::{DateTime, Utc};
use crudcrate::{EntityToModels, errors::ApiError};
use sea_orm::{entity::prelude::*, DatabaseConnection};
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(
    _db: &DatabaseConnection,
    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(
    _db: &DatabaseConnection,
    task: &Task,
) -> Result<(), ApiError> {
    tracing::info!("Task created: {} ({})", task.title, task.id);
    Ok(())
}

async fn check_delete_permission(
    db: &DatabaseConnection,
    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?

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).

Partial-success mode

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

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

HTTP status codes:

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

Header injection prevention

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

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

Error sanitization

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

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

SecurityProfile

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

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

Per-resource override

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

Global override

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

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

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

Custom profile

Use struct-update syntax to mix fields:

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

Resolution order

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

Build-time caveat for max_request_body_bytes

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

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

use axum::extract::DefaultBodyLimit;

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

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

Authentication

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

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

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

Authorization

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

async fn before_get_all(
    &self,
    _db: &DatabaseConnection,
    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(
    &self,
    db: &DatabaseConnection,
    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(
    &self,
    _db: &DatabaseConnection,
    _id: Uuid,
    data: &mut UserUpdate,
) -> Result<(), ApiError> {
    data.is_admin = None;
    data.password_reset_token = None;
    Ok(())
}

Layers CRUDCrate does not provide

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

Logging

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

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

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

Security checklist

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

Next Steps

Performance Optimization

Optimize your CRUDCrate API for production workloads.

Database Indexing

Index Filtered Fields

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

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

Index Sorted Fields

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

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

Fulltext Indexes

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

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

Query Optimization

Use Selective Filters

# ❌ Full table scan
GET /articles

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

Limit Result Size

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

# Built-in limit: max 1000 items per request

Avoid Deep Joins

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

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

Connection Pooling

Configure Sea-ORM connection pool:

use sea_orm::{Database, ConnectOptions};

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

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

Caching Strategies

Response Caching

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

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

Query Caching with Redis

use redis::AsyncCommands;

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

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

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

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

    Ok(articles)
}

Count Caching

Counting large tables is expensive:

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

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

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

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

    count
}

Pagination Optimization

Keyset Pagination

For large datasets, use cursor-based pagination:

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

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

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

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

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

Skip Count for Infinite Scroll

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

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

    Ok((articles, has_more))
}

List Optimization

Exclude Heavy Fields from Lists

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

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

Select Only Needed Columns

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

    Ok(Json(articles))
}

Async Best Practices

Batch Database Operations

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

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

Parallel Independent Queries

use tokio::join;

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

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

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

Monitoring

Query Logging

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

Slow Query Detection

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

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

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

    result
}

Performance Checklist

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

Next Steps

Custom Operations

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

When to use operations vs per-attribute hooks

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

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

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

Setup

1. Define the operations struct

use async_trait::async_trait;
use crudcrate::{CRUDOperations, ApiError};
use sea_orm::DatabaseConnection;
use uuid::Uuid;

pub struct AssetOps;

#[async_trait]
impl CRUDOperations for AssetOps {
    type Resource = Asset;

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

    async fn before_delete(
        &self,
        _db: &DatabaseConnection,
        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
}

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(&self, db: &DatabaseConnection, data: &CreateModel) -> Result<(), ApiError>;
async fn after_create(&self, db: &DatabaseConnection, entity: &mut Self::Resource) -> Result<(), ApiError>;

async fn before_get_one(&self, db: &DatabaseConnection, id: Uuid) -> Result<(), ApiError>;
async fn after_get_one(&self, db: &DatabaseConnection, 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(&self, db: &DatabaseConnection, id: Uuid) -> Result<Self::Resource, ApiError> {
    // Custom query — eg. select specific columns, add joins
    // ...
}

async fn perform_create(&self, db: &DatabaseConnection, 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(&self, db: &DatabaseConnection, id: Uuid) -> Result<Uuid, ApiError> {
    // Completely custom — S3 cleanup, cascade, audit, everything
    let asset = Asset::get_one(db, id).await?;
    delete_from_s3(&asset.s3_key).await?;
    Asset::delete(db, id).await
}

Interaction with joins

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

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

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

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

Common patterns

Validation

async fn before_create(
    &self,
    _db: &DatabaseConnection,
    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(
    &self,
    db: &DatabaseConnection,
    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(
    &self,
    db: &DatabaseConnection,
    entity: &mut Article,
) -> Result<(), ApiError> {
    entity.view_count = get_view_count(db, entity.id).await?;
    Ok(())
}

Cascading deletes

async fn before_delete(
    &self,
    db: &DatabaseConnection,
    id: Uuid,
) -> Result<(), ApiError> {
    comment::Entity::delete_many()
        .filter(comment::Column::ArticleId.eq(id))
        .exec(db)
        .await?;
    Ok(())
}

See also

How It Works

Understanding CRUDCrate’s architecture helps you use it effectively and extend it when needed.

The Big Picture

┌─────────────────────────────────────────────────────────────────┐
│                    Your Sea-ORM Entity                          │
│   #[derive(DeriveEntityModel, EntityToModels)]                  │
│   pub struct Model { ... }                                      │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                 CRUDCrate Proc Macros                           │
│  (Compile-time code generation)                                 │
│                                                                 │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐        │
│  │ Create   │  │ Update   │  │ List     │  │ Response │        │
│  │ Model    │  │ Model    │  │ Model    │  │ Model    │        │
│  └──────────┘  └──────────┘  └──────────┘  └──────────┘        │
│                                                                 │
│  ┌──────────────────────────────────────────────────────┐      │
│  │              CRUDResource Implementation              │      │
│  │  (get_one, get_all, create, update, delete)          │      │
│  └──────────────────────────────────────────────────────┘      │
│                                                                 │
│  ┌──────────────────────────────────────────────────────┐      │
│  │              Axum Router (optional)                   │      │
│  │  GET /items, POST /items, PUT /items/:id, etc.       │      │
│  └──────────────────────────────────────────────────────┘      │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                 CRUDCrate Runtime                               │
│  (Query parsing, filtering, pagination, error handling)         │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                     Sea-ORM                                     │
│  (Database abstraction, queries, migrations)                    │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│              PostgreSQL / MySQL / SQLite                        │
└─────────────────────────────────────────────────────────────────┘

Two Crates, One System

CRUDCrate consists of two crates:

1. crudcrate-derive (Procedural Macros)

This crate runs at compile time. It:

  • Parses your entity struct and #[crudcrate(...)] attributes
  • Generates model structs (Create, Update, List, Response)
  • Implements the CRUDResource trait
  • Optionally generates an Axum router

The generated code is type-safe and verified by the Rust compiler.

2. crudcrate (Runtime Library)

This crate runs at runtime. It provides:

  • CRUDResource trait definition
  • Query parameter parsing (FilterOptions)
  • SQL condition building (filtering, sorting)
  • Pagination utilities
  • Error handling (ApiError)

Code Generation Flow

When you write:

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

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

CRUDCrate generates (conceptually):

// 1. Response model (your struct name without "Model")
pub struct Item {
    pub id: i32,
    pub name: String,
}

// 2. Create model (excludes `id`)
pub struct ItemCreate {
    pub name: String,
}

// 3. Update model (all optional)
pub struct ItemUpdate {
    pub name: Option<String>,
}

// 4. List model
pub struct ItemList {
    pub id: i32,
    pub name: String,
}

// 5. CRUDResource implementation
impl CRUDResource for Item {
    type EntityType = Entity;
    type CreateModel = ItemCreate;
    type UpdateModel = ItemUpdate;
    type ListModel = ItemList;

    async fn get_one(db: &DatabaseConnection, id: i32) -> Result<Self, ApiError> {
        // Generated query logic
    }

    async fn get_all(
        db: &DatabaseConnection,
        condition: Condition,
        order: (Column, Order),
        offset: u64,
        limit: u64,
    ) -> Result<Vec<Self::ListModel>, ApiError> {
        // Generated query logic with filtering
    }

    async fn create(db: &DatabaseConnection, data: ItemCreate) -> Result<Self, ApiError> {
        // Generated insert logic
    }

    async fn update(db: &DatabaseConnection, id: i32, data: ItemUpdate) -> Result<Self, ApiError> {
        // Generated update logic
    }

    async fn delete(db: &DatabaseConnection, id: i32) -> Result<(), ApiError> {
        // Generated delete logic
    }
}

// 6. Router (if generate_router enabled)
pub fn item_router() -> Router {
    Router::new()
        .route("/items", get(list_handler).post(create_handler))
        .route("/items/:id", get(get_handler).put(update_handler).delete(delete_handler))
}

Request Flow

Here’s what happens when a request hits your API:

HTTP Request: GET /items?filter={"name":"test"}&sort=["id","DESC"]&range=[0,9]
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  1. Axum Router                                                 │
│     - Matches route /items                                      │
│     - Extracts query parameters                                 │
│     - Calls list_handler                                        │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  2. Query Parsing (CRUDCrate Runtime)                           │
│     - FilterOptions::from_query_params()                        │
│     - Parses filter JSON: {"name": "test"}                      │
│     - Parses sort: ["id", "DESC"]                               │
│     - Parses range: [0, 9] → offset=0, limit=10                │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  3. Condition Building                                          │
│     - apply_filters() converts JSON to Condition                │
│     - Validates "name" is marked filterable                     │
│     - Builds: Column::Name.eq("test")                          │
│     - SQL injection prevention applied                          │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  4. CRUDResource::get_all()                                     │
│     - Entity::find()                                            │
│     - .filter(condition)                                        │
│     - .order_by(Column::Id, Order::Desc)                       │
│     - .offset(0).limit(10)                                      │
│     - .all(db).await                                            │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  5. Sea-ORM                                                     │
│     - Builds SQL: SELECT * FROM items                           │
│                   WHERE name = $1                               │
│                   ORDER BY id DESC                              │
│                   LIMIT 10 OFFSET 0                             │
│     - Executes against database                                 │
│     - Returns Vec<Model>                                        │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  6. Response Building                                           │
│     - Convert Vec<Model> to Vec<ItemList>                       │
│     - Add Content-Range header                                  │
│     - Serialize to JSON                                         │
│     - Return HTTP 200 with body                                 │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
HTTP Response: 200 OK
Content-Range: items 0-9/42
[{"id": 1, "name": "test"}, ...]

Compile-Time vs Runtime

AspectCompile-Time (Macros)Runtime (Library)
Whencargo buildRequest handling
WhatCode generationQuery execution
ErrorsCompilation errorsHTTP error responses
CostBuild timeRequest latency
ExamplesMissing attributes, type mismatchesInvalid filters, DB errors

Extension Points

CRUDCrate provides hooks at multiple levels:

1. Attribute Configuration

Configure behavior at compile time:

#[crudcrate(filterable, exclude(list))]
pub field: String,

2. CRUDOperations Trait

Add business logic:

impl CRUDOperations for MyOps {
    async fn before_create(&self, data: &mut CreateModel) -> Result<(), ApiError> {
        // Validation, transformation
    }
}

3. Lifecycle Hooks

Per-operation customization:

#[crudcrate(
    create::one::pre = validate_fn,
    create::one::post = notify_fn,
)]

4. Full Handler Override

Complete control when needed:

async fn custom_create(
    Extension(db): Extension<DatabaseConnection>,
    Json(data): Json<ItemCreate>,
) -> Result<Json<Item>, ApiError> {
    // Your custom logic
}

Performance Characteristics

  • Zero runtime overhead for generated code (no reflection)
  • Compile-time type checking catches errors early
  • Database-native features used when available (indexes, fulltext)
  • Parameterized queries prevent SQL injection without string escaping
  • Pagination limits prevent DoS attacks (max 1000 items)

Next Steps

Generated Models

CRUDCrate generates four model types from your entity. Each serves a specific purpose in your API.

Overview

From this entity:

#[derive(EntityToModels)]
#[sea_orm(table_name = "users")]
pub struct Model {
    #[crudcrate(primary_key, exclude(create, update))]
    pub id: i32,

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

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

    pub display_name: Option<String>,

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

CRUDCrate generates:

ModelPurposeUsed In
UserFull responseGET /users/:id
UserCreateCreate requestPOST /users
UserUpdateUpdate requestPUT /users/:id
UserListList responseGET /users

Response Model (User)

The main response model, returned from get_one:

// Generated
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct User {
    pub id: i32,
    pub email: String,
    // password_hash excluded via exclude(one)
    pub display_name: Option<String>,
    pub created_at: DateTimeUtc,
}

Characteristics:

  • Includes all fields except those with exclude(one)
  • Used for single-item responses
  • Can include loaded relationships

Create Model (UserCreate)

Used for POST requests:

// Generated
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UserCreate {
    // id excluded via exclude(create)
    pub email: String,
    pub password_hash: String,  // NOT excluded from create
    pub display_name: Option<String>,
    // created_at excluded via exclude(create)
}

Characteristics:

  • Excludes primary keys (usually auto-generated)
  • Excludes timestamp fields
  • Includes fields the client should provide

Update Model (UserUpdate)

Used for PUT requests:

// Generated
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UserUpdate {
    // id excluded via exclude(update)
    pub email: Option<String>,
    pub password_hash: Option<String>,
    pub display_name: Option<Option<String>>,  // Double Option for nullable fields
    // created_at excluded via exclude(update)
}

Characteristics:

  • All fields are Option<T> for partial updates
  • Nullable fields become Option<Option<T>>
  • Only provided fields are updated

Double Option Explained

For nullable database fields:

// In entity
pub bio: Option<String>,

// In UserUpdate
pub bio: Option<Option<String>>,

// Usage:
// None = don't change
// Some(None) = set to NULL
// Some(Some("text")) = set to "text"
// Don't change bio
{}

// Set bio to null
{"bio": null}

// Set bio to value
{"bio": "Hello world"}

List Model (UserList)

Used for GET collection responses:

// Generated
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UserList {
    pub id: i32,
    pub email: String,
    // password_hash excluded via exclude(list)
    pub display_name: Option<String>,
    pub created_at: DateTimeUtc,
}

Characteristics:

  • Often identical to Response model
  • Can exclude expensive fields (e.g., large text, relationships)
  • Optimized for list performance

Field Exclusion Matrix

Control which fields appear in which models:

Fieldexclude(one)exclude(create)exclude(update)exclude(list)
idIn responseNot in createNot in updateIn list
emailIn responseIn createIn updateIn list
passwordNot in responseIn createIn updateNot in list
created_atIn responseNot in createNot in updateIn list

Example:

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

// Client can't set ID or timestamps
#[crudcrate(primary_key, exclude(create, update))]
pub id: Uuid,

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

// Exclude expensive content from lists
#[crudcrate(exclude(list))]
pub full_content: String,

Model Traits

All generated models implement:

// Serialization
impl Serialize for User { }
impl Deserialize for User { }

// Cloning
impl Clone for User { }

// Debug output
impl Debug for User { }

Additionally:

// Response model implements From<Sea-ORM Model>
impl From<Model> for User { }

// Create model implements Into<ActiveModel>
impl From<UserCreate> for ActiveModel { }

// Update model implements merge
impl MergeIntoActiveModel<ActiveModel> for UserUpdate { }

Conversions

Entity to Response

let db_model: Model = Entity::find_by_id(1).one(db).await?;
let response: User = db_model.into();

Create to ActiveModel

let create_data: UserCreate = /* from request */;
let active_model: ActiveModel = create_data.into();
let result = active_model.insert(db).await?;

Update Merge

let update_data: UserUpdate = /* from request */;
let mut active_model: ActiveModel = existing.into();

// Only updates fields that were provided
update_data.merge_into(&mut active_model);

let result = active_model.update(db).await?;

Customization

Custom Model Names

#[crudcrate(api_struct = "Item")]
pub struct Model { }

// Generates: Item, ItemCreate, ItemUpdate, ItemList

Additional Derives

The generated models use standard derives. For additional traits, implement them manually:

// In your code
impl Default for UserCreate {
    fn default() -> Self {
        Self {
            email: String::new(),
            password_hash: String::new(),
            display_name: None,
        }
    }
}

Validation

Add validation in CRUDOperations:

impl CRUDOperations for UserOps {
    async fn before_create(&self, data: &mut UserCreate) -> Result<(), ApiError> {
        if !data.email.contains('@') {
            return Err(ApiError::ValidationFailed(vec![
                ValidationError::new("email", "Invalid email format")
            ]));
        }
        Ok(())
    }
}

Relationships in Models

Join fields appear in response models:

// Entity definition
#[sea_orm(ignore)]
#[crudcrate(non_db_attr, join(one, all))]
pub posts: Vec<Post>,

// Generated in User response model
pub struct User {
    pub id: i32,
    pub email: String,
    pub posts: Vec<Post>,  // Loaded automatically
}

Control when relationships load:

  • join(one) - Load in get_one only
  • join(all) - Load in get_all too (can be expensive)
  • join(one, all) - Load in both

Next Steps

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)


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.


Lifecycle Hook Attributes

create::one::pre

Function called before create operation.

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

async fn validate_create(
    db: &DatabaseConnection,
    data: &mut ModelCreate,
) -> Result<(), ApiError> { }

create::one::post

Function called after successful create.

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

async fn notify_created(
    db: &DatabaseConnection,
    created: &Model,
) -> Result<(), ApiError> { }

create::one::body

Replace entire create logic.

#[crudcrate(create::one::body = custom_create)]

async fn custom_create(
    db: &DatabaseConnection,
    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(
    db: &DatabaseConnection,
    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(
    db: &DatabaseConnection,
    updated: &Model,
) -> Result<(), ApiError> { }

update::one::body

Replace entire update logic.

#[crudcrate(update::one::body = custom_update)]

async fn custom_update(
    db: &DatabaseConnection,
    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(
    db: &DatabaseConnection,
    id: PrimaryKeyType,
) -> Result<(), ApiError> { }

delete::one::post

Function called after successful delete.

#[crudcrate(delete::one::post = cleanup_related)]

async fn cleanup_related(
    db: &DatabaseConnection,
    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(
    db: &DatabaseConnection,
    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(
    db: &DatabaseConnection,
    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(
    db: &DatabaseConnection,
    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 CustomerFK column CustomerId (SeaORM Column enum) / customer_id (field name)
  • Parent struct VehiclePartFK column VehiclePartId / vehicle_part_id

Your related entity’s SeaORM model must have a matching foreign key field. For example, if Customer has vehicles: Vec<Vehicle>, the Vehicle model must have a customer_id: Uuid field and a Column::CustomerId variant.

Note: Custom FK names (e.g., owner_id instead of customer_id) are not yet supported via attributes. If your FK name doesn’t follow the convention, use a custom read::many::body hook to implement the loading logic.

Batch Loading Query Behavior

For get_all() with join(all) or join(one, all), CRUDCrate uses batch loading:

  • Depth=1 joins: 2 queries total (1 for parents + 1 per join field using WHERE fk IN (...))
  • Depth > 1 joins: Additional per-item queries for nested children (falls back to get_one() calls)
  • get_one() with join(one): Per-item queries (single entity, no batching needed)

Note: Batch loading currently requires UUID primary keys, consistent with the CRUDResource trait contract.

See Security for partial success and batch limit configuration.

See Also

Field Attributes Reference

Complete reference for #[crudcrate(...)] attributes on fields.

Syntax

#[crudcrate(attribute1, attribute2 = value, ...)]
pub field_name: FieldType,

Core Attributes

primary_key

Marks the field as the primary key.

#[crudcrate(primary_key)]
pub id: i32,

Required: Yes (exactly one field) Type: Flag


exclude(...)

Exclude field from specific generated models.

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

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

Type: List of targets Targets:

  • one - Response model (GET /items/:id)
  • create - Create model (POST /items)
  • update - Update model (PUT /items/:id)
  • list - List model (GET /items)
  • scoped - Scoped response models (when ScopeCondition is active). Also strips the field from filterable/sortable lists in scoped context. See Public & Private Endpoints.

filterable

Enable filtering on this field.

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

Type: Flag Effect: Allows ?filter={"status":"value"} and ?status_eq=value


sortable

Enable sorting on this field.

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

Type: Flag Effect: Allows ?sort=["created_at","DESC"]


fulltext

Include field in fulltext search.

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

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

Type: Flag Effect: Field included when using ?q=search+terms


Default Value Attributes

on_create

Set default value when creating new records.

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

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

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

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

Type: Rust expression When: Evaluated during create operation


on_update

Set default value when updating records.

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

Type: Rust expression When: Evaluated during every update operation


Relationship Attributes

non_db_attr

Marks field as non-database (for computed or relationship fields).

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

Type: Flag Required: Yes, when using join(...)


join(...)

Configure relationship loading.

// Load in get_one only
#[crudcrate(non_db_attr, join(one))]
pub comments: Vec<Comment>,

// Load in both get_one and get_all
#[crudcrate(non_db_attr, join(one, all))]
pub author: Option<User>,

// Limit recursion depth
#[crudcrate(non_db_attr, join(one, all, depth = 2))]
pub nested: Vec<Nested>,

Type: Configuration Parameters:

  • one - Load in single-item responses
  • all - Load in list responses
  • depth = N - Maximum recursion depth (default: 5)

join_filterable(...)

Enable filtering on columns from related entities using dot-notation.

#[sea_orm(ignore)]
#[crudcrate(
    non_db_attr,
    join(one, all),
    join_filterable("make", "year", "color")
)]
pub vehicles: Vec<Vehicle>,

Type: List of column names Effect: Enables ?filter={"vehicles.make":"BMW","vehicles.year_gte":2020} Security: Only listed columns can be filtered - unlisted columns are silently ignored


join_sortable(...)

Enable sorting on columns from related entities using dot-notation.

#[sea_orm(ignore)]
#[crudcrate(
    non_db_attr,
    join(one, all),
    join_sortable("year", "mileage")
)]
pub vehicles: Vec<Vehicle>,

Type: List of column names Effect: Enables ?sort=["vehicles.year","DESC"] Security: Only listed columns can be sorted - unlisted columns fall back to default sort


Common Patterns

Auto-Generated ID

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

Managed Timestamps

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

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

Sensitive Data

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

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

Searchable Content

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

#[crudcrate(fulltext, exclude(list))]
pub content: String,

Foreign Key with Relation

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

#[sea_orm(ignore)]
#[crudcrate(non_db_attr, join(one, all, depth = 1))]
pub author: Option<User>,

Relationship with Join Filtering/Sorting

// Enable filtering and sorting on related entity columns
#[sea_orm(ignore)]
#[crudcrate(
    non_db_attr,
    join(one, all, depth = 1),
    join_filterable("make", "year", "color"),
    join_sortable("year", "mileage")
)]
pub vehicles: Vec<Vehicle>,

Enables queries like:

  • ?filter={"vehicles.make":"BMW"}
  • ?sort=["vehicles.year","DESC"]

Computed Field (Read-Only)

#[crudcrate(sortable, exclude(create, update))]
pub view_count: i32,

Complete Example

#[derive(EntityToModels)]
#[crudcrate(generate_router)]
#[sea_orm(table_name = "articles")]
pub struct Model {
    // Primary key with auto-generation
    #[sea_orm(primary_key, auto_increment = false)]
    #[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
    pub id: Uuid,

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

    // Searchable content, excluded from lists
    #[crudcrate(fulltext, exclude(list))]
    pub content: String,

    // Optional summary for lists
    pub summary: Option<String>,

    // Filterable status
    #[crudcrate(filterable)]
    pub status: ArticleStatus,

    // Foreign key with relationship
    #[crudcrate(filterable)]
    pub author_id: Uuid,

    // Read-only view counter
    #[crudcrate(sortable, exclude(create, update), on_create = 0)]
    pub view_count: i32,

    // Auto-managed timestamps
    #[crudcrate(sortable, exclude(create, update), on_create = chrono::Utc::now())]
    pub created_at: DateTimeUtc,

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

    // Relationships
    #[sea_orm(ignore)]
    #[crudcrate(non_db_attr, join(one, all, depth = 1))]
    pub author: Option<User>,

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

    // Relationship with join filtering/sorting
    #[sea_orm(ignore)]
    #[crudcrate(
        non_db_attr,
        join(one, all, depth = 1),
        join_filterable("tag_name"),
        join_sortable("tag_name")
    )]
    pub tags: Vec<Tag>,
}

Attribute Compatibility

AttributeCombinable With
primary_keyexclude, on_create
excludeAll except join targets conflict
filterablesortable, fulltext, exclude
sortablefilterable, fulltext, exclude
fulltextfilterable, sortable, exclude
on_createon_update, exclude(create)
on_updateon_create, exclude(update)
non_db_attrjoin, join_filterable, join_sortable (required)
joinnon_db_attr (required), join_filterable, join_sortable
join_filterablenon_db_attr, join, join_sortable
join_sortablenon_db_attr, join, join_filterable

See Also

Query Parameters Reference

Complete reference for all supported query parameters.

FilterOptions Struct

pub struct FilterOptions {
    pub filter: Option<String>,      // JSON filter object
    pub sort: Option<String>,        // Sort specification
    pub order: Option<String>,       // Sort order (ASC/DESC)
    pub range: Option<String>,       // Pagination range [start, end]
    pub page: Option<u64>,           // Page number
    pub per_page: Option<u64>,       // Items per page
    pub q: Option<String>,           // Fulltext search query
    // Plus dynamic field-specific filters
}

Filtering Parameters

filter (JSON Object)

Filter by exact field values.

# Single field
?filter={"status":"active"}

# Multiple fields (AND)
?filter={"status":"active","priority":5}

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

Field-Specific Operators

# Exact match (default)
?status=active
?status_eq=active

# Not equal
?status_ne=deleted

# Greater than
?priority_gt=5

# Greater than or equal
?priority_gte=5

# Less than
?priority_lt=10

# Less than or equal
?priority_lte=10

# Contains (LIKE)
?title_like=urgent

# In list
?status_in=active,pending,review

Operator Reference:

SuffixSQLExample
_eq=?status_eq=active
_ne!=?status_ne=deleted
_gt>?age_gt=18
_gte>=?age_gte=18
_lt<?price_lt=100
_lte<=?price_lte=100
_likeLIKE?name_like=john
_inIN?status_in=a,b,c

Sorting Parameters

sort (JSON Array - React Admin)

# Field and direction
?sort=["created_at","DESC"]

# Field only (defaults to ASC)
?sort=["name"]

sort + order (Standard)

?sort=created_at&order=DESC
?sort=name&order=ASC

Combined Format

?sort=created_at_desc
?sort=name_asc

Order Values:

  • ASC / asc - Ascending (A-Z, 0-9, oldest)
  • DESC / desc - Descending (Z-A, 9-0, newest)

Pagination Parameters

range (React Admin)

# Items 0-9 (first 10)
?range=[0,9]

# Items 20-29
?range=[20,29]

# Items 0-99 (first 100)
?range=[0,99]

page + per_page (Standard)

# Page 1, 20 per page
?page=1&per_page=20

# Page 5, 50 per page
?page=5&per_page=50

Limits:

  • Maximum per_page: 1,000
  • Maximum offset: 1,000,000

Search Parameters

# Search all fulltext fields
?q=meeting notes

# Combined with filters
?q=urgent&filter={"status":"open"}

Response Headers

Content-Range

Content-Range: items 0-19/150

Format: {resource} {start}-{end}/{total}

Complete Examples

Basic List

GET /articles

Filtered List

GET /articles?filter={"status":"published","author_id":5}

Sorted List

GET /articles?sort=["created_at","DESC"]

Paginated List

GET /articles?range=[0,19]
GET /articles?q=rust programming

Combined Query

GET /articles?filter={"status":"published"}&sort=["views","DESC"]&range=[0,9]&q=tutorial

Complex Filter

GET /articles?status=published&views_gte=1000&created_at_gte=2024-01-01

Parsing Functions

apply_filters

Build Sea-ORM condition from query parameters.

use crudcrate::filtering::apply_filters;

let condition = apply_filters::<Entity>(&params)?;

parse_pagination

Extract offset and limit from parameters.

use crudcrate::filtering::parse_pagination;

let (offset, limit) = parse_pagination(&params);
// Default: (0, 20)

parse_sorting

Extract column and order from parameters.

use crudcrate::filtering::parse_sorting;

let (column, order) = parse_sorting::<Entity>(&params);
// Default: (primary_key_column, Order::Asc)

build_fulltext_condition

Build fulltext search condition.

use crudcrate::filtering::build_fulltext_condition;

let condition = build_fulltext_condition(
    query,
    &["title", "content"],
    db.get_database_backend()
);

URL Encoding

Special characters in values must be URL-encoded:

# Space → %20 or +
?q=hello+world
?q=hello%20world

# Brackets → %5B %5D
?range=%5B0,9%5D

# Curly braces → %7B %7D
?filter=%7B"status":"active"%7D

# Comma in value → %2C
?tags_in=one%2Ctwo%2Cthree

Error Responses

Invalid Filter Field

GET /articles?unknown_field=value
{"error": "Invalid filter field: unknown_field"}

Invalid Filter Value

GET /articles?priority_gte=not-a-number
{"error": "Invalid filter value for field 'priority': expected number"}

Invalid Sort Field

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

Invalid Range Format

GET /articles?range=invalid

Falls back to default pagination.

See Also

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.1"
sea-orm = { version = "1.0", features = ["runtime-tokio-rustls", "sqlx-sqlite"] }
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
uuid = { version = "1.0", features = ["v4", "serde"] }

Run

cargo run

Test

# Create
curl -X POST http://localhost:3000/items \
  -H "Content-Type: application/json" \
  -d '{"name": "Test", "description": "A test item"}'

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

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

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

# Get one (use ID from create response)
curl http://localhost:3000/items/{id}

# Update
curl -X PUT http://localhost:3000/items/{id} \
  -H "Content-Type: application/json" \
  -d '{"name": "Updated"}'

# Delete
curl -X DELETE http://localhost:3000/items/{id}

What You Get

From ~35 lines:

  • 6 REST endpoints
  • UUID generation
  • Filtering on name
  • Sorting on name
  • Pagination with Content-Range headers
  • JSON serialization
  • Error handling

Without CRUDCrate, this would require 500+ lines of handlers, models, and parsing logic.


Next: See the Todo App for a more complete example with timestamps and status tracking.

Todo Application Example

A practical todo app with filtering, sorting, and search.

Run It Now

The minimal example includes a todo entity:

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

Then visit:

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

Entity Definition

use crudcrate::EntityToModels;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use uuid::Uuid;

#[derive(Clone, Debug, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)]
#[sea_orm(rs_type = "String", db_type = "String(StringLen::N(15))")]
pub enum Priority {
    #[sea_orm(string_value = "low")]
    Low,
    #[sea_orm(string_value = "medium")]
    Medium,
    #[sea_orm(string_value = "high")]
    High,
}

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

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

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

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

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

    #[crudcrate(filterable, sortable)]
    pub due_date: Option<DateTimeUtc>,

    #[crudcrate(sortable, exclude(create, update), on_create = chrono::Utc::now())]
    pub created_at: DateTimeUtc,
}

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

impl ActiveModelBehavior for ActiveModel {}

Usage Examples

Create Todos

# High priority task
curl -X POST http://localhost:3000/todos \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Review pull request",
    "description": "Check the new authentication feature",
    "completed": false,
    "priority": "high"
  }'

# Medium priority with due date
curl -X POST http://localhost:3000/todos \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Update documentation",
    "completed": false,
    "priority": "medium",
    "due_date": "2024-12-31T23:59:59Z"
  }'

Filter by Status

# Incomplete tasks
curl "http://localhost:3000/todos?filter={\"completed\":false}"

# Completed tasks
curl "http://localhost:3000/todos?filter={\"completed\":true}"

Filter by Priority

# High priority only
curl "http://localhost:3000/todos?filter={\"priority\":\"high\"}"

# High and medium
curl "http://localhost:3000/todos?priority_in=high,medium"

Sort by Due Date

# Earliest due first
curl "http://localhost:3000/todos?sort=[\"due_date\",\"ASC\"]"

# Latest due first
curl "http://localhost:3000/todos?sort=[\"due_date\",\"DESC\"]"

Search

# Search title and description
curl "http://localhost:3000/todos?q=authentication"

Combined Queries

# Incomplete high-priority, sorted by due date
curl "http://localhost:3000/todos?filter={\"completed\":false,\"priority\":\"high\"}&sort=[\"due_date\",\"ASC\"]"

Mark Complete

curl -X PUT http://localhost:3000/todos/{id} \
  -H "Content-Type: application/json" \
  -d '{"completed": true}'

React Admin Integration

// dataProvider.js
import { fetchUtils } from 'react-admin';

const apiUrl = 'http://localhost:3000';

export const dataProvider = {
  getList: (resource, params) => {
    const { page, perPage } = params.pagination;
    const { field, order } = params.sort;
    const range = [(page - 1) * perPage, page * perPage - 1];

    const query = {
      sort: JSON.stringify([field, order]),
      range: JSON.stringify(range),
      filter: JSON.stringify(params.filter),
    };

    const url = `${apiUrl}/${resource}?${fetchUtils.queryParameters(query)}`;

    return fetchUtils.fetchJson(url).then(({ headers, json }) => {
      const contentRange = headers.get('Content-Range');
      const total = parseInt(contentRange.split('/').pop(), 10);
      return { data: json, total };
    });
  },
  // ... other methods
};

Next: See the Blog Example for handling relationships between entities.

Blog with Comments Example

A blog API with posts, comments, and authors demonstrating relationships.

Note: This is a reference example. To see relationships in action, run:

cargo run --example recursive_join

Then visit http://localhost:3000/docs


Entities

User (Author)

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

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

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

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

    pub bio: Option<String>,

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

    // Relationship: User has many Posts
    #[sea_orm(ignore)]
    #[crudcrate(non_db_attr, join(one))]
    pub posts: Vec<super::post::Post>,
}

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

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

Post

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

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

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

    pub slug: String,

    #[crudcrate(fulltext, exclude(list))]
    pub content: String,

    pub excerpt: Option<String>,

    #[crudcrate(filterable)]
    pub status: PostStatus,

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

    #[crudcrate(sortable, filterable)]
    pub published_at: Option<DateTimeUtc>,

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

    // Author (belongs_to)
    #[sea_orm(ignore)]
    #[crudcrate(non_db_attr, join(one, all, depth = 1))]
    pub author: Option<super::user::User>,

    // Comments (has_many) - only in detail view
    #[sea_orm(ignore)]
    #[crudcrate(non_db_attr, join(one))]
    pub comments: Vec<super::comment::Comment>,
}

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

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

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

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

Comment

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

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

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

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

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

    // Comment author
    #[sea_orm(ignore)]
    #[crudcrate(non_db_attr, join(one, all, depth = 1))]
    pub author: Option<super::user::User>,
}

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

    #[sea_orm(
        belongs_to = "super::user::Entity",
        from = "Column::AuthorId",
        to = "super::user::Column::Id"
    )]
    Author,
}

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

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

Router Setup

let app = Router::new()
    .merge(user::user_router())
    .merge(post::post_router())
    .merge(comment::comment_router())
    .layer(Extension(db));

API Examples

Create a User

curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Alice",
    "email": "[email protected]",
    "password_hash": "hashed_password",
    "bio": "Software developer"
  }'

Create a Post

curl -X POST http://localhost:3000/posts \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Getting Started with Rust",
    "slug": "getting-started-with-rust",
    "content": "Full article content...",
    "excerpt": "Learn the basics of Rust programming",
    "status": "published",
    "author_id": "{user-id}",
    "published_at": "2024-01-15T10:00:00Z"
  }'

List Published Posts with Authors

curl "http://localhost:3000/posts?filter={\"status\":\"published\"}&sort=[\"published_at\",\"DESC\"]"

Get Post with Comments

curl "http://localhost:3000/posts/{post-id}"

# Response includes author and comments
{
  "id": "...",
  "title": "Getting Started with Rust",
  "author": {
    "id": "...",
    "name": "Alice"
  },
  "comments": [
    {
      "id": "...",
      "content": "Great article!",
      "author": {"name": "Bob"}
    }
  ]
}

Add a Comment

curl -X POST http://localhost:3000/comments \
  -H "Content-Type: application/json" \
  -d '{
    "content": "This really helped me understand Rust!",
    "post_id": "{post-id}",
    "author_id": "{user-id}"
  }'

Search Posts

curl "http://localhost:3000/posts?q=rust programming"

Next: See the E-commerce Example for complex entity relationships with custom operations.

E-commerce Orders Example

Order management with products, customers, and line items demonstrating deep joins.

Note: This is a reference example. To see multi-level relationships in action, run:

cargo run --example recursive_join

This shows Customer → Vehicle → Parts/Maintenance (similar to Order → Items → Products).


Entities

Product

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

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

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

    #[crudcrate(filterable, sortable)]
    pub price: Decimal,

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

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

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

    #[crudcrate(sortable, exclude(create, update), on_create = chrono::Utc::now())]
    pub created_at: DateTimeUtc,
}

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

impl ActiveModelBehavior for ActiveModel {}

Customer

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

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

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

    pub phone: Option<String>,

    pub shipping_address: Option<String>,

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

    // Customer's orders
    #[sea_orm(ignore)]
    #[crudcrate(non_db_attr, join(one))]
    pub orders: Vec<super::order::Order>,
}

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

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

Order

#[derive(Clone, Debug, EnumIter, DeriveActiveEnum, Serialize, Deserialize)]
#[sea_orm(rs_type = "String", db_type = "String(StringLen::N(20))")]
pub enum OrderStatus {
    #[sea_orm(string_value = "pending")]
    Pending,
    #[sea_orm(string_value = "confirmed")]
    Confirmed,
    #[sea_orm(string_value = "shipped")]
    Shipped,
    #[sea_orm(string_value = "delivered")]
    Delivered,
    #[sea_orm(string_value = "cancelled")]
    Cancelled,
}

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

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

    #[crudcrate(filterable, sortable)]
    pub status: OrderStatus,

    #[crudcrate(sortable)]
    pub total_amount: Decimal,

    pub shipping_address: String,

    pub notes: Option<String>,

    #[crudcrate(sortable, filterable, exclude(create, update), on_create = chrono::Utc::now())]
    pub created_at: DateTimeUtc,

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

    // Customer
    #[sea_orm(ignore)]
    #[crudcrate(non_db_attr, join(one, all, depth = 1))]
    pub customer: Option<super::customer::Customer>,

    // Line items
    #[sea_orm(ignore)]
    #[crudcrate(non_db_attr, join(one))]
    pub items: Vec<super::order_item::OrderItem>,
}

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

    #[sea_orm(has_many = "super::order_item::Entity")]
    Items,
}

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

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

Order Item

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

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

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

    pub quantity: i32,

    pub unit_price: Decimal,

    pub total_price: Decimal,

    // Product details at time of order
    #[sea_orm(ignore)]
    #[crudcrate(non_db_attr, join(one, all, depth = 1))]
    pub product: Option<super::product::Product>,
}

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

    #[sea_orm(
        belongs_to = "super::product::Entity",
        from = "Column::ProductId",
        to = "super::product::Column::Id"
    )]
    Product,
}

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

Custom Operations

pub struct OrderOperations;

#[async_trait]
impl CRUDOperations for OrderOperations {
    type Resource = Order;

    async fn before_create(
        &self,
        db: &DatabaseConnection,
        data: &mut OrderCreate,
    ) -> Result<(), ApiError> {
        // Calculate total from items
        // Validate stock availability
        Ok(())
    }

    async fn after_create(
        &self,
        db: &DatabaseConnection,
        created: &Order,
    ) -> Result<(), ApiError> {
        // Update stock quantities
        // Send confirmation email
        Ok(())
    }

    async fn before_update(
        &self,
        db: &DatabaseConnection,
        id: Uuid,
        data: &mut OrderUpdate,
    ) -> Result<(), ApiError> {
        let order = Entity::find_by_id(id).one(db).await?.ok_or(ApiError::NotFound)?;

        // Prevent changing cancelled orders
        if order.status == OrderStatus::Cancelled {
            return Err(ApiError::BadRequest("Cannot modify cancelled order".into()));
        }

        // Validate status transitions
        if let Some(new_status) = &data.status {
            if !is_valid_transition(&order.status, new_status) {
                return Err(ApiError::BadRequest("Invalid status transition".into()));
            }
        }

        Ok(())
    }
}

API Examples

Create Order

curl -X POST http://localhost:3000/orders \
  -H "Content-Type: application/json" \
  -d '{
    "customer_id": "{customer-id}",
    "status": "pending",
    "total_amount": "99.99",
    "shipping_address": "123 Main St, City, Country"
  }'

Get Order with Items

curl "http://localhost:3000/orders/{order-id}"

# Response
{
  "id": "...",
  "status": "confirmed",
  "customer": {"name": "John Doe", "email": "..."},
  "items": [
    {"product": {"name": "Widget"}, "quantity": 2, "total_price": "49.98"}
  ]
}

Filter Orders by Status

# Pending orders
curl "http://localhost:3000/orders?filter={\"status\":\"pending\"}"

# Customer's orders
curl "http://localhost:3000/orders?filter={\"customer_id\":\"{id}\"}"

Update Order Status

curl -X PUT http://localhost:3000/orders/{id} \
  -H "Content-Type: application/json" \
  -d '{"status": "shipped"}'

Ready to dive deeper? Check out the Tutorial for step-by-step learning, or the Reference for all available options.

Frequently Asked Questions

General

What is CRUDCrate?

CRUDCrate is a Rust library that generates complete REST APIs from Sea-ORM entities using derive macros. It eliminates boilerplate code for CRUD operations, filtering, sorting, pagination, and relationships.

How does it compare to other solutions?

FeatureCRUDCrateManual AxumDieselOther ORMs
Code Generation✅ Derive macro❌ Manual❌ ManualVaries
Filtering✅ Built-in❌ Manual❌ ManualVaries
Pagination✅ Built-in❌ Manual❌ ManualVaries
Relationships✅ Automatic❌ Manual❌ ManualVaries
Type Safety✅ Full✅ Full✅ FullVaries

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.1"
sea-orm = { version = "1.0", features = ["runtime-tokio-rustls", "sqlx-postgres"] }

Usage

Can I use CRUDCrate with an existing Sea-ORM project?

Yes! Just add #[derive(EntityToModels)] and #[crudcrate(...)] attributes to your existing entities.

How do I customize the generated endpoints?

Three ways:

  1. Attributes: Configure behavior with #[crudcrate(...)]
  2. CRUDOperations: Implement hooks for business logic
  3. Custom Handlers: Replace entire handlers when needed

Can I have some entities without routers?

Yes. Only add generate_router when you want endpoints:

// With router
#[crudcrate(generate_router)]

// Without router (just models)
#[crudcrate()]

How do I add authentication?

Use Axum middleware:

let app = Router::new()
    .merge(protected_router())
    .layer(middleware::from_fn(auth_middleware));

See Security for details.

Can I have different auth for different routes?

Yes. Use nested routers:

let public = Router::new().merge(public_routes());

let protected = Router::new()
    .merge(admin_routes())
    .layer(admin_auth_layer);

let app = Router::new().merge(public).merge(protected);

Which fields can be filtered?

Only fields marked #[crudcrate(filterable)]:

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

pub secret: String,  // Cannot filter

How does fulltext search work?

Fields marked #[crudcrate(fulltext)] are searched with ?q=:

GET /items?q=search terms

The query uses database-optimized search (GIN for Postgres, FULLTEXT for MySQL).

Yes:

GET /items?filter={"status":"active"}&q=urgent&sort=["created_at","DESC"]

Relationships

  1. Define Sea-ORM relations
  2. Add join field with #[crudcrate(non_db_attr, join(one))]
#[sea_orm(ignore)]
#[crudcrate(non_db_attr, join(one))]
pub comments: Vec<Comment>,

Why are relationships not loading?

Check that:

  1. #[sea_orm(ignore)] is present
  2. #[crudcrate(non_db_attr)] is present
  3. Sea-ORM Related trait is implemented
  4. join(...) specifies one and/or all

How do I prevent circular references?

Use depth limit:

#[crudcrate(non_db_attr, join(one, depth = 2))]

Performance

Is CRUDCrate slow?

No. Generated code has zero runtime overhead. All generation happens at compile time.

How do I optimize for large tables?

  1. Add database indexes on filtered/sorted fields
  2. Use pagination (built-in limits: 1000 items max)
  3. Exclude heavy fields from lists: #[crudcrate(exclude(list))]
  4. Limit join depth

Does loading relationships cause N+1 queries?

No — get_all() with join(all) uses batch loading that reduces N+1 queries to just 2 queries (1 for parents + 1 per join field) using WHERE parent_id IN (...). This applies to depth=1 joins; deeper joins (depth > 1) may issue additional queries for nested relations. Single-item get_one() uses per-item queries, which is acceptable for individual lookups.

Troubleshooting

Compilation error: “cannot find derive macro”

Import it:

use crudcrate::EntityToModels;

Error: “field not found” when filtering

The field must be marked filterable:

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

Relationships return empty

Ensure:

  1. Database has related records
  2. Sea-ORM Related trait is implemented
  3. Join is configured: join(one) or join(one, all)

“Too many items” error on bulk delete

Built-in safety limit is 100 items. Split into multiple requests.

Timestamp fields not auto-updating

Check:

  1. on_create and on_update are set
  2. Field is excluded from update model: exclude(update)
#[crudcrate(exclude(create, update), on_create = chrono::Utc::now(), on_update = chrono::Utc::now())]
pub updated_at: DateTimeUtc,

Migration

Can I migrate from manual handlers?

Yes. CRUDCrate is additive. You can:

  1. Start with one entity
  2. Keep existing handlers for others
  3. Gradually migrate

How do I migrate to a new version?

Check the Changelog for breaking changes. Most updates are backward compatible.

Contributing

How can I contribute?

See Contributing for guidelines.

Where do I report bugs?

Open an issue on GitHub.

Is there a roadmap?

Planned features:

  • GraphQL support
  • OpenAPI generation
  • More database optimizations
  • Cursor-based pagination option

Learn More

Changelog

All notable changes to the crudcrate project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

Unreleased

[0.9.1] - 2026-06-01

Fixed

  • LIKE queries broken on Postgres. build_like_condition and the fulltext search functions used ? as the bind placeholder in Expr::cust_with_values templates. Sea-query’s Postgres backend uses $ as its placeholder character, so the ? was passed through as a literal — Postgres then parsed ? ESCAPE '!' as a JSONB operator followed by a type cast, producing type "escape" does not exist. Fixed by using $1 for Postgres and ? for MySQL/SQLite.

  • build_like_condition missing ESCAPE clause. The LIKE condition for like_filterable fields used sea-query’s .like() which never emitted an ESCAPE clause. Rewritten to use Expr::cust_with_values with ESCAPE '!', matching the fulltext functions.

  • Fulltext search on non-text columns. The fallback LIKE search path (when fulltext_searchable_columns() is empty) applied UPPER(col) LIKE ... to all searchable columns including booleans. Postgres and MySQL reject UPPER(boolean). Fixed by casting columns to TEXT (Postgres/SQLite) or CHAR (MySQL) before UPPER().

  • LIKE escape character conflicts with Postgres string quoting. Switched escape_like_wildcards from backslash to ! as the escape character. Backslash inside single-quoted SQL strings is ambiguous across backends (Postgres standard_conforming_strings).

Changed

  • Removed dead codegen helpers (runtime_fk_* functions) from crudcrate-derive.

  • Resolved clippy warnings across the workspace (collapsible ifs, duplicate match arms, missing error docs, unwrap after is_some).

  • Updated trybuild snapshot for rustc 1.96.

0.9.0 - 2026-05-19

Security

  • SecurityProfile config struct + presets. New crudcrate::SecurityProfile bundles the security-sensitive runtime defaults — strict filter parsing, scope propagation, deleted-ID exposure, and request body size — under one type with three presets: SecurityProfile::secure(), react_admin(), and legacy(). Override individual fields via Rust’s struct-update syntax: SecurityProfile { expose_deleted_ids: true, ..SecurityProfile::secure() }.

  • Per-resource override via derive attribute. #[crudcrate(security_profile = "secure" | "react_admin" | "legacy")] generates a CRUDResource::security_profile() impl that returns the named preset.

  • Global override via Axum extension. Apply .layer(Extension(SecurityProfile::secure())) on your router to override the per-resource setting at request time. Resolution order: Extension > CRUDResource::security_profile() > trait default.

  • Default profile flipped to secure(). New resources ship hardened defaults. See MIGRATION_0.9.md for the per-flag breakdown and opt-out instructions.

  • Explicit batch body limit. The generated router now applies an Axum DefaultBodyLimit::max(...) layer derived from SecurityProfile::max_request_body_bytes (default 2 MiB, matching axum-core’s baseline). Previous behavior relied on Axum’s implicit default and broke if any consumer wired DefaultBodyLimit::disable() up the tree.

  • Scope-propagation side-channel guard. Under secure() profile, joined filters (?filter={"vehicles.color":"..."}) on a child entity that has no exclude(scoped) scope condition are rejected with 400 Bad Request when the request carries a ScopeCondition. Prevents parent-existence side-channels via unscoped child columns.

  • Strict filter parsing. Under secure() profile, a malformed ?filter=... value returns 400 instead of silently dropping the filter and returning the unfiltered result.

  • Deleted-ID enumeration guard. Under secure() profile, batch delete responses return {"deleted": N} instead of the array of UUIDs that actually existed in the database, removing the existence-enumeration side-channel through the delete endpoint. react-admin frontends that rely on the ID array for cache invalidation should pin SecurityProfile::react_admin() or legacy().

  • Fulltext SQL bind parameterization. The Postgres / MySQL / SQLite fulltext condition builders now route the user query value through Expr::cust_with_values so the value is bound as a parameter rather than interpolated into the SQL string. Defense-in-depth — column names were already compile-time-known, but raw SimpleExpr::Custom(format!(...)) was removed everywhere user input could reach it.

Fixed

  • Join loading with operations attribute. Entities using #[crudcrate(operations = MyOps)] for create/update/delete hooks had their join loading silently bypassed on get_one and get_all — the codegen delegated entirely to CRUDOperations which does plain queries with no relation loading. The operations path now falls through to the standard join-loading codegen when the entity has join(...) fields, with before_get_one/after_get_one and before_get_all/after_get_all hooks wrapping the join-loaded body. get_one_scoped and get_all_scoped are also generated for this path (previously missing entirely).

  • FK column resolution in batch loading. The batch loader and join loader now resolve FK columns from the SeaORM RelationDef at runtime instead of guessing from the struct name convention. Joins with non-standard FK names (eg. author_ref instead of author_id) now load correctly.

Changed

  • Replaced unmaintained impls = "1" (no release since 2019) with an inline crudcrate::impls! macro. Same autoref-specialization semantics, 30 LOC, no behavior change.

  • Workspace dependencies bumped: axum 0.8.6 → 0.8.9, sea-orm 1.1.19 → 1.1.20, serde_json → 1.0.149, uuid → 1.23.1, tokio → 1.52.3, chrono → 0.4.44, tower-http → 0.6.11, utoipa → 5.5.0, plus proc-macro and rust_decimal patches.

  • url-escape (unmaintained dev dep) replaced with percent-encoding.

Documentation

  • README.md: added security caveat for the mysql feature, which pulls in rsa 0.9.10 (RUSTSEC-2023-0071, Marvin attack — no upstream fix).

0.8.1 - 2026-05-19

Security

  • Filter clause limit. Requests with more than 100 filter keys are rejected with 400 Bad Request (MAX_FILTER_CLAUSES = 100). Prevents query-planning DoS via oversized filter payloads.

  • DB error sanitization. Internal database error messages are stripped from client-facing responses. Only a generic prefix is returned; the full error is logged via tracing.

Added

  • Joined filters are now applied by the default handler. Requests like GET /customers?filter={"vehicles.make":"BMW"} previously parsed and whitelisted the filter but silently dropped it before hitting the database — users got unfiltered results. The default get_all_handler now resolves each JoinedFilter into a sub-query on the child table (with the child’s ScopeFilterable::scope_condition() applied), collects matching parent-FK values, and adds id IN (...) to the main condition. Query shape: one extra SELECT parent_fk FROM child WHERE ... per joined-filter field plus the usual list + count queries — no JOIN, no DISTINCT. Backed by test_suite/tests/joined_filter_http_test.rs and a runnable cargo run --example joined_filter.

  • New CRUDResource::resolve_joined_filters trait method. Takes the parsed condition plus the &[JoinedFilter] list and returns the augmented condition to use for both the list query and the count query. Default impl logs and returns the condition unchanged (backward compatible for non-derive users); the derive macro generates an override for every resource that declares join(..., filterable(...)) on any Vec<Child> field.

  • New public helper crudcrate::build_comparison_expr. Translates a column + FilterOperator + serde_json::Value into an Option<SimpleExpr> for use in custom filter resolvers.

Changed

  • crudcrate::filtering::ParsedFilters::joined_filters is now consumed by the handler (previously only populated by the parser and read by tests). No API change — the field was already public.

  • Pruned unused dependencies from the workspace.

Documentation

  • docs/src/features/filtering.md “Filtering on Related Entities” rewritten to describe the actual query shape, scope-safety guarantees, and the Vec<Child>-only limitation. Removed the stale “requires a custom read::many::body hook” note.
  • docs/src/features/relationships.md migrated from the deprecated join_filterable(...) / join_sortable(...) syntax to the current filterable(...) / sortable(...) inside join(...).

0.8.0 - 2026-04-17

Security

  • Atomic scope check in get_one: Scoped get_one requests now verify the scope condition in a single query (ID + scope filter), eliminating a TOCTOU race where a separate total_count() verification could see stale data between the fetch and the check.
  • FK column runtime validation: The derive macro generates #[cfg(test)] functions that verify convention-derived FK column names match the actual RelationDef from SeaORM at test time. Catches silent data mismatches from FK naming convention violations before they reach production.
  • SQL-level scope filtering for joins (all endpoints, all depths): Child entities with exclude(scoped) fields are now filtered at the SQL level (WHERE is_private = false) during join loading on both get_one_scoped and get_all_scoped, and at every depth when depth > 1. The scoped batch loader applies each child’s ScopeFilterable::scope_condition() to its Entity::find().filter(FK in parent_ids) query, and recurses via get_one_scoped (not get_one) for nested children. The in-memory ScopeFilterable::is_scope_visible() filter remains as defense-in-depth, but privacy is now enforced in the database, not just at serialisation time — private rows never leave Postgres on public endpoints.
  • require_scope attribute: New #[crudcrate(require_scope)] struct-level attribute. When set, read handlers return HTTP 500 if no ScopeCondition middleware is present — catches misconfigured routes that should be scoped but aren’t.

Added

  • Struct-level join definitions: Join fields can now be defined at the struct level instead of on the SeaORM Model. This keeps the Model lightweight and avoids stack overflow when loading entities with heavy join types. The join field only exists on the generated API struct.

    #[crudcrate(
        api_struct = "Site",
        join(name = "replicates", result = "Vec<SiteReplicate>", one, all, depth = 1)
    )]
    pub struct Model { /* no replicates field here */ }

    Field-level joins with #[sea_orm(ignore)] + #[crudcrate(non_db_attr, join(...))] still work for backward compatibility.

  • SQL-level column exclusion for exclude(list): Fields marked #[crudcrate(exclude(list))] with Option<T> types are now skipped at the SQL level in list queries — the database never transfers the data. Previously, exclude(list) only removed the field from the response struct while still fetching all columns. This dramatically improves performance for entities with large fields (photos, blobs, documents). Benchmarked at 7x improvement (1,013 → 7,121 req/s) on an endpoint with base64 photo data.

  • ScopeCondition for auth-aware query filtering: New ScopeCondition type that can be injected via Axum Extension to add conditions to read queries. Auth-system-agnostic — users write middleware to convert their auth state into a ScopeCondition. When present, get_all_handler merges the condition into the query filter, and get_one_handler verifies the fetched record passes the condition. Write operations are unaffected.

    use crudcrate::ScopeCondition;
    let public = Article::read_only_router(&db)
        .layer(Extension(ScopeCondition(
            Condition::all().add(article::Column::IsPrivate.eq(false))
        )));
  • read_only_router() method: Generates a router with only GET endpoints (get_one + get_all), no create/update/delete. Use with ScopeCondition for public/filtered API endpoints.

  • fk_column join parameter: Optional fk_column = "ColumnName" in join(...) attributes for entities where the FK column doesn’t follow the {StructName}Id convention. The convention remains the default; this is an escape hatch for non-standard schemas.

    #[crudcrate(join(one, all, depth = 1, fk_column = "OwnerUuid"))]
    pub items: Vec<Item>,
  • ScopeFilterable::scope_condition(): New trait method that returns a sea_orm::Condition matching an entity’s exclude(scoped) fields. Auto-generated by the derive macro. Enables SQL-level scope filtering for join queries.

  • get_one_scoped / get_all_scoped: New CRUDResource trait methods with scope-aware query variants. Default implementations delegate to the non-scoped get_one / get_all (safe for resources without join(all) children). The derive macro overrides both with SQL-level child-scope propagation. get_all_handler dispatches to get_all_scoped whenever a ScopeCondition extension is present.

Fixed

  • Stack overflow with many joins: All join-loading futures are now Box::pinned, moving large async state off the stack. Prevents stack overflow in debug builds with many join fields.
  • Async state machine bloat in debug builds: All join-loading futures are wrapped in Box::pin, preventing debug-build async state machine bloat from Related<E> monomorphization.

Changed

  • depth = 0 is now a compile error: Use depth = 1 for shallow loading. Previously depth = 0 could cause infinite recursion at runtime.
  • Compile-time bidirectional relation detection: Joins targeting an entity that has a Related<Self> impl (bidirectional/cyclic relationship) now produce a compile error unless an explicit depth is set. Previously, these silently caused infinite recursion at runtime via SeaORM’s Relation::def() chain. The error message explains the cycle and suggests the fix.
  • Compile-time warnings for risky join depths: Self-referencing joins without an explicit depth and joins with depth > 5 now emit #[deprecated] warnings at compile time, guiding users to set safe depth values.

0.7.2 - 2026-03-27

Added

  • Automatic enum field detection: Fields with types implementing sea_orm::ActiveEnum are now detected at compile time — no #[crudcrate(enum_field)] annotation needed. Uses zero-cost compile-time trait detection (inherent impl trick) to check each field’s type.
  • Case-insensitive enum array filtering: Array/IN filters on enum fields now apply UPPER(CAST(col AS TEXT)) on Postgres, matching the case-insensitive behavior already used for single-value enum filters.

Deprecated

  • #[crudcrate(enum_field)]: No longer required. Enum fields are auto-detected from the ActiveEnum trait implementation. The attribute still works for backward compatibility but can be safely removed.

Fixed

  • Array/IN filtering on enum fields: process_array_filter() now handles enum fields by casting to TEXT and uppercasing on Postgres. Previously, array filters on enum columns could fail on native Postgres ENUM types or produce case-sensitive results.

0.7.1 - 2026-03-09

Added

  • Transform Hooks: New transform phase in hook system for result modification
    • Hook execution order: pre → body → transform → post
    • Transform hooks receive the result and return a modified version
    • Allows enriching, decorating, or transforming CRUD results before returning
    • Supported for all operations: create, read, update, delete (one and many)
    • Example: #[crudcrate(read::one::transform = enrich_with_metadata)]
  • Partial Success for Batch Operations: New ?partial=true query parameter for batch endpoints
    • Returns HTTP 207 Multi-Status when some items succeed and some fail
    • Response includes succeeded and failed arrays with indices and error messages
    • Available for: POST /batch, PATCH /batch, DELETE /batch
    • New types: BatchResult<T>, BatchFailure, BatchOptions
    • Note: Partial mode processes items individually using single-item hooks (create::one::*, etc.), not batch hooks (create::many::*). Each item commits independently with no shared transaction.
  • Batch Create/Update Endpoints: POST /batch and PATCH /batch for bulk operations
    • Transaction-based all-or-nothing semantics by default
    • Pre-validation for batch updates ensures true atomicity across all DB backends
  • Runtime-Configurable Limits: Override batch and pagination limits per-resource
    • #[crudcrate(batch_limit = 500)] - Max items for batch create/update/delete (default: 100)
    • #[crudcrate(max_page_size = 500)] - Max items per page (default: 1000)
    • Trait methods fn batch_limit() and fn max_page_size() can be overridden for runtime logic (env vars, config)
  • Security Startup Log: Info-level log message when mounting CRUD routes
    • Reports resource name, table, batch_limit, max_page_size, and enabled security defaults
    • Silent when no tracing subscriber is configured
  • Batch Loading for Joins (N+1 Query Fix): Optimized get_all() with joins
    • Reduced from N+1 queries to 2 queries for depth=1 joins (1 for parents + 1 per join field). Deeper joins (depth > 1) may issue additional queries to load nested relations.
    • Uses WHERE parent_id IN (...) with in-memory grouping
  • Documentation Test Links: New mdbook preprocessor linking documentation examples to test files
  • IDE Documentation: Comprehensive attribute reference in crate-level documentation

Changed

  • Documentation Overhaul: Complete restructure of tutorial documentation
    • New progressive tutorial: First Steps → Auto IDs → Timestamps → Filtering → Sorting → Search → Hiding Fields → Relationships → Hooks
    • Simplified navigation structure in SUMMARY.md
    • Enhanced examples with “Run It Now” sections
    • Net reduction of ~800 lines while covering more features
  • DateTimeWithTimeZone schema fix: All generated model structs (API, Create, Update, List, Response) now resolve DateTimeWithTimeZone to chrono::DateTime<chrono::FixedOffset> so utoipa’s ToSchema derive recognizes it as a DateTime type
  • Generated API struct derives now use fully qualified paths (serde::Serialize, utoipa::ToSchema, etc.) to avoid conflicts with user imports
  • Bumped sea-orm from 1.1.17 to 1.1.19
  • Batch operation limit checking now uses Self::batch_limit() method (configurable per-resource)
  • BATCH_LIMIT and MAX_PAGE_SIZE changed from associated constants to trait methods for runtime overridability
  • Batch loading uses .remove() from HashMap instead of .get().cloned() — moves data instead of copying

Fixed

  • UUID array filtering now passes native Uuid values to is_in() instead of stringified values, fixing incorrect query generation for UUID column arrays
  • max_page_size() trait method now enforced in HTTP pagination handler
  • delete_many() returns only actually-deleted IDs
  • update_many() removed redundant pre-validation queries outside the transaction (TOCTOU race)
  • Self-referencing join errors now logged via tracing::warn! instead of silently swallowed
  • Nested relation loading errors (get_one() fallbacks) now logged via tracing::warn!
  • to_snake_case in FK derivation now handles acronyms correctly
  • Batch loading uses PK field name from entity metadata instead of hardcoded id
  • update() trait default used plural instead of singular resource name in not-found error
  • delete_many() trait default had no batch limit check (now enforces batch_limit())
  • Broken cross-reference links in reference documentation
  • Clippy doc-markdown warnings

Removed

  • BatchUpdateItem<T>: Dead struct removed from public API
  • Dead code path: Unreachable self-referencing branch in batch loading
  • Documentation: Legacy tutorial structure replaced by progressive tutorials

0.7.0 - 2025-11-26

Security

  • Harden search queries with proper wildcard escaping
  • Improve input sanitization in filtering and pagination
  • Add pagination limits to prevent excessive queries

Added

  • Join Filtering: Filter by related entity columns using dot-notation syntax
    • filterable("col1", "col2") nested inside join(...) attribute
    • Query: ?filter={"vehicles.make":"BMW"}
    • All standard operators supported (_gt, _gte, _lt, _lte, _neq)
    • Single-level joins only (nested paths like vehicles.parts.name not supported)
  • Join Sorting: Sort by related entity columns using dot-notation syntax
    • sortable("col1", "col2") nested inside join(...) attribute
    • Query: ?sort=["vehicles.year","DESC"] or ?sort_by=vehicles.year&order=DESC
    • Single-level joins only (nested paths not supported)
  • Hook System: Attribute-based customization with {operation}::{cardinality}::{phase} syntax
    • Operations: create, read, update, delete
    • Cardinality: one (single), many (batch)
    • Phases: pre, body, post
    • Example: #[crudcrate(create::one::pre = validate_fn)]
  • Batch operations: create_many and update_many with hook support
  • ApiError error type: Consistent error handling with separate internal/client messages (fixes #3)
    • impl From<DbErr> for seamless Sea-ORM integration with automatic internal logging
    • Internal errors logged via tracing, generic message sent to client
    • Custom errors: ApiError::custom(StatusCode::IM_A_TEAPOT, "client msg", Some("internal log"))
    • Variants: NotFound, BadRequest, Unauthorized, Forbidden, Conflict, ValidationFailed, Database, Internal, Custom
  • Lifecycle hooks in CRUDOperations trait
  • Improved test coverage across modules

Changed

  • Major codebase refactoring (38% size reduction)
    • Removed index_analysis module
    • Simplified relation_validator.rs
    • Consolidated join/recursion handling
    • Modular codegen/ structure
  • Handler code generation refactored for hook flow
  • Replace eprintln! with tracing for logging
  • Legacy fn_* attributes auto-map to new hook syntax

Fixed

  • Improved error handling in join path parsing
  • Fixed flaky tests with serial execution
  • All clippy::pedantic warnings resolved

Removed

  • index_analysis module: Database index recommendations moved to external tooling (pgAdmin, MySQL Workbench, etc.)
  • register_crud_analyser! macro: No longer needed without index analysis
  • attributes.rs: Dead code (IDE autocomplete hints only, never used at runtime)
  • join_strategies/ module: Consolidated into codegen/joins/
  • field_analyzer.rs: Reorganized into fields/ module
  • Redundant examples: minimal_debug.rs, minimal_spring.rs, test_router_only.rs
  • Verbose documentation: ~400 lines of excessive doc comments trimmed

Dependencies

  • Added serial_test = "3.2" for test isolation
  • Added tracing for structured logging

0.6.1 - 2025-11-03

Fixed

  • Global path resolution of joined structs
  • Restructuring of crudcrate-derive into smaller modules, bit by bit.

0.6.0 - 2025-10-31

Added

  • Recursive Join Loading: Multi-level relationship loading with #[crudcrate(join(one, all))] attribute
  • Cyclic dependency detection at compile-time with actionable error messages
  • Unlimited join depth support with default depth warnings for relationships > 3 levels
  • exclude() function-style syntax for model exclusion: #[crudcrate(exclude(create, update))]
  • The get one response is now its own model, allowing for exclusion of fields from get one/create/update responses
  • New recursive_join example demonstrating nested relationship loading
  • Debug output functionality for procedural macros with debug_output attribute

Changed

  • derive: Removed requirement for Eq and PartialEq derives on generated API structs
  • derive: Improved multi-pass code generation to handle cyclic dependencies

Fixed

  • Database test cleanup logic for PostgreSQL and MySQL backends
  • Relationship loading in get_one() and get_all() endpoints

Dependencies

  • derive: Updated with recursive join support, cyclic dependency detection, and enhanced attribute parsing

0.5.0 - 2025-08-28

Added

  • Spring-RS framework support with minimal example in /examples
  • Restored CRUD benchmarks from 0.4.5

Changed

  • Moved crudcrate-derive and examples into repository
  • Simplified framework architecture - removed redundant code generation paths
  • Refactored macro code generation by splitting helpers.rs into focused modules

Removed

  • BREAKING: Case-sensitive enum filtering functionality

0.4.5 - 2025-08-25

Fixed

  • Batch delete endpoints now returns the array of successfully deleted resource UUIDs, suitable for a react-admin batch delete response.

0.4.4 - 2025-08-20

Added

  • Index analysis system for database optimization recommendations
  • analyse_indexes_for_resource and analyse_all_registered_models functions
  • Database-specific index recommendations with priority-based output

Changed

  • BREAKING (if still using CRUDResource manually): Added required TABLE_NAME constant to CRUDResource trait. This does not affect EntityToModel functionality.
  • Made validate_field_value function const
  • Improved code organization with extracted helper functions

Fixed

  • All clippy warnings (pessimistic and pedantic)
  • Test compilation errors and naming inconsistencies
  • Documentation examples and missing trait implementations

0.4.3 - 2025-08-19

Added

  • Testing: Integration tests for create_model=false compatibility with non_db_attr
  • Testing: Comprehensive test suite for use_target_models functionality with cross-model referencing

Fixed

  • derive: Resolved lingering compilation errors from List model update
  • derive: Fixed test compatibility issues following List model integration
  • Filter system: Minor improvements to filtering logic consistency

Dependencies

  • derive: Updated to latest version with enhanced List model support and improved compatibility

0.4.2 - 2025-08-18

Added

  • List Model Support: New List model generation capability for customizing fields returned in list/getAll endpoints, similar to Create and Update models
  • Generated List model behavior with field deselection support
  • Built-in getAll query optimization to only return fields specified in List model
  • derive: Support for reserved field names using r# syntax (e.g., r#type)
  • derive: Enhanced target model usage with CRUDResource structs for cross-model referencing
  • derive: Automatic From<> trait generation for List structs from Sea-ORM DB models

Changed

  • derive: Improved trait compatibility by re-adding PartialEq, Eq, Debug, and Clone derives to models for Sea-ORM compatibility
  • derive: Route generation now uses root-level paths instead of prefixed routes for better user control
  • derive: Enhanced use_target_models functionality for better cross-model integration

Fixed

  • derive: Fixed ActiveModel generation when create model excludes keys
  • derive: Fixed create_model=false compatibility with non_db_attr
  • derive: Improved function linking in crudcrate function overrides
  • derive: Fixed trait signature for Condition in get_all operations
  • derive: Various clippy warnings resolved

Dependencies

  • derive: Updated to 0.2.6 with List model support, reserved field handling, and enhanced model generation capabilities

0.4.1 - 2025-08-05

Added

  • Index analysis functionality with analyze_indexes_for_resource() and analyze_and_display_indexes() methods
  • Full-text search support in filtering system with fulltext_searchable_columns() method
  • REST-standard pagination and query filters alongside React Admin compatibility
  • Multi-database testing support (SQLite, PostgreSQL, MySQL) via DATABASE_URL environment variable
  • Comprehensive benchmark suite with performance testing across database backends
  • Security integration tests for SQL injection protection
  • Coverage reporting with Codecov integration
  • Database feature flags for selective driver compilation (mysql, postgresql, sqlite)
  • Binary size optimization through conditional database driver inclusion

Changed

  • Enhanced filtering system with enum case insensitivity and improved edge case handling
  • Updated README with minimal examples and comprehensive testing documentation
  • Restructured test infrastructure to support multiple database backends
  • Improved error handling in filter parsing with better validation
  • Removed Clone requirement from generated API structs (Create/Update models)
  • Optimized trait methods to use references instead of owned values where possible
  • Sea-ORM dependency now uses default-features = false with selective feature enabling
  • Enhanced README with database feature selection examples

Fixed

  • Enum filtering now supports case-insensitive matching
  • Filter edge cases handle malformed JSON gracefully
  • PostgreSQL test isolation issues with race conditions during parallel execution
  • Clippy warnings resolved across codebase
  • derive: Improved integration tests and restructured codebase

Dependencies

  • derive: Updated to 0.2.1 with full-text search support and enhanced router generation capabilities
  • derive: Removed Clone derives from generated structs to reduce memory overhead

0.4.0 - 2025-07-17

Added

  • Enhanced Router Generation: Automatic router generation via generate_router attribute in EntityToModels macro
  • Non-Database Field Support: Complete support for non-DB fields using #[sea_orm(ignore)] + #[crudcrate(non_db_attr = true)] pattern
  • Single-File API Capability: Full CRUD API can now be implemented in under 60 lines of code
  • Documentation improvements for non-DB field usage with examples
  • derive: EntityToModels macro with complete entity-to-API generation and CRUDResource implementation
  • derive: Router generation capability integrated into EntityToModels
  • derive: Enhanced support for non-database fields with proper Sea-ORM integration
  • derive: Comprehensive integration tests and restructured codebase

Changed

  • Enhanced EntityToModels macro to automatically generate router functions
  • Improved documentation with comprehensive non-DB field examples
  • Router generation now fully automated with zero boilerplate
  • derive: Enhanced ToCreateModel and ToUpdateModel with new trait system
  • derive: Added MergeIntoActiveModel trait implementation

Fixed

  • derive: Test infrastructure improvements and better error handling in macro generation

0.3.3 - 2025-06-23

Fixed

  • Fix newline formatting in auto-generated OpenAPI documentation
  • Remove debug messages from production builds

Changed

  • Accept enum exact comparison in filter queries
  • Filter on integer columns support

0.3.2 - 2025-06-06

Changed

  • Bump dependencies including crudcrate-derive for improved into() casting support

Dependencies

  • derive: Updated to 0.1.6 with improved .into() casting support and enhanced field attribute handling

0.3.1 - 2025-05-12

Changed

  • Update lockfile and enhance filtering capabilities for enum and integer columns

0.3.0 - 2025-04-05

Added

  • Major: Default implementations for get_one, get_all, and update_one in CRUDResource trait
  • New MergeIntoActiveModel trait for improved update model handling
  • Enhanced derive macro integration with new trait system

Changed

  • Restructured core trait system for better usability
  • Updated derive macro to reference new MergeIntoActiveModel trait

Dependencies

  • derive: Updated to 0.1.5 with IntoActiveModel trait for UpdateModel and improved trait derivations

0.2.5 - 2025-04-04

Added

  • Export serde_with for better serialization support
  • Enhanced error responses in API endpoints
  • Documentation for query parameters

Changed

  • Renamed openapi.rs to routes.rs for better organization
  • Updated dependencies

0.2.4 - 2025-03-11

Added

  • Description string support in CRUDResource
  • Auto-populated summary and description for macro-generated endpoints
  • Enhanced OpenAPI documentation generation

Dependencies

  • derive: Updated to 0.1.4 with improved serialization support using exported serde_with

0.2.3 - 2025-03-07

Added

  • Comprehensive OpenAPI macro support
  • Better API documentation generation

Fixed

  • Improved error responses in endpoints

0.2.2 - 2025-03-06

Added

  • Documentation for query parameters

0.2.1 - 2025-03-05

Added

  • Description string support in CRUDResource
  • Auto-populated summary and description for macro-generated endpoints

0.2.0 - 2025-03-05

Changed

  • Breaking: Major refactor from route-based to macro-based approach
  • Introduced crud_handlers! macro for generating CRUD endpoints
  • Simplified API creation process significantly

Removed

  • Legacy route-based implementation

0.1.4 - 2025-03-03

Fixed

  • Fixed return type of delete_one handler
  • Applied clippy suggestions for performance improvements

0.1.3 - 2025-02-19

Changed

  • Update crudcrate-derive to allow non-db parameters in update/create models

Dependencies

  • derive: Updated to 0.1.3 with support for auxiliary attributes in structs that don’t relate to DB model

0.1.2 - 2025-02-18

Changed

  • Update proc macro to 0.1.2

Dependencies

  • derive: Updated to 0.1.2 with improved trait derivations (Clone instead of Copy where appropriate)

0.1.0 - 2025-02-18

Added

  • Initial release of crudcrate
  • Basic CRUD operation framework
  • Sea-ORM and Axum integration
  • OpenAPI documentation support
  • Move common functions and traits from existing API
  • Import proc-macros from crudcrate-derive

Dependencies

  • derive: Initial release (0.1.0) with ToCreateModel and ToUpdateModel derive macros, field-level attribute support for CRUD customization, and integration with Sea-ORM ActiveModel system