Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 .ferriorm file 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: sqlx must be a direct dependency in your Cargo.toml. The generated code uses #[derive(sqlx::FromRow)], which is a proc macro that expands to code referencing ::sqlx:: absolute paths internally. Transitive dependencies (through ferriorm-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 postgres with sqlite in 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-cli installed (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:

  1. Diffs your schema against the current database state
  2. Creates a SQL migration file in migrations/0001_init/migration.sql
  3. 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

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.

FileContents
mod.rsRe-exports the client and all model/enum modules
client.rsThe FerriormClient struct with connect(), disconnect(), and accessor methods for each model
user.rsUser struct, UserCreateInput, UserUpdateInput, UserWhereInput, UserWhereUniqueInput, UserOrderByInput, UserInclude, and the UserActions query builder
post.rsSame pattern for the Post model
enums.rsRust 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

  1. Edit schema.ferriorm
  2. Run ferriorm migrate dev --name describe_the_change
  3. The CLI diffs, generates a migration, applies it, and regenerates src/generated/
  4. Your Rust code gets compile-time errors if anything changed – fix and continue

Next steps

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 datasource block 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: provider and url.

provider

Specifies the database engine. The value is a case-insensitive string.

ValueDatabase
"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:

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.

FieldTypeDefault
outputString"./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 output value is a string literal specifying a path relative to the location of the schema file.
  • Multiple generator blocks 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 NameDefault Table Name
Userusers
BlogPostblog_posts
OrderItemorder_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:

  1. Name – a camelCase identifier (e.g., email, createdAt)
  2. Type – a scalar type, enum name, or model name, with optional ? or [] modifier
  3. 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 @unique field 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 name optional (String? maps to Option<String> in Rust)
  • Stores a Role enum value, defaulting to User
  • Has a one-to-many relation to Post (the posts field) and a one-to-one relation to Profile (the profile field)
  • Tracks creation and update timestamps automatically
  • Creates a database index on the email column
  • 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 TypeRust TypePostgreSQLSQLite
StringStringTEXTTEXT
Inti32INTEGERINTEGER
BigInti64BIGINTINTEGER
Floatf64DOUBLE PRECISIONREAL
Decimalrust_decimal::DecimalDECIMALTEXT
BooleanboolBOOLEANINTEGER
DateTimechrono::DateTime<chrono::Utc>TIMESTAMPTZTEXT
Jsonserde_json::ValueJSONBTEXT
BytesVec<u8>BYTEABLOB

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

FunctionDescriptionApplicable Types
uuid()Generates a random UUID v4String
cuid()Generates a CUIDString
autoincrement()Auto-incrementing integerInt, BigInt
now()Current date and timeDateTime
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

ArgumentRequiredDescription
name (positional or name:)When ambiguousDisambiguator when two or more relations connect the same pair of models. See Relation names.
fieldsYesArray of field names on this model that store the foreign key
referencesYesArray of field names on the related model that the foreign key points to
onDeleteNoReferential action when the referenced record is deleted
onUpdateNoReferential 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

ActionDescription
CascadeDelete/update all related records
RestrictPrevent deletion/update if related records exist
NoActionSimilar to Restrict (database-dependent)
SetNullSet the foreign key field(s) to NULL
SetDefaultSet 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:

HintEffect
@db.BigInt on IntGenerates 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 @unique field 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:

  1. The side with @relation stores the foreign key. The @relation attribute specifies which field(s) on the current model hold the foreign key and which field(s) on the related model they reference.

  2. 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).

  3. Both sides must be defined. The model that owns the foreign key has the @relation attribute; 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:

