Repositories in autumn-web provide a clean, type-safe, and highly optimized ORM-like data access layer. By annotating a trait with #[autumn_web::repository(Model, table = "table_name")], Autumn automatically generates high-performance implementations targeting PostgreSQL using diesel-async.
In version 0.5.0, Autumn introduces high-performance Bulk CRUD operations to minimize database round trips and execute massive writes transaction-safely and hook-compliantly.
Generated Bulk CRUD Methods
When you declare a repository, the generated Pg[Name]Repository automatically implements the following high-performance bulk operations:
fn save_many(
&self,
new: &[NewModel]
) -> impl Future<Output = AutumnResult<Vec<Model>>> + Send;
fn save_many_skip_invalid(
&self,
new: &[NewModel]
) -> impl Future<Output = AutumnResult<(Vec<Model>, Vec<(usize, AutumnError)>)>> + Send;
fn update_many(
&self,
ids: &[i64],
changes: &UpdateModel
) -> impl Future<Output = AutumnResult<Vec<Model>>> + Send;
fn delete_many(
&self,
ids: &[i64]
) -> impl Future<Output = AutumnResult<()>> + Send;
fn upsert_many(
&self,
records: &[Model]
) -> impl Future<Output = AutumnResult<Vec<Model>>> + Send;
1. High-Performance Batch Insertion: save_many
save_many takes a slice of new records and inserts them in a single batch statement.
Non-Hooked (Zero-Cost Path)
If your model has no hooks configured, save_many translates to a single SQL query:
INSERT INTO table_name (col1, col2, ...)
VALUES ($1, $2, ...), ($3, $4, ...), ...
RETURNING *;
For large inputs, queries are automatically chunked under the Postgres parameter ceiling (65,535 parameters), preventing compilation or runtime DB overflow errors.
Hook-Aware Execution
If hooks are enabled on your repository, save_many guarantees full transaction integrity:
- Runs
before_createhooks sequentially on each record. - Batches the validated records and inserts them in a single database round trip inside a transaction.
- Runs
after_createhooks sequentially on successfully inserted records. - Stages
after_create_commithooks to fire only after the surrounding transaction successfully commits.
2. Validation & Partial Success: save_many_skip_invalid
When bulk importing dirty external data (e.g., from CSVs or public API hooks), some rows might violate business rules or database constraints. save_many_skip_invalid enables maximum throughput without losing valid rows.
- It runs
before_createhooks on each row and filters out custom validation failures. - It attempts a high-speed batch insert of all successful records in a transaction.
- Constraint Fallback: If the batch insert fails due to a database constraint (e.g.,
UniqueViolation), it automatically falls back to row-by-row insertion for that chunk, isolating individual DB constraint failures. - Returns a tuple of
(successful_models, list_of_errors_with_indices).
3. Bulk Updates: update_many
update_many modifies a batch of records identified by their IDs in a single SQL operation.
Non-Hooked
Updates all matching rows directly:
repo.update_many(&[1, 2, 3], &UpdatePost { title: Some("Bulk Updated Title".to_string()) }).await?;
Hook-Aware
If before_update hooks are configured:
- Performs a
SELECT ... FOR UPDATEon all specified IDs to load their current state. - For each row, constructs an
UpdateDraftcontaining the original model and applies the changes. - Runs
before_updatehooks on each draft. - Updates all matching records in the database.
- Runs
after_updatehooks.
4. Bulk Deletions: delete_many
delete_many deletes or soft-deletes a batch of records in a single statement.
Non-Hooked
Runs a single direct delete or soft-delete update statement.
Hook-Aware
- Performs a
SELECT ... FOR UPDATEon all specified IDs. - Runs
before_deletehooks sequentially. - Executes the batch delete / soft-delete.
- Runs
after_deletehooks sequentially.
5. Bulk Upserts: upsert_many
upsert_many executes high-performance "insert-or-update" operations using a single SQL query matching on the primary key:
INSERT INTO table_name (id, col1, col2, ...)
VALUES ($1, $2, ...), ($3, $4, ...)
ON CONFLICT (id) DO UPDATE SET col1 = EXCLUDED.col1, ...
RETURNING *;
[!IMPORTANT] Compile-Time Hook Safety: If hooks are enabled on your repository, calling
upsert_manyis explicitly rejected at compile-time. Because Postgres determines whether a row will insert vs update at runtime, it is impossible to correctly invokebefore_createorbefore_updatehooks before sending the query. To prevent silent hook bypass, this is caught during compilation.
Read Replicas: Automatic Read Routing
When database.replica_url is configured, every generated read-only method — find_by_id, find_all, count, exists_by_id, paginate, cursor_page, derived find_by_* / count_by_* / exists_by_* queries, and full-text search / search_page — automatically acquires its connection from the replica pool. Mutating methods (save, update, delete_by_id, the bulk operations, hook-driven writes, with_lock) always run on the primary. Provisioning a replica therefore offloads your primary with zero application code changes.
When no replica is configured, all methods use the primary — single-pool apps are unaffected.
The routing decision is snapshotted per request from AppState::read_pool(), so it honors the database.replica_fallback policy: when the replica is unready, reads either fall back to the primary (replica_fallback = "primary") or fail fast with 503 Service Unavailable (replica_fallback = "fail_readiness") rather than silently serving from the wrong role.
Opting Out: primary_reads
Replica reads can be stale by up to your replication lag. For aggregates where a stale read is worse than extra primary load (e.g. account balances, inventory counters), pin the whole repository to the primary:
#[autumn_web::repository(AccountBalance, primary_reads)]
pub trait AccountBalanceRepository {}
All generated reads on this repository use the primary pool, even when a replica is configured. Prefer the per-call escape hatch below when only some call sites are read-after-write-sensitive — a repository-wide opt-out gives up replica offloading everywhere.
Read-Your-Writes: on_primary()
After a handler performs a write, an immediate read may land on a replica that has not replayed it yet. The generated on_primary() method returns a clone of the repository whose reads are pinned to the primary, so you can read-your-writes without dropping to raw Diesel:
let created = repo.save(&new_post).await?;
// The replica may not have seen this row yet — read it from the primary.
let fresh = repo.on_primary().find_by_id(created.id).await?;
The original repo keeps routing reads to the replica; only the pinned clone (and call chains on it) use the primary.
Transactions
Reads executed inside an explicit transaction (db.tx(...) or repo.with_lock(...)) run on the transaction's own primary connection — a transaction never splits reads onto a replica.
Performance & Scaling Guidelines
Bulk operations are built for maximum performance, with the following built-in safeguards:
The Postgres Parameter Ceiling
Postgres supports a maximum of 65,535 parameters per statement. If you try to insert 10,000 rows with 8 columns, that requires 80,000 parameters, which ordinarily crashes. Autumn automatically calculates the optimal chunk size based on your model's columns and inserts in chunks (e.g. 1000 records at a time) to always remain well below the ceiling while maintaining peak batching throughput (>50x speedups over individual insertions).