sqlstack 1.0.13 → 1.0.15

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.
Files changed (54) hide show
  1. package/dist/cjs/binders/sqlBinder.d.ts +2 -1
  2. package/dist/cjs/binders/sqlBinder.d.ts.map +1 -1
  3. package/dist/cjs/binders/sqlBinder.js +3 -1
  4. package/dist/cjs/binders/sqlBinder.js.map +1 -1
  5. package/dist/cjs/core/errors.d.ts +4 -0
  6. package/dist/cjs/core/errors.d.ts.map +1 -1
  7. package/dist/cjs/core/errors.js +9 -1
  8. package/dist/cjs/core/errors.js.map +1 -1
  9. package/dist/cjs/core/metadata.d.ts +3 -0
  10. package/dist/cjs/core/metadata.d.ts.map +1 -1
  11. package/dist/cjs/core/metadata.js.map +1 -1
  12. package/dist/cjs/core/sqlParams.d.ts +6 -0
  13. package/dist/cjs/core/sqlParams.d.ts.map +1 -0
  14. package/dist/cjs/core/sqlParams.js +120 -0
  15. package/dist/cjs/core/sqlParams.js.map +1 -0
  16. package/dist/cjs/core/validator.d.ts +0 -5
  17. package/dist/cjs/core/validator.d.ts.map +1 -1
  18. package/dist/cjs/core/validator.js +2 -11
  19. package/dist/cjs/core/validator.js.map +1 -1
  20. package/dist/cjs/decorators/missingAsNull.d.ts +34 -0
  21. package/dist/cjs/decorators/missingAsNull.d.ts.map +1 -0
  22. package/dist/cjs/decorators/missingAsNull.js +40 -0
  23. package/dist/cjs/decorators/missingAsNull.js.map +1 -0
  24. package/dist/cjs/decorators/query.d.ts.map +1 -1
  25. package/dist/cjs/decorators/query.js +23 -0
  26. package/dist/cjs/decorators/query.js.map +1 -1
  27. package/dist/cjs/index.d.ts +5 -4
  28. package/dist/cjs/index.d.ts.map +1 -1
  29. package/dist/cjs/index.js +10 -5
  30. package/dist/cjs/index.js.map +1 -1
  31. package/dist/esm/adapters/index.js +3 -3
  32. package/dist/esm/binders/sqlBinder.js +3 -0
  33. package/dist/esm/binders/sqlBinder.js.map +1 -1
  34. package/dist/esm/core/errors.js +7 -0
  35. package/dist/esm/core/errors.js.map +1 -1
  36. package/dist/esm/core/metadata.js.map +1 -1
  37. package/dist/esm/core/sqlParams.js +117 -0
  38. package/dist/esm/core/sqlParams.js.map +1 -0
  39. package/dist/esm/core/validator.js +1 -8
  40. package/dist/esm/core/validator.js.map +1 -1
  41. package/dist/esm/decorators/defaults.js +1 -1
  42. package/dist/esm/decorators/missingAsNull.js +37 -0
  43. package/dist/esm/decorators/missingAsNull.js.map +1 -0
  44. package/dist/esm/decorators/page.js +1 -1
  45. package/dist/esm/decorators/query.js +29 -6
  46. package/dist/esm/decorators/query.js.map +1 -1
  47. package/dist/esm/decorators/queryBinder.js +1 -1
  48. package/dist/esm/decorators/resultShape.js +1 -1
  49. package/dist/esm/decorators/transform.js +1 -1
  50. package/dist/esm/decorators/validateResult.js +1 -1
  51. package/dist/esm/index.js +19 -14
  52. package/dist/esm/index.js.map +1 -1
  53. package/package.json +2 -2
  54. package/readme.md +458 -230
package/readme.md CHANGED
@@ -1,16 +1,18 @@
1
1
  # sqlstack
2
2
 
3
- SQL-first data access for Node.js and TypeScript.
3
+ **SQL-first data access for Node.js and TypeScript.**
4
4
 
5
- Write real SQL, keep it next to your code, and use small, composable decorators to add behavior like defaults, single-row results, and result validation — without ORM complexity.
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.
6
6
 
7
- - Real SQL in `.sql` files
8
- - TypeScript-first: declare args/return types yourself, or plug in validation/typegen
9
- - Composable decorators: `@Single`, `@Only`, `@Exist`, `@Defaults`, `@ValidateResult` (order-independent)
10
- - Minimal runtime: a thin executor layer on top of your DB driver
11
- - Works with Postgres, MySQL/MariaDB, SQLite (more adapters coming)
12
- - Structured errors via `SqlStackError` base class
13
- - Configurable connections via a simple registry (per-class and per-method)
7
+ ## Key Features
8
+
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.
14
16
 
