A Prisma-like DSL for SeaORM that provides a type-safe and ergonomic way to build database queries.
Caustics are the shimmering patterns of light that form when sunlight passes through water and reflects off the sea bed. Similarly, this crate bends and focuses SeaORM's query interface into a more ergonomic shape, offering an alternative to SeaORM's native DSL with a familiar Prisma-like syntax deduced by reflection on SeaORM's datamodel.
- Prerequisites
- Getting Started
- Installation
- Feature Usage
- Quick Start
- Define Entities
- Primary Key Configuration
- Basic Operations
- Relations and Includes
- Filtering
- Pagination and Sorting
- Advanced Features
- Testing
- Acknowledgments
- License
- Rust 1.70+ (stable or nightly)
- SeaORM-compatible database (PostgreSQL, MySQL, SQLite)
- Basic familiarity with SeaORM and Rust async/await
To build and test the Caustics project:
# Build the project
cargo build
# Run tests (stable Rust)
cargo test
# Run tests with nightly features
cargo +nightly test --workspace --exclude library --all-features
# Build examples
cargo build --examplesAdd this to your Cargo.toml:
[dependencies]
caustics = { path = "../caustics" }
caustics-macros = { path = "../caustics-macros" }
[build-dependencies]
caustics-build = { path = "../caustics-build" }Create a build.rs file in your project root to generate the Caustics client:
use caustics_build::generate_caustics_client;
fn main() {
if let Err(e) = generate_caustics_client(&["src"], "caustics_client.rs") {
eprintln!("Error generating client: {}", e);
std::process::exit(1);
}
}This build script will automatically generate a caustics_client.rs file in your OUT_DIR that contains the client code for all entities marked with the #[caustics] macro. Include the generated client in your main library file:
// In your src/lib.rs or src/main.rs
include!(concat!(env!("OUT_DIR"), "/caustics_client.rs"));Toolchain support: this project supports both stable and nightly Rust toolchains. The select! macro requires nightly Rust and is gated behind the "select" feature. Use stable Rust for basic functionality, or enable the "select" feature with nightly Rust for enhanced field selection syntax.
[dependencies]
caustics = { path = "../caustics" }
caustics-macros = { path = "../caustics-macros" }[dependencies]
caustics = { path = "../caustics" }
caustics-macros = { path = "../caustics-macros", features = ["select"] }- When the "select" feature is enabled, you can use the convenient
entity::select!(field1, field2)
use caustics_client::CausticsClient;
use caustics_macros::{caustics, select_struct};
use sea_orm::{Database, DatabaseConnection};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Connect to your database
let db: DatabaseConnection = Database::connect("your_database_url").await?;
// Create a Caustics client
let client = CausticsClient::new(db);
// Now you can use the client for type-safe database operations
// See the examples below for more details
Ok(())
}#[caustics]
pub mod user {
use caustics_macros::Caustics;
use chrono::{DateTime, FixedOffset};
use sea_orm::entity::prelude::*;
#[derive(Caustics, Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "users")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub email: String,
pub name: String,
#[sea_orm(nullable)]
pub age: Option<i32>,
pub created_at: DateTime<FixedOffset>,
pub updated_at: DateTime<FixedOffset>,
}
#[derive(Caustics, Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
has_many = "super::post::Entity",
from = "Column::Id",
to = "super::post::Column::UserId"
)]
Posts,
}
// Add Related trait implementation for relations
impl Related<super::post::Entity> for Entity {
fn to() -> RelationDef {
Relation::Posts.def()
}
}
impl sea_orm::ActiveModelBehavior for ActiveModel {}
}
#[caustics]
pub mod post {
use caustics_macros::Caustics;
use chrono::{DateTime, FixedOffset};
use sea_orm::entity::prelude::*;
#[derive(Caustics, Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "posts")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub title: String,
#[sea_orm(nullable)]
pub content: Option<String>,
#[sea_orm(created_at)]
pub created_at: DateTime<FixedOffset>,
#[sea_orm(updated_at)]
pub updated_at: DateTime<FixedOffset>,
#[sea_orm(column_name = "user_id")]
pub user_id: i32,
}
#[derive(Caustics, Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::UserId",
to = "super::user::Column::Id"
)]
User,
}
// Add Related trait implementation for relations
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
}By default, primary keys in SeaORM are auto-increment and are automatically excluded from the create method parameters. This is the expected behavior for most use cases:
#[derive(Caustics, Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "users")]
pub struct Model {
#[sea_orm(primary_key)] // Auto-increment by default
pub id: i32, // Excluded from create() method
pub email: String,
pub name: String,
}For primary keys that are not auto-increment (like UUIDs), you need to explicitly specify this and they will be included in the create method:
#[derive(Caustics, Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "users")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid, // Included in create() method
pub email: String,
pub name: String,
}Caustics supports composite primary keys where multiple fields form the primary key:
#[derive(Caustics, Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "books")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub title: String, // Part of composite primary key
#[sea_orm(primary_key, auto_increment = false)]
pub author_id: i32, // Part of composite primary key
pub publication_year: i32,
pub created_at: DateTime<FixedOffset>,
pub updated_at: DateTime<FixedOffset>,
}Caustics provides two powerful attributes that enhance your entity definitions with automatic features:
The #[caustics(default)] attribute allows you to mark fields that should be auto-generated by the application, excluding them from the create method parameters.
#[sea_orm(column_name = "createdAt")]
/// #[caustics(default)]
pub created_at: DateTime<Utc>,- Timestamp fields: Automatically set
created_atandupdated_atto current time - UUID fields: Auto-generate UUIDs for primary keys
- Computed fields: Any field that should be set by the application logic
#[derive(Caustics, Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "users")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
/// #[caustics(default)]
pub id: Uuid, // Auto-generated UUID
pub email: String,
pub name: String,
#[sea_orm(column_name = "createdAt")]
/// #[caustics(default)]
pub created_at: DateTime<Utc>, // Auto-generated timestamp
#[sea_orm(column_name = "updatedAt")]
/// #[caustics(default)]
pub updated_at: DateTime<Utc>, // Auto-generated timestamp
}With this configuration, the create method will only require email and name parameters:
let user = client
.user()
.create(
"john@example.com".to_string(),
"John Doe".to_string(),
vec![] // No need to provide id, created_at, or updated_at
)
.exec()
.await?;The #[caustics(field_name="custom_name")] attribute allows you to customize the field names for relations, overriding the default pluralization behavior.
#[sea_orm(has_many = "super::book::Entity", from = "Column::Id", to = "super::book::Column::AuthorId")]
/// #[caustics(field_name="published_works")]
Books,- Automatic Pluralization: HasMany relations are automatically pluralized (e.g.,
Booksbecomesbooks) - Custom Names: Override the default naming with your own field names
- BelongsTo Relations: Remain singular by default (e.g.,
Authorbecomesauthor)
The #[caustics(nullable)] attribute allows you to mark has_one relations as optional, meaning the related record may not exist. This is particularly useful for optional one-to-one relationships.
#[sea_orm(has_one = "super::profile::Entity", from = "Column::Id", to = "super::profile::Column::AuthorId")]
/// #[caustics(field_name="profile", nullable)]
Profile,- Optional Relations: Marks has_one relations as nullable, allowing them to be
None - Type Safety: Returns
Option<Option<Box<Selected>>>where:None= relation not fetchedSome(None)= relation fetched but no record existsSome(Some(record))= relation fetched and record exists
- Database Flexibility: Allows for optional one-to-one relationships in your schema
#[derive(Caustics, Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
// Required has_one relation
#[sea_orm(has_one = "super::api_key::Entity", from = "Column::Id", to = "super::api_key::Column::AuthorId")]
/// #[caustics(field_name="access_key")]
ApiKey,
// Optional has_one relation
#[sea_orm(has_one = "super::profile::Entity", from = "Column::Id", to = "super::profile::Column::AuthorId")]
/// #[caustics(field_name="profile", nullable)]
Profile,
}// Fetch author with optional profile
let author_with_profile = client
.author()
.find_unique(author::id::equals(1))
.with(author::profile::include(|rel| rel))
.exec()
.await?;
// Handle the nullable relation
match author_with_profile.profile {
Some(Some(profile)) => {
// Profile exists and was fetched
println!("Bio: {:?}", profile.bio);
}
Some(None) => {
// Profile was fetched but doesn't exist
println!("No profile found");
}
None => {
// Profile relation was not fetched
println!("Profile not included in query");
}
}// Find a unique record
let user = client
.user()
.find_unique(user::id::equals(1))
.exec()
.await?;
// Find first record
let user = client
.user()
.find_first(vec![
user::name::equals("John"),
user::age::gt(18),
])
.exec()
.await?;
// Find many records
let users = client
.user()
.find_many(vec![user::age::gt(18)])
.exec()
.await?;// Create a new record
let user = client
.user()
.create(
"john@example.com".to_string(),
"John".to_string(),
DateTime::<FixedOffset>::parse_from_rfc3339("2021-01-01T00:00:00Z").unwrap(),
DateTime::<FixedOffset>::parse_from_rfc3339("2021-01-01T00:00:00Z").unwrap(),
vec![user::age::set(Some(25))],
)
.exec()
.await?;// Update a record
let user = client
.user()
.update(
user::id::equals(1),
vec![
user::name::set("John Doe"),
user::age::set(Some(26)),
],
)
.exec()
.await?;
// Partial updates with type safety
let updated_user = client
.user()
.update(
user::id::equals(1),
vec![
user::name::set("New Name".to_string()),
// Only update specific fields, others remain unchanged
]
)
.exec()
.await?;
// Set field to null
let user_with_null_age = client
.user()
.update(
user::id::equals(1),
vec![user::age::set(None)]
)
.exec()
.await?;// Delete a record
client
.user()
.delete(user::id::equals(1))
.exec()
.await?;// Include relations (full model)
let users_with_posts = client
.user()
.find_many(vec![])
.with(user::posts::fetch())
.exec()
.await?;
// Select specific fields
let users_basic = client
.user()
.find_many(vec![])
.select(user::select!(id, name))
.exec()
.await?;// First, define custom structs for type-safe nested data
select_struct! {
UserWithPosts from user::Selected {
id: i32,
name: String,
posts: Vec<PostSummary from post::Selected {
id: i32,
title: String,
created_at: DateTime<FixedOffset>
}>
}
}
// Simple nested relation
let user_with_posts: UserWithPosts = client
.user()
.find_unique(user::id::equals(1))
.select(user::select!(id, name))
.with(user::posts::include(|posts| {
posts.select(post::select!(id, title, created_at))
}))
.exec()
.await?
.unwrap();
// Access nested data with full type safety
for post in user_with_posts.posts {
}// Complex nested structs for deep relations
select_struct! {
StudentWithEnrollments from student::Selected {
first_name: String,
last_name: String,
enrollments: Vec<EnrollmentWithCourse from enrollment::Selected {
id: i32,
enrollment_date: DateTime<FixedOffset>,
status: String,
course: CourseWithTeacher from course::Selected {
name: String,
teacher: TeacherData from teacher::Selected {
first_name: String,
last_name: String
}
}
}>
}
}
// Deep nested query with custom structs
let student: StudentWithEnrollments = client
.student()
.find_unique(student::id::equals(1))
.select(student::select!(first_name, last_name))
.with(student::enrollments::include(|enrollments| {
enrollments
.select(enrollment::select!(id, enrollment_date, status))
.with(enrollment::course::include(|course| {
course
.select(course::select!(name))
.with(course::teacher::include(|teacher| {
teacher.select(teacher::select!(first_name, last_name))
}))
}))
}))
.exec()
.await?
.unwrap();
// Access deeply nested data
for enrollment in student.enrollments {
}// String search
let users = client
.user()
.find_many(vec![
user::name::contains("john"),
user::age::gt(18),
])
.exec()
.await?;
// Collections
let users = client
.user()
.find_many(vec![
user::id::in_vec(vec![1, 2, 3]),
])
.exec()
.await?;
// Null checks
let users = client
.user()
.find_many(vec![
user::age::is_not_null(),
])
.exec()
.await?;Caustics provides both global and entity-specific logical operators for maximum flexibility:
// Global operators (recommended)
use caustics::operator;
let users = client
.user()
.find_many(vec![
operator::and(vec![
user::age::gte(Some(18)),
user::name::starts_with("J"),
]),
operator::or(vec![
user::age::lt(Some(25)),
user::age::gt(Some(55))
]),
])
.exec()
.await?;let users = client
.user()
.find_many(vec![user::age::gt(18)])
.take(10)
.skip(0)
.order_by(user::created_at::order(SortOrder::Desc))
.exec()
.await?;Caustics supports powerful relation ordering capabilities, allowing you to sort by related data:
// Order users by their post count (ascending)
let users_by_post_count = client
.user()
.find_many(vec![])
.with(user::posts::fetch())
.order_by(user::posts::count(SortOrder::Asc))
.exec()
.await?;
// Order users by their post count (descending)
let users_most_active = client
.user()
.find_many(vec![])
.with(user::posts::fetch())
.order_by(user::posts::count(SortOrder::Desc))
.exec()
.await?;
// Complex ordering - by post count, then by name
let users_complex_order = client
.user()
.find_many(vec![])
.with(user::posts::fetch())
.order_by(user::posts::count(SortOrder::Asc))
.order_by(user::name::order(SortOrder::Asc))
.exec()
.await?;// Order by age with nulls first
let users_nulls_first = client
.user()
.find_many(vec![])
.order_by((
user::age::order(SortOrder::Asc),
NullsOrder::First,
))
.exec()
.await?;
// Order by age with nulls last
let users_nulls_last = client
.user()
.find_many(vec![])
.order_by((
user::age::order(SortOrder::Desc),
NullsOrder::Last,
))
.exec()
.await?;// createMany
let inserted = client
.user()
.create_many(vec![
user::Create {
email: "user1@example.com".to_string(),
name: "User 1".to_string(),
created_at: DateTime::<FixedOffset>::parse_from_rfc3339("2021-01-01T00:00:00Z").unwrap(),
updated_at: DateTime::<FixedOffset>::parse_from_rfc3339("2021-01-01T00:00:00Z").unwrap(),
_params: vec![],
},
// ... more users
])
.exec()
.await?;
// updateMany
let affected = client
.user()
.update_many(
vec![user::age::gte(Some(30))],
vec![user::name::set("Updated")],
)
.exec()
.await?;let result = client
._transaction()
.run(|tx| async move {
let user = tx
.user()
.create(
"john@example.com".to_string(),
"John".to_string(),
DateTime::<FixedOffset>::parse_from_rfc3339("2021-01-01T00:00:00Z").unwrap(),
DateTime::<FixedOffset>::parse_from_rfc3339("2021-01-01T00:00:00Z").unwrap(),
vec![],
)
.exec()
.await?;
let post = tx
.post()
.create(
"Hello World".to_string(),
DateTime::<FixedOffset>::parse_from_rfc3339("2021-01-01T00:00:00Z").unwrap(),
DateTime::<FixedOffset>::parse_from_rfc3339("2021-01-01T00:00:00Z").unwrap(),
user::id::equals(user.id),
vec![],
)
.exec()
.await?;
Ok((user, post))
})
.await?;let agg = client
.user()
.aggregate(vec![user::age::is_not_null()])
.count()
.avg(user::select!(age), "age_avg")
.min(user::select!(age), "age_min")
.max(user::select!(age), "age_max")
.exec()
.await?;
// Access aggregate values by alias
let avg = agg.values.get("age_avg");// Increment a user's age by 5
let user = client
.user()
.update(
user::id::equals(1),
vec![user::age::increment(5)]
)
.exec()
.await?;use sea_orm::FromQueryResult;
#[derive(FromQueryResult)]
struct Row { value: i32 }
// Typed query
let rows: Vec<Row> = client
._query_raw::<Row>(raw!("SELECT {} as value", 1))
.exec()
.await?;This project is inspired by the excellent work done on Prisma Client Rust, which provides a type-safe database client for Rust. While Caustics is not derived from Prisma Client Rust, it shares similar design goals of providing an ergonomic, type-safe database interface and is intended to be a drop-in replacement for most of its features.
This project is licensed under the Apache License 2.0 - see the LICENSE file for details.