@rawsql-ts/sql-contract 0.1.0 → 0.3.1
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 +617 -476
- package/package.json +20 -9
- package/LICENSE +0 -21
- package/dist/.tsbuildinfo +0 -1
- package/dist/index.d.ts +0 -2
- package/dist/index.js +0 -19
- package/dist/index.js.map +0 -1
- package/dist/mapper/index.d.ts +0 -192
- package/dist/mapper/index.js +0 -691
- package/dist/mapper/index.js.map +0 -1
- package/dist/query-params.d.ts +0 -2
- package/dist/query-params.js +0 -3
- package/dist/query-params.js.map +0 -1
- package/dist/writer/index.d.ts +0 -52
- package/dist/writer/index.js +0 -155
- package/dist/writer/index.js.map +0 -1
- package/dist/writer/presets.d.ts +0 -25
- package/dist/writer/presets.js +0 -67
- package/dist/writer/presets.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,476 +1,617 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
```
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
```
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
```
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
```ts
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
)
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
```
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
1
|
+
# @rawsql-ts/sql-contract
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+
|
|
6
|
+
A lightweight library for mapping SQL query results into typed application models. It removes the repetitive, mechanical code around handwritten SQL while keeping SQL as the authoritative source for domain logic.
|
|
7
|
+
|
|
8
|
+
Inspired by minimal mapping libraries such as Dapper — stopping short of a full ORM and instead providing a predictable, transparent layer.
|
|
9
|
+
|
|
10
|
+
## Features
|
|
11
|
+
|
|
12
|
+
- Zero runtime dependencies
|
|
13
|
+
- Works with any SQL executor returning rows (driver/DBMS agnostic)
|
|
14
|
+
- Automatic snake_case to camelCase column name conversion
|
|
15
|
+
- Single and multi-model mapping with `rowMapping`
|
|
16
|
+
- Validator-agnostic schema integration (Zod, ArkType, or any `parse`/`assert` compatible library)
|
|
17
|
+
- Scalar query helpers for COUNT / aggregate values
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install @rawsql-ts/sql-contract
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick Start
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { createReader, type QueryParams } from '@rawsql-ts/sql-contract'
|
|
29
|
+
|
|
30
|
+
const executor = async (sql: string, params: QueryParams) => {
|
|
31
|
+
const result = await pool.query(sql, params as unknown[])
|
|
32
|
+
return result.rows
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const reader = createReader(executor)
|
|
36
|
+
|
|
37
|
+
const customers = await reader.list(
|
|
38
|
+
'SELECT customer_id, customer_name FROM customers WHERE status = $1',
|
|
39
|
+
['active']
|
|
40
|
+
)
|
|
41
|
+
// [{ customerId: 1, customerName: 'Alice' }, ...]
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Reader API
|
|
45
|
+
|
|
46
|
+
### Basic queries: `one` and `list`
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
const customer = await reader.one(
|
|
50
|
+
'SELECT customer_id, customer_name FROM customers WHERE customer_id = $1',
|
|
51
|
+
[1]
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const customers = await reader.list(
|
|
55
|
+
'SELECT customer_id, customer_name FROM customers'
|
|
56
|
+
)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Column naming conventions
|
|
60
|
+
|
|
61
|
+
By default, Reader converts snake_case columns to camelCase properties automatically:
|
|
62
|
+
|
|
63
|
+
```sql
|
|
64
|
+
SELECT customer_id, created_at FROM customers
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
becomes:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
{ customerId: number, createdAt: Date }
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Presets are available to change this behavior:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import { mapperPresets } from '@rawsql-ts/sql-contract'
|
|
77
|
+
|
|
78
|
+
const reader = createReader(executor, mapperPresets.safe()) // no transformation
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Custom mapping
|
|
82
|
+
|
|
83
|
+
For derived values or format conversion:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
const rows = await reader.map(
|
|
87
|
+
'SELECT price, quantity FROM order_items',
|
|
88
|
+
(row) => ({ total: row.price * row.quantity })
|
|
89
|
+
)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Row mapping with `rowMapping`
|
|
93
|
+
|
|
94
|
+
For reusable, explicit mappings where domain terms differ from column names:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
import { rowMapping } from '@rawsql-ts/sql-contract'
|
|
98
|
+
|
|
99
|
+
const orderSummaryMapping = rowMapping({
|
|
100
|
+
name: 'OrderSummary',
|
|
101
|
+
key: 'orderId',
|
|
102
|
+
columnMap: {
|
|
103
|
+
orderId: 'order_id',
|
|
104
|
+
customerLabel: 'customer_display_name',
|
|
105
|
+
totalAmount: 'grand_total',
|
|
106
|
+
},
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const summaries = await reader
|
|
110
|
+
.bind(orderSummaryMapping)
|
|
111
|
+
.list('SELECT order_id, customer_display_name, grand_total FROM order_view')
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Keys can be composite (`key: ['col_a', 'col_b']`) or derived (`key: (row) => [row.col_a, row.col_b]`).
|
|
115
|
+
|
|
116
|
+
### Multi-model mapping
|
|
117
|
+
|
|
118
|
+
Map joined results into nested domain models with `belongsTo`:
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
const customerMapping = rowMapping({
|
|
122
|
+
name: 'Customer',
|
|
123
|
+
key: 'customerId',
|
|
124
|
+
columnMap: {
|
|
125
|
+
customerId: 'customer_customer_id',
|
|
126
|
+
customerName: 'customer_customer_name',
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
const orderMapping = rowMapping({
|
|
131
|
+
name: 'Order',
|
|
132
|
+
key: 'orderId',
|
|
133
|
+
columnMap: {
|
|
134
|
+
orderId: 'order_order_id',
|
|
135
|
+
orderTotal: 'order_total',
|
|
136
|
+
customerId: 'order_customer_id',
|
|
137
|
+
},
|
|
138
|
+
}).belongsTo('customer', customerMapping, 'customerId')
|
|
139
|
+
|
|
140
|
+
const orders = await reader.bind(orderMapping).list(`
|
|
141
|
+
SELECT
|
|
142
|
+
c.id AS customer_customer_id,
|
|
143
|
+
c.name AS customer_customer_name,
|
|
144
|
+
o.id AS order_order_id,
|
|
145
|
+
o.total AS order_total,
|
|
146
|
+
o.customer_id AS order_customer_id
|
|
147
|
+
FROM customers c
|
|
148
|
+
JOIN orders o ON o.customer_id = c.customer_id
|
|
149
|
+
`)
|
|
150
|
+
// [{ orderId: 1, orderTotal: 500, customerId: 3, customer: { customerId: 3, customerName: 'Alice' } }]
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Scalar queries
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
const count = await reader.scalar(
|
|
157
|
+
'SELECT count(*) FROM customers WHERE status = $1',
|
|
158
|
+
['active']
|
|
159
|
+
)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Validation Integration
|
|
163
|
+
|
|
164
|
+
The `.validator()` method accepts any object implementing `parse(value)` or `assert(value)`. This means **Zod**, **ArkType**, and other validation libraries work out of the box — no additional adapter packages required.
|
|
165
|
+
|
|
166
|
+
### With Zod
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
import { z } from 'zod'
|
|
170
|
+
|
|
171
|
+
const CustomerSchema = z.object({
|
|
172
|
+
customerId: z.number(),
|
|
173
|
+
customerName: z.string(),
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
const customers = await reader
|
|
177
|
+
.validator(CustomerSchema)
|
|
178
|
+
.list('SELECT customer_id, customer_name FROM customers')
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### With ArkType
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
import { type } from 'arktype'
|
|
185
|
+
|
|
186
|
+
const CustomerSchema = type({
|
|
187
|
+
customerId: 'number',
|
|
188
|
+
customerName: 'string',
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
const customers = await reader
|
|
192
|
+
.validator((value) => {
|
|
193
|
+
CustomerSchema.assert(value)
|
|
194
|
+
return value
|
|
195
|
+
})
|
|
196
|
+
.list('SELECT customer_id, customer_name FROM customers')
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Validators run after row mapping, so schema errors surface before application code relies on the result shape. Validators are also chainable: `.validator(v1).validator(v2)`.
|
|
200
|
+
|
|
201
|
+
## Catalog Executor
|
|
202
|
+
|
|
203
|
+
For larger projects, `createCatalogExecutor` executes queries through a `QuerySpec` contract instead of raw SQL strings. A `QuerySpec` couples an SQL file, parameter shape, and output rules into a stable identity for debugging and observability.
|
|
204
|
+
|
|
205
|
+
### QuerySpec
|
|
206
|
+
|
|
207
|
+
A `QuerySpec` is the core contract type:
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
import type { QuerySpec } from '@rawsql-ts/sql-contract'
|
|
211
|
+
import { rowMapping } from '@rawsql-ts/sql-contract'
|
|
212
|
+
|
|
213
|
+
const activeCustomersSpec: QuerySpec<[], { customerId: number; customerName: string }> = {
|
|
214
|
+
id: 'customers.active',
|
|
215
|
+
sqlFile: 'customers/active.sql',
|
|
216
|
+
params: { shape: 'positional', example: [] },
|
|
217
|
+
metadata: {
|
|
218
|
+
material: ['active_customer_ids'],
|
|
219
|
+
scalarMaterial: ['active_customer_count'],
|
|
220
|
+
},
|
|
221
|
+
output: {
|
|
222
|
+
mapping: rowMapping({
|
|
223
|
+
name: 'Customer',
|
|
224
|
+
key: 'customerId',
|
|
225
|
+
columnMap: {
|
|
226
|
+
customerId: 'customer_id',
|
|
227
|
+
customerName: 'customer_name',
|
|
228
|
+
},
|
|
229
|
+
}),
|
|
230
|
+
example: { customerId: 1, customerName: 'Alice' },
|
|
231
|
+
},
|
|
232
|
+
tags: { domain: 'crm' },
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
| Field | Description |
|
|
237
|
+
|-------|-------------|
|
|
238
|
+
| `id` | Unique identifier for debugging and observability |
|
|
239
|
+
| `sqlFile` | Path passed to the SQL loader |
|
|
240
|
+
| `params.shape` | `'positional'` (array) or `'named'` (record) |
|
|
241
|
+
| `params.example` | Example parameters (for documentation and testing) |
|
|
242
|
+
| `output.mapping` | Optional `rowMapping` applied before validation |
|
|
243
|
+
| `output.validate` | Optional function to validate/transform each row |
|
|
244
|
+
| `output.example` | Example output (for documentation and testing) |
|
|
245
|
+
| `notes` | Optional human-readable description |
|
|
246
|
+
| `tags` | Optional key-value metadata forwarded to observability events |
|
|
247
|
+
| `metadata.material` | Optional CTE names to materialize as temp tables at runtime |
|
|
248
|
+
| `metadata.scalarMaterial` | Optional CTE names to treat as scalar materializations at runtime |
|
|
249
|
+
|
|
250
|
+
### QuerySpec metadata
|
|
251
|
+
|
|
252
|
+
Use `metadata` when runtime adapters need execution hints without changing the SQL asset itself:
|
|
253
|
+
|
|
254
|
+
```ts
|
|
255
|
+
const monthlyReportSpec: QuerySpec<{ tenantId: string }, { value: number }> = {
|
|
256
|
+
id: 'reports.monthly',
|
|
257
|
+
sqlFile: 'reports/monthly.sql',
|
|
258
|
+
params: {
|
|
259
|
+
shape: 'named',
|
|
260
|
+
example: { tenantId: 'tenant-1' },
|
|
261
|
+
},
|
|
262
|
+
metadata: {
|
|
263
|
+
material: ['report_base'],
|
|
264
|
+
scalarMaterial: ['report_total'],
|
|
265
|
+
},
|
|
266
|
+
output: {
|
|
267
|
+
example: { value: 1 },
|
|
268
|
+
},
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
The metadata remains available on `spec.metadata` inside rewriters and is also forwarded to runtime extensions through `ExecInput.metadata`.
|
|
273
|
+
|
|
274
|
+
### Creating a CatalogExecutor
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
import { createCatalogExecutor } from '@rawsql-ts/sql-contract'
|
|
278
|
+
import { readFile } from 'node:fs/promises'
|
|
279
|
+
import { resolve } from 'node:path'
|
|
280
|
+
|
|
281
|
+
function createFileSqlLoader(baseDir: string) {
|
|
282
|
+
return {
|
|
283
|
+
load(sqlFile: string) {
|
|
284
|
+
return readFile(resolve(baseDir, sqlFile), 'utf-8')
|
|
285
|
+
},
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const catalog = createCatalogExecutor({
|
|
290
|
+
loader: createFileSqlLoader('sql'),
|
|
291
|
+
executor,
|
|
292
|
+
})
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
The executor exposes three methods matching the Reader API:
|
|
296
|
+
|
|
297
|
+
```ts
|
|
298
|
+
const customers = await catalog.list(activeCustomersSpec, [])
|
|
299
|
+
const customer = await catalog.one(customerByIdSpec, [42])
|
|
300
|
+
const count = await catalog.scalar(customerCountSpec, [])
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
For larger applications, keeping the file-backed loader in one helper avoids
|
|
304
|
+
repeating the same `readFile(resolve(...))` wiring in every repository module.
|
|
305
|
+
|
|
306
|
+
### Common catalog output patterns
|
|
307
|
+
|
|
308
|
+
The output pipeline for `list()` / `one()` is:
|
|
309
|
+
|
|
310
|
+
1. raw SQL row
|
|
311
|
+
2. `output.mapping` (optional)
|
|
312
|
+
3. `output.validate` (optional)
|
|
313
|
+
|
|
314
|
+
That means validators should read the mapped DTO shape, not the raw SQL row.
|
|
315
|
+
|
|
316
|
+
For scalar queries, the pipeline is:
|
|
317
|
+
|
|
318
|
+
1. raw SQL row
|
|
319
|
+
2. single-column scalar extraction
|
|
320
|
+
3. `output.validate` (optional)
|
|
321
|
+
|
|
322
|
+
That makes `count(*)` and `RETURNING id` contracts read more clearly when they
|
|
323
|
+
validate the extracted scalar directly instead of inventing a one-field DTO.
|
|
324
|
+
|
|
325
|
+
See [docs/recipes/sql-contract.md](../../docs/recipes/sql-contract.md) for
|
|
326
|
+
copy-paste-ready catalog examples covering:
|
|
327
|
+
|
|
328
|
+
- reusable file-backed loaders
|
|
329
|
+
- mapped DTO validation
|
|
330
|
+
- scalar contract patterns
|
|
331
|
+
|
|
332
|
+
### Named parameters
|
|
333
|
+
|
|
334
|
+
Specs declaring `shape: 'named'` require either a `Binder` or an explicit opt-in:
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
const catalog = createCatalogExecutor({
|
|
338
|
+
loader,
|
|
339
|
+
executor,
|
|
340
|
+
// Option A: provide a binder that converts named → positional
|
|
341
|
+
binders: [{
|
|
342
|
+
name: 'pg-named',
|
|
343
|
+
bind: ({ sql, params }) => {
|
|
344
|
+
// convert :name placeholders to $1, $2, ...
|
|
345
|
+
return { sql: boundSql, params: positionalArray }
|
|
346
|
+
},
|
|
347
|
+
}],
|
|
348
|
+
// Option B: pass named params directly to the executor
|
|
349
|
+
// allowNamedParamsWithoutBinder: true,
|
|
350
|
+
})
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
### Mutation specs
|
|
354
|
+
|
|
355
|
+
Catalog specs can declare mutation metadata for `INSERT`, `UPDATE`, and `DELETE` assets:
|
|
356
|
+
|
|
357
|
+
```ts
|
|
358
|
+
const createUserSpec: QuerySpec<
|
|
359
|
+
{ id: string; display_name?: string | null; created_at?: string },
|
|
360
|
+
never
|
|
361
|
+
> = {
|
|
362
|
+
id: 'user.create',
|
|
363
|
+
sqlFile: 'user/create.sql',
|
|
364
|
+
params: {
|
|
365
|
+
shape: 'named',
|
|
366
|
+
example: {
|
|
367
|
+
id: 'user-1',
|
|
368
|
+
display_name: 'Alice',
|
|
369
|
+
created_at: '2026-03-05T00:00:00.000Z',
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
mutation: {
|
|
373
|
+
kind: 'insert',
|
|
374
|
+
},
|
|
375
|
+
output: {
|
|
376
|
+
example: undefined as never,
|
|
377
|
+
},
|
|
378
|
+
}
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
The `insert` behavior is covered by `packages/sql-contract/tests/catalog.create.test.ts`.
|
|
382
|
+
|
|
383
|
+
```ts
|
|
384
|
+
const updateUserSpec: QuerySpec<
|
|
385
|
+
{ id: string; display_name?: string | null; bio?: string | null },
|
|
386
|
+
never
|
|
387
|
+
> = {
|
|
388
|
+
id: 'user.update-profile',
|
|
389
|
+
sqlFile: 'user/update-profile.sql',
|
|
390
|
+
params: {
|
|
391
|
+
shape: 'named',
|
|
392
|
+
example: { id: 'user-1', display_name: 'Alice', bio: null },
|
|
393
|
+
},
|
|
394
|
+
mutation: {
|
|
395
|
+
kind: 'update',
|
|
396
|
+
},
|
|
397
|
+
output: {
|
|
398
|
+
example: undefined as never,
|
|
399
|
+
},
|
|
400
|
+
}
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
Phase 1 intentionally keeps the safety rules narrow:
|
|
404
|
+
|
|
405
|
+
- `INSERT` subtracts only direct `VALUES (:named_param)` entries when the key is missing or `undefined`.
|
|
406
|
+
- `UPDATE` and `DELETE` require a `WHERE` clause by default.
|
|
407
|
+
- `UPDATE` subtracts only simple `SET column = :param` assignments when the key is missing or `undefined`.
|
|
408
|
+
- `null` is preserved, so `SET column = :param` still executes and binds `NULL`.
|
|
409
|
+
- Mandatory parameter validation only inspects the `WHERE` clause because Phase 1 focuses on preventing accidental broad mutations first.
|
|
410
|
+
|
|
411
|
+
For example, the SQL asset below will drop `display_name = :display_name` when
|
|
412
|
+
`display_name` is omitted or `undefined`, but it keeps the fixed timestamp write:
|
|
413
|
+
|
|
414
|
+
```sql
|
|
415
|
+
UPDATE public.user_account
|
|
416
|
+
SET display_name = :display_name,
|
|
417
|
+
bio = :bio,
|
|
418
|
+
updated_at = NOW()
|
|
419
|
+
WHERE id = :id
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
Assignments with inline comments or more complex expressions stay untouched in
|
|
423
|
+
Phase 1. They remain visible in SQL and any unresolved placeholders still flow
|
|
424
|
+
through the configured binder/executor path.
|
|
425
|
+
|
|
426
|
+
### Rewriters
|
|
427
|
+
|
|
428
|
+
Rewriters apply semantic-preserving SQL transformations before execution:
|
|
429
|
+
|
|
430
|
+
```ts
|
|
431
|
+
const catalog = createCatalogExecutor({
|
|
432
|
+
loader,
|
|
433
|
+
executor,
|
|
434
|
+
rewriters: [{
|
|
435
|
+
name: 'add-limit',
|
|
436
|
+
rewrite: ({ sql, params }) => ({
|
|
437
|
+
sql: `${sql} LIMIT 1000`,
|
|
438
|
+
params,
|
|
439
|
+
}),
|
|
440
|
+
}],
|
|
441
|
+
})
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
The execution pipeline order is: **SQL load → rewriters → binders → executor**.
|
|
445
|
+
|
|
446
|
+
Mutation specs apply one extra safety rule in Phase 1: every configured
|
|
447
|
+
rewriter must explicitly declare `mutationSafety: 'safe'`. This keeps mutation
|
|
448
|
+
preprocessing stable by rejecting rewriters that might alter `SET` or `WHERE`
|
|
449
|
+
structure.
|
|
450
|
+
|
|
451
|
+
```ts
|
|
452
|
+
const auditCommentRewriter: Rewriter & { mutationSafety: 'safe' } = {
|
|
453
|
+
name: 'audit-comment',
|
|
454
|
+
mutationSafety: 'safe',
|
|
455
|
+
rewrite: ({ sql, params }) => ({
|
|
456
|
+
sql: `${sql} -- audit`,
|
|
457
|
+
params,
|
|
458
|
+
}),
|
|
459
|
+
}
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
Rewriters without that explicit marker still work for non-mutation specs.
|
|
463
|
+
|
|
464
|
+
### DELETE guards and `rowCount`
|
|
465
|
+
|
|
466
|
+
Physical deletes default to an affected-row guard of `exactly 1`. To evaluate
|
|
467
|
+
that guard safely, the configured executor must expose `rowCount` via
|
|
468
|
+
`{ rows, rowCount }` results.
|
|
469
|
+
|
|
470
|
+
```ts
|
|
471
|
+
const executor = async (sql: string, params: QueryParams) => {
|
|
472
|
+
const result = await client.query(sql, params as unknown[])
|
|
473
|
+
return {
|
|
474
|
+
rows: result.rows,
|
|
475
|
+
rowCount: result.rowCount,
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
If the executor does not expose `rowCount`, delete specs fail by default. You
|
|
481
|
+
may opt out per spec only when you intentionally want no guard:
|
|
482
|
+
|
|
483
|
+
```ts
|
|
484
|
+
mutation: {
|
|
485
|
+
kind: 'delete',
|
|
486
|
+
delete: {
|
|
487
|
+
affectedRowsGuard: { mode: 'none' },
|
|
488
|
+
},
|
|
489
|
+
}
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
For fixture-backed tests, `@rawsql-ts/testkit-core` provides `createCatalogRewriter()` so you can plug `SelectFixtureRewriter` into the catalog pipeline without writing an adapter:
|
|
493
|
+
|
|
494
|
+
```ts
|
|
495
|
+
import { createCatalogExecutor } from '@rawsql-ts/sql-contract'
|
|
496
|
+
import { createCatalogRewriter } from '@rawsql-ts/testkit-core'
|
|
497
|
+
|
|
498
|
+
const catalog = createCatalogExecutor({
|
|
499
|
+
loader,
|
|
500
|
+
executor,
|
|
501
|
+
rewriters: [createCatalogRewriter({
|
|
502
|
+
fixtures: [{
|
|
503
|
+
tableName: 'users',
|
|
504
|
+
rows: [{ id: 1, name: 'Alice' }],
|
|
505
|
+
schema: {
|
|
506
|
+
columns: {
|
|
507
|
+
id: 'INTEGER',
|
|
508
|
+
name: 'TEXT',
|
|
509
|
+
},
|
|
510
|
+
},
|
|
511
|
+
}],
|
|
512
|
+
})],
|
|
513
|
+
})
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
### Observability
|
|
517
|
+
|
|
518
|
+
When an `observabilitySink` is provided, the executor emits lifecycle events:
|
|
519
|
+
|
|
520
|
+
```ts
|
|
521
|
+
const catalog = createCatalogExecutor({
|
|
522
|
+
loader,
|
|
523
|
+
executor,
|
|
524
|
+
observabilitySink: {
|
|
525
|
+
emit(event) {
|
|
526
|
+
// event.kind: 'query_start' | 'query_end' | 'query_error'
|
|
527
|
+
// event.specId, event.sqlFile, event.execId, event.durationMs, ...
|
|
528
|
+
console.log(`[${event.kind}] ${event.specId}`)
|
|
529
|
+
},
|
|
530
|
+
},
|
|
531
|
+
})
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
### Error handling
|
|
535
|
+
|
|
536
|
+
Catalog errors form a hierarchy rooted at `CatalogError`:
|
|
537
|
+
|
|
538
|
+
| Error class | Cause |
|
|
539
|
+
|-------------|-------|
|
|
540
|
+
| `SQLLoaderError` | SQL file could not be loaded |
|
|
541
|
+
| `RewriterError` | A rewriter threw during transformation |
|
|
542
|
+
| `BinderError` | A binder failed or returned invalid output |
|
|
543
|
+
| `ContractViolationError` | Parameter shape mismatch, unexpected row count, etc. |
|
|
544
|
+
| `CatalogExecutionError` | The underlying query executor failed |
|
|
545
|
+
|
|
546
|
+
All error classes expose `specId` and `cause` properties for structured logging.
|
|
547
|
+
|
|
548
|
+
## Execution Scope and Transaction Boundaries
|
|
549
|
+
|
|
550
|
+
sql-contract is responsible for **query definition and result mapping**. Transaction control (`BEGIN` / `COMMIT` / `ROLLBACK`) and connection lifecycle management are outside its scope — they remain the caller's execution concern.
|
|
551
|
+
|
|
552
|
+
### What sql-contract manages
|
|
553
|
+
|
|
554
|
+
- SQL loading and transformation (rewriters, binders)
|
|
555
|
+
- Parameter binding and placeholder conversion
|
|
556
|
+
- Result row mapping and validation
|
|
557
|
+
- Observability events for query execution
|
|
558
|
+
|
|
559
|
+
### What the caller manages
|
|
560
|
+
|
|
561
|
+
- Connection pooling and lifecycle (open, close, release)
|
|
562
|
+
- Transaction boundaries (`BEGIN` / `COMMIT` / `ROLLBACK`)
|
|
563
|
+
- Error recovery and retry policies
|
|
564
|
+
- Connection scoping (ensuring related queries share one connection)
|
|
565
|
+
|
|
566
|
+
### QueryExecutor and connection scoping
|
|
567
|
+
|
|
568
|
+
The `QueryExecutor` type assumes it runs within a **single connection scope**. When using a connection pool, each call to the executor may be dispatched to a different connection, which makes multi-statement transactions unsafe.
|
|
569
|
+
|
|
570
|
+
To execute transactional workflows, the caller should obtain a dedicated connection and build the executor from it:
|
|
571
|
+
|
|
572
|
+
```ts
|
|
573
|
+
// Acquire a dedicated connection from the pool
|
|
574
|
+
const client = await pool.connect();
|
|
575
|
+
try {
|
|
576
|
+
await client.query('BEGIN');
|
|
577
|
+
|
|
578
|
+
// Build an executor scoped to this connection
|
|
579
|
+
const executor = async (sql: string, params: readonly unknown[]) => {
|
|
580
|
+
const result = await client.query(sql, params as unknown[]);
|
|
581
|
+
return result.rows;
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
const reader = createReader(executor);
|
|
585
|
+
const user = await reader.one('SELECT ...', [userId]);
|
|
586
|
+
// ... additional queries on the same connection ...
|
|
587
|
+
|
|
588
|
+
await client.query('COMMIT');
|
|
589
|
+
} catch (e) {
|
|
590
|
+
try {
|
|
591
|
+
await client.query('ROLLBACK');
|
|
592
|
+
} catch {
|
|
593
|
+
// ignore secondary rollback failure
|
|
594
|
+
}
|
|
595
|
+
throw e;
|
|
596
|
+
} finally {
|
|
597
|
+
client.release();
|
|
598
|
+
}
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
This separation keeps sql-contract focused on the mapping layer while leaving execution policy decisions — such as isolation level, retry logic, and savepoints — in the application layer where they belong.
|
|
602
|
+
|
|
603
|
+
## DBMS Differences
|
|
604
|
+
|
|
605
|
+
sql-contract does not normalize SQL dialects or placeholder styles. Use the syntax required by your driver:
|
|
606
|
+
|
|
607
|
+
```ts
|
|
608
|
+
// PostgreSQL ($1, $2, ...)
|
|
609
|
+
await executor('SELECT * FROM customers WHERE id = $1', [42])
|
|
610
|
+
|
|
611
|
+
// Named parameters (:id)
|
|
612
|
+
await executor('SELECT * FROM customers WHERE id = :id', { id: 42 })
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
## License
|
|
616
|
+
|
|
617
|
+
MIT
|