qbrs-sqlx 0.1.0

sqlx-based execution integration for qbrs (Postgres).
Documentation

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.1"
qbrs-sqlx = "0.1"                                                     # 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, 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 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() 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 self-join. Two #[derive(Table)] structs must not share a #[table(name = "..")] — that compiles and then renders FROM "t" JOIN "t", which the database refuses.
  • Nothing relates GROUP BY/ORDER BY to the selection list. An aggregate or window function in WHERE or RETURNING is 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) and count(DISTINCT x) need sql!{}.
  • 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 16 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.