SQL access toolkit · Rust 2024

A SQL toolkit for Rust that never hides the SQL.

Per-dialect query builders, a driver-free execution layer, models and factories generated from your live schema, and hand-written .sql files compiled into typed code. Four layers — take one, or take all of them.

pre-1.00.1.1 is the current release. The API will change, and nothing here has run in production. The four layers are implemented and tested against real PostgreSQL, MySQL and SQLite.

keelsonpublished 2026-08-04
version
0.1.1
license
MIT
msrv
1.90
engines
PostgreSQL · MySQL · SQLite

One statement, drawn twice

Rustwhat you write
// A filter decided at runtime: `Option<M>` is a mod, and// `None` contributes nothing.let only_adults = Some(select::where_(quote("age").gte(arg(21))));let q = sqlite::select((    select::columns((quote("id"), quote("name")),    select::from(quote("crew")),    only_adults,    select::where_("name IS NOT NULL"), // raw SQL, same tuple    select::order_by(quote("id")),));// Building is synchronous and driver-free.let (sql, args) = q.build()?;
SQLwhat runs
SELECT "id", "name"FROM "crew"WHERE ("age" >= ?1) AND name IS NOT NULLORDER BY "id"
args[21]
ST 02the four layers

Four layers. Each depends only on the ones below it.

