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

afastorm

afastorm is a framework-independent Rust ORM built on SQLx. It provides model metadata, repository operations, migrations, and transaction support without requiring callers to pass a connection pool to every operation.

Features

  • SQLite and PostgreSQL drivers, selected with Cargo features.
  • Derive-based model metadata and an explicit model registry.
  • Typed repositories for CRUD operations and filtered queries.
  • Soft deletion and optimistic version locking.
  • Model operations inside explicit SQLx transactions.
  • Migration generation and execution APIs, plus a command-line tool.
  • A Dialect trait for custom SQL dialects backed by SQLx Any.

The crate does not depend on AFaster. HTTP administration interfaces and framework integration belong in application crates.

Documentation

Getting Started

Add dependencies

The default afastorm features are derive and sqlite. Enable migrate if you want the migration API and CLI:

[dependencies]
afastorm = { version = "0.0.1", features = ["migrate"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
serde_json = "1"

For PostgreSQL, disable defaults and select the needed features:

afastorm = { version = "0.0.1", default-features = false, features = [
    "derive", "postgres", "migrate", "uuid", "chrono"
] }

SQLite and PostgreSQL features may also be enabled together. Optional Rust SQLx types require their matching afastorm feature. See SQLite Types and PostgreSQL Types for the exact mappings. The full feature list and each feature’s dependencies are in Cargo Features.

Connect

Orm::connect chooses a built-in driver from the connection URL. SQLite:

let orm = Orm::connect("sqlite://app.db?mode=rwc").await?;

PostgreSQL:

let orm = Orm::connect("postgres://user:password@127.0.0.1:5432/app").await?;

Keep credentials outside source control; connection strings can be read from environment variables such as DATABASE_URL.

Register and use a model

use afastorm::{ModelRegistry, Orm};

let orm = Orm::connect(database_url)
    .await?
    .with_models(ModelRegistry::new().register::<User>());
let users = orm.repo::<User>()?;

Repositories retain the ORM handle, so operations do not take a pool parameter. Models must be registered before calling repo; registration does not create tables. Use reviewed migrations to provision the schema.

For complete examples, see Models, the CRUD Guide, and Repositories and Queries.

Run tests

SQLite tests:

cargo test --no-default-features --features derive,sqlite,migrate

PostgreSQL integration tests are ignored unless explicitly selected and require a dedicated test database. Set AFASTORM_TEST_DATABASE_URL and run:

cargo test --no-default-features --features derive,postgres,migrate --test orm_postgres -- --ignored

These tests create tables and migration records in the configured database. Never point them at a production or shared database.

Cargo Features

afastorm uses Cargo features to select database drivers, derive support, migrations, and optional SQLx-compatible Rust types. Feature names and dependencies below match the crate’s Cargo.toml.

Defaults

The default features are:

derive
sqlite

For PostgreSQL-only applications, disable defaults and enable postgres instead. When using default-features = false, enable every capability the application needs explicitly.

Feature reference

FeatureDefaultEnablesNotes
deriveYes#[derive(Model)] via afastorm-deriveTurn off if implementing Model manually.
sqliteYesNative SQLx SQLite driverUsed by Orm::connect for sqlite: URLs.
postgresNoNative SQLx PostgreSQL driverUsed by Orm::connect for postgres:// and postgresql:// URLs.
migrateNoMigration API and afastorm CLIThe CLI binary is unavailable without this feature.
uuidNouuid::Uuid model/query supportEnable alongside the selected driver.
chronoNochrono date/time model/query typesProvides NaiveDate, NaiveTime, NaiveDateTime, and supported DateTime variants.
timeNotime crate date/time model/query typesProvides Date, Time, PrimitiveDateTime, and OffsetDateTime.
rust_decimalNorust_decimal::DecimalSQLite stores the decimal text representation; PostgreSQL uses NUMERIC.
bigdecimalNobigdecimal::BigDecimalSQLite stores the decimal text representation; PostgreSQL uses NUMERIC.
ipnetworkNostd::net::IpAddr and ipnetwork::IpNetworkAlso enables the postgres driver feature.
ipnetNostd::net::IpAddr and ipnet::IpNetAlso enables the postgres driver feature.
mac_addressNomac_address::MacAddressAlso enables the postgres driver feature.
bit-vecNobit_vec::BitVecAlso enables the postgres driver feature.

JSON support is always available through serde_json::Value; there is no separate afastorm json feature. On SQLite, JSON model values are serialized as text in a TEXT column and parsed when read. On PostgreSQL, they map to JSONB. See SQLite Types and PostgreSQL Types.

The ipnetwork, ipnet, mac_address, and bit-vec features enable PostgreSQL at compile time because SQLx provides those type integrations for PostgreSQL. Their afastorm SQLite model conversions use text representations, so an application can still connect to SQLite with one of these features enabled. Enabling both sqlite and postgres is supported.

Optional type features enable only the listed built-in afastorm model types; they do not enable every type supported by SQLx. See Models for accepted field types and unsupported custom-field cases.

Examples

Default SQLite application with migrations:

afastorm = { version = "0.0.1", features = ["migrate"] }

PostgreSQL application with derive support, migrations, UUID, and chrono types:

afastorm = { version = "0.0.1", default-features = false, features = [
    "derive", "postgres", "migrate", "uuid", "chrono"
] }

Application using SQLite with Decimal and JSON:

afastorm = { version = "0.0.1", features = ["rust_decimal"] }

Here JSON is available without a feature; rust_decimal adds the optional decimal model type.

Models

#[derive(Model)] generates table metadata, row decoding, value conversion, and typed field proxies. It supports named-field structs and the built-in field types listed below. Models must be registered before a repository can be created; registration does not create or migrate tables.

use afastorm::Model;

#[derive(Debug, Model)]
#[orm(table = "users")]
struct User {
    #[orm(primary_key, auto_increment)]
    id: Option<i64>,
    #[orm(unique)]
    email: String,
    display_name: String,
    metadata: serde_json::Value,
    #[orm(soft_delete)]
    deleted_at: Option<String>,
    #[orm(version)]
    version: i64,
}

#[orm(table = "...")] overrides the inferred table name. Otherwise, the struct name is converted to lowercase snake case without pluralization (BlogPost becomes blog_post). Use #[orm(column = "...")] to override a column name. A model may have at most one primary-key, soft-delete, and version field. auto_increment requires a primary key.

Generated find_by methods

Mark a field with #[orm(find_by)] to generate a single-field repository lookup. A model-level find_by(...) declares a composite lookup; all listed values are combined with AND:

use afastorm::Model;

#[derive(Debug, Model)]
#[orm(find_by(tenant_id, username))]
pub struct User {
    #[orm(primary_key, auto_increment)]
    pub id: Option<i64>,
    #[orm(find_by)]
    pub email: String,
    pub tenant_id: i64,
    pub username: String,
}

The derive generates find_by_email from the field attribute and find_by_tenant_id_username from the model attribute. The model-level attribute takes Rust field identifiers (not database column names); a field renamed with #[orm(column = "...")] still uses its Rust field name here. Unknown field names and duplicate generated method names are compile errors. Repeat model-level find_by(...) attributes to generate additional composite lookup methods.

Generated methods are provided by the model-specific UserFindBy trait on Repository<User>. Import the trait wherever the methods are called:

use crate::models::{User, UserFindBy};

let user = orm.repo::<User>()?
    .find_by_tenant_id_username(7, "ada".to_owned())
    .await?;

Each method returns Result<Option<User>> and returns the first matching row without imposing an order. These methods do not require fields to be unique. For Option<T> model fields, the lookup argument is T; use first_where(User::field.is_null()) to search for SQL NULL.

Generated JSON lookups

Declare fixed JSON paths and the operations to expose with repeatable model-level find_json attributes:

#[derive(Debug, Model)]
#[orm(
    find_json(profile.settings.theme, eq, ne, string_match),
    find_json(profile.settings.age, numeric_compare),
    find_json(profile.settings, contains, has_key, has_any_keys, has_all_keys)
)]
struct User {
    #[orm(primary_key, auto_increment)]
    id: Option<i64>,
    profile: serde_json::Value,
}