15
17
  ## Install
16
18
 
@@ -30,14 +32,107 @@ npm install mysql2 # MySQL / MariaDB
30
32
  npm install better-sqlite3 # SQLite
31
33
  ```
32
34
 
33
- ## Configure Databases
35
+ ## Quick Start (5 minutes)
34
36
 
35
- Register your database connections once at application startup and set a default.
37
+ ### 1. Register Your Database
36
38
 
37
- boot.ts
39
+ Create a `boot.ts` (or similar) file at your app's entry point:
40
+
41
+ ```ts
42
+ // boot.ts
43
+ import { SqlStackDB } from "sqlstack/registry";
44
+ import { createPgDb } from "sqlstack/adapters";
45
+
46
+ SqlStackDB
47
+ .register("primary", createPgDb(process.env.DATABASE_URL!))
48
+ .setDefault("primary");
49
+ ```
50
+
51
+ Load this file once during app startup.
52
+
53
+ ### 2. Create a Repository Class
54
+
55
+ ```ts
56
+ // users/index.ts
57
+ import { QueryBinder, Query, SqlStackError } from "sqlstack";
58
+
59
+ @QueryBinder() // looks for .sql files in the same folder by default
60
+ export class UsersRepo {
61
+ @Query()
62
+ async findByEmail(_a: { email: string }): Promise<User[]> {
63
+ throw new SqlStackError("replaced by @Query");
64
+ }
65
+ }
66
+ ```
67
+
68
+ ### 3. Create a `.sql` File
69
+
70
+ Place the SQL file next to your class:
71
+
72
+ ```
73
+ users/
74
+ index.ts
75
+ findByEmail.sql
76
+ ```
77
+
78
+ **users/findByEmail.sql**
79
+ ```sql
80
+ SELECT id, email, name, status, created_at
81
+ FROM users
82
+ WHERE email = :email;
83
+ ```
84
+
85
+ ### 4. Use Your Repository
86
+
87
+ ```ts
88
+ const repo = new UsersRepo();
89
+ const users = await repo.findByEmail({ email: "alice@example.com" });
90
+ console.log(users); // User[]
91
+ ```
92
+
93
+ That's it! The `@Query` decorator intercepts the method, loads and executes the SQL, and returns results.
94
+
95
+ ---
96
+
97
+ ## How It Works
98
+
99
+ ### Execution Pipeline
100
+
101
+ sqlstack executes decorators in a **fixed, internal order** regardless of how you stack them:
102
+
103
+ ```
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)
115
+ ```
116
+
117
+ You can list decorators in any order; they always execute in this pipeline. See [docs/decorators.md](docs/decorators.md) for details.
118
+
119
+ ### Validation Controls
120
+
121
+ Validation (via `@ValidateResult`) is **enabled by default**. Control it with environment variables:
122
+
123
+ - `SQLSTACK_ENABLE_VALIDATION=true` → force validation ON
124
+ - `SQLSTACK_DISABLE_VALIDATION=true` → force validation OFF
125
+ - Otherwise → enabled
126
+
127
+ ---
128
+
129
+ ## Configuration
130
+
131
+ ### Register Databases
132
+
133
+ Use `SqlStackDB.register(name, dbInstance)` to set up connections:
38
134
 
39
135
  ```ts
40
- // boot.ts — register your connections
41
136
  import { SqlStackDB } from "sqlstack/registry";
42
137
  import { createPgDb, createMysqlDb, createSqliteDb } from "sqlstack/adapters";
43
138
 
@@ -48,7 +143,7 @@ SqlStackDB
48
143
  .setDefault("primary");
49
144
  ```
50
145
 
51
- Adapters can receive either a connection string or an existing driver instance (pool/connection):
146
+ Adapters accept either a connection string or an existing driver instance:
52
147
 
53
148
  ```ts
54
149
  import { Pool as PgPool } from 'pg';
@@ -68,14 +163,15 @@ const sqlite = new BetterSqlite3(':memory:');
68
163
  SqlStackDB.register('sqlite-existing', createSqliteDb(sqlite));
69
164
  ```
70
165
 
71
- Then, choose a database per repository (class-level default):
166
+ ### Set Database per Class
167
+
168
+ Use `@QueryBinder({ db: "name" })` to set a default database for all methods in a class:
72
169
 
73
170
  ```ts
74
171
  import { QueryBinder, Query, SqlStackError } from "sqlstack";
75
172
 
