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
let rows = select
.from
.left_join
.order_by
.load
.await?;
for row in &rows
-- 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
[]
= "0.1"
= "0.1" # Postgres execution via sqlx
= { = "0.9", = ["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,
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
Rowread withrow.get(users::email)or the generatedrow.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
*Updatewhose every field is untouched, or an insert of zero rows, hands backNothingToSet/NothingToInsertrather 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 subquery
in an expression position (IN (SELECT ..), a scalar subquery), 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. A
correlated EXISTS covers what IN (SELECT ..) means.
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()intoDynSelectis 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 self-join. Two
#[derive(Table)]structs must not share a#[table(name = "..")]— that compiles and then rendersFROM "t" JOIN "t", which the database refuses. - Nothing relates
GROUP BY/ORDER BYto the selection list. An aggregate or window function inWHEREorRETURNINGis accepted by the builder and rejected by the database, and.distinct()sorted by an unselected column renders SQL Postgres won't take. Aggregates take a bare column:sum(price * qty)andcount(DISTINCT x)needsql!{}. - 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, aLIKE— says what it decodes to once, with.decodes_as::<Bool>(); asql!{}fragment states its type in the macro. - A selection list holds at most 16 elements (
<table>::Allcounts as one, whatever the column count). Naming a row type in a signature takes a type alias long enough to tripclippy::type_complexity; inference covers everything that stays inside a function, and<table>::AllRowcovers a storedselect(All). - One
label!per scope — it declares alabelmodule, and a scope holds one. List every name that scope needs in the one invocation. - The derives expand to
::qbrs::paths, so depend on theqbrsfacade rather than onqbrs-core+qbrs-macrosdirectly. - Every
?in asql!{}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, aconst,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
crates/core— type-level machinery and SQL rendering. No I/O, no async, no driver.crates/macros—#[derive(Table)],#[derive(FromRow)],label!,with!.crates/qbrs— the facade; depend on this one.crates/qbrs-sqlx— execution viasqlx(Postgres).examples— runnable examples;tests/dialect-execruns every rendered shape against SQLite;tests/compile-benchbacks the "linear at 40+ joins" claim.
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.