The first path segment must name a model field of type serde_json::Value or Option<serde_json::Value>. Later segments are JSON object keys and must be Rust identifiers. Use Rust field names, not #[orm(column = "...")] database column names. A declared profile.settings.theme path generates operation-first methods such as find_json_eq_profile_settings_theme. Unknown/non-JSON fields, invalid paths, and duplicate generated names fail at derive time. The macro validates the Rust model declaration; it cannot inspect an external database schema at compile time. SQLite stores JSON as text and queries it with JSON1; PostgreSQL’s default model mapping is JSONB. Existing tables must have valid JSON/JSONB data in the mapped column.

Generated methods are provided by the model-specific UserFindJson trait, which callers import:

use crate::models::{User, UserFindJson};
use serde_json::json;

let exact = users.find_json_eq_profile_settings_theme(json!("dark")).await?;
let older = users
    .find_json_gt_profile_settings_age(serde_json::Number::from(18))
    .await?;
let has_key = users
    .find_json_has_key_profile_settings("theme".to_owned())
    .await?;

Every method returns Result<Option<User>>, has the same first-match/no-order behavior as first_where, and applies the repository’s soft-delete filter. The declared path selects the JSON value or object to test:

Declaration operationGenerated operation(s)Parameter typeMeaning
eq, neeq, neserde_json::ValueExact JSON value equality/inequality. Object key order is ignored; array order matters.
containscontainsserde_json::ValueRecursive JSON containment. Object key/value subsets and unordered array-element subsets are supported.
has_keyhas_keyStringThe declared path must resolve to an object containing this key.
has_any_keys, has_all_keyssameVec<String>The declared path must resolve to an object containing any/all supplied keys.
numeric_comparegt, gte, lt, lteserde_json::NumberNumeric comparison; non-number or missing paths do not match.
string_matchlike, starts_with, ends_with, string_containsStringString pattern or literal string matching.

Use json!(...) for equality and containment values, including JSON null. JSON null is a value and differs from SQL NULL; an SQL-null JSON column or a missing path does not match these lookups. For like, % and _ are SQL wildcards. The other string methods treat their argument as a literal and are case-sensitive; like follows the backend’s native LIKE behavior.

