sql-composer 0.0.3

SQL template engine that composes reusable SQL fragments with parameterized bindings
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
# sql-composer

`sql-composer` helps to manage the complexity of growing SQL codebases by eliminating duplication while keeping you in real SQL. It is a clean macro-based template engine that composes complete, reusable SQL statements into larger queries. You write real SQL, extract the shared queries into their own files, and compose them into the queries with predictable named bindings that are portable across SQL dialects.

The ability to actively collaborate with your DBA team as the database fills up and query performance starts to matter by providing them SQL is worth the code maintenance costs, but is a major ask for the design team. SQLx makes this process dramatically more manageable with its `sql_file!()` macro for compile-time query verification. sql-composer tries to reintroduce some of the code reuse lost when SQL is just SQL.

In probably its most natural use case, sql-composer serves as a pre-compiler for SQL templates that get read by `sql_file!()` at compile time. You write `.sqlc` templates with `:compose()` references to shared logic, run `cargo sqlc compose` to generate the final `.sql` files, and `sql_file!()` picks them up for compile-time verification.

The clean macros provide query reuse and basic bindings and feel and look a fair amount like a SQL function. The average SQL-savvy user should be able to quickly learn to read `.sqlc` templates and adapting to an existing set of templates should be fairly straightforward. This means DBAs should be able to follow along with templates but more importantly that OLTP queries can now be reused at least partially for ETL and OLAP query workloads.

The templates allow for `#` comments that are stripped during composition, so you can document your business rules, explain join ordering decisions, and leave notes for future developers without cluttering the SQL that hits your database. These comments can help teams collaborate and ensure a strong contextual understanding of queries for agents and search tools.

Reading and adapting to `.sqlc` templates is fairly straightforward, but designing them — particularly when starting from scratch — is a different skill. These templates will add cognitive load while designing your data paths in SQL, but they help distribute the ever-changing burden of query performance tuning across as many different specialists as possible. It will inevitably take all of you.

## Features

- **Clean macro syntax** embedded in SQL — no new language to learn
- **Dialect-aware placeholders** — Postgres (`$1`), MySQL (`?`), SQLite (`?1`)
- **Template composition** — include and reuse complete SQL statements via `:compose(path)`
- **Parameterized slots** — declare `@slot` placeholders in shared templates, filled by callers
- **Multi-value bindings** — expand `:bind(ids)` into `$1, $2, $3` for `IN` clauses
- **Recursive directory scanning** — compose entire directory trees of templates
- **`--verify` mode** — CI-friendly check that composed output matches committed `.sql` files
- **Atomic output** — composition writes to a temp directory, swaps on success, cleans stale files
- **Circular reference detection** — prevents infinite loops in template includes
- **`#` comments** — rich documentation stripped from output, zero cost in production
- **Driver crates** — thin wrappers for rusqlite, DuckDB, postgres, and MySQL

## Why sql-composer

**Eliminate SQL duplication without ever leaving SQL.** Shared business logic — what "active" means, how you resolve versions, how you calculate revenue — lives in one place and gets composed into every query that needs it. No ORM abstraction layer, no query builder DSL. Just SQL.

**Work directly with your DBA team.** The composed `.sql` files are the exact queries that hit your database. When your DBA has questions or suggestions, you're both looking at the same SQL. You can trace any statement back to the template that produced it.

**Reuse across OLTP and OLAP systems.** The same shared templates can be composed into transactional queries for Postgres (via SQLx) and analytical queries for DuckDB (via the duckdb driver) — without maintaining two divergent copies of your core business logic. Even across different database engines, the shared SQL stays in sync.

**Built for `sql_file!()`**. sql-composer is designed as a complement to SQLx. `cargo sqlc compose` generates the `.sql` files that `sql_file!()` reads at compile time, then runs `cargo sqlx prepare` to keep the offline query cache up to date. You get DRY templates and compile-time verified queries.

