sqlstack 1.0.18 → 1.0.20
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/adapters/sqlite.d.ts.map +1 -1
- package/dist/cjs/adapters/sqlite.js +91 -7
- package/dist/cjs/adapters/sqlite.js.map +1 -1
- package/dist/cjs/binders/sqlBinder.d.ts.map +1 -1
- package/dist/cjs/binders/sqlBinder.js +205 -0
- package/dist/cjs/binders/sqlBinder.js.map +1 -1
- package/dist/esm/adapters/sqlite.js +91 -7
- package/dist/esm/adapters/sqlite.js.map +1 -1
- package/dist/esm/binders/sqlBinder.js +205 -0
- package/dist/esm/binders/sqlBinder.js.map +1 -1
- package/package.json +1 -1
- package/readme.md +328 -360
package/readme.md
CHANGED
|
@@ -2,19 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
**SQL-first data access for Node.js and TypeScript.**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Write real SQL next to your code, and use small, composable decorators to bind, execute, and shape results—without ORM complexity. If you already like dropping down to “raw queries”, this is for you.
|
|
6
|
+
|
|
7
|
+
## Table of Contents
|
|
8
|
+
|
|
9
|
+
- [Install](#install)
|
|
10
|
+
- [Quick Start (5 minutes)](#quick-start-5-minutes)
|
|
11
|
+
- [Writing and Extending SQL](#writing-and-extending-sql)
|
|
12
|
+
- [Configuration](#configuration)
|
|
13
|
+
- [SQL Parameters](#sql-parameters)
|
|
14
|
+
- [How It Works](#how-it-works)
|
|
15
|
+
- [Result Types](#result-types)
|
|
16
|
+
- [Error Handling](#error-handling)
|
|
17
|
+
- [Inline SQL](#inline-sql)
|
|
18
|
+
- [Advanced: Direct Database Access](#advanced-direct-database-access)
|
|
19
|
+
- [Database Support](#database-support)
|
|
20
|
+
- [Philosophy](#philosophy)
|
|
21
|
+
- [Contributing](#contributing)
|
|
22
|
+
- [License](#license)
|
|
6
23
|
|
|
7
|
-
##
|
|
24
|
+
## Install
|
|
8
25
|
|
|
9
|
-
|
|
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.
|
|
26
|
+
### Prerequisites
|
|
16
27
|
|
|
17
|
-
|
|
28
|
+
- Node.js 18+ (or a modern LTS runtime).
|
|
29
|
+
- Works with both TypeScript and JavaScript; examples use TypeScript syntax.
|
|
18
30
|
|
|
19
31
|
```bash
|
|
20
32
|
npm install sqlstack
|
|
@@ -34,6 +46,14 @@ npm install better-sqlite3 # SQLite
|
|
|
34
46
|
|
|
35
47
|
## Quick Start (5 minutes)
|
|
36
48
|
|
|
49
|
+
At a high level, you:
|
|
50
|
+
|
|
51
|
+
- Register one or more database connections with `SqlStackDB`.
|
|
52
|
+
- Write normal classes with methods, decorate them with `@QueryBinder` and `@Query`.
|
|
53
|
+
- Put SQL in `.sql` files next to those classes.
|
|
54
|
+
|
|
55
|
+
When you call a decorated method, sqlstack loads the SQL, binds parameters from your method arguments, executes it via the selected adapter, then optionally shapes and validates the results via decorators.
|
|
56
|
+
|
|
37
57
|
### 1. Register Your Database
|
|
38
58
|
|
|
39
59
|
Create a `boot.ts` (or similar) file at your app's entry point:
|
|
@@ -53,13 +73,15 @@ Load this file once during app startup.
|
|
|
53
73
|
### 2. Create a Repository Class
|
|
54
74
|
|
|
55
75
|
```ts
|
|
56
|
-
// users/index.ts
|
|
76
|
+
// src/users/index.ts
|
|
57
77
|
import { QueryBinder, Query, SqlStackError } from "sqlstack";
|
|
58
78
|
|
|
59
79
|
@QueryBinder() // looks for .sql files in the same folder by default
|
|
60
|
-
export class
|
|
80
|
+
export class UsersRepository {
|
|
61
81
|
@Query()
|
|
62
|
-
async findByEmail(
|
|
82
|
+
async findByEmail(params: { email: string }): Promise<User[]> {
|
|
83
|
+
// Implementation bodies are never called; this method exists
|
|
84
|
+
// only to define the TypeScript signature for @Query.
|
|
63
85
|
throw new SqlStackError("replaced by @Query");
|
|
64
86
|
}
|
|
65
87
|
}
|
|
@@ -85,36 +107,220 @@ WHERE email = :email;
|
|
|
85
107
|
### 4. Use Your Repository
|
|
86
108
|
|
|
87
109
|
```ts
|
|
88
|
-
const repo = new
|
|
110
|
+
const repo = new UsersRepository();
|
|
89
111
|
const users = await repo.findByEmail({ email: "alice@example.com" });
|
|
90
112
|
console.log(users); // User[]
|
|
91
113
|
```
|
|
92
114
|
|
|
93
115
|
That's it! The `@Query` decorator intercepts the method, loads and executes the SQL, and returns results.
|
|
94
116
|
|
|
95
|
-
|
|
117
|
+
You can then add more methods on the same repository that use sqlstack’s SQL helpers:
|
|
96
118
|
|
|
97
|
-
|
|
119
|
+
```ts
|
|
120
|
+
@QueryBinder()
|
|
121
|
+
export class UsersRepository {
|
|
122
|
+
@Query()
|
|
123
|
+
async findByEmail(params: { email: string }): Promise<User[]> {
|
|
124
|
+
throw new SqlStackError("replaced by @Query");
|
|
125
|
+
}
|
|
98
126
|
|
|
99
|
-
|
|
127
|
+
@Query()
|
|
128
|
+
async updateProfile(params: { id: string; name?: string; status?: string }): Promise<WriteResult> {
|
|
129
|
+
throw new SqlStackError("replaced by @Query");
|
|
130
|
+
}
|
|
100
131
|
|
|
101
|
-
|
|
132
|
+
@Query()
|
|
133
|
+
async createUser(params: { id: string; email: string; name?: string }): Promise<WriteResult> {
|
|
134
|
+
throw new SqlStackError("replaced by @Query");
|
|
135
|
+
}
|
|
102
136
|
|
|
137
|
+
@Query()
|
|
138
|
+
async search(filters: { name?: string; status?: string }): Promise<User[]> {
|
|
139
|
+
throw new SqlStackError("replaced by @Query");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
103
142
|
```
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
143
|
+
|
|
144
|
+
**users/updateProfile.sql** — partial update with `:update`:
|
|
145
|
+
|
|
146
|
+
```sql
|
|
147
|
+
UPDATE users
|
|
148
|
+
:update(name, status, updated_at = now())
|
|
149
|
+
WHERE id = :id;
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
**users/createUser.sql** — partial insert with `:insert`:
|
|
153
|
+
|
|
154
|
+
```sql
|
|
155
|
+
INSERT INTO users :insert(id, email, name, status = 'active');
|
|
115
156
|
```
|
|
116
157
|
|
|
117
|
-
|
|
158
|
+
**users/search.sql** — dynamic filters with `:filter`:
|
|
159
|
+
|
|
160
|
+
```sql
|
|
161
|
+
SELECT id, email, name, status
|
|
162
|
+
FROM users
|
|
163
|
+
WHERE :filter(name, status);
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
As your needs grow, you evolve the SQL in these files (and use helpers like `:update`, `:insert`, and `:filter`) while keeping the TypeScript method interfaces—and all call sites—unchanged.
|
|
167
|
+
|
|
168
|
+
### 5. Go Further with SQL Helpers
|
|
169
|
+
|
|
170
|
+
Once you’re comfortable with basic `.sql` files, you can start using:
|
|
171
|
+
|
|
172
|
+
- `:insert(...)` to build column lists and `VALUES` from your named parameters.
|
|
173
|
+
- `:update(...)` to generate `SET` clauses for partial updates.
|
|
174
|
+
- `:filter(...)` to build dynamic `WHERE` / `HAVING` conditions from whichever filters are defined.
|
|
175
|
+
|
|
176
|
+
These helpers expand into plain SQL; the full behavior and examples are covered in the **Writing and Extending SQL** section below.
|
|
177
|
+
|
|
178
|
+
## Writing and Extending SQL
|
|
179
|
+
|
|
180
|
+
sqlstack is **SQL-first**: you write normal SQL, and sqlstack gives you a few helpers to make it easier to evolve queries over time without changing your method interface.
|
|
181
|
+
|
|
182
|
+
### Plain SQL in Files
|
|
183
|
+
|
|
184
|
+
Start with regular SQL in `.sql` files:
|
|
185
|
+
|
|
186
|
+
```sql
|
|
187
|
+
-- users/findByEmail.sql
|
|
188
|
+
SELECT id, email, name, status
|
|
189
|
+
FROM users
|
|
190
|
+
WHERE email = :email;
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Later, you can refactor this query (add joins, filters, or switch to a CTE) without changing the TypeScript method that calls it, as long as the returned columns still match your declared return type.
|
|
194
|
+
|
|
195
|
+
### Extending SQL with Helpers
|
|
196
|
+
|
|
197
|
+
sqlstack adds a small set of macros that expand into standard SQL, so you keep full control. They are designed to feel like “obvious SQL you would have written by hand.”
|
|
198
|
+
|
|
199
|
+
#### `:insert(...)`
|
|
200
|
+
|
|
201
|
+
```sql
|
|
202
|
+
-- posts/createPost.sql
|
|
203
|
+
INSERT INTO posts :insert(id, author_id, title, body, status = 'published');
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
await repo.createPost({
|
|
208
|
+
id: 'post_1',
|
|
209
|
+
author_id: 'user_1',
|
|
210
|
+
title: 'Hello world',
|
|
211
|
+
body: 'First post!',
|
|
212
|
+
// status: undefined → defaults to 'published' from SQL literal
|
|
213
|
+
});
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
```sql
|
|
217
|
+
-- expands to
|
|
218
|
+
INSERT INTO posts (id, author_id, title, body, status)
|
|
219
|
+
VALUES (?, ?, ?, ?, 'published');
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
**Additional behaviors and rules**
|
|
223
|
+
|
|
224
|
+
- Columns whose values are `undefined` are skipped; at least one non-literal column must be defined or a literal must be present.
|
|
225
|
+
- Pure literal expressions (e.g. `created_at = now()`) are allowed and included even with no params.
|
|
226
|
+
- `null` values are allowed and are bound as `NULL`.
|
|
227
|
+
- Column order in the generated `INSERT` follows the order listed in `:insert(...)`, regardless of object key order.
|
|
228
|
+
- `:insert()` only works with **named parameters**; using positional args throws.
|
|
229
|
+
|
|
230
|
+
#### `:batch_insert(...)`
|
|
231
|
+
|
|
232
|
+
Use `:batch_insert(...)` to build an efficient multi-row `VALUES` clause from one or more array arguments (plus optional scalars):
|
|
233
|
+
|
|
234
|
+
```sql
|
|
235
|
+
-- comments/createComments.sql
|
|
236
|
+
INSERT INTO comments (post_id, author_id, body)
|
|
237
|
+
:batch_insert(post_id, comment.author_id, comment.body);
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
await repo.createComments({
|
|
242
|
+
post_id: 'post_1',
|
|
243
|
+
comment: [
|
|
244
|
+
{ author_id: 'user_1', body: 'Nice post!' },
|
|
245
|
+
{ author_id: 'user_2', body: 'Subscribed.' },
|
|
246
|
+
],
|
|
247
|
+
});
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
```sql
|
|
251
|
+
-- expands (MySQL/SQLite)
|
|
252
|
+
INSERT INTO comments (post_id, author_id, body)
|
|
253
|
+
VALUES (?, ?, ?), (?, ?, ?);
|
|
254
|
+
|
|
255
|
+
-- expands (Postgres)
|
|
256
|
+
INSERT INTO comments (post_id, author_id, body)
|
|
257
|
+
VALUES ($1, $2, $3), ($4, $5, $6);
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
- At least one argument must resolve to an array; all array arguments must have the same length and be non-empty.
|
|
261
|
+
- Arguments can be scalars (reused for every row) or dot-paths into nested objects/arrays (e.g. `comment.body`, `root.ids`).
|
|
262
|
+
- `:batch_insert()` only works with **named parameters**, not positional ones.
|
|
263
|
+
|
|
264
|
+
#### `:update(...)`
|
|
265
|
+
|
|
266
|
+
```sql
|
|
267
|
+
-- posts/updatePost.sql
|
|
268
|
+
UPDATE posts
|
|
269
|
+
:update(updated_at = now(), title, body)
|
|
270
|
+
WHERE id = :id;
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
await repo.updatePost({
|
|
275
|
+
id: 'post_1',
|
|
276
|
+
title: 'Updated title',
|
|
277
|
+
// body: undefined → not updated
|
|
278
|
+
});
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
```sql
|
|
282
|
+
-- expands to
|
|
283
|
+
UPDATE posts
|
|
284
|
+
SET updated_at = now(), title = ?
|
|
285
|
+
WHERE id = ?;
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
#### `:filter(...)`
|
|
289
|
+
|
|
290
|
+
```sql
|
|
291
|
+
-- users/search.sql
|
|
292
|
+
SELECT id, email, name, status
|
|
293
|
+
FROM users
|
|
294
|
+
WHERE :filter(
|
|
295
|
+
name, -- name = ?
|
|
296
|
+
age >= :min_age, -- age >= ?
|
|
297
|
+
status = 'active', -- literal
|
|
298
|
+
name LIKE :search, -- name LIKE ?
|
|
299
|
+
id IN (:ids) -- id IN (?, ?, ?)
|
|
300
|
+
);
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
```ts
|
|
304
|
+
await repo.search({
|
|
305
|
+
name: "Alice",
|
|
306
|
+
min_age: 18,
|
|
307
|
+
search: "%ali%",
|
|
308
|
+
ids: [1, 2, 3],
|
|
309
|
+
});
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
```sql
|
|
313
|
+
-- expands to
|
|
314
|
+
SELECT id, email, name, status
|
|
315
|
+
FROM users
|
|
316
|
+
WHERE name = ?
|
|
317
|
+
AND age >= ?
|
|
318
|
+
AND status = 'active'
|
|
319
|
+
AND name LIKE ?
|
|
320
|
+
AND id IN (?, ?, ?);
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
You can gradually adopt these helpers as your queries become more dynamic; they never hide the SQL that is ultimately sent to the database, they just generate the repetitive parts.
|
|
118
324
|
|
|
119
325
|
### Validation Controls
|
|
120
326
|
|
|
@@ -126,6 +332,35 @@ Validation (via `@ValidateResult`) is **enabled by default**. Control it with en
|
|
|
126
332
|
|
|
127
333
|
---
|
|
128
334
|
|
|
335
|
+
## How It Works
|
|
336
|
+
|
|
337
|
+
### Execution Pipeline
|
|
338
|
+
|
|
339
|
+
When you invoke a decorated method, sqlstack:
|
|
340
|
+
|
|
341
|
+
- Merges any default values into your arguments.
|
|
342
|
+
- Loads SQL (from a file or inline), binds placeholders, and executes it via the chosen adapter.
|
|
343
|
+
- If the adapter returned rows (a SELECT/WITH), it shapes and optionally validates those rows.
|
|
344
|
+
- If the adapter returned a write result (INSERT/UPDATE/DELETE/DDL), it returns that result object as-is.
|
|
345
|
+
|
|
346
|
+
Decorators run in a **fixed, internal order** regardless of how you stack them:
|
|
347
|
+
|
|
348
|
+
```
|
|
349
|
+
1. @Defaults (fill in missing parameter values)
|
|
350
|
+
↓
|
|
351
|
+
2. @Query (execute the SQL)
|
|
352
|
+
↓
|
|
353
|
+
3. @Single/@Only/@Exist/@None (shape the results)
|
|
354
|
+
↓
|
|
355
|
+
4. @ValidateResult (validate against JSON Schema)
|
|
356
|
+
↓
|
|
357
|
+
5. @Transform (map flat rows to nested objects)
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
You can list decorators in any order; they always execute in this pipeline. See docs/decorators.md for details.
|
|
361
|
+
|
|
362
|
+
---
|
|
363
|
+
|
|
129
364
|
## Configuration
|
|
130
365
|
|
|
131
366
|
### Register Databases
|
|
@@ -262,321 +497,6 @@ await repo.listByIds({ ids });
|
|
|
262
497
|
- `null` → SQL `NULL`
|
|
263
498
|
- `undefined` → throws an error; always provide a value or omit the parameter
|
|
264
499
|
|
|
265
|
-
### :update() Syntax for Partial Updates
|
|
266
|
-
|
|
267
|
-
The `:update()` syntax simplifies UPDATE statements by automatically generating SET clauses. It supports both parameterized columns and literal SQL expressions, making partial updates easy without needing `COALESCE` or `IFNULL`.
|
|
268
|
-
|
|
269
|
-
**Key Features:**
|
|
270
|
-
- Automatically generates `SET` keyword (don't write it yourself)
|
|
271
|
-
- Literal expressions: `col = datetime('now')` → always included
|
|
272
|
-
- Parameterized columns: `col` → only included if defined in args
|
|
273
|
-
- Mixed usage: combine literals and parameterized columns
|
|
274
|
-
|
|
275
|
-
**Basic Usage:**
|
|
276
|
-
|
|
277
|
-
```sql
|
|
278
|
-
-- updateInvoice.sql
|
|
279
|
-
UPDATE invoices
|
|
280
|
-
:update(invoice_date, due_date, subtotal_cents)
|
|
281
|
-
WHERE id = :id
|
|
282
|
-
```
|
|
283
|
-
|
|
284
|
-
```ts
|
|
285
|
-
await repo.updateInvoice({
|
|
286
|
-
invoice_date: '2024-01-01',
|
|
287
|
-
id: 1
|
|
288
|
-
});
|
|
289
|
-
// Generates: UPDATE invoices SET invoice_date = ? WHERE id = ?
|
|
290
|
-
// due_date and subtotal_cents are skipped (undefined)
|
|
291
|
-
```
|
|
292
|
-
|
|
293
|
-
**Literal SQL Expressions:**
|
|
294
|
-
|
|
295
|
-
Use `=` to include literal SQL expressions that are always included:
|
|
296
|
-
|
|
297
|
-
```sql
|
|
298
|
-
UPDATE invoices
|
|
299
|
-
:update(updated_at = datetime('now'), invoice_date, due_date)
|
|
300
|
-
WHERE id = :id
|
|
301
|
-
```
|
|
302
|
-
|
|
303
|
-
```ts
|
|
304
|
-
await repo.updateInvoice({
|
|
305
|
-
invoice_date: '2024-01-01',
|
|
306
|
-
id: 1
|
|
307
|
-
});
|
|
308
|
-
// Generates: UPDATE invoices SET updated_at = datetime('now'), invoice_date = ? WHERE id = ?
|
|
309
|
-
```
|
|
310
|
-
|
|
311
|
-
**Mixed Literal and Parameterized:**
|
|
312
|
-
|
|
313
|
-
```sql
|
|
314
|
-
UPDATE invoices
|
|
315
|
-
:update(
|
|
316
|
-
updated_at = datetime('now'),
|
|
317
|
-
invoice_date,
|
|
318
|
-
due_date,
|
|
319
|
-
status = 'active'
|
|
320
|
-
)
|
|
321
|
-
WHERE id = :id
|
|
322
|
-
```
|
|
323
|
-
|
|
324
|
-
```ts
|
|
325
|
-
await repo.updateInvoice({
|
|
326
|
-
invoice_date: '2024-01-01',
|
|
327
|
-
due_date: '2024-01-15',
|
|
328
|
-
id: 1
|
|
329
|
-
});
|
|
330
|
-
// Generates: UPDATE invoices SET updated_at = datetime('now'), invoice_date = ?, due_date = ?, status = 'active' WHERE id = ?
|
|
331
|
-
```
|
|
332
|
-
|
|
333
|
-
**Notes:**
|
|
334
|
-
- `:update()` only works with named parameters (not positional)
|
|
335
|
-
- Literal expressions are always included
|
|
336
|
-
- Parameterized columns are only included if defined and not `undefined`
|
|
337
|
-
- If all parameterized columns are undefined (and no literals), throws an error
|
|
338
|
-
- Case-insensitive: `:update`, `:UPDATE`, `:Update` all work
|
|
339
|
-
|
|
340
|
-
### :insert() Syntax for INSERT Statements
|
|
341
|
-
|
|
342
|
-
The `:insert()` syntax simplifies INSERT statements by automatically generating both the column list and VALUES clause. It supports both parameterized columns and literal SQL expressions, making INSERT statements much more concise.
|
|
343
|
-
|
|
344
|
-
**Key Features:**
|
|
345
|
-
- Automatically generates column list `(col1, col2, ...)` and VALUES clause `VALUES (?, ?, ...)`
|
|
346
|
-
- Literal expressions: `col = datetime('now')` → column name and literal value included
|
|
347
|
-
- Parameterized columns: `col` → only included if defined in args
|
|
348
|
-
- Mixed usage: combine literals and parameterized columns
|
|
349
|
-
|
|
350
|
-
**Basic Usage:**
|
|
351
|
-
|
|
352
|
-
```sql
|
|
353
|
-
-- createInvoice.sql
|
|
354
|
-
INSERT INTO invoices :insert(case_id, invoice_number, invoice_date, due_date)
|
|
355
|
-
```
|
|
356
|
-
|
|
357
|
-
```ts
|
|
358
|
-
await repo.createInvoice({
|
|
359
|
-
case_id: 1,
|
|
360
|
-
invoice_number: 'INV-001',
|
|
361
|
-
invoice_date: '2024-01-01',
|
|
362
|
-
due_date: '2024-01-15'
|
|
363
|
-
});
|
|
364
|
-
// Generates: INSERT INTO invoices (case_id, invoice_number, invoice_date, due_date) VALUES (?, ?, ?, ?)
|
|
365
|
-
// Params: [1, 'INV-001', '2024-01-01', '2024-01-15']
|
|
366
|
-
```
|
|
367
|
-
|
|
368
|
-
**With Undefined Columns:**
|
|
369
|
-
|
|
370
|
-
```sql
|
|
371
|
-
INSERT INTO invoices :insert(case_id, invoice_number, invoice_date, due_date)
|
|
372
|
-
```
|
|
373
|
-
|
|
374
|
-
```ts
|
|
375
|
-
await repo.createInvoice({
|
|
376
|
-
case_id: 1,
|
|
377
|
-
invoice_number: 'INV-001'
|
|
378
|
-
});
|
|
379
|
-
// Generates: INSERT INTO invoices (case_id, invoice_number) VALUES (?, ?)
|
|
380
|
-
// invoice_date and due_date are skipped (undefined)
|
|
381
|
-
```
|
|
382
|
-
|
|
383
|
-
**Literal SQL Expressions:**
|
|
384
|
-
|
|
385
|
-
Use `=` to include literal SQL expressions that are always included:
|
|
386
|
-
|
|
387
|
-
```sql
|
|
388
|
-
INSERT INTO invoices :insert(created_at = datetime('now'), case_id, invoice_number)
|
|
389
|
-
```
|
|
390
|
-
|
|
391
|
-
```ts
|
|
392
|
-
await repo.createInvoice({
|
|
393
|
-
case_id: 1,
|
|
394
|
-
invoice_number: 'INV-001'
|
|
395
|
-
});
|
|
396
|
-
// Generates: INSERT INTO invoices (created_at, case_id, invoice_number) VALUES (datetime('now'), ?, ?)
|
|
397
|
-
// Params: [1, 'INV-001']
|
|
398
|
-
```
|
|
399
|
-
|
|
400
|
-
**Mixed Literal and Parameterized:**
|
|
401
|
-
|
|
402
|
-
```sql
|
|
403
|
-
INSERT INTO invoices :insert(
|
|
404
|
-
created_at = datetime('now'),
|
|
405
|
-
case_id,
|
|
406
|
-
invoice_number,
|
|
407
|
-
status = 'draft'
|
|
408
|
-
)
|
|
409
|
-
```
|
|
410
|
-
|
|
411
|
-
```ts
|
|
412
|
-
await repo.createInvoice({
|
|
413
|
-
case_id: 1,
|
|
414
|
-
invoice_number: 'INV-001'
|
|
415
|
-
});
|
|
416
|
-
// Generates: INSERT INTO invoices (created_at, case_id, invoice_number, status) VALUES (datetime('now'), ?, ?, 'draft')
|
|
417
|
-
// Params: [1, 'INV-001']
|
|
418
|
-
```
|
|
419
|
-
|
|
420
|
-
**Notes:**
|
|
421
|
-
- `:insert()` only works with named parameters (not positional)
|
|
422
|
-
- Literal expressions are always included (both column name and value)
|
|
423
|
-
- Parameterized columns are only included if defined and not `undefined`
|
|
424
|
-
- If all parameterized columns are undefined (and no literals), throws an error
|
|
425
|
-
- Case-insensitive: `:insert`, `:INSERT`, `:Insert` all work
|
|
426
|
-
|
|
427
|
-
### :filter() Syntax for WHERE/HAVING Clauses
|
|
428
|
-
|
|
429
|
-
The `:filter()` syntax simplifies WHERE and HAVING clauses by automatically generating conditions. It supports simple column names (equality), literal comparisons, and parameterized comparisons, making dynamic filtering much easier.
|
|
430
|
-
|
|
431
|
-
**Key Features:**
|
|
432
|
-
- Simple column names → equality: `name` → `name = ?`
|
|
433
|
-
- Literal comparisons → included as-is: `age > 30` → `age > 30`
|
|
434
|
-
- Parameterized comparisons → bind parameters: `age > :min_age` → `age > ?`
|
|
435
|
-
- Mixed usage: combine all three types
|
|
436
|
-
- Works in both WHERE and HAVING clauses
|
|
437
|
-
- Conditions are joined with `AND`
|
|
438
|
-
|
|
439
|
-
**Basic Usage:**
|
|
440
|
-
|
|
441
|
-
```sql
|
|
442
|
-
-- findUsers.sql
|
|
443
|
-
SELECT * FROM users WHERE :filter(name, age)
|
|
444
|
-
```
|
|
445
|
-
|
|
446
|
-
```ts
|
|
447
|
-
await repo.findUsers({ name: 'John', age: 30 });
|
|
448
|
-
// Generates: SELECT * FROM users WHERE name = ? AND age = ?
|
|
449
|
-
// Params: ['John', 30]
|
|
450
|
-
```
|
|
451
|
-
|
|
452
|
-
**With Some Undefined Columns:**
|
|
453
|
-
|
|
454
|
-
```sql
|
|
455
|
-
SELECT * FROM users WHERE :filter(name, age, status)
|
|
456
|
-
```
|
|
457
|
-
|
|
458
|
-
```ts
|
|
459
|
-
await repo.findUsers({ name: 'John' });
|
|
460
|
-
// Generates: SELECT * FROM users WHERE name = ?
|
|
461
|
-
// age and status are skipped (undefined)
|
|
462
|
-
```
|
|
463
|
-
|
|
464
|
-
**Literal Comparisons:**
|
|
465
|
-
|
|
466
|
-
```sql
|
|
467
|
-
SELECT * FROM users WHERE :filter(age > 30, status = 'active')
|
|
468
|
-
```
|
|
469
|
-
|
|
470
|
-
```ts
|
|
471
|
-
await repo.findUsers({});
|
|
472
|
-
// Generates: SELECT * FROM users WHERE age > 30 AND status = 'active'
|
|
473
|
-
// Params: []
|
|
474
|
-
```
|
|
475
|
-
|
|
476
|
-
**Parameterized Comparisons:**
|
|
477
|
-
|
|
478
|
-
```sql
|
|
479
|
-
SELECT * FROM users WHERE :filter(age > :min_age, name LIKE :search)
|
|
480
|
-
```
|
|
481
|
-
|
|
482
|
-
```ts
|
|
483
|
-
await repo.findUsers({ min_age: 30, search: '%John%' });
|
|
484
|
-
// Generates: SELECT * FROM users WHERE age > ? AND name LIKE ?
|
|
485
|
-
// Params: [30, '%John%']
|
|
486
|
-
```
|
|
487
|
-
|
|
488
|
-
**Mixed Usage:**
|
|
489
|
-
|
|
490
|
-
```sql
|
|
491
|
-
SELECT * FROM users WHERE :filter(name, age > :min_age, status = 'active')
|
|
492
|
-
```
|
|
493
|
-
|
|
494
|
-
```ts
|
|
495
|
-
await repo.findUsers({ name: 'John', min_age: 30 });
|
|
496
|
-
// Generates: SELECT * FROM users WHERE name = ? AND age > ? AND status = 'active'
|
|
497
|
-
// Params: ['John', 30]
|
|
498
|
-
```
|
|
499
|
-
|
|
500
|
-
**With Array Parameters (IN clause):**
|
|
501
|
-
|
|
502
|
-
```sql
|
|
503
|
-
SELECT * FROM users WHERE :filter(id IN (:ids))
|
|
504
|
-
```
|
|
505
|
-
|
|
506
|
-
```ts
|
|
507
|
-
await repo.findUsers({ ids: [1, 2, 3] });
|
|
508
|
-
// Generates: SELECT * FROM users WHERE id IN (?, ?, ?)
|
|
509
|
-
// Params: [1, 2, 3]
|
|
510
|
-
```
|
|
511
|
-
|
|
512
|
-
**With :or() Expansion:**
|
|
513
|
-
|
|
514
|
-
The `:or()` syntax expands array parameters into multiple OR conditions:
|
|
515
|
-
|
|
516
|
-
```sql
|
|
517
|
-
SELECT * FROM users WHERE :filter(name = :or(:names), age > :min_age)
|
|
518
|
-
```
|
|
519
|
-
|
|
520
|
-
```ts
|
|
521
|
-
await repo.findUsers({ names: ['John', 'Jane', 'Bob'], min_age: 30 });
|
|
522
|
-
// Generates: SELECT * FROM users WHERE (name = ? OR name = ? OR name = ?) AND age > ?
|
|
523
|
-
// Params: ['John', 'Jane', 'Bob', 30]
|
|
524
|
-
```
|
|
525
|
-
|
|
526
|
-
**Complex :or() Usage:**
|
|
527
|
-
|
|
528
|
-
```sql
|
|
529
|
-
SELECT * FROM users WHERE :filter(
|
|
530
|
-
(name = :or(:names)),
|
|
531
|
-
age > :min_age,
|
|
532
|
-
status = 'active',
|
|
533
|
-
(department = :or(:depts) OR role = :or(:roles))
|
|
534
|
-
)
|
|
535
|
-
```
|
|
536
|
-
|
|
537
|
-
```ts
|
|
538
|
-
await repo.findUsers({
|
|
539
|
-
names: ['John', 'Jane'],
|
|
540
|
-
min_age: 25,
|
|
541
|
-
depts: ['Engineering'],
|
|
542
|
-
roles: ['Manager']
|
|
543
|
-
});
|
|
544
|
-
// Generates: SELECT * FROM users WHERE (name = ? OR name = ?) AND age > ? AND status = 'active' AND (department = ? OR role = ?)
|
|
545
|
-
// Params: ['John', 'Jane', 25, 'Engineering', 'Manager']
|
|
546
|
-
```
|
|
547
|
-
|
|
548
|
-
**HAVING Clause:**
|
|
549
|
-
|
|
550
|
-
```sql
|
|
551
|
-
SELECT department, COUNT(*) as count
|
|
552
|
-
FROM employees
|
|
553
|
-
GROUP BY department
|
|
554
|
-
HAVING :filter(count > :min_count, department = 'Sales')
|
|
555
|
-
```
|
|
556
|
-
|
|
557
|
-
```ts
|
|
558
|
-
await repo.findDepartments({ min_count: 10 });
|
|
559
|
-
// Generates: SELECT department, COUNT(*) as count FROM employees GROUP BY department HAVING count > ? AND department = 'Sales'
|
|
560
|
-
// Params: [10]
|
|
561
|
-
```
|
|
562
|
-
|
|
563
|
-
**Supported Operators:**
|
|
564
|
-
|
|
565
|
-
- Comparison: `=`, `!=`, `<>`, `>`, `<`, `>=`, `<=`
|
|
566
|
-
- Pattern matching: `LIKE`
|
|
567
|
-
- Membership: `IN`
|
|
568
|
-
- Range: `BETWEEN`
|
|
569
|
-
- Null checks: `IS NULL`, `IS NOT NULL`
|
|
570
|
-
|
|
571
|
-
**Notes:**
|
|
572
|
-
- `:filter()` only works with named parameters (not positional)
|
|
573
|
-
- Literal expressions are always included
|
|
574
|
-
- Parameterized conditions are only included if the parameter is defined and not `undefined`
|
|
575
|
-
- If all parameterized conditions are undefined (and no literals), throws an error
|
|
576
|
-
- Case-insensitive: `:filter`, `:FILTER`, `:Filter` all work
|
|
577
|
-
- `:or()` expands arrays into multiple OR conditions
|
|
578
|
-
- Operators inside string literals are not treated as operators (e.g., `name = 'value > test'`)
|
|
579
|
-
|
|
580
500
|
## SQL File Resolution
|
|
581
501
|
|
|
582
502
|
The `@Query()` decorator locates `.sql` files using a simple, predictable rule. See [docs/file_resolution.md](docs/file_resolution.md) for complete details.
|
|
@@ -665,7 +585,7 @@ import { QueryBinder, Page, Query, SqlStackError } from "sqlstack";
|
|
|
665
585
|
class UsersRepo {
|
|
666
586
|
@Page(10) // limit = 10, pages 1-based
|
|
667
587
|
@Query()
|
|
668
|
-
async listUsers(
|
|
588
|
+
async listUsers(params: { status?: string; page?: number }): Promise<User[]> {
|
|
669
589
|
throw new SqlStackError();
|
|
670
590
|
}
|
|
671
591
|
}
|
|
@@ -688,10 +608,10 @@ await repo.listUsers({ status: "active", page: 2 }); // page 2 of 10 per page
|
|
|
688
608
|
|
|
689
609
|
**Zero-based pages:** Use `@Page(10, { zeroBase: true })`.
|
|
690
610
|
|
|
691
|
-
**Trailing options:**
|
|
611
|
+
**Trailing options (multiple parameters):** When your method has more than one parameter, pass `{ page: N }` as the last argument:
|
|
692
612
|
```ts
|
|
693
|
-
await repo.list({ user_id: 1 }, { page: 2 });
|
|
694
|
-
await repo.list(userId, { page: 2 });
|
|
613
|
+
await repo.list({ user_id: 1 }, { page: 2 }); // named-style params + trailing options
|
|
614
|
+
await repo.list(userId, { page: 2 }); // positional id + trailing options
|
|
695
615
|
```
|
|
696
616
|
|
|
697
617
|
### Result Shape Decorators
|
|
@@ -773,11 +693,9 @@ Example schema:
|
|
|
773
693
|
}
|
|
774
694
|
```
|
|
775
695
|
|
|
776
|
-
Validation is **enabled by default**; control with `SQLSTACK_ENABLE_VALIDATION` and `SQLSTACK_DISABLE_VALIDATION` environment variables.
|
|
777
|
-
|
|
778
696
|
### `@Transform`
|
|
779
697
|
|
|
780
|
-
|
|
698
|
+
Use `DTO()` with `@Transform` to turn flat joined rows into nested objects, automatically deduplicating parents and children.
|
|
781
699
|
|
|
782
700
|
```ts
|
|
783
701
|
import { DTO, Transform, Query, QueryBinder } from 'sqlstack';
|
|
@@ -817,20 +735,60 @@ class Repo {
|
|
|
817
735
|
}
|
|
818
736
|
```
|
|
819
737
|
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
738
|
+
Given flat rows like:
|
|
739
|
+
|
|
740
|
+
```ts
|
|
741
|
+
[
|
|
742
|
+
{ group_id: 'g1', group_name: 'Chest', muscle_id: 'm1', muscle_name: 'Pecs', exercise_id: 'e1', exercise_name: 'Bench' },
|
|
743
|
+
{ group_id: 'g1', group_name: 'Chest', muscle_id: 'm1', muscle_name: 'Pecs', exercise_id: 'e2', exercise_name: 'Incline' },
|
|
744
|
+
{ group_id: 'g1', group_name: 'Chest', muscle_id: 'm2', muscle_name: 'Delts', exercise_id: 'e3', exercise_name: 'Raise' },
|
|
745
|
+
]
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
the transform returns:
|
|
749
|
+
|
|
750
|
+
```ts
|
|
751
|
+
[
|
|
752
|
+
{
|
|
753
|
+
id: 'g1',
|
|
754
|
+
name: 'Chest',
|
|
755
|
+
muscles: [
|
|
756
|
+
{
|
|
757
|
+
id: 'm1',
|
|
758
|
+
name: 'Pecs',
|
|
759
|
+
exercises: [
|
|
760
|
+
{ id: 'e1', name: 'Bench' },
|
|
761
|
+
{ id: 'e2', name: 'Incline' },
|
|
762
|
+
],
|
|
763
|
+
},
|
|
764
|
+
{
|
|
765
|
+
id: 'm2',
|
|
766
|
+
name: 'Delts',
|
|
767
|
+
exercises: [
|
|
768
|
+
{ id: 'e3', name: 'Raise' },
|
|
769
|
+
],
|
|
770
|
+
},
|
|
771
|
+
],
|
|
772
|
+
},
|
|
773
|
+
];
|
|
774
|
+
```
|
|
775
|
+
|
|
776
|
+
**Direct mapping with dot paths**
|
|
826
777
|
|
|
827
|
-
Example with direct mapping:
|
|
828
778
|
```ts
|
|
829
779
|
// Row: { id: '1', muscle: 'biceps', group: 'arms' }
|
|
830
780
|
const toSimple = DTO({ id: 'id', name: 'muscle', 'group.name': 'group' } as any, { single: true });
|
|
831
781
|
// Result: { id: '1', name: 'biceps', group: { name: 'arms' } }
|
|
832
782
|
```
|
|
833
783
|
|
|
784
|
+
**More behaviors**
|
|
785
|
+
|
|
786
|
+
- If you omit `id` at a level, DTO uses all mapped fields there as a composite identity to dedupe children.
|
|
787
|
+
- Child branches where all identifying columns are `NULL` are skipped (common with LEFT JOINs).
|
|
788
|
+
- Ordering of children follows your SQL `ORDER BY`; sort in SQL, not in the DTO.
|
|
789
|
+
|
|
790
|
+
See `docs/DTO.md` for a full guide with more patterns and edge cases.
|
|
791
|
+
|
|
834
792
|
---
|
|
835
793
|
|
|
836
794
|
## Result Types
|
|
@@ -893,6 +851,16 @@ type PostgresWriteResult = WriteResult & {
|
|
|
893
851
|
};
|
|
894
852
|
```
|
|
895
853
|
|
|
854
|
+
### Evolving Queries Without Breaking Callers
|
|
855
|
+
|
|
856
|
+
Your method signature is the contract; the SQL is an implementation detail.
|
|
857
|
+
|
|
858
|
+
- Call sites depend only on the TypeScript interface (`args` and return type), not on the shape of the underlying SQL file.
|
|
859
|
+
- You can freely refactor a `.sql` file to add joins, filters, or pagination, or switch from a simple query to a CTE, without touching call sites—as long as the returned columns still match your declared return type (and any validation schema).
|
|
860
|
+
- Decorators like `@Transform` make it easy to map new or reorganized SQL results back into the same nested DTO shape, so you can take advantage of more advanced SQL while keeping your public interface stable.
|
|
861
|
+
|
|
862
|
+
This lets you start with very simple queries and incrementally adopt more complex SQL over time, with TypeScript and optional JSON Schema validation guarding the boundary.
|
|
863
|
+
|
|
896
864
|
---
|
|
897
865
|
|
|
898
866
|
## Error Handling
|
|
@@ -993,7 +961,7 @@ The placeholder style is inferred from the selected database. Override per-metho
|
|
|
993
961
|
|
|
994
962
|
**SQL-first:** SQL is the source of truth. No hidden queries.
|
|
995
963
|
|
|
996
|
-
**Decorators as metadata:** Decorators (`@Defaults`, `@
|
|
964
|
+
**Decorators as metadata:** Decorators (`@Defaults`, `@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.
|
|
997
965
|
|
|
998
966
|
**Escape hatches:** Use inline SQL for simple queries; keep complex SQL in `.sql` files. You stay in control.
|
|
999
967
|
|