Built-in SQLite queries require JSON1 support. The built-in SQLite and PostgreSQL dialects implement these operations. A custom Dialect must override render_json_lookup; otherwise a JSON lookup fails with an explicit unsupported-dialect error.

Built-in model field types

The derive macro accepts these base types and Option<T> forms:

  • Always available: bool, i16, i32, i64, f32, f64, String, Vec<u8>, and serde_json::Value.
  • uuid feature: uuid::Uuid.
  • chrono feature: chrono::NaiveDate, NaiveTime, NaiveDateTime, DateTime<chrono::Utc>, and DateTime<chrono::FixedOffset>.
  • time feature: time::Date, Time, PrimitiveDateTime, and OffsetDateTime.
  • rust_decimal feature: rust_decimal::Decimal.
  • bigdecimal feature: bigdecimal::BigDecimal.
  • ipnetwork feature: std::net::IpAddr and ipnetwork::IpNetwork.
  • ipnet feature: std::net::IpAddr and ipnet::IpNet.
  • mac_address feature: mac_address::MacAddress.
  • bit-vec feature: bit_vec::BitVec.

The ipnetwork, ipnet, mac_address, and bit-vec features also enable the PostgreSQL driver. For backend-specific storage, null behavior, and supported operators, see SQLite Types and PostgreSQL Types.

sqlx::FromRow is not required for models: afastorm decodes derived model fields itself. It is still useful for result structs used directly with SQLx.

Custom Rust and database types

Arbitrary newtypes, type aliases with a different Rust type name, and custom SQLx types are not currently accepted as #[derive(Model)] fields. Implementing IntoValue, SQLx Type/Encode/ Decode, or PgArrayElement alone does not register a model field type or its schema mapping. PostgreSQL user-defined enum and composite model fields are not supported.

For a custom query result, use an SQLx FromRow result type with Orm::query_as when its decoding is supported by the selected driver. For a custom persistent model field, use one of the listed built-in types (for example, store an application newtype as String and convert at the application boundary), or implement Model manually. There is currently no derive codec attribute for custom model fields.

PostgreSQL arrays and ranges

With the postgres feature, PgArray<T> and PgRange<T> can be used as model fields and query values. Their supported element types and limitations are listed in PostgreSQL Types. They cannot be used as SQLite model fields.

Soft deletion and timestamps

#[orm(soft_delete)] requires an Option<T> field. Normal repository reads and counts filter out rows whose soft-delete column is non-NULL. delete marks a row deleted, restore clears the marker, and force_delete physically removes it.

The supported soft-delete and automatic timestamp types are bool, i32, i64, f32, f64, String, serde_json::Value, date/time types, and decimal types. i16, UUID, bytes, network, MAC, bit-vector, array, and range fields cannot be used with these attributes. The value is database-generated and backend-specific; see the backend type pages before choosing a storage type.

#[orm(auto_create)] fills a field on insertion. #[orm(auto_update)] fills it on insertion and each repository update. These attributes cannot be combined with primary-key, auto-increment, soft-delete, or version attributes.

Optimistic version locking

#[orm(version)] requires a non-optional i32 or i64. Inserts initialize it to 1; successful repository updates, deletes, and restores advance it. Repository save/update use the model’s current version by default. A stale write returns an optimistic-lock error.

Registration

use afastorm::ModelRegistry;

let registry = ModelRegistry::new()
    .register::<User>()
    .register::<Post>();
let orm = orm.with_models(registry);
let users = orm.repo::<User>()?;

The registry supplies metadata to repositories, schema snapshots, and integrations. Use migrations to create or evolve the database schema.

SQLite Types

Enable the sqlite Cargo feature to use the native SQLx SQLite pool. SQLite uses dynamic typing; the declared column types below are the schema affinities generated by afastorm, not strict validation of every stored value.

Model field mapping

Rust model typeGenerated SQLite column typeNotes
bool, i16, i32, i64INTEGERBooleans are bound/decoded through SQLx.
f32, f64REAL
String, serde_json::ValueTEXTJSON is serialized as JSON text and parsed when read.
Vec<u8>BLOBRaw bytes.
uuid::Uuid (uuid)TEXTSQLx SQLite UUID encoding/decoding is used.
chrono / time date-time typesTEXTSQLx’s SQLite type support is used.
Decimal, BigDecimalTEXTText representation avoids SQLite numeric-affinity precision loss; values are parsed on read.
IP address/network typesTEXTText representation is parsed on read.
MacAddressTEXTText representation is parsed on read.
BitVecTEXTStored as a string of 0 and 1 characters.

Enable each optional type’s matching afastorm feature. ipnetwork, ipnet, mac_address, and bit-vec also enable the PostgreSQL Cargo feature, but their SQLite text handling is available when connecting to SQLite.

PgArray<T> and PgRange<T> are PostgreSQL-only. If included in SQLite model metadata, their columns are declared TEXT; values may be bound as text, but afastorm does not decode them as SQLite model fields. Do not use them in SQLite models or repositories.