76
- @QueryBinder({ db: "analytics" }) // class default
173
+ @QueryBinder({ db: "analytics" }) // all methods use "analytics"
77
174
  export class ReportsRepo {
78
- // Uses "analytics" unless overridden below
79
175
  @Query()
80
176
  async topPages(_a: { since: string }): Promise<Row[]> {
81
177
  throw new SqlStackError("replaced");
@@ -83,13 +179,11 @@ export class ReportsRepo {
83
179
  }
84
180
  ```
85
181
 
86
- Load `boot.ts` once during app startup (for example, in your server entry).
182
+ ### Override Database per Method
87
183
 
88
- Per-method override (use a different DB for a specific query):
184
+ Use `@Query({ db: "name" })` to override the class default for a specific method:
89
185
 
90
186
  ```ts
91
- import { QueryBinder, Query, SqlStackError } from "sqlstack";
92
-
93
187
  @QueryBinder({ db: "primary" }) // default for this class
94
188
  export class MixedRepo {
95
189
  @Query() // uses "primary"
@@ -104,52 +198,15 @@ export class MixedRepo {
104
198
  }
105
199
  ```
106
200
 
107
- ## Quick Start
108
-
109
- 1) Create a repository class
110
-
111
- ```ts
112
- import { QueryBinder, Query, SqlStackError } from "sqlstack";
113
-
114
- @QueryBinder() // looks for .sql files in the same folder by default
115
- export class UsersRepo {
116
- @Query()
117
- async findByEmail(_a: { email: string }): Promise<User[]> {
118
- throw new SqlStackError("replaced by @Query");
119
- }
120
- }
121
- ```
122
-
123
- 2) Add the SQL file next to your class
124
-
125
- ```
126
- users/
127
- index.ts
128
- findByEmail.sql
129
- ```
130
-
131
- findByEmail.sql
132
-
133
- ```sql
134
- SELECT id, email, name, status, created_at
135
- FROM users
136
- WHERE email = :email;
137
- ```
138
-
139
- 3) Call your repository
140
-
141
- ```ts
142
- const repo = new UsersRepo();
143
- const rows = await repo.findByEmail({ email: "alice@example.com" });
144
- ```
201
+ ---
145
202
 
146
203
  ## SQL Parameters
147
204
 
148
- Write parameters directly in your `.sql` files and pass values from your method call.
205
+ Pass parameters from your method call to the SQL using named or positional placeholders.
149
206
 
150
- ### Named parameters
207
+ ### Named Parameters
151
208
 
152
- Use `:name` placeholders that map to properties of a single object argument.
209
+ Use `:name` placeholders in SQL; pass an object from your method:
153
210
 
154
211
  ```sql
155
212
  -- findByEmail.sql
@@ -162,9 +219,9 @@ WHERE email = :email AND status = :status;
162
219
  await repo.findByEmail({ email: "alice@example.com", status: "active" });
163
220
  ```
164
221
 
165
- ### Positional parameters
222
+ ### Positional Parameters
166
223
 
167
- Use `:arg1`, `:arg2`, … and pass values by position. Indexing starts at 1.
224
+ Use `:arg1`, `:arg2`, etc. (1-based indexing); pass values by position:
168
225
 
169
226
  ```sql
170
227
  -- findById.sql
@@ -177,11 +234,11 @@ WHERE id = :arg1 AND org_id = :arg2;
177
234
  await repo.findById("user-123", "org-456");
178
235
  ```
179
236
 
180
- Use either named or positional parameters within a single query (do not mix both).
237
+ **Do not mix named and positional parameters in a single query.**
181
238
 
182
- ### Array parameters
239
+ ### Array Parameters
183
240
 
184
- Pass arrays to expand into `IN (...)` lists.
241
+ Pass arrays to expand into `IN (...)` lists:
185
242
 
186
243
  ```sql
187
244
  -- listByIds.sql
@@ -194,28 +251,125 @@ WHERE id IN (:ids);
194
251
  await repo.listByIds({ ids: ["user-1", "user-2", "user-3"] });
195
252
  ```
196
253
 
197
- Note: For empty arrays, prefer an early return in your code (e.g., `if (!ids.length) return [];`) to avoid generating invalid SQL.
254
+ **Tip:** For empty arrays, return early from your method to avoid invalid SQL:
255
+ ```ts
256
+ if (!ids.length) return [];
257
+ await repo.listByIds({ ids });
258
+ ```
259
+
260
+ ### Null and Undefined
261
+
262
+ - `null` → SQL `NULL`
263
+ - `undefined` → throws an error; always provide a value or omit the parameter
264
+
265
+ ### Automatic NULL Binding with `@MissingAsNull`
266
+
267
+ By default, missing or undefined named parameters throw an error. Use `@MissingAsNull` to automatically bind missing parameters as `NULL` instead, enabling partial updates with `COALESCE`/`IFNULL`:
268
+
269
+ ```ts
270
+ import { QueryBinder, MissingAsNull, Query, SqlStackError } from "sqlstack";
271
+
272
+ @QueryBinder()
273
+ class UsersRepo {
274
+ @MissingAsNull()
275
+ @Query({
276
+ sql: `
277
+ UPDATE users
278
+ SET name = COALESCE(:name, name),
279
+ email = COALESCE(:email, email),
280
+ status = COALESCE(:status, status)
281
+ WHERE id = :id
282
+ `
283
+ })
284
+ async updateUserPartial(_a: { id: string; name?: string; email?: string; status?: string }): Promise<any> {
285
+ throw new SqlStackError("replaced");
286
+ }
287
+ }
288
+ ```
289
+
290
+ Call with partial arguments:
291
+ ```ts
292
+ await repo.updateUserPartial({ id: "u1", name: "Alice Updated" });
293
+ // Only updates name; email and status keep their old values via COALESCE
294
+ ```
198
295
 
199
- ### Null and undefined
296
+ **Optional: Require Specific Parameters**
200
297
 
201
- - `null` maps to SQL `NULL`.
202
- - `undefined` is not allowed and will throw; provide a value or omit the parameter entirely.
298
+ Use `@MissingAsNull({ require: ['id'] })` to mark certain parameters as required:
299
+
300
+ ```ts
301
+ @MissingAsNull({ require: ['id'] })
302
+ @Query({
303
+ sql: `UPDATE users SET name = COALESCE(:name, name) WHERE id = :id`
304
+ })
305
+ async updateUser(_a: { id: string; name?: string }): Promise<any> {
306
+ throw new SqlStackError("replaced");
307
+ }
308
+ ```
309
+
310
+ If `id` is missing or undefined, an error is thrown. Other parameters default to `NULL`.
311
+
312
+ **Note:** `@MissingAsNull` only applies to named parameters. Positional parameters (`:arg1`, `:arg2`, etc.) ignore the decorator.
313
+
314
+ ---
203
315
 
204
316
  ## SQL File Resolution
205
317
 
206
- `@Query()` loads SQL from files based on a simple, predictable rule:
318
+ The `@Query()` decorator locates `.sql` files using a simple, predictable rule. See [docs/file_resolution.md](docs/file_resolution.md) for complete details.
207
319
 
208
- - No argument: resolves from the declaring file’s folder.
209
- - File path: resolves from `dirname(filePath)`.
210
- - Folder path: resolves directly from that folder.
320
+ ### Default: Same Folder
211
321
 
212
- For each method name, sqlstack looks for `<method>.<dialect>.sql` first, then `<method>.sql`. Dialect suffixes: `.pg.sql`, `.mysql.sql`, `.sqlite.sql`. Resolution and file contents are cached. See docs/file_resolution.md for details.
322
+ No argument to `@Query()` → looks in the same folder as the class file:
323
+
324
+ ```ts
325
+ @QueryBinder()
326
+ class UsersRepo {
327
+ @Query()
328
+ async findByEmail(_a: { email: string }): Promise<User[]> {
329
+ throw new Error();
330
+ }
331
+ }
332
+ ```
333
+
334
+ File structure:
335
+ ```
336
+ users/
337
+ index.ts
338
+ findByEmail.sql
339
+ ```
340
+
341
+ ### Dialect-Specific Files
342
+
343
+ sqlstack looks for `<method>.<dialect>.sql` first, then `<method>.sql`:
344
+
345
+ ```
346
+ users/
347
+ findByEmail.pg.sql ← Postgres
348
+ findByEmail.mysql.sql ← MySQL
349
+ findByEmail.sql ← fallback (all dialects)
350
+ ```
351
+
352
+ Dialect suffixes: `.pg.sql`, `.mysql.sql`, `.sqlite.sql`.
353
+
354
+ ### Explicit Path
355
+
356
+ Pass a file or folder path to `@Query()`:
357
+
358
+ ```ts
359
+ @Query(__filename) // uses this file's directory
360
+ @Query(__dirname) // uses this folder
361
+ @Query('./sql') // uses ./sql folder relative to cwd
362
+ ```
363
+
364
+ ---
213
365
 
214
366
  ## Decorators
215
367
 
368
+ Use decorators to add behavior to your methods. They compose freely and always execute in the same internal order.
369
+
216
370
  ### `@Defaults`
217
371
 
218
- Provide default parameter values.
372
+ Provide default parameter values:
219
373
 
220
374
  ```ts
221
375
  import { QueryBinder, Defaults, Query, SqlStackError } from "sqlstack";
@@ -230,18 +384,22 @@ class UsersRepo {
230
384
  }
231
385
  ```
232
386
 
233
- - Calling `repo.listUsers({})` fills `{ status: "active", limit: 10 }`.
387
+ Call with partial arguments:
388
+ ```ts
389
+ await repo.listUsers({}); // { status: "active", limit: 10 }
390
+ await repo.listUsers({ status: "inactive" }); // { status: "inactive", limit: 10 }
391
+ ```
234
392
 
235
393
  ### `@Page`
236
394
 
237
- Add pagination parameters `:offset` and `:limit` derived from a page size and a page number. Pages are 1-based by default.
395
+ Add pagination with `:offset` and `:limit` parameters. Pages are 1-based by default:
238
396
 
239
397
  ```ts
240
398
  import { QueryBinder, Page, Query, SqlStackError } from "sqlstack";
241
399
 
242
400
  @QueryBinder()
243
401
  class UsersRepo {
244
- @Page(10) // limit = 10, pages are 1-based
402
+ @Page(10) // limit = 10, pages 1-based
245
403
  @Query()
246
404
  async listUsers(_a: { page?: number; status?: string }): Promise<User[]> {
247
405
  throw new SqlStackError();
@@ -249,17 +407,32 @@ class UsersRepo {
249
407
  }
250
408
  ```
251
409
 
252
- - Use `:offset` and `:limit` in your SQL files.
253
- - The current page is read from `page` when passing a named object, or from a trailing options object as the last argument. This works for both named and positional styles:
254
- - Named style: `list({ user_id: 1 })` or `list({ user_id: 1 }, { page: 2 })`.
255
- - Positional style: `list(userId)` or `list(userId, { page: 2 })`.
256
- Only the last argument is checked for `{ page }`.
257
- If no page is provided, page defaults to 1 (or 0 when `zeroBase: true`).
258
- - Zero-based pages are supported with `@Page(10, { zeroBase: true })`.
410
+ Use `:offset` and `:limit` in your SQL:
411
+
412
+ ```sql
413
+ SELECT id, email, name, status
414
+ FROM users
415
+ WHERE status = :status
416
+ ORDER BY created_at DESC
417
+ LIMIT :limit OFFSET :offset;
418
+ ```
419
+
420
+ Call with page:
421
+ ```ts
422
+ await repo.listUsers({ status: "active", page: 2 }); // page 2 of 10 per page
423
+ ```
424
+
425
+ **Zero-based pages:** Use `@Page(10, { zeroBase: true })`.
426
+
427
+ **Trailing options:** Pass `{ page: N }` as the last argument for both named and positional styles:
428
+ ```ts
429
+ await repo.list({ user_id: 1 }, { page: 2 }); // named
430
+ await repo.list(userId, { page: 2 }); // positional
431
+ ```
259
432
 
260
- ### Result shape decorators
433
+ ### Result Shape Decorators
261
434
 
262
- Choose a decorator to shape row results without parameters.
435
+ Shape single-query results without parameters:
263
436
 
264
437
  ```ts
265
438
  import { QueryBinder, Single, Only, Exist, None, Query, SqlStackError } from "sqlstack";
@@ -296,9 +469,16 @@ class UsersRepo {
296
469
  }
297
470
  ```
298
471
 
472
+ | Decorator | Input | Output | Throws if |
473
+ |-----------|-------|--------|-----------|
474
+ | `@Single` | `[Row, Row, ...]` | `Row \| null` | — (never) |
475
+ | `@Only` | `[Row]` | `Row` | ≠ 1 row |
476
+ | `@Exist` | `[Row, Row, ...]` | `Row` | 0 rows |
477
+ | `@None` | `[]` | `null` | > 0 rows |
478
+
299
479
  ### `@ValidateResult`
300
480
 
301
- Validate query results with JSON Schema.
481
+ Validate query results against a JSON Schema:
302
482
 
303
483
  ```ts
304
484
  import schema from "./user.row.schema.json" assert { type: "json" };
@@ -315,18 +495,7 @@ class UsersRepo {
315
495
  }
316
496
  ```
317
497
 
318
-
319
-
320
- ## Inline Builders
321
-
322
- Intentionally not included. sqlstack prioritizes explicit `.sql` files for clarity and control.
323
-
324
- ## JSON Schema
325
-
326
- Add a JSON Schema file and attach it to a method with `@ValidateResult`.
327
-
328
- user.row.schema.json
329
-
498
+ Example schema:
330
499
  ```json
331
500
  {
332
501
  "type": "object",
@@ -340,121 +509,11 @@ user.row.schema.json
340
509
  }
341
510
  ```
342
511
 
343
- Usage
512
+ Validation is **enabled by default**; control with `SQLSTACK_ENABLE_VALIDATION` and `SQLSTACK_DISABLE_VALIDATION` environment variables.
344
513
 
345
- ```ts
346
- import schema from "./user.row.schema.json" assert { type: "json" };
347
- import { QueryBinder, ValidateResult, Query } from "sqlstack";
514
+ ### `@Transform`
348
515
 
349
- @QueryBinder()
350
- class UsersRepo {
351
- @ValidateResult(schema)
352
- @Query()
353
- async listUsers(): Promise<User[]> {
354
- // executes and validates each row against the schema
355
- }
356
- }
357
- ```
358
-
359
- ## Errors
360
-
361
- sqlstack provides a base error and specific error types you can import and catch.
362
-
363
- ```ts
364
- import { SqlStackError } from "sqlstack";
365
- import { ExactlyOneRowError, NoRowsError, ValidationError } from "sqlstack/errors";
366
-
367
- try {
368
- // call repository methods
369
- } catch (err) {
370
- if (err instanceof ExactlyOneRowError) {
371
- // @Only: expected exactly one row
372
- }
373
- if (err instanceof NoRowsError) {
374
- // @Exist: expected at least one row
375
- }
376
- if (err instanceof ValidationError) {
377
- // @ValidateResult: schema mismatch
378
- }
379
- if (err instanceof SqlStackError) {
380
- // handle sqlstack-specific failures
381
- }
382
- throw err;
383
- }
384
- ```
385
-
386
-
387
- ## Philosophy
388
-
389
- - SQL-first: SQL is the source of truth. No hidden queries.
390
- - Decorators as metadata: `@Defaults`, `@Single`, `@Only`, `@Exist`, `@ValidateResult` add labels; execution order is fixed internally, so decorator order doesn’t matter.
391
- - Escape hatches: Use inline builders for simple CRUD; keep complex SQL in `.sql` files. You stay in control.
392
-
393
- ### Execution Pipeline
394
-
395
- At runtime, decorators execute in a fixed, predictable order regardless of source order:
396
-
397
- 1) Defaults → 2) Query → 3) Single/Only/Exist → 4) ValidateResult. See docs/decorators.md for the model and examples.
398
-
399
- ### Validation Controls
400
-
401
- - Default: validation is enabled.
402
- - Env toggles (precedence):
403
- - `SQLSTACK_ENABLE_VALIDATION` truthy → force ON (even in production)
404
- - else if `SQLSTACK_DISABLE_VALIDATION` truthy → force OFF
405
- - else → ON
406
-
407
- ## Database Support
408
-
409
- - Postgres (`pg`)
410
- - MySQL / MariaDB (`mysql2`)
411
- - SQLite (`better-sqlite3`)
412
-
413
- More adapters are planned.
414
-
415
- ### Dialect Inference
416
-
417
- The binder placeholder style is inferred from the selected database (method → class → registry default). Only override via `@Query({ dialect })` if you need to force a specific dialect.
418
-
419
- ## License
420
-
421
- ISC
422
-
423
- ## Result Shape
424
-
425
- - SELECT and WITH CTE queries return an array of rows and can be shaped via decorators.
426
- - INSERT/UPDATE/DELETE return a common object: `{ rowsAffected: number, lastInsertId?: unknown }`.
427
- - Postgres-only: if a non-SELECT query includes `RETURNING`, the result also includes an optional `returning` array: `{ rowsAffected, returning: Row[] }`.
428
-
429
- ### Examples
430
-
431
- ```ts
432
- import type { WriteResult } from 'sqlstack';
433
- import { SqlStackDB } from 'sqlstack/registry';
434
-
435
- const db = SqlStackDB.get();
436
-
437
- // A write returns WriteResult
438
- const write: WriteResult = await db.query(
439
- 'UPDATE users SET status = ? WHERE id = ?',
440
- ['inactive', 'u1']
441
- );
442
- console.log(write.rowsAffected);
443
-
444
- // Postgres with RETURNING returns WriteResult & { returning?: any[] }
445
- // (only has `returning` when RETURNING is present)
446
- const pgWrite = await db.query(
447
- 'INSERT INTO users(id,email) VALUES($1,$2) RETURNING id',
448
- ['u9', 'nine@example.com']
449
- );
450
- if (!Array.isArray(pgWrite) && pgWrite.returning) {
451
- console.log(pgWrite.returning[0].id);
452
- }
453
- ```
454
-
455
- ## DTO Helper
456
-
457
- Turn flat SQL rows into nested objects using a simple spec and the `@Transform` decorator.
516
+ Map flat SQL rows to nested objects using a nesting spec:
458
517
 
459
518
  ```ts
460
519
  import { DTO, Transform, Query, QueryBinder } from 'sqlstack';
@@ -473,7 +532,7 @@ const toMuscleGroup = DTO(
473
532
  },
474
533
  },
475
534
  },
476
- { single: true } // default returns an array; set { single: true } to return a single root (or null)
535
+ { single: true } // default: array; set to return a single root object or null
477
536
  );
478
537
 
479
538
  @QueryBinder()
@@ -494,34 +553,203 @@ class Repo {
494
553
  }
495
554
  ```
496
555
 
497
- You can also project flat columns into nested properties directly in the spec:
556
+ **Features:**
557
+ - Arbitrary depth: nest collections by adding objects under new keys.
558
+ - Deduplication: nodes are de-duplicated by their `id` at each level.
559
+ - Null-safe: branches with `NULL` child ids are skipped.
560
+ - Order: sibling order follows your SQL `ORDER BY`.
561
+ - Direct mapping: use `'target.path': 'source_column'` to populate nested properties from flat columns.
498
562
 
563
+ Example with direct mapping:
499
564
  ```ts
500
- // If a row has: { id: '1', muscle: 'biceps', group: 'arms' }
565
+ // Row: { id: '1', muscle: 'biceps', group: 'arms' }
501
566
  const toSimple = DTO({ id: 'id', name: 'muscle', 'group.name': 'group' } as any, { single: true });
502
- // Result root: { name: 'biceps', group: { name: 'arms' } }
567
+ // Result: { id: '1', name: 'biceps', group: { name: 'arms' } }
503
568
  ```
504
569
 
505
- - Arbitrary depth: nest collections by adding objects under new keys.
506
- - Dedupe: nodes are de-duplicated by their `id` at each level.
507
- - Null-safe: branches with `NULL` child ids are skipped for that row.
508
- - Order: sibling order follows your SQL `ORDER BY`.
509
- - Mapping: put `'target.path': 'source_key'` entries directly in your spec to populate nested properties from flat columns.
570
+ ---
571
+
572
+ ## Result Types
573
+
574
+ ### SELECT / WITH CTE
575
+
576
+ Returns an array of rows, which can be shaped via decorators:
577
+
578
+ ```ts
579
+ @QueryBinder()
580
+ class Repo {
581
+ @Query()
582
+ async allUsers(): Promise<User[]> {
583
+ throw new Error();
584
+ }
585
+
586
+ @Single
587
+ @Query()
588
+ async firstUser(): Promise<User | null> {
589
+ throw new Error();
590
+ }
591
+ }
592
+ ```
593
+
594
+ ### INSERT / UPDATE / DELETE
595
+
596
+ Returns a result object:
597
+
598
+ ```ts
599
+ import type { WriteResult } from 'sqlstack';
600
+
601
+ const result: WriteResult = await db.query(
602
+ 'UPDATE users SET status = ? WHERE id = ?',
603
+ ['inactive', 'u1']
604
+ );
605
+ console.log(result.rowsAffected); // number
606
+ ```
607
+
608
+ **Postgres with RETURNING:**
609
+
610
+ ```ts
611
+ const pgResult = await db.query(
612
+ 'INSERT INTO users(id, email) VALUES($1, $2) RETURNING id',
613
+ ['u9', 'nine@example.com']
614
+ );
615
+ if (pgResult.returning) {
616
+ console.log(pgResult.returning[0].id); // 'u9'
617
+ }
618
+ ```
619
+
620
+ Type definition:
621
+ ```ts
622
+ type WriteResult = {
623
+ rowsAffected: number;
624
+ lastInsertId?: unknown;
625
+ };
626
+
627
+ type PostgresWriteResult = WriteResult & {
628
+ returning?: Row[];
629
+ };
630
+ ```
631
+
632
+ ---
510
633
 
511
- ## Underlying Connections
634
+ ## Error Handling
512
635
 
513
- Every `Database` exposes its driver via `conn` so you can drop down to the raw client when needed:
636
+ sqlstack provides a base error class and specific error types. Import and catch as needed:
637
+
638
+ ```ts
639
+ import { SqlStackError } from "sqlstack";
640
+ import { ExactlyOneRowError, NoRowsError, ValidationError } from "sqlstack/errors";
641
+
642
+ try {
643
+ const user = await repo.findByEmail({ email: "alice@example.com" });
644
+ } catch (err) {
645
+ if (err instanceof ExactlyOneRowError) {
646
+ console.log("@Only: expected exactly one row, got a different number");
647
+ } else if (err instanceof NoRowsError) {
648
+ console.log("@Exist: expected at least one row, got none");
649
+ } else if (err instanceof ValidationError) {
650
+ console.log("@ValidateResult: row did not match schema");
651
+ } else if (err instanceof SqlStackError) {
652
+ console.log("Generic sqlstack error");
653
+ } else {
654
+ console.log("Unknown error:", err);
655
+ }
656
+ }
657
+ ```
658
+
659
+ Available error classes:
660
+ - `SqlStackError` — base class for all sqlstack errors
661
+ - `ExactlyOneRowError` — thrown by `@Only` when row count ≠ 1
662
+ - `NoRowsError` — thrown by `@Exist` when no rows found
663
+ - `ValidationError` — thrown by `@ValidateResult` on schema mismatch
664
+ - Database driver errors — pass through unchanged
665
+
666
+ ---
667
+
668
+ ## Inline SQL
669
+
670
+ For simple or dynamic queries, use `@Query({ sql: "..." })` with inline SQL:
671
+
672
+ ```ts
673
+ @QueryBinder()
674
+ class Repo {
675
+ @Query({ sql: `
676
+ SELECT id, email, name
677
+ FROM users
678
+ WHERE email = :email
679
+ ` })
680
+ async findByEmail(_a: { email: string }): Promise<User[]> {
681
+ throw new Error("replaced");
682
+ }
683
+ }
684
+ ```
685
+
686
+ sqlstack applies the same parameter binding and result shaping as file-based SQL.
687
+
688
+ ---
689
+
690
+ ## Advanced: Direct Database Access
691
+
692
+ Every `Database` instance exposes the underlying driver via `.conn`, so you can drop down to raw queries when needed:
514
693
 
515
694
  ```ts
516
695
  import { SqlStackDB } from 'sqlstack/registry';
517
696
 
518
- const db = SqlStackDB.get();
697
+ const db = SqlStackDB.get(); // get the default database
698
+
519
699
  // Driver shape depends on adapter:
520
700
  // - Postgres: pg.Pool
521
701
  // - MySQL: mysql2/promise Pool
522
- // - SQLite: better-sqlite3 Database or sqlite3.Database
702
+ // - SQLite: better-sqlite3 Database
523
703
  const raw = db.conn;
524
704
 
525
- // close() only disposes connections created by the adapter itself.
526
- // If you supplied an existing connection/pool, close() is a no-op.
705
+ // Execute raw queries (driver-specific)
706
+ const result = await raw.query('SELECT 1');
707
+ console.log(result);
527
708
  ```
709
+
710
+ **Note:** `close()` only disposes connections created by the adapter. If you supplied an existing connection/pool, `close()` is a no-op.
711
+
712
+ ---
713
+
714
+ ## Database Support
715
+
716
+ - **Postgres** — adapter: `createPgDb()`, driver: `pg`
717
+ - **MySQL / MariaDB** — adapter: `createMysqlDb()`, driver: `mysql2/promise`
718
+ - **SQLite** — adapter: `createSqliteDb()`, driver: `better-sqlite3`
719
+
720
+ More adapters are planned.
721
+
722
+ ### Dialect Inference
723
+
724
+ The placeholder style is inferred from the selected database. Override per-method with `@Query({ dialect: "pg" })` if needed.
725
+
726
+ ---
727
+
728
+ ## Philosophy
729
+
730
+ **SQL-first:** SQL is the source of truth. No hidden queries.
731
+
732
+ **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.
733
+
734
+ **Escape hatches:** Use inline SQL for simple queries; keep complex SQL in `.sql` files. You stay in control.
735
+
736
+ ---
737
+
738
+ ## Contributing
739
+
740
+ Found a bug? Have a feature idea? Please open an [issue](https://github.com/anthropics/sqlstack/issues) or submit a [pull request](https://github.com/anthropics/sqlstack/pulls).
741
+
742
+ ### Development
743
+
744
+ 1. Clone the repository
745
+ 2. Install dependencies: `npm install`
746
+ 3. Run tests: `npm test`
747
+ 4. Build: `npm run build`
748
+
749
+ See [CONTRIBUTING.md](CONTRIBUTING.md) (if available) and [AGENTS.md](AGENTS.md) for detailed contributor guidance.
750
+
751
+ ---
752
+
753
+ ## License
754
+
755
+ ISC