qbrs 0.2.0

A Drizzle-flavored, type-safe SQL query builder for Rust.
Documentation
  • Coverage
  • 100%
    2 out of 2 items documented1 out of 1 items with examples
  • Size
  • Source code size: 56.16 kB This is the summed size of all the files inside the crates.io package for this release.
  • Documentation size: 8.68 MB This is the summed size of all files generated by rustdoc for all configured targets
  • Ø build duration
  • this release: 8s Average build duration of successful builds.
  • all releases: 7s Average build duration of successful builds in releases after 2024-10-23.
  • Links
  • RyutaroYako/qbrs
    1 0 0
  • crates.io
  • Dependencies
  • Versions
  • Owners
  • RyutaroYako

qbrs checks column and join references at compile time without giving up dynamic query composition — most builders make you pick one. See Why qbrs? for how.

[!WARNING] Not production ready. 0.1.0 is the first public release. The API will break between 0.x minors, only Postgres has an execution layer, and nothing here has been run against a real workload yet. Worth trying and filing issues against; not worth putting under something that matters.

Not an ORM. qbrs builds and renders SQL with compile-time-checked column/join references; it doesn't do change-tracking, identity maps, or hide SQL behind an object graph. Want that? See SeaORM. Want raw SQL with compile-time type-checking instead of a builder? See sqlx.

Quick example

#[derive(Table)]
#[table(name = "users")]
struct Users {
    #[column(primary_key, generated)]
    id: i64,
    email: String,
    display_name: Option<String>,
    #[column(default)]
    active: bool,
}

#[derive(Table)]
#[table(name = "orders")]
struct Orders {
    #[column(primary_key, generated)]
    id: i64,
    user_id: i64,
    total: i64,
}

let rows = select((users::email, orders::total))
    .from(users::Table)
    .left_join(orders::Table, orders::user_id.eq(users::id))
    .order_by(users::id.asc())
    .load(&pool)
    .await?;

for row in &rows {
    println!("{} {:?}", row.email(), row.total()); // &String, &Option<i64>
}
-- rendered by qbrs:
SELECT "users"."email", "orders"."total"
FROM "users" LEFT JOIN "orders" ON ("orders"."user_id" = "users"."id")
ORDER BY "users"."id" ASC

orders::total is declared as a plain i64 column, but because it's on the far side of a LEFT JOIN, row.total() comes back as &Option<i64> automatically. A row is read by the column value that selected it, not by position, so adding a column to the selection moves nothing. Forget the join and reference orders::total anyway, and it's a compile error:

error[E0277]: `orders::Table` is not available in this query's scope
  --> src/main.rs:23:22
   |
23 |       let rows = select((users::email, orders::total))
   |  ________________^
24 | |         .from(users::Table)
   | |____________________________^ add `.join(<table>, ..)` (or `.from(..)`)
   |                                for `orders::Table` before referencing
   |                                its columns here

Why qbrs?

qbrs diesel sea-query sqlx
Compile-time column/join validity checking ❌ (runtime AST) n/a — not a builder
NULL-ability auto-derived from join kind ❌ (manual .nullable()) n/a
Add predicates conditionally/in a loop, no escape hatch ⚠️ needs .into_boxed() ✅ (dynamic by design) ⚠️ drops to QueryBuilder
Table/column refs are plain values, not turbofish/closures partial n/a
Result rows keyed by column, not by position ✅ (query_as!)
Compile time at ~40+ joins (measured, see tests/compile-bench) linear, ~ms documented exponential blowup at ~7 joins n/a n/a

The trick: whether a column reference makes sense given what's joined is a compile-time question, checked once via a flat type-level list (see crates/core/src/scope.rs). How many predicates you've added is a runtime question — a plain Vec, so .filter() can be called conditionally or in a loop without changing the query's type. Most builders conflate the two and need an escape hatch (.$dynamic(), .into_boxed()) the moment a query gets built conditionally. qbrs needs one too, but it's deliberately narrow — see Known limitations.

Install

[dependencies]
qbrs = "0.2.0"
qbrs-sqlx = "0.2.0"                                                     # Postgres execution via sqlx
sqlx = { version = "0.9", features = ["runtime-tokio", "postgres"] }  # for `PgPool`

qbrs-sqlx's methods take any sqlx::PgExecutor, so the sqlx version has to be the one it is built against (0.9). Column types beyond the six built in are features — chrono, uuid, decimal — and each has to be enabled on both qbrs and qbrs-sqlx, which are separate cfgs over one Value: enabling only one surfaces as a FeatureNotEnabled at bind time, not as a compile error.

What's in it

SELECT/INSERT/UPDATE/DELETE, every JOIN kind, GROUP BY/HAVING, aggregates, DISTINCT, upsert (ON CONFLICT), UNION/INTERSECT/EXCEPT, ranking window functions, non-recursive CTEs, correlated EXISTS, IN (SELECT ..)/NOT IN (SELECT ..), transactions, the sql!{} escape hatch, and typed prepared statements (prepare!{}).

A schema is a #[derive(Table)] struct; use qbrs::prelude::*; and use qbrs_sqlx::prelude::*; cover a query. Everything above has a runnable, end-to-end example against a real Postgres — see examples/README.md for the index, and docs.rs/qbrs for the API.

