sqlstack 1.0.13 → 1.0.14
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/dist/cjs/binders/sqlBinder.d.ts +2 -1
- package/dist/cjs/binders/sqlBinder.d.ts.map +1 -1
- package/dist/cjs/binders/sqlBinder.js +3 -1
- package/dist/cjs/binders/sqlBinder.js.map +1 -1
- package/dist/cjs/core/errors.d.ts +4 -0
- package/dist/cjs/core/errors.d.ts.map +1 -1
- package/dist/cjs/core/errors.js +9 -1
- package/dist/cjs/core/errors.js.map +1 -1
- package/dist/cjs/core/metadata.d.ts +3 -0
- package/dist/cjs/core/metadata.d.ts.map +1 -1
- package/dist/cjs/core/metadata.js.map +1 -1
- package/dist/cjs/core/sqlParams.d.ts +6 -0
- package/dist/cjs/core/sqlParams.d.ts.map +1 -0
- package/dist/cjs/core/sqlParams.js +120 -0
- package/dist/cjs/core/sqlParams.js.map +1 -0
- package/dist/cjs/core/validator.d.ts +0 -5
- package/dist/cjs/core/validator.d.ts.map +1 -1
- package/dist/cjs/core/validator.js +2 -11
- package/dist/cjs/core/validator.js.map +1 -1
- package/dist/cjs/decorators/missingAsNull.d.ts +34 -0
- package/dist/cjs/decorators/missingAsNull.d.ts.map +1 -0
- package/dist/cjs/decorators/missingAsNull.js +40 -0
- package/dist/cjs/decorators/missingAsNull.js.map +1 -0
- package/dist/cjs/decorators/query.d.ts.map +1 -1
- package/dist/cjs/decorators/query.js +23 -0
- package/dist/cjs/decorators/query.js.map +1 -1
- package/dist/cjs/index.d.ts +5 -4
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +10 -5
- package/dist/cjs/index.js.map +1 -1
- package/dist/esm/adapters/index.js +3 -3
- package/dist/esm/binders/sqlBinder.js +3 -0
- package/dist/esm/binders/sqlBinder.js.map +1 -1
- package/dist/esm/core/errors.js +7 -0
- package/dist/esm/core/errors.js.map +1 -1
- package/dist/esm/core/metadata.js.map +1 -1
- package/dist/esm/core/sqlParams.js +117 -0
- package/dist/esm/core/sqlParams.js.map +1 -0
- package/dist/esm/core/validator.js +1 -8
- package/dist/esm/core/validator.js.map +1 -1
- package/dist/esm/decorators/defaults.js +1 -1
- package/dist/esm/decorators/missingAsNull.js +37 -0
- package/dist/esm/decorators/missingAsNull.js.map +1 -0
- package/dist/esm/decorators/page.js +1 -1
- package/dist/esm/decorators/query.js +29 -6
- package/dist/esm/decorators/query.js.map +1 -1
- package/dist/esm/decorators/queryBinder.js +1 -1
- package/dist/esm/decorators/resultShape.js +1 -1
- package/dist/esm/decorators/transform.js +1 -1
- package/dist/esm/decorators/validateResult.js +1 -1
- package/dist/esm/index.js +19 -14
- package/dist/esm/index.js.map +1 -1
- package/package.json +2 -2
- 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
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
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
|
-
##
|
|
35
|
+
## Quick Start (5 minutes)
|
|
34
36
|
|
|
35
|
-
|
|
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
|
|
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
|
-
|
|
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" }) //
|
|
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
|
-
|
|
182
|
+
### Override Database per Method
|
|
87
183
|
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
+
Pass parameters from your method call to the SQL using named or positional placeholders.
|
|
149
206
|
|
|
150
|
-
### Named
|
|
207
|
+
### Named Parameters
|
|
151
208
|
|
|
152
|
-
Use `:name` placeholders
|
|
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
|
|
222
|
+
### Positional Parameters
|
|
166
223
|
|
|
167
|
-
Use `:arg1`, `:arg2`,
|
|
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
|
-
|
|
237
|
+
**Do not mix named and positional parameters in a single query.**
|
|
181
238
|
|
|
182
|
-
### Array
|
|
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
|
-
|
|
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
|
-
|
|
296
|
+
**Optional: Require Specific Parameters**
|
|
200
297
|
|
|
201
|
-
|
|
202
|
-
|
|
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()`
|
|
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
|
-
|
|
209
|
-
- File path: resolves from `dirname(filePath)`.
|
|
210
|
-
- Folder path: resolves directly from that folder.
|
|
320
|
+
### Default: Same Folder
|
|
211
321
|
|
|
212
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
|
433
|
+
### Result Shape Decorators
|
|
261
434
|
|
|
262
|
-
|
|
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
|
|
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
|
-
|
|
512
|
+
Validation is **enabled by default**; control with `SQLSTACK_ENABLE_VALIDATION` and `SQLSTACK_DISABLE_VALIDATION` environment variables.
|
|
344
513
|
|
|
345
|
-
|
|
346
|
-
import schema from "./user.row.schema.json" assert { type: "json" };
|
|
347
|
-
import { QueryBinder, ValidateResult, Query } from "sqlstack";
|
|
514
|
+
### `@Transform`
|
|
348
515
|
|
|
349
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
567
|
+
// Result: { id: '1', name: 'biceps', group: { name: 'arms' } }
|
|
503
568
|
```
|
|
504
569
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
##
|
|
634
|
+
## Error Handling
|
|
512
635
|
|
|
513
|
-
|
|
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
|
|
702
|
+
// - SQLite: better-sqlite3 Database
|
|
523
703
|
const raw = db.conn;
|
|
524
704
|
|
|
525
|
-
//
|
|
526
|
-
|
|
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
|