Skip to content

Repository files navigation

dbkit

A small, Postgres-first async ORM-ish library with type-level loaded/unloaded relations.

Usage

use dbkit::prelude::*;

Quick Intro

Define models with #[model] and use the generated query and relation APIs:

use dbkit::{model, Database};

#[model(table = "users")]
#[derive(Debug)]
struct User {
    #[key]
    #[autoincrement]
    id: i64,
    name: String,
    #[unique]
    email: String,
    #[has_many]
    todos: dbkit::HasMany<Todo>,
}

#[model(table = "todos")]
#[derive(Debug)]
struct Todo {
    #[key]
    id: i64,
    #[index]
    user_id: i64,
    #[belongs_to(key = user_id, references = id)]
    user: dbkit::BelongsTo<User>,
    title: String,
}

#[tokio::main]
async fn main() -> Result<(), dbkit::Error> {
    let db = Database::connect("postgres://...").await?;

    let users: Vec<User<Vec<Todo>>> = User::query()
        .filter(User::email.ilike("%@example.com"))
        .with(User::todos.selectin())
        .all(&db)
        .await?;

    for user in &users {
        for todo in &user.todos {
            println!("{} / {}", user.name, todo.title);
        }
    }

    Ok(())
}

The loaded graph is part of the Rust type. If a relation is not requested, that field stays dbkit::NotLoaded; once you add .with(...), normal field access is available at the matching depth.

If a Rust field needs a different DB column name, use #[dbkit(column = "...")]:

#[dbkit(column = "type")]
type_: String,

Required and nullable model fields stay distinct throughout queries and mutations. None works only with nullable fields, and SQL functions keep nullable results nullable when PostgreSQL can return NULL.

Common Mutations

Insert with the generated insert type:

let created = User::insert(UserInsert {
    name: "Alex".to_string(),
    email: "a@b.com".to_string(),
})
.returning_all()
.one(&db)
.await?
.expect("inserted");

Update one loaded row with into_active():

let mut active = created.into_active();
active.name = "Renamed".into();
let updated = active.update(&db).await?;

Delete one loaded row with the active model:

let deleted = created.into_active().delete(&db).await?;

Use query-builder updates/deletes for bulk or conditional mutations.

More Docs

TODOs

  • Implement true joined eager loading (single-query join decoding).
  • Add aggregation/projection support: select_only, column_as, group_by, sum, count, min, max, filtered aggregates, 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_by on 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 active model 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).

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages