modo-db
Database integration for the modo framework. Provides SeaORM-backed connection pooling, automatic schema synchronisation, versioned migrations, and a compile-time entity/migration registration system built on inventory.
Features
sqlite(default) — enables SQLite viasqlx-sqlite. WAL mode, busy-timeout, and foreign keys are applied automatically.postgres— enables PostgreSQL viasqlx-postgres.
Usage
Configuration
DatabaseConfig is deserialized from your app's YAML config. The backend is auto-detected from the URL scheme.
use AppConfig;
use DatabaseConfig;
use Deserialize;
Example config.yaml:
database:
url: "sqlite://data/main.db?mode=rwc"
max_connections: 5
min_connections: 1
Defaults: sqlite://data/main.db?mode=rwc, max_connections: 5, min_connections: 1.
Connecting and migrating
async
sync_and_migrate runs in two phases:
- Schema sync — creates or adds columns for all registered entities (addition-only).
- Migration runner — executes pending versioned migrations tracked in
_modo_migrations.
Group-scoped sync
Use sync_and_migrate_group to sync only entities and migrations belonging to a named group. This is useful when entities in a group live in a separate database (e.g. a dedicated jobs database):
let jobs_db = connect.await?;
sync_and_migrate_group.await?; // syncs only "jobs" group
sync_and_migrate.await?; // syncs all entities to main DB
Defining entities
Apply #[modo_db::entity(table = "...")] to a plain struct. The macro preserves your struct as a first-class domain type and generates a SeaORM entity module alongside it.
The struct receives Clone, Debug, Serialize, Default, and From<Model> automatically. You never need to work with the SeaORM Model directly.
Optionally assign an entity to a named group with group = "<name>" (defaults to "default"). Entities in a group can be synced to a separate database via sync_and_migrate_group.
// adds created_at, updated_at
The macro creates:
- Your struct
TodowithClone, Debug, Serialize, Default, From<todo::Model> - A submodule
todocontainingModel,ActiveModel,Entity,Column, andRelation - Inherent CRUD methods (
insert,update,delete,find_by_id,delete_by_id) on the struct - An
impl Record for Todowith query builder methods (find_all,query,update_many,delete_many)
Because Default is generated, you can use struct-update syntax to set only the fields you care about:
let todo = Todo ;
Field attributes
| Attribute | Effect |
|---|---|
primary_key |
Marks the primary key column |
auto_increment = false |
Disables auto-increment (required for composite PKs) |
auto = "ulid" / auto = "short_id" |
Auto-generates the PK before insert (primary key only) |
unique |
Adds a unique constraint |
indexed |
Adds a single-column index |
column_type = "Text" |
Overrides the SeaORM column type |
default_value = <lit> |
Sets a column default |
default_expr = "<sql>" |
Sets a SQL expression default |
belongs_to = "OtherEntity" |
Defines a FK relation; combine with on_delete/on_update |
has_many / has_one |
Declares an inverse relation (no DB column) |
via = "JunctionEntity" |
Many-to-many through a junction table |
renamed_from = "old_name" |
Records rename as a column comment |
nullable |
Accepted (no-op; Option<T> already implies nullable) |
Struct attributes
| Attribute | Effect |
|---|---|
#[entity(timestamps)] |
Appends created_at and updated_at (DateTime<Utc>) |
#[entity(soft_delete)] |
Appends deleted_at (Option<DateTime<Utc>>) and generates soft-delete methods on the struct |
#[entity(index(columns = ["a", "b"]))] |
Generates a composite index via CREATE INDEX IF NOT EXISTS |
#[entity(index(columns = ["slug"], unique))] |
Generates a composite unique index |
CRUD operations
All CRUD methods are inherent methods generated on your struct. The Record trait methods (find_all, query, update_many, delete_many) require use modo_db::Record in scope.
Insert
use ;
async
Find by ID
find_by_id returns the record or a 404 Not Found error automatically:
let todo = find_by_id.await?;
Find all
let todos = find_all.await?;
Update
update mutates the struct in-place and refreshes all fields from the database:
let mut todo = find_by_id.await?;
todo.completed = true;
todo.update.await?;
Delete
delete_by_id.await?;
// or, if you already have the record:
todo.delete.await?;
Filtered queries
Use Todo::query() to build chainable queries. Results are automatically converted to the domain type.
// All incomplete todos, newest first
let todos: = query
.filter
.order_by_desc
.all
.await?;
// At most one result
let maybe: = query
.filter
.one
.await?;
// Count matching rows
let n: u64 = query
.filter
.count
.await?;
limit and offset are also available for manual slicing:
let page: = query
.order_by_asc
.limit
.offset
.all
.await?;
Pagination
Offset-based
use QueryReq;
use ;
async
Cursor-based
use ;
async
per_page defaults to 20 and is clamped to [1, 100]. Paginate forward with ?after=<cursor> and backward with ?before=<cursor>.
Bulk operations
Bulk update
use Expr;
let affected = update_many
.filter
.col_expr
.exec
.await?;
Bulk delete
let deleted = delete_many
.filter
.exec
.await?;
Both return the number of rows affected as u64.
Transactions
Pass the transaction handle the same way as &db:
let txn = db.begin.await.map_err?;
let todo = Todo .insert.await?;
txn.commit.await.map_err?;
Lifecycle hooks
Define inherent methods on your struct to hook into save and delete operations. No attributes or trait imports are required — Rust's inherent-method priority means your methods automatically take precedence over the no-op defaults provided by DefaultHooks.
The three hook signatures are:
| Hook | Signature | When called |
|---|---|---|
before_save |
fn(&mut self) -> Result<(), modo::Error> |
Before insert and update |
after_save |
fn(&self) -> Result<(), modo::Error> |
After successful insert / update |
before_delete |
fn(&self) -> Result<(), modo::Error> |
Before delete |
Relations
Declare relations with field attributes and the macro generates async accessor methods on your struct.
Generated accessors:
// belongs_to: field `user_id` -> method `user()`
let author: = post.user.await?;
// has_many: field `posts` -> method `posts()`
let posts: = user.posts.await?;
has_one works the same way but returns Option<T> instead of Vec<T>.
Soft delete
Add #[entity(soft_delete)] to inject a deleted_at column and enable soft-delete semantics. Standard query() and find_all() automatically exclude soft-deleted records.
Generated methods:
// Soft-delete a single record (sets deleted_at = now, does not remove the row)
item.delete.await?;
// Soft-delete by ID
delete_by_id.await?;
// Restore a soft-deleted record (clears deleted_at)
item.restore.await?;
// Hard-delete a single record
item.force_delete.await?;
// Hard-delete by ID
force_delete_by_id.await?;
// Query including soft-deleted records
let all: = with_deleted.all.await?;
// Query only soft-deleted records
let trash: = only_deleted.all.await?;
// Bulk soft-delete (UPDATE SET deleted_at = now() WHERE ...)
let n = delete_many
.filter
.exec
.await?;
// Bulk hard-delete (bypasses soft-delete)
let n = force_delete_many
.filter
.exec
.await?;
Partial updates
To update only specific fields using the raw SeaORM active model API, use into_active_model to obtain a PK-only active model and set only the fields you need:
use ;
use Record;
let mut am = todo.into_active_model;
am.completed = Set;
am.update.await.map_err?;
Escape hatch
EntityQuery wraps a SeaORM Select<E>. Unwrap it at any point with into_select() for advanced queries:
use QuerySelect;
let select = query
.filter
.into_select;
// Use raw SeaORM from here
let models = select
.columns
.all
.await?;
You can also use the SeaORM Entity directly at any time:
use EntityTrait;
let models = find.all.await?;
let todos: = models.into_iter.map.collect;
Versioned migrations
Use #[modo_db::migration] for changes that schema sync cannot express (e.g. data seeding, backfills, renaming columns).
The db parameter is a &sea_orm::DatabaseConnection, so you can use the full SeaORM typed API:
async
Raw SQL is also available for DDL operations that SeaORM cannot express:
async
Migrations are executed in ascending version order. Each version is recorded in _modo_migrations and runs exactly once. Duplicate version numbers are detected at startup and cause an error.
Migrations can also be assigned to a group with group = "<name>" so they only run when sync_and_migrate_group is called with the matching group.
ID generation
let ulid_id = generate_ulid; // 26-char Crockford Base32
let short_id = generate_short_id; // 13-char Base36, time-sortable
Key Types
| Type | Purpose |
|---|---|
DatabaseConfig |
Connection URL + pool size, deserialised from YAML |
DbPool |
Newtype over sea_orm::DatabaseConnection; implements GracefulShutdown |
Db |
Axum extractor that pulls DbPool from app state |
Record |
Trait providing find_all, query, update_many, delete_many; implemented for every entity struct |
DefaultHooks |
Blanket trait providing no-op before_save, after_save, before_delete |
EntityQuery<T, E> |
Chainable query builder with automatic domain-type conversion |
EntityUpdateMany<E> |
Chainable bulk UPDATE builder |
EntityDeleteMany<E> |
Chainable bulk DELETE builder |
EntityRegistration |
Compile-time entity registry entry (produced by #[entity] macro) |
MigrationRegistration |
Compile-time migration registry entry (produced by #[migration] macro) |
PageParams / PageResult<T> |
Offset pagination request + response |
CursorParams<V> / CursorResult<T> |
Cursor pagination request + response |