sql-composer
A SQL template engine that composes reusable SQL fragments with parameterized bindings.
sql-composer lets you write SQL templates with a simple macro syntax, then compose them into final SQL with dialect-specific placeholders and ordered bind parameters. Templates can include other templates, enabling reuse across queries.
Features
- Simple macro syntax embedded in SQL — no new language to learn
- Dialect-aware placeholders — Postgres (
$1), MySQL (?), SQLite (?1) - Template composition — include and reuse SQL fragments via
:compose(path) - Multi-value bindings — expand
:bind(ids)into$1, $2, $3forINclauses - Circular reference detection — prevents infinite loops in template includes
- Driver crates — thin wrappers for rusqlite, DuckDB, postgres, MySQL, and sqlx
- CLI tool —
cargo sqlc composepre-compiles templates and runscargo sqlx prepare
Workspace Structure
crates/
sql-composer/ # Core: parser (winnow), types, composer
sql-composer-rusqlite/ # rusqlite driver (ComposerConnection)
sql-composer-duckdb/ # DuckDB driver (ComposerConnection)
sql-composer-postgres/ # PostgreSQL driver (sync + async)
sql-composer-mysql/ # MySQL driver (sync + async)
sql-composer-sqlx/ # sqlx integration (verify, validate)
cargo-sqlc/ # CLI pre-compiler
Quick Start
Library usage
Add sql-composer to your Cargo.toml:
[]
= "0.0.1"
Parse a template and compose it into final SQL:
use parse_template;
use Composer;
use ;
let input = "SELECT * FROM users WHERE id = :bind(user_id) AND active = :bind(active);";
let template = parse_template.unwrap;
let composer = new;
let result = composer.compose.unwrap;
// Bind params are alphabetically ordered for numbered dialects
assert_eq!;
assert_eq!;
CLI usage
Install cargo-sqlc as a cargo subcommand:
# From crates.io (once published)
# From source
Cargo automatically discovers binaries named cargo-<name> on your PATH and makes them available as cargo <name>. After installing, cargo sqlc is ready to use.
Create .sqlc template files in your source directory (default sqlc/):
-- sqlc/get_user.sqlc
SELECT id, name, email
FROM users
WHERE id = :bind(user_id) AND active = :bind(active)
Compose templates into final .sql files:
# Compose templates from sqlc/ -> sql/ and run cargo sqlx prepare
# With a specific dialect
# Skip the sqlx prepare step
# Override source/target directories
The compose step reads all .sqlc files from the source directory, composes them into .sql files in the target directory, and then runs cargo sqlx prepare to keep the query cache up to date for compile-time checked queries. DATABASE_URL must be set for the cargo sqlx prepare step (or use --skip-prepare to skip it).
Environment variables
Directories can be configured via environment variables so you don't have to pass them every time:
# Now just:
Priority: CLI arg > env var > default (sqlc/sql).
Template Syntax
All macros use the syntax :command(). SQL outside of macros is passed through unchanged. Lines starting with # are template comments and are stripped during parsing.
:bind(name)
Creates one or more dialect-specific placeholders and adds the name to the bind parameter list.
SELECT * FROM users WHERE id = :bind(user_id) AND status = :bind(status)
-- Postgres: SELECT * FROM users WHERE id = $2 AND status = $1
-- MySQL: SELECT * FROM users WHERE id = ? AND status = ?
-- SQLite: SELECT * FROM users WHERE id = ?2 AND status = ?1
For multi-value bindings (e.g. IN clauses), pass multiple values for the same name at compose time:
SELECT * FROM users WHERE id IN (:bind(ids))
-- With 3 values → Postgres: SELECT * FROM users WHERE id IN ($1, $2, $3)
Validation options
:bind() supports optional validation constraints:
-- Require at least 1 value
:bind(ids EXPECTING 1)
-- Require between 1 and 10 values
:bind(ids EXPECTING 1..10)
-- Allow NULL values
:bind(optional_field NULL)
:compose(path)
Include another SQL template file, resolved from configured search paths.
SELECT * FROM users WHERE :compose(filters/active_users.sqlc)
Compose references are resolved against the search paths added via Composer::add_search_path(). Circular references are detected and produce an error.
:union(sources...) and :count(sources...)
Combine multiple template sources:
-- Union multiple queries
:union(queries/admins.sqlc, queries/moderators.sqlc)
-- With DISTINCT or ALL modifiers
:union(DISTINCT a.sqlc, b.sqlc)
:union(ALL a.sqlc, b.sqlc)
-- Count rows from a template
:count(queries/active_users.sqlc)
-- Count specific columns
:count(id, name OF queries/active_users.sqlc)
Driver Crates
Each driver crate wraps a database connection with a ComposerConnection (sync) or ComposerConnectionAsync (async) trait implementation that composes templates and resolves bind values in one step.
rusqlite
[]
= "0.0.1"
= "0.0.1"
use Composer;
use ComposerConnection;
use parse_template;
use ;
use bind_values;
use SqliteConnection;
let conn = open_in_memory.unwrap;
conn.execute.unwrap;
let input = "SELECT * FROM users WHERE id = :bind(user_id)";
let template = parse_template.unwrap;
let composer = new;
let values = bind_values!;
let = conn.compose.unwrap;
let refs: = params.iter.map.collect;
let mut stmt = conn.prepare.unwrap;
let _rows = stmt.query.unwrap;
DuckDB
[]
= "0.0.1"
= "0.0.1"
use Composer;
use ComposerConnection;
use parse_template;
use ;
use bind_values;
use DuckDbConnection;
let conn = open_in_memory.unwrap;
let input = "SELECT * FROM users WHERE id = :bind(user_id)";
let template = parse_template.unwrap;
// DuckDB uses Postgres-style $N placeholders
let composer = new;
let values = bind_values!;
let = conn.compose.unwrap;
PostgreSQL (sync + async)
[]
= "0.0.1"
= "0.0.1" # both sync and async enabled by default
# sql-composer-postgres = { version = "0.0.1", default-features = false, features = ["async"] }
Features: sync (enables postgres crate), async (enables tokio-postgres). Both enabled by default.
// Async (tokio-postgres)
use ;
use ComposerConnectionAsync;
let = connect.await?;
spawn;
let client = from_client;
let values = bind_values!;
let = client.compose.await?;
let refs = boxed_params;
let rows = client.query.await?;
// Sync (postgres)
use ;
use ComposerConnection;
let client = connect?;
let conn = from_client;
let values = bind_values!;
let = conn.compose?;
MySQL (sync + async)
[]
= "0.0.1"
= "0.0.1" # both sync and async enabled by default
# sql-composer-mysql = { version = "0.0.1", default-features = false, features = ["async"] }
Features: sync (enables mysql crate), async (enables mysql_async). Both enabled by default.
// Async (mysql_async)
use MysqlConn;
use ComposerConnectionAsync;
let pool = new;
let conn = pool.get_conn.await?;
let conn = from_conn;
let values = bind_values!;
let = conn.compose.await?;
// Sync (mysql)
use MysqlConnection;
use ComposerConnection;
let conn = new?;
let conn = from_conn;
let values = bind_values!;
let = conn.compose?;
sqlx (verification + validation)
[]
= "0.0.1" # postgres verification (default)
# sql-composer-sqlx = { version = "0.0.1", features = ["validate"] } # add offline syntax checking
# sql-composer-sqlx = { version = "0.0.1", features = ["mysql"] } # mysql instead of postgres
Features: postgres (default, enables live verification against PostgreSQL), mysql (live verification against MySQL), validate (offline syntax checking via sqlparser).
use verify_postgres;
// Verify against a live database (checks tables, columns, syntax)
let stmts = vec!;
verify_postgres.await?;
use validate_syntax;
use Dialect;
// Offline syntax validation (no database needed, requires "validate" feature)
validate_syntax?;
Core Library Features
The sql-composer core crate has the following optional features:
| Feature | Description |
|---|---|
std |
Standard library support (enabled by default) |
serde |
Derive Serialize/Deserialize for core types (Template, Element, etc.) |
# With serde support
= { = "0.0.1", = ["serde"] }
Core Types
| Type | Description |
|---|---|
Template |
A parsed SQL template containing elements |
Element |
SQL literal, bind macro, compose reference, or command |
Binding |
A :bind() with name, optional value count constraints, and nullable flag |
ComposeRef |
A :compose() reference to another template file |
Command |
A :count() or :union() combinator |
Composer |
Transforms templates into final SQL with placeholders |
ComposedSql |
The result: final SQL string + ordered bind param names |
Dialect |
Target database: Postgres, Mysql, Sqlite |
How Bind Parameter Ordering Works
Numbered dialects (Postgres, SQLite) use a two-pass approach:
- Collect — scan all
:bind()names into a deduplicated, alphabetically sorted set - Assign — give each unique name a 1-based index (
$1,$2, ...) - Emit — replace each
:bind()with its assigned placeholder
This means the same bind name always gets the same placeholder number, regardless of where it appears in the template. Alphabetical ordering provides deterministic, predictable parameter positions.
MySQL uses document-order positional ? placeholders with no deduplication, matching its native parameter style.
Project Status
This project is under active development. The core API (Template, Composer, ComposedSql) is stabilizing, but may still change before 0.1.0. Several crates in this workspace will be production tested over the next few months at which point the project and so what API problems are lurking I plan to have flushed out quickly.
License
MIT