**Rich documentation, zero production overhead.** Templates support `#` comments that are stripped during composition. Document your business rules, explain join ordering, leave notes for agents and future developers — none of it ends up in the SQL that hits your database.

**Clean macros.** sql-composer macros compose structured SQL building blocks — CTEs, queries, filters — not arbitrary string fragments. You can't splice in a bare WHERE clause or a partial JOIN. Templates are organized around composable units that each have a clear role in the final query, preventing the fragment spaghetti that makes other template systems unmaintainable.

### Caveats

* sql-composer requires structuring your SQL so that shared logic lives in composable units (CTEs, subqueries, filters) that get assembled into larger queries. This is a different way of organizing SQL than the more common approach of copy-pasting fragments. It may take some experimentation to find the right balance of template granularity and composition for your codebase. Also, as always, a particular RDBMS engine and the evolving shape of the data will ultimately decide which of this code reuse remains viable over time.

* This adds another layer of tooling and complexity to your SQL codebase. I am currently experimenting with a `sqlc_file!()` macro that could help somewhat mimic the amazing SQLx-style compile-time SQL errors that make life dramatically easier.

## Example: Lego Database

The examples below use the [Lego database](https://www.kaggle.com/datasets/rtatman/lego-database) ([SQL](https://raw.githubusercontent.com/neondatabase/postgres-sample-dbs/main/lego.sql)), which has sets, parts, colors, themes, inventories, and the joins between them. It's a good fit because even simple questions ("what parts are in this set, with colors and categories?") require a 4-table join — exactly the kind of logic you'd want to write once and reuse.

### The problem: duplicated joins

Suppose you need to query the full part details for a set. The join chain is always the same:

```sql
-- This 4-table join appears in every query that needs part details for a set.
-- Without sql-composer, you copy-paste it into SELECT, INSERT, and UPDATE queries.
FROM lego_inventory_parts ip
JOIN lego_inventories i ON i.id = ip.inventory_id
JOIN lego_parts p ON p.part_num = ip.part_num
JOIN lego_part_categories pc ON pc.id = p.part_cat_id
JOIN lego_colors c ON c.id = ip.color_id
WHERE i.set_num = '75192-1'
```

Every query that needs part details — listing parts, building reports, updating counts — repeats this join chain. When the schema changes or the DBA adds an index hint, you update it in one place or forget and break something.

### The solution: shared template, composed three ways

Write the shared logic once as a CTE in its own template:

```
sqlc/
  shared/
    set_part_details.sqlc       # The canonical 4-table join, written once
  sets/
    select_set_parts.sqlc       # SELECT — list parts for a set
  reports/
    insert_set_summary.sqlc     # INSERT SELECT — populate a reporting table
  inventory/
    update_spare_counts.sqlc    # UPDATE — sync spare part counts
```

#### [`shared/set_part_details.sqlc`](examples/lego/sqlc/shared/set_part_details.sqlc)

```sql
# Canonical resolution of full part details for a given set.
# Joins inventory_parts -> inventories, parts, part_categories, colors.
#
# This CTE is the single source of truth for "what parts are in a set."
# If the DBA asks you to change join order or add a filter, change it here
# and every query that composes this template picks up the fix.
#
# Used by: sets/select_set_parts.sqlc
#          reports/insert_set_summary.sqlc
#          inventory/update_spare_counts.sqlc
#          shared/filtered_set_parts.sqlc
SELECT
    ip.part_num,
    p.name AS part_name,
    pc.name AS category_name,
    c.name AS color_name,
    c.rgb AS color_rgb,
    c.is_trans,
    ip.quantity,
    ip.is_spare
FROM lego_inventory_parts ip
JOIN lego_inventories i ON i.id = ip.inventory_id
JOIN lego_parts p ON p.part_num = ip.part_num
JOIN lego_part_categories pc ON pc.id = p.part_cat_id
JOIN lego_colors c ON c.id = ip.color_id
WHERE i.set_num = :bind(set_num)
```

This is a complete, valid query. Callers compose it into a CTE — the `WITH ... AS (...)` wrapping lives in the calling template, not here. Every `#` comment is stripped from the output.

#### [`sets/select_set_parts.sqlc`](examples/lego/sqlc/sets/select_set_parts.sqlc) — SELECT

```sql
# List all parts for a set with full details.
# Composes the shared part resolution query into a CTE.
WITH set_part_details AS (
    :compose(shared/set_part_details.sqlc)
)
SELECT
    part_name,
    category_name,
    color_name,
    color_rgb,
    quantity,
    is_spare
FROM set_part_details
ORDER BY category_name, part_name, color_name
```

#### [`reports/insert_set_summary.sqlc`](examples/lego/sqlc/reports/insert_set_summary.sqlc) — INSERT SELECT

```sql
# Populate the per-category part count summary for a set.
# Reuses the same part resolution as the detail query so counts
# are always consistent with what the detail view shows.
WITH set_part_details AS (
    :compose(shared/set_part_details.sqlc)
)
INSERT INTO set_category_summary (set_num, category_name, total_parts, total_spare)
SELECT
    :bind(set_num),
    category_name,
    SUM(quantity) FILTER (WHERE NOT is_spare),
    SUM(quantity) FILTER (WHERE is_spare)
FROM set_part_details
GROUP BY category_name
```

#### [`inventory/update_spare_counts.sqlc`](examples/lego/sqlc/inventory/update_spare_counts.sqlc) — UPDATE

```sql
# Sync the spare part counts in inventory_tracking from the
# canonical part resolution. When the DBA changes the join
# logic in set_part_details, this UPDATE stays in sync.
WITH set_part_details AS (
    :compose(shared/set_part_details.sqlc)
)
UPDATE inventory_tracking it
SET
    spare_count = spd.total_spare,
    updated_at = NOW()
FROM (
    SELECT part_num, SUM(quantity) AS total_spare
    FROM set_part_details
    WHERE is_spare
    GROUP BY part_num
) spd
WHERE it.part_num = spd.part_num
  AND it.set_num = :bind(set_num)
```

All three queries share the same 4-table join. Change the join logic once in `shared/set_part_details.sqlc`, run `cargo sqlc compose`, and every composed `.sql` file is updated. The DBA can read any of the output files and see the full query — no templates, no macros, just SQL.

### Parameterized composition with slots

Sometimes a shared template needs to vary one piece of its logic depending on the caller. For example, the same base query might need different filters for different use cases. Instead of duplicating the entire template, use **slot arguments** — the shared template declares named slots with `@slot_name`, and the caller fills them with concrete file paths.

#### [`shared/filtered_set_parts.sqlc`](examples/lego/sqlc/shared/filtered_set_parts.sqlc) — base query with a `@filter` slot

```sql
# Base query with a pluggable filter. The caller decides which filter to use.
# Composes set_part_details into a CTE, then adds filter as a sibling CTE.
WITH set_part_details AS (
    :compose(shared/set_part_details.sqlc)
),
filter AS (
    :compose(@filter)
)
SELECT p.*
FROM set_part_details p
JOIN filter f ON f.part_num = p.part_num
```

#### [`filters/by_color.sqlc`](examples/lego/sqlc/filters/by_color.sqlc) — a standalone filter query

```sql
# Filter parts by color.
SELECT part_num
FROM lego_inventory_parts ip
JOIN lego_colors c ON c.id = ip.color_id
WHERE c.name = :bind(color_name)
```

#### [`filters/by_category.sqlc`](examples/lego/sqlc/filters/by_category.sqlc) — another standalone filter

```sql
# Filter parts by category.
SELECT ip.part_num
FROM lego_inventory_parts ip
JOIN lego_parts p ON p.part_num = ip.part_num
JOIN lego_part_categories pc ON pc.id = p.part_cat_id
WHERE pc.name = :bind(category_name)
```

#### [`sets/select_colored_parts.sqlc`](examples/lego/sqlc/sets/select_colored_parts.sqlc) — fills `@filter` with the color filter

```sql
:compose(shared/filtered_set_parts.sqlc, @filter = filters/by_color.sqlc)
```

#### [`sets/select_category_parts.sqlc`](examples/lego/sqlc/sets/select_category_parts.sqlc) — fills `@filter` with the category filter

```sql
:compose(shared/filtered_set_parts.sqlc, @filter = filters/by_category.sqlc)
```

Both callers compose the same base query but plug in different filters. The base template is written once; the filter logic is swapped at composition time. Slots are explicitly scoped — a child template does NOT inherit slots from its parent, so there's no spooky action at a distance.

### Union, count, and multi-value bindings

The Lego example also demonstrates combining queries with `:union()`, counting with `:count()`, and multi-value bind parameters for `IN` clauses.

#### [`sets/select_sets_by_themes.sqlc`](examples/lego/sqlc/sets/select_sets_by_themes.sqlc) — multi-value `:bind()` for `IN`

```sql
# Find sets matching any of the given theme IDs.
# Demonstrates multi-value :bind() for IN clauses.
SELECT
    s.set_num,
    s.name,
    s.year,
    t.name AS theme_name,
    s.num_parts
FROM lego_sets s
JOIN lego_themes t ON t.id = s.theme_id
WHERE s.theme_id IN (:bind(theme_ids EXPECTING 1..20))
  AND s.year >= :bind(min_year)
ORDER BY s.year DESC, s.num_parts DESC
```

At runtime, call `compose_with_values()` with the actual value count for `theme_ids`, and the composer expands `:bind(theme_ids)` into the right number of placeholders: `$2, $3, $4` for 3 values.

#### [`reports/combined_theme_sets.sqlc`](examples/lego/sqlc/reports/combined_theme_sets.sqlc) — `:union()`

```sql
# Combine Technic and City sets into one result.
# Demonstrates :union() composing two standalone queries.
:union(queries/technic_sets.sqlc, queries/city_sets.sqlc)
```

Each union source ([`queries/technic_sets.sqlc`](examples/lego/sqlc/queries/technic_sets.sqlc), [`queries/city_sets.sqlc`](examples/lego/sqlc/queries/city_sets.sqlc)) is a complete, standalone query. Bind parameters from both sources are merged alphabetically: `$1`=city_theme, `$2`=min_year, `$3`=technic_theme.

#### [`reports/count_theme_parts.sqlc`](examples/lego/sqlc/reports/count_theme_parts.sqlc) — `:count(DISTINCT)`

```sql
# Count distinct parts across all sets in a theme.
# Demonstrates :count(DISTINCT col OF source).
:count(DISTINCT part_num OF queries/theme_set_parts.sqlc)
```

Wraps [`queries/theme_set_parts.sqlc`](examples/lego/sqlc/queries/theme_set_parts.sqlc) in `SELECT COUNT(DISTINCT part_num) FROM (...)`.

### Running the example

The complete working example with all templates, a Rust binary, and setup instructions is in [`examples/lego/`](examples/lego/). See [`examples/lego/README.md`](examples/lego/README.md) for details.

## Quick Start

### CLI usage

Install `cargo-sqlc` as a cargo subcommand:

```sh
# From crates.io (once published)
cargo install cargo-sqlc

# From source
cargo install --path crates/cargo-sqlc
```

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.

Compose templates into `.sql` files:

```sh
# Compose all templates recursively from sqlc/ -> .sql/
cargo sqlc compose

# The output mirrors the source directory structure:
#   sqlc/sets/select_set_parts.sqlc -> .sql/sets/select_set_parts.sql
#   sqlc/shared/set_part_details.sqlc -> .sql/shared/set_part_details.sql

# With a specific dialect
cargo sqlc compose --dialect mysql

# Skip the sqlx prepare step
cargo sqlc compose --skip-prepare

# Override source/target directories
cargo sqlc compose --source assets/sqlc --target assets/.sql

# Verify that committed .sql files match templates (for CI)
cargo sqlc compose --verify
```

The compose step recursively walks all subdirectories under `--source`, composes every `.sqlc` file, and writes the output to the corresponding path under `--target`. The target directory is wiped and recreated on each run, so deleted or reorganized source files don't leave stale output behind. All composition happens to a temporary directory first — the target is only replaced after every file composes successfully.

After composing, `cargo sqlx prepare` runs automatically to keep the query cache up to date for compile-time checked queries. Set `DATABASE_URL` for this step, or use `--skip-prepare` to skip it.

#### `--verify` mode

```sh
cargo sqlc compose --verify
```

Composes all templates to memory and diffs against the existing target files. Reports changed, missing, and stale files, then exits with code 1 on any mismatch. Use this in CI to ensure committed `.sql` files stay in sync with `.sqlc` sources — analogous to `cargo fmt -- --check`.

#### Environment variables

Directories can be configured via environment variables:

```sh
export SQLC_SOURCE_DIR=src/queries
export SQLC_TARGET_DIR=generated/.sql

# Now just:
cargo sqlc compose
```

Priority: CLI arg > env var > default (`sqlc` / `.sql`).

### Library usage

Add `sql-composer` to your `Cargo.toml`:

```toml
[dependencies]
sql-composer = "0.0.2"
```

Parse a template and compose it into final SQL:

```rust,ignore
use sql_composer::parser::parse_template;
use sql_composer::composer::Composer;
use sql_composer::types::{Dialect, TemplateSource};

let input = "SELECT set_num, name, year FROM lego_sets WHERE year = :bind(year) AND theme_id = :bind(theme_id)";
let template = parse_template(input, TemplateSource::Literal("example".into())).unwrap();

let composer = Composer::new(Dialect::Postgres);
let result = composer.compose(&template).unwrap();

// Bind params are alphabetically ordered for numbered dialects
assert_eq!(result.sql, "SELECT set_num, name, year FROM lego_sets WHERE year = $2 AND theme_id = $1");
assert_eq!(result.bind_params, vec!["theme_id", "year"]);
```

## 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 composition.

### `:bind(name)`

Creates one or more dialect-specific placeholders and adds the name to the bind parameter list.

```sql
SELECT set_num, name, year
FROM lego_sets
WHERE theme_id = :bind(theme_id) AND year >= :bind(min_year)
-- Postgres: ... WHERE theme_id = $2 AND year >= $1
-- MySQL:    ... WHERE theme_id = ? AND year >= ?
-- SQLite:   ... WHERE theme_id = ?2 AND year >= ?1
```

For multi-value bindings (e.g. `IN` clauses), pass multiple values for the same name at compose time:

```sql
SELECT name, rgb FROM lego_colors WHERE id IN (:bind(color_ids))
-- With 3 values -> Postgres: ... WHERE id IN ($1, $2, $3)
```

#### Validation options

`:bind()` supports optional validation constraints:

```sql
-- Require at least 1 value
:bind(color_ids EXPECTING 1)

-- Require between 1 and 10 values
:bind(color_ids EXPECTING 1..10)

-- Allow NULL values
:bind(parent_theme_id NULL)
```

### `:compose(path)` and `:compose(path, @slot = path)`

Include another complete SQL template, resolved from configured search paths.

```sql
# Compose the shared part details CTE, then query a specific category
:compose(shared/set_part_details.sqlc)
SELECT part_name, color_name, quantity
FROM set_part_details
WHERE category_name = :bind(category_name)
ORDER BY quantity DESC
```

Compose targets can be SQL fragments (like a CTE without a `SELECT`) — they just need to produce valid SQL when composed into the calling template. Compose references are resolved against the search paths added via `Composer::add_search_path()`. Circular references are detected and produce an error.

#### Slot arguments

Templates can declare **slots** — named placeholders that the caller fills with concrete file paths:

```sql
# In the shared template: declare a slot
:compose(@filter)

# At the call site: fill the slot
:compose(shared/base.sqlc, @filter = filters/by_color.sqlc)

# Multiple slots
:compose(shared/report.sqlc, @source = data.sqlc, @filter = filters/active.sqlc)

# The target can itself be a slot
:compose(@slot)
:compose(@slot, @inner = detail.sqlc)
```

**Design rules:**
- `@` is only special inside `:compose()`. In regular SQL, `@anything` passes through unchanged.
- Slots are **explicitly scoped**. Child templates do NOT inherit parent slots. Pass them explicitly.
- Missing slots are a compile error.
- Slot names: alphanumeric, hyphens, underscores. No dots.

### `:union(sources...)` and `:count(sources...)`

Combine multiple template sources:

```sql
-- Union queries across different set types
:union(queries/technic_sets.sqlc, queries/city_sets.sqlc)

-- With DISTINCT or ALL modifiers
:union(DISTINCT queries/technic_sets.sqlc, queries/city_sets.sqlc)

-- Count rows from a template
:count(queries/star_wars_sets.sqlc)

-- Count specific columns
:count(set_num, name OF queries/star_wars_sets.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

```toml
[dependencies]
sql-composer = "0.0.2"
sql-composer-rusqlite = "0.0.2"
```

```rust,ignore
use sql_composer::composer::Composer;
use sql_composer::driver::ComposerConnection;
use sql_composer::parser::parse_template;
use sql_composer::types::{Dialect, TemplateSource};
use sql_composer::bind_values;
use sql_composer_rusqlite::SqliteConnection;

let conn = SqliteConnection::open_in_memory().unwrap();
conn.execute("CREATE TABLE lego_sets (set_num TEXT, name TEXT, year INTEGER, theme_id INTEGER, num_parts INTEGER)", []).unwrap();

let input = "SELECT set_num, name FROM lego_sets WHERE year = :bind(year)";
let template = parse_template(input, TemplateSource::Literal("example".into())).unwrap();
let composer = Composer::new(Dialect::Sqlite);

let values = bind_values!("year" => [Box::new(2017i32) as Box<dyn rusqlite::types::ToSql>]);
let (sql, params) = conn.compose(&composer, &template, values).unwrap();

let refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|v| v.as_ref()).collect();
let mut stmt = conn.prepare(&sql).unwrap();
let _rows = stmt.query(refs.as_slice()).unwrap();
```

### DuckDB

```toml
[dependencies]
sql-composer = "0.0.2"
sql-composer-duckdb = "0.0.2"
```

```rust,ignore
use sql_composer::composer::Composer;
use sql_composer::driver::ComposerConnection;
use sql_composer::parser::parse_template;
use sql_composer::types::{Dialect, TemplateSource};
use sql_composer::bind_values;
use sql_composer_duckdb::DuckDbConnection;

