A Mongoose-style MongoDB ODM for Rust — schema definitions, lifecycle hooks, a chainable query builder, population, aggregation, pagination, and middleware, all driven by derive macros.
[dependencies]
mongrel = { path = "mongrel" } # once published: mongrel = "0.1"- Features
- Quick Start
- Schema Definition
- Model Operations
- Query Builder
- Population (Refs)
- Virtuals
- Aggregation Pipeline
- Pagination
- Lifecycle Hooks
- Middleware Chaining
- Index Builder
- Error Handling
- Project Structure
#[derive(Schema)]— declare your collection, field constraints, timestamps, and unique indexes on a plain Rust struct#[derive(Model)]— auto-generates aXModelhandle with full CRUD, query, and aggregation methods- Chainable query builder —
where_field().gte().sort().limit().exec()— no raw BSON required for common queries - Typed
Ref<T>— store ObjectId references, populate them into full documents on demand - Virtuals — computed fields that never touch MongoDB, serialized alongside real fields for API responses
- Aggregation pipeline — fluent builder for
$match,$group,$lookup,$project,$sort,$limit,$unwind, and more - Pagination —
model.paginate(page, per_page)returns total, total_pages, has_next, has_prev alongside docs - Lifecycle hooks —
pre_save,post_save,pre_delete,post_delete,pre_validate,post_validate - Middleware chaining —
MiddlewareRegistry<T>runs an ordered async stack of functions per event - Index builder — compound, sparse, TTL, text, and partial-filter indexes via
IndexBuilder - Lean queries —
.lean()skips deserialization and returns rawDocumentfor performance-critical paths - Async-first — built on Tokio and the official
mongodbasync driver
use async_trait::async_trait;
use mongrel::{bson::doc, hooks::Hooks, error::Result, Model, Mongrel, Schema, SortDir};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Schema, Model)]
#[schema(collection = "users", timestamps)]
pub struct User {
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
pub id: Option<bson::oid::ObjectId>,
#[field(required, min_length = 2, max_length = 50)]
pub name: String,
#[field(required, unique)]
pub email: String,
#[field(enum_values = "admin, user, moderator")]
pub role: String,
pub age: Option<i32>,
}
#[async_trait]
impl Hooks for User {}
#[tokio::main]
async fn main() -> Result<()> {
let db = Mongrel::connect("mongodb://localhost:27017", "myapp").await?;
let users = UserModel::new(db);
users.ensure_indexes().await?;
// Create
let user = users.create(User {
id: None,
name: "Alice".into(),
email: "alice@example.com".into(),
role: "admin".into(),
age: Some(30),
}).await?;
// Query
let adults = users
.find()
.where_field("age").gte(18)
.sort("name", SortDir::Asc)
.limit(10)
.exec()
.await?;
// Update
users.find_by_id_and_update(
&user.id.unwrap().to_hex(),
doc! { "$set": { "role": "moderator" } },
).await?;
// Delete
users.find_by_id_and_delete(&user.id.unwrap().to_hex()).await?;
Ok(())
}Annotate any named struct with #[derive(Schema, Model)].
| Attribute | Type | Description |
|---|---|---|
collection = "name" |
string | MongoDB collection name. Defaults to snake_case plural of the struct name. |
timestamps |
flag | Auto-injects created_at and updated_at (bson::DateTime) on create/update. |
#[derive(Serialize, Deserialize, Schema, Model)]
#[schema(collection = "blog_posts", timestamps)]
pub struct BlogPost { /* ... */ }| Attribute | Type | Description |
|---|---|---|
required |
flag | Marks the field as required (enforced at validation time). |
unique |
flag | Creates a unique index on this field via ensure_indexes(). |
min_length = N |
usize | Minimum character length for String / Option<String> fields. |
max_length = N |
usize | Maximum character length for String / Option<String> fields. |
enum_values = "a, b, c" |
string | Comma-separated list of allowed string values. |
rename = "mongo_name" |
string | Override the field name stored in MongoDB. |
#[derive(Serialize, Deserialize, Schema, Model)]
#[schema(collection = "products")]
pub struct Product {
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
pub id: Option<bson::oid::ObjectId>,
#[field(required, min_length = 3, max_length = 120)]
pub name: String,
#[field(enum_values = "draft, published, archived")]
pub status: String,
#[field(unique)]
pub sku: String,
}Override validate() directly on MongooseSchema for logic that can't be expressed with attributes:
use mongrel::schema::MongooseSchema;
use mongrel::error::MongooseError;
impl MongooseSchema for Product {
fn collection_name() -> &'static str { "products" }
fn validate(&self) -> std::result::Result<(), MongooseError> {
if self.name.contains('<') {
return Err(MongooseError::Validation("name must not contain HTML".into()));
}
Ok(())
}
}#[derive(Model)] generates a {Name}Model struct. Construct it with a shared Arc<Database>:
let db = Mongrel::connect("mongodb://localhost:27017", "myapp").await?;
let users = UserModel::new(Arc::clone(&db));let user = users.create(User { id: None, name: "Bob".into(), /* ... */ }).await?;
// Runs: pre_validate → validate → post_validate → pre_save → insert → post_save// All documents
let all = users.find().exec().await?;
// By id
let one = users.find_by_id("64b1f...").await?; // Option<User>
// Raw filter
let admins = users.find_many(doc! { "role": "admin" }).exec().await?;
// Single doc
let first = users.find_one_where(doc! { "email": "x@y.com" }).exec_one().await?;// By id — returns the updated document
let updated = users
.find_by_id_and_update("64b1f...", doc! { "$set": { "name": "New Name" } })
.await?;
// By filter
let updated = users
.find_one_and_update(doc! { "email": "x@y.com" }, doc! { "$inc": { "age": 1 } })
.await?;
// Many
let modified_count = users
.update_many(doc! { "role": "user" }, doc! { "$set": { "active": true } })
.await?;let deleted = users.find_by_id_and_delete("64b1f...").await?; // Option<User>
let deleted = users.find_one_and_delete(doc! { "email": "x" }).await?;
let count = users.delete_many(doc! { "active": false }).await?;let doc = users
.find_one_and_upsert(
doc! { "email": "x@y.com" },
doc! { "$setOnInsert": { "name": "X", "role": "user" } },
)
.await?;let total = users.count_documents(doc! {}).await?;
let admins = users.count_documents(doc! { "role": "admin" }).await?;model.find() returns a QueryBuilder<T> with a fluent API. All filters are composed with AND semantics.
users.find()
.where_field("age").gte(18)
.where_field("age").lte(65)
.where_field("role").eq("admin")
.where_field("score").ne(0)
.where_field("rank").gt(10)
.where_field("rank").lt(100)
.exec().await?;users.find()
.where_field("role").in_list(["admin", "moderator"])
.where_field("status").nin(["banned", "deleted"])
.exec().await?;users.find()
.where_field("name").regex("^alice", "i") // case-insensitive
.exec().await?;users.find()
.where_field("phone").field_exists(true)
.exec().await?;users.find()
.sort("created_at", SortDir::Desc)
.sort("name", SortDir::Asc)
.limit(20)
.skip(40)
.select(["name", "email", "role"])
.exec().await?;| Method | Returns | Description |
|---|---|---|
.exec() |
Vec<T> |
All matching documents |
.exec_one() |
Option<T> |
First matching document |
.count() |
u64 |
Number of matching documents |
.any() |
bool |
true if at least one match exists |
.lean() |
Vec<Document> |
Raw BSON — skips deserialization |
.lean_one() |
Option<Document> |
Raw BSON, single document |
users.find()
.filter(doc! { "$or": [{ "role": "admin" }, { "age": { "$gt": 60 } }] })
.exec().await?;Use Ref<T> to store a typed reference to another collection's document.
use mongrel::Ref;
#[derive(Serialize, Deserialize, Schema, Model)]
#[schema(collection = "posts")]
pub struct Post {
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
pub id: Option<bson::oid::ObjectId>,
pub title: String,
pub author: Ref<User>, // stored as ObjectId in MongoDB
}Implement the Populate trait on your struct:
use mongrel::populate::{Populate, resolve_ref};
use async_trait::async_trait;
#[async_trait]
impl Populate for Post {
async fn populate(mut self, db: Arc<mongodb::Database>) -> Result<Self> {
self.author = resolve_ref(self.author, Arc::clone(&db)).await?;
Ok(self)
}
}Then call it after fetching:
let post = posts.find_by_id("64b1f...").await?.unwrap();
let populated = post.populate(Arc::clone(&db)).await?;
match &populated.author {
Ref::Populated(user) => println!("Author: {}", user.name),
Ref::Id(id) => println!("Unpopulated id: {}", id),
}| Method | Description |
|---|---|
.id() |
Returns Option<&ObjectId> if unpopulated |
.populated() |
Returns Option<&T> if populated |
.is_populated() |
true if the document has been loaded |
Ref<T> serializes and deserializes as a plain ObjectId, so it stores correctly in MongoDB regardless of population state.
Computed properties derived at runtime — never written to MongoDB.
use mongrel::virtual_fields::Virtuals;
pub struct User {
pub first_name: String,
pub last_name: String,
pub age: i32,
}
impl Virtuals for User {
// Define computed methods — they live on the struct, not in MongoDB
}
impl User {
pub fn full_name(&self) -> String {
format!("{} {}", self.first_name, self.last_name)
}
pub fn is_adult(&self) -> bool {
self.age >= 18
}
}Use WithVirtuals<T> to merge computed values into a JSON response:
use mongrel::WithVirtuals;
let response = WithVirtuals::new(user)
.add("full_name", user.full_name())
.add("is_adult", user.is_adult());
let json = serde_json::to_string(&response)?;
// { "first_name": "Alice", "last_name": "Smith", ..., "full_name": "Alice Smith", "is_adult": true }model.aggregate() returns an AggregationPipeline<T> — chain stages and call exec() or exec_raw().
let results = users
.aggregate()
.match_stage(doc! { "age": { "$gte": 18 } })
.sort(doc! { "age": -1 })
.limit(100)
.exec()
.await?;| Method | MongoDB stage |
|---|---|
.match_stage(filter) |
$match |
.sort(doc) |
$sort |
.limit(n) |
$limit |
.skip(n) |
$skip |
.project(doc) |
$project |
.unwind(path) |
$unwind |
.unwind_opts(path, preserve_null) |
$unwind with options |
.group(doc) |
$group |
.lookup(from, local, foreign, as) |
$lookup |
.add_fields(doc) |
$addFields |
.replace_root(expr) |
$replaceRoot |
.count(field) |
$count |
.raw_stage(doc) |
any custom stage |
let stats = orders
.aggregate()
.match_stage(doc! { "status": "completed" })
.lookup("users", "user_id", "_id", "user")
.unwind("$user")
.group(doc! {
"_id": "$user._id",
"total": { "$sum": "$amount" },
"count": { "$sum": 1 },
})
.sort(doc! { "total": -1 })
.exec_raw() // returns Vec<Document> — no schema needed for shaped output
.await?;let page = users
.paginate(2, 10) // page 2, 10 per page
.filter(doc! { "active": true })
.sort(doc! { "created_at": -1 })
.exec()
.await?;
println!("{} total users", page.total);
println!("Page {} of {}", page.page, page.total_pages);
println!("Has next: {} | Has prev: {}", page.has_next, page.has_prev);
for user in page.docs {
println!("- {}", user.name);
}| Field | Type | Description |
|---|---|---|
docs |
Vec<T> |
Documents for this page |
total |
u64 |
Total matching documents |
page |
u64 |
Current page (1-based) |
per_page |
u64 |
Page size requested |
total_pages |
u64 |
ceil(total / per_page) |
has_next |
bool |
page < total_pages |
has_prev |
bool |
page > 1 |
Implement Hooks on your schema struct. All methods are async and have default no-op implementations — override only what you need.
use async_trait::async_trait;
use mongrel::{hooks::Hooks, error::Result};
#[async_trait]
impl Hooks for User {
async fn pre_validate(&self) -> Result<()> {
// Runs before field-level validation
Ok(())
}
async fn post_validate(&self) -> Result<()> {
// Runs after validation passes
Ok(())
}
async fn pre_save(&mut self) -> Result<()> {
// Hash passwords, normalize fields, etc.
self.email = self.email.to_lowercase();
Ok(())
}
async fn post_save(&self) -> Result<()> {
// Send welcome email, emit event, etc.
println!("Saved: {}", self.email);
Ok(())
}
async fn pre_delete(&self) -> Result<()> {
// Cancel subscriptions, check constraints, etc.
Ok(())
}
async fn post_delete(&self) -> Result<()> {
// Cleanup related data, audit log, etc.
Ok(())
}
}pre_validate → validate (field attributes) → post_validate
→ pre_save → insert into MongoDB → post_save
MiddlewareRegistry<T> lets you register multiple ordered async functions per event — useful for cross-cutting concerns (logging, auditing, encryption) without polluting your schema's Hooks impl.
use mongrel::{MiddlewareRegistry, ModelWithMiddleware};
let registry = MiddlewareRegistry::new()
.pre_save(|doc: &mut User| async {
println!("[audit] saving user: {}", doc.email);
Ok(())
})
.pre_save(|doc: &mut User| async {
// second pre_save runs after first
doc.email = doc.email.to_lowercase();
Ok(())
})
.post_delete(|doc: &mut User| async {
println!("[audit] deleted: {}", doc.email);
Ok(())
});
let users_mw = ModelWithMiddleware::new(UserModel::new(Arc::clone(&db)), registry);
// Uses the middleware stack automatically
let user = users_mw.create(User { /* ... */ }).await?;Middleware runs before the built-in Hooks trait methods, in registration order.
Fields marked #[field(unique)] have their indexes created when you call ensure_indexes():
users.ensure_indexes().await?;Implement MongooseIndexes on your struct and call ensure_custom_indexes():
use mongrel::index::{IndexBuilder, MongooseIndexes};
impl MongooseIndexes for User {
fn indexes() -> Vec<mongrel::IndexDef> {
vec![
// Compound index
IndexBuilder::new()
.field("last_name")
.field("first_name")
.name("full_name_idx")
.build(),
// Sparse index (only indexes documents where the field exists)
IndexBuilder::new()
.field("phone")
.sparse()
.build(),
// TTL index — documents expire after 1 hour
IndexBuilder::new()
.field("created_at")
.ttl(3600)
.name("session_ttl")
.build(),
// Full-text search index
IndexBuilder::new()
.text("bio")
.text("name")
.name("text_search")
.build(),
// Partial index — only index active users
IndexBuilder::new()
.field("email")
.unique()
.partial_filter(doc! { "active": true })
.build(),
]
}
}
// At startup:
users.ensure_indexes().await?;
users.ensure_custom_indexes().await?;All fallible methods return mongrel::Result<T>, which is std::result::Result<T, MongooseError>.
use mongrel::error::MongooseError;
match users.find_by_id("bad_id").await {
Ok(Some(user)) => println!("Found: {}", user.name),
Ok(None) => println!("Not found"),
Err(MongooseError::InvalidId(msg)) => eprintln!("Bad ObjectId: {msg}"),
Err(MongooseError::Validation(msg)) => eprintln!("Validation failed: {msg}"),
Err(MongooseError::NotFound) => eprintln!("Document not found"),
Err(MongooseError::Driver(e)) => eprintln!("MongoDB error: {e}"),
Err(MongooseError::Serialization(msg))=> eprintln!("BSON error: {msg}"),
}| Variant | When |
|---|---|
Driver(mongodb::error::Error) |
Any error from the MongoDB driver |
Validation(String) |
Field constraint or custom validation failed |
NotFound |
Expected document was absent |
Serialization(String) |
BSON serialization / deserialization failure |
InvalidId(String) |
Malformed ObjectId string |
mongrel/ ← library crate — what users depend on
├── src/
│ ├── lib.rs — public re-exports
│ ├── connection.rs — Mongrel::connect() + global Arc<Database>
│ ├── schema.rs — MongooseSchema trait
│ ├── model.rs — Model<T>: CRUD, paginate, aggregate, indexes
│ ├── query.rs — QueryBuilder: chainable filter/sort/limit/lean
│ ├── populate.rs — Ref<T>, resolve_ref(), Populate trait
│ ├── virtual_fields.rs — Virtuals trait + WithVirtuals<T>
│ ├── aggregation.rs — AggregationPipeline builder
│ ├── pagination.rs — PaginateBuilder → PaginatedResult<T>
│ ├── middleware.rs — MiddlewareRegistry<T>, ModelWithMiddleware<T>
│ ├── index.rs — IndexBuilder, MongooseIndexes trait
│ ├── hooks.rs — Hooks trait (pre/post save/delete/validate)
│ └── error.rs — MongooseError, Result<T>
├── examples/
│ └── basic.rs — end-to-end usage demo
└── tests/
└── integration.rs — integration tests (testcontainers + real MongoDB)
mongrel-macros/ ← proc-macro crate (internal — not imported directly)
└── src/
└── lib.rs — #[derive(Schema)] + #[derive(Model)]
Requires a running MongoDB instance on localhost:27017:
cargo run --example basicTests spin up a real MongoDB container via Docker — requires Docker running locally:
cargo testMIT