SQLite-specific behavior

  • Auto-increment primary keys are declared INTEGER PRIMARY KEY AUTOINCREMENT.
  • For auto_create, auto_update, and soft-delete values, booleans use 0/1, integers use Unix seconds, floats use fractional Unix seconds, text uses CURRENT_TIMESTAMP, and JSON stores the timestamp as a JSON string.
  • SQLite migration generation rejects column type changes and ambiguous/destructive schema changes; write and review those migrations manually.
  • SQLite migrations are run in a transaction per migration by the built-in runner.

For general model usage, see Models. For PostgreSQL’s native type mappings, see PostgreSQL Types.

PostgreSQL Types

Enable the postgres Cargo feature to use the native SQLx PostgreSQL pool. The derive macro supports the common model types in Models, with the PostgreSQL mappings below. This is not a promise to support every type SQLx can encode.

Scalar model field mapping

Rust model typePostgreSQL column type
boolBOOLEAN
i16, i32, i64SMALLINT, INTEGER, BIGINT
f32, f64REAL, DOUBLE PRECISION
StringTEXT
Vec<u8>BYTEA
serde_json::ValueJSONB
uuid::Uuid (uuid)UUID
chrono/time date and time typesDATE, TIME, TIMESTAMP, or TIMESTAMPTZ
rust_decimal::Decimal, bigdecimal::BigDecimalNUMERIC
IpAddr, IpNetwork, IpNetINET
MacAddressMACADDR
BitVecBIT VARYING

Optional Rust values use nullable columns. Date/time, UUID, decimal, network, MAC, and bit-vector types require the corresponding Cargo feature from Models.

sqlx::postgres::types::PgInterval is currently supported as a PgArray element, not as a standalone derived model field. Other SQLx PostgreSQL-specific scalar types are not automatically accepted by #[derive(Model)].

Arrays

Use PgArray<T> for PostgreSQL array columns. It supports these built-in element types:

  • Always available: bool, i8, i16, i32, i64, f32, f64, String, serde_json::Value, and sqlx::postgres::types::PgInterval.
  • With uuid: uuid::Uuid.
  • With chrono: NaiveDate, NaiveTime, NaiveDateTime, and DateTime<Utc> / DateTime<FixedOffset>.
  • With time: Date, Time, PrimitiveDateTime, and OffsetDateTime.
  • With rust_decimal / bigdecimal: the matching decimal type.
  • With ipnetwork: std::net::IpAddr and ipnetwork::IpNetwork.
  • With ipnet: std::net::IpAddr and ipnet::IpNet.
  • With mac_address: mac_address::MacAddress.
  • With bit-vec: bit_vec::BitVec.

PgArray<Option<T>> represents NULL elements inside a non-NULL array. Option<PgArray<T>> represents a nullable array column. Empty arrays are represented by an empty PgArray.

Arrays are encoded as PostgreSQL array text and bound with an explicit element-type cast. Their text representation is intended for the built-in element implementations above. Implementing PgArrayElement alone does not currently make an arbitrary custom PostgreSQL type usable in a derived model or bypass afastorm’s type-name validation.

Ranges

Use PgRange<T> for PostgreSQL ranges. Supported element/range pairs are:

Rust element typePostgreSQL range type
i32INT4RANGE
i64INT8RANGE
chrono::NaiveDate or time::DateDATERANGE
chrono::NaiveDateTime or time::PrimitiveDateTimeTSRANGE
chrono::DateTime<Utc> / DateTime<FixedOffset> or time::OffsetDateTimeTSTZRANGE
rust_decimal::Decimal or bigdecimal::BigDecimalNUMRANGE

Convert a standard Rust range with PgRange::from(1_i32..10) or specify endpoint inclusivity and unbounded endpoints with PgRange::new(Bound, Bound). Option<PgRange<T>> represents a NULL range column. Custom range types are not currently supported.

PostgreSQL-specific behavior and limits

  • Auto-increment primary keys use BIGINT GENERATED BY DEFAULT AS IDENTITY.
  • JSON parameters and columns use JSONB; decimal values use NUMERIC.
  • Network, MAC, and bit-vector fields use PostgreSQL’s native types. SQLite does not provide their PostgreSQL operators or semantics.
  • postgres model metadata carries explicit array/range column types for generated schemas.
  • The schema migration generator’s column parser currently recognizes only INTEGER, BIGINT, REAL, DOUBLE PRECISION, TEXT, BOOLEAN, and JSONB for removal/type-change comparisons. PostgreSQL type-change statements additionally require unchanged constraints and no primary-key or unique constraint. It emits explicit casts; inspect both directions and the data before applying them. Other type changes need manual migrations.
  • PostgreSQL user-defined enum and composite fields are not supported by the derive macro.

See Models for field attributes and Migrations for schema generation details.

Repositories and Queries

Obtain a repository for a registered model:

let users = orm.repo::<User>()?;
let mut user = User {
    id: None,
    email: "ada@example.com".to_owned(),
    display_name: "Ada".to_owned(),
    deleted_at: None,
    version: 0,
};
users.insert(&mut user).await?;
let loaded = users.find_by_id(user.id.expect("generated ID")).await?;

Repository handles retain the ORM connection context. The API includes insert/save/update, find-by-ID, first/all and count queries, soft-delete restoration, and physical delete operations. Model repositories require registration with Orm::with_models.

