for a reader with nothing running yet
Tutorial
In one sitting: add the dependency, build a statement, read the SQL it produced, run it against a real database, and decide a filter at run time. Nothing is stubbed and nothing is elided.
Everything below was compiled and executed against the published keelson 0.1.1 before it was written down, and the SQL shown is the process's own output rather than a transcription. That is the same rule the library holds itself to, and it is not decoration: writing this page is how a bug in #[derive(FromRow)] was found in 0.1.0.
Add one dependency
keelson is a facade: it re-exports the individual crates behind features, and no feature is on by default. There is no dialect that could be the right default and no driver that could be, so you name them.
[dependencies]
keelson = { version = "0.1.1", features = ["sqlx-sqlite", "macros"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }sqlx-sqlite is three things at once: the SQLite dialect (Layer 1), the execution traits (Layer 2), and the sqlx driver behind them. macros brings #[derive(FromRow)], which step 03 needs. Swap in sqlx-psql or sqlx-mysql and everything below still reads the same — except the SQL, which is the point of the whole library.
Build a statement, and look at it
A statement is a starter — select, insert, update, delete, merge — plus mods: plain values that modify it. They go in a tuple, and a tuple of mods is itself one mod, so nesting never runs out of arity.
use keelson::sqlite::{self, Query as _, quote, select};
let q = sqlite::select((
select::columns((quote("id"), quote("name"))),
select::from(quote("crew")),
));
let (sql, args) = q.build()?;build() is synchronous and takes no connection. It hands back the SQL string and the bound arguments, and you can print them:
SELECT "id", "name" FROM "crew"
-- args: []build() and read it. Nothing is hidden behind an execution step, and this works for generated model queries too — they are Layer 1 statements underneath.Run it, and map the rows
#[derive(FromRow)] reads one column per field, by name. The verbs — fetch_all, fetch_one, fetch_optional, fetch_scalar, fetch_scalars, execute — hang off every query, and take anything that can execute: a pool, a connection, a transaction, a &dyn Executor.
use keelson::FromRow;
use keelson::exec::{Execute as _, Executor as _, Statement};
use keelson::sqlx::sqlite::Pool;
#[derive(Debug, PartialEq, FromRow)]
struct Crew {
id: i64,
name: String,
}
let db = Pool::connect("sqlite::memory:").await?;
db.execute(Statement::new(
"CREATE TABLE crew (id INTEGER PRIMARY KEY, name TEXT NOT NULL, age INTEGER)",
vec![],
))
.await?;
// The same `q` from step 02 — building and running are separate acts.
let crew: Vec<Crew> = q.fetch_all(&db).await?;[Crew { id: 1, name: "Ada" }, Crew { id: 2, name: "Kid" }]If a column will not decode, the error names which one — not just that the row failed.
Decide a filter at run time
This is the case that pushes people back to string assembly: a condition that is only sometimes there. keelson's answer is that mods are ordinary values, so the standard library's own tools work on them.
// Whatever decided this — a CLI flag, a query string, a config file.
let min_age: Option<i64> = Some(21);
// `Option<M>` is itself a mod. `None` contributes nothing.
let only_adults = min_age.map(|n| select::where_(quote("age").gte(arg(n))));
let q2 = sqlite::select((
select::columns((quote("id"), quote("name"))),
select::from(quote("crew")),
only_adults,
select::order_by(quote("id")),
));SELECT "id", "name" FROM "crew" WHERE ("age" >= ?1) ORDER BY "id"
-- args: [I64(21)]Make min_age a None and the WHERE clause is simply absent from the SQL — no empty 1=1, no branch that builds a second query. Vec<M> and () are mods too, so a list of filters and no filter at all are both expressible.
Write some SQL yourself
A bare &str is a first-class expression anywhere an expression is accepted — in the same tuple as the typed ones, with no escape hatch and no second API.
let q3 = sqlite::select((
select::columns(quote("name")),
select::from(quote("crew")),
select::where_(quote("age").gte(arg(21))),
select::where_("name IS NOT NULL"), // <- just a &str
select::order_by(quote("id")),
));
let names: Vec<String> = q3.fetch_scalars(&db).await?;SELECT "name" FROM "crew" WHERE ("age" >= ?1) AND name IS NOT NULL ORDER BY "id"
-- args: [I64(21)]Note what did not happen: quote("age") became "age" because it is an identifier and the dialect decides the quoting, while name IS NOT NULL went through untouched because you wrote it. And arg(21) is a bound parameter — ?1 — never interpolated into the string.
sqlite::sql!("… WHERE age >= {min}") makes a hand-written statement an ordinary query with the same verbs and the same row mapping. See examples/raw_sql.rs ↗.Let the schema write the rest
Everything so far was Layers 1 and 2, and stopping here is a legitimate way to use keelson. Layer 3 adds a typed shell per table, and Layer 4 generates it from a database you already migrated.
cargo install keelson-gen
keelson-gen --config keelson.toml --url "$DATABASE_URL"It writes .rs files you commit, diff and step through. Then a query is typed at the column level, and Layer 1 mods still drop into the same tuple:
let adults = users::table()
.query((
users::age().gte(21), // typed: .gte("x") will not compile
select::order_by(users::age()).desc(),
users::then_load::posts(),
))
.all(db)
.await?;keelson is DML-only: it does not emit DDL or track migrations. Run whichever migration tool you already trust, then re-run the generator. The loop is migrate → regenerate → compile, and the compiler is what tells you which call sites the schema change broke.
Where to go from here
- How-to — fourteen tasks, each answered by a program you can run.
- examples/ ↗ — the same programs, as a worked application with a schema, committed generated code and hand-written hooks.
- Explanation — why each dialect gets its own grammar, and what the execution layer decided against.
- Reference — the API for all eleven crates, and the type × dialect table.