sqlstack 1.0.18 → 1.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/readme.md CHANGED
@@ -2,19 +2,31 @@
2
2
 
3
3
  **SQL-first data access for Node.js and TypeScript.**
4
4
 
5
- sqlstack lets you write real SQL, keep it organized next to your code, and use small, composable decorators to add behavior—all without ORM complexity. Think of it as a thin, decorator-powered layer on top of your database driver.
5
+ Write real SQL next to your code, and use small, composable decorators to bind, execute, and shape results—without ORM complexity. If you already like dropping down to “raw queries”, this is for you.
6
+
7
+ ## Table of Contents
8
+
9
+ - [Install](#install)
10
+ - [Quick Start (5 minutes)](#quick-start-5-minutes)
11
+ - [Writing and Extending SQL](#writing-and-extending-sql)
12
+ - [Configuration](#configuration)
13
+ - [SQL Parameters](#sql-parameters)
14
+ - [How It Works](#how-it-works)
15
+ - [Result Types](#result-types)
16
+ - [Error Handling](#error-handling)
17
+ - [Inline SQL](#inline-sql)
18
+ - [Advanced: Direct Database Access](#advanced-direct-database-access)
19
+ - [Database Support](#database-support)
20
+ - [Philosophy](#philosophy)
21
+ - [Contributing](#contributing)
22
+ - [License](#license)
6
23
 
7
- ## Key Features
24
+ ## Install
8
25
 
9
- - **Real SQL in `.sql` files**: No query builders. SQL is the source of truth.
10
- - **TypeScript-first**: Declare your own argument and return types; optional JSON Schema validation.
11
- - **Composable decorators**: `@Single`, `@Only`, `@Exist`, `@Defaults`, `@Page`, `@MissingAsNull`, `@ValidateResult`, `@Transform` (order-independent).
12
- - **Minimal runtime**: A thin executor layer on top of your DB driver (no ORM overhead).
13
- - **Multi-database support**: Postgres, MySQL/MariaDB, SQLite (more adapters planned).
14
- - **Structured errors**: Catch specific errors like `ExactlyOneRowError` and `ValidationError`.
15
- - **Simple configuration**: Register databases once at startup; set per-class and per-method overrides.
26
+ ### Prerequisites
16
27
 
17
- ## Install
28
+ - Node.js 18+ (or a modern LTS runtime).
29
+ - Works with both TypeScript and JavaScript; examples use TypeScript syntax.
18
30
 
19
31
  ```bash
20
32
  npm install sqlstack
@@ -34,6 +46,14 @@ npm install better-sqlite3 # SQLite
34
46
 
35
47
  ## Quick Start (5 minutes)
36
48
 
49
+ At a high level, you:
50
+
51
+ - Register one or more database connections with `SqlStackDB`.
52
+ - Write normal classes with methods, decorate them with `@QueryBinder` and `@Query`.
53
+ - Put SQL in `.sql` files next to those classes.
54
+
55
+ When you call a decorated method, sqlstack loads the SQL, binds parameters from your method arguments, executes it via the selected adapter, then optionally shapes and validates the results via decorators.
56
+
37
57
  ### 1. Register Your Database
38
58
 
39
59
  Create a `boot.ts` (or similar) file at your app's entry point:
@@ -53,13 +73,15 @@ Load this file once during app startup.
53
73
  ### 2. Create a Repository Class
54
74
 
55
75
  ```ts
56
- // users/index.ts
76
+ // src/users/index.ts
57
77
  import { QueryBinder, Query, SqlStackError } from "sqlstack";
58
78
 
59
79
  @QueryBinder() // looks for .sql files in the same folder by default
60
- export class UsersRepo {
80
+ export class UsersRepository {
61
81
  @Query()
62
- async findByEmail(_a: { email: string }): Promise<User[]> {
82
+ async findByEmail(params: { email: string }): Promise<User[]> {
83
+ // Implementation bodies are never called; this method exists
84
+ // only to define the TypeScript signature for @Query.
63
85
  throw new SqlStackError("replaced by @Query");
64
86
  }
65
87
  }
@@ -85,36 +107,177 @@ WHERE email = :email;
85
107
  ### 4. Use Your Repository
86
108
 
87
109
  ```ts
88
- const repo = new UsersRepo();
110
+ const repo = new UsersRepository();
89
111
  const users = await repo.findByEmail({ email: "alice@example.com" });
90
112
  console.log(users); // User[]
91
113
  ```
92
114
 
93
115
  That's it! The `@Query` decorator intercepts the method, loads and executes the SQL, and returns results.
94
116
 
95
- ---
117
+ You can then add more methods on the same repository that use sqlstack’s SQL helpers:
96
118
 
97
- ## How It Works
119
+ ```ts
120
+ @QueryBinder()
121
+ export class UsersRepository {
122
+ @Query()
123
+ async findByEmail(params: { email: string }): Promise<User[]> {
124
+ throw new SqlStackError("replaced by @Query");
125
+ }
98
126
 
99
- ### Execution Pipeline
127
+ @Query()
128
+ async updateProfile(params: { id: string; name?: string; status?: string }): Promise<WriteResult> {
129
+ throw new SqlStackError("replaced by @Query");
130
+ }
131
+
132
+ @Query()
133
+ async createUser(params: { id: string; email: string; name?: string }): Promise<WriteResult> {
134
+ throw new SqlStackError("replaced by @Query");
135
+ }
136
+
137
+ @Query()
138
+ async search(filters: { name?: string; status?: string }): Promise<User[]> {
139
+ throw new SqlStackError("replaced by @Query");
140
+ }
141
+ }
142
+ ```
100
143
 
101
- sqlstack executes decorators in a **fixed, internal order** regardless of how you stack them:
144
+ **users/updateProfile.sql** — partial update with `:update`:
102
145
 
146
+ ```sql
147
+ UPDATE users
148
+ :update(name, status, updated_at = now())
149
+ WHERE id = :id;
103
150
  ```
104
- 1. @Defaults (fill in missing parameter values)
105
- ↓
106
- 2. @MissingAsNull (bind missing named parameters as NULL)
107
- ↓
108
- 3. @Query (execute the SQL)
109
- ↓
110
- 4. @Single/@Only/@Exist/@None (shape the results)
111
- ↓
112
- 5. @ValidateResult (validate against JSON Schema)
113
- ↓
114
- 6. @Transform (map flat rows to nested objects)
151
+
152
+ **users/createUser.sql** — partial insert with `:insert`:
153
+
154
+ ```sql
155
+ INSERT INTO users :insert(id, email, name, status = 'active');
156
+ ```
157
+
158
+ **users/search.sql** — dynamic filters with `:filter`:
159
+
160
+ ```sql
161
+ SELECT id, email, name, status
162
+ FROM users
163
+ WHERE :filter(name, status);
164
+ ```
165
+
166
+ As your needs grow, you evolve the SQL in these files (and use helpers like `:update`, `:insert`, and `:filter`) while keeping the TypeScript method interfaces—and all call sites—unchanged.
167
+
168
+ ### 5. Go Further with SQL Helpers
169
+
170
+ Once you’re comfortable with basic `.sql` files, you can start using:
171
+
172
+ - `:insert(...)` to build column lists and `VALUES` from your named parameters.
173
+ - `:update(...)` to generate `SET` clauses for partial updates.
174
+ - `:filter(...)` to build dynamic `WHERE` / `HAVING` conditions from whichever filters are defined.
175
+
176
+ These helpers expand into plain SQL; the full behavior and examples are covered in the **Writing and Extending SQL** section below.
177
+
178
+ ## Writing and Extending SQL
179
+
180
+ sqlstack is **SQL-first**: you write normal SQL, and sqlstack gives you a few helpers to make it easier to evolve queries over time without changing your method interface.
181
+
182
+ ### Plain SQL in Files
183
+
184
+ Start with regular SQL in `.sql` files:
185
+
186
+ ```sql
187
+ -- users/findByEmail.sql
188
+ SELECT id, email, name, status
189
+ FROM users
190
+ WHERE email = :email;
191
+ ```
192
+
193
+ Later, you can refactor this query (add joins, filters, or switch to a CTE) without changing the TypeScript method that calls it, as long as the returned columns still match your declared return type.
194
+
195
+ ### Extending SQL with Helpers
196
+
197
+ sqlstack adds a small set of macros that expand into standard SQL, so you keep full control. They are designed to feel like “obvious SQL you would have written by hand.”
198
+
199
+ #### `:insert(...)`
200
+
201
+ ```sql
202
+ -- invoices/createInvoice.sql
203
+ INSERT INTO invoices :insert(case_id, invoice_number, invoice_date, due_date, status = 'draft');
204
+ ```
205
+
206
+ ```ts
207
+ await repo.createInvoice({
208
+ case_id: 1,
209
+ invoice_number: "INV-001",
210
+ invoice_date: "2024-01-01",
211
+ // due_date: undefined
212
+ });
213
+ ```
214
+
215
+ ```sql
216
+ -- expands to
217
+ INSERT INTO invoices (case_id, invoice_number, invoice_date, status)
218
+ VALUES (?, ?, ?, 'draft');
219
+ ```
220
+
221
+ #### `:update(...)`
222
+
223
+ ```sql
224
+ -- invoices/updateInvoice.sql
225
+ UPDATE invoices
226
+ :update(updated_at = now(), invoice_date, due_date)
227
+ WHERE id = :id;
228
+ ```
229
+
230
+ ```ts
231
+ await repo.updateInvoice({
232
+ id: 1,
233
+ invoice_date: "2024-01-01",
234
+ // due_date: undefined
235
+ });
236
+ ```
237
+
238
+ ```sql
239
+ -- expands to
240
+ UPDATE invoices
241
+ SET updated_at = now(), invoice_date = ?
242
+ WHERE id = ?;
243
+ ```
244
+
245
+ #### `:filter(...)`
246
+
247
+ ```sql
248
+ -- users/search.sql
249
+ SELECT id, email, name, status
250
+ FROM users
251
+ WHERE :filter(
252
+ name, -- name = ?
253
+ age >= :min_age, -- age >= ?
254
+ status = 'active', -- literal
255
+ name LIKE :search, -- name LIKE ?
256
+ id IN (:ids) -- id IN (?, ?, ?)
257
+ );
258
+ ```
259
+
260
+ ```ts
261
+ await repo.search({
262
+ name: "Alice",
263
+ min_age: 18,
264
+ search: "%ali%",
265
+ ids: [1, 2, 3],
266
+ });
267
+ ```
268
+
269
+ ```sql
270
+ -- expands to
271
+ SELECT id, email, name, status
272
+ FROM users
273
+ WHERE name = ?
274
+ AND age >= ?
275
+ AND status = 'active'
276
+ AND name LIKE ?
277
+ AND id IN (?, ?, ?);
115
278
  ```
116
279
 
117
- You can list decorators in any order; they always execute in this pipeline. See [docs/decorators.md](docs/decorators.md) for details.
280
+ You can gradually adopt these helpers as your queries become more dynamic; they never hide the SQL that is ultimately sent to the database, they just generate the repetitive parts.
118
281
 
119
282
  ### Validation Controls
120
283
 
@@ -126,6 +289,35 @@ Validation (via `@ValidateResult`) is **enabled by default**. Control it with en
126
289
 
127
290
  ---
128
291
 
292
+ ## How It Works
293
+
294
+ ### Execution Pipeline
295
+
296
+ When you invoke a decorated method, sqlstack:
297
+
298
+ - Merges any default values into your arguments.
299
+ - Loads SQL (from a file or inline), binds placeholders, and executes it via the chosen adapter.
300
+ - If the adapter returned rows (a SELECT/WITH), it shapes and optionally validates those rows.
301
+ - If the adapter returned a write result (INSERT/UPDATE/DELETE/DDL), it returns that result object as-is.
302
+
303
+ Decorators run in a **fixed, internal order** regardless of how you stack them:
304
+
305
+ ```
306
+ 1. @Defaults (fill in missing parameter values)
307
+ ↓
308
+ 2. @Query (execute the SQL)
309
+ ↓
310
+ 3. @Single/@Only/@Exist/@None (shape the results)
311
+ ↓
312
+ 4. @ValidateResult (validate against JSON Schema)
313
+ ↓
314
+ 5. @Transform (map flat rows to nested objects)
315
+ ```
316
+
317
+ You can list decorators in any order; they always execute in this pipeline. See docs/decorators.md for details.
318
+
319
+ ---
320
+
129
321
  ## Configuration
130
322
 
131
323
  ### Register Databases
@@ -262,321 +454,6 @@ await repo.listByIds({ ids });
262
454
  - `null` → SQL `NULL`
263
455
  - `undefined` → throws an error; always provide a value or omit the parameter
264
456
 
265
- ### :update() Syntax for Partial Updates
266
-
267
- The `:update()` syntax simplifies UPDATE statements by automatically generating SET clauses. It supports both parameterized columns and literal SQL expressions, making partial updates easy without needing `COALESCE` or `IFNULL`.
268
-
269
- **Key Features:**
270
- - Automatically generates `SET` keyword (don't write it yourself)
271
- - Literal expressions: `col = datetime('now')` → always included
272
- - Parameterized columns: `col` → only included if defined in args
273
- - Mixed usage: combine literals and parameterized columns
274
-
275
- **Basic Usage:**
276
-
277
- ```sql
278
- -- updateInvoice.sql
279
- UPDATE invoices
280
- :update(invoice_date, due_date, subtotal_cents)
281
- WHERE id = :id
282
- ```
283
-
284
- ```ts
285
- await repo.updateInvoice({
286
- invoice_date: '2024-01-01',
287
- id: 1
288
- });
289
- // Generates: UPDATE invoices SET invoice_date = ? WHERE id = ?
290
- // due_date and subtotal_cents are skipped (undefined)
291
- ```
292
-
293
- **Literal SQL Expressions:**
294
-
295
- Use `=` to include literal SQL expressions that are always included:
296
-
297
- ```sql
298
- UPDATE invoices
299
- :update(updated_at = datetime('now'), invoice_date, due_date)
300
- WHERE id = :id
301
- ```
302
-
303
- ```ts
304
- await repo.updateInvoice({
305
- invoice_date: '2024-01-01',
306
- id: 1
307
- });
308
- // Generates: UPDATE invoices SET updated_at = datetime('now'), invoice_date = ? WHERE id = ?
309
- ```
310
-
311
- **Mixed Literal and Parameterized:**
312
-
313
- ```sql
314
- UPDATE invoices
315
- :update(
316
- updated_at = datetime('now'),
317
- invoice_date,
318
- due_date,
319
- status = 'active'
320
- )
321
- WHERE id = :id
322
- ```
323
-
324
- ```ts
325
- await repo.updateInvoice({
326
- invoice_date: '2024-01-01',
327
- due_date: '2024-01-15',
328
- id: 1
329
- });
330
- // Generates: UPDATE invoices SET updated_at = datetime('now'), invoice_date = ?, due_date = ?, status = 'active' WHERE id = ?
331
- ```
332
-
333
- **Notes:**
334
- - `:update()` only works with named parameters (not positional)
335
- - Literal expressions are always included
336
- - Parameterized columns are only included if defined and not `undefined`
337
- - If all parameterized columns are undefined (and no literals), throws an error
338
- - Case-insensitive: `:update`, `:UPDATE`, `:Update` all work
339
-
340
- ### :insert() Syntax for INSERT Statements
341
-
342
- The `:insert()` syntax simplifies INSERT statements by automatically generating both the column list and VALUES clause. It supports both parameterized columns and literal SQL expressions, making INSERT statements much more concise.
343
-
344
- **Key Features:**
345
- - Automatically generates column list `(col1, col2, ...)` and VALUES clause `VALUES (?, ?, ...)`
346
- - Literal expressions: `col = datetime('now')` → column name and literal value included
347
- - Parameterized columns: `col` → only included if defined in args
348
- - Mixed usage: combine literals and parameterized columns
349
-
350
- **Basic Usage:**
351
-
352
- ```sql
353
- -- createInvoice.sql
354
- INSERT INTO invoices :insert(case_id, invoice_number, invoice_date, due_date)
355
- ```
356
-
357
- ```ts
358
- await repo.createInvoice({
359
- case_id: 1,
360
- invoice_number: 'INV-001',
361
- invoice_date: '2024-01-01',
362
- due_date: '2024-01-15'
363
- });
364
- // Generates: INSERT INTO invoices (case_id, invoice_number, invoice_date, due_date) VALUES (?, ?, ?, ?)
365
- // Params: [1, 'INV-001', '2024-01-01', '2024-01-15']
366
- ```
367
-
368
- **With Undefined Columns:**
369
-
370
- ```sql
371
- INSERT INTO invoices :insert(case_id, invoice_number, invoice_date, due_date)
372
- ```
373
-
374
- ```ts
375
- await repo.createInvoice({
376
- case_id: 1,
377
- invoice_number: 'INV-001'
378
- });
379
- // Generates: INSERT INTO invoices (case_id, invoice_number) VALUES (?, ?)
380
- // invoice_date and due_date are skipped (undefined)
381
- ```
382
-
383
- **Literal SQL Expressions:**
384
-
385
- Use `=` to include literal SQL expressions that are always included:
386
-
387
- ```sql
388
- INSERT INTO invoices :insert(created_at = datetime('now'), case_id, invoice_number)
389
- ```
390
-
391
- ```ts
392
- await repo.createInvoice({
393
- case_id: 1,
394
- invoice_number: 'INV-001'
395
- });
396
- // Generates: INSERT INTO invoices (created_at, case_id, invoice_number) VALUES (datetime('now'), ?, ?)
397
- // Params: [1, 'INV-001']
398
- ```
399
-
400
- **Mixed Literal and Parameterized:**
401
-
402
- ```sql
403
- INSERT INTO invoices :insert(
404
- created_at = datetime('now'),
405
- case_id,
406
- invoice_number,
407
- status = 'draft'
408
- )
409
- ```
410
-
411
- ```ts
412
- await repo.createInvoice({
413
- case_id: 1,
414
- invoice_number: 'INV-001'
415
- });
416
- // Generates: INSERT INTO invoices (created_at, case_id, invoice_number, status) VALUES (datetime('now'), ?, ?, 'draft')
417
- // Params: [1, 'INV-001']
418
- ```
419
-
420
- **Notes:**
421
- - `:insert()` only works with named parameters (not positional)
422
- - Literal expressions are always included (both column name and value)
423
- - Parameterized columns are only included if defined and not `undefined`
424
- - If all parameterized columns are undefined (and no literals), throws an error
425
- - Case-insensitive: `:insert`, `:INSERT`, `:Insert` all work
426
-
427
- ### :filter() Syntax for WHERE/HAVING Clauses
428
-
429
- The `:filter()` syntax simplifies WHERE and HAVING clauses by automatically generating conditions. It supports simple column names (equality), literal comparisons, and parameterized comparisons, making dynamic filtering much easier.
430
-
431
- **Key Features:**
432
- - Simple column names → equality: `name` → `name = ?`
433
- - Literal comparisons → included as-is: `age > 30` → `age > 30`
434
- - Parameterized comparisons → bind parameters: `age > :min_age` → `age > ?`
435
- - Mixed usage: combine all three types
436
- - Works in both WHERE and HAVING clauses
437
- - Conditions are joined with `AND`
438
-
439
- **Basic Usage:**
440
-
441
- ```sql
442
- -- findUsers.sql
443
- SELECT * FROM users WHERE :filter(name, age)
444
- ```
445
-
446
- ```ts
447
- await repo.findUsers({ name: 'John', age: 30 });
448
- // Generates: SELECT * FROM users WHERE name = ? AND age = ?
449
- // Params: ['John', 30]
450
- ```
451
-
452
- **With Some Undefined Columns:**
453
-
454
- ```sql
455
- SELECT * FROM users WHERE :filter(name, age, status)
456
- ```
457
-
458
- ```ts
459
- await repo.findUsers({ name: 'John' });
460
- // Generates: SELECT * FROM users WHERE name = ?
461
- // age and status are skipped (undefined)
462
- ```
463
-
464
- **Literal Comparisons:**
465
-
466
- ```sql
467
- SELECT * FROM users WHERE :filter(age > 30, status = 'active')
468
- ```
469
-
470
- ```ts
471
- await repo.findUsers({});
472
- // Generates: SELECT * FROM users WHERE age > 30 AND status = 'active'
473
- // Params: []
474
- ```
475
-
476
- **Parameterized Comparisons:**
477
-
478
- ```sql
479
- SELECT * FROM users WHERE :filter(age > :min_age, name LIKE :search)
480
- ```
481
-
482
- ```ts
483
- await repo.findUsers({ min_age: 30, search: '%John%' });
484
- // Generates: SELECT * FROM users WHERE age > ? AND name LIKE ?
485
- // Params: [30, '%John%']
486
- ```
487
-
488
- **Mixed Usage:**
489
-
490
- ```sql
491
- SELECT * FROM users WHERE :filter(name, age > :min_age, status = 'active')
492
- ```
493
-
494
- ```ts
495
- await repo.findUsers({ name: 'John', min_age: 30 });
496
- // Generates: SELECT * FROM users WHERE name = ? AND age > ? AND status = 'active'
497
- // Params: ['John', 30]
498
- ```
499
-
500
- **With Array Parameters (IN clause):**
501
-
502
- ```sql
503
- SELECT * FROM users WHERE :filter(id IN (:ids))
504
- ```
505
-
506
- ```ts
507
- await repo.findUsers({ ids: [1, 2, 3] });
508
- // Generates: SELECT * FROM users WHERE id IN (?, ?, ?)
509
- // Params: [1, 2, 3]
510
- ```
511
-
512
- **With :or() Expansion:**
513
-
514
- The `:or()` syntax expands array parameters into multiple OR conditions:
515
-
516
- ```sql
517
- SELECT * FROM users WHERE :filter(name = :or(:names), age > :min_age)
518
- ```
519
-
520
- ```ts
521
- await repo.findUsers({ names: ['John', 'Jane', 'Bob'], min_age: 30 });
522
- // Generates: SELECT * FROM users WHERE (name = ? OR name = ? OR name = ?) AND age > ?
523
- // Params: ['John', 'Jane', 'Bob', 30]
524
- ```
525
-
526
- **Complex :or() Usage:**
527
-
528
- ```sql
529
- SELECT * FROM users WHERE :filter(
530
- (name = :or(:names)),
531
- age > :min_age,
532
- status = 'active',
533
- (department = :or(:depts) OR role = :or(:roles))
534
- )
535
- ```
536
-
537
- ```ts
538
- await repo.findUsers({
539
- names: ['John', 'Jane'],
540
- min_age: 25,
541
- depts: ['Engineering'],
542
- roles: ['Manager']
543
- });
544
- // Generates: SELECT * FROM users WHERE (name = ? OR name = ?) AND age > ? AND status = 'active' AND (department = ? OR role = ?)
545
- // Params: ['John', 'Jane', 25, 'Engineering', 'Manager']
546
- ```
547
-
548
- **HAVING Clause:**
549
-
550
- ```sql
551
- SELECT department, COUNT(*) as count
552
- FROM employees
553
- GROUP BY department
554
- HAVING :filter(count > :min_count, department = 'Sales')
555
- ```
556
-
557
- ```ts
558
- await repo.findDepartments({ min_count: 10 });
559
- // Generates: SELECT department, COUNT(*) as count FROM employees GROUP BY department HAVING count > ? AND department = 'Sales'
560
- // Params: [10]
561
- ```
562
-
563
- **Supported Operators:**
564
-
565
- - Comparison: `=`, `!=`, `<>`, `>`, `<`, `>=`, `<=`
566
- - Pattern matching: `LIKE`
567
- - Membership: `IN`
568
- - Range: `BETWEEN`
569
- - Null checks: `IS NULL`, `IS NOT NULL`
570
-
571
- **Notes:**
572
- - `:filter()` only works with named parameters (not positional)
573
- - Literal expressions are always included
574
- - Parameterized conditions are only included if the parameter is defined and not `undefined`
575
- - If all parameterized conditions are undefined (and no literals), throws an error
576
- - Case-insensitive: `:filter`, `:FILTER`, `:Filter` all work
577
- - `:or()` expands arrays into multiple OR conditions
578
- - Operators inside string literals are not treated as operators (e.g., `name = 'value > test'`)
579
-
580
457
  ## SQL File Resolution
581
458
 
582
459
  The `@Query()` decorator locates `.sql` files using a simple, predictable rule. See [docs/file_resolution.md](docs/file_resolution.md) for complete details.
@@ -665,7 +542,7 @@ import { QueryBinder, Page, Query, SqlStackError } from "sqlstack";
665
542
  class UsersRepo {
666
543
  @Page(10) // limit = 10, pages 1-based
667
544
  @Query()
668
- async listUsers(_a: { page?: number; status?: string }): Promise<User[]> {
545
+ async listUsers(params: { status?: string; page?: number }): Promise<User[]> {
669
546
  throw new SqlStackError();
670
547
  }
671
548
  }
@@ -688,10 +565,10 @@ await repo.listUsers({ status: "active", page: 2 }); // page 2 of 10 per page
688
565
 
689
566
  **Zero-based pages:** Use `@Page(10, { zeroBase: true })`.
690
567
 
691
- **Trailing options:** Pass `{ page: N }` as the last argument for both named and positional styles:
568
+ **Trailing options (multiple parameters):** When your method has more than one parameter, pass `{ page: N }` as the last argument:
692
569
  ```ts
693
- await repo.list({ user_id: 1 }, { page: 2 }); // named
694
- await repo.list(userId, { page: 2 }); // positional
570
+ await repo.list({ user_id: 1 }, { page: 2 }); // named-style params + trailing options
571
+ await repo.list(userId, { page: 2 }); // positional id + trailing options
695
572
  ```
696
573
 
697
574
  ### Result Shape Decorators
@@ -773,11 +650,9 @@ Example schema:
773
650
  }
774
651
  ```
775
652
 
776
- Validation is **enabled by default**; control with `SQLSTACK_ENABLE_VALIDATION` and `SQLSTACK_DISABLE_VALIDATION` environment variables.
777
-
778
653
  ### `@Transform`
779
654
 
780
- Map flat SQL rows to nested objects using a nesting spec:
655
+ Use `DTO()` with `@Transform` to turn flat joined rows into nested objects, automatically deduplicating parents and children.
781
656
 
782
657
  ```ts
783
658
  import { DTO, Transform, Query, QueryBinder } from 'sqlstack';
@@ -817,20 +692,60 @@ class Repo {
817
692
  }
818
693
  ```
819
694
 
820
- **Features:**
821
- - Arbitrary depth: nest collections by adding objects under new keys.
822
- - Deduplication: nodes are de-duplicated by their `id` at each level.
823
- - Null-safe: branches with `NULL` child ids are skipped.
824
- - Order: sibling order follows your SQL `ORDER BY`.
825
- - Direct mapping: use `'target.path': 'source_column'` to populate nested properties from flat columns.
695
+ Given flat rows like:
696
+
697
+ ```ts
698
+ [
699
+ { group_id: 'g1', group_name: 'Chest', muscle_id: 'm1', muscle_name: 'Pecs', exercise_id: 'e1', exercise_name: 'Bench' },
700
+ { group_id: 'g1', group_name: 'Chest', muscle_id: 'm1', muscle_name: 'Pecs', exercise_id: 'e2', exercise_name: 'Incline' },
701
+ { group_id: 'g1', group_name: 'Chest', muscle_id: 'm2', muscle_name: 'Delts', exercise_id: 'e3', exercise_name: 'Raise' },
702
+ ]
703
+ ```
704
+
705
+ the transform returns:
706
+
707
+ ```ts
708
+ [
709
+ {
710
+ id: 'g1',
711
+ name: 'Chest',
712
+ muscles: [
713
+ {
714
+ id: 'm1',
715
+ name: 'Pecs',
716
+ exercises: [
717
+ { id: 'e1', name: 'Bench' },
718
+ { id: 'e2', name: 'Incline' },
719
+ ],
720
+ },
721
+ {
722
+ id: 'm2',
723
+ name: 'Delts',
724
+ exercises: [
725
+ { id: 'e3', name: 'Raise' },
726
+ ],
727
+ },
728
+ ],
729
+ },
730
+ ];
731
+ ```
732
+
733
+ **Direct mapping with dot paths**
826
734
 
827
- Example with direct mapping:
828
735
  ```ts
829
736
  // Row: { id: '1', muscle: 'biceps', group: 'arms' }
830
737
  const toSimple = DTO({ id: 'id', name: 'muscle', 'group.name': 'group' } as any, { single: true });
831
738
  // Result: { id: '1', name: 'biceps', group: { name: 'arms' } }
832
739
  ```
833
740
 
741
+ **More behaviors**
742
+
743
+ - If you omit `id` at a level, DTO uses all mapped fields there as a composite identity to dedupe children.
744
+ - Child branches where all identifying columns are `NULL` are skipped (common with LEFT JOINs).
745
+ - Ordering of children follows your SQL `ORDER BY`; sort in SQL, not in the DTO.
746
+
747
+ See `docs/DTO.md` for a full guide with more patterns and edge cases.
748
+
834
749
  ---
835
750
 
836
751
  ## Result Types
@@ -893,6 +808,16 @@ type PostgresWriteResult = WriteResult & {
893
808
  };
894
809
  ```
895
810
 
811
+ ### Evolving Queries Without Breaking Callers
812
+
813
+ Your method signature is the contract; the SQL is an implementation detail.
814
+
815
+ - Call sites depend only on the TypeScript interface (`args` and return type), not on the shape of the underlying SQL file.
816
+ - You can freely refactor a `.sql` file to add joins, filters, or pagination, or switch from a simple query to a CTE, without touching call sites—as long as the returned columns still match your declared return type (and any validation schema).
817
+ - Decorators like `@Transform` make it easy to map new or reorganized SQL results back into the same nested DTO shape, so you can take advantage of more advanced SQL while keeping your public interface stable.
818
+
819
+ This lets you start with very simple queries and incrementally adopt more complex SQL over time, with TypeScript and optional JSON Schema validation guarding the boundary.
820
+
896
821
  ---
897
822
 
898
823
  ## Error Handling
@@ -993,7 +918,7 @@ The placeholder style is inferred from the selected database. Override per-metho
993
918
 
994
919
  **SQL-first:** SQL is the source of truth. No hidden queries.
995
920
 
996
- **Decorators as metadata:** Decorators (`@Defaults`, `@MissingAsNull`, `@Single`, `@Only`, `@Exist`, `@ValidateResult`, `@Transform`) are metadata labels. They always execute in a fixed internal order, so decorator order doesn't matter on your class.
921
+ **Decorators as metadata:** Decorators (`@Defaults`, `@Single`, `@Only`, `@Exist`, `@ValidateResult`, `@Transform`) are metadata labels. They always execute in a fixed internal order, so decorator order doesn't matter on your class.
997
922
 
998
923
  **Escape hatches:** Use inline SQL for simple queries; keep complex SQL in `.sql` files. You stay in control.
999
924