dbkit
A small, Postgres-first async ORM-ish library with type-level loaded/unloaded relations.
Quick intro
Define models with #[model] and use the generated query and relation APIs:
use ;
async
More examples
Basic query + ordering:
use *;
let users = query
.filter
.order_by
.limit
.all
.await?;
Arithmetic expressions:
let rows = query
.filter
.filter
.order_by
.all
.await?;
Row locking:
let rows = query.for_update.all.await?;
let rows = query.for_update.skip_locked.all.await?;
let rows = query.for_update.nowait.all.await?;
Migrations (optional, via sqlx):
# Cargo.toml
= { = "0.2", = ["migrations"] }
use ;
static MIGRATOR: Migrator = migrate!;
let db = connect.await?;
db.migrate.await?;
dbkit keeps migration execution thin and delegates migration file parsing/running to sqlx.
Count / exists / pagination:
let total = query.count.await?;
let exists = query
.filter
.exists
.await?;
let page = query
.order_by
.paginate
.await?;
println!;
Interval expressions:
let rows = query
.filter
.order_by
.all
.await?;
Insert / update / delete:
let created = insert
.returning_all
.one
.await?
.expect;
let updated = update
.set
.filter
.returning_all
.all
.await?;
let deleted = delete
.filter
.execute
.await?;
Bulk insert:
let inserted = insert_many
.execute
.await?;
assert_eq!;
Insert conflict handling (ON CONFLICT):
let ignored = insert
.on_conflict_do_nothing
.execute
.await?;
let updated = insert
.on_conflict_do_update
.returning_all
.one
.await?;
pgvector embeddings:
CREATE EXTENSION IF NOT EXISTS vector;
let query = new?;
// ANN/index-friendly top-k retrieval
let ann_top_k = query
.filter
.order_by
.limit
.all
.await?;
// True inner product score (semantic ranking), may not use ANN index
let semantic_top_k = query
.filter
.order_by
.limit
.all
.await?;
let high_similarity = query
.filter
.order_by
.all
.await?;
Available vector distance/similarity functions:
dbkit::func::l2_distancedbkit::func::cosine_distancedbkit::func::inner_productdbkit::func::l1_distancedbkit::func::inner_product_distance
Notes:
- Dimension is part of the Rust type (
PgVector<3>,PgVector<1536>, etc.). - Optional embeddings are supported via
Option<PgVector<N>>. cosine_distanceis a distance metric (lower means more similar), so use.lt(...)thresholds.- Operator-based helpers (
l2_distance,cosine_distance,l1_distance,inner_product_distance) are ANN-index compatible forORDER BY ... LIMITwith pgvector indexes. inner_productpreserves true score semantics (higher is better), but as a function expression it may not use pgvector ANN indexes forORDER BY ... LIMIT.inner_product_distanceuses negative inner-product distance, soinner_product > 0.9corresponds toinner_product_distance < -0.9.- For CI, use a Postgres image with pgvector installed (for example
pgvector/pgvector:pg16).
Active model insert / update (change-tracked):
let mut active = new_active;
active.name = "Active".into;
active.email = "active@db.com".into;
let created = active.insert.await?;
let mut active = created.into_active;
active.name = "Updated".into;
let updated = active.update.await?;
Note: into_active() marks fields as unchanged. Updates only include fields you explicitly set
(ActiveValue::Set) or null out (ActiveValue::Null), so existing values aren’t overwritten.
Active model save (insert vs update):
let mut active = new_active;
active.name = "Saved".into;
active.email = "saved@db.com".into;
let created = active.save.await?;
let mut active = created.into_active;
active.name = "Renamed".into;
let updated = active.save.await?;
Eager loading and join filtering:
let users: = query
.with
.all
.await?;
let users: = query
.with
.all
.await?;
let filtered = query
.join
.filter
.distinct
.all
.await?;
Select-in vs joined eager loading:
// selectin = 1 query for parents, then 1 query per relation (per level)
let users: = query
.limit
.with
.all
.await?;
// joined = single SQL query with LEFT JOINs + row decoding
let users: = query
.with
.all
.await?;
Notes:
selectin()is best when you need stable parent pagination (LIMIT/OFFSET) or large child fan-out.joined()is best when you want a single query and you can tolerate row multiplication.- If you filter on joined tables (e.g.
filter(Todo::title.eq("foo"))),joined()will only load the matching child rows because the filter is part of the join query.
Dynamic conditions:
let mut cond = any
.add
.add;
if let Some = cond.into_expr
Column-to-column comparisons:
let changed = query
.filter
.all
.await?;
let retryable = query
.filter
.all
.await?;
Supported column comparison helpers:
eq_colne_colis_distinct_from_colis_not_distinct_from_collt_colle_colgt_colge_col
Stale-embedding predicate (nullable hash vs non-null content hash):
let stale = query
.filter
.all
.await?;
Null-safe hash mismatch (Postgres IS DISTINCT FROM semantics):
let stale = query
.filter
.all
.await?;
Type-level loaded relations:
// `User` is the "bare row" alias: all relations are `NotLoaded`.
// Use the generic model type to require loaded relations in APIs.
// For multiple relations, generic params follow relation-field order.
// In this repo, `Todo` declares `user` then `tags`, so:
// - user loaded, tags not loaded => TodoModel<Option<User>, dbkit::NotLoaded>
// - user loaded, tags loaded => TodoModel<Option<User>, Vec<Tag>>
//
// Nested loaded relations compose too:
// `UserModel<Vec<TodoModel<Option<User>, Vec<Tag>>>>`
// (i.e., users with todos loaded, and each todo has its user + tags loaded)
Lazy loading:
let user = by_id.one.await?.unwrap;
let user = user.load.await?;
println!;
Aggregation and projections:
use *;
let totals: = query
.select_only
.column_as
.column_as
.group_by
.having
.into_model
.all
.await?;
SQL functions and expression-based grouping:
let buckets: = query
.select_only
.column_as
.column_as
.group_by
.into_model
.all
.await?;
Join + aggregation:
let rows: = query
.select_only
.column_as
.column_as
.join
.group_by
.order_by
.into_model
.all
.await?;
Notes:
select_only()switches fromSELECT *to projections viacolumn(...)orcolumn_as(...).- Use
into_model::<T>()to map into a customsqlx::FromRowstruct. SUMover integer columns returnsNUMERICin Postgres; useBigDecimal(or cast) for totals.- Aggregations work across joins; order-by currently expects a real column/expr rather than an alias.
NULL handling with Option<T>:
// assuming `NullableRow { note: Option<String> }`
let row = insert
.returning_all
.one
.await?;
let rows = query
.filter
.all
.await?;
Supported types
Built-in typed query/insert/update bindings currently support:
booli16,i32,i64f32,f64String(and&strwhere string expressions are accepted)uuid::Uuidchrono::NaiveDateTime(TIMESTAMP)chrono::DateTime<chrono::Utc>(TIMESTAMPTZ)chrono::NaiveDate(DATE)chrono::NaiveTime(TIME)dbkit::PgInterval(INTERVAL)serde_json::Value(JSON/JSONB)Vec<String>(TEXT[])dbkit::PgVector<const N: usize>(vector)- custom Postgres enums via
#[derive(dbkit::DbEnum)] Option<T>for nullable columns, whereTis one of the above
Notes:
eq(None)/ne(None)compile toIS NULL/IS NOT NULL.- Interval expressions are available via
dbkit::interval::{days, hours, minutes, seconds}. - Enum binds are emitted as typed placeholders (
$n::your_enum_type) for Postgres enum columns. - For types outside this list, use raw
sqlxqueries or add explicit dbkit support first.
Postgres enums with DbEnum
dbkit supports first-class Postgres enums in models, filters, inserts, updates, and conflict updates.
Define the enum once:
Use it directly in your model:
use model;
Use it in typed query/mutation APIs:
let rows = query
.filter
.filter
.all
.await?;
let updated = update
.set
.set
.filter
.returning_all
.one
.await?;
update
.set
.filter
.execute
.await?;
Upsert with enum columns is also supported:
let row = insert
.on_conflict_do_update
.returning_all
.one
.await?;
Enum naming controls
#[dbkit(type_name = "...")]is required and should match your Postgres enum type.#[dbkit(rename_all = "...")]is optional and supports:snake_caselowercaseUPPERCASESCREAMING_SNAKE_CASE
- Override a single variant with
#[dbkit(rename = "...")]:
Transactions:
let tx = db.begin.await?;
let users = query.all.await?;
tx.commit.await?;
TODOs
- Implement true joined eager loading (single-query join decoding).
- Add aggregation/projection support:
select_only,column_as,group_by,sum,count, and mapping into custom result structs (e.g.,into_model::<T>()for aggregates). - Add SQL function expressions in queries (e.g.,
COALESCE,DATE_TRUNC,UPPER). - Add JSON column support (
serde_json::Value) for insert/update/filter. - Add Postgres array column support (e.g.,
Vec<String>) for insert/update/filter. - Generalize Postgres array support beyond
Vec<String>(e.g.,Vec<i64>,Vec<uuid::Uuid>,Vec<bool>). - Add bulk insert support (multi-row
insert_many). - Add dynamic condition builder helpers (e.g.,
Condition::any/Condition::all). - Allow
order_byon expressions or aliases (e.g.,date_trunc(...),total). - Add
between(a, b)convenience for columns/expressions. - Add locking options:
for_update,skip_locked,nowait. - Add optional helpers:
count(),exists(),paginate(). - Add typed conflict helpers:
on_conflict_do_nothing,on_conflict_do_update. - Add ActiveModel
save()that chooses insert vs update. - Store
#[unique]/#[index]as metadata (even if no-op).
Deviations from spec
load(...)requires an executor argument:user.load(User::todos, &ex).- Relation state sealing is looser than spec (any
Vec<T>/Option<T>satisfies the state trait).