Model queries

The derive macro generates a ModelField<T> proxy for each field. Comparisons are typed to the field’s Rust type; for an Option<T> field, comparison methods take T. The query builder adds the soft-delete IS NULL condition automatically when the model declares #[orm(soft_delete)].

let statement = users
    .query()
    .filter(User::email.like(String::from("%@example.com")))
    .filter(User::id.gte(1_i64))
    .order_by(User::email.asc())
    .limit(20)
    .offset(0)
    .build()?;
let page: Vec<User> = users.fetch_all(&statement).await?;

Comparisons are eq, ne, gt, gte, lt, lte, and like. Conditions compose with and and or; fields also provide is_null, is_not_null, and is_true. Ordering uses asc() and desc(). A condition’s Rust type does not guarantee that the selected database supports the operator for that column type.

Use Field::new("column") when you need an untyped condition for a known column. select and order_by_name accept string column names. Identifiers are validated when the statement is built; runtime values are bound separately.

Raw SQL

Use Orm::query or query_as for developer-authored SQL outside the repository query builder. Bind runtime values rather than interpolating them:

let matching: Vec<User> = orm
    .query_as("SELECT id, email, display_name, deleted_at, version FROM users WHERE email = ?")
    .bind("ada@example.com")
    .fetch_all()
    .await?;

The query string is a static SQL string, while bind values are supplied separately. The ? placeholder is rewritten for PostgreSQL; PostgreSQL JSON operators are preserved.

For PostgreSQL arrays/ranges, see PostgreSQL Types. For SQLite-specific mappings, see SQLite Types.

CRUD Guide

This guide covers the model repository methods currently provided by afastorm, how to find rows, compose filters, and handle updates and deletes. It uses SQLite in the connection example; the repository API is the same for PostgreSQL.

Define a model and connect

use afastorm::{Model, ModelRegistry, Orm};
use serde_json::Value;

#[derive(Debug, Model)]
#[orm(table = "users")]
struct User {
    #[orm(primary_key, auto_increment)]
    id: Option<i64>,
    #[orm(unique)]
    email: String,
    display_name: String,
    active: bool,
    profile: Value,
    #[orm(auto_create)]
    created_at: String,
    #[orm(soft_delete)]
    deleted_at: Option<i64>,
    #[orm(version)]
    version: i64,
}

let orm = Orm::connect("sqlite://app.db?mode=rwc")
    .await?
    .with_models(ModelRegistry::new().register::<User>());
let users = orm.repo::<User>()?;

Models must be registered before calling repo. Registration does not create tables; prefer reviewed migrations for application schema. Repository::create_table() is available for tests and simple development setups.

auto_create, soft_delete, and version behavior is described in Models. Field storage and JSON behavior are backend-specific; see SQLite Types and PostgreSQL Types.

Method overview

MethodResultBehavior
insert(&mut model)Result<u64>Inserts a row; writes generated primary-key values back into the model.
save(&mut model)Result<u64>Inserts when the primary-key value is absent; otherwise updates.
update(&mut model, expected_version)Result<u64>Updates mutable fields by primary key; uses the model’s version unless explicitly overridden.
find_by_id(id)Result<Option<M>>Returns the row for its primary key, or None.
generated find_by_*Result<Option<M>>Field/model find_by attributes generate typed lookups; see Models.
generated find_json_*Result<Option<M>>Model find_json attributes generate typed JSON-path lookups; see Models.
first_where(condition)Result<Option<M>>Returns the first matching row, or None.
all()Result<Vec<M>>Loads all matching rows; use query() for filtering/pagination.
count() / count_where(condition)Result<i64>Counts all rows or rows matching a condition.
delete(id)Result<u64>Soft-deletes when configured; otherwise physically deletes.
restore(id)Result<u64>Clears a soft-delete marker; requires a soft-delete field.
force_delete(id)Result<u64>Physically deletes, even for a soft-delete model.
delete_where(condition)Result<u64>Physically deletes every matching row; it does not apply soft deletion.
delete_versioned(id, version) / restore_versioned(id, version)Result<u64>Version-checked delete/restore; conflict returns Error::OptimisticLock.

Methods returning u64 report affected rows, except insert with a generated key returns 1 after a successful insert. An affected-row count of 0 means no row was changed, unless a version-checked operation reports an optimistic-lock error.

Create: insert and save

insert always inserts, regardless of whether a primary-key value is already present. An auto-increment key is set on the input model:

let mut user = User {
    id: None,
    email: "ada@example.com".to_owned(),
    display_name: "Ada".to_owned(),
    active: true,
    profile: serde_json::json!({ "role": "author" }),
    created_at: String::new(),
    deleted_at: None,
    version: 0,
};

users.insert(&mut user).await?;
let id = user.id.expect("database-generated ID");
assert_eq!(user.version, 1);

When a model has auto_create/auto_update fields, the insert expression is generated by the dialect and the caller-provided value is ignored. Reload the model if you need the exact value written by the database.

save chooses insert versus update based on whether the model has a primary-key value:

users.save(&mut new_user).await?;       // primary key absent: insert
users.save(&mut existing_user).await?;  // primary key present: update