let conn = DuckDbConnection::open_in_memory().unwrap();
let input = "SELECT set_num, name FROM lego_sets WHERE year = :bind(year)";
let template = parse_template(input, TemplateSource::Literal("example".into())).unwrap();
// DuckDB uses Postgres-style $N placeholders
let composer = Composer::new(Dialect::Postgres);

let values = bind_values!("year" => [Box::new(2017i32) as Box<dyn duckdb::ToSql>]);
let (sql, params) = conn.compose(&composer, &template, values).unwrap();
```

### PostgreSQL (sync + async)

```toml
[dependencies]
sql-composer = "0.0.2"
sql-composer-postgres = "0.0.2"  # both sync and async enabled by default
# sql-composer-postgres = { version = "0.0.2", default-features = false, features = ["async"] }
```

**Features:** `sync` (enables `postgres` crate), `async` (enables `tokio-postgres`). Both enabled by default.

```rust,ignore
// Async (tokio-postgres)
use sql_composer_postgres::{PgClient, boxed_params};
use sql_composer::driver::ComposerConnectionAsync;

let (client, connection) = tokio_postgres::connect("host=localhost dbname=lego", tokio_postgres::NoTls).await?;
tokio::spawn(connection);
let client = PgClient::from_client(client);

let values = bind_values!("year" => [Box::new(2017i32) as Box<dyn tokio_postgres::types::ToSql + Sync + Send>]);
let (sql, params) = client.compose(&composer, &template, values).await?;
let refs = boxed_params(&params);
let rows = client.query(&sql as &str, &refs).await?;
```

```rust,ignore
// Sync (postgres)
use sql_composer_postgres::{PgConnection, boxed_params_sync};
use sql_composer::driver::ComposerConnection;

let client = postgres::Client::connect("host=localhost dbname=lego", postgres::NoTls)?;
let conn = PgConnection::from_client(client);

let values = bind_values!("year" => [Box::new(2017i32) as Box<dyn postgres::types::ToSql + Sync>]);
let (sql, params) = conn.compose(&composer, &template, values)?;
```

### MySQL (sync + async)

```toml
[dependencies]
sql-composer = "0.0.2"
sql-composer-mysql = "0.0.2"  # both sync and async enabled by default
# sql-composer-mysql = { version = "0.0.2", default-features = false, features = ["async"] }
```

**Features:** `sync` (enables `mysql` crate), `async` (enables `mysql_async`). Both enabled by default.

```rust,ignore
// Async (mysql_async)
use sql_composer_mysql::MysqlConn;
use sql_composer::driver::ComposerConnectionAsync;

let pool = mysql_async::Pool::new("mysql://root@localhost/lego");
let conn = pool.get_conn().await?;
let conn = MysqlConn::from_conn(conn);

let values = bind_values!("year" => [mysql_async::Value::from(2017i32)]);
let (sql, params) = conn.compose(&composer, &template, values).await?;
```

```rust,ignore
// Sync (mysql)
use sql_composer_mysql::MysqlConnection;
use sql_composer::driver::ComposerConnection;

