@rawsql-ts/sql-contract 0.1.0 → 0.2.0
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 +406 -300
- package/dist/.tsbuildinfo +1 -1
- package/dist/catalog/index.d.ts +163 -0
- package/dist/catalog/index.js +377 -0
- package/dist/catalog/index.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/mapper/index.d.ts +61 -8
- package/dist/mapper/index.js +261 -42
- package/dist/mapper/index.js.map +1 -1
- package/dist/mapper/internal.d.ts +21 -0
- package/dist/mapper/internal.js +103 -0
- package/dist/mapper/internal.js.map +1 -0
- package/dist/utils/coercions.d.ts +20 -0
- package/dist/utils/coercions.js +80 -0
- package/dist/utils/coercions.js.map +1 -0
- package/package.json +11 -1
package/README.md
CHANGED
|
@@ -1,51 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
|
|
2
|
+
# @rawsql-ts/sql-contract
|
|
2
3
|
|
|
3
4
|
## Overview
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
It improves the following aspects of the development experience:
|
|
8
|
-
|
|
9
|
-
- Mapping query results to models
|
|
10
|
-
- Writing simple INSERT, UPDATE, and DELETE statements
|
|
11
|
-
|
|
12
|
-
---
|
|
13
|
-
|
|
14
|
-
## Features
|
|
15
|
-
|
|
16
|
-
* Zero runtime dependencies
|
|
17
|
-
(pure JavaScript; no external packages required at runtime)
|
|
18
|
-
* Zero DBMS dependency
|
|
19
|
-
(tested with PostgreSQL, MySQL, SQL Server, and SQLite)
|
|
20
|
-
* Zero database client dependency
|
|
21
|
-
(works with any client that executes SQL and returns rows)
|
|
22
|
-
* Zero framework and ORM dependency
|
|
23
|
-
(fits into any application architecture that uses raw SQL)
|
|
24
|
-
* No schema models or metadata required
|
|
25
|
-
(tables, columns, and relationships are defined only in SQL)
|
|
26
|
-
* Result mapping helpers that operate on any SQL returning rows
|
|
27
|
-
(including SELECT queries and CUD statements with RETURNING or aggregate results)
|
|
28
|
-
* Simple builders for common INSERT, UPDATE, and DELETE cases, without query inference
|
|
29
|
-
|
|
30
|
-
---
|
|
31
|
-
|
|
32
|
-
## Philosophy
|
|
33
|
-
|
|
34
|
-
sql-contract treats SQL?especially SELECT statements?as a language for expressing domain requirements.
|
|
35
|
-
|
|
36
|
-
In SQL development, it is essential to iterate quickly through the cycle of design, writing, verification, and refinement. To achieve this, a SQL client is indispensable. SQL must remain SQL, directly executable and verifiable; it cannot be adequately replaced by a DSL without breaking this feedback loop.
|
|
37
|
-
|
|
38
|
-
Based on this philosophy, this library intentionally does not provide query construction features for SELECT statements. Queries should be written by humans, as raw SQL, and validated directly against the database.
|
|
39
|
-
|
|
40
|
-
At the same time, writing SQL inevitably involves mechanical tasks. In particular, mapping returned rows to application-level models is not part of the domain logic, yet it often becomes verbose and error-prone. sql-contract focuses on reducing this burden.
|
|
41
|
-
|
|
42
|
-
By contrast, write operations such as INSERT, UPDATE, and DELETE generally do not carry the same level of domain significance as SELECT statements. They are often repetitive, consisting of short and predictable patterns such as primary-key-based updates.
|
|
43
|
-
|
|
44
|
-
To address this, the library provides minimal builder helpers for common cases only.
|
|
45
|
-
|
|
46
|
-
It deliberately goes no further than this.
|
|
47
|
-
|
|
48
|
-
---
|
|
6
|
+
`@rawsql-ts/sql-contract` is a lightweight library for reducing repetitive, mechanical code around handwritten SQL.
|
|
7
|
+
It focuses on mapping query results into application-typed models and simplifying common CUD (Create / Update / Delete) statement construction.
|
|
49
8
|
|
|
50
9
|
## Getting Started
|
|
51
10
|
|
|
@@ -54,86 +13,74 @@ It deliberately goes no further than this.
|
|
|
54
13
|
```sh
|
|
55
14
|
pnpm add @rawsql-ts/sql-contract
|
|
56
15
|
```
|
|
16
|
+
|
|
57
17
|
### Minimal CRUD sample
|
|
58
18
|
|
|
59
19
|
```ts
|
|
60
20
|
import { Pool } from 'pg'
|
|
61
|
-
import { insert, update, remove } from '@rawsql-ts/sql-contract/writer'
|
|
62
21
|
import {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
type QueryParams
|
|
66
|
-
} from '@rawsql-ts/sql-contract
|
|
67
|
-
|
|
68
|
-
type Customer = {
|
|
69
|
-
customerId: number
|
|
70
|
-
customerName: string
|
|
71
|
-
customerStatus: string
|
|
72
|
-
}
|
|
22
|
+
createReader,
|
|
23
|
+
createWriter,
|
|
24
|
+
type QueryParams
|
|
25
|
+
} from '@rawsql-ts/sql-contract'
|
|
73
26
|
|
|
74
27
|
async function main() {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
28
|
+
const pool = new Pool({
|
|
29
|
+
connectionString: process.env.DATABASE_URL
|
|
30
|
+
})
|
|
78
31
|
|
|
79
32
|
const executor = async (sql: string, params: QueryParams) => {
|
|
80
33
|
const result = await pool.query(sql, params as unknown[])
|
|
81
34
|
return result.rows
|
|
82
35
|
}
|
|
83
36
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const mapper = createMapperFromExecutor(executor, mapperPresets.appLike())
|
|
87
|
-
const rows = await mapper.query<Customer>(
|
|
88
|
-
`
|
|
89
|
-
select
|
|
90
|
-
customer_id,
|
|
91
|
-
customer_name,
|
|
92
|
-
customer_status
|
|
93
|
-
from customers
|
|
94
|
-
where customer_id = $1
|
|
95
|
-
`,
|
|
96
|
-
[42],
|
|
97
|
-
)
|
|
37
|
+
const reader = createReader(executor)
|
|
38
|
+
const writer = createWriter(executor)
|
|
98
39
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
status: 'pending',
|
|
104
|
-
})
|
|
105
|
-
await executor(insertResult.sql, insertResult.params)
|
|
40
|
+
const rows = await reader.list(
|
|
41
|
+
`SELECT customer_id, customer_name FROM customers WHERE status = $1`,
|
|
42
|
+
['active']
|
|
43
|
+
)
|
|
106
44
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const updateResult = update(
|
|
45
|
+
await writer.insert('customers', { name: 'alice', status: 'active' })
|
|
46
|
+
await writer.update(
|
|
110
47
|
'customers',
|
|
111
|
-
{ status: '
|
|
112
|
-
{ id: 42 }
|
|
48
|
+
{ status: 'inactive' },
|
|
49
|
+
{ id: 42 }
|
|
113
50
|
)
|
|
114
|
-
await
|
|
115
|
-
|
|
116
|
-
// DELETE:
|
|
117
|
-
// Simplify repetitive SQL for common write operations.
|
|
118
|
-
const deleteResult = remove('customers', { id: 17 })
|
|
119
|
-
await executor(deleteResult.sql, deleteResult.params)
|
|
51
|
+
await writer.remove('customers', { id: 17 })
|
|
120
52
|
|
|
121
53
|
await pool.end()
|
|
54
|
+
|
|
122
55
|
void rows
|
|
123
56
|
}
|
|
124
|
-
|
|
125
|
-
void main()
|
|
126
57
|
```
|
|
127
58
|
|
|
128
|
-
|
|
59
|
+
## Features
|
|
60
|
+
|
|
61
|
+
* Zero runtime dependencies
|
|
62
|
+
* Zero DBMS and driver dependencies
|
|
63
|
+
* Works with any SQL-executor returning rows
|
|
64
|
+
* Minimal mapping helpers for SELECT results
|
|
65
|
+
* Simple builders for INSERT / UPDATE / DELETE
|
|
66
|
+
|
|
67
|
+
## Philosophy
|
|
68
|
+
|
|
69
|
+
### SQL as Domain Specification
|
|
70
|
+
|
|
71
|
+
SQL is the primary language for expressing domain requirements—precise, unambiguous, and directly verifiable against the database.
|
|
72
|
+
|
|
73
|
+
Mapping returned rows to typed domain models is mechanical and repetitive.
|
|
74
|
+
This library removes that burden, letting domain logic remain in SQL.
|
|
129
75
|
|
|
130
|
-
|
|
76
|
+
Write operations (INSERT/UPDATE/DELETE) are usually repetitive and predictable.
|
|
77
|
+
So the library offers simple builders for common cases only.
|
|
131
78
|
|
|
132
|
-
|
|
133
|
-
To integrate it with a specific database or driver, **you must define a small executor function**.
|
|
79
|
+
## Concepts
|
|
134
80
|
|
|
135
|
-
|
|
136
|
-
|
|
81
|
+
### Executor: DBMS / Driver Integration
|
|
82
|
+
|
|
83
|
+
To integrate with any database or driver, define a single executor:
|
|
137
84
|
|
|
138
85
|
```ts
|
|
139
86
|
const executor = async (sql: string, params: QueryParams) => {
|
|
@@ -142,335 +89,494 @@ const executor = async (sql: string, params: QueryParams) => {
|
|
|
142
89
|
}
|
|
143
90
|
```
|
|
144
91
|
|
|
145
|
-
|
|
146
|
-
|
|
92
|
+
Connection pooling, retries, transactions, and error handling belong inside the executor.
|
|
93
|
+
|
|
94
|
+
### Reader: Query Execution and Result Mapping
|
|
95
|
+
|
|
96
|
+
Reader executes SELECT queries and maps raw database rows into application-friendly structures.
|
|
97
|
+
|
|
98
|
+
It supports multiple levels of mapping depending on your needs,
|
|
99
|
+
from quick projections to fully validated domain models.
|
|
100
|
+
|
|
101
|
+
### Catalog executor: QuerySpec contract and observability
|
|
102
|
+
|
|
103
|
+
Catalog executor executes queries through a `QuerySpec` instead of running raw
|
|
104
|
+
SQL directly.
|
|
147
105
|
|
|
148
|
-
|
|
149
|
-
|
|
106
|
+
A `QuerySpec` defines a stable query contract that couples an SQL file,
|
|
107
|
+
parameter shape, and output rules. By executing queries through this contract,
|
|
108
|
+
the executor can enforce parameter expectations, apply output mapping or
|
|
109
|
+
validation, and provide a stable identity for debugging and observability.
|
|
110
|
+
|
|
111
|
+
`createCatalogExecutor` is wired with a SQL loader and a concrete query executor,
|
|
112
|
+
and can optionally apply rewriters, binders, SQL caching,
|
|
113
|
+
`allowNamedParamsWithoutBinder`, extensions, or an `observabilitySink`.
|
|
114
|
+
|
|
115
|
+
When observability is enabled, execution emits lifecycle events
|
|
116
|
+
(`query_start`, `query_end`, `query_error`) including `spec.id`, `sqlFile`,
|
|
117
|
+
and execution identifiers, allowing queries to be traced and debugged by
|
|
118
|
+
specification rather than raw SQL strings.
|
|
150
119
|
|
|
151
120
|
---
|
|
152
|
-
## Mapper: Query Result Mapping (R)
|
|
153
121
|
|
|
154
|
-
|
|
122
|
+
#### Basic result APIs: one and list
|
|
123
|
+
|
|
124
|
+
Reader provides two primary methods:
|
|
155
125
|
|
|
156
|
-
|
|
126
|
+
- one : returns a single row
|
|
127
|
+
- list : returns multiple rows
|
|
157
128
|
|
|
158
129
|
```ts
|
|
159
|
-
const
|
|
160
|
-
|
|
130
|
+
const customer = await reader.one(
|
|
131
|
+
'select customer_id, customer_name from customers where customer_id = $1',
|
|
132
|
+
[1]
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
const customers = await reader.list(
|
|
136
|
+
'select customer_id, customer_name from customers'
|
|
137
|
+
)
|
|
161
138
|
```
|
|
162
139
|
|
|
163
|
-
|
|
164
|
-
|
|
140
|
+
These methods focus only on execution.
|
|
141
|
+
Mapping behavior depends on how the reader is configured.
|
|
165
142
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
#### Duck typing (minimal, disposable)
|
|
146
|
+
|
|
147
|
+
For quick or localized queries, you can rely on structural typing without defining models.
|
|
169
148
|
|
|
170
149
|
```ts
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
mapperPresets,
|
|
174
|
-
} from '@rawsql-ts/sql-contract/mapper'
|
|
175
|
-
|
|
176
|
-
// `executor` is defined according to the Executor section above.
|
|
177
|
-
const mapper = createMapperFromExecutor(
|
|
178
|
-
executor,
|
|
179
|
-
mapperPresets.appLike(),
|
|
150
|
+
const rows = await reader.list<{ customerId: number }>(
|
|
151
|
+
'select customer_id from customers limit 1',
|
|
180
152
|
)
|
|
181
153
|
```
|
|
182
154
|
|
|
183
|
-
|
|
155
|
+
You can also omit the DTO type entirely:
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
const rows = await reader.list(
|
|
159
|
+
'select customer_id from customers limit 1',
|
|
160
|
+
)
|
|
161
|
+
```
|
|
184
162
|
|
|
185
|
-
|
|
163
|
+
This approach is:
|
|
186
164
|
|
|
187
|
-
|
|
188
|
-
|
|
165
|
+
* Fast to write
|
|
166
|
+
* Suitable for one-off queries
|
|
167
|
+
* No runtime validation
|
|
189
168
|
|
|
190
|
-
|
|
169
|
+
---
|
|
191
170
|
|
|
192
|
-
|
|
193
|
-
| ------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
|
194
|
-
| `mapperPresets.appLike()` | Applies common application-friendly defaults, such as snake_case to camelCase conversion and date coercion. |
|
|
195
|
-
| `mapperPresets.safe()` | Leaves column names and values untouched, suitable for exploratory queries or legacy schemas. |
|
|
171
|
+
#### Custom mapping
|
|
196
172
|
|
|
197
|
-
|
|
198
|
-
This allows localized adjustments without changing the preset used elsewhere.
|
|
173
|
+
Reader allows custom projection logic when structural mapping is insufficient.
|
|
199
174
|
|
|
200
175
|
```ts
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
},
|
|
208
|
-
})
|
|
176
|
+
const rows = await reader.map(
|
|
177
|
+
'select price, quantity from order_items',
|
|
178
|
+
(row) => ({
|
|
179
|
+
total: row.price * row.quantity,
|
|
180
|
+
})
|
|
181
|
+
)
|
|
209
182
|
```
|
|
210
183
|
|
|
211
|
-
This
|
|
184
|
+
This is useful for:
|
|
185
|
+
|
|
186
|
+
* Derived values
|
|
187
|
+
* Format conversion
|
|
188
|
+
* Aggregated projections
|
|
212
189
|
|
|
213
190
|
---
|
|
214
191
|
|
|
215
|
-
### Duck-typed mapping (no model definitions)
|
|
216
192
|
|
|
217
|
-
|
|
193
|
+
#### Column naming conventions (default behavior)
|
|
218
194
|
|
|
219
|
-
|
|
220
|
-
|
|
195
|
+
Reader applies a default naming rule that converts snake_case database columns
|
|
196
|
+
into camelCase JavaScript properties.
|
|
221
197
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
198
|
+
This allows most queries to work without explicit mapping.
|
|
199
|
+
|
|
200
|
+
Example:
|
|
201
|
+
|
|
202
|
+
```sql
|
|
203
|
+
select customer_id, created_at from customers
|
|
227
204
|
```
|
|
228
205
|
|
|
229
|
-
|
|
206
|
+
becomes:
|
|
230
207
|
|
|
231
208
|
```ts
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
209
|
+
{
|
|
210
|
+
customerId: number
|
|
211
|
+
createdAt: Date
|
|
212
|
+
}
|
|
235
213
|
```
|
|
236
214
|
|
|
237
|
-
|
|
238
|
-
If the shape of the query results is important or reused throughout your application, consider moving to explicit row mapping.
|
|
215
|
+
No mapping definition is required for this transformation.
|
|
239
216
|
|
|
240
217
|
---
|
|
241
218
|
|
|
242
|
-
|
|
219
|
+
#### Mapper presets
|
|
220
|
+
|
|
221
|
+
You can configure how column names are transformed.
|
|
243
222
|
|
|
244
|
-
|
|
223
|
+
Example:
|
|
245
224
|
|
|
246
225
|
```ts
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
customerName: string
|
|
250
|
-
}
|
|
226
|
+
const reader = createReader(executor, mapperPresets.safe())
|
|
227
|
+
```
|
|
251
228
|
|
|
252
|
-
|
|
253
|
-
`
|
|
254
|
-
select
|
|
255
|
-
customer_id,
|
|
256
|
-
customer_name
|
|
257
|
-
from customers
|
|
258
|
-
where customer_id = $1
|
|
259
|
-
`,
|
|
260
|
-
[42],
|
|
261
|
-
)
|
|
229
|
+
Common presets include:
|
|
262
230
|
|
|
263
|
-
|
|
264
|
-
|
|
231
|
+
* appLike : snake_case → camelCase conversion
|
|
232
|
+
* safe : no column name transformation
|
|
233
|
+
|
|
234
|
+
Choose a preset based on how closely your domain models align with database naming.
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
#### When explicit mapping is useful
|
|
265
239
|
|
|
266
|
-
|
|
240
|
+
Even with automatic naming conversion, explicit mappings become valuable when:
|
|
267
241
|
|
|
268
|
-
|
|
242
|
+
* Domain terms differ from column names
|
|
243
|
+
* Multiple columns combine into one field
|
|
244
|
+
* Queries are reused across modules
|
|
245
|
+
* Schema stability should be decoupled from application models
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
#### Single model mapping (reusable definition)
|
|
250
|
+
|
|
251
|
+
Mapping models provide explicit control over how rows map to domain objects.
|
|
252
|
+
|
|
253
|
+
Example:
|
|
269
254
|
|
|
270
255
|
```ts
|
|
271
|
-
const
|
|
256
|
+
const orderSummaryMapping = rowMapping({
|
|
257
|
+
name: 'OrderSummary',
|
|
258
|
+
key: 'orderId',
|
|
272
259
|
columnMap: {
|
|
273
|
-
|
|
274
|
-
|
|
260
|
+
orderId: 'order_id',
|
|
261
|
+
customerLabel: 'customer_display_name',
|
|
262
|
+
totalAmount: 'grand_total',
|
|
275
263
|
},
|
|
276
264
|
})
|
|
277
265
|
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
customerMapping, // explicitly specify the mapping rule
|
|
288
|
-
)
|
|
289
|
-
|
|
290
|
-
// rows[0].customerName is type-safe
|
|
266
|
+
const summaries = await reader
|
|
267
|
+
.bind(orderSummaryMapping)
|
|
268
|
+
.list(`
|
|
269
|
+
select
|
|
270
|
+
order_id,
|
|
271
|
+
customer_display_name,
|
|
272
|
+
grand_total
|
|
273
|
+
from order_view
|
|
274
|
+
`)
|
|
291
275
|
```
|
|
292
276
|
|
|
293
|
-
|
|
277
|
+
In this example:
|
|
294
278
|
|
|
295
|
-
|
|
296
|
-
|
|
279
|
+
* Domain terminology differs from database naming
|
|
280
|
+
* Mapping clarifies intent
|
|
281
|
+
* The definition can be reused across queries
|
|
282
|
+
|
|
283
|
+
Benefits:
|
|
284
|
+
|
|
285
|
+
* Reusable mapping definitions
|
|
286
|
+
* Explicit domain language alignment
|
|
287
|
+
* Reduced accidental schema coupling
|
|
288
|
+
* Better long-term maintainability
|
|
297
289
|
|
|
298
290
|
---
|
|
299
291
|
|
|
300
|
-
|
|
292
|
+
#### Composite keys
|
|
301
293
|
|
|
302
|
-
|
|
294
|
+
`rowMapping` keys can now be more than a single column without breaking existing consumers:
|
|
303
295
|
|
|
304
|
-
|
|
296
|
+
* **Array-based composite keys** — pass the raw column names in SQL order (`key: ['col_a', 'col_b']`). These column values are extracted directly from the executor’s row, so `columnMap` / `prefix` rules are not involved.
|
|
297
|
+
* **Derived keys** — supply a function, e.g. `key: (row) => [row.col_a, row.col_b]`, that returns strings/numbers/bigints or an array thereof. The library type-tags each component so `'1'` and `1` are never conflated, and order of the array is preserved.
|
|
305
298
|
|
|
306
|
-
|
|
307
|
-
const orderMapping = rowMapping({
|
|
308
|
-
name: 'order',
|
|
309
|
-
key: 'orderId',
|
|
310
|
-
prefix: 'order_',
|
|
311
|
-
}).belongsTo('customer', customerMapping, 'customerId')
|
|
312
|
-
```
|
|
299
|
+
Both forms feed through a single normalization path, so you can combine mixed types safely and receive clear errors if a value is `null`, `undefined`, or missing. Creating a synthetic column inside SQL (e.g. `SELECT CONCAT(col_a, '|', col_b) AS composite_key`) still works as a workaround, but we recommend using the multi-column helpers because they keep the schema explicit and avoid delimiter collisions.
|
|
313
300
|
|
|
314
|
-
|
|
301
|
+
`name` continues to serve as the user-visible label for error messages, independent of whether the key is scalar, composite, or derived.
|
|
302
|
+
|
|
303
|
+
#### Multi-model mapping
|
|
304
|
+
|
|
305
|
+
Reader supports mapping joined results into multiple domain models by composing `rowMapping` definitions.
|
|
315
306
|
|
|
316
307
|
```ts
|
|
317
|
-
const
|
|
308
|
+
const customerMapping = rowMapping({
|
|
309
|
+
name: 'Customer',
|
|
310
|
+
key: 'customerId',
|
|
311
|
+
columnMap: {
|
|
312
|
+
customerId: 'customer_customer_id',
|
|
313
|
+
customerName: 'customer_customer_name',
|
|
314
|
+
},
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
const orderMapping = rowMapping<{
|
|
318
|
+
orderId: number
|
|
319
|
+
orderTotal: number
|
|
320
|
+
customerId: number
|
|
321
|
+
customer: { customerId: number; customerName: string }
|
|
322
|
+
}>({
|
|
323
|
+
name: 'Order',
|
|
324
|
+
key: 'orderId',
|
|
325
|
+
columnMap: {
|
|
326
|
+
orderId: 'order_order_id',
|
|
327
|
+
orderTotal: 'order_total',
|
|
328
|
+
customerId: 'order_customer_id',
|
|
329
|
+
},
|
|
330
|
+
}).belongsTo('customer', customerMapping, 'customerId')
|
|
318
331
|
|
|
319
|
-
const
|
|
320
|
-
|
|
332
|
+
const result = await reader
|
|
333
|
+
.bind(orderMapping)
|
|
334
|
+
.list(`
|
|
321
335
|
select
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
[123],
|
|
331
|
-
orderMapping,
|
|
332
|
-
)
|
|
336
|
+
c.id as customer_customer_id,
|
|
337
|
+
c.name as customer_customer_name,
|
|
338
|
+
o.id as order_order_id,
|
|
339
|
+
o.total as order_total,
|
|
340
|
+
o.customer_id as order_customer_id
|
|
341
|
+
from customers c
|
|
342
|
+
join orders o on o.customer_id = c.customer_id
|
|
343
|
+
`)
|
|
333
344
|
```
|
|
334
345
|
|
|
346
|
+
`belongsTo` attaches each customer row to its owning order, so the mapped result exposes a nested `customer` object without duplicating join logic.
|
|
347
|
+
|
|
348
|
+
This enables structured projections from complex joins.
|
|
349
|
+
|
|
335
350
|
---
|
|
336
351
|
|
|
337
|
-
|
|
352
|
+
#### Validator-backed mapping (recommended)
|
|
338
353
|
|
|
339
|
-
|
|
340
|
-
|
|
354
|
+
Runtime validation ensures data correctness.
|
|
355
|
+
Zod integration is the recommended approach.
|
|
341
356
|
|
|
342
|
-
|
|
357
|
+
```ts
|
|
358
|
+
import { z } from 'zod'
|
|
343
359
|
|
|
344
|
-
|
|
360
|
+
const CustomerSchema = z.object({
|
|
361
|
+
customerId: z.number(),
|
|
362
|
+
customerName: z.string(),
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
const row = await reader
|
|
366
|
+
.validator(CustomerSchema)
|
|
367
|
+
.one(
|
|
368
|
+
'select customer_id, customer_name from customers where customer_id = $1',
|
|
369
|
+
[1]
|
|
370
|
+
)
|
|
371
|
+
```
|
|
345
372
|
|
|
346
|
-
|
|
373
|
+
Benefits include:
|
|
347
374
|
|
|
348
|
-
|
|
375
|
+
* Runtime safety
|
|
376
|
+
* Explicit schema documentation
|
|
377
|
+
* Refactoring confidence
|
|
378
|
+
* AI-friendly feedback loops
|
|
349
379
|
|
|
350
|
-
|
|
351
|
-
* identifiers are validated against ASCII-safe patterns unless explicitly allowed
|
|
352
|
-
* WHERE clauses are limited to equality-based AND fragments
|
|
353
|
-
* no inference, no joins, no multi-table logic
|
|
380
|
+
---
|
|
354
381
|
|
|
355
|
-
|
|
356
|
-
Using `'all'` maps to `RETURNING *`; otherwise, column names are sorted alphabetically.
|
|
382
|
+
### Scalar Queries
|
|
357
383
|
|
|
358
|
-
|
|
359
|
-
It emits SQL exactly as specified so that success or failure remains observable at execution time.
|
|
384
|
+
Use scalar helpers when a query returns a single value.
|
|
360
385
|
|
|
361
|
-
|
|
386
|
+
#### Basic scalar usage
|
|
362
387
|
|
|
363
388
|
```ts
|
|
364
|
-
await
|
|
365
|
-
'
|
|
366
|
-
|
|
367
|
-
{ returning: ['project_id'] },
|
|
389
|
+
const count = await reader.scalar(
|
|
390
|
+
'select count(*) from customers where status = $1',
|
|
391
|
+
['active']
|
|
368
392
|
)
|
|
369
393
|
```
|
|
370
394
|
|
|
371
|
-
|
|
395
|
+
This is useful for:
|
|
396
|
+
|
|
397
|
+
* COUNT queries
|
|
398
|
+
* Aggregate values
|
|
399
|
+
* Existence checks
|
|
400
|
+
|
|
401
|
+
---
|
|
402
|
+
|
|
403
|
+
#### Typed scalar mapping
|
|
404
|
+
|
|
405
|
+
You can explicitly define the expected scalar type.
|
|
372
406
|
|
|
373
407
|
```ts
|
|
374
|
-
await
|
|
375
|
-
'
|
|
376
|
-
|
|
377
|
-
{ project_id: 1 },
|
|
408
|
+
const count = await reader.scalar<number>(
|
|
409
|
+
'select count(*) from customers where status = $1',
|
|
410
|
+
['active']
|
|
378
411
|
)
|
|
379
412
|
```
|
|
380
413
|
|
|
381
|
-
|
|
414
|
+
This improves readability and helps prevent accidental misuse.
|
|
415
|
+
|
|
416
|
+
---
|
|
417
|
+
|
|
418
|
+
#### Scalar validation with Zod (recommended)
|
|
419
|
+
|
|
420
|
+
For stricter guarantees, scalar values can be validated at runtime.
|
|
382
421
|
|
|
383
422
|
```ts
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
423
|
+
import { z } from 'zod'
|
|
424
|
+
|
|
425
|
+
const count = await reader.scalar(
|
|
426
|
+
z.number(),
|
|
427
|
+
'select count(*) from customers where status = $1',
|
|
428
|
+
['active']
|
|
387
429
|
)
|
|
388
430
|
```
|
|
389
431
|
|
|
390
|
-
|
|
432
|
+
This approach ensures:
|
|
433
|
+
|
|
434
|
+
* Runtime type safety
|
|
435
|
+
* Clear intent
|
|
436
|
+
* Safer refactoring
|
|
437
|
+
|
|
438
|
+
---
|
|
439
|
+
|
|
440
|
+
### Zod integration and coercion helpers
|
|
441
|
+
|
|
442
|
+
Reader integrates smoothly with Zod for runtime validation and safe type conversion.
|
|
443
|
+
|
|
444
|
+
Zod validation helps ensure that query results match your domain expectations,
|
|
445
|
+
especially when working with numeric or date values returned as strings by drivers.
|
|
446
|
+
|
|
447
|
+
---
|
|
448
|
+
|
|
449
|
+
#### Row validation with Zod
|
|
391
450
|
|
|
392
451
|
```ts
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
)
|
|
452
|
+
import { z } from 'zod'
|
|
453
|
+
|
|
454
|
+
const CustomerSchema = z.object({
|
|
455
|
+
customerId: z.number(),
|
|
456
|
+
customerName: z.string(),
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
const row = await reader
|
|
460
|
+
.validator(CustomerSchema)
|
|
461
|
+
.one(
|
|
462
|
+
'select customer_id, customer_name from customers where customer_id = $1',
|
|
463
|
+
[1]
|
|
464
|
+
)
|
|
398
465
|
```
|
|
399
466
|
|
|
400
|
-
|
|
467
|
+
This provides:
|
|
401
468
|
|
|
402
|
-
|
|
469
|
+
* Runtime safety
|
|
470
|
+
* Clear schema documentation
|
|
471
|
+
* Refactoring confidence
|
|
403
472
|
|
|
404
|
-
|
|
473
|
+
---
|
|
405
474
|
|
|
406
|
-
|
|
407
|
-
2. whether parameters are positional or named,
|
|
408
|
-
3. how parameters are ordered and bound.
|
|
475
|
+
#### Scalar validation with Zod
|
|
409
476
|
|
|
410
477
|
```ts
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
executor,
|
|
415
|
-
writerPresets.named({
|
|
416
|
-
formatPlaceholder: (paramName) => ':' + paramName,
|
|
417
|
-
}),
|
|
478
|
+
const count = await reader.scalar(
|
|
479
|
+
z.number(),
|
|
480
|
+
'select count(*) from customers'
|
|
418
481
|
)
|
|
419
482
|
```
|
|
420
483
|
|
|
421
|
-
|
|
484
|
+
---
|
|
485
|
+
|
|
486
|
+
#### Coercion helpers for numeric values
|
|
422
487
|
|
|
423
|
-
|
|
488
|
+
Some database drivers return numeric values as strings.
|
|
489
|
+
The package provides helpers to safely convert them.
|
|
424
490
|
|
|
425
|
-
|
|
426
|
-
`name_1`, `owner_id_2`.
|
|
491
|
+
Example:
|
|
427
492
|
|
|
428
493
|
```ts
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
494
|
+
import { z } from 'zod'
|
|
495
|
+
import {
|
|
496
|
+
zNumberFromString,
|
|
497
|
+
zBigIntFromString
|
|
498
|
+
} from '@rawsql-ts/sql-contract-zod'
|
|
499
|
+
|
|
500
|
+
const schema = z.object({
|
|
501
|
+
totalAmount: zNumberFromString,
|
|
502
|
+
largeCounter: zBigIntFromString,
|
|
503
|
+
})
|
|
432
504
|
```
|
|
433
505
|
|
|
506
|
+
These helpers:
|
|
507
|
+
|
|
508
|
+
* Convert strings into numeric types
|
|
509
|
+
* Fail fast when values are invalid
|
|
510
|
+
* Reduce manual parsing logic
|
|
511
|
+
|
|
434
512
|
---
|
|
435
513
|
|
|
436
|
-
|
|
514
|
+
#### When to use coercion
|
|
437
515
|
|
|
438
|
-
|
|
516
|
+
Use coercion helpers when:
|
|
439
517
|
|
|
440
|
-
|
|
441
|
-
|
|
518
|
+
* Working with NUMERIC / DECIMAL columns
|
|
519
|
+
* Drivers return BIGINT as strings
|
|
520
|
+
* You want runtime guarantees
|
|
442
521
|
|
|
443
|
-
|
|
522
|
+
---
|
|
523
|
+
|
|
524
|
+
### Writer: Simple CUD Helpers
|
|
444
525
|
|
|
445
|
-
|
|
446
|
-
| --------------------------------- | ------------------ |
|
|
447
|
-
| PostgreSQL / Neon (node-postgres) | `$1`, `$2`, ... |
|
|
448
|
-
| PostgreSQL / pg-promise | `$/name/` |
|
|
449
|
-
| MySQL / SQLite | `?` |
|
|
450
|
-
| SQL Server | `@p1`, `@p2`, ... |
|
|
451
|
-
| Oracle | `:1`, `:name`, ... |
|
|
526
|
+
Writer helpers build simple INSERT/UPDATE/DELETE SQL:
|
|
452
527
|
|
|
453
528
|
```ts
|
|
454
|
-
await
|
|
455
|
-
|
|
456
|
-
|
|
529
|
+
await writer.insert('projects', {
|
|
530
|
+
name: 'Apollo',
|
|
531
|
+
owner_id: 7
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
await writer.update(
|
|
535
|
+
'projects',
|
|
536
|
+
{ name: 'Apollo' },
|
|
537
|
+
{ project_id: 1 }
|
|
457
538
|
)
|
|
539
|
+
|
|
540
|
+
await writer.remove('projects', { project_id: 1 })
|
|
458
541
|
```
|
|
459
542
|
|
|
543
|
+
You can also build statements without execution:
|
|
544
|
+
|
|
460
545
|
```ts
|
|
461
|
-
|
|
462
|
-
'
|
|
463
|
-
{
|
|
546
|
+
const stmt = writer.build.insert(
|
|
547
|
+
'projects',
|
|
548
|
+
{ name: 'Apollo' }
|
|
464
549
|
)
|
|
465
550
|
```
|
|
466
551
|
|
|
467
|
-
|
|
468
|
-
Both writer and mapper remain independent of these differences.
|
|
552
|
+
## Reducers (Coercion Helpers)
|
|
469
553
|
|
|
470
|
-
|
|
554
|
+
The package exposes pure coercion helpers:
|
|
471
555
|
|
|
472
|
-
|
|
556
|
+
* `decimalStringToNumberUnsafe`
|
|
557
|
+
* `bigintStringToBigInt`
|
|
558
|
+
|
|
559
|
+
They convert raw DB output strings into numbers or bigints when needed.
|
|
473
560
|
|
|
474
|
-
|
|
561
|
+
## DBMS Differences
|
|
562
|
+
|
|
563
|
+
sql-contract does not normalize SQL dialects or placeholder styles.
|
|
564
|
+
You must use the placeholder syntax required by your driver.
|
|
565
|
+
|
|
566
|
+
Examples:
|
|
567
|
+
|
|
568
|
+
```ts
|
|
569
|
+
await executor(
|
|
570
|
+
'select * from customers where id = $1',
|
|
571
|
+
[42],
|
|
572
|
+
)
|
|
573
|
+
await executor(
|
|
574
|
+
'select * from customers where id = :id',
|
|
575
|
+
{ id: 42 },
|
|
576
|
+
)
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
## Influences / Related Ideas
|
|
475
580
|
|
|
476
|
-
|
|
581
|
+
sql-contract is inspired by minimal mapping libraries such as Dapper,
|
|
582
|
+
stopping short of a full ORM and instead providing a predictable, transparent layer.
|