It does not first query whether a row with that key exists. A present key selects the update path; it is not an upsert. save requires model primary-key metadata.

Read: find_by_*, find_by_id, first_where, and all

find_by_id looks up the declared primary key and returns an optional model:

let found: Option<User> = users.find_by_id(id).await?;
if let Some(user) = found {
    println!("{}", user.email);
}

For generated field and composite lookups, annotate the model with #[orm(find_by)] attributes and import its generated extension trait. A field-level attribute creates a lookup for that field; a model-level attribute creates a lookup whose fields are combined with AND:

#[derive(Model)]
#[orm(find_by(tenant_id, username))]
struct User {
    #[orm(primary_key, auto_increment)]
    id: Option<i64>,
    #[orm(find_by)]
    email: String,
    tenant_id: i64,
    username: String,
}

use crate::models::{User, UserFindBy};

let by_email = users.find_by_email("ada@example.com".to_owned()).await?;
let by_tenant_and_name = users
    .find_by_tenant_id_username(7, "ada".to_owned())
    .await?;

This generates find_by_email and find_by_tenant_id_username; each returns Result<Option<User>>. See Generated find_by methods for naming rules and compile-time checks.

For fixed JSON paths and JSON-specific operations, declare model-level #[orm(find_json(...))] attributes and import the generated JSON extension trait:

#[derive(Model)]
#[orm(
    find_json(profile.settings.theme, eq, string_match),
    find_json(profile.settings, contains)
)]
struct User {
    #[orm(primary_key, auto_increment)]
    id: Option<i64>,
    profile: serde_json::Value,
}

use crate::models::{User, UserFindJson};
use serde_json::json;

let by_theme = users
    .find_json_eq_profile_settings_theme(json!("dark"))
    .await?;
let by_theme_prefix = users
    .find_json_starts_with_profile_settings_theme("da".to_owned())
    .await?;
let with_published_settings = users
    .find_json_contains_profile_settings(json!({"published": true}))
    .await?;

The declared operations determine which methods are generated and their parameter types. See Generated JSON lookups for the full operation list and behavior.

first_where takes a Condition. It returns None when no row matches:

let found = users
    .first_where(User::email.eq("ada@example.com".to_owned()))
    .await?;

let active_user = users
    .first_where(User::active.is_true())
    .await?;

first_where applies LIMIT 1 but does not add an order. If several rows match and which row is returned matters, build an ordered query with query() instead.

all loads every row returned by the repository query:

let active_users = users
    .query()
    .filter(User::active.is_true())
    .order_by(User::email.asc())
    .build()?;
let active_users: Vec<User> = users.fetch_all(&active_users).await?;

Unlike first_where, all() itself takes no filter or pagination arguments. Normal repository reads (find_by_id, first_where, all, and query) automatically exclude soft-deleted rows.

Filter and count

The derive macro creates typed field proxies. Their comparison methods take the field’s Rust type; for an Option<T> field, compare using a T value. Supported comparisons are eq, ne, gt, gte, lt, lte, and like.

let condition = User::active
    .is_true()
    .and(User::email.like("%@example.com".to_owned()))
    .and(User::id.gte(10_i64));

let query = users
    .query()
    .filter(condition)
    .order_by(User::created_at.desc())
    .limit(25)
    .offset(0)
    .build()?;
let page: Vec<User> = users.fetch_all(&query).await?;

let total_active = users.count().await?;
let matching_active = users
    .count_where(User::active.is_true())
    .await?;

Conditions compose with and and or. Use is_null and is_not_null for nullable fields:

let not_deleted = User::deleted_at.is_null();
let has_deleted_at = User::deleted_at.is_not_null();
let no_email_or_inactive = User::email
    .eq("".to_owned())
    .or(User::active.eq(false));

count and count_where also exclude soft-deleted rows when the model declares #[orm(soft_delete)]. Conditions are parameterized; values are not interpolated into SQL.

For a dynamic column name, use the untyped Field API:

use afastorm::Field;

let statement = users
    .query()
    .filter(Field::new("email").like("%@example.com"))
    .build()?;

Unlike User::email, Field::new does not provide compile-time field/value type checking. Identifiers are validated when the statement is built. Only use column names controlled by the application, not arbitrary client input.

Update

Mutate a loaded model and call save or update:

let mut user = users.find_by_id(id).await?.expect("user exists");
user.display_name = "Ada Lovelace".to_owned();

// The model's current version is used automatically when #[orm(version)] is present.
users.save(&mut user).await?;
assert_eq!(user.version, 2);

update requires a primary key and updates mutable model fields. Primary-key, auto-increment, soft-delete, version, and auto-create fields are not written as ordinary values; auto-update fields are generated by the dialect. If a version field exists, the model’s version is used by default. Pass Some(expected_version) to explicitly supply the expected version, or None to use the model value:

users.update(&mut user, Some(2)).await?;

A stale version returns Error::OptimisticLock. On successful versioned update, afastorm increments the version in the in-memory model. Without a version field, zero affected rows are returned as Ok(0).

Delete, soft-delete, and restore

For a model with #[orm(soft_delete)], delete(id) writes the current-time marker and normal reads stop returning that row. If the model has a version field, delete also increments it:

