qbrs checks column and join references at compile time without giving up dynamic query composition. Most builders make you pick one of the two. See Why qbrs? for how.
[!WARNING] Not production ready. qbrs is pre-1.0: 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 stays deliberately narrow. See
Known limitations.
Install
[]
= "0.5.0"
= "0.5.0" # 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 that need a crate to
decode to are features (chrono, uuid, decimal, json), and each has to
be enabled on both qbrs and qbrs-sqlx, which are separate cfgs over
one Value. Enabling it on qbrs alone is a compile error where the value is
decoded, and a FeatureNotEnabled where one is bound.
What's in it
SELECT/INSERT/UPDATE/DELETE (INSERT .. SELECT included), every JOIN kind, GROUP BY/HAVING,
aggregates (count/count_of/sum/min/max/avg/string_agg),
DISTINCT, upsert (ON CONFLICT, partial unique indexes, excluded(..) and a conditional DO UPDATE included), UNION/INTERSECT/EXCEPT,
ranking window functions, non-recursive CTEs (Postgres data-modifying ones
included), correlated EXISTS,
a SELECT with no FROM (now(), pg_try_advisory_lock($1)),
Postgres array columns (Vec<T> as TEXT[]/INTEGER[]/BIGINT[]/UUID[], with = ANY(..)),
JSON/JSONB columns (serde_json::Value),
IN (SELECT ..)/NOT IN (SELECT ..), transactions, streaming
(.stream(..)), the sql!{} escape hatch, and typed prepared statements
(prepare!{}). A selection is named once and reused: a tuple of columns is
one element of a longer list, the way <table>::All is, so a const of the
columns two endpoints share goes into both.
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, so capability gating happens
while the query is built, not when it renders. It is 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, each documented in the module it belongs to:
WITH RECURSIVE.- Aggregates as window functions (
sum(x) OVER (..)). - A CTE referencing another CTE.
- A data-modifying CTE anywhere but the top level. Postgres refuses one inside
an
EXISTS/INsubquery or a set-operation branch, and that is the server's error rather than the compiler's. - Row locking (
FOR UPDATE/SKIP LOCKED). - A scalar subquery in an expression position (
col = (SELECT max(x) ..),RETURNINGincluded). UPDATE .. FROMandDELETE .. USING.- The array operators
@>,&&,array_append.= ANY(..)is built, as.eq_any(..). - Array element types beyond the four that are built (
TEXT[],INTEGER[],BIGINT[],UUID[]):BOOLEAN[],DOUBLE PRECISION[],TIMESTAMPTZ[],NUMERIC[], and any array whose elements can be NULL. - The JSON operators
->,->>,@>,?, and ajsoncolumn's missing=/ORDER BY(the marker isjsonb's). ON CONFLICTor a column subset on anINSERT .. SELECT. It fills every column the target lets a statement write, and its source is aSelectrather than aUNIONor aDynSelect.sql!{}reaches neither: it builds an expression, not a statement suffix, and aSelectis not a slot value.- Relations and eager-loading.
IN (SELECT ..)/NOT IN (SELECT ..) is not a limitation:
Select::contains/.not_contains cover it. Like EXISTS, it is
dialect-pinned rather than a plain expression.
Design constraints worth knowing before adopting:
-
A SQL type is a marker type, except
Uuid.Text,BigInt,Numericand the rest are markers this crate declares, each named apart from the Rust type it decodes to.UUIDis the one whose marker name would be that type's, soUuidisuuid::Uuiditself rather than a second thing of the same name: importing it from theuuidcrate and taking it from the prelude name one type, and a marker position takes the sameUuida field's type does. -
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 stays 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 rendersFROM "t" JOIN "t", which the database refuses. Awith!{}pseudo-table bound to a plainselect(..).from(t::Table)gets the same result today:with! { struct managers { id: Integer, name: Text } }thenselect((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 aWITHCTE joined back to the same table: correct, type-checked, and available today, rather than the bare-alias SQL shape. The declared column types are the body's:Integerbecauseemployees::idis ani32. -
RETURNINGnames the written row. SQL's reaches further, to a scalar subquery or a column of anUPDATE .. FROM, and neither is built. The first of those is the deferred scalar subquery. Bind the write as a CTE body and join from the outer query instead: that is checked, and it stays one statement (29_data_modifying_cte). -
GROUP BYisn't related to the selection list. Every non-aggregated selected column has to appear inGROUP BY(or be functionally dependent), and nothing here checks that. It is the selection-into-GROUP BYdirection, the reverse of what arow::Fieldlookup can check:GROUP BYnaming a column that isn't selected is perfectly valid SQL, so checking membership the other way round would enforce a rule that doesn't exist. An aggregate or window function inWHEREorRETURNINGis likewise accepted by the builder and rejected by the database. Aggregates take a bare column, sosum(price * qty)andcount(DISTINCT x)needsql!{}. So does anORDER BYinside astring_agg: SQLite reached that only in 3.44, past the 3.39 this crate targets, and MySQL spells it elsewhere in the call.string_agg's separator binds under Postgres and SQLite, which take it as an argument. MySQL'sSEPARATORtakes a literal and rejects a parameter, so there it is written into the SQL, which is why it is a&'static streverywhere. MySQL also truncates the result atgroup_concat_max_len(1024 bytes by default) with a warning rather than an error. -
ORDER BYhas a checked and an unchecked form. Plain.order_by(..)only checks scope membership, since a non-DISTINCTquery may sort by any column in scope..order_by_selected(..)/.order_by_selection(..)also check the sort key is in the selection, through the samerow::FieldlookupRow::getuses. That is exactly whatSELECT DISTINCTrequires, since Postgres rejects a sort key that isn't selected. Pair.distinct()with one of these rather than plain.order_by(..), and the sort key is checked before the database sees it. One thing they still don't catch:.reselect(..)after one of them keeps theORDER BYit 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, aLIKE) says what it decodes to once, with.decodes_as::<Bool>(). Asql!{}fragment states its type in the macro. -
A selection's shape includes position. Three places make two selections agree, and each walks them together: a
UNIONbranch against the first branch, a CTE body against itswith!{}declaration, and anINSERT .. SELECTsource against its target. The pair at each position must match on name and on type. Two lists holding the same columns in a different order are therefore not the same shape. For the first two that is because the result is read by key; for the third, because SQL fills anINSERT's column list by position. -
A selection list holds at most 32 elements.
<table>::Allcounts as one whatever the column count, and so does a nested tuple, so a wider row is reached by naming part of the list. Separately,into_tuplestops at 32 fields: past that a row is read by key, which is how it is read anyway. 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. -
A JSON column is a document, not a structure.
serde_json::Valuebinds and decodes whole. The marker isjsonb's: ajsoncolumn takes the same values back and forth, but onlyjsonbhas an equality and an ordering operator, so.eq(..)/.asc()/GROUP BYon ajsoncolumn is the server's error rather than the compiler's. The operators that look inside a document (->,->>,@>) are not built and go throughsql!{}; Postgres's?existence operators are the ones that cannot, since a?there is a slot, so they are reached asjsonb_exists(..),jsonb_exists_any(..)andjsonb_exists_all(..). -
An array column is a value, not a set.
Vec<T>binds and decodes as a Postgres array, and=compares two of them whole..eq_any(..)asks the one question about an element:x = ANY(arr), which is whatis_inasks of a written-out list, of an array the database unnests. The array operators (@>,&&,array_append) are not built; they go throughsql!{}, where the column and the value are still slots. MySQL and SQLite have no array type at all, and since anExprcarries no dialect there is nothing to gate on: an array reaches those two as a bind their driver refuses, and= ANY(..)as a statement they won't parse. -
Every
?in asql!{}text is a slot, with no escape for a literal one, since 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. One fragment reused across clauses of a statement (selected, grouped by, ordered by) renders as one expression, which is what Postgres's syntacticGROUP BYmatching asks for. Where its placeholders are numbered, a repeated value is named again rather than bound again, so the rendered text depends on which of a statement's values are equal, as it already depends on how many rows anINSERTcarries.
Status
| Postgres | MySQL | SQLite | |
|---|---|---|---|
| Query building & SQL rendering | ✅ | ✅ | ✅ |
| Rendered SQL executed in CI | ✅ | not yet | ✅ |
Dialect capability gating (RETURNING, ON CONFLICT, RIGHT/FULL JOIN, data-modifying CTE) |
✅ | ✅ | ✅ |
Execution (via qbrs-sqlx) |
✅ | not yet | not yet |
Transactions (via qbrs-sqlx) |
✅ | not yet | not yet |
Streaming (.stream(..), 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 and no service to launch. The engine is downloaded once, when the crate
is first built, and cached under ~/.cache/pglite-rs; nothing is fetched while
a test runs. The first run also pays for an initdb, then caches the data
directory under target/ for every later test and example to copy. 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.