PartPurpose
posts Post[] on UserBack-relation (virtual). A user has many posts. Not a database column.
author User @relation(...) on PostRelation field (virtual). Represents the related user. Not a database column.
authorId String on PostForeign 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.userId has @unique, so each user can have at most one profile.
  • User.profile is typed as Profile? (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 (postId and tagId) linking to Post and Tag
  • 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

ActionOn DeleteOn Update
CascadeDelete all posts when the user is deletedUpdate foreign keys when the user’s id changes
RestrictPrevent deleting a user who has postsPrevent updating a user’s id if posts reference it
NoActionSimilar to Restrict (exact behavior is database-dependent)Similar to Restrict
SetNullSet authorId to NULL when the user is deleted (field must be optional)Set authorId to NULL when the user’s id changes
SetDefaultSet authorId to its default value when the user is deletedSet 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 Post to User require 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 @relation must specify fields and references.
  • The fields array lists foreign key fields on the current model.
  • The references array lists the corresponding key fields on the related model.
  • The number of entries in fields and references must 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 patternDatabase
postgres://... or postgresql://...PostgreSQL
sqlite:..., file:..., or *.dbSQLite

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

FieldTypeDescription
max_connectionsOption<u32>Maximum number of connections in the pool.
min_connectionsOption<u32>Minimum idle connections to keep open.
idle_timeoutOption<Duration>How long a connection can sit idle before being closed.
max_lifetimeOption<Duration>Maximum lifetime of a connection before it is recycled.
acquire_timeoutOption<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 support
  • sqlite – 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 kindCreateInput typeNotes
Required, no defaultTMust 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 modified
  • Some(SetValue::Set(value)) – set the field to value

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 emits WHERE 1 = 0 so the query stays portable across SQLite and Postgres (Postgres rejects bare IN ()).
  • Empty not_in (Some(vec![])) is dropped — NOT IN over an empty set is vacuously true, so the predicate isn’t applied.
  • None leaves the operator unset (no fragment emitted), same as every other filter field.
  • NULL columns: per standard SQL, IN and NOT IN do not match NULL values. To include NULLs, compose with equals: Some(None) on a nullable filter (or wrap the IN inside an or:).

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 SQL IS NULL (or IS NOT NULL for not).
  • Some(Some(v)) — ordinary equality/inequality against v.
#![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

VariantSQL
SortOrder::AscASC
SortOrder::DescDESC

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.

MethodSQLDescription
.take(n)LIMIT nMaximum number of records to return
.skip(n)OFFSET nNumber 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_by clause 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
}
}
  • data contains all scalar fields of the parent record.
  • Each relation field is Option<Vec<RelatedModel>> for one-to-many relations, and Option<RelatedModel> for one-to-one.
  • The value is None when the relation was not included, and Some(...) 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:

  1. Primary query: Fetch all parent records with a single SELECT.
  2. Relation query: Collect all parent IDs, then fetch related records with a single SELECT ... WHERE foreign_key IN (id1, id2, ...) query.
  3. 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 typeminmaxavgsum
Int, BigInt, FloatYesYesYesYes
DateTimeYesYesNoNo
String, Boolean, EnumNoNoNoNo

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 to group_by(vec![...]) are populated; the rest are None).
  • count: Option<i64>, populated when .count() is called.
  • avg_<col> / sum_<col> (Option<f64>) and min_<col> / max_<col> for every numeric / DateTime column 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 on COUNT(*).
  • 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 DateTime column col:
    • 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 BY on 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_many support

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

  1. run_transaction begins a database transaction.
  2. Your closure receives a TransactionClient wrapping the transaction handle.
  3. On Ok((value, tx)) – the transaction is committed and value is returned.
  4. On Err(e) – the TransactionClient is 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.
  • T can be any type – it is the value returned to the caller after commit.
  • The closure takes ownership of TransactionClient and 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)

  1. Creates a temporary “shadow” database.
  2. Replays all existing migrations against it.
  3. Introspects the shadow database to get the “current” schema.
  4. Diffs the current schema against your schema.ferriorm to produce the migration SQL.
  5. 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

  1. Reads a local .snapshot file representing the last-known schema state.
  2. Diffs the snapshot against your schema.ferriorm.
  3. 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.sql file.
  • migration_lock.toml records 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

StageCommandStrategy
Developmentferriorm migrate devShadow DB or snapshot
Productionferriorm migrate deployApply pending only
Status checkferriorm migrate statusRead-only
Brownfieldferriorm db pullIntrospection

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:

  1. Detects schema changes by comparing your schema.ferriorm against the current database state.
  2. Generates a new migration.sql file in migrations/<timestamp>_<name>/.
  3. Applies the migration to your development database.
  4. 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_URL environment 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:

  1. Reads the _ferriorm_migrations table to determine which migrations have already been applied.
  2. Verifies checksums of previously applied migrations.
  3. Applies any pending migrations in chronological order.
  4. 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 deploy before 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:

  1. Write a new migration that undoes the changes:

    # In development
    ferriorm migrate dev --name revert_add_posts
    
  2. Edit the generated migration.sql to contain the reverse DDL:

    -- Manual rollback
    DROP TABLE IF EXISTS "posts";
    
  3. 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:

  1. Connects to the database specified by DATABASE_URL.
  2. Reads the database’s tables, columns, indexes, and foreign keys.
  3. Generates (or overwrites) a schema.ferriorm file 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 featureIntrospected?
TablesYes
Columns and typesYes
Primary keysYes
Unique constraintsYes
Foreign keys (relations)Yes
IndexesYes
Default valuesYes
Enums (PostgreSQL)Yes
ViewsNo
TriggersNo
Stored proceduresNo

Type Mapping

Introspection maps database-native types to ferriorm schema types:

PostgreSQL

PostgreSQL typeSchema type
text, varchar, charString
integer, int4Int
bigint, int8BigInt
real, double precisionFloat
booleanBoolean
timestamp, timestamptzDateTime
uuidString
User-defined enumsenum

SQLite

SQLite typeSchema type
TEXTString
INTEGERInt
REALFloat
BOOLEAN, TINYINT(1)Boolean
DATETIME, TIMESTAMPDateTime

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 generate to produce the Rust client.