Three things that aren't obvious from a signature:

  • Rows are keyed by column, not by position. A tuple selection decodes to a Row read with row.get(users::email) or the generated row.email(); #[derive(FromRow)] fills a plain domain struct by field name, with no column path or table in it. into_tuples() recovers the positional view.
  • A statement with nothing in it has no SQL form. An *Update whose every field is untouched, or an insert of zero rows, hands back NothingToSet/NothingToInsert rather than rendering broken SQL. These are the only fallible builder methods; everything downstream is infallible.
  • The dialect is part of a query's type — capability gating happens while the query is built, not when it renders — but it's never a turbofish: .load(&pool) infers it from the executor, .to_sql(Postgres) takes it as a value.

Known limitations

Deferred rather than half-supported, and documented in the relevant module: WITH RECURSIVE, aggregates as window functions (sum(x) OVER (..)), a CTE referencing another CTE, row locking (FOR UPDATE/SKIP LOCKED), a scalar subquery in an expression position (col = (SELECT max(x) ..)), and relations/eager-loading. sql!{} doesn't reach the last two: it builds an expression, not a statement suffix, and a Select isn't a slot value. IN (SELECT ..)/NOT IN (SELECT ..) is covered by Select::contains/ .not_contains, which — like EXISTS — is dialect-pinned rather than a plain expression.

Design constraints worth knowing before adopting:

  • Conditionally joining a table has no fully-static solution — a single type can't mean "joined" in one branch and "not joined" in another. .erase() into DynSelect is the way out, and it's narrow: only the join skeleton is erased, and no further .filter()/.join() is offered on it. Conditional filtering needs none of this.
  • No table aliasing, so no literal FROM "t" AS a JOIN "t" AS b. Two #[derive(Table)] structs must not share a #[table(name = "..")] — that compiles and then renders FROM "t" JOIN "t", which the database refuses. A with!{} pseudo-table bound to a plain select(..).from(t::Table) gets the same result today: with! { struct managers { id: Integer, name: Text } } then select((employees::name, managers::name)).from(employees::Table) .inner_join(cte::with(managers::Table, &select((employees::id, employees::name)).from(employees::Table)), managers::id.eq(employees::manager_id)) renders a WITH CTE joined back to the same table — correct, type-checked, and available now — rather than the bare-alias SQL shape. The declared column types are the body's: Integer because employees::id is an i32.
  • GROUP BY isn't related to the selection list. Every non-aggregated selected column has to appear in GROUP BY (or be functionally dependent), and nothing here checks that — it's the selection-into-GROUP BY direction, the reverse of what a row::Field lookup can check (GROUP BY naming a column that isn't selected is perfectly valid SQL, so checking membership the other way round would be enforcing a rule that doesn't exist). An aggregate or window function in WHERE or RETURNING is accepted by the builder and rejected by the database, too. Aggregates take a bare column: sum(price * qty) and count(DISTINCT x) need sql!{}.
  • ORDER BY has a checked and an unchecked form. Plain .order_by(..) only checks scope membership, since a non-DISTINCT query may sort by any column in scope. .order_by_selected(..)/.order_by_selection(..) also check the sort key is in the selection — the same row::Field lookup Row::get uses — which is exactly what SELECT DISTINCT requires (Postgres rejects a sort key that isn't selected): pair .distinct() with one of these instead of plain .order_by(..) for a query that can't render SQL the database would reject. One thing they still don't catch: .reselect(..) after one of them keeps the ORDER BY it added, so swap the selection before sorting by it.
  • A computed expression's nullability isn't derived the way a column's is. An expression whose type the builder inferred — a comparison, an is_null, a LIKE — says what it decodes to once, with .decodes_as::<Bool>(); a sql!{} fragment states its type in the macro.
  • A selection list holds at most 32 elements (<table>::All counts as one, whatever the column count). Naming a row type in a signature takes a type alias long enough to trip clippy::type_complexity; inference covers everything that stays inside a function, and <table>::AllRow covers a stored select(All).
  • One label! per scope — it declares a label module, and a scope holds one. List every name that scope needs in the one invocation.
  • The derives expand to ::qbrs:: paths, so depend on the qbrs facade rather than on qbrs-core + qbrs-macros directly.
  • Every ? in a sql!{} text is a slot, with no escape for a literal one — MySQL and SQLite spell their bind parameters the same way. Its text must be a constant (a literal, a const, concat!, include_str!), so runtime-assembled text can never become SQL shape.

Status

Postgres MySQL SQLite
Query building & SQL rendering
Rendered SQL executed in CI not yet
Dialect capability gating (RETURNING, ON CONFLICT, RIGHT/FULL JOIN)
Execution (via qbrs-sqlx) not yet not yet
Transactions (via qbrs-sqlx) not yet not yet

MySQL is rendered and asserted as strings only, so its dialect differences are caught only where someone thought to look. One known difference: DEFAULT in an INSERT ... VALUES is Postgres and MySQL only — SQLite rejects it, which makes Defaultable::Default unusable there.

Development

cargo test --workspace --all-features

No setup required: the real-DB tests start their own throwaway PostgreSQL 17.5, embedded via pglite-rs — no Docker, no service to launch, nothing downloaded at test time. Set DATABASE_URL to run them against an external Postgres instead.

License

Dual-licensed under MIT or Apache-2.0, at your option. Contributions are licensed the same way unless stated otherwise.