let conn = mysql::Conn::new("mysql://root@localhost/lego")?;
let conn = MysqlConnection::from_conn(conn);

let values = bind_values!("year" => [mysql::Value::from(2017i32)]);
let (sql, params) = conn.compose(&composer, &template, values)?;
```

### sqlx (verification + validation)

```toml
[dependencies]
sql-composer-sqlx = "0.0.2"                                        # postgres verification (default)
# sql-composer-sqlx = { version = "0.0.2", features = ["validate"] } # add offline syntax checking
# sql-composer-sqlx = { version = "0.0.2", 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`).

```rust,ignore
use sql_composer_sqlx::verify_postgres;

// Verify composed queries against a live database
let stmts = vec![&composed_sql];
verify_postgres("postgres://localhost/lego", &stmts).await?;
```

```rust,ignore
use sql_composer_sqlx::validate_syntax;
use sql_composer::Dialect;

// Offline syntax validation (no database needed, requires "validate" feature)
validate_syntax("SELECT set_num, name FROM lego_sets WHERE year = $1", Dialect::Postgres)?;
```

## 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
```

## 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.) |

```toml
# With serde support
sql-composer = { version = "0.0.2", features = ["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 with optional slot arguments |
| `ComposeTarget` | Path or slot reference (`@name`) in a compose target |
| `SlotAssignment` | A `@name = path` slot binding in `:compose()` |
| `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:

1. **Collect** — scan all `:bind()` names into a deduplicated, alphabetically sorted set
2. **Assign** — give each unique name a 1-based index (`$1`, `$2`, ...)
3. **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 are being production tested to flush out remaining API issues.

## License

MIT