CLI Reference

The ferriorm CLI manages code generation, migrations, and database introspection.

Global Options

FlagDefaultDescription
--schema <path>schema.ferriormPath to the schema file
--versionPrint version
--helpPrint 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
FlagDefaultDescription
--provider <provider>postgresqlDatabase 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
FlagDefaultDescription
--schema <path>schema.ferriormPath to the schema file

Generated files:

  • mod.rs – module declarations and re-exports
  • client.rsFerriormClient with model accessors
  • enums.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
FlagDefaultDescription
--name <name>(auto)Migration name (used in the directory name)
--snapshotfalseUse snapshot strategy instead of shadow database
--schema <path>schema.ferriormPath to the schema file

What it does:

  1. Diffs the current database state (or snapshot) against schema.ferriorm
  2. Generates migrations/<timestamp>_<name>/migration.sql
  3. Applies the migration to the database
  4. Regenerates the Rust client

Environment variables:

  • DATABASE_URL – required (unless using --snapshot with 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
FlagDefaultDescription
--schema <path>schema.ferriormPath to the schema file

What it does:

  1. Reads the _ferriorm_migrations table
  2. Verifies checksums of applied migrations
  3. Applies pending migrations in order
  4. 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
FlagDefaultDescription
--schema <path>schema.ferriormPath 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
FlagDefaultDescription
--schema <path>schema.ferriormPath to write the schema file

What it does:

  1. Connects to the database
  2. Reads tables, columns, indexes, foreign keys, and enums
  3. Writes a schema.ferriorm file (backs up existing file if present)

Environment variables:

  • DATABASE_URL – required

Environment Variables

VariableRequiredDescription
DATABASE_URLFor most commandsDatabase connection URL
RUST_LOGNoControls log verbosity (e.g., RUST_LOG=debug)

Exit Codes

CodeMeaning
0Success
1Error (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

FeaturePostgreSQLSQLite
Connection
Connection poolingYesYes
PoolConfigYesYes
Auto-detection from URLYesYes
CRUD
create / find / update / deleteYesYes
create_many / update_many / delete_manyYesYes
countYesYes
Filtering
StringFilter (equals, contains, etc.)YesYes
IntFilter / BigIntFilter / FloatFilterYesYes
BoolFilterYesYes
DateTimeFilterYesYes
EnumFilterYesYes
AND / OR / NOT combinatorsYesYes
Case-insensitive mode (QueryMode)Yes (ILIKE)Partial (LIKE is case-insensitive for ASCII in SQLite)
Ordering & Pagination
ORDER BYYesYes
LIMIT / OFFSETYesYes
Relations
Include (batched loading)YesYes
Foreign keysYesYes (if enabled)
Select
Partial column selectionYesYes
Aggregates
MIN / MAXYesYes
AVG / SUMYesYes
Raw SQL
raw_fetch_all / one / optionalYesYes
raw_executeYesYes
Direct pool accessYes (pg_pool())Yes (sqlite_pool())
Transactions
run_transactionYesYes
Auto-rollback on errorYesYes
Migrations
migrate dev (shadow DB)YesYes
migrate dev (snapshot)YesYes
migrate deployYesYes
migrate statusYesYes
db pull (introspection)YesYes
Schema Features
@idYesYes
@uniqueYesYes
@default(uuid())YesYes
@default(now())YesYes
@default(autoincrement())YesYes
@updatedAtYesYes
@relationYesYes
@@indexYesYes
@@unique (composite)YesYes
@@mapYesYes
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 typeRust typePostgreSQL type
StringStringTEXT
Inti32INTEGER
BigInti64BIGINT
Floatf64DOUBLE PRECISION
BooleanboolBOOLEAN
DateTimechrono::DateTime<Utc>TIMESTAMPTZ
enum FooFoo (generated)Custom enum type

SQLite

Schema typeRust typeSQLite type
StringStringTEXT
Inti32INTEGER
BigInti64INTEGER
Floatf64REAL
BooleanboolBOOLEAN
DateTimechrono::DateTime<Utc>TEXT (ISO 8601)
enum FooFoo (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

LimitationAffects
No nested includesBoth
No raw SQL bind helpers (use sqlx directly)Both
Enum columns stored as TEXTSQLite
No array/JSON column typesBoth
No database viewsBoth
No stored proceduresBoth

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::FromRow derive
  • 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 FerriormClient wrapper
  • 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 wrapper
  • SortOrder – ordering enum
  • run_transaction / TransactionClient – transaction support
  • FerriormError – 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_migrations table
  • Introspection: reads database metadata to produce a schema IR

ferriorm-cli

The ferriorm binary. Thin layer over the other crates:

  • init – scaffolds a new project
  • generate – calls parser + core + codegen
  • migrate dev – calls parser + core + migrate + codegen
  • migrate 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.