sql_forge
A proc-macro that uses sqlx under the hood, supports MySQL, PostgreSQL, and SQLite, and combines compile-time SQL validation (via sqlx::query_as! / sqlx::query_scalar!) with a runtime QueryBuilder for dynamic queries.
Write SQL with named parameters and optional sections that are swapped in at runtime, while still getting sqlx's full type-checking at compile time. For compile-time safety, sql_forge! uses a smart-cycling strategy to validate dynamic grouped shapes without exploding them combinatorially, while runtime query construction is handled with QueryBuilder.
Installation
[]
= "0.1"
= { = "0.8", = ["mysql", "runtime-tokio"] } # or postgres / sqlite
Import the macro:
use sql_forge;
Quick start
let users: = sql_forge!
.fetch_all
.await?;
Full syntax
sql_forge!(
[DB,] // optional (sqlx::MySql | sqlx::Postgres | sqlx::Sqlite)
[Model,] // optional result spec
SQL, // string literal
[params,] // optional parameter source
[(sections),] // optional section map
[..batch] // optional batch source used by {( ... )}
)
Model has three forms:
- omitted: execute-only query; only
.execute(...)is available Typeorscalar Type: a single result query( >key1 = TypeA, >key2 = scalar TypeB ): a grouped multi-result query
The trailing parameter source, section map, and batch source are optional.
The batch source is a single ..expr argument and is only needed when the SQL contains a {( ... )} batch section.
Specifying the database type
The DB type can be given explicitly as the first argument, via the SQL_FORGE_DB_TYPE environment variable, or inferred from Cargo.toml metadata (lowest priority).
Explicit
sql_forge!
Via environment variable (project-wide default)
SQL_FORGE_DB_TYPE=sqlx::MySql
Via Cargo.toml (fallback)
[]
= "sqlx::MySql"
When any of the above is configured, the first macro argument may be the model type directly instead of the DB type.
Parameters
Named parameters (:name)
Placeholders are written as :name inside the SQL. Each :name is replaced by a push_bind call at runtime and by database-specific placeholders for compile-time validation: ? for MySQL and SQLite, and $1, $2, ... for Postgres.
Inline map
Bind individual expressions with ( :name = expr, ... ):
sql_forge!
Struct source
Pass any struct (or local variable) whose fields match the parameter names:
sql_forge!
Sections ({#name})
Sections are named placeholders in the SQL that are filled at runtime with a string and optional parameters.
sql_forge!
Dynamic section with match
sql_forge!
Section with local parameters
A section value can be a tuple ("sql", params) to bind parameters that are only relevant to that section:
sql_forge!
The parameter source can also be a struct:
sql_forge!
Grouped sections
Multiple section placeholders can be driven by the same match expression. Declare them as #(a, b) and each arm must return a tuple with the same number of items:
sql_forge!
Grouped section items can themselves contain nested match expressions. When that happens, sql_forge! applies the same smart-cycling idea inside that grouped arm for static compile-time validation: sibling nested matches are aligned by index instead of expanded as a cartesian product.
For example, this arm:
true =>
produces two aligned variants, not four cartesian combinations, for compile-time checking:
- variant 0:
(" JOIN ...", "o.name AS org_name", "'org' AS org_kind") - variant 1:
(" JOIN ...", "users.name AS org_name", "'user' AS org_kind")
This keeps compile-time validation tractable while avoiding combinatorial explosion. Runtime behavior still follows the grouped structure exactly as written in the source code.
Scalar output
When the model type is a Rust primitive (i8, i16, i32, i64, u8 … u64, f32, f64, bool, String), sql_forge! automatically uses query_scalar! for validation and build_query_scalar for execution:
let count: i64 = sql_forge!
.fetch_one
.await?;
For non-primitive types that SQLx can treat as scalars (e.g. tuple structs with a single field annotated with #[sqlx(transparent)], like wrapped IDs), use the scalar keyword before the model name:
let user_id: UserId = sql_forge!
.fetch_one
.await?;
The scalar keyword is only required for non-primitive scalar types.
IN (...) with a list parameter
Append [] to the placeholder name in the SQL to mark it as a list parameter:
let ids = vec!;
let users: = sql_forge!
.fetch_all
.await?;
Without the [] suffix, the parameter is treated as a single value.
Empty lists
sql_forge! does not rewrite empty list predicates. If a list parameter used with [] is empty, runtime SQL becomes IN () / NOT IN (), and the database will raise a syntax error.
Handle empty lists explicitly in your own logic. For example, return early, or use a section with match to emit alternative SQL:
let users: = sql_forge!
.fetch_all
.await?;
Transparent wrapper types (#[sql_forge::sql_forge_transparent])
When using a custom wrapper type as a parameter with sql_forge!, PostgreSQL requires the type to implement SqlForgeValidatorValue so that the compile-time validator can convert it to the underlying SQL-facing type. Annotate the type with #[sql_forge::sql_forge_transparent] to derive both #[sqlx(transparent)] and the required trait impl:
;
This expands to:
#[derive(sqlx::Type)]+#[sqlx(transparent)]is needed for all database backends (enablespush_bind)impl SqlForgeValidatorValue<i64>is needed only for PostgreSQL (strict type matching inquery_as!)
PostgreSQL requires this for all parameter types (single values and IN (:ids[]) lists). MySQL and SQLite do not use the trait at all, as #[sqlx(transparent)] alone is sufficient for those backends.
Batch inserts ({( ... )})
A batch section {( ... )} repeats its content for each item in an iterable source passed as ..expr. Inside the batch, :name refers to a field on the current item. List parameters (:name[]) are not allowed inside batch sections.
Struct batch
let items = vec!;
sql_forge!
.execute
.await?;
The validator expands the batch to 3 copies so the SQL is valid for compile-time checking ((?, ?, 10, 'Batch'), (?, ?, 10, 'Batch'), (?, ?, 10, 'Batch')). At runtime the iterable drives the actual number of rows.
Execution
The macro expands to a SqlForgeQuery value. Call .fetch_all(executor), .fetch_one(executor), or .fetch_optional(executor) directly on it:
let result = sql_forge!
.fetch_all
.await?;
You can also store the query and execute it later:
let query = sql_forge!;
let result = query.fetch_one.await?;
Returning a query from a function
Use impl SqlForgeQuery<Model> as the return type to build a query in one place and execute it elsewhere. The macro return type is unnameable, so impl Trait is the only option.
use ;
pub type AppDb = db_type!; // or explicitly sqlx::MySql, sqlx::Postgres, etc.
// Later, at call site:
let query = users_by_ids_query;
let users = query.fetch_all.await?;
Execute-only (no model)
When the model type is omitted entirely, the macro produces a value implementing SqlForgeQueryExecute. Only .execute(executor) is available and there is no return type to deserialize into. This is useful for INSERT, UPDATE, DELETE, and other DML statements.
use SqlForgeQueryExecute;
let result = sql_forge!
.execute
.await?;
Sections work the same way:
let rows_affected = sql_forge!
.execute
.await?;
The parameter source can also be a struct:
let filter = UpdateFilter ;
let result = sql_forge!
.execute
.await?;
Multiple results (SqlForgeQueryGroup)
A single SQL template can produce multiple independent queries that share the same base SQL, parameters, and dynamic sections. This avoids duplicating the query structure when you need different result shapes (e.g., a count and a paginated list) from the same filters.
Result map syntax
Replace the single model type with a map of >key = Type entries:
sql_forge!
Each key becomes a field on the returned group object, holding its own SqlForgeQuery<Type, Db = DB>.
Conditional sections with {>key}
In the section map, the special expression {>key_name} evaluates to true when generating the query for that specific key, and false otherwise. Use it with match to select different SQL per result:
Complete example
use ;
use crateListAndAmount;
pub type AppDb = MySql;
Accessing individual queries
The group struct exposes each key as a field. Pass the field to any SQLx executor method:
let q = build_item_query;
// Execute the count query
let total: i64 = q.amount.fetch_one.await?;
// Execute the list query
let items: = q.list.fetch_all.await?;
Scalar keys
Keys can also use scalar Type for primitive/scalar output:
sql_forge!
When the key type is marked as scalar, the macro generates a query_scalar! validator instead of query_as!. This is equivalent to using a standalone scalar AmountWrapper in a single-result query.
Return type pattern
One common return-type pattern for a function producing multiple queries is to use your own wrapper struct, such as ListAndAmount<A, L>:
The generated group struct (__SqlForgeQueryGroup) is unnameable, so returning your own wrapper type like ListAndAmount is a practical pattern when you want to expose the individual queries from a function. You can also call .into_parts() on the group result to destructure it into a tuple of the individual queries.
Expanded file reference
The repository includes expanded source files generated from tests/tests.rs (one per database backend) at:
They show the exact Rust code the sql_forge! macro produces and can be taken as a reference for how the macro works:
- Validator closures (never called at runtime): contain
sqlx::query_as!/sqlx::query_scalar!invocations used solely for compile-time SQL and type validation. Several static SQL queries may be generated for a singlesql_forge!invocation to cover different section combinations. - Runtime code: uses
sqlx::QueryBuilderwithpush()for static SQL fragments andpush_bind()for all user-supplied values.
Compile-time validation
Under the hood the macro emits a never-called closure containing one or more sqlx::query_as! / sqlx::query_scalar! invocations. Rather than generating the full cartesian product of all section variants (which would explode for many sections), it uses a smart cycling strategy:
- Let each section have m possible variants (match arms).
- Find n_max, the largest m across all sections.
- Generate exactly n_max validator queries.
- Query i (0-based) uses variant
i % mfor each section.
For example, with two sections having 3 and 10 variants respectively, 10 validator queries are generated, instead of 30; the first section cycles (0, 1, 2, 0, 1, 2, 0, 1, 2, 0) while the second uses each of its 10 variants once. This ensures every variant of the widest section appears in exactly one validator query, and every other section is exercised as many times as its own variant count allows, without combinatorial growth.
The same idea is also used recursively inside grouped sections: if a grouped arm contains nested match values for multiple tuple slots, those slots are cycled together by index within that arm instead of generating every cartesian pairing.
List parameters are validated using index access to the first element (.as_slice()[0]), as if provided 3 times. The validator closure is never called at runtime, it exists solely to drive query_as!/query_scalar! compile-time type checking. This means that IN (:list[]) would be validated as IN (?, ?, ?) (or IN ($1, $2, $3) in Postgres) using the first list element in a closure that is never called, used only for compile-time validation (the runtime query will use the full list and call QueryBuilder::push_bind for each item).
Combining features
This example uses the WHERE 1 = 1 idiom so that every optional filter can start with AND without needing to track whether it is the first condition. Modern database engines (MySQL, PostgreSQL, SQLite) constant-fold 1 = 1 away at planning time, so there is no runtime performance cost.
let rows: = sql_forge!
.fetch_all
.await?;
Caveats
String literals containing :
The macro scans the SQL template text for :parameter tokens to locate bind parameter placeholders. A colon that appears inside a SQL string literal in the template (e.g. "abc:def") is indistinguishable from a parameter placeholder at the text level and will cause a parse error or an unexpected parameter name.
This also affects MySQL-specific alias text such as SELECT 1 AS `my_field:String` . The parser still sees :String as a parameter token, so that alias form is not supported inside a sql_forge! SQL template. You can add a whitespace to avoid that: SELECT 1 AS `my_field: String` .
Workaround: pass the value as a bind parameter and use the :parameter placeholder in the template instead of embedding the literal directly.
| ❌ Inline literal (breaks) | ✅ Bind parameter (correct) |
|---|---|
WHERE name = "abc:def" in the SQL string |
WHERE name = :name with ( :name = "abc:def" ) |
// ❌ will fail, as the macro sees ":def" as a parameter placeholder
// sql_forge!(User, r#"SELECT ... WHERE name = "abc:def""#);
// ✅ bind the value that contains ":" as a parameter
let _query = sql_forge!;
String literals containing {#
The macro also scans the SQL template text for {#section} tokens to locate dynamic section slots. A {# sequence that appears inside a SQL string literal in the template (e.g. "abc{#def") will be treated as a section slot, causing a parse error or a missing-section error.
Workaround: pass the value as a bind parameter and use the :parameter placeholder in the template instead.
| ❌ Inline literal (breaks) | ✅ Bind parameter (correct) |
|---|---|
WHERE name = "abc{#def" in the SQL string |
WHERE name = :name with ( :name = "abc{#def" ) |
// ❌ will fail, as the macro sees "{#def" as a section slot
// sql_forge!(User, r#"SELECT ... WHERE name = "abc{#def""#);
// ✅ bind the value that contains "{#" as a parameter
let _query = sql_forge!;
String literals containing {( or )}
The macro also scans the SQL template text for {( ... )} batch sections. A {( sequence anywhere in the template starts batch parsing, even if it appeared only as part of a string literal. A )} sequence is only special while the parser is already inside a batch section, where it can prematurely terminate or unbalance the batch body.
In practice, both markers should be avoided inside inline literals. Pass those values as bind parameters instead.
| ❌ Inline literal (breaks) | ✅ Bind parameter (correct) |
|---|---|
WHERE name = "abc{(def" in the SQL string |
WHERE name = :name with ( :name = "abc{(def" ) |
VALUES {("abc)}")} inside a batch SQL template |
VALUES {(:name)} with ..items and item.name |
// ❌ will fail, as the macro sees "{(" as the start of a batch section
// sql_forge!(User, r#"SELECT ... WHERE name = "abc{(def""#);
// ❌ inside a batch section, ")}" can terminate or unbalance batch parsing
// sql_forge!(
// r#"INSERT INTO products (name) VALUES {("abc)}")}"#,
// ..items
// );
// ✅ bind the value instead of embedding either marker directly
let items = vec!;
let _query = sql_forge!;
Incomplete cross-section validation
The validator intentionally does not expand the full cartesian product of section variants, because doing so grows exponentially and becomes impractical for real queries. Instead, it uses the cycling strategy described above.
That tradeoff leads to the first class of behavior below. The second class is an inherent property of compile-time validation and occurs independently of cycling:
- Missed required validation (cycling limitation): A problematic combination can be skipped by the cycle, so code that should fail may compile.
- Conservative rejection (always present): A query can be flagged even when runtime control flow would make it safe, because compile-time validation does not reason about runtime conditions. This happens with or without cycling.
In both situations, the recommended fix is the same: group dependent sections under one match using #(a, b, ...) so related SQL fragments always move together.
Even when a non-grouped version happens to work today, grouping is safer and less fragile under future maintenance (similar to choosing stricter safety constraints that prevent subtle bugs).
Case 1: may compile, but should fail (cycling can miss it)
// ⚠ Not recommended: dependent sections are independent.
// With 2x2 variants, cycling checks (0,0) and (1,1), so (0,1) may be skipped.
sql_forge!;
Recommended grouped version for Case 1
This rewrite preserves the intended dependency: needs_t2_field is only consulted when include_t2 is true. If the original logic can produce needs_t2_field = true while include_t2 = false, that logic is already inconsistent and should be reconsidered.
sql_forge!;
Case 2: may be rejected, even though runtime logic would work (conservative validation)
// ⚠ Not recommended: runtime implies safety, but compile-time still checks impossible variants.
sql_forge!;
Recommended grouped version for Case 2
sql_forge!;
Grouping keeps related fragments synchronized, avoids skipped problematic combinations, and reduces fragile query shapes during maintenance, but it should be used only when there is a real dependency between sections.
Development Workflow & CI Parity
Before pushing changes, run the full local validation flow with Docker:
This command is the expected end-to-end local check for the project. It runs the full backend matrix flow and refreshes generated artifacts that are used by tests and reviews.
After it finishes, verify any generated or changed artifacts before pushing, especially:
- SQLx metadata files under
tests/{db_type}/.sqlx .stderrfiles undertests/{db_type}/ui-common.stderrfiles undertests/{db_type}/ui- expanded Rust files under
tests/{db_type}/tests_expanded.rs
Those files can change when the macro expansion, validation behavior, expected compile-fail output, or relevant dependency and tool versions change. Review them carefully to confirm that the errors and expansions are still correct.
Only commit those generated changes when they make sense for the current state of the codebase, for example when a compile-fail test still fails for the right reason and an expanded file still reflects the intended macro expansion.
Commit these artifacts only when they make sense for the current state of the codebase, e.g., when a test fails for the expected reason or an expansion remains semantically correct.