Introduction
ferriorm is a schema-first ORM for Rust, inspired by Prisma. You define your data model once in a .ferriorm schema file, and ferriorm generates type-safe Rust code – structs, query builders, filters, and a database client – so you never write boilerplate by hand.
Why ferriorm?
Most Rust ORMs require you to manually define structs, derive traits, write migrations, and wire it all together. ferriorm flips that around:
- Schema-first – A single
.ferriormfile is your source of truth for models, relations, and database mapping. - Type-safe code generation – The generated Rust client catches errors at compile time. No runtime surprises.
- Zero boilerplate – No derive macros to remember, no manual struct definitions, no hand-written SQL for basic CRUD.
- Automatic migrations – ferriorm diffs your schema against the database and generates SQL migrations for you.
- Multi-database – PostgreSQL and SQLite are supported out of the box.
How it works
The workflow has three steps: define, generate, use.
1. Define your schema
// schema.ferriorm
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
output = "./src/generated"
}
model User {
id String @id @default(uuid())
email String @unique
name String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("users")
}
2. Generate and migrate
ferriorm migrate dev --name init
This creates a SQL migration, applies it to your database, and generates the Rust client code into src/generated/.
3. Use the generated client
mod generated;
use generated::FerriormClient;
use ferriorm_runtime::prelude::*;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = FerriormClient::connect("postgres://localhost/mydb").await?;
// Create a user
let user = client.user().create(generated::user::data::UserCreateInput {
email: "alice@example.com".into(),
name: Some("Alice".into()),
id: None,
created_at: None,
}).exec().await?;
println!("Created user: {} (id={})", user.email, user.id);
// Find users by filter
let users = client.user()
.find_many(generated::user::filter::UserWhereInput {
email: Some(StringFilter {
contains: Some("@example.com".into()),
..Default::default()
}),
..Default::default()
})
.order_by(generated::user::order::UserOrderByInput::CreatedAt(SortOrder::Desc))
.take(10)
.exec().await?;
println!("Found {} users", users.len());
Ok(())
}
Every query is fully typed. If you rename a field in your schema and regenerate, the Rust compiler tells you exactly where your code needs to change.
What’s next?
Head to Installation to set up ferriorm, then follow the Quick Start tutorial to build your first project.
Installation
ferriorm has two parts: a CLI that parses your schema and generates code, and a runtime library that ships with your application.
1. Install the CLI
cargo install ferriorm-cli
This gives you the ferriorm command. Verify it works:
ferriorm --version
2. Add runtime dependencies
Add the ferriorm runtime to your project. Pick the feature flag for your database:
# PostgreSQL
cargo add ferriorm-runtime --features postgres
# SQLite
cargo add ferriorm-runtime --features sqlite
# Both
cargo add ferriorm-runtime --features postgres,sqlite
3. Add required companion crates
ferriorm’s generated code depends on a few standard crates. Add them to your project.
Important:
sqlxmust be a direct dependency in yourCargo.toml. The generated code uses#[derive(sqlx::FromRow)], which is a proc macro that expands to code referencing::sqlx::absolute paths internally. Transitive dependencies (throughferriorm-runtime) are not sufficient for absolute path resolution in Rust.
cargo add sqlx --features runtime-tokio,tls-rustls,postgres,chrono,uuid
cargo add tokio --features full
cargo add serde --features derive
cargo add serde_json
cargo add chrono --features serde
cargo add uuid --features v4,serde
For SQLite, replace
postgreswithsqlitein the sqlx features.
Complete Cargo.toml example
Here is a full Cargo.toml for a PostgreSQL project:
[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
[dependencies]
ferriorm-runtime = { version = "0.1", features = ["postgres"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "chrono", "uuid"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4", "serde"] }
And for SQLite:
[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
[dependencies]
ferriorm-runtime = { version = "0.1", features = ["sqlite"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "sqlite", "chrono", "uuid"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4", "serde"] }
Next steps
With everything installed, head to the Quick Start guide to create your first ferriorm project.
Quick Start
This tutorial walks you through creating a project with ferriorm from scratch. By the end you will have a schema, a database migration, and working Rust code that creates and queries data.
Prerequisites
- Rust toolchain installed (rustup.rs)
ferriorm-cliinstalled (see Installation)- A running PostgreSQL instance (or SQLite – just adjust the provider)
1. Create a new Rust project
cargo new my-app
cd my-app
Add the required dependencies to your Cargo.toml (see Installation for the full list).
2. Initialize ferriorm
ferriorm init --provider postgresql
This creates a schema.ferriorm file in your project root with a starter template.
3. Define your schema
Open schema.ferriorm and replace its contents with:
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
output = "./src/generated"
}
model User {
id String @id @default(uuid())
email String @unique
name String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("users")
}
This defines a single User model mapped to a users table. The id is an auto-generated UUID, email is required and unique, and name is optional.
4. Set your database URL
Export the connection string for your database:
# PostgreSQL
export DATABASE_URL="postgres://user:password@localhost:5432/my_app"
# SQLite
export DATABASE_URL="sqlite://./dev.db"
Make sure the database exists before continuing. For PostgreSQL:
createdb my_app
5. Create and apply the migration
ferriorm migrate dev --name init
This command does three things:
- Diffs your schema against the current database state
- Creates a SQL migration file in
migrations/0001_init/migration.sql - Applies the migration and generates the Rust client into
src/generated/
You should see output confirming the migration was applied and the client was generated.
6. Write your application
Replace src/main.rs with:
mod generated;
use generated::FerriormClient;
use ferriorm_runtime::prelude::*;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let database_url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL must be set");
// Connect to the database
let client = FerriormClient::connect(&database_url).await?;
// Create a user
let user = client.user().create(generated::user::data::UserCreateInput {
email: "alice@example.com".into(),
name: Some("Alice".into()),
id: None,
created_at: None,
}).exec().await?;
println!("Created user: {} (id={})", user.email, user.id);
// Find the user by unique field
let found = client.user()
.find_unique(generated::user::filter::UserWhereUniqueInput::Email(
"alice@example.com".into(),
))
.exec()
.await?;
if let Some(u) = found {
println!("Found user: {} (name={:?})", u.email, u.name);
}
// List all users
let all_users = client.user()
.find_many(generated::user::filter::UserWhereInput::default())
.exec()
.await?;
println!("Total users: {}", all_users.len());
// Update the user
let updated = client.user()
.update(
generated::user::filter::UserWhereUniqueInput::Email(
"alice@example.com".into(),
),
generated::user::data::UserUpdateInput {
name: Some(Some("Alice Smith".into())),
..Default::default()
},
)
.exec()
.await?;
println!("Updated name to: {:?}", updated.name);
// Delete the user
client.user()
.delete(generated::user::filter::UserWhereUniqueInput::Email(
"alice@example.com".into(),
))
.exec()
.await?;
println!("User deleted.");
// Disconnect
client.disconnect().await;
Ok(())
}
7. Run it
cargo run
You should see output like:
Created user: alice@example.com (id=550e8400-e29b-41d4-a716-446655440000)
Found user: alice@example.com (name=Some("Alice"))
Total users: 1
Updated name to: Some("Alice Smith")
User deleted.
What just happened?
You went from an empty project to a working database application without writing a single SQL query or struct definition by hand. ferriorm generated all the types, filters, and query builders from your schema.
Next steps
- Learn about the Project Structure ferriorm creates
- Explore the full Schema Reference to add relations, enums, and more
- See all available CRUD Operations in the client API
Project Structure
After running ferriorm init and ferriorm migrate dev, your project will have the following layout:
my-project/
├── Cargo.toml
├── schema.ferriorm ← your schema
├── migrations/ ← auto-generated SQL
│ └── 0001_init/
│ ├── migration.sql
│ └── _schema_snapshot.json
└── src/
├── main.rs
└── generated/ ← auto-generated Rust code
├── mod.rs
├── client.rs
├── user.rs
└── enums.rs
schema.ferriorm
The schema file is the single source of truth for your data model. It defines:
- Datasource – which database provider to use and how to connect
- Generator – where to output the generated Rust code
- Models – your tables, fields, relations, and indexes
- Enums – shared enum types used across models
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
output = "./src/generated"
}
model User {
id String @id @default(uuid())
email String @unique
name String?
posts Post[]
@@map("users")
}
model Post {
id String @id @default(uuid())
title String
author User @relation(fields: [authorId], references: [id])
authorId String
@@map("posts")
}
You edit this file, then run ferriorm migrate dev to sync everything.
migrations/
Each migration lives in a numbered directory. ferriorm creates these automatically when you run ferriorm migrate dev --name <name>.
migrations/
├── 0001_init/
│ ├── migration.sql ← the SQL that was applied
│ └── _schema_snapshot.json ← schema state after this migration
└── 0002_add_posts/
├── migration.sql
└── _schema_snapshot.json
- migration.sql – The SQL statements that create or alter tables. You can review and edit these before applying. ferriorm tracks checksums to detect manual edits.
- _schema_snapshot.json – A JSON snapshot of the schema at this point in time. Used by the snapshot migration strategy for offline diffing.
Tip: Commit the entire
migrations/directory to version control. These files are how your teammates and production environments apply the same schema changes.
src/generated/
This directory contains the Rust code that ferriorm generates from your schema. Do not edit these files – they are overwritten every time you run ferriorm generate or ferriorm migrate dev.
| File | Contents |
|---|---|
mod.rs | Re-exports the client and all model/enum modules |
client.rs | The FerriormClient struct with connect(), disconnect(), and accessor methods for each model |
user.rs | User struct, UserCreateInput, UserUpdateInput, UserWhereInput, UserWhereUniqueInput, UserOrderByInput, UserInclude, and the UserActions query builder |
post.rs | Same pattern for the Post model |
enums.rs | Rust enums corresponding to any enum blocks in your schema |
Each model file follows the same structure:
#![allow(unused)]
fn main() {
// In generated/user.rs (simplified)
pub struct User {
pub id: String,
pub email: String,
pub name: Option<String>,
// ...
}
pub mod data {
pub struct UserCreateInput { /* required + optional fields */ }
pub struct UserUpdateInput { /* all fields optional */ }
}
pub mod filter {
pub struct UserWhereInput { /* filterable fields */ }
pub enum UserWhereUniqueInput { /* unique fields */ }
}
pub mod order {
pub enum UserOrderByInput { /* orderable fields */ }
}
}
To use the generated code, add mod generated; at the top of your main.rs (or lib.rs).
Typical workflow
- Edit
schema.ferriorm - Run
ferriorm migrate dev --name describe_the_change - The CLI diffs, generates a migration, applies it, and regenerates
src/generated/ - Your Rust code gets compile-time errors if anything changed – fix and continue
Next steps
- See the full Schema Reference for all available model attributes and field types
- Learn about CRUD Operations available on the generated client
Schema Overview
The .ferriorm schema file is the central configuration for your ferriorm project. It serves as the single source of truth for your database structure, describing your data models, their fields, relationships, and how they map to both your Rust code and the underlying database.
When you run the ferriorm code generator, it reads this schema file and produces type-safe Rust structs, query builders, and migration SQL – all derived from your schema definition.
File Format
A .ferriorm schema file is a plain-text file (by convention named schema.ferriorm) that uses a declarative, block-based syntax. The syntax is intentionally similar to Prisma so that developers familiar with that ecosystem can adopt ferriorm with minimal friction.
Top-Level Blocks
A schema file consists of four kinds of top-level blocks:
1. Datasource
Configures which database you are connecting to. Exactly one datasource block is required.
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
See Datasource for full details.
2. Generator
Controls where and how the generated Rust code is emitted. You may define one or more generator blocks.
generator client {
output = "./src/generated"
}
See Generator for full details.
3. Enum
Defines a set of named constants that can be used as field types in your models.
enum Role {
User
Admin
Moderator
}
See Enums for full details.
4. Model
Describes a database table and its columns, including relationships to other models.
model User {
id String @id @default(uuid())
email String @unique
name String?
role Role @default(User)
posts Post[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([email])
@@map("users")
}
See Models, Fields & Types, Attributes, and Relations for full details.
Comments
Line comments start with // and continue to the end of the line:
// This is a comment
model User {
id String @id // inline comment
}
Whitespace
Whitespace (spaces and tabs) is insignificant except as a separator between tokens. Newlines separate fields and block attributes within model and enum bodies.
Complete Example
Below is a full schema file showing all block types working together:
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
output = "./src/generated"
}
enum Role {
User
Admin
Moderator
}
enum PostStatus {
Draft
Published
Archived
}
model User {
id String @id @default(uuid())
email String @unique
name String?
role Role @default(User)
posts Post[]
profile Profile?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([email])
@@map("users")
}
model Profile {
id String @id @default(uuid())
bio String?
avatar String?
user User @relation(fields: [userId], references: [id])
userId String @unique
@@map("profiles")
}
model Post {
id String @id @default(uuid())
title String
content String?
published Boolean @default(false)
status PostStatus @default(Draft)
author User @relation(fields: [authorId], references: [id])
authorId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([authorId])
@@map("posts")
}
Datasource
The datasource block tells ferriorm which database to connect to and what dialect to use for SQL generation and migrations.
Syntax
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
Rules
- Exactly one
datasourceblock is allowed per schema file. - The block name (e.g.,
db) is an identifier you choose; it does not affect behavior. - Two fields are required:
providerandurl.
provider
Specifies the database engine. The value is a case-insensitive string.
| Value | Database |
|---|---|
"postgresql" or "postgres" | PostgreSQL |
"sqlite" | SQLite |
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
url
The database connection string. It can be specified in two ways:
Environment variable (recommended)
Use the env() function to read the URL from an environment variable at runtime. This keeps credentials out of your source files.
url = env("DATABASE_URL")
String literal
Provide the URL directly as a quoted string. This is convenient for local SQLite databases or quick prototyping, but should be avoided for production configurations that contain credentials.
url = "file:./dev.db"
Connection string formats
PostgreSQL:
postgresql://user:password@localhost:5432/mydb
SQLite:
file:./dev.db
Example
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
At runtime, ferriorm resolves env("DATABASE_URL") by reading the DATABASE_URL environment variable. If the variable is not set, the client will return an error at connection time.
Generator
The generator block controls where ferriorm places the generated Rust code and how the code generation process behaves.
Syntax
generator client {
output = "./src/generated"
}
Fields
output
The path (relative to the schema file) where generated code will be written.
| Field | Type | Default |
|---|---|---|
output | String | "./src/generated" |
If output is omitted, the generator defaults to ./src/generated.
// Uses the default output directory
generator client {
}
// Custom output directory
generator client {
output = "./src/db"
}
Rules
- The block name (e.g.,
client) is an identifier you choose. It does not affect the generated code. - The
outputvalue is a string literal specifying a path relative to the location of the schema file. - Multiple
generatorblocks are allowed, each with a different output path.
Example
A typical project layout after generation:
my-project/
schema.ferriorm
src/
generated/ <-- output directory
mod.rs
user.rs
post.rs
...
main.rs
The generator creates one module file per model, plus a mod.rs that re-exports everything. You then include the generated module in your project with:
#![allow(unused)]
fn main() {
mod generated;
use generated::*;
}
Models
A model block represents a database table and its columns. Each model produces a Rust struct with typed fields, along with query builder methods for CRUD operations.
Syntax
model User {
id String @id @default(uuid())
email String @unique
name String?
posts Post[]
@@map("users")
@@index([email])
}
Naming Conventions
The model name must be written in PascalCase (e.g., User, BlogPost, OrderItem).
By default, ferriorm derives the database table name by converting the PascalCase model name to snake_case and appending “s”:
| Model Name | Default Table Name |
|---|---|
User | users |
BlogPost | blog_posts |
OrderItem | order_items |
To override the default table name, use the @@map block attribute:
model User {
id String @id
@@map("app_users")
}
This maps the User model to the app_users table instead of the default users.
Fields
Each line inside a model body defines a field. A field consists of:
- Name – a camelCase identifier (e.g.,
email,createdAt) - Type – a scalar type, enum name, or model name, with optional
?or[]modifier - Attributes – zero or more field-level attributes (e.g.,
@id,@unique,@default(...))
model Post {
id String @id @default(uuid())
title String
content String?
published Boolean @default(false)
status PostStatus @default(Draft)
author User @relation(fields: [authorId], references: [id])
authorId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
See Fields & Types and Attributes for full details on types and attributes.
Block Attributes
Block attributes appear at the bottom of a model body and are prefixed with @@. They apply to the model (table) as a whole rather than to individual fields.
@@map
Override the default table name.
model User {
id String @id
@@map("users")
}
@@index
Create a database index on one or more fields. This improves query performance for lookups and sorts on the indexed columns.
model Post {
id String @id
authorId String
title String
@@index([authorId])
@@index([authorId, title])
}
Pass name: (or map:) to override the default idx_<table>_<cols> identifier:
@@index([authorId, title], name: "ix_posts_author_title")
@@unique
Create a composite unique constraint across multiple fields. This ensures that the combination of values is unique across all rows. Each @@unique also becomes a struct-style variant on the generated WhereUniqueInput, so it can be used for find_unique, update, delete, and as the upsert conflict target. See Attributes › @@unique for the generated shape.
model Subscription {
id String @id
userId Int
channel String
@@unique([userId, channel])
}
Like @@index, the constraint name can be overridden with name: / map::
@@unique([userId, channel], name: "uq_subs_user_channel")
For single-field uniqueness, prefer the
@uniquefield attribute instead.
@@id
Define a composite primary key. Use this when the primary key spans more than one field.
model PostTag {
postId String
tagId String
@@id([postId, tagId])
}
When using @@id, no individual field needs the @id attribute.
Complete Example
model User {
id String @id @default(uuid())
email String @unique
name String?
role Role @default(User)
posts Post[]
profile Profile?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([email])
@@map("users")
}
This model:
- Uses a UUID string as the primary key, auto-generated on insert
- Enforces a unique constraint on
email - Makes
nameoptional (String?maps toOption<String>in Rust) - Stores a
Roleenum value, defaulting toUser - Has a one-to-many relation to
Post(thepostsfield) and a one-to-one relation toProfile(theprofilefield) - Tracks creation and update timestamps automatically
- Creates a database index on the
emailcolumn - Maps to a table named
users
Fields & Types
Every field in a model has a name and a type. This page covers all the scalar types supported by ferriorm, how they map to Rust and database types, and the type modifiers for optional and list fields.
Scalar Types
ferriorm provides the following built-in scalar types:
| Schema Type | Rust Type | PostgreSQL | SQLite |
|---|---|---|---|
String | String | TEXT | TEXT |
Int | i32 | INTEGER | INTEGER |
BigInt | i64 | BIGINT | INTEGER |
Float | f64 | DOUBLE PRECISION | REAL |
Decimal | rust_decimal::Decimal | DECIMAL | TEXT |
Boolean | bool | BOOLEAN | INTEGER |
DateTime | chrono::DateTime<chrono::Utc> | TIMESTAMPTZ | TEXT |
Json | serde_json::Value | JSONB | TEXT |
Bytes | Vec<u8> | BYTEA | BLOB |
String
Variable-length text. Use for names, emails, URLs, and other textual content.
model User {
name String
}
Int
A 32-bit signed integer.
model Product {
quantity Int
}
BigInt
A 64-bit signed integer. Use when values may exceed the 32-bit range.
model Event {
timestamp BigInt
}
Float
A 64-bit floating-point number (double precision).
model Measurement {
value Float
}
Decimal
An arbitrary-precision decimal number. Useful for monetary values or other cases where floating-point rounding is unacceptable.
model Invoice {
total Decimal
}
Boolean
A true/false value. In SQLite, stored as INTEGER (0 or 1).
model Post {
published Boolean @default(false)
}
DateTime
A timestamp with timezone. In Rust this is chrono::DateTime<chrono::Utc>. In SQLite, stored as an ISO 8601 TEXT string.
model Post {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Json
Arbitrary JSON data. In Rust this is serde_json::Value. In PostgreSQL, stored as JSONB (binary JSON) for efficient querying. In SQLite, stored as TEXT.
model Settings {
data Json
}
Bytes
Raw binary data. In Rust this is Vec<u8>.
model File {
content Bytes
}
Type Modifiers
Optional Fields (?)
Append ? to a type to make the field optional. In the database, the column allows NULL. In Rust, the generated field type is wrapped in Option<T>.
model User {
name String // required -- Rust type: String
bio String? // optional -- Rust type: Option<String>
}
A field without ? is required – it cannot be NULL in the database, and the Rust type does not use Option.
List Fields ([])
Append [] to a type to mark it as a list. This is used exclusively for relation fields to represent the “many” side of a one-to-many or many-to-many relationship. List fields are not stored as database columns; they are virtual fields resolved by querying the related table.
model User {
id String @id
posts Post[] // one-to-many: a user has many posts
}
model Post {
id String @id
author User @relation(fields: [authorId], references: [id])
authorId String
}
See Relations for more details.
Enum Fields
You can use any enum defined in your schema as a field type. The value must match one of the enum’s variants.
enum Role {
User
Admin
Moderator
}
model User {
id String @id
role Role @default(User)
}
See Enums for details on how enums map to Rust and database types.
Model Fields (Relations)
Using another model’s name as a field type creates a relation. These fields are virtual – they do not correspond to a database column directly. Instead, a separate foreign key field stores the actual value.
model Post {
id String @id
author User @relation(fields: [authorId], references: [id])
authorId String // this is the actual database column
}
See Relations for complete documentation.
Native Database Type Overrides (@db.Type)
You can annotate fields with @db.<Type> to influence the generated Rust type and migration SQL. Currently the wired-up hint is:
model Account {
followerCount Int @default(0) @db.BigInt
}
@db.BigInt on an Int field generates i64 (instead of i32) in Rust, uses BigIntFilter, and emits BIGINT in PostgreSQL migrations. SQLite’s INTEGER is already variable-width so no migration change is needed there.
Other hints like @db.VarChar(n), @db.DoublePrecision, and @db.Text parse successfully but are not yet consumed by code generation or migrations. See Attributes › @db.Type for the full list.
Attributes
Attributes annotate fields and models with additional behavior such as primary keys, defaults, uniqueness constraints, and relations. There are two kinds:
- Field attributes (prefixed with
@) apply to a single field. - Block attributes (prefixed with
@@) apply to the model as a whole.
Field Attributes
@id
Marks a field as the primary key of the model.
model User {
id String @id
}
Every model must have either a field with @id or a @@id block attribute for composite primary keys.
Common patterns:
// UUID primary key (generated automatically)
id String @id @default(uuid())
// CUID primary key
id String @id @default(cuid())
// Auto-incrementing integer primary key
id Int @id @default(autoincrement())
@unique
Adds a unique constraint to the field. The database will reject any insert or update that would create a duplicate value in this column.
model User {
id String @id
email String @unique
}
For composite uniqueness across multiple fields, use @@unique instead.
@default(value)
Sets a default value for the field. When a record is created without specifying this field, the default is used.
The argument can be one of the following:
Functions
| Function | Description | Applicable Types |
|---|---|---|
uuid() | Generates a random UUID v4 | String |
cuid() | Generates a CUID | String |
autoincrement() | Auto-incrementing integer | Int, BigInt |
now() | Current date and time | DateTime |
model User {
id String @id @default(uuid())
createdAt DateTime @default(now())
}
model Counter {
id Int @id @default(autoincrement())
}
Literal values
You can use string, integer, float, or boolean literals as defaults.
model Post {
published Boolean @default(false)
views Int @default(0)
category String @default("general")
}
Enum variants
When a field’s type is an enum, the default value is the variant name (without quotes).
enum Role {
User
Admin
}
model User {
id String @id
role Role @default(User)
}
@updatedAt
Automatically sets the field to the current timestamp whenever the record is updated. Applicable to DateTime fields only.
model Post {
id String @id
updatedAt DateTime @updatedAt
}
@map("column_name")
Overrides the database column name for a field. By default, the column name matches the field name. Use @map when the database column follows a different naming convention.
model User {
id String @id
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
}
In this example, the Rust struct field is created_at (derived from createdAt), but the database column is explicitly named created_at.
@relation(fields: [...], references: [...], onDelete: ..., onUpdate: ...)
Defines a relation between two models. This attribute goes on the field that represents the related model.
model Post {
id String @id
author User @relation(fields: [authorId], references: [id])
authorId String
}
Arguments
| Argument | Required | Description |
|---|---|---|
name (positional or name:) | When ambiguous | Disambiguator when two or more relations connect the same pair of models. See Relation names. |
fields | Yes | Array of field names on this model that store the foreign key |
references | Yes | Array of field names on the related model that the foreign key points to |
onDelete | No | Referential action when the referenced record is deleted |
onUpdate | No | Referential action when the referenced record’s key is updated |
Relation names
When two relations connect the same pair of models, you must distinguish them with a name:
model User {
id String @id
authored Post[] @relation("Authored")
reviewed Post[] @relation("Reviewed")
}
model Post {
id String @id
authorId String
reviewerId String
author User @relation("Authored", fields: [authorId], references: [id])
reviewer User @relation("Reviewed", fields: [reviewerId], references: [id])
}
The name appears as the first positional argument or as a name: named argument — both forms are accepted:
author User @relation("Authored", fields: [authorId], references: [id])
author User @relation(name: "Authored", fields: [authorId], references: [id])
The same name must appear on both sides of a relation so the validator and code generator can pair them. Single-relation schemas don’t need a name.
Referential actions
| Action | Description |
|---|---|
Cascade | Delete/update all related records |
Restrict | Prevent deletion/update if related records exist |
NoAction | Similar to Restrict (database-dependent) |
SetNull | Set the foreign key field(s) to NULL |
SetDefault | Set the foreign key field(s) to their default value |
model Post {
id String @id
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
authorId String
}
See Relations for complete examples.
@db.Type(args)
Specifies a native database type override for the column, bypassing the default type mapping.
model Product {
id String @id
name String @db.VarChar(255)
price Float @db.DoublePrecision
}
The type name after @db. and any arguments in parentheses are parsed from the schema. Currently, the fully-wired hint is:
| Hint | Effect |
|---|---|
@db.BigInt on Int | Generates i64 (not i32) in Rust, uses BigIntFilter, migrates as BIGINT on PostgreSQL. SQLite’s INTEGER is already variable-width, so no migration change is needed there. |
Use this when your Int column holds values that can exceed i32 range — follower counts, byte sizes, aggregate sums — without committing the whole schema to BigInt.
model Account {
id String @id
followerCount Int @default(0) @db.BigInt
storageBytes Int @default(0) @db.BigInt
}
Other hints like @db.VarChar(255) and @db.Decimal(p, s) parse but are not yet consumed by code generation or migrations.
Block Attributes
Block attributes appear at the bottom of a model body (after all field definitions) and are prefixed with @@.
@@map("table_name")
Overrides the database table name for the model.
model User {
id String @id
@@map("app_users")
}
Without @@map, the table name is derived automatically from the model name (PascalCase to snake_case + “s”).
@@index([field1, field2, ...], name: "...")
Creates a database index on one or more fields. Indexes speed up queries that filter or sort by the indexed columns.
model Post {
id String @id
authorId String
title String
@@index([authorId])
}
Composite indexes span multiple columns:
model Post {
id String @id
authorId String
createdAt DateTime
@@index([authorId, createdAt])
}
You can define multiple @@index attributes on the same model.
Custom index name
By default, the database index name is idx_<table>_<col1>_<col2>.... To override it (for instance to match an existing database object or to keep names short), pass name: (or map:, the Prisma-style alias):
model Article {
id String @id
slug String
authorId String
@@index([slug, authorId], name: "ix_articles_slug_author")
// or, equivalently:
// @@index([slug, authorId], map: "ix_articles_slug_author")
}
The custom name is used verbatim in the generated CREATE INDEX statement and is round-tripped through migration snapshots.
@@unique([field1, field2, ...], name: "...")
Creates a composite unique constraint across multiple fields. The database will reject any insert or update that would create a duplicate combination of values in these columns.
model Subscription {
id String @id
userId Int
channel String
@@unique([userId, channel])
}
Like @@index, you can override the auto-generated uq_<table>_<cols> name with name: (or map: as alias):
@@unique([userId, channel], name: "uq_subs_user_channel")
Each @@unique also materializes as a struct-style variant on the model’s WhereUniqueInput enum, so you can use it with find_unique, update, delete, and upsert. The variant name is the PascalCase concatenation of the field names:
#![allow(unused)]
fn main() {
use generated::subscription::filter::SubscriptionWhereUniqueInput;
client.subscription().upsert(
SubscriptionWhereUniqueInput::UserIdChannel {
user_id: 42,
channel: "ig".into(),
},
create_input,
update_input,
).exec().await?;
}
At the SQL layer, upsert uses this compound key as the ON CONFLICT (...) target automatically.
For single-field uniqueness, prefer the
@uniquefield attribute.
@@id([field1, field2, ...])
Defines a composite primary key spanning multiple fields. When using @@id, individual fields should not carry the @id attribute.
model PostTag {
postId String
tagId String
@@id([postId, tagId])
}
This creates a table where the combination of postId and tagId forms the primary key.
Enums
An enum block defines a set of named constants. Enums can be used as field types in your models, giving you type-safe, exhaustive variants in both your Rust code and your database.
Syntax
enum Role {
User
Admin
Moderator
}
Naming
- The enum name must be PascalCase (e.g.,
Role,PostStatus). - Variant names must be PascalCase (e.g.,
User,Admin,Draft,Published).
Using Enums in Models
Reference an enum by name as a field type, just like a scalar type:
enum PostStatus {
Draft
Published
Archived
}
model Post {
id String @id @default(uuid())
status PostStatus @default(Draft)
}
The @default attribute accepts a bare variant name (without quotes) to set the default value.
Generated Rust Code
Each enum in the schema generates a Rust enum with the following derives:
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)]
pub enum Role {
User,
Admin,
Moderator,
}
}
The sqlx::Type derive enables the enum to be read from and written to the database directly via sqlx. Serialize and Deserialize (from serde) enable JSON serialization.
Database Representation
PostgreSQL
In PostgreSQL, ferriorm creates a custom enum type using CREATE TYPE:
CREATE TYPE "Role" AS ENUM ('User', 'Admin', 'Moderator');
Columns with this type store the variant as the enum’s internal representation, which is compact and enforces that only valid variants are stored.
SQLite
SQLite does not have native enum types. Enum values are stored as TEXT – the variant name is stored as a plain string (e.g., 'Admin'). Validation happens at the application level through the generated Rust enum.
Multiple Enums
You can define as many enums as you need:
enum Role {
User
Admin
Moderator
}
enum PostStatus {
Draft
Published
Archived
}
model User {
id String @id @default(uuid())
role Role @default(User)
}
model Post {
id String @id @default(uuid())
status PostStatus @default(Draft)
}
Optional Enum Fields
Enum fields can be made optional with ?, just like scalar fields:
model User {
id String @id
role Role?
}
This generates Option<Role> in Rust and allows NULL in the database column.
Relations
Relations connect models to each other and let you query across tables. ferriorm supports one-to-one, one-to-many, and implicit many-to-many relationships (via join models).
Key Concepts
Before looking at examples, there are a few things to understand about how relations work in ferriorm:
-
The side with
@relationstores the foreign key. The@relationattribute specifies which field(s) on the current model hold the foreign key and which field(s) on the related model they reference. -
Relation fields are virtual. A field whose type is another model (e.g.,
author User) does not create a database column. It tells the code generator to produce a relation accessor. The actual foreign key is stored in a separate scalar field (e.g.,authorId String). -
Both sides must be defined. The model that owns the foreign key has the
@relationattribute; the other model has a back-relation field (plain model type or list type) with no@relation.
One-to-Many
The most common relation type. One record in the parent model is related to many records in the child model.
model User {
id String @id @default(uuid())
posts Post[]
}
model Post {
id String @id @default(uuid())
author User @relation(fields: [authorId], references: [id])
authorId String
}
What this means:
| Part | Purpose |
|---|---|
posts Post[] on User | Back-relation (virtual). A user has many posts. Not a database column. |
author User @relation(...) on Post | Relation field (virtual). Represents the related user. Not a database column. |
authorId String on Post | Foreign key (real column). Stores the User.id value in the database. |
The fields: [authorId] argument tells ferriorm that Post.authorId is the foreign key, and references: [id] says it points to User.id.
Generated SQL (PostgreSQL)
CREATE TABLE "users" (
"id" TEXT NOT NULL PRIMARY KEY
);
CREATE TABLE "posts" (
"id" TEXT NOT NULL PRIMARY KEY,
"authorId" TEXT NOT NULL REFERENCES "users"("id")
);
One-to-One
A one-to-one relation is like a one-to-many except the foreign key side has a @unique constraint, ensuring only one related record can exist.
model User {
id String @id @default(uuid())
profile Profile?
}
model Profile {
id String @id @default(uuid())
bio String?
user User @relation(fields: [userId], references: [id])
userId String @unique
}
What makes this one-to-one:
Profile.userIdhas@unique, so each user can have at most one profile.User.profileis typed asProfile?(optional), because a user may or may not have a profile.
Generated SQL (PostgreSQL)
CREATE TABLE "users" (
"id" TEXT NOT NULL PRIMARY KEY
);
CREATE TABLE "profiles" (
"id" TEXT NOT NULL PRIMARY KEY,
"bio" TEXT,
"userId" TEXT NOT NULL UNIQUE REFERENCES "users"("id")
);
Many-to-Many (via Join Model)
For many-to-many relationships, create an explicit join model with two foreign keys:
model Post {
id String @id @default(uuid())
tags PostTag[]
}
model Tag {
id String @id @default(uuid())
name String @unique
posts PostTag[]
}
model PostTag {
post Post @relation(fields: [postId], references: [id])
postId String
tag Tag @relation(fields: [tagId], references: [id])
tagId String
@@id([postId, tagId])
}
The PostTag join model:
- Has two foreign keys (
postIdandtagId) linking toPostandTag - Uses
@@id([postId, tagId])to create a composite primary key - Is a real table in the database
Referential Actions
Referential actions control what happens when a referenced record is deleted or updated. Specify them with the onDelete and onUpdate arguments in @relation.
model Post {
id String @id @default(uuid())
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
authorId String
}
Available Actions
| Action | On Delete | On Update |
|---|---|---|
Cascade | Delete all posts when the user is deleted | Update foreign keys when the user’s id changes |
Restrict | Prevent deleting a user who has posts | Prevent updating a user’s id if posts reference it |
NoAction | Similar to Restrict (exact behavior is database-dependent) | Similar to Restrict |
SetNull | Set authorId to NULL when the user is deleted (field must be optional) | Set authorId to NULL when the user’s id changes |
SetDefault | Set authorId to its default value when the user is deleted | Set authorId to its default value when the user’s id changes |
Example: Cascade Delete
When a user is deleted, all their posts are automatically deleted:
model User {
id String @id @default(uuid())
posts Post[]
}
model Post {
id String @id @default(uuid())
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
authorId String
}
Example: Set Null
When a user is deleted, posts are kept but authorId is set to NULL:
model User {
id String @id @default(uuid())
posts Post[]
}
model Post {
id String @id @default(uuid())
author User? @relation(fields: [authorId], references: [id], onDelete: SetNull)
authorId String?
}
Note that both author and authorId must be optional (?) for SetNull to work.
Self-Relations
A model can relate to itself. For example, a tree structure where each category can have a parent:
model Category {
id String @id @default(uuid())
name String
parent Category? @relation(fields: [parentId], references: [id])
parentId String?
children Category[]
}
A simple self-reference like this — one forward FK + one back-reference list — does not need a relation name. The validator can pair the two fields unambiguously.
Multiple Relations Between The Same Models
When two relations connect the same pair of models, both sides must carry a relation name so the validator and code generator can pair forward and back references correctly.
model User {
id String @id @default(uuid())
authored Post[] @relation("Authored")
reviewed Post[] @relation("Reviewed")
}
model Post {
id String @id @default(uuid())
title String
authorId String
reviewerId String
author User @relation("Authored", fields: [authorId], references: [id])
reviewer User @relation("Reviewed", fields: [reviewerId], references: [id])
}
The first positional argument to @relation is the relation name. The same name must appear on both sides — Authored pairs User.authored with Post.author; Reviewed pairs User.reviewed with Post.reviewer.
Both forms below are equivalent:
author User @relation("Authored", fields: [authorId], references: [id])
author User @relation(name: "Authored", fields: [authorId], references: [id])
If you forget to add a name where one is required, the validator reports the error:
Multiple relations from
PosttoUserrequire disambiguation. Add@relation("<Name>", ...)to each related field on both sides.
The same rule applies to multiple self-references: a model with two FKs to itself (e.g. an org chart with both a manager and a mentor pointing at User) needs distinct relation names.
Rules and Constraints
- Every
@relationmust specifyfieldsandreferences. - The
fieldsarray lists foreign key fields on the current model. - The
referencesarray lists the corresponding key fields on the related model. - The number of entries in
fieldsandreferencesmust match. - Relation fields (model-typed fields) are not stored in the database. Only the scalar foreign key fields become columns.
- The back-relation side (the side without
@relation) uses either a list type (Post[]) for one-to-many or an optional type (Profile?) for one-to-one.
Connecting to a Database
Ferriorm connects to your database through a generated FerriormClient that wraps a connection pool. The client auto-detects your database type from the connection URL.
Basic Connection
#![allow(unused)]
fn main() {
use generated::FerriormClient;
let client = FerriormClient::connect("postgres://user:pass@localhost/mydb").await?;
// Use the client...
client.disconnect().await;
}
The URL scheme determines which driver is used:
| URL pattern | Database |
|---|---|
postgres://... or postgresql://... | PostgreSQL |
sqlite:..., file:..., or *.db | SQLite |
Connection with Pool Configuration
For production deployments, configure the underlying connection pool using PoolConfig:
#![allow(unused)]
fn main() {
use generated::FerriormClient;
use ferriorm_runtime::client::PoolConfig;
use std::time::Duration;
let config = PoolConfig {
max_connections: Some(20),
min_connections: Some(5),
idle_timeout: Some(Duration::from_secs(300)),
max_lifetime: Some(Duration::from_secs(1800)),
acquire_timeout: Some(Duration::from_secs(5)),
..Default::default()
};
let client = FerriormClient::connect_with_config(
"postgres://user:pass@localhost/mydb",
&config,
).await?;
}
PoolConfig Options
| Field | Type | Description |
|---|---|---|
max_connections | Option<u32> | Maximum number of connections in the pool. |
min_connections | Option<u32> | Minimum idle connections to keep open. |
idle_timeout | Option<Duration> | How long a connection can sit idle before being closed. |
max_lifetime | Option<Duration> | Maximum lifetime of a connection before it is recycled. |
acquire_timeout | Option<Duration> | Maximum time to wait when acquiring a connection. |
All fields default to None, which uses the sqlx defaults.
Disconnecting
Always disconnect before your application exits to close the pool gracefully:
#![allow(unused)]
fn main() {
client.disconnect().await;
}
disconnect() consumes the client, so it cannot be used after disconnection.
Accessing the Raw Pool
If you need the underlying sqlx pool for advanced operations, use the accessor methods:
#![allow(unused)]
fn main() {
// PostgreSQL
let pool: &sqlx::PgPool = client.pg_pool()?;
// SQLite
let pool: &sqlx::SqlitePool = client.sqlite_pool()?;
}
These return an error if the client is connected to a different database type than requested. See Raw SQL for usage examples.
Feature Flags
The database backends are controlled by Cargo feature flags on ferriorm-runtime:
postgres– enables PostgreSQL supportsqlite– enables SQLite support
Both can be enabled simultaneously. If neither is enabled, connect() returns an error.
CRUD Operations
All CRUD operations follow the same pattern: call a method on the model accessor, optionally chain modifiers, then call .exec().await? to execute.
#![allow(unused)]
fn main() {
let user = client.user().create(input).exec().await?;
}
The examples below assume a User model with fields id, email, name, role, createdAt, and updatedAt.
Create
Insert a single record. Returns the created record with all server-generated fields populated.
#![allow(unused)]
fn main() {
use generated::user::data::UserCreateInput;
use generated::Role;
let user = client
.user()
.create(UserCreateInput {
email: "alice@example.com".into(),
name: Some("Alice".into()),
role: Some(Role::Admin),
id: None, // auto-generated (uuid)
created_at: None, // auto-generated (now)
})
.exec()
.await?;
println!("Created: {} (id={})", user.email, user.id);
}
Required vs optional fields:
| Field kind | CreateInput type | Notes |
|---|---|---|
| Required, no default | T | Must be provided |
Optional (? in schema) | Option<T> | None inserts NULL |
Has @default(...) | Option<T> | None uses the default |
@id @default(uuid()) | Option<String> | None auto-generates a UUID |
@default(now()) | Option<DateTime> | None uses current timestamp |
Find Unique
Fetch a single record by a unique field. Returns Option<Model>.
#![allow(unused)]
fn main() {
use generated::user::filter::UserWhereUniqueInput;
// By ID
let user = client
.user()
.find_unique(UserWhereUniqueInput::Id("some-uuid".into()))
.exec()
.await?;
// By unique field
let user = client
.user()
.find_unique(UserWhereUniqueInput::Email("alice@example.com".into()))
.exec()
.await?;
if let Some(u) = user {
println!("Found: {}", u.email);
}
}
UserWhereUniqueInput is an enum with one variant per @unique or @id field, plus a struct-style variant for every @@unique([...]) on the model.
model Subscription {
id String @id
userId Int
channel String
@@unique([userId, channel])
}
#![allow(unused)]
fn main() {
use generated::subscription::filter::SubscriptionWhereUniqueInput;
client.subscription()
.find_unique(SubscriptionWhereUniqueInput::UserIdChannel {
user_id: 42,
channel: "ig".into(),
})
.exec()
.await?;
}
The same compound variant is accepted by update, delete, and upsert.
Find First
Fetch the first matching record, with optional ordering. Returns Option<Model>.
#![allow(unused)]
fn main() {
use generated::user::filter::UserWhereInput;
use generated::user::order::UserOrderByInput;
use ferriorm_runtime::prelude::*;
let newest = client
.user()
.find_first(UserWhereInput {
email: Some(StringFilter {
contains: Some("@example.com".into()),
..Default::default()
}),
..Default::default()
})
.order_by(UserOrderByInput::CreatedAt(SortOrder::Desc))
.exec()
.await?;
}
Find Many
Fetch multiple records with filtering, ordering, and pagination.
#![allow(unused)]
fn main() {
let users = client
.user()
.find_many(UserWhereInput::default()) // no filter = all records
.order_by(UserOrderByInput::CreatedAt(SortOrder::Desc))
.skip(0)
.take(10)
.exec()
.await?;
}
Returns Vec<Model>. An empty Vec when no records match (never errors for zero results).
Update
Update a single record by unique field. Returns the updated record.
#![allow(unused)]
fn main() {
use generated::user::data::UserUpdateInput;
use generated::user::filter::UserWhereUniqueInput;
use ferriorm_runtime::prelude::*;
let updated = client
.user()
.update(
UserWhereUniqueInput::Id("some-uuid".into()),
UserUpdateInput {
name: Some(SetValue::Set(Some("Alice Smith".into()))),
role: Some(SetValue::Set(Role::Moderator)),
..Default::default()
},
)
.exec()
.await?;
}
SetValue wrapper: Update fields use Option<SetValue<T>>:
None– field is not modifiedSome(SetValue::Set(value))– set the field tovalue
For nullable fields, the inner type is Option<T>, so setting a field to NULL looks like Some(SetValue::Set(None)).
Fields with @updatedAt are automatically set to the current timestamp on every update.
Delete
Delete a single record by unique field. Returns the deleted record.
#![allow(unused)]
fn main() {
let deleted = client
.user()
.delete(UserWhereUniqueInput::Id("some-uuid".into()))
.exec()
.await?;
println!("Deleted: {}", deleted.email);
}
Upsert
Insert a record or update it if the conflict target already exists. Uses INSERT ... ON CONFLICT DO UPDATE under the hood — works on both PostgreSQL and SQLite.
The conflict target is derived at runtime from the WhereUniqueInput variant you pass: a single-field variant (::Id(..), ::Email(..)) targets that column, a compound variant (::UserIdChannel { .. }) targets all its columns. This lets a single upsert cover every @unique and @@unique on the model.
#![allow(unused)]
fn main() {
// Upsert by a single unique field:
let user = client.user().upsert(
user::filter::UserWhereUniqueInput::Email("alice@example.com".into()),
user::data::UserCreateInput {
email: "alice@example.com".into(),
name: Some("Alice".into()),
role: None,
id: None,
created_at: None,
},
user::data::UserUpdateInput {
name: Some(SetValue::Set(Some("Alice Updated".into()))),
..Default::default()
},
).exec().await?;
// Upsert by a compound @@unique([userId, channel]):
client.subscription().upsert(
subscription::filter::SubscriptionWhereUniqueInput::UserIdChannel {
user_id: 42,
channel: "ig".into(),
},
create_input,
update_input,
).exec().await?;
}
If no update fields are provided (UpdateInput::default()), the existing row is returned unchanged.
Create with On-Conflict Ignore
Dedup-on-write: insert the record, or silently skip it if a unique constraint already holds. Returns Ok(None) when the insert was suppressed, Ok(Some(row)) when it succeeded.
#![allow(unused)]
fn main() {
let maybe_event = client
.webhook_event()
.create(WebhookEventCreateInput {
external_id: "evt_abc123".into(),
payload: Some(body),
id: None,
created_at: None,
})
.on_conflict_ignore()
.exec()
.await?;
match maybe_event {
Some(row) => println!("stored new event {}", row.id),
None => println!("duplicate event, ignored"),
}
}
Under the hood: PostgreSQL emits ON CONFLICT DO NOTHING RETURNING *, SQLite emits INSERT OR IGNORE ... RETURNING *. No conflict target is specified, so any unique violation (primary key, single @unique, or @@unique) triggers the ignore path.
Update First (compare-and-swap)
update only accepts a WhereUniqueInput, which means the row is located solely by its unique key. For state-machine transitions (status = 'pending' → 'approved') you usually want extra predicates so the update is race-safe:
UPDATE submissions SET status = 'approved' WHERE id = ? AND status = 'pending' RETURNING *;
Use update_first for that. It takes a full WhereInput (same type as find_first/update_many) and returns Result<Option<Model>> — None if no row matched.
#![allow(unused)]
fn main() {
let approved = client
.submission()
.update_first(
submission::filter::SubmissionWhereInput {
id: Some(StringFilter { equals: Some(id.clone()), ..Default::default() }),
status: Some(EnumFilter { equals: Some(Status::Pending), ..Default::default() }),
..Default::default()
},
submission::data::SubmissionUpdateInput {
status: Some(SetValue::Set(Status::Approved)),
..Default::default()
},
)
.exec()
.await?;
if approved.is_none() {
// Another concurrent worker already moved it out of `pending`.
}
}
Unlike update_many, update_first returns the updated row. Narrow the filter to one row (typically by including the primary key) — if multiple rows match, all of them are updated but only the first is returned.
Create Many
Insert multiple records in a batch. Returns the number of records created.
#![allow(unused)]
fn main() {
let count = client
.user()
.create_many(vec![
UserCreateInput {
email: "bob@example.com".into(),
name: Some("Bob".into()),
role: None,
id: None,
created_at: None,
},
UserCreateInput {
email: "carol@example.com".into(),
name: Some("Carol".into()),
role: None,
id: None,
created_at: None,
},
])
.exec()
.await?;
println!("Created {count} users");
}
Update Many
Update all records matching a filter. Returns the number of rows affected.
#![allow(unused)]
fn main() {
let count = client
.user()
.update_many(
UserWhereInput {
role: Some(EnumFilter {
equals: Some(Role::User),
..Default::default()
}),
..Default::default()
},
UserUpdateInput {
role: Some(SetValue::Set(Role::Moderator)),
..Default::default()
},
)
.exec()
.await?;
println!("Updated {count} users");
}
Delete Many
Delete all records matching a filter. Returns the number of rows deleted.
#![allow(unused)]
fn main() {
let count = client
.user()
.delete_many(UserWhereInput {
role: Some(EnumFilter {
equals: Some(Role::Admin),
..Default::default()
}),
..Default::default()
})
.exec()
.await?;
println!("Deleted {count} users");
}
Pass UserWhereInput::default() to delete all records (use with caution).
Count
Count records matching a filter. Returns i64.
#![allow(unused)]
fn main() {
let total = client
.user()
.count(UserWhereInput::default())
.exec()
.await?;
println!("Total users: {total}");
}
With a filter:
#![allow(unused)]
fn main() {
let admin_count = client
.user()
.count(UserWhereInput {
role: Some(EnumFilter {
equals: Some(Role::Admin),
..Default::default()
}),
..Default::default()
})
.exec()
.await?;
}
Filtering
Filters are type-safe structs that map to SQL WHERE clauses. Each scalar type in your schema has a corresponding filter type. Filters are used with find_many, find_first, update_many, delete_many, and count.
WhereInput Structure
Every model generates a WhereInput struct. Each field is Option<FilterType> – set it to apply that condition, leave it None to skip.
#![allow(unused)]
fn main() {
use generated::user::filter::UserWhereInput;
use ferriorm_runtime::filter::StringFilter;
let filter = UserWhereInput {
email: Some(StringFilter {
contains: Some("@example.com".into()),
..Default::default()
}),
..Default::default()
};
}
Multiple fields set on the same WhereInput are combined with AND.
StringFilter
For String fields.
#![allow(unused)]
fn main() {
use ferriorm_runtime::filter::StringFilter;
// Exact match
StringFilter { equals: Some("alice@example.com".into()), ..Default::default() }
// Not equal
StringFilter { not: Some("bob@example.com".into()), ..Default::default() }
// Contains substring (SQL LIKE '%value%')
StringFilter { contains: Some("example".into()), ..Default::default() }
// Starts with (SQL LIKE 'value%')
StringFilter { starts_with: Some("alice".into()), ..Default::default() }
// Ends with (SQL LIKE '%value')
StringFilter { ends_with: Some("@example.com".into()), ..Default::default() }
// In a list of values
StringFilter { r#in: Some(vec!["a@ex.com".into(), "b@ex.com".into()]), ..Default::default() }
// Not in a list
StringFilter { not_in: Some(vec!["spam@ex.com".into()]), ..Default::default() }
}
IN / NOT IN semantics
r#in and not_in map to SQL IN (...) / NOT IN (...). They share these rules across every filter that supports them (StringFilter, IntFilter, BigIntFilter, FloatFilter, DateTimeFilter, their nullable variants, and EnumFilter):
- Empty
r#in(Some(vec![])) matches no rows — codegen emitsWHERE 1 = 0so the query stays portable across SQLite and Postgres (Postgres rejects bareIN ()). - Empty
not_in(Some(vec![])) is dropped —NOT INover an empty set is vacuously true, so the predicate isn’t applied. Noneleaves the operator unset (no fragment emitted), same as every other filter field.- NULL columns: per standard SQL,
INandNOT INdo not match NULL values. To include NULLs, compose withequals: Some(None)on a nullable filter (or wrap the IN inside anor:).
Case-Insensitive Mode
Set mode: Some(QueryMode::Insensitive) for case-insensitive string matching:
#![allow(unused)]
fn main() {
use ferriorm_runtime::filter::{StringFilter, QueryMode};
StringFilter {
contains: Some("alice".into()),
mode: Some(QueryMode::Insensitive),
..Default::default()
}
}
Nullable filters (IS NULL / IS NOT NULL)
Every optional (?) scalar field gets a matching nullable filter: NullableStringFilter, NullableIntFilter, NullableBigIntFilter, NullableFloatFilter, NullableBoolFilter, NullableDateTimeFilter.
They behave like the non-nullable siblings except equals and not are Option<Option<T>>:
None— the condition is not applied.Some(None)— matches NULL via SQLIS NULL(orIS NOT NULLfornot).Some(Some(v))— ordinary equality/inequality againstv.
#![allow(unused)]
fn main() {
use ferriorm_runtime::filter::{NullableStringFilter, NullableDateTimeFilter};
// Users with no name set (name IS NULL)
NullableStringFilter { equals: Some(None), ..Default::default() }
// A specific name
NullableStringFilter { equals: Some(Some("Alice".into())), ..Default::default() }
// Profiles that have not been disconnected yet (disconnected_at IS NULL)
NullableDateTimeFilter { equals: Some(None), ..Default::default() }
// Anything that HAS been disconnected
NullableDateTimeFilter { not: Some(None), ..Default::default() }
}
Ordering operators (gt, gte, lt, lte) and contains/starts_with/ends_with keep their non-nullable Option<T> shape — they intentionally can’t express IS NULL, and NULL never matches LIKE or comparison operators anyway.
IntFilter
For Int (i32) fields.
#![allow(unused)]
fn main() {
use ferriorm_runtime::filter::IntFilter;
// Exact match
IntFilter { equals: Some(42), ..Default::default() }
// Not equal
IntFilter { not: Some(0), ..Default::default() }
// Greater than
IntFilter { gt: Some(18), ..Default::default() }
// Greater than or equal
IntFilter { gte: Some(18), ..Default::default() }
// Less than
IntFilter { lt: Some(100), ..Default::default() }
// Less than or equal
IntFilter { lte: Some(100), ..Default::default() }
// In a list
IntFilter { r#in: Some(vec![1, 2, 3]), ..Default::default() }
// Not in a list
IntFilter { not_in: Some(vec![0, -1]), ..Default::default() }
}
Range Example
Combine operators to create ranges:
#![allow(unused)]
fn main() {
IntFilter {
gte: Some(18),
lt: Some(65),
..Default::default()
}
}
BigIntFilter
For BigInt (i64) fields. Same operators as IntFilter.
FloatFilter
For Float (f64) fields. Supports equals, not, gt, gte, lt, lte, in, not_in.
BoolFilter
For Boolean fields.
#![allow(unused)]
fn main() {
use ferriorm_runtime::filter::BoolFilter;
// Match published posts
BoolFilter { equals: Some(true), ..Default::default() }
// Match unpublished posts
BoolFilter { not: Some(true), ..Default::default() }
}
DateTimeFilter
For DateTime fields. Uses chrono::DateTime<chrono::Utc>.
#![allow(unused)]
fn main() {
use ferriorm_runtime::filter::DateTimeFilter;
use chrono::{Utc, Duration};
// Created in the last 24 hours
DateTimeFilter {
gt: Some(Utc::now() - Duration::hours(24)),
..Default::default()
}
// Created before a specific date
DateTimeFilter {
lt: Some("2025-01-01T00:00:00Z".parse().unwrap()),
..Default::default()
}
// Exact match
DateTimeFilter { equals: Some(some_datetime), ..Default::default() }
// In a list
DateTimeFilter { r#in: Some(vec![date1, date2]), ..Default::default() }
// Not in a list
DateTimeFilter { not_in: Some(vec![date_to_skip]), ..Default::default() }
}
Supported operators: equals, not, gt, gte, lt, lte, in, not_in.
EnumFilter
For enum fields. Generic over the enum type.
#![allow(unused)]
fn main() {
use ferriorm_runtime::filter::EnumFilter;
use generated::Role;
// Exact match
EnumFilter { equals: Some(Role::Admin), ..Default::default() }
// Not equal
EnumFilter { not: Some(Role::User), ..Default::default() }
// In a list
EnumFilter {
r#in: Some(vec![Role::Admin, Role::Moderator]),
..Default::default()
}
// Not in a list
EnumFilter {
not_in: Some(vec![Role::User]),
..Default::default()
}
}
AND / OR / NOT Combinators
The WhereInput struct includes and, or, and not fields for composing complex conditions.
AND
All conditions must match. This is the default when setting multiple fields, but and lets you express the same field with different conditions:
#![allow(unused)]
fn main() {
UserWhereInput {
and: Some(vec![
UserWhereInput {
email: Some(StringFilter {
contains: Some("example".into()),
..Default::default()
}),
..Default::default()
},
UserWhereInput {
name: Some(NullableStringFilter {
not: Some(None), // name IS NOT NULL
..Default::default()
}),
..Default::default()
},
]),
..Default::default()
}
}
OR
At least one condition must match:
#![allow(unused)]
fn main() {
UserWhereInput {
or: Some(vec![
UserWhereInput {
role: Some(EnumFilter { equals: Some(Role::Admin), ..Default::default() }),
..Default::default()
},
UserWhereInput {
role: Some(EnumFilter { equals: Some(Role::Moderator), ..Default::default() }),
..Default::default()
},
]),
..Default::default()
}
}
NOT
Negate a condition:
#![allow(unused)]
fn main() {
UserWhereInput {
not: Some(Box::new(UserWhereInput {
email: Some(StringFilter {
ends_with: Some("@spam.com".into()),
..Default::default()
}),
..Default::default()
})),
..Default::default()
}
}
Complete Example
Find active admin users created in the last week whose email is from a specific domain:
#![allow(unused)]
fn main() {
use chrono::{Utc, Duration};
let users = client
.user()
.find_many(UserWhereInput {
role: Some(EnumFilter {
equals: Some(Role::Admin),
..Default::default()
}),
email: Some(StringFilter {
ends_with: Some("@company.com".into()),
..Default::default()
}),
created_at: Some(DateTimeFilter {
gte: Some(Utc::now() - Duration::weeks(1)),
..Default::default()
}),
..Default::default()
})
.exec()
.await?;
}
Ordering & Pagination
Ordering
Use .order_by() to sort results. Each model generates a ModelOrderByInput enum with a variant for every field.
#![allow(unused)]
fn main() {
use generated::user::order::UserOrderByInput;
use ferriorm_runtime::prelude::SortOrder;
let users = client
.user()
.find_many(UserWhereInput::default())
.order_by(UserOrderByInput::CreatedAt(SortOrder::Desc))
.exec()
.await?;
}
SortOrder
| Variant | SQL |
|---|---|
SortOrder::Asc | ASC |
SortOrder::Desc | DESC |
Multiple Order Clauses
Chain multiple .order_by() calls. They are applied in order (first call is the primary sort):
#![allow(unused)]
fn main() {
let users = client
.user()
.find_many(UserWhereInput::default())
.order_by(UserOrderByInput::Role(SortOrder::Asc))
.order_by(UserOrderByInput::Email(SortOrder::Asc))
.exec()
.await?;
// SQL: ORDER BY "role" ASC, "email" ASC
}
Available Fields
Every column in the model has a corresponding variant:
#![allow(unused)]
fn main() {
pub enum UserOrderByInput {
Id(SortOrder),
Email(SortOrder),
Name(SortOrder),
Role(SortOrder),
CreatedAt(SortOrder),
UpdatedAt(SortOrder),
}
}
Pagination
Use .skip(n) and .take(n) for offset-based pagination. Both accept i64 values.
| Method | SQL | Description |
|---|---|---|
.take(n) | LIMIT n | Maximum number of records to return |
.skip(n) | OFFSET n | Number of records to skip |
Basic Pagination
#![allow(unused)]
fn main() {
// Page 1: first 10 records
let page1 = client
.user()
.find_many(UserWhereInput::default())
.order_by(UserOrderByInput::CreatedAt(SortOrder::Desc))
.take(10)
.exec()
.await?;
// Page 2: next 10 records
let page2 = client
.user()
.find_many(UserWhereInput::default())
.order_by(UserOrderByInput::CreatedAt(SortOrder::Desc))
.skip(10)
.take(10)
.exec()
.await?;
}
Paginated List Helper
A common pattern for API endpoints:
#![allow(unused)]
fn main() {
async fn list_users(
client: &FerriormClient,
page: i64,
page_size: i64,
) -> Result<(Vec<User>, i64), FerriormError> {
let filter = UserWhereInput::default();
let total = client
.user()
.count(filter.clone())
.exec()
.await?;
let users = client
.user()
.find_many(filter)
.order_by(UserOrderByInput::CreatedAt(SortOrder::Desc))
.skip((page - 1) * page_size)
.take(page_size)
.exec()
.await?;
Ok((users, total))
}
}
Ordering with Select
Ordering works with .select() queries too:
#![allow(unused)]
fn main() {
let partials = client
.user()
.find_many(UserWhereInput::default())
.select(UserSelect { id: true, email: true, ..Default::default() })
.order_by(UserOrderByInput::Email(SortOrder::Asc))
.skip(0)
.take(50)
.exec()
.await?;
}
Note: Always include an
order_byclause when paginating. Without it, the database does not guarantee a stable ordering, and pages may contain duplicate or missing records.
Relations (Include)
Ferriorm can load related records alongside the primary query using .include(). Related records are fetched in a single batched query to avoid the N+1 problem.
Basic Include
Given this schema:
model User {
id String @id @default(uuid())
email String @unique
posts Post[]
@@map("users")
}
model Post {
id String @id @default(uuid())
title String
author User @relation(fields: [authorId], references: [id])
authorId String
@@map("posts")
}
Load users with their posts:
#![allow(unused)]
fn main() {
use generated::user::UserInclude;
let users = client
.user()
.find_many(UserWhereInput::default())
.include(UserInclude {
posts: true,
..Default::default()
})
.exec()
.await?;
for u in &users {
println!(
"{} has {} posts",
u.data.email,
u.posts.as_ref().map(|p| p.len()).unwrap_or(0)
);
}
}
UserInclude Struct
Each model generates an include struct with a bool field for every relation:
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Default)]
pub struct UserInclude {
pub posts: bool,
// pub profile: bool, // if the model has a profile relation
}
}
Set a field to true to load that relation. Fields left false (or defaulted) are not loaded.
UserWithRelations Struct
When using .include(), the return type changes from Vec<Model> to Vec<ModelWithRelations>:
#![allow(unused)]
fn main() {
pub struct UserWithRelations {
pub data: User, // the base record
pub posts: Option<Vec<Post>>, // None if not included
}
}
datacontains all scalar fields of the parent record.- Each relation field is
Option<Vec<RelatedModel>>for one-to-many relations, andOption<RelatedModel>for one-to-one. - The value is
Nonewhen the relation was not included, andSome(...)when it was (even if the list is empty).
Include with Find Unique
.include() also works with find_unique:
#![allow(unused)]
fn main() {
let user = client
.user()
.find_unique(UserWhereUniqueInput::Email("alice@example.com".into()))
.include(UserInclude {
posts: true,
..Default::default()
})
.exec()
.await?;
if let Some(u) = user {
println!("Found {} with {} posts", u.data.email, u.posts.unwrap().len());
}
}
The return type is Option<UserWithRelations>.
How Batched Loading Works
Ferriorm avoids the N+1 query problem by loading relations in two steps:
- Primary query: Fetch all parent records with a single
SELECT. - Relation query: Collect all parent IDs, then fetch related records with a single
SELECT ... WHERE foreign_key IN (id1, id2, ...)query. - Assembly: Match related records to their parents using a
HashMap.
This means loading 100 users with their posts executes exactly 2 SQL queries, regardless of how many users or posts exist.
Example: Load Posts with Author
Relations work from either side:
#![allow(unused)]
fn main() {
use generated::post::PostInclude;
let posts = client
.post()
.find_many(PostWhereInput::default())
.include(PostInclude {
author: true,
..Default::default()
})
.exec()
.await?;
for p in &posts {
if let Some(author) = &p.author {
println!("{} by {}", p.data.title, author.email);
}
}
}
Note: Nested includes (e.g., loading a user’s posts and each post’s comments) are not yet supported. Use separate queries or raw SQL for deeply nested data.
Select (Partial Loading)
Use .select() to fetch only specific columns from a table. This reduces data transfer and can improve performance for tables with large or many columns.
Basic Select
#![allow(unused)]
fn main() {
use generated::user::UserSelect;
let users = client
.user()
.find_many(UserWhereInput::default())
.select(UserSelect {
id: true,
email: true,
..Default::default()
})
.exec()
.await?;
for u in &users {
println!("id={:?}, email={:?}", u.id, u.email);
}
}
UserSelect Struct
Each model generates a select struct with a bool field per column:
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Default)]
pub struct UserSelect {
pub id: bool,
pub email: bool,
pub name: bool,
pub role: bool,
pub created_at: bool,
pub updated_at: bool,
}
}
Set fields to true to include them in the query. If no fields are set to true, all columns are selected (equivalent to SELECT *).
UserPartial Return Type
When using .select(), the return type changes from Model to ModelPartial, where every field is Option<T>:
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct UserPartial {
pub id: Option<String>,
pub email: Option<String>,
pub name: Option<String>,
pub role: Option<Role>,
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
pub updated_at: Option<chrono::DateTime<chrono::Utc>>,
}
}
Fields that were not selected will be None. Fields that were selected will be Some(value) (or None only if the database value is NULL).
Select with Find Unique
#![allow(unused)]
fn main() {
let user = client
.user()
.find_unique(UserWhereUniqueInput::Email("alice@example.com".into()))
.select(UserSelect {
id: true,
email: true,
role: true,
..Default::default()
})
.exec()
.await?;
if let Some(u) = user {
println!("Role: {:?}", u.role.unwrap());
}
}
Returns Option<UserPartial>.
Select with Find First
#![allow(unused)]
fn main() {
let user = client
.user()
.find_first(UserWhereInput::default())
.select(UserSelect {
email: true,
..Default::default()
})
.order_by(UserOrderByInput::CreatedAt(SortOrder::Desc))
.exec()
.await?;
}
Returns Option<UserPartial>.
Select with Pagination
.select() queries support the same .order_by(), .skip(), and .take() modifiers:
#![allow(unused)]
fn main() {
let page = client
.user()
.find_many(UserWhereInput::default())
.select(UserSelect {
id: true,
email: true,
created_at: true,
..Default::default()
})
.order_by(UserOrderByInput::CreatedAt(SortOrder::Desc))
.skip(0)
.take(20)
.exec()
.await?;
}
When to Use Select
- Large text/blob columns – skip them when listing records.
- API responses – return only the fields your endpoint needs.
- Performance – fewer columns means less data over the wire and less deserialization work.
Note:
.select()and.include()cannot be combined on the same query. Use.include()when you need related records, or run separate queries.
Aggregates
Ferriorm supports aggregate operations (min, max, avg, sum) on model fields. The operations are type-checked at compile time based on the field’s data type.
Basic Usage
#![allow(unused)]
fn main() {
use generated::user::UserAggregateField;
use generated::user::filter::UserWhereInput;
let result = client
.user()
.aggregate(UserWhereInput::default())
.min(UserAggregateField::CreatedAt)
.max(UserAggregateField::CreatedAt)
.exec()
.await?;
println!("Earliest user: {:?}", result.min_created_at);
println!("Latest user: {:?}", result.max_created_at);
}
API
Start an aggregate query with .aggregate(where_input), chain operations, then call .exec():
#![allow(unused)]
fn main() {
client
.model()
.aggregate(WhereInput::default())
.min(field)
.max(field)
.avg(field) // numeric fields only
.sum(field) // numeric fields only
.exec()
.await?
}
At least one operation must be specified or .exec() returns an error.
UserAggregateField
Each model generates an enum listing the fields that support aggregation:
#![allow(unused)]
fn main() {
pub enum UserAggregateField {
CreatedAt,
UpdatedAt,
}
}
Which operations are allowed?
| Field type | min | max | avg | sum |
|---|---|---|---|---|
Int, BigInt, Float | Yes | Yes | Yes | Yes |
DateTime | Yes | Yes | No | No |
String, Boolean, Enum | No | No | No | No |
Calling avg() or sum() on a non-numeric field will panic at runtime with a clear message.
UserAggregateResult
The return type contains an Option field for each possible operation+field combination:
#![allow(unused)]
fn main() {
pub struct UserAggregateResult {
pub min_created_at: Option<chrono::DateTime<chrono::Utc>>,
pub max_created_at: Option<chrono::DateTime<chrono::Utc>>,
pub min_updated_at: Option<chrono::DateTime<chrono::Utc>>,
pub max_updated_at: Option<chrono::DateTime<chrono::Utc>>,
}
}
Fields that were not requested in the query will be None (via #[sqlx(default)]).
For models with numeric fields, you would also see fields like avg_age, sum_score, etc.
Aggregate with Filters
Pass a WhereInput to compute aggregates over a subset of records:
#![allow(unused)]
fn main() {
use generated::user::filter::UserWhereInput;
use ferriorm_runtime::filter::EnumFilter;
use generated::Role;
let result = client
.user()
.aggregate(UserWhereInput {
role: Some(EnumFilter {
equals: Some(Role::Admin),
..Default::default()
}),
..Default::default()
})
.min(UserAggregateField::CreatedAt)
.max(UserAggregateField::CreatedAt)
.exec()
.await?;
println!("First admin created: {:?}", result.min_created_at);
}
Numeric Aggregate Example
For a model with numeric fields (e.g., Order with a total field of type Float):
#![allow(unused)]
fn main() {
let stats = client
.order()
.aggregate(OrderWhereInput {
status: Some(EnumFilter {
equals: Some(OrderStatus::Completed),
..Default::default()
}),
..Default::default()
})
.sum(OrderAggregateField::Total)
.avg(OrderAggregateField::Total)
.min(OrderAggregateField::Total)
.max(OrderAggregateField::Total)
.exec()
.await?;
println!("Revenue: {:?}", stats.sum_total);
println!("Average order: {:?}", stats.avg_total);
}
Group By
group_by buckets rows by one or more columns and computes aggregates per
bucket – the SQL equivalent of SELECT keys, AGG(...) FROM t GROUP BY keys.
Each model with at least one groupable scalar field gets a group_by() method
on its actions, plus a generated <Model>GroupByField enum and
<Model>GroupByResult row struct.
Basic Usage
#![allow(unused)]
fn main() {
use generated::user::UserGroupByField;
let buckets = client
.user()
.group_by(vec![UserGroupByField::Role])
.count()
.exec()
.await?;
for b in &buckets {
println!("role={:?} users={:?}", b.role, b.count);
}
}
group_by returns Vec<<Model>GroupByResult> where each row exposes:
- An
Option<T>field for every groupable column on the model (only the columns passed togroup_by(vec![...])are populated; the rest areNone). count: Option<i64>, populated when.count()is called.avg_<col>/sum_<col>(Option<f64>) andmin_<col>/max_<col>for every numeric /DateTimecolumn on the model – populated only for the ones requested via.avg(...),.sum(...),.min(...),.max(...).
Combining group_by with Aggregates
The aggregate methods on GroupByQuery accept the same
<Model>AggregateField variants used by aggregate():
#![allow(unused)]
fn main() {
use generated::order::{OrderGroupByField, OrderAggregateField};
let buckets = client
.order()
.group_by(vec![OrderGroupByField::CustomerId])
.count()
.sum(OrderAggregateField::Total)
.avg(OrderAggregateField::Total)
.exec()
.await?;
for b in &buckets {
println!(
"customer={:?} orders={:?} revenue={:?} avg={:?}",
b.customer_id, b.count, b.sum_total, b.avg_total,
);
}
}
Multiple Group Keys
Pass multiple variants to bucket on the cross-product of columns:
#![allow(unused)]
fn main() {
let buckets = client
.order()
.group_by(vec![
OrderGroupByField::CustomerId,
OrderGroupByField::Status,
])
.count()
.exec()
.await?;
}
Each b.customer_id and b.status is populated; the other key columns on
the model stay None.
Filtering Source Rows with WHERE
Apply a WhereInput before grouping using .r#where(...). This is the SQL
WHERE clause – it filters rows before the grouping step:
#![allow(unused)]
fn main() {
use generated::order::filter::OrderWhereInput;
use ferriorm_runtime::filter::DateTimeFilter;
let recent = client
.order()
.group_by(vec![OrderGroupByField::CustomerId])
.r#where(OrderWhereInput {
created_at: Some(DateTimeFilter {
gte: Some(thirty_days_ago),
..Default::default()
}),
..Default::default()
})
.count()
.exec()
.await?;
}
Filtering Buckets with HAVING
having() filters the post-aggregation result – the SQL HAVING clause.
The generated <Model>HavingInput mirrors WhereInput but its fields target
aggregate expressions:
count: Option<BigIntFilter>– filters onCOUNT(*).- For each numeric column
col:avg_<col>: Option<FloatFilter>sum_<col>: Option<FloatFilter>min_<col>: Option<<col_filter>>max_<col>: Option<<col_filter>>
- For each
DateTimecolumncol:min_<col>: Option<DateTimeFilter>max_<col>: Option<DateTimeFilter>
- Compose with
and: Option<Vec<Self>>,or: Option<Vec<Self>>,not: Option<Box<Self>>.
#![allow(unused)]
fn main() {
use generated::order::OrderHavingInput;
use ferriorm_runtime::filter::{BigIntFilter, FloatFilter};
let big_spenders = client
.order()
.group_by(vec![OrderGroupByField::CustomerId])
.count()
.sum(OrderAggregateField::Total)
.having(OrderHavingInput {
count: Some(BigIntFilter {
gte: Some(5),
..Default::default()
}),
sum_total: Some(FloatFilter {
gt: Some(1000.0),
..Default::default()
}),
..Default::default()
})
.exec()
.await?;
}
The example above selects customers who placed 5 or more orders and spent more than $1000 in total.
Each HAVING field supports the same operators as the underlying scalar filter,
including r#in and not_in. They follow the same empty-list rules as
WHERE IN: an empty r#in becomes
HAVING ... AND 1 = 0 (no rows), and an empty not_in is dropped.
Allowed Group Keys
A field is groupable if it is a scalar of type String, Int, BigInt,
Float, Boolean, DateTime, or any user-defined enum. Json, Bytes,
Decimal, and relations are excluded – they are not hashable / orderable
in SQL.
Limitations
group_by() is intentionally narrow in v1 and does not currently support:
ORDER BYon the bucket result – buckets come back in database-defined order. Sort in Rust if you need a deterministic order.skip/take(pagination) at the group level.COUNT(DISTINCT col).- Grouping by a relation column (use the foreign-key scalar instead, e.g.
OrderGroupByField::CustomerId).
Raw SQL
Ferriorm provides escape hatches for executing raw SQL when the generated query builders are not sufficient.
When to Use Raw SQL
- Complex joins or subqueries not expressible with the query builder
- Database-specific features (CTEs, window functions, full-text search)
- Performance-critical queries that benefit from hand-tuned SQL
- Bulk operations beyond what
create_many/update_manysupport
Zero-Bind Queries
For simple queries without parameters, use the raw_fetch_* and raw_execute_* methods directly on the client.
PostgreSQL
#![allow(unused)]
fn main() {
use generated::user::User;
// Fetch all rows
let users: Vec<User> = client
.client()
.raw_fetch_all_pg("SELECT * FROM users ORDER BY created_at DESC")
.await?;
// Fetch exactly one row
let user: User = client
.client()
.raw_fetch_one_pg("SELECT * FROM users LIMIT 1")
.await?;
// Fetch an optional row
let user: Option<User> = client
.client()
.raw_fetch_optional_pg("SELECT * FROM users WHERE email = 'alice@example.com'")
.await?;
// Execute without returning rows (returns rows affected)
let rows_affected: u64 = client
.client()
.raw_execute_pg("DELETE FROM users WHERE role = 'user'")
.await?;
}
SQLite
The SQLite variants work identically:
#![allow(unused)]
fn main() {
let users: Vec<User> = client
.client()
.raw_fetch_all_sqlite("SELECT * FROM users ORDER BY created_at DESC")
.await?;
let rows: u64 = client
.client()
.raw_execute_sqlite("UPDATE users SET role = 'admin' WHERE id = '123'")
.await?;
}
Return Type Requirement
The generic type T must implement sqlx::FromRow for the corresponding database row type. All ferriorm-generated model structs (User, Post, etc.) implement this automatically.
For custom result types, derive sqlx::FromRow:
#![allow(unused)]
fn main() {
#[derive(sqlx::FromRow)]
struct UserCount {
role: String,
count: i64,
}
let counts: Vec<UserCount> = client
.client()
.raw_fetch_all_pg("SELECT role, COUNT(*) as count FROM users GROUP BY role")
.await?;
}
Parameterized Queries with sqlx
For queries with user-provided values (to prevent SQL injection), access the underlying sqlx pool and use sqlx’s query API directly.
PostgreSQL
#![allow(unused)]
fn main() {
let pool = client.pg_pool()?;
// Parameterized SELECT
let users: Vec<User> = sqlx::query_as::<_, User>(
"SELECT * FROM users WHERE email = $1 AND role = $2"
)
.bind("alice@example.com")
.bind("admin")
.fetch_all(pool)
.await?;
// Parameterized INSERT
let result = sqlx::query(
"INSERT INTO users (id, email, name, role, created_at, updated_at) \
VALUES ($1, $2, $3, $4, NOW(), NOW())"
)
.bind(uuid::Uuid::new_v4().to_string())
.bind("bob@example.com")
.bind("Bob")
.bind("user")
.execute(pool)
.await?;
println!("Inserted {} rows", result.rows_affected());
}
SQLite
#![allow(unused)]
fn main() {
let pool = client.sqlite_pool()?;
let users: Vec<User> = sqlx::query_as::<_, User>(
"SELECT * FROM users WHERE email = ?1"
)
.bind("alice@example.com")
.fetch_all(pool)
.await?;
}
Note: PostgreSQL uses
$1, $2, ...for bind parameters. SQLite uses?1, ?2, ...or just?.
Complex Query Example
Using a CTE to rank users by post count:
#![allow(unused)]
fn main() {
#[derive(sqlx::FromRow)]
struct UserWithPostCount {
email: String,
post_count: i64,
rank: i64,
}
let pool = client.pg_pool()?;
let ranked: Vec<UserWithPostCount> = sqlx::query_as::<_, UserWithPostCount>(
"WITH user_posts AS (
SELECT u.email, COUNT(p.id) as post_count
FROM users u
LEFT JOIN posts p ON p.author_id = u.id
GROUP BY u.email
)
SELECT email, post_count,
RANK() OVER (ORDER BY post_count DESC) as rank
FROM user_posts"
)
.fetch_all(pool)
.await?;
}
Mixing Raw SQL and Query Builders
You can use raw SQL for reads and the generated client for writes (or vice versa) within the same application. Both operate on the same connection pool.
#![allow(unused)]
fn main() {
// Complex read with raw SQL
let top_users: Vec<User> = client
.client()
.raw_fetch_all_pg(
"SELECT u.* FROM users u \
JOIN posts p ON p.author_id = u.id \
GROUP BY u.id \
HAVING COUNT(p.id) > 5 \
ORDER BY COUNT(p.id) DESC"
)
.await?;
// Type-safe update with the query builder
for user in &top_users {
client
.user()
.update(
UserWhereUniqueInput::Id(user.id.clone()),
UserUpdateInput {
role: Some(SetValue::Set(Role::Moderator)),
..Default::default()
},
)
.exec()
.await?;
}
}
Transactions
Ferriorm supports database transactions through the run_transaction function. A transaction groups multiple operations into an atomic unit – either all succeed (commit) or all are rolled back.
Basic Usage
#![allow(unused)]
fn main() {
use ferriorm_runtime::transaction::run_transaction;
let new_user = run_transaction(client.client(), |tx| async move {
// ... perform operations using tx ...
// Return the result and the tx client
Ok((result, tx))
}).await?;
}
How It Works
run_transactionbegins a database transaction.- Your closure receives a
TransactionClientwrapping the transaction handle. - On
Ok((value, tx))– the transaction is committed andvalueis returned. - On
Err(e)– theTransactionClientis dropped, which triggers an automatic rollback via sqlx.
TransactionClient
TransactionClient is an enum mirroring DatabaseClient:
#![allow(unused)]
fn main() {
pub enum TransactionClient {
Postgres(sqlx::Transaction<'static, sqlx::Postgres>),
Sqlite(sqlx::Transaction<'static, sqlx::Sqlite>),
}
}
It provides commit() and rollback() methods, but you typically do not call them directly – run_transaction handles this based on your closure’s return value.
Complete Example
Transfer a post from one user to another, ensuring both the update and a log entry succeed atomically:
#![allow(unused)]
fn main() {
use ferriorm_runtime::transaction::run_transaction;
let post_id = "some-post-id";
let new_author_id = "new-author-id";
let updated_post = run_transaction(client.client(), |tx| async move {
// For now, use the raw sqlx transaction handle for queries
match tx {
ferriorm_runtime::transaction::TransactionClient::Postgres(mut pg_tx) => {
// Update the post's author
let post: Post = sqlx::query_as::<_, Post>(
"UPDATE posts SET author_id = $1, updated_at = NOW() \
WHERE id = $2 RETURNING *"
)
.bind(new_author_id)
.bind(post_id)
.fetch_one(&mut *pg_tx)
.await
.map_err(FerriormError::from)?;
// Insert an audit log entry
sqlx::query(
"INSERT INTO audit_log (action, entity_id, timestamp) \
VALUES ($1, $2, NOW())"
)
.bind("transfer_post")
.bind(post_id)
.execute(&mut *pg_tx)
.await
.map_err(FerriormError::from)?;
// Return the result and wrap the transaction back
let tx_client = ferriorm_runtime::transaction::TransactionClient::Postgres(pg_tx);
Ok((post, tx_client))
}
_ => Err(FerriormError::Connection("Expected PostgreSQL".into())),
}
}).await?;
println!("Transferred post: {}", updated_post.title);
}
Error Handling and Rollback
If any operation in the closure fails, return an Err and the transaction rolls back automatically:
#![allow(unused)]
fn main() {
let result = run_transaction(client.client(), |tx| async move {
match tx {
ferriorm_runtime::transaction::TransactionClient::Postgres(mut pg_tx) => {
// This succeeds
sqlx::query("INSERT INTO users (id, email) VALUES ($1, $2)")
.bind("id-1")
.bind("alice@example.com")
.execute(&mut *pg_tx)
.await
.map_err(FerriormError::from)?;
// This fails (duplicate email) -- entire transaction rolls back
sqlx::query("INSERT INTO users (id, email) VALUES ($1, $2)")
.bind("id-2")
.bind("alice@example.com")
.execute(&mut *pg_tx)
.await
.map_err(FerriormError::from)?;
let tx_client = ferriorm_runtime::transaction::TransactionClient::Postgres(pg_tx);
Ok(((), tx_client))
}
_ => Err(FerriormError::Connection("Expected PostgreSQL".into())),
}
}).await;
match result {
Ok(_) => println!("Committed"),
Err(e) => println!("Rolled back: {e}"),
}
}
Function Signature
#![allow(unused)]
fn main() {
pub async fn run_transaction<F, Fut, T>(
client: &DatabaseClient,
f: F,
) -> Result<T, FerriormError>
where
F: FnOnce(TransactionClient) -> Fut,
Fut: Future<Output = Result<(T, TransactionClient), FerriormError>>;
}
Key points:
- The closure must return
(T, TransactionClient)on success so the transaction can be committed. Tcan be any type – it is the value returned to the caller after commit.- The closure takes ownership of
TransactionClientand must return it.
Migrations Overview
Migrations track incremental changes to your database schema over time. Each migration is a SQL file that transforms the database from one state to the next.
Why Migrations?
Your schema.ferriorm file describes the desired state of the database. Migrations bridge the gap between the current database state and the desired state by generating the SQL ALTER TABLE, CREATE TABLE, and other DDL statements needed.
Migration Strategies
Ferriorm supports two strategies for generating migration SQL:
Shadow Database (Default)
- Creates a temporary “shadow” database.
- Replays all existing migrations against it.
- Introspects the shadow database to get the “current” schema.
- Diffs the current schema against your
schema.ferriormto produce the migration SQL. - Drops the shadow database.
This is the most reliable strategy and catches issues like migration drift. It requires a running database server with permissions to create/drop databases.
Snapshot
- Reads a local
.snapshotfile representing the last-known schema state. - Diffs the snapshot against your
schema.ferriorm. - Writes the new migration SQL and updates the snapshot.
Use snapshot mode with --snapshot when you cannot connect to a database (CI, offline development, SQLite). No temporary database is created.
Directory Structure
Migrations live in a migrations/ directory next to your schema file:
project/
schema.ferriorm
migrations/
20250315120000_init/
migration.sql
20250320143000_add_posts/
migration.sql
migration_lock.toml
- Each migration is a timestamped directory containing a
migration.sqlfile. migration_lock.tomlrecords the database provider to prevent accidentally running PostgreSQL migrations against SQLite.
Migration SQL
Each migration.sql file contains raw SQL statements:
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL PRIMARY KEY,
"email" TEXT NOT NULL,
"name" TEXT,
"role" TEXT NOT NULL DEFAULT 'user',
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
You can edit migration files after generation but before applying them.
Checksum Verification
Every applied migration is recorded in a _ferriorm_migrations table in your database with a SHA-256 checksum of its SQL content. On subsequent runs, ferriorm verifies that previously applied migrations have not been modified. If a checksum mismatch is detected, the command fails with an error.
Workflow Summary
| Stage | Command | Strategy |
|---|---|---|
| Development | ferriorm migrate dev | Shadow DB or snapshot |
| Production | ferriorm migrate deploy | Apply pending only |
| Status check | ferriorm migrate status | Read-only |
| Brownfield | ferriorm db pull | Introspection |
See the following pages for details on each stage.
Development Workflow
The ferriorm migrate dev command is the primary tool for evolving your schema during development. It generates a new migration, applies it, and regenerates the Rust client.
Basic Usage
# Edit your schema.ferriorm, then run:
ferriorm migrate dev --name add_posts_table
This command:
- Detects schema changes by comparing your
schema.ferriormagainst the current database state. - Generates a new
migration.sqlfile inmigrations/<timestamp>_<name>/. - Applies the migration to your development database.
- Regenerates the Rust client code.
Full Workflow
# 1. Edit the schema
vim schema.ferriorm
# 2. Generate and apply the migration
ferriorm migrate dev --name add_user_roles
# 3. Your generated client is updated automatically
cargo build
The --name Flag
The --name flag is optional but recommended. It gives the migration a descriptive name:
ferriorm migrate dev --name init
# Creates: migrations/20250315120000_init/migration.sql
ferriorm migrate dev --name add_email_index
# Creates: migrations/20250320143000_add_email_index/migration.sql
Without --name, a default name is used.
Editing Migrations After Generation
After migrate dev generates the SQL, you can edit migration.sql before the next deploy. This is useful for:
- Adding data migrations (
INSERT,UPDATE) - Renaming columns (ferriorm generates drop + add by default)
- Adding database-specific features (triggers, functions)
Warning: Do not edit migrations that have already been applied to other environments. Checksum verification will fail.
Shadow Database Strategy (Default)
By default, migrate dev uses a shadow database:
Your DB (current state)
|
v
Shadow DB (replay all migrations) --> Introspect --> Diff against schema.ferriorm
| |
v v
(dropped) migration.sql
Requirements:
- A running database server (PostgreSQL or SQLite)
- Permissions to create and drop temporary databases
- The
DATABASE_URLenvironment variable must be set
The shadow database is created with a _shadow suffix, used temporarily, and dropped automatically.
Snapshot Strategy
For environments where you cannot create a shadow database, use the --snapshot flag:
ferriorm migrate dev --name add_posts --snapshot
This uses a local .snapshot file instead of a temporary database. The snapshot records the schema state after the last migration.
When to use snapshot mode:
- Offline development without a database connection
- CI environments without database access
- SQLite projects (simpler setup)
Trade-off: Snapshot mode cannot detect drift between your migrations and the actual database state. The shadow database strategy is more reliable.
Checking Migration Status
See which migrations have been applied and which are pending:
ferriorm migrate status
This reads the _ferriorm_migrations table in your database and compares it to the migrations/ directory.
Example: Adding a Field
Starting schema:
model User {
id String @id @default(uuid())
email String @unique
@@map("users")
}
Add a name field:
model User {
id String @id @default(uuid())
email String @unique
name String?
@@map("users")
}
Run the migration:
ferriorm migrate dev --name add_user_name
Generated migration.sql:
-- AlterTable
ALTER TABLE "users" ADD COLUMN "name" TEXT;
Example: Adding a Relation
Add a Post model related to User:
model User {
id String @id @default(uuid())
email String @unique
name String?
posts Post[]
@@map("users")
}
model Post {
id String @id @default(uuid())
title String
author User @relation(fields: [authorId], references: [id])
authorId String
@@map("posts")
}
ferriorm migrate dev --name add_posts
Generated migration.sql:
-- CreateTable
CREATE TABLE "posts" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"author_id" TEXT NOT NULL,
CONSTRAINT "posts_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "users"("id")
);
-- CreateIndex
CREATE INDEX "posts_author_id_idx" ON "posts"("author_id");
Resetting the Database
If your development database gets into a bad state, you can reset it by dropping and recreating it manually, then running:
ferriorm migrate dev
This replays all migrations from scratch.
Production Deployment
The ferriorm migrate deploy command applies pending migrations to a production database. Unlike migrate dev, it never generates new migrations or resets data.
Basic Usage
DATABASE_URL="postgres://prod-host/mydb" ferriorm migrate deploy
This command:
- Reads the
_ferriorm_migrationstable to determine which migrations have already been applied. - Verifies checksums of previously applied migrations.
- Applies any pending migrations in chronological order.
- Records each applied migration with its checksum.
Checksum Verification
Every migration is stored with a SHA-256 checksum. If a previously applied migration’s SQL file has been modified since it was applied, migrate deploy will fail with a checksum mismatch error.
This prevents accidental corruption of the migration history. If you see this error:
Error: Migration checksum mismatch for 20250315120000_init.
The migration has been modified after it was applied.
Do not edit the migration file to fix it. Instead, investigate why it changed (accidental edit, line-ending differences, etc.) and restore the original content.
CI/CD Integration
Docker Example
FROM rust:1.80 AS builder
WORKDIR /app
COPY . .
RUN cargo build --release
FROM debian:bookworm-slim
COPY --from=builder /app/target/release/myapp /usr/local/bin/
COPY --from=builder /app/migrations/ /app/migrations/
COPY --from=builder /app/schema.ferriorm /app/
# Run migrations before starting the application
CMD ferriorm migrate deploy --schema /app/schema.ferriorm && myapp
GitHub Actions Example
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install ferriorm CLI
run: cargo install ferriorm-cli
- name: Run migrations
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: ferriorm migrate deploy
- name: Deploy application
run: # your deploy step
General Guidelines
- Run
migrate deploybefore deploying new application code. - If migrations fail, the application deployment should be aborted.
- Each migration runs in its own transaction (for databases that support transactional DDL).
- Migrations are applied in filename order (chronological by timestamp).
Rollback Strategy
Ferriorm does not provide automatic rollback commands. To revert a migration:
-
Write a new migration that undoes the changes:
# In development ferriorm migrate dev --name revert_add_posts -
Edit the generated
migration.sqlto contain the reverse DDL:-- Manual rollback DROP TABLE IF EXISTS "posts"; -
Deploy:
ferriorm migrate deploy
Best practices for safe deployments:
- Make migrations backward-compatible when possible (add columns as nullable, avoid renaming).
- Test migrations against a staging database before production.
- Back up your database before applying migrations.
Schema File Location
By default, migrate deploy looks for schema.ferriorm in the current directory. Use --schema to specify a different path:
ferriorm migrate deploy --schema /path/to/schema.ferriorm
The migrations/ directory is resolved relative to the schema file location.
Database Introspection
The ferriorm db pull command reverse-engineers a schema.ferriorm file from an existing database. This is the primary tool for adopting ferriorm in projects with an existing database.
Basic Usage
DATABASE_URL="postgres://localhost/mydb" ferriorm db pull
This command:
- Connects to the database specified by
DATABASE_URL. - Reads the database’s tables, columns, indexes, and foreign keys.
- Generates (or overwrites) a
schema.ferriormfile representing the current database state.
Brownfield Adoption Workflow
If you have an existing database and want to start using ferriorm:
# 1. Pull the current schema from your database
ferriorm db pull
# 2. Review the generated schema.ferriorm
cat schema.ferriorm
# 3. Generate the Rust client
ferriorm generate
# 4. Create a baseline migration (marks current state as "already applied")
ferriorm migrate dev --name baseline
# 5. Start making changes to schema.ferriorm and migrating normally
Backup
If a schema.ferriorm file already exists, db pull backs it up before overwriting. Look for a .backup file in the same directory if you need to recover the previous version.
What Gets Introspected
| Database feature | Introspected? |
|---|---|
| Tables | Yes |
| Columns and types | Yes |
| Primary keys | Yes |
| Unique constraints | Yes |
| Foreign keys (relations) | Yes |
| Indexes | Yes |
| Default values | Yes |
| Enums (PostgreSQL) | Yes |
| Views | No |
| Triggers | No |
| Stored procedures | No |
Type Mapping
Introspection maps database-native types to ferriorm schema types:
PostgreSQL
| PostgreSQL type | Schema type |
|---|---|
text, varchar, char | String |
integer, int4 | Int |
bigint, int8 | BigInt |
real, double precision | Float |
boolean | Boolean |
timestamp, timestamptz | DateTime |
uuid | String |
| User-defined enums | enum |
SQLite
| SQLite type | Schema type |
|---|---|
TEXT | String |
INTEGER | Int |
REAL | Float |
BOOLEAN, TINYINT(1) | Boolean |
DATETIME, TIMESTAMP | DateTime |
Schema File Location
By default, db pull writes to schema.ferriorm in the current directory. Use --schema to specify a different path:
ferriorm db pull --schema path/to/schema.ferriorm
Example Output
Given a PostgreSQL database with users and posts tables, db pull might generate:
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
output = "./src/generated"
}
model User {
id String @id @default(uuid())
email String @unique
name String?
role Role @default(User)
posts Post[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([email])
@@map("users")
}
model Post {
id String @id @default(uuid())
title String
content String?
author User @relation(fields: [authorId], references: [id])
authorId String
@@index([authorId])
@@map("posts")
}
enum Role {
User
Admin
Moderator
}
Tip: After introspection, review the generated schema and adjust field names, relation names, and model names to match your project’s conventions. Then run
ferriorm generateto produce the Rust client.
CLI Reference
The ferriorm CLI manages code generation, migrations, and database introspection.
Global Options
| Flag | Default | Description |
|---|---|---|
--schema <path> | schema.ferriorm | Path to the schema file |
--version | Print version | |
--help | Print help |
Commands
ferriorm init
Initialize a new ferriorm project. Creates a schema.ferriorm file with a datasource and generator block.
ferriorm init
ferriorm init --provider sqlite
| Flag | Default | Description |
|---|---|---|
--provider <provider> | postgresql | Database provider: postgresql or sqlite |
Generated files:
schema.ferriorm– starter schema with datasource configuration
ferriorm generate
Generate the Rust client code from the schema file. Reads schema.ferriorm and writes generated modules to the output directory specified in the generator block.
ferriorm generate
ferriorm generate --schema path/to/schema.ferriorm
| Flag | Default | Description |
|---|---|---|
--schema <path> | schema.ferriorm | Path to the schema file |
Generated files:
mod.rs– module declarations and re-exportsclient.rs–FerriormClientwith model accessorsenums.rs– Rust enums for schema enums<model>.rs– one file per model with struct, filters, CRUD builders, etc.
ferriorm migrate dev
Create a new migration, apply it to the development database, and regenerate the client. This is the primary command during development.
ferriorm migrate dev --name init
ferriorm migrate dev --name add_posts --snapshot
| Flag | Default | Description |
|---|---|---|
--name <name> | (auto) | Migration name (used in the directory name) |
--snapshot | false | Use snapshot strategy instead of shadow database |
--schema <path> | schema.ferriorm | Path to the schema file |
What it does:
- Diffs the current database state (or snapshot) against
schema.ferriorm - Generates
migrations/<timestamp>_<name>/migration.sql - Applies the migration to the database
- Regenerates the Rust client
Environment variables:
DATABASE_URL– required (unless using--snapshotwith SQLite)
ferriorm migrate deploy
Apply all pending migrations to the database. Used in production and CI/CD pipelines. Never generates new migrations.
ferriorm migrate deploy
| Flag | Default | Description |
|---|---|---|
--schema <path> | schema.ferriorm | Path to the schema file |
What it does:
- Reads the
_ferriorm_migrationstable - Verifies checksums of applied migrations
- Applies pending migrations in order
- Records each applied migration
Environment variables:
DATABASE_URL– required
ferriorm migrate status
Show the status of all migrations: which are applied, which are pending, and whether any checksums are mismatched.
ferriorm migrate status
| Flag | Default | Description |
|---|---|---|
--schema <path> | schema.ferriorm | Path to the schema file |
Environment variables:
DATABASE_URL– required
ferriorm db pull
Introspect an existing database and generate a schema.ferriorm file from its current state. Used for brownfield adoption.
ferriorm db pull
| Flag | Default | Description |
|---|---|---|
--schema <path> | schema.ferriorm | Path to write the schema file |
What it does:
- Connects to the database
- Reads tables, columns, indexes, foreign keys, and enums
- Writes a
schema.ferriormfile (backs up existing file if present)
Environment variables:
DATABASE_URL– required
Environment Variables
| Variable | Required | Description |
|---|---|---|
DATABASE_URL | For most commands | Database connection URL |
RUST_LOG | No | Controls log verbosity (e.g., RUST_LOG=debug) |
Exit Codes
| Code | Meaning |
|---|---|
0 | Success |
1 | Error (migration failure, connection error, parse error, etc.) |
Database Support
Ferriorm supports PostgreSQL and SQLite. Support is controlled by Cargo feature flags on the ferriorm-runtime crate.
Feature Matrix
| Feature | PostgreSQL | SQLite |
|---|---|---|
| Connection | ||
| Connection pooling | Yes | Yes |
| PoolConfig | Yes | Yes |
| Auto-detection from URL | Yes | Yes |
| CRUD | ||
| create / find / update / delete | Yes | Yes |
| create_many / update_many / delete_many | Yes | Yes |
| count | Yes | Yes |
| Filtering | ||
| StringFilter (equals, contains, etc.) | Yes | Yes |
| IntFilter / BigIntFilter / FloatFilter | Yes | Yes |
| BoolFilter | Yes | Yes |
| DateTimeFilter | Yes | Yes |
| EnumFilter | Yes | Yes |
| AND / OR / NOT combinators | Yes | Yes |
| Case-insensitive mode (QueryMode) | Yes (ILIKE) | Partial (LIKE is case-insensitive for ASCII in SQLite) |
| Ordering & Pagination | ||
| ORDER BY | Yes | Yes |
| LIMIT / OFFSET | Yes | Yes |
| Relations | ||
| Include (batched loading) | Yes | Yes |
| Foreign keys | Yes | Yes (if enabled) |
| Select | ||
| Partial column selection | Yes | Yes |
| Aggregates | ||
| MIN / MAX | Yes | Yes |
| AVG / SUM | Yes | Yes |
| Raw SQL | ||
| raw_fetch_all / one / optional | Yes | Yes |
| raw_execute | Yes | Yes |
| Direct pool access | Yes (pg_pool()) | Yes (sqlite_pool()) |
| Transactions | ||
| run_transaction | Yes | Yes |
| Auto-rollback on error | Yes | Yes |
| Migrations | ||
| migrate dev (shadow DB) | Yes | Yes |
| migrate dev (snapshot) | Yes | Yes |
| migrate deploy | Yes | Yes |
| migrate status | Yes | Yes |
| db pull (introspection) | Yes | Yes |
| Schema Features | ||
| @id | Yes | Yes |
| @unique | Yes | Yes |
| @default(uuid()) | Yes | Yes |
| @default(now()) | Yes | Yes |
| @default(autoincrement()) | Yes | Yes |
| @updatedAt | Yes | Yes |
| @relation | Yes | Yes |
| @@index | Yes | Yes |
| @@unique (composite) | Yes | Yes |
| @@map | Yes | Yes |
| Enums (native) | Yes (CREATE TYPE) | Emulated (TEXT + CHECK) |
URL Formats
PostgreSQL
postgres://user:password@host:port/database
postgresql://user:password@host:port/database
Options can be appended as query parameters:
postgres://user:pass@host/db?sslmode=require
SQLite
sqlite:path/to/database.db
sqlite::memory:
file:path/to/database.db
path/to/database.db
SQLite URLs support query parameters for pragmas:
sqlite:mydb.db?mode=rwc
Type Mapping
PostgreSQL
| Schema type | Rust type | PostgreSQL type |
|---|---|---|
String | String | TEXT |
Int | i32 | INTEGER |
BigInt | i64 | BIGINT |
Float | f64 | DOUBLE PRECISION |
Boolean | bool | BOOLEAN |
DateTime | chrono::DateTime<Utc> | TIMESTAMPTZ |
enum Foo | Foo (generated) | Custom enum type |
SQLite
| Schema type | Rust type | SQLite type |
|---|---|---|
String | String | TEXT |
Int | i32 | INTEGER |
BigInt | i64 | INTEGER |
Float | f64 | REAL |
Boolean | bool | BOOLEAN |
DateTime | chrono::DateTime<Utc> | TEXT (ISO 8601) |
enum Foo | Foo (generated) | TEXT |
SQLite DateTime Handling
SQLite’s CURRENT_TIMESTAMP produces values in the format YYYY-MM-DD HH:MM:SS (without timezone). The sqlx chrono integration parses these values correctly, treating them as UTC. This means @default(now()) works as expected on SQLite – the value is stored as a TEXT column in ISO-like format and deserialized into chrono::DateTime<Utc> by sqlx.
Note that @default(uuid()) and @default(cuid()) do not produce SQL-level defaults on SQLite. The UUID/CUID values are generated in Rust application code before the INSERT statement is executed, so the column will always receive a value from the application.
Known Limitations
| Limitation | Affects |
|---|---|
| No nested includes | Both |
| No raw SQL bind helpers (use sqlx directly) | Both |
| Enum columns stored as TEXT | SQLite |
| No array/JSON column types | Both |
| No database views | Both |
| No stored procedures | Both |
Architecture
Ferriorm is organized as a Cargo workspace with six crates, following a layered architecture where each crate has a clear responsibility and minimal coupling.
Crate Structure
ferriorm/
crates/
ferriorm-parser/ # Schema file parser
ferriorm-core/ # Shared types and schema IR
ferriorm-codegen/ # Rust code generator
ferriorm-runtime/ # Runtime library (ships with your app)
ferriorm-migrate/ # Migration engine
ferriorm-cli/ # CLI binary
Dependency Flow
ferriorm-cli
/ | \
v v v
ferriorm-codegen ferriorm-migrate
| |
v v
ferriorm-core ferriorm-core
| |
v v
ferriorm-parser ferriorm-parser
[Your Application]
|
v
[Generated Code] ---> ferriorm-runtime
|
v
sqlx
Crate Responsibilities
ferriorm-parser
Parses .ferriorm schema files into an unvalidated AST. Handles the Prisma-like DSL syntax including datasource blocks, generator blocks, models, fields, attributes, and enums.
ferriorm-core
Defines the validated intermediate representation (IR) of a schema. Transforms the raw AST from the parser into structured types: Schema, Model, Field, Relation, Enum, etc. This IR is consumed by both the code generator and the migration engine.
ferriorm-codegen
Takes a Schema IR and generates Rust source files:
- Model structs with
sqlx::FromRowderive - Filter structs (
WhereInput,WhereUniqueInput) - Data structs (
CreateInput,UpdateInput) - Order enums (
OrderByInput) - Query builder structs (find, create, update, delete, aggregate)
- Select/Include types and relation loaders
- The
FerriormClientwrapper - Enum definitions
ferriorm-runtime
The only crate that ships as a dependency of your application. Provides:
DatabaseClient– connection pool wrapper (PostgreSQL + SQLite)PoolConfig– pool tuning options- Filter types (
StringFilter,IntFilter,BoolFilter,DateTimeFilter,EnumFilter, etc.) SetValue<T>– update operation wrapperSortOrder– ordering enumrun_transaction/TransactionClient– transaction supportFerriormError– unified error type- Raw SQL execution helpers
ferriorm-migrate
Manages database migrations:
- Shadow database strategy: creates a temp DB, replays migrations, introspects, diffs
- Snapshot strategy: local file-based schema diffing
- SQL generation: produces DDL for PostgreSQL and SQLite
- Runner: applies migrations, tracks state in
_ferriorm_migrationstable - Introspection: reads database metadata to produce a schema IR
ferriorm-cli
The ferriorm binary. Thin layer over the other crates:
init– scaffolds a new projectgenerate– calls parser + core + codegenmigrate dev– calls parser + core + migrate + codegenmigrate deploy– calls migrate (runner only)migrate status– calls migrate (state reader)db pull– calls migrate (introspection) + writes schema file
Design Principles
Code generation over reflection. All query builders and type mappings are generated at build time. There are no runtime macros, trait-based query DSLs, or dynamic dispatch for query construction. The generated code is plain Rust that uses sqlx’s QueryBuilder directly.
sqlx as the foundation. Ferriorm does not implement its own database driver or connection pool. It generates code that uses sqlx’s QueryBuilder, FromRow, and pool types. You can always drop down to raw sqlx when needed.
Schema as the source of truth. The schema.ferriorm file is the single source of truth for your data model. Migrations, generated code, and type mappings are all derived from it.
For implementation details, see the source code in each crate’s src/ directory.