users.delete(id).await?;
assert!(users.find_by_id(id).await?.is_none());

For optimistic locking, provide the expected version:

users.delete_versioned(id, 2).await?;
users.restore_versioned(id, 3).await?;

A stale version produces Error::OptimisticLock. restore / restore_versioned require a soft-delete field and only affect rows that are currently deleted. Successful restore clears the marker and increments the version when one is configured.

force_delete(id) always issues a physical DELETE, bypassing soft deletion. delete_where also physically deletes rows and should be used with care:

let deleted = users
    .delete_where(User::active.eq(false))
    .await?;

There is no soft-delete bulk-delete variant. To soft-delete matching rows, select them and call delete for each ID (preferably inside a transaction when the set must be handled atomically).

Transactions

For multiple related repository operations, use Orm::transaction; the callback commits on success and rolls back on error. Obtain each repository from the transaction so it uses the same connection:

orm.transaction(async |transaction| {
    let mut user = new_user;
    transaction.repo::<User>()?.insert(&mut user).await?;
    // Other operations through transaction.repo::<OtherModel>() go here.
    Ok(())
}).await?;

See Transactions for manual commit/rollback and error behavior.

Raw SQL and projections

For operations not covered by the repository API, use Orm::query or query_as and bind parameters:

let found: Option<User> = orm
    .query_as(
        "SELECT id, email, display_name, active, profile, created_at, deleted_at, version \
         FROM users WHERE email = ?",
    )
    .bind("ada@example.com")
    .fetch_optional()
    .await?;

The query string is developer-authored SQL. fetch_optional returns None for zero rows, fetch_one errors if there is no row, and fetch_all returns a vector. Raw SQL does not automatically apply the repository’s soft-delete filter. Use SQLx FromRow for custom projection types, or use the derive model decoder when selecting every field required by that model.

For custom query execution and typed field behavior, see Repositories and Queries.

Transactions

Orm::transaction runs an asynchronous callback with an OrmTransaction. The callback receives model repositories from the transaction, so all operations use the same connection.

let user_id = orm
    .transaction(async |transaction| {
        let mut user = User {
            id: None,
            email: "ada@example.com".into(),
            display_name: "Ada".into(),
            deleted_at: None,
            version: 0,
        };
        transaction.repo::<User>()?.insert(&mut user).await?;

        let mut profile = Profile {
            id: None,
            user_id: user.id.expect("generated id"),
            biography: "Mathematician".into(),
        };
        transaction.repo::<Profile>()?.insert(&mut profile).await?;

        Ok(user.id.expect("generated id"))
    })
    .await?;

The transaction commits when the callback returns Ok. If the callback returns an error, the ORM rolls back and returns the error. A rollback failure is reported together with the callback error.

For explicit control, call begin, perform operations through transaction.repo::<Model>(), then call commit or rollback:

let mut transaction = orm.begin().await?;
transaction.repo::<User>()?.delete(user_id).await?;
transaction.commit().await?;

Repository handles created from the root Orm are not transaction-bound. Inside a transaction callback or manual transaction, obtain repositories from the transaction object.

Migrations

Enable the migrate feature for the migration API and afastorm CLI. Migration files use paired names:

<version>_<name>.up.sql
<version>_<name>.down.sql

Versions must be unique within the migration directory. Keep both files under source control and review the SQL before applying it.

Create manual migrations

cargo run --no-default-features --features derive,sqlite,migrate --bin afastorm -- \
  --dir migrations makemigrations add_user_bio

This creates empty up/down SQL templates; it does not inspect the database or model code.

Generate a migration from model metadata

Export the current registered model schema from an application helper:

orm.write_schema_snapshot("schema.json")?;

Then pass the exported snapshot to the CLI:

cargo run --no-default-features --features derive,sqlite,migrate --bin afastorm -- \
  --dir migrations --schema schema.json makemigrations add_user_bio

The generator compares the supplied snapshot with migrations/schema.json from the previous generation. If there is no prior snapshot, it generates table-creation SQL for all models in the new snapshot. After generating a migration, it writes the new snapshot to migrations/schema.json. Keep that file with the migration history. Generation does not apply the migration to a database.

The generator supports new tables, nullable non-unique column additions, and removal of some nullable non-unique columns without other constraints. Its column-definition parser is limited: for SQLite, removal is supported for generated INTEGER, REAL, and TEXT columns; for PostgreSQL, removal and type-change comparisons currently recognize INTEGER, BIGINT, REAL, DOUBLE PRECISION, TEXT, BOOLEAN, and JSONB. Other declarations require a reviewed manual migration even if the column is nullable.

The generator rejects table removals, primary-key or unique changes, non-null column additions, SQLite type changes, and other ambiguous/destructive changes. PostgreSQL type changes additionally require an unchanged constraint set and no primary-key or unique constraint. Generated PostgreSQL type changes use explicit casts in both directions; inspect the data and SQL for failures or information loss. A down migration that restores a removed nullable column cannot restore values that were deleted with it.

Apply, inspect, and roll back

cargo run --no-default-features --features derive,sqlite,migrate --bin afastorm -- \
  --database-url 'sqlite://app.db?mode=rwc' --dir migrations migrate

cargo run --no-default-features --features derive,sqlite,migrate --bin afastorm -- \
  --database-url 'sqlite://app.db?mode=rwc' status

cargo run --no-default-features --features derive,sqlite,migrate --bin afastorm -- \
  --database-url 'sqlite://app.db?mode=rwc' --dir migrations rollback VERSION

Database commands accept --database-url URL or DATABASE_URL. status creates the _afastorm_migrations tracking table if needed. The standalone CLI does not load application configuration files and rejects --config.

The runner applies unapplied migrations in filename/version order and records each successful version. SQLite and PostgreSQL migrations run in a transaction per migration, including the tracking record. Rollback runs the matching .down.sql file transactionally and removes its tracking record. A missing down file or failed statement is reported as an error.

For framework-specific command integration, use the public migration API. See SQLite Types and PostgreSQL Types for backend-specific schema behavior.

Admin Metadata

The Model derive macro stores optional admin labels and capability flags in model and field metadata. afastorm does not generate or serve management pages, perform authorization, or enforce these flags in repository methods. An application integration is responsible for interpreting the metadata and enforcing access control on the server.

#[derive(Model)]
#[orm(
    table = "posts",
    admin(label = "Posts", list, detail, create, update, delete)
)]
struct Post {
    #[orm(primary_key, auto_increment, admin(label = "ID", list, detail, readonly))]
    id: Option<i64>,
    #[orm(admin(label = "Title", list, detail, create, update, search))]
    title: String,
    #[orm(admin(label = "Body", detail, create, update))]
    body: String,
    #[orm(soft_delete, admin(hidden))]
    deleted_at: Option<String>,
    #[orm(version, admin(label = "Version", detail, readonly))]
    version: i64,
}

Model-level options are label, list, detail, create, update, and delete. Field-level options are label, list, detail, create, update, filter, search, readonly, and hidden. These are metadata booleans/labels only; fields are not automatically included in a UI, and hidden/readonly are not security controls.

Read metadata from M::INFO or the registered models returned by the ORM. A web integration should validate submitted field names and values, enforce user permissions independently, and use the repository/query APIs rather than treating client-side controls as authorization.

Custom Dialects

SqliteDialect and PostgresDialect are used with afastorm’s native SQLx pools when their features are enabled. A custom Dialect can be used with Orm::connect_with_dialect, which connects through SQLx Any; the target database must be supported by SQLx Any.

use afastorm::{Dialect, FieldType, Orm, Result, SqliteDialect};

#[derive(Clone, Copy)]
struct CustomSqliteDialect;

impl Dialect for CustomSqliteDialect {
    fn name(&self) -> &'static str {
        "sqlite"
    }

    fn quote_identifier(&self, identifier: &str) -> Result<String> {
        SqliteDialect.quote_identifier(identifier)
    }

    fn placeholder(&self, index: usize) -> String {
        SqliteDialect.placeholder(index)
    }

    fn column_type(&self, field_type: FieldType) -> &'static str {
        SqliteDialect.column_type(field_type)
    }

    fn auto_increment_primary_key(&self) -> &'static str {
        SqliteDialect.auto_increment_primary_key()
    }

    fn boolean_default(&self, value: bool) -> &'static str {
        SqliteDialect.boolean_default(value)
    }

    fn current_value(&self, field_type: FieldType) -> &'static str {
        SqliteDialect.current_value(field_type)
    }
}

let orm = Orm::connect_with_dialect("sqlite::memory:", CustomSqliteDialect, 1).await?;

In this example, the dialect delegates SQLite syntax and uses SQLx Any; it is intended to show the integration point, not to add a new database backend.

The trait controls identifier quoting, placeholders, column types, auto-increment primary keys, boolean defaults, and current-value expressions. The default field column mapping uses column_type; PostgreSQL-specific per-field array/range schema metadata is handled by the built-in PostgreSQL dialect.

Any has driver and type-encoding limitations. Confirm support for the URL scheme, SQL syntax, RETURNING, transaction behavior, and every model type with the target SQLx driver. Implement current_value with a type-compatible expression if models use auto_create, auto_update, or soft deletion. Custom dialect support does not add custom model field codecs; see Models.

Releasing

The crate and its derive macro are published from this repository by GitHub Actions.

  1. Update version in Cargo.toml. If the derive macro changes, update its package version in afastorm-derive/Cargo.toml and the afastorm-derive dependency requirement in the main manifest.
  2. Update CHANGELOG.md and run the CI checks.
  3. Configure the repository Actions secret CARGO_REGISTRY_TOKEN with a crates.io publish token.
  4. Create and push a tag matching the v<version> pattern, such as v0.0.1.

The release workflow verifies the tag against the main crate version, publishes the derive dependency if that version is not already available, then publishes afastorm. The docs workflow builds the English afastorm book for GitHub Pages.

The minimum supported Rust version (MSRV) is 1.94, matching the SQLx 0.9 dependency. CI tests both Rust 1.94 and stable.

Do not reuse a published version. Crates.io releases are immutable; publish a new version for every change to either crate.

Applications that integrate afastorm through a Git/path dependency should switch to the published crates.io version after the release. The AFaster repository consumes afastorm by its published version and must update its lockfile after the first crate release.