Which is why stopping at Layer 1 is a legitimate way to use keelson — and why adopting the next one never asks you to give up the last. The layer numbering is bob's, and so is the idea that you can stop at any of them.

  1. 01

    Query builder

    Statement starters — select, insert, update, delete, merge — filled in by mods: values that modify a statement. A tuple of mods is itself one mod, so composition never runs out of arity, and a raw &str is an expression anywhere one is accepted.

    • keelson-core
    • keelson-psql
    • keelson-mysql
    • keelson-sqlite
    keelson-psql
    let q = psql::select((
        select::columns((quote("id"), quote("name"))),
        select::from(quote("crew")),
        maybe_filter,                  // Option<M> is a mod; None adds nothing
        select::where_("name IS NOT NULL"),  // raw &str, same tuple
    ));
    
    let (sql, args) = q.build()?;      // synchronous, driver-free
  2. 02

    Execution

    An object-safe Executor — a pool, a connection, a transaction, a &dyn Executor — and the Execute verbs on every query. Transactions are closures with no lifetime to thread: within commits on Ok and rolls back on Err, and the closure gets a &Transaction it cannot commit twice or forget to commit.

    • keelson-exec
    • keelson-sqlx
    keelson-exec
    let crew: Vec<Crew> = q.fetch_all(db).await?;
    
    // A savepoint is a closure too: Ok releases it, Err rolls back
    // to it, and the outer transaction lives on.
    db.within(async |tx| {
        insert_tag(tx, "kept").await?;
        let _ = tx.savepoint(async |tx| bulk_load(tx).await).await;
        insert_tag(tx, "also kept").await?;
        Ok::<_, ExecError>(())
    })
    .await?;
  3. 03

    Models

    A typed shell per table or view. users::age() is one Column<i64> that is the expression, the filter origin and the alias carrier at once. A three-state Setter tells “leave the column alone” apart from “write NULL”. Relations load by same-query preload or by chained, batched then-loads — never lazily.

    • keelson-models
    • keelson-factory
    generated · committed · steppable
    let adults = users::table()
        .query((
            users::age().gte(21),      // typed: .gte("x") will not compile
            select::where_(r#""users"."name" <> 'bob'"#),  // raw, same tuple
            select::order_by(users::age()).desc(),         // Layer 1 mod
            users::then_load::posts(), // one batched IN query per level
        ))
        .all(db)
        .await?;
  4. 04

    Generation

    A CLI, not a proc macro: it introspects a live schema and writes .rs files you commit, diff and step through. It also compiles hand-written .sql files into typed modules — and each query has two faces, one that runs the file's own SQL and one that merges its clauses flat into a model query instead of nesting as a sub-select.

    • keelson-gen
    migrate → regenerate → compile
    cargo install keelson-gen
    keelson-gen --config keelson.toml --url "$DATABASE_URL"
ST 03no shared ast

One intent. Three grammars. Written three times.

“Insert a user; if that email is already taken, update the existing row instead.” Your statement type is keelson_psql::InsertQuery, or keelson_mysql::InsertQuery, or keelson_sqlite::InsertQuery — and each is written to its own engine's reference manual.

keelson_psql::InsertQuery$n placeholders · "double quotes" · EXCLUDED · RETURNING
what you write
psql::insert((
    insert::into(quote("users")).columns(["email", "name"]),
    insert::values((arg("ada@example.com"), arg("Ada"))),
    insert::on_conflict(quote("email"))
        .do_update(insert::set_excluded(["name"])),
    // PostgreSQL hands the written row back, so a write is one round trip.
    insert::returning((quote("id"), quote("name"))),
))
what runs
INSERT INTO "users" ("email", "name") VALUES ($1, $2)
ON CONFLICT ("email") DO UPDATE SET "name" = EXCLUDED."name"
RETURNING "id", "name"

The cost is real. You cannot build one statement and render it for whichever database the customer brought — keelson makes that impossible by construction. What it buys is constructs a common denominator cannot express, and the guarantee that what compiles is grammatical for the engine you compiled it for.

ST 04what holds everywhere

Four promises that hold across every layer.

Not features — properties. They are the reason the generated code is worth committing and the reason a refusal is worth trusting.

  • grammar

    Each dialect is written to its own grammar.

    There is no shared AST that every database is squeezed through. keelson-psql's SELECT is shaped by PostgreSQL's own reference manual, keelson-sqlite's by SQLite's railroad diagrams, keelson-mysql's by MySQL's. Clauses that genuinely coincide are shared through traits — but a construct only one engine has is only on that engine, and a construct it lacks is not offered.

    docs/sql-rendering.md
  • proof

    Everything that renders SQL is judged by a real parser.

    Expected SQL in the tests is derived from the official grammar and checked with the engine's own parser — PostgreSQL's libpg_query, SQLite's lemon grammar — and, in the engine tier, PREPAREd by a real containerised server. A coverage gate then proves that every construct the library declares was actually exercised.

    docs/testing-tiers.md
  • refusal

    Anything unsupported is an explicit error.

    No silent fallback, no plausible guess. If MySQL cannot honour a read-only transaction the way you asked, you get a refusal that names the engine's rule — not a downgrade. SQLite cannot run READ COMMITTED, so it refuses that isolation level rather than pretending it applied.

    docs/execution.md
  • legibility

    Generated code is meant to be read.

    keelson-gen is a CLI that writes .rs files you commit and diff, not a proc macro that expands somewhere you cannot step through. Every nullability decision in a generated query module is written into the file as the numbered rule that made it.

    examples/
ST 05the neighbourhood

The Rust SQL ecosystem is good. Here is when to use it instead.

keelson is not a replacement for any of these, and for most of them there is a clear question that picks the other one. Those questions are worth asking before you pick this.

  • query! verifies your SQL and its types against a live database at compile time — a stronger guarantee than anything keelson offers. It ships migrations, and it is mature and enormously deployed. keelson stands on it: keelson-sqlx is a backend over its drivers.

    you want the database itself to check your SQL, and you are content writing each statement out.

  • the strongest static guarantees in the ecosystem: its schema DSL makes a column/table mismatch a type error with no database in the loop, and its migrations are excellent.

    you want the compiler to reject a malformed query at all costs.

  • the ORM things: ActiveModel change tracking, entity relations, sea-orm-cli codegen, a large documented surface. keelson has no ActiveModel and no entity graph.

    you want an ORM. SeaORM is the mature one.

  • the one thing keelson refuses: build one AST and render it for MySQL, PostgreSQL or SQLite as needed. keelson's per-dialect crates make that impossible by construction.

    your product must run on whichever database the customer brought.

  • within PostgreSQL, a sharper tool than keelson's Layer 4: it derives its types from the server's own prepared-statement description, so its nullability is the server's answer rather than an analysis.

    you are PostgreSQL-only and want the server to settle nullability.

Pick keelson if you want to write SQL that looks like the SQL of your specific database, compose it from values rather than strings, and have the boring parts — models, factories, row mapping — generated from the schema you already migrated. And you can live with a young library.

ST 06getting started

One dependency line, with the engine and the layers chosen by feature.

keelson is a facade: it re-exports the individual crates and nothing else, so keelson::psql is keelson_psql. Depending on those crates directly is equally supported, and is what generated code does.

Cargo.toml
[dependencies]
keelson = { version = "0.1.1", features = ["sqlx-psql", "models", "macros"] }
generation is a separate, build-time tool
cargo install keelson-gen
keelson-gen --config keelson.toml --url "$DATABASE_URL"
fourteen runnable programs, one topic each
cargo run -p keelson-examples --example builder_basics
./scripts/run-examples.sh   # all fourteen

The examples need no server — SQLite in a temporary file — and each asserts its own output, so CI runs them. The directory is also a worked application: a schema, a keelson.toml, the committed generated code, hand-written hooks, and a test that fails when the generated files stop matching their sources.

Out of scope

keelson is DML-only. It does not emit DDL, diff schemas, or track migration history. Use a migration tool you already trust, then re-run keelson-gen. The loop is migrate → regenerate → compile, where the compiler is what tells you which call sites the schema change broke — schema migration is a solved problem whose value is in history tracking and team workflow, none of which a query builder improves by owning.

Features

none are on by default
psql · mysql · sqlite
the dialect crate — Layer 1
exec
Layer 2 traits, no driver
sqlx-psql · sqlx-mysql · sqlx-sqlite
Layer 2 with a driver
models
Layer 3 — typed columns, setters, relations
factory
test data: factories, sequences, a seedable faker
macros
derive(Bind), derive(FromRow), each dialect's sql!
chrono · uuid · decimal · json
typed columns beyond the scalars
tracing
per-statement spans in the execution funnel