@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 CHANGED
@@ -1,51 +1,10 @@
1
- # @rawsql-ts/sql-contract
1
+ 
2
+ # @rawsql-ts/sql-contract
2
3
 
3
4
  ## Overview
4
5
 
5
- @rawsql-ts/sql-contract is a lightweight library designed to reduce the repetitive, mechanical code commonly encountered when working with handwritten SQL.
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
- createMapperFromExecutor,
64
- mapperPresets,
65
- type QueryParams,
66
- } from '@rawsql-ts/sql-contract/mapper'
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
- // Prepare an executor that runs SQL and returns rows.
76
- // sql-contract remains DBMS- and driver-agnostic by depending only on this function.
77
- const pool = new Pool({ connectionString: process.env.DATABASE_URL })
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
- // SELECT:
85
- // Map snake_case SQL columns to a typed DTO without writing per-column mapping code.
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
- // INSERT:
100
- // Simplify repetitive SQL for common write operations.
101
- const insertResult = insert('customers', {
102
- name: 'alice',
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
- // UPDATE:
108
- // Simplify repetitive SQL for common write operations.
109
- const updateResult = update(
45
+ await writer.insert('customers', { name: 'alice', status: 'active' })
46
+ await writer.update(
110
47
  'customers',
111
- { status: 'active' },
112
- { id: 42 },
48
+ { status: 'inactive' },
49
+ { id: 42 }
113
50
  )
114
- await executor(updateResult.sql, updateResult.params)
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
- ## Executor: DBMS / Driver Integration
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
- `sql-contract` is designed as a reusable, DBMS-agnostic library.
133
- To integrate it with a specific database or driver, **you must define a small executor function**.
79
+ ## Concepts
134
80
 
135
- An executor receives a SQL string and parameters, executes them using your DB driver, and returns the resulting rows as `Row[]`.
136
- By doing so, `sql-contract` can consume query results without knowing anything about the underlying database or driver.
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
- This function is the single integration point between `sql-contract` and the DBMS.
146
- Connection pooling, transactions, retries, error handling, and other DBMS- or driver-specific concerns should all be handled within the executor.
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
- The `params` argument uses the exported `QueryParams` type.
149
- It supports both positional arrays and named records, allowing executors to work with positional, anonymous, or named parameter styles depending on the driver.
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
- The mapper is responsible for projecting query results (`Row[]`) into DTOs.
122
+ #### Basic result APIs: one and list
123
+
124
+ Reader provides two primary methods:
155
125
 
156
- R looks like this:
126
+ - one : returns a single row
127
+ - list : returns multiple rows
157
128
 
158
129
  ```ts
159
- const reader = mapper.bind(customerMapping)
160
- await reader.one('SELECT ...', [42])
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
- In a typical application, a mapper is created once and reused across queries.
164
- It defines application-wide mapping behavior, while individual queries decide how results are projected.
140
+ These methods focus only on execution.
141
+ Mapping behavior depends on how the reader is configured.
165
142
 
166
- The mapper operates purely on returned rows and never inspects SQL, parameters, or execution behavior.
167
- To keep mapping predictable, it does not guess column semantics or relationships.
168
- All transformations are applied through explicit configuration.
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
- import {
172
- createMapperFromExecutor,
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
- This example shows a typical mapper setup.
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
- For read-heavy flows you can also call `createReader(executor)`, which aliases the same factory and applies `mapperPresets.appLike()` by default so you get camelCase mapping with minimal setup.
163
+ This approach is:
186
164
 
187
- `createMapperFromExecutor` binds an executor to a mapper and accepts optional mapping options.
188
- These options control how column names are normalized, how values are coerced, and how identifiers are treated.
165
+ * Fast to write
166
+ * Suitable for one-off queries
167
+ * No runtime validation
189
168
 
190
- For convenience, `mapperPresets` provide reusable configurations for common scenarios:
169
+ ---
191
170
 
192
- | Preset | Description |
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
- When a specific query needs fine-grained control, you can also provide a custom options object.
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 mapper = createMapperFromExecutor(executor, {
202
- keyTransform: 'snake_to_camel',
203
- coerceDates: true,
204
- idKeysAsString: true,
205
- typeHints: {
206
- createdAt: 'date',
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 form mirrors `mapperPresets.appLike()` while allowing targeted overrides for a specific mapping.
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
- For lightweight or localized use cases, the mapper supports duck-typed projections without defining any schema or mapping models.
193
+ #### Column naming conventions (default behavior)
218
194
 
219
- In duck-typed mapping, the mapper applies no additional structural assumptions beyond its configured defaults.
220
- The shape of the result is defined locally at the query site, either by providing a TypeScript type or by relying on the raw row shape.
195
+ Reader applies a default naming rule that converts snake_case database columns
196
+ into camelCase JavaScript properties.
221
197
 
222
- ```ts
223
- // Explicitly typed projection
224
- const rows = await mapper.query<{ customerId: number }>(
225
- 'select customer_id from customers limit 1',
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
- Although not recommended, you can omit the DTO type for quick exploration:
206
+ becomes:
230
207
 
231
208
  ```ts
232
- const rows = await mapper.query(
233
- 'select customer_id from customers limit 1',
234
- )
209
+ {
210
+ customerId: number
211
+ createdAt: Date
212
+ }
235
213
  ```
236
214
 
237
- Duck-typed mapping is intentionally minimal and local.
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
- ### Mapping to a Single Model
219
+ #### Mapper presets
220
+
221
+ You can configure how column names are transformed.
243
222
 
244
- sql-contract allows you to map query results to typed DTOs.
223
+ Example:
245
224
 
246
225
  ```ts
247
- type Customer = {
248
- customerId: number
249
- customerName: string
250
- }
226
+ const reader = createReader(executor, mapperPresets.safe())
227
+ ```
251
228
 
252
- const rows = await mapper.query<Customer>(
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
- // rows[0].customerName is type-safe
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
- The normalization rules applied during mapping are controlled by the selected mapper preset.
240
+ Even with automatic naming conversion, explicit mappings become valuable when:
267
241
 
268
- You can also define one-off mapping rules using columnMap.
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 customerMapping = rowMapping<Customer>({
256
+ const orderSummaryMapping = rowMapping({
257
+ name: 'OrderSummary',
258
+ key: 'orderId',
272
259
  columnMap: {
273
- customerId: 'customer_id',
274
- customerName: 'customer_name',
260
+ orderId: 'order_id',
261
+ customerLabel: 'customer_display_name',
262
+ totalAmount: 'grand_total',
275
263
  },
276
264
  })
277
265
 
278
- const rows = await mapper.query<Customer>(
279
- `
280
- select
281
- customer_id,
282
- customer_name
283
- from customers
284
- where customer_id = $1
285
- `,
286
- [42],
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
- The `rowMapping()` helper replaces the previous `entity()` alias. The old name is still supported for now but is deprecated in favor of `rowMapping()`.
277
+ In this example:
294
278
 
295
- When a query includes JOINs or relationships, explicit row mappings are required.
296
- The structure for such mappings is explained in the next section.
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
- ### Mapping to multiple models (joined rows)
292
+ #### Composite keys
301
293
 
302
- The mapper also supports mapping joined result sets into multiple related models.
294
+ `rowMapping` keys can now be more than a single column without breaking existing consumers:
303
295
 
304
- Relations are explicitly defined and never inferred.
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
- ```ts
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
- Joined queries remain transparent and deterministic:
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 mapper = createMapperFromExecutor(executor)
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 rows = await mapper.query(
320
- `
332
+ const result = await reader
333
+ .bind(orderMapping)
334
+ .list(`
321
335
  select
322
- o.order_id,
323
- o.order_total,
324
- c.customer_id,
325
- c.customer_name
326
- from orders o
327
- join customers c on c.customer_id = o.customer_id
328
- where o.order_id = $1
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
- ## Writer: emitting simple C / U / D statements
352
+ #### Validator-backed mapping (recommended)
338
353
 
339
- The writer helpers provide a small, opinionated DSL for common
340
- INSERT, UPDATE, and DELETE statements.
354
+ Runtime validation ensures data correctness.
355
+ Zod integration is the recommended approach.
341
356
 
342
- They accept table names and plain objects of column-value pairs, and deterministically emit `{ sql, params }`.
357
+ ```ts
358
+ import { z } from 'zod'
343
359
 
344
- The writer focuses on *construction*, not execution.
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
- ### Writer basics
373
+ Benefits include:
347
374
 
348
- Writer helpers are intentionally limited:
375
+ * Runtime safety
376
+ * Explicit schema documentation
377
+ * Refactoring confidence
378
+ * AI-friendly feedback loops
349
379
 
350
- * `undefined` values are omitted
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
- If `returning` is provided, a `RETURNING` clause is appended.
356
- Using `'all'` maps to `RETURNING *`; otherwise, column names are sorted alphabetically.
382
+ ### Scalar Queries
357
383
 
358
- The writer never checks backend support for `RETURNING`.
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
- ### INSERT
386
+ #### Basic scalar usage
362
387
 
363
388
  ```ts
364
- await writer.insert(
365
- 'projects',
366
- { name: 'Apollo', owner_id: 7 },
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
- ### UPDATE
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 writer.update(
375
- 'projects',
376
- { name: 'Apollo' },
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
- ### DELETE
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
- await writer.remove(
385
- 'projects',
386
- { project_id: 1 },
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
- Statements can also be built without execution:
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
- const built = writer.build.insert(
394
- 'projects',
395
- { name: 'Apollo', owner_id: 7 },
396
- { returning: ['project_id'] },
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
- ### Writer presets and placeholder strategies
467
+ This provides:
401
468
 
402
- Advanced usage flows through `createWriter` (aliasing the historic `createWriterFromExecutor`), which binds an executor to a concrete placeholder strategy.
469
+ * Runtime safety
470
+ * Clear schema documentation
471
+ * Refactoring confidence
403
472
 
404
- A writer preset defines:
473
+ ---
405
474
 
406
- 1. how placeholders are formatted,
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
- import { createWriter, writerPresets } from '@rawsql-ts/sql-contract/writer'
412
-
413
- const writer = createWriter(
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
- ### Named placeholders
484
+ ---
485
+
486
+ #### Coercion helpers for numeric values
422
487
 
423
- Named presets derive parameter names from column names.
488
+ Some database drivers return numeric values as strings.
489
+ The package provides helpers to safely convert them.
424
490
 
425
- Each bind increments a counter and produces deterministic names such as
426
- `name_1`, `owner_id_2`.
491
+ Example:
427
492
 
428
493
  ```ts
429
- await writer.insert('projects', { name: 'Apollo', owner_id: 7 })
430
- // SQL: INSERT INTO projects (name, owner_id) VALUES (:name_1, :owner_id_2)
431
- // params: { name_1: 'Apollo', owner_id_2: 7 }
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
- ## DBMS and driver differences
514
+ #### When to use coercion
437
515
 
438
- `sql-contract` does not normalize SQL dialects or placeholder styles.
516
+ Use coercion helpers when:
439
517
 
440
- Write SQL using the placeholder syntax required by your driver, and bind parameters exactly as that driver expects.
441
- Whether parameters are positional or named is a concern of the executor and driver, not `sql-contract`.
518
+ * Working with NUMERIC / DECIMAL columns
519
+ * Drivers return BIGINT as strings
520
+ * You want runtime guarantees
442
521
 
443
- Examples of common placeholder styles:
522
+ ---
523
+
524
+ ### Writer: Simple CUD Helpers
444
525
 
445
- | DBMS / driver | Placeholder style |
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 executor(
455
- 'select * from customers where customer_id = $1',
456
- [42],
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
- await executor(
462
- 'select * from customers where customer_id = :customerId',
463
- { customerId: 42 },
546
+ const stmt = writer.build.insert(
547
+ 'projects',
548
+ { name: 'Apollo' }
464
549
  )
465
550
  ```
466
551
 
467
- All DBMS- and driver-specific concerns live in the executor.
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
- ## Influences / Related Ideas
556
+ * `decimalStringToNumberUnsafe`
557
+ * `bigintStringToBigInt`
558
+
559
+ They convert raw DB output strings into numbers or bigints when needed.
473
560
 
474
- Sql-contract is inspired by minimal mapping libraries such as Dapper and other thin contracts that keep SQL visible while wiring rows to typed results. These projects demonstrate the value of stopping short of a full ORM and instead providing a predictable, testable layer for purely mechanical concerns.
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
- Sql-contract adopts that lesson within the rawsql-ts ecosystem: SQL remains the domain language, and this package automates only the tedious bridging work around it.
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.