sumak 0.0.6 → 0.0.8
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 +703 -566
- package/dist/ast/ddl-nodes.d.mts +153 -0
- package/dist/ast/ddl-nodes.mjs +1 -0
- package/dist/builder/ddl/alter-table.d.mts +25 -0
- package/dist/builder/ddl/alter-table.mjs +146 -0
- package/dist/builder/ddl/create-index.d.mts +19 -0
- package/dist/builder/ddl/create-index.mjs +67 -0
- package/dist/builder/ddl/create-table.d.mts +40 -0
- package/dist/builder/ddl/create-table.mjs +186 -0
- package/dist/builder/ddl/create-view.d.mts +15 -0
- package/dist/builder/ddl/create-view.mjs +57 -0
- package/dist/builder/ddl/drop.d.mts +38 -0
- package/dist/builder/ddl/drop.mjs +133 -0
- package/dist/builder/delete.d.mts +1 -0
- package/dist/builder/delete.mjs +17 -0
- package/dist/builder/eb.d.mts +14 -1
- package/dist/builder/eb.mjs +26 -5
- package/dist/builder/select.d.mts +1 -0
- package/dist/builder/select.mjs +17 -0
- package/dist/builder/typed-delete.d.mts +10 -0
- package/dist/builder/typed-delete.mjs +25 -2
- package/dist/builder/typed-insert.d.mts +15 -6
- package/dist/builder/typed-insert.mjs +57 -28
- package/dist/builder/typed-merge.d.mts +1 -2
- package/dist/builder/typed-merge.mjs +9 -22
- package/dist/builder/typed-select.d.mts +27 -2
- package/dist/builder/typed-select.mjs +91 -47
- package/dist/builder/typed-update.d.mts +11 -2
- package/dist/builder/typed-update.mjs +36 -18
- package/dist/builder/update.d.mts +1 -0
- package/dist/builder/update.mjs +17 -0
- package/dist/index.d.mts +10 -2
- package/dist/index.mjs +10 -2
- package/dist/printer/ddl.d.mts +21 -0
- package/dist/printer/ddl.mjs +223 -0
- package/dist/schema/column.d.mts +10 -1
- package/dist/schema/column.mjs +33 -2
- package/dist/schema/index.d.mts +1 -1
- package/dist/schema/index.mjs +1 -1
- package/dist/sumak.d.mts +47 -1
- package/dist/sumak.mjs +104 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,12 +14,50 @@
|
|
|
14
14
|
<a href="https://github.com/productdevbook/sumak/blob/main/LICENSE"><img src="https://img.shields.io/github/license/productdevbook/sumak?style=flat&colorA=18181B&colorB=e11d48" alt="license"></a>
|
|
15
15
|
</p>
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Table of Contents
|
|
20
|
+
|
|
21
|
+
- [Install](#install)
|
|
22
|
+
- [Quick Start](#quick-start)
|
|
23
|
+
- [SELECT](#select)
|
|
24
|
+
- [INSERT](#insert)
|
|
25
|
+
- [UPDATE](#update)
|
|
26
|
+
- [DELETE](#delete)
|
|
27
|
+
- [WHERE Conditions](#where-conditions)
|
|
28
|
+
- [Joins](#joins)
|
|
29
|
+
- [Expressions](#expressions)
|
|
30
|
+
- [Aggregates](#aggregates)
|
|
31
|
+
- [Window Functions](#window-functions)
|
|
32
|
+
- [SQL Functions](#sql-functions)
|
|
33
|
+
- [Subqueries](#subqueries)
|
|
34
|
+
- [Set Operations](#set-operations)
|
|
35
|
+
- [CTEs (WITH)](#ctes-with)
|
|
36
|
+
- [Conditional / Dynamic Queries](#conditional--dynamic-queries)
|
|
37
|
+
- [Raw SQL](#raw-sql)
|
|
38
|
+
- [ON CONFLICT / Upsert](#on-conflict--upsert)
|
|
39
|
+
- [MERGE](#merge-sql2003)
|
|
40
|
+
- [Row Locking](#row-locking)
|
|
41
|
+
- [Schema Builder (DDL)](#schema-builder-ddl)
|
|
42
|
+
- [Full-Text Search](#full-text-search)
|
|
43
|
+
- [Temporal Tables](#temporal-tables-sql2011)
|
|
44
|
+
- [Plugins](#plugins)
|
|
45
|
+
- [Hooks](#hooks)
|
|
46
|
+
- [Dialects](#dialects)
|
|
47
|
+
- [Architecture](#architecture)
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Install
|
|
18
52
|
|
|
19
53
|
```sh
|
|
20
54
|
npm install sumak
|
|
21
55
|
```
|
|
22
56
|
|
|
57
|
+
## Quick Start
|
|
58
|
+
|
|
59
|
+
Define your tables and create a typed instance:
|
|
60
|
+
|
|
23
61
|
```ts
|
|
24
62
|
import { sumak, pgDialect, serial, text, boolean, integer, jsonb } from "sumak"
|
|
25
63
|
|
|
@@ -43,252 +81,302 @@ const db = sumak({
|
|
|
43
81
|
})
|
|
44
82
|
```
|
|
45
83
|
|
|
46
|
-
|
|
84
|
+
That's it. `db` now knows every table, column, and type. All queries are fully type-checked.
|
|
47
85
|
|
|
48
|
-
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## SELECT
|
|
49
89
|
|
|
50
90
|
```ts
|
|
91
|
+
// Basic select
|
|
92
|
+
db.selectFrom("users").select("id", "name").toSQL()
|
|
93
|
+
// SELECT "id", "name" FROM "users"
|
|
94
|
+
|
|
95
|
+
// Select all columns
|
|
96
|
+
db.selectFrom("users").selectAll().toSQL()
|
|
97
|
+
|
|
98
|
+
// With WHERE, ORDER BY, LIMIT, OFFSET
|
|
51
99
|
db.selectFrom("users")
|
|
52
100
|
.select("id", "name")
|
|
53
|
-
.where(({ age
|
|
101
|
+
.where(({ age }) => age.gte(18))
|
|
54
102
|
.orderBy("name")
|
|
55
103
|
.limit(10)
|
|
56
|
-
.
|
|
104
|
+
.offset(20)
|
|
105
|
+
.toSQL()
|
|
106
|
+
|
|
107
|
+
// DISTINCT
|
|
108
|
+
db.selectFrom("users").select("name").distinct().toSQL()
|
|
109
|
+
|
|
110
|
+
// DISTINCT ON (PostgreSQL)
|
|
111
|
+
db.selectFrom("users")
|
|
112
|
+
.selectAll()
|
|
113
|
+
.distinctOn("dept")
|
|
114
|
+
.orderBy("dept")
|
|
115
|
+
.orderBy("salary", "DESC")
|
|
116
|
+
.toSQL()
|
|
57
117
|
```
|
|
58
118
|
|
|
59
|
-
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## INSERT
|
|
60
122
|
|
|
61
123
|
```ts
|
|
124
|
+
// Single row
|
|
125
|
+
db.insertInto("users").values({ name: "Alice", email: "alice@example.com" }).toSQL()
|
|
126
|
+
|
|
127
|
+
// Multiple rows
|
|
62
128
|
db.insertInto("users")
|
|
63
|
-
.
|
|
64
|
-
name: "Alice",
|
|
65
|
-
email: "
|
|
66
|
-
|
|
67
|
-
.
|
|
68
|
-
|
|
129
|
+
.valuesMany([
|
|
130
|
+
{ name: "Alice", email: "a@b.com" },
|
|
131
|
+
{ name: "Bob", email: "b@b.com" },
|
|
132
|
+
])
|
|
133
|
+
.toSQL()
|
|
134
|
+
|
|
135
|
+
// RETURNING
|
|
136
|
+
db.insertInto("users").values({ name: "Alice", email: "a@b.com" }).returningAll().toSQL()
|
|
137
|
+
|
|
138
|
+
// INSERT ... SELECT
|
|
139
|
+
const source = db.selectFrom("users").select("name", "email").build()
|
|
140
|
+
db.insertInto("archive").fromSelect(source).toSQL()
|
|
141
|
+
|
|
142
|
+
// DEFAULT VALUES
|
|
143
|
+
db.insertInto("users").defaultValues().toSQL()
|
|
144
|
+
|
|
145
|
+
// SQLite: INSERT OR IGNORE / INSERT OR REPLACE
|
|
146
|
+
db.insertInto("users").values({ name: "Alice" }).orIgnore().toSQL()
|
|
69
147
|
```
|
|
70
148
|
|
|
71
|
-
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## UPDATE
|
|
72
152
|
|
|
73
153
|
```ts
|
|
154
|
+
// Basic update
|
|
74
155
|
db.update("users")
|
|
75
156
|
.set({ active: false })
|
|
76
157
|
.where(({ id }) => id.eq(1))
|
|
77
|
-
.
|
|
78
|
-
```
|
|
158
|
+
.toSQL()
|
|
79
159
|
|
|
80
|
-
|
|
160
|
+
// SET with expression
|
|
161
|
+
db.update("users")
|
|
162
|
+
.setExpr("name", val("Anonymous"))
|
|
163
|
+
.where(({ active }) => active.eq(false))
|
|
164
|
+
.toSQL()
|
|
81
165
|
|
|
82
|
-
|
|
83
|
-
db.
|
|
166
|
+
// UPDATE ... FROM (PostgreSQL)
|
|
167
|
+
db.update("users")
|
|
168
|
+
.set({ name: "Bob" })
|
|
169
|
+
.from("posts")
|
|
84
170
|
.where(({ id }) => id.eq(1))
|
|
85
|
-
.
|
|
86
|
-
.compile(db.printer())
|
|
87
|
-
```
|
|
88
|
-
|
|
89
|
-
## Joins
|
|
171
|
+
.toSQL()
|
|
90
172
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
db.selectFrom("users")
|
|
94
|
-
.innerJoin("posts", ({ users, posts }) => users.id.eqCol(posts.userId))
|
|
95
|
-
.select("id", "title")
|
|
96
|
-
.compile(db.printer())
|
|
173
|
+
// UPDATE with JOIN (MySQL)
|
|
174
|
+
db.update("orders").set({ total: 0 }).innerJoin("users", onExpr).toSQL()
|
|
97
175
|
|
|
98
|
-
//
|
|
99
|
-
db.
|
|
100
|
-
.
|
|
101
|
-
.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
db.selectFrom("users")
|
|
105
|
-
.rightJoin("posts", ({ users, posts }) => users.id.eqCol(posts.userId))
|
|
106
|
-
.compile(db.printer())
|
|
107
|
-
|
|
108
|
-
// FULL JOIN — both sides become nullable
|
|
109
|
-
db.selectFrom("users")
|
|
110
|
-
.fullJoin("posts", ({ users, posts }) => users.id.eqCol(posts.userId))
|
|
111
|
-
.compile(db.printer())
|
|
176
|
+
// RETURNING
|
|
177
|
+
db.update("users")
|
|
178
|
+
.set({ active: false })
|
|
179
|
+
.where(({ id }) => id.eq(1))
|
|
180
|
+
.returningAll()
|
|
181
|
+
.toSQL()
|
|
112
182
|
|
|
113
|
-
//
|
|
114
|
-
db.
|
|
183
|
+
// ORDER BY + LIMIT (MySQL)
|
|
184
|
+
db.update("users").set({ active: false }).orderBy("id").limit(lit(10)).toSQL()
|
|
115
185
|
```
|
|
116
186
|
|
|
117
|
-
|
|
187
|
+
---
|
|
118
188
|
|
|
119
|
-
|
|
189
|
+
## DELETE
|
|
120
190
|
|
|
121
191
|
```ts
|
|
122
|
-
.
|
|
123
|
-
id.eq(
|
|
124
|
-
)
|
|
192
|
+
db.deleteFrom("users")
|
|
193
|
+
.where(({ id }) => id.eq(1))
|
|
194
|
+
.toSQL()
|
|
125
195
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
)
|
|
196
|
+
// RETURNING
|
|
197
|
+
db.deleteFrom("users")
|
|
198
|
+
.where(({ id }) => id.eq(1))
|
|
199
|
+
.returning("id")
|
|
200
|
+
.toSQL()
|
|
129
201
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
)
|
|
202
|
+
// DELETE ... USING (PostgreSQL)
|
|
203
|
+
db.deleteFrom("orders").using("users").where(onExpr).toSQL()
|
|
133
204
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
)
|
|
205
|
+
// DELETE with JOIN (MySQL)
|
|
206
|
+
db.deleteFrom("orders")
|
|
207
|
+
.innerJoin("users", onExpr)
|
|
208
|
+
.where(({ id }) => id.eq(1))
|
|
209
|
+
.toSQL()
|
|
210
|
+
```
|
|
137
211
|
|
|
138
|
-
|
|
139
|
-
age.lte(65),
|
|
140
|
-
)
|
|
212
|
+
---
|
|
141
213
|
|
|
142
|
-
|
|
143
|
-
active.neq(false),
|
|
144
|
-
)
|
|
145
|
-
```
|
|
214
|
+
## WHERE Conditions
|
|
146
215
|
|
|
147
|
-
|
|
216
|
+
Every `.where()` takes a callback with typed column proxies.
|
|
148
217
|
|
|
149
|
-
|
|
150
|
-
.where(({ name }) =>
|
|
151
|
-
name.like("%ali%"),
|
|
152
|
-
)
|
|
218
|
+
### Comparisons
|
|
153
219
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
)
|
|
220
|
+
```ts
|
|
221
|
+
.where(({ age }) => age.eq(25)) // = 25
|
|
222
|
+
.where(({ age }) => age.neq(0)) // != 0
|
|
223
|
+
.where(({ age }) => age.gt(18)) // > 18
|
|
224
|
+
.where(({ age }) => age.gte(18)) // >= 18
|
|
225
|
+
.where(({ age }) => age.lt(65)) // < 65
|
|
226
|
+
.where(({ age }) => age.lte(65)) // <= 65
|
|
227
|
+
```
|
|
157
228
|
|
|
158
|
-
|
|
159
|
-
.where(({ name }) =>
|
|
160
|
-
name.ilike("%alice%"),
|
|
161
|
-
)
|
|
229
|
+
### Pattern Matching
|
|
162
230
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
)
|
|
231
|
+
```ts
|
|
232
|
+
.where(({ name }) => name.like("%ali%")) // LIKE
|
|
233
|
+
.where(({ name }) => name.notLike("%bob%")) // NOT LIKE
|
|
234
|
+
.where(({ name }) => name.ilike("%alice%")) // ILIKE (PG)
|
|
235
|
+
.where(({ email }) => email.notIlike("%spam%")) // NOT ILIKE
|
|
166
236
|
```
|
|
167
237
|
|
|
168
|
-
### Range &
|
|
238
|
+
### Range & Lists
|
|
169
239
|
|
|
170
240
|
```ts
|
|
171
|
-
.where(({ age }) =>
|
|
172
|
-
|
|
173
|
-
)
|
|
241
|
+
.where(({ age }) => age.between(18, 65)) // BETWEEN
|
|
242
|
+
.where(({ age }) => age.notBetween(0, 17)) // NOT BETWEEN
|
|
243
|
+
.where(({ age }) => age.betweenSymmetric(65, 18)) // BETWEEN SYMMETRIC (PG)
|
|
244
|
+
.where(({ id }) => id.in([1, 2, 3])) // IN
|
|
245
|
+
.where(({ id }) => id.notIn([99, 100])) // NOT IN
|
|
246
|
+
```
|
|
174
247
|
|
|
175
|
-
|
|
176
|
-
age.notBetween(18, 65),
|
|
177
|
-
)
|
|
248
|
+
### Null Checks
|
|
178
249
|
|
|
179
|
-
|
|
180
|
-
.where(({
|
|
181
|
-
|
|
182
|
-
|
|
250
|
+
```ts
|
|
251
|
+
.where(({ bio }) => bio.isNull()) // IS NULL
|
|
252
|
+
.where(({ email }) => email.isNotNull()) // IS NOT NULL
|
|
253
|
+
```
|
|
183
254
|
|
|
184
|
-
|
|
185
|
-
id.in([1, 2, 3]),
|
|
186
|
-
)
|
|
255
|
+
### Null-Safe Comparisons
|
|
187
256
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
)
|
|
257
|
+
```ts
|
|
258
|
+
.where(({ age }) => age.isDistinctFrom(null)) // IS DISTINCT FROM
|
|
259
|
+
.where(({ age }) => age.isNotDistinctFrom(25)) // IS NOT DISTINCT FROM
|
|
191
260
|
```
|
|
192
261
|
|
|
193
|
-
###
|
|
262
|
+
### IN Subquery
|
|
194
263
|
|
|
195
264
|
```ts
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
)
|
|
265
|
+
const deptIds = db
|
|
266
|
+
.selectFrom("departments")
|
|
267
|
+
.select("id")
|
|
268
|
+
.build()
|
|
199
269
|
|
|
200
|
-
.where(({
|
|
201
|
-
|
|
202
|
-
)
|
|
270
|
+
.where(({ dept_id }) => dept_id.inSubquery(deptIds)) // IN (SELECT ...)
|
|
271
|
+
.where(({ dept_id }) => dept_id.notInSubquery(deptIds)) // NOT IN (SELECT ...)
|
|
203
272
|
```
|
|
204
273
|
|
|
205
274
|
### Logical Combinators
|
|
206
275
|
|
|
207
276
|
```ts
|
|
208
|
-
// AND
|
|
277
|
+
// AND (variadic — 2 or more args)
|
|
209
278
|
.where(({ age, active }) =>
|
|
210
|
-
and(
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
279
|
+
and(age.gt(18), active.eq(true)),
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
// AND with 3+ conditions
|
|
283
|
+
.where(({ id, age, active }) =>
|
|
284
|
+
and(id.gt(0), age.gt(18), active.eq(true)),
|
|
214
285
|
)
|
|
215
286
|
|
|
216
|
-
// OR
|
|
287
|
+
// OR (variadic)
|
|
217
288
|
.where(({ name, email }) =>
|
|
218
|
-
or(
|
|
219
|
-
name.like("%alice%"),
|
|
220
|
-
email.like("%alice%"),
|
|
221
|
-
),
|
|
289
|
+
or(name.like("%alice%"), email.like("%alice%")),
|
|
222
290
|
)
|
|
223
291
|
|
|
224
292
|
// NOT
|
|
225
|
-
.where(({ active }) =>
|
|
226
|
-
not(active.eq(true)),
|
|
227
|
-
)
|
|
293
|
+
.where(({ active }) => not(active.eq(true)))
|
|
228
294
|
```
|
|
229
295
|
|
|
230
|
-
###
|
|
296
|
+
### Multiple WHERE (implicit AND)
|
|
231
297
|
|
|
232
298
|
```ts
|
|
233
|
-
//
|
|
234
|
-
.
|
|
235
|
-
|
|
236
|
-
)
|
|
299
|
+
// Calling .where() multiple times ANDs conditions together
|
|
300
|
+
db.selectFrom("users")
|
|
301
|
+
.select("id")
|
|
302
|
+
.where(({ age }) => age.gt(18))
|
|
303
|
+
.where(({ active }) => active.eq(true))
|
|
304
|
+
.toSQL()
|
|
305
|
+
// WHERE ("age" > $1) AND ("active" = $2)
|
|
306
|
+
```
|
|
237
307
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
)
|
|
308
|
+
### Column-to-Column Comparisons
|
|
309
|
+
|
|
310
|
+
```ts
|
|
311
|
+
.where(({ price, cost }) => price.gtCol(cost)) // "price" > "cost"
|
|
312
|
+
.where(({ a, b }) => a.eqCol(b)) // "a" = "b"
|
|
313
|
+
.where(({ a, b }) => a.neqCol(b)) // "a" != "b"
|
|
314
|
+
.where(({ a, b }) => a.gteCol(b)) // "a" >= "b"
|
|
315
|
+
.where(({ a, b }) => a.ltCol(b)) // "a" < "b"
|
|
316
|
+
.where(({ a, b }) => a.lteCol(b)) // "a" <= "b"
|
|
242
317
|
```
|
|
243
318
|
|
|
244
|
-
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
## Joins
|
|
245
322
|
|
|
246
323
|
```ts
|
|
247
|
-
|
|
324
|
+
// INNER JOIN
|
|
325
|
+
db.selectFrom("users")
|
|
326
|
+
.innerJoin("posts", ({ users, posts }) => users.id.eqCol(posts.userId))
|
|
327
|
+
.select("id", "title")
|
|
328
|
+
.toSQL()
|
|
248
329
|
|
|
249
|
-
|
|
330
|
+
// LEFT JOIN — joined columns become nullable
|
|
331
|
+
db.selectFrom("users")
|
|
332
|
+
.leftJoin("posts", ({ users, posts }) => users.id.eqCol(posts.userId))
|
|
333
|
+
.toSQL()
|
|
250
334
|
|
|
251
|
-
|
|
252
|
-
|
|
335
|
+
// RIGHT JOIN
|
|
336
|
+
db.selectFrom("users")
|
|
337
|
+
.rightJoin("posts", ({ users, posts }) => users.id.eqCol(posts.userId))
|
|
338
|
+
.toSQL()
|
|
253
339
|
|
|
254
|
-
|
|
340
|
+
// FULL JOIN — both sides nullable
|
|
341
|
+
db.selectFrom("users")
|
|
342
|
+
.fullJoin("posts", ({ users, posts }) => users.id.eqCol(posts.userId))
|
|
343
|
+
.toSQL()
|
|
255
344
|
|
|
256
|
-
|
|
345
|
+
// CROSS JOIN
|
|
346
|
+
db.selectFrom("users").crossJoin("posts").toSQL()
|
|
257
347
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
.compile(db.printer())
|
|
348
|
+
// LATERAL JOINs (correlated subqueries)
|
|
349
|
+
db.selectFrom("users").innerJoinLateral(subquery, "recent_posts", onExpr).toSQL()
|
|
261
350
|
|
|
262
|
-
|
|
263
|
-
db.selectFrom("orders").selectExpr(sumDistinct(col.amount), "uniqueSum").compile(db.printer())
|
|
264
|
-
db.selectFrom("orders").selectExpr(avgDistinct(col.amount), "uniqueAvg").compile(db.printer())
|
|
351
|
+
db.selectFrom("users").leftJoinLateral(subquery, "recent_posts", onExpr).toSQL()
|
|
265
352
|
|
|
266
|
-
|
|
267
|
-
db.selectFrom("users")
|
|
268
|
-
.selectExpr(coalesce(col.nick, col.name, val("Anonymous")), "displayName")
|
|
269
|
-
.compile(db.printer())
|
|
353
|
+
db.selectFrom("users").crossJoinLateral(subquery, "latest").toSQL()
|
|
270
354
|
```
|
|
271
355
|
|
|
272
|
-
|
|
356
|
+
---
|
|
273
357
|
|
|
274
|
-
|
|
275
|
-
import { stringAgg, arrayAgg, jsonAgg, jsonBuildObject } from "sumak"
|
|
358
|
+
## Expressions
|
|
276
359
|
|
|
277
|
-
|
|
278
|
-
db.selectFrom("users")
|
|
279
|
-
.selectExpr(stringAgg(col.name, ", ", [{ expr: col.name, direction: "ASC" }]), "names")
|
|
280
|
-
.compile(db.printer())
|
|
281
|
-
// STRING_AGG("name", ', ' ORDER BY "name" ASC)
|
|
360
|
+
### Computed Columns
|
|
282
361
|
|
|
283
|
-
|
|
284
|
-
|
|
362
|
+
```ts
|
|
363
|
+
import { val, cast, rawExpr } from "sumak"
|
|
285
364
|
|
|
286
|
-
//
|
|
287
|
-
db.selectFrom("users").selectExpr(
|
|
365
|
+
// Add a computed column with alias
|
|
366
|
+
db.selectFrom("users").selectExpr(val("hello"), "greeting").toSQL()
|
|
288
367
|
|
|
368
|
+
// Multiple expressions at once
|
|
289
369
|
db.selectFrom("users")
|
|
290
|
-
.
|
|
291
|
-
|
|
370
|
+
.selectExprs({
|
|
371
|
+
total: count(),
|
|
372
|
+
greeting: val("hello"),
|
|
373
|
+
})
|
|
374
|
+
.toSQL()
|
|
375
|
+
|
|
376
|
+
// CAST
|
|
377
|
+
db.selectFrom("users")
|
|
378
|
+
.selectExpr(cast(val(42), "text"), "idAsText")
|
|
379
|
+
.toSQL()
|
|
292
380
|
```
|
|
293
381
|
|
|
294
382
|
### Arithmetic
|
|
@@ -296,43 +384,15 @@ db.selectFrom("users")
|
|
|
296
384
|
```ts
|
|
297
385
|
import { add, sub, mul, div, mod, neg } from "sumak"
|
|
298
386
|
|
|
299
|
-
db.selectFrom("orders").selectExpr(mul(col.price, col.qty), "total").
|
|
387
|
+
db.selectFrom("orders").selectExpr(mul(col.price, col.qty), "total").toSQL()
|
|
300
388
|
// ("price" * "qty") AS "total"
|
|
301
389
|
|
|
302
390
|
db.selectFrom("orders")
|
|
303
391
|
.selectExpr(add(col.price, val(10)), "adjusted")
|
|
304
|
-
.
|
|
305
|
-
```
|
|
306
|
-
|
|
307
|
-
### EXISTS / NOT EXISTS
|
|
308
|
-
|
|
309
|
-
```ts
|
|
310
|
-
import { exists, notExists } from "sumak"
|
|
311
|
-
|
|
312
|
-
db.selectFrom("users")
|
|
313
|
-
.where(() =>
|
|
314
|
-
exists(
|
|
315
|
-
db
|
|
316
|
-
.selectFrom("posts")
|
|
317
|
-
.where(({ userId }) => userId.eq(1))
|
|
318
|
-
.build(),
|
|
319
|
-
),
|
|
320
|
-
)
|
|
321
|
-
.compile(db.printer())
|
|
322
|
-
|
|
323
|
-
db.selectFrom("users")
|
|
324
|
-
.where(() =>
|
|
325
|
-
notExists(
|
|
326
|
-
db
|
|
327
|
-
.selectFrom("posts")
|
|
328
|
-
.where(({ userId }) => userId.eq(1))
|
|
329
|
-
.build(),
|
|
330
|
-
),
|
|
331
|
-
)
|
|
332
|
-
.compile(db.printer())
|
|
392
|
+
.toSQL()
|
|
333
393
|
```
|
|
334
394
|
|
|
335
|
-
### CASE
|
|
395
|
+
### CASE / WHEN
|
|
336
396
|
|
|
337
397
|
```ts
|
|
338
398
|
import { case_, val } from "sumak"
|
|
@@ -346,151 +406,211 @@ db.selectFrom("users")
|
|
|
346
406
|
.end(),
|
|
347
407
|
"status",
|
|
348
408
|
)
|
|
349
|
-
.
|
|
409
|
+
.toSQL()
|
|
350
410
|
```
|
|
351
411
|
|
|
352
|
-
###
|
|
412
|
+
### JSON Operations
|
|
353
413
|
|
|
354
414
|
```ts
|
|
355
|
-
import {
|
|
415
|
+
import { jsonRef, jsonAgg, toJson, jsonBuildObject } from "sumak"
|
|
356
416
|
|
|
417
|
+
// Access: -> (JSON object), ->> (text value)
|
|
357
418
|
db.selectFrom("users")
|
|
358
|
-
.selectExpr(
|
|
359
|
-
.
|
|
419
|
+
.selectExpr(jsonRef(col.meta, "name", "->>"), "metaName")
|
|
420
|
+
.toSQL()
|
|
421
|
+
|
|
422
|
+
// JSON_AGG / TO_JSON
|
|
423
|
+
db.selectFrom("users").selectExpr(jsonAgg(col.name), "namesJson").toSQL()
|
|
424
|
+
|
|
425
|
+
// JSON_BUILD_OBJECT
|
|
426
|
+
db.selectFrom("users")
|
|
427
|
+
.selectExpr(jsonBuildObject(["name", col.name], ["age", col.age]), "obj")
|
|
428
|
+
.toSQL()
|
|
360
429
|
```
|
|
361
430
|
|
|
362
|
-
###
|
|
431
|
+
### PostgreSQL Array Operators
|
|
432
|
+
|
|
433
|
+
```ts
|
|
434
|
+
import { arrayContains, arrayContainedBy, arrayOverlaps, rawExpr } from "sumak"
|
|
435
|
+
|
|
436
|
+
.where(() => arrayContains(rawExpr("tags"), rawExpr("ARRAY['sql']"))) // @>
|
|
437
|
+
.where(() => arrayContainedBy(rawExpr("tags"), rawExpr("ARRAY[...]"))) // <@
|
|
438
|
+
.where(() => arrayOverlaps(rawExpr("tags"), rawExpr("ARRAY['sql']"))) // &&
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
---
|
|
442
|
+
|
|
443
|
+
## Aggregates
|
|
363
444
|
|
|
364
445
|
```ts
|
|
365
|
-
import {
|
|
446
|
+
import { count, countDistinct, sum, sumDistinct, avg, avgDistinct, min, max, coalesce } from "sumak"
|
|
447
|
+
|
|
448
|
+
db.selectFrom("users").selectExpr(count(), "total").toSQL()
|
|
449
|
+
db.selectFrom("users").selectExpr(countDistinct(col.dept), "uniqueDepts").toSQL()
|
|
450
|
+
db.selectFrom("orders").selectExpr(sumDistinct(col.amount), "uniqueSum").toSQL()
|
|
451
|
+
db.selectFrom("orders").selectExpr(avg(col.amount), "avgAmount").toSQL()
|
|
366
452
|
|
|
367
|
-
//
|
|
453
|
+
// COALESCE (variadic)
|
|
368
454
|
db.selectFrom("users")
|
|
369
|
-
.selectExpr(
|
|
370
|
-
.
|
|
455
|
+
.selectExpr(coalesce(col.nick, col.name, val("Anonymous")), "displayName")
|
|
456
|
+
.toSQL()
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
### Aggregate with FILTER (PostgreSQL)
|
|
460
|
+
|
|
461
|
+
```ts
|
|
462
|
+
import { filter, count } from "sumak"
|
|
371
463
|
|
|
372
|
-
|
|
464
|
+
db.selectFrom("users").selectExpr(filter(count(), activeExpr), "activeCount").toSQL()
|
|
465
|
+
// COUNT(*) FILTER (WHERE ...)
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
### Aggregate with ORDER BY
|
|
469
|
+
|
|
470
|
+
```ts
|
|
471
|
+
import { stringAgg, arrayAgg } from "sumak"
|
|
472
|
+
|
|
473
|
+
// STRING_AGG with ORDER BY
|
|
373
474
|
db.selectFrom("users")
|
|
374
|
-
.selectExpr(
|
|
375
|
-
.
|
|
475
|
+
.selectExpr(stringAgg(col.name, ", ", [{ expr: col.name, direction: "ASC" }]), "names")
|
|
476
|
+
.toSQL()
|
|
477
|
+
// STRING_AGG("name", ', ' ORDER BY "name" ASC)
|
|
478
|
+
|
|
479
|
+
// ARRAY_AGG
|
|
480
|
+
db.selectFrom("users").selectExpr(arrayAgg(col.id), "ids").toSQL()
|
|
376
481
|
```
|
|
377
482
|
|
|
483
|
+
---
|
|
484
|
+
|
|
378
485
|
## Window Functions
|
|
379
486
|
|
|
380
487
|
```ts
|
|
381
488
|
import { over, rowNumber, rank, denseRank, lag, lead, ntile, count, sum } from "sumak"
|
|
382
489
|
|
|
383
|
-
// ROW_NUMBER
|
|
490
|
+
// ROW_NUMBER
|
|
384
491
|
db.selectFrom("employees")
|
|
385
492
|
.selectExpr(
|
|
386
493
|
over(rowNumber(), (w) => w.partitionBy("dept").orderBy("salary", "DESC")),
|
|
387
494
|
"rn",
|
|
388
495
|
)
|
|
389
|
-
.
|
|
496
|
+
.toSQL()
|
|
390
497
|
|
|
391
|
-
// RANK
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
over(rank(), (w) => w.orderBy("score", "DESC")),
|
|
395
|
-
"rnk",
|
|
396
|
-
)
|
|
397
|
-
.compile(db.printer())
|
|
498
|
+
// RANK / DENSE_RANK
|
|
499
|
+
over(rank(), (w) => w.orderBy("score", "DESC"))
|
|
500
|
+
over(denseRank(), (w) => w.orderBy("score", "DESC"))
|
|
398
501
|
|
|
399
502
|
// Running total with frame
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
.rows({ type: "unbounded_preceding" }, { type: "current_row" }),
|
|
407
|
-
),
|
|
408
|
-
"runningTotal",
|
|
409
|
-
)
|
|
410
|
-
.compile(db.printer())
|
|
503
|
+
over(sum(col.amount), (w) =>
|
|
504
|
+
w
|
|
505
|
+
.partitionBy("userId")
|
|
506
|
+
.orderBy("createdAt")
|
|
507
|
+
.rows({ type: "unbounded_preceding" }, { type: "current_row" }),
|
|
508
|
+
)
|
|
411
509
|
|
|
412
|
-
//
|
|
413
|
-
|
|
414
|
-
.
|
|
415
|
-
|
|
416
|
-
"prevPrice",
|
|
417
|
-
)
|
|
418
|
-
.compile(db.printer())
|
|
510
|
+
// RANGE / GROUPS frames
|
|
511
|
+
over(count(), (w) =>
|
|
512
|
+
w.orderBy("salary").range({ type: "preceding", value: 100 }, { type: "following", value: 100 }),
|
|
513
|
+
)
|
|
419
514
|
|
|
420
|
-
// NTILE
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
"quartile",
|
|
425
|
-
)
|
|
426
|
-
.compile(db.printer())
|
|
515
|
+
// LAG / LEAD / NTILE
|
|
516
|
+
over(lag(col.price, 1), (w) => w.orderBy("date"))
|
|
517
|
+
over(lead(col.price, 1), (w) => w.orderBy("date"))
|
|
518
|
+
over(ntile(4), (w) => w.orderBy("salary", "DESC"))
|
|
427
519
|
```
|
|
428
520
|
|
|
521
|
+
---
|
|
522
|
+
|
|
429
523
|
## SQL Functions
|
|
430
524
|
|
|
431
|
-
### String
|
|
525
|
+
### String
|
|
432
526
|
|
|
433
527
|
```ts
|
|
434
528
|
import { upper, lower, concat, substring, trim, length } from "sumak"
|
|
435
529
|
|
|
436
|
-
|
|
437
|
-
//
|
|
530
|
+
upper(col.name) // UPPER("name")
|
|
531
|
+
lower(col.email) // LOWER("email")
|
|
532
|
+
concat(col.first, val(" "), col.last) // CONCAT(...)
|
|
533
|
+
substring(col.name, 1, 3) // SUBSTRING("name", 1, 3)
|
|
534
|
+
trim(col.name) // TRIM("name")
|
|
535
|
+
length(col.name) // LENGTH("name")
|
|
536
|
+
```
|
|
438
537
|
|
|
439
|
-
|
|
538
|
+
### Numeric
|
|
440
539
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
540
|
+
```ts
|
|
541
|
+
import { abs, round, ceil, floor, greatest, least } from "sumak"
|
|
542
|
+
|
|
543
|
+
abs(col.balance) // ABS("balance")
|
|
544
|
+
round(col.price, 2) // ROUND("price", 2)
|
|
545
|
+
ceil(col.amount) // CEIL("amount")
|
|
546
|
+
floor(col.amount) // FLOOR("amount")
|
|
547
|
+
greatest(col.a, col.b) // GREATEST("a", "b")
|
|
548
|
+
least(col.a, col.b) // LEAST("a", "b")
|
|
549
|
+
```
|
|
444
550
|
|
|
445
|
-
|
|
446
|
-
.selectExpr(substring(col.name, 1, 3), "prefix")
|
|
447
|
-
.compile(db.printer())
|
|
551
|
+
### Conditional
|
|
448
552
|
|
|
449
|
-
|
|
553
|
+
```ts
|
|
554
|
+
import { nullif, coalesce } from "sumak"
|
|
450
555
|
|
|
451
|
-
|
|
556
|
+
nullif(col.age, val(0)) // NULLIF("age", 0)
|
|
557
|
+
coalesce(col.nick, col.name, val("Anonymous")) // COALESCE(...)
|
|
452
558
|
```
|
|
453
559
|
|
|
454
|
-
###
|
|
560
|
+
### Date/Time
|
|
455
561
|
|
|
456
562
|
```ts
|
|
457
|
-
import {
|
|
458
|
-
|
|
459
|
-
db.selectFrom("orders").selectExpr(abs(col.balance), "absBalance").compile(db.printer())
|
|
563
|
+
import { now, currentTimestamp } from "sumak"
|
|
460
564
|
|
|
461
|
-
|
|
565
|
+
now() // NOW()
|
|
566
|
+
currentTimestamp() // CURRENT_TIMESTAMP()
|
|
567
|
+
```
|
|
462
568
|
|
|
463
|
-
|
|
569
|
+
---
|
|
464
570
|
|
|
465
|
-
|
|
466
|
-
```
|
|
571
|
+
## Subqueries
|
|
467
572
|
|
|
468
|
-
###
|
|
573
|
+
### EXISTS / NOT EXISTS
|
|
469
574
|
|
|
470
575
|
```ts
|
|
471
|
-
import {
|
|
576
|
+
import { exists, notExists } from "sumak"
|
|
472
577
|
|
|
473
578
|
db.selectFrom("users")
|
|
474
|
-
.
|
|
475
|
-
|
|
579
|
+
.where(() =>
|
|
580
|
+
exists(
|
|
581
|
+
db
|
|
582
|
+
.selectFrom("posts")
|
|
583
|
+
.where(({ userId }) => userId.eq(1))
|
|
584
|
+
.build(),
|
|
585
|
+
),
|
|
586
|
+
)
|
|
587
|
+
.toSQL()
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
### Derived Tables (Subquery in FROM)
|
|
476
591
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
.
|
|
592
|
+
```ts
|
|
593
|
+
const sub = db
|
|
594
|
+
.selectFrom("users")
|
|
595
|
+
.select("id", "name")
|
|
596
|
+
.where(({ age }) => age.gt(18))
|
|
480
597
|
|
|
481
|
-
db.
|
|
482
|
-
|
|
483
|
-
.compile(db.printer())
|
|
598
|
+
db.selectFromSubquery(sub, "adults").selectAll().toSQL()
|
|
599
|
+
// SELECT * FROM (SELECT ...) AS "adults"
|
|
484
600
|
```
|
|
485
601
|
|
|
486
|
-
###
|
|
602
|
+
### IN Subquery
|
|
487
603
|
|
|
488
604
|
```ts
|
|
489
|
-
|
|
605
|
+
const deptIds = db.selectFrom("departments").select("id").build()
|
|
490
606
|
|
|
491
|
-
db.selectFrom("users")
|
|
607
|
+
db.selectFrom("users")
|
|
608
|
+
.where(({ dept_id }) => dept_id.inSubquery(deptIds))
|
|
609
|
+
.toSQL()
|
|
492
610
|
```
|
|
493
611
|
|
|
612
|
+
---
|
|
613
|
+
|
|
494
614
|
## Set Operations
|
|
495
615
|
|
|
496
616
|
```ts
|
|
@@ -498,72 +618,40 @@ const active = db
|
|
|
498
618
|
.selectFrom("users")
|
|
499
619
|
.select("id")
|
|
500
620
|
.where(({ active }) => active.eq(true))
|
|
501
|
-
|
|
502
621
|
const premium = db
|
|
503
622
|
.selectFrom("users")
|
|
504
623
|
.select("id")
|
|
505
|
-
.where(({
|
|
506
|
-
|
|
507
|
-
// UNION
|
|
508
|
-
active.
|
|
509
|
-
|
|
510
|
-
//
|
|
511
|
-
active.
|
|
512
|
-
|
|
513
|
-
// INTERSECT / INTERSECT ALL
|
|
514
|
-
active.intersect(premium).compile(db.printer())
|
|
515
|
-
active.intersectAll(premium).compile(db.printer())
|
|
516
|
-
|
|
517
|
-
// EXCEPT / EXCEPT ALL
|
|
518
|
-
active.except(premium).compile(db.printer())
|
|
519
|
-
active.exceptAll(premium).compile(db.printer())
|
|
624
|
+
.where(({ tier }) => tier.eq("premium"))
|
|
625
|
+
|
|
626
|
+
active.union(premium).toSQL() // UNION
|
|
627
|
+
active.unionAll(premium).toSQL() // UNION ALL
|
|
628
|
+
active.intersect(premium).toSQL() // INTERSECT
|
|
629
|
+
active.intersectAll(premium).toSQL() // INTERSECT ALL
|
|
630
|
+
active.except(premium).toSQL() // EXCEPT
|
|
631
|
+
active.exceptAll(premium).toSQL() // EXCEPT ALL
|
|
520
632
|
```
|
|
521
633
|
|
|
634
|
+
---
|
|
635
|
+
|
|
522
636
|
## CTEs (WITH)
|
|
523
637
|
|
|
524
638
|
```ts
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
.
|
|
528
|
-
|
|
529
|
-
db
|
|
530
|
-
.selectFrom("users")
|
|
531
|
-
.where(({ active }) => active.eq(true))
|
|
532
|
-
.build(),
|
|
533
|
-
)
|
|
534
|
-
.compile(db.printer())
|
|
535
|
-
|
|
536
|
-
// INSERT with CTE
|
|
537
|
-
db.insertInto("users")
|
|
538
|
-
.with("source", sourceCte)
|
|
539
|
-
.values({ name: "Alice", email: "a@b.com" })
|
|
540
|
-
.compile(db.printer())
|
|
541
|
-
|
|
542
|
-
// UPDATE with CTE
|
|
543
|
-
db.update("users").with("target", targetCte).set({ active: false }).compile(db.printer())
|
|
639
|
+
const activeCte = db
|
|
640
|
+
.selectFrom("users")
|
|
641
|
+
.where(({ active }) => active.eq(true))
|
|
642
|
+
.build()
|
|
544
643
|
|
|
545
|
-
|
|
546
|
-
db.deleteFrom("users")
|
|
547
|
-
.with("to_delete", deleteCte)
|
|
548
|
-
.where(({ id }) => id.eq(1))
|
|
549
|
-
.compile(db.printer())
|
|
644
|
+
db.selectFrom("users").with("active_users", activeCte).toSQL()
|
|
550
645
|
|
|
551
646
|
// Recursive CTE
|
|
552
|
-
db.selectFrom("
|
|
647
|
+
db.selectFrom("categories").with("tree", recursiveQuery, true).toSQL()
|
|
553
648
|
```
|
|
554
649
|
|
|
555
|
-
|
|
650
|
+
---
|
|
556
651
|
|
|
557
|
-
|
|
558
|
-
db.update("users")
|
|
559
|
-
.set({ name: "Bob" })
|
|
560
|
-
.from("posts")
|
|
561
|
-
.where(({ id }) => id.eq(1))
|
|
562
|
-
.compile(db.printer())
|
|
563
|
-
// UPDATE "users" SET "name" = $1 FROM "posts" WHERE ("id" = $2)
|
|
564
|
-
```
|
|
652
|
+
## Conditional / Dynamic Queries
|
|
565
653
|
|
|
566
|
-
|
|
654
|
+
### `$if()` — conditional clause
|
|
567
655
|
|
|
568
656
|
```ts
|
|
569
657
|
const withFilter = true
|
|
@@ -573,350 +661,342 @@ db.selectFrom("users")
|
|
|
573
661
|
.select("id", "name")
|
|
574
662
|
.$if(withFilter, (qb) => qb.where(({ age }) => age.gt(18)))
|
|
575
663
|
.$if(withOrder, (qb) => qb.orderBy("name"))
|
|
576
|
-
.
|
|
664
|
+
.toSQL()
|
|
577
665
|
// WHERE applied, ORDER BY skipped
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
### `$call()` — reusable query fragments
|
|
669
|
+
|
|
670
|
+
```ts
|
|
671
|
+
const withPagination = (qb) => qb.limit(10).offset(20)
|
|
672
|
+
const onlyActive = (qb) => qb.where(({ active }) => active.eq(true))
|
|
578
673
|
|
|
579
|
-
|
|
674
|
+
db.selectFrom("users").select("id", "name").$call(onlyActive).$call(withPagination).toSQL()
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
### `clear*()` — reset clauses
|
|
678
|
+
|
|
679
|
+
```ts
|
|
580
680
|
db.selectFrom("users")
|
|
581
681
|
.select("id")
|
|
582
|
-
.
|
|
583
|
-
.
|
|
584
|
-
.
|
|
585
|
-
|
|
682
|
+
.orderBy("name")
|
|
683
|
+
.clearOrderBy() // removes ORDER BY
|
|
684
|
+
.orderBy("id", "DESC") // re-add different order
|
|
685
|
+
.toSQL()
|
|
586
686
|
```
|
|
587
687
|
|
|
588
|
-
|
|
688
|
+
Available: `clearWhere()`, `clearOrderBy()`, `clearLimit()`, `clearOffset()`, `clearGroupBy()`, `clearHaving()`, `clearSelect()`.
|
|
689
|
+
|
|
690
|
+
---
|
|
691
|
+
|
|
692
|
+
## Raw SQL
|
|
693
|
+
|
|
694
|
+
### `sql` tagged template
|
|
589
695
|
|
|
590
696
|
```ts
|
|
591
|
-
|
|
592
|
-
const withPagination = (qb) => qb.limit(10).offset(20)
|
|
593
|
-
const withActiveFilter = (qb) => qb.where(({ active }) => active.eq(true))
|
|
697
|
+
import { sql } from "sumak"
|
|
594
698
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
699
|
+
// Primitives are parameterized
|
|
700
|
+
sql`SELECT * FROM users WHERE name = ${"Alice"}`
|
|
701
|
+
// params: ["Alice"]
|
|
702
|
+
|
|
703
|
+
// Expressions are inlined
|
|
704
|
+
sql`SELECT * FROM users WHERE active = ${val(true)}`
|
|
705
|
+
// → ... WHERE active = TRUE
|
|
706
|
+
|
|
707
|
+
// Helpers
|
|
708
|
+
sql`SELECT ${sql.ref("id")} FROM ${sql.table("users", "public")}`
|
|
709
|
+
// → SELECT "id" FROM "public"."users"
|
|
600
710
|
|
|
601
|
-
//
|
|
711
|
+
// In queries
|
|
602
712
|
db.selectFrom("users")
|
|
603
|
-
.
|
|
604
|
-
|
|
605
|
-
greeting: val("hello"),
|
|
606
|
-
})
|
|
607
|
-
.compile(db.printer())
|
|
713
|
+
.selectExpr(sql`CURRENT_DATE`, "today")
|
|
714
|
+
.toSQL()
|
|
608
715
|
```
|
|
609
716
|
|
|
610
|
-
|
|
717
|
+
### `rawExpr()` escape hatch
|
|
611
718
|
|
|
612
719
|
```ts
|
|
613
|
-
|
|
614
|
-
const selectQuery = db.selectFrom("users").select("name", "age").build()
|
|
615
|
-
db.insertInto("archive").fromSelect(selectQuery).compile(db.printer())
|
|
616
|
-
|
|
617
|
-
// INSERT ... DEFAULT VALUES
|
|
618
|
-
db.insertInto("users").defaultValues().compile(db.printer())
|
|
720
|
+
import { rawExpr } from "sumak"
|
|
619
721
|
|
|
620
|
-
//
|
|
621
|
-
db.
|
|
622
|
-
|
|
722
|
+
// In WHERE
|
|
723
|
+
db.selectFrom("users")
|
|
724
|
+
.where(() => rawExpr<boolean>("age > 18"))
|
|
725
|
+
.toSQL()
|
|
623
726
|
|
|
624
|
-
|
|
625
|
-
|
|
727
|
+
// In SELECT
|
|
728
|
+
db.selectFrom("users").selectExpr(rawExpr<number>("EXTRACT(YEAR FROM created_at)"), "year").toSQL()
|
|
626
729
|
```
|
|
627
730
|
|
|
628
|
-
|
|
731
|
+
---
|
|
732
|
+
|
|
733
|
+
## ON CONFLICT / Upsert
|
|
629
734
|
|
|
630
735
|
```ts
|
|
631
|
-
// DO NOTHING
|
|
736
|
+
// PostgreSQL: ON CONFLICT DO NOTHING
|
|
632
737
|
db.insertInto("users")
|
|
633
738
|
.values({ name: "Alice", email: "a@b.com" })
|
|
634
739
|
.onConflictDoNothing("email")
|
|
635
|
-
.
|
|
740
|
+
.toSQL()
|
|
636
741
|
|
|
637
|
-
// DO UPDATE (
|
|
742
|
+
// ON CONFLICT DO UPDATE (with Expression)
|
|
638
743
|
db.insertInto("users")
|
|
639
744
|
.values({ name: "Alice", email: "a@b.com" })
|
|
640
|
-
.onConflictDoUpdate(["email"], [{ column: "name", value: val("
|
|
641
|
-
.
|
|
745
|
+
.onConflictDoUpdate(["email"], [{ column: "name", value: val("Updated") }])
|
|
746
|
+
.toSQL()
|
|
642
747
|
|
|
643
|
-
// DO
|
|
748
|
+
// ON CONFLICT DO UPDATE (with plain object — auto-parameterized)
|
|
749
|
+
db.insertInto("users")
|
|
750
|
+
.values({ name: "Alice", email: "a@b.com" })
|
|
751
|
+
.onConflictDoUpdateSet(["email"], { name: "Alice Updated" })
|
|
752
|
+
.toSQL()
|
|
753
|
+
|
|
754
|
+
// ON CONFLICT ON CONSTRAINT
|
|
644
755
|
db.insertInto("users")
|
|
645
756
|
.values({ name: "Alice", email: "a@b.com" })
|
|
646
757
|
.onConflictConstraintDoNothing("users_email_key")
|
|
647
|
-
.
|
|
648
|
-
// ON CONFLICT ON CONSTRAINT "users_email_key" DO NOTHING
|
|
758
|
+
.toSQL()
|
|
649
759
|
|
|
650
760
|
// MySQL: ON DUPLICATE KEY UPDATE
|
|
651
761
|
db.insertInto("users")
|
|
652
762
|
.values({ name: "Alice" })
|
|
653
763
|
.onDuplicateKeyUpdate([{ column: "name", value: val("Alice") }])
|
|
654
|
-
.
|
|
764
|
+
.toSQL()
|
|
655
765
|
```
|
|
656
766
|
|
|
767
|
+
---
|
|
768
|
+
|
|
657
769
|
## MERGE (SQL:2003)
|
|
658
770
|
|
|
659
771
|
```ts
|
|
660
772
|
db.mergeInto("users", "staging", "s", ({ target, source }) => target.id.eqCol(source.id))
|
|
661
773
|
.whenMatchedThenUpdate({ name: "updated" })
|
|
662
|
-
.whenNotMatchedThenInsert({
|
|
663
|
-
|
|
664
|
-
email: "alice@example.com",
|
|
665
|
-
})
|
|
666
|
-
.compile(db.printer())
|
|
774
|
+
.whenNotMatchedThenInsert({ name: "Alice", email: "a@b.com" })
|
|
775
|
+
.toSQL()
|
|
667
776
|
|
|
668
|
-
//
|
|
777
|
+
// Conditional delete
|
|
669
778
|
db.mergeInto("users", "staging", "s", ({ target, source }) => target.id.eqCol(source.id))
|
|
670
779
|
.whenMatchedThenDelete()
|
|
671
|
-
.
|
|
780
|
+
.toSQL()
|
|
672
781
|
```
|
|
673
782
|
|
|
783
|
+
---
|
|
784
|
+
|
|
674
785
|
## Row Locking
|
|
675
786
|
|
|
676
787
|
```ts
|
|
677
|
-
// FOR UPDATE
|
|
678
|
-
db.selectFrom("users").select("id").
|
|
679
|
-
|
|
680
|
-
// FOR SHARE
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
//
|
|
684
|
-
db.selectFrom("users").select("id").
|
|
685
|
-
db.selectFrom("users").select("id").forKeyShare().compile(db.printer())
|
|
686
|
-
|
|
687
|
-
// SKIP LOCKED / NOWAIT
|
|
688
|
-
db.selectFrom("users").select("id").forUpdate().skipLocked().compile(db.printer())
|
|
689
|
-
db.selectFrom("users").select("id").forUpdate().noWait().compile(db.printer())
|
|
788
|
+
db.selectFrom("users").select("id").forUpdate().toSQL() // FOR UPDATE
|
|
789
|
+
db.selectFrom("users").select("id").forShare().toSQL() // FOR SHARE
|
|
790
|
+
db.selectFrom("users").select("id").forNoKeyUpdate().toSQL() // FOR NO KEY UPDATE (PG)
|
|
791
|
+
db.selectFrom("users").select("id").forKeyShare().toSQL() // FOR KEY SHARE (PG)
|
|
792
|
+
|
|
793
|
+
// Modifiers
|
|
794
|
+
db.selectFrom("users").select("id").forUpdate().skipLocked().toSQL() // SKIP LOCKED
|
|
795
|
+
db.selectFrom("users").select("id").forUpdate().noWait().toSQL() // NOWAIT
|
|
690
796
|
```
|
|
691
797
|
|
|
692
|
-
|
|
798
|
+
---
|
|
693
799
|
|
|
694
|
-
|
|
695
|
-
db.selectFrom("users")
|
|
696
|
-
.selectAll()
|
|
697
|
-
.distinctOn("dept")
|
|
698
|
-
.orderBy("dept")
|
|
699
|
-
.orderBy("salary", "DESC")
|
|
700
|
-
.compile(db.printer())
|
|
701
|
-
// SELECT DISTINCT ON ("dept") * FROM "users" ORDER BY "dept" ASC, "salary" DESC
|
|
702
|
-
```
|
|
703
|
-
|
|
704
|
-
## DELETE USING / JOIN in UPDATE & DELETE
|
|
800
|
+
## EXPLAIN
|
|
705
801
|
|
|
706
802
|
```ts
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
.using("users")
|
|
710
|
-
.where(eq(col("orders.user_id"), col("users.id")))
|
|
711
|
-
.compile(db.printer())
|
|
803
|
+
db.selectFrom("users").select("id").explain().toSQL()
|
|
804
|
+
// EXPLAIN SELECT "id" FROM "users"
|
|
712
805
|
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
.innerJoin("users", eq(col("user_id", "orders"), col("id", "users")))
|
|
716
|
-
.where(eq(col("name", "users"), lit("Alice")))
|
|
717
|
-
.compile(db.printer())
|
|
806
|
+
db.selectFrom("users").select("id").explain({ analyze: true }).toSQL()
|
|
807
|
+
// EXPLAIN ANALYZE SELECT ...
|
|
718
808
|
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
.set({ total: 0 })
|
|
722
|
-
.innerJoin("users", eq(col("user_id", "orders"), col("id", "users")))
|
|
723
|
-
.compile(db.printer())
|
|
809
|
+
db.selectFrom("users").select("id").explain({ format: "JSON" }).toSQL()
|
|
810
|
+
// EXPLAIN (FORMAT JSON) SELECT ...
|
|
724
811
|
```
|
|
725
812
|
|
|
726
|
-
|
|
813
|
+
---
|
|
727
814
|
|
|
728
|
-
|
|
729
|
-
// INNER JOIN LATERAL — correlated subquery join
|
|
730
|
-
const recentPosts = db
|
|
731
|
-
.selectFrom("posts")
|
|
732
|
-
.select("id", "title")
|
|
733
|
-
.where(({ userId }) => userId.eq(1))
|
|
734
|
-
.limit(3)
|
|
815
|
+
## Schema Builder (DDL)
|
|
735
816
|
|
|
736
|
-
|
|
737
|
-
// SELECT * FROM "users" INNER JOIN LATERAL (SELECT ...) AS "rp" ON ...
|
|
817
|
+
The schema builder generates DDL SQL (CREATE, ALTER, DROP). It is separate from the query builder — you use `db.compileDDL(node)` to compile DDL nodes.
|
|
738
818
|
|
|
739
|
-
|
|
740
|
-
db.selectFrom("users").leftJoinLateral(recentPosts, "rp", onExpr).compile(db.printer())
|
|
741
|
-
```
|
|
742
|
-
|
|
743
|
-
## Tuple Comparisons
|
|
819
|
+
### CREATE TABLE
|
|
744
820
|
|
|
745
821
|
```ts
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
.
|
|
751
|
-
.
|
|
752
|
-
|
|
822
|
+
db.schema
|
|
823
|
+
.createTable("users")
|
|
824
|
+
.ifNotExists()
|
|
825
|
+
.addColumn("id", "serial", (c) => c.primaryKey())
|
|
826
|
+
.addColumn("name", "varchar(255)", (c) => c.notNull())
|
|
827
|
+
.addColumn("email", "varchar", (c) => c.unique().notNull())
|
|
828
|
+
.addColumn("active", "boolean", (c) => c.defaultTo(lit(true)))
|
|
829
|
+
.build()
|
|
830
|
+
|
|
831
|
+
// Foreign key with ON DELETE CASCADE
|
|
832
|
+
db.schema
|
|
833
|
+
.createTable("posts")
|
|
834
|
+
.addColumn("id", "serial", (c) => c.primaryKey())
|
|
835
|
+
.addColumn("user_id", "integer", (c) => c.notNull().references("users", "id").onDelete("CASCADE"))
|
|
836
|
+
.build()
|
|
837
|
+
|
|
838
|
+
// Composite primary key
|
|
839
|
+
db.schema
|
|
840
|
+
.createTable("order_items")
|
|
841
|
+
.addColumn("order_id", "integer")
|
|
842
|
+
.addColumn("product_id", "integer")
|
|
843
|
+
.addPrimaryKeyConstraint("pk_order_items", ["order_id", "product_id"])
|
|
844
|
+
.build()
|
|
753
845
|
```
|
|
754
846
|
|
|
755
|
-
|
|
847
|
+
### ALTER TABLE
|
|
756
848
|
|
|
757
849
|
```ts
|
|
758
|
-
|
|
850
|
+
db.schema
|
|
851
|
+
.alterTable("users")
|
|
852
|
+
.addColumn("age", "integer", (c) => c.notNull())
|
|
853
|
+
.build()
|
|
854
|
+
|
|
855
|
+
db.schema.alterTable("users").dropColumn("age").build()
|
|
856
|
+
db.schema.alterTable("users").renameColumn("name", "full_name").build()
|
|
857
|
+
db.schema.alterTable("users").renameTo("people").build()
|
|
858
|
+
|
|
859
|
+
db.schema
|
|
860
|
+
.alterTable("users")
|
|
861
|
+
.alterColumn("age", { type: "set_data_type", dataType: "bigint" })
|
|
862
|
+
.build()
|
|
863
|
+
db.schema.alterTable("users").alterColumn("name", { type: "set_not_null" }).build()
|
|
864
|
+
```
|
|
759
865
|
|
|
760
|
-
|
|
761
|
-
sql`SELECT * FROM users WHERE name = ${"Alice"}`
|
|
762
|
-
// params: ["Alice"]
|
|
866
|
+
### CREATE INDEX
|
|
763
867
|
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
868
|
+
```ts
|
|
869
|
+
db.schema.createIndex("idx_users_name").on("users").column("name").build()
|
|
870
|
+
db.schema.createIndex("uq_email").unique().on("users").column("email").build()
|
|
871
|
+
|
|
872
|
+
// Multi-column with direction
|
|
873
|
+
db.schema
|
|
874
|
+
.createIndex("idx_multi")
|
|
875
|
+
.on("users")
|
|
876
|
+
.column("last_name", "ASC")
|
|
877
|
+
.column("age", "DESC")
|
|
878
|
+
.build()
|
|
879
|
+
|
|
880
|
+
// GIN index (PG)
|
|
881
|
+
db.schema.createIndex("idx_tags").on("posts").column("tags").using("gin").build()
|
|
882
|
+
|
|
883
|
+
// Partial index
|
|
884
|
+
db.schema
|
|
885
|
+
.createIndex("idx_active")
|
|
886
|
+
.on("users")
|
|
887
|
+
.column("email")
|
|
888
|
+
.where(rawExpr("active = true"))
|
|
889
|
+
.build()
|
|
890
|
+
```
|
|
767
891
|
|
|
768
|
-
|
|
769
|
-
sql`SELECT ${sql.ref("id")} FROM ${sql.table("users", "public")}`
|
|
770
|
-
// → SELECT "id" FROM "public"."users"
|
|
892
|
+
### CREATE VIEW
|
|
771
893
|
|
|
772
|
-
|
|
773
|
-
db.
|
|
774
|
-
|
|
775
|
-
|
|
894
|
+
```ts
|
|
895
|
+
db.schema.createView("active_users").asSelect(selectQuery).build()
|
|
896
|
+
db.schema.createView("stats").materialized().asSelect(selectQuery).build()
|
|
897
|
+
db.schema.createView("my_view").orReplace().columns("id", "name").asSelect(selectQuery).build()
|
|
776
898
|
```
|
|
777
899
|
|
|
778
|
-
|
|
900
|
+
### DROP
|
|
779
901
|
|
|
780
902
|
```ts
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
db.selectFrom("users").selectExpr(filter(count(), activeExpr), "activeCount").compile(db.printer())
|
|
903
|
+
db.schema.dropTable("users").ifExists().cascade().build()
|
|
904
|
+
db.schema.dropIndex("idx_name").ifExists().build()
|
|
905
|
+
db.schema.dropView("my_view").materialized().ifExists().build()
|
|
785
906
|
```
|
|
786
907
|
|
|
787
|
-
|
|
908
|
+
### Auto-Generate from Schema
|
|
909
|
+
|
|
910
|
+
The schema you pass to `sumak({ tables })` can auto-generate CREATE TABLE SQL:
|
|
788
911
|
|
|
789
912
|
```ts
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
913
|
+
const db = sumak({
|
|
914
|
+
dialect: pgDialect(),
|
|
915
|
+
tables: {
|
|
916
|
+
users: {
|
|
917
|
+
id: serial().primaryKey(),
|
|
918
|
+
name: text().notNull(),
|
|
919
|
+
email: text().notNull(),
|
|
920
|
+
},
|
|
921
|
+
posts: {
|
|
922
|
+
id: serial().primaryKey(),
|
|
923
|
+
title: text().notNull(),
|
|
924
|
+
userId: integer().references("users", "id"),
|
|
925
|
+
},
|
|
926
|
+
},
|
|
927
|
+
})
|
|
793
928
|
|
|
794
|
-
|
|
795
|
-
|
|
929
|
+
const ddl = db.generateDDL()
|
|
930
|
+
// [
|
|
931
|
+
// { sql: 'CREATE TABLE "users" ("id" serial PRIMARY KEY NOT NULL, "name" text NOT NULL, "email" text NOT NULL)', params: [] },
|
|
932
|
+
// { sql: 'CREATE TABLE "posts" ("id" serial PRIMARY KEY NOT NULL, "title" text NOT NULL, "userId" integer REFERENCES "users"("id"))', params: [] },
|
|
933
|
+
// ]
|
|
796
934
|
|
|
797
|
-
//
|
|
798
|
-
db.
|
|
799
|
-
// EXPLAIN (FORMAT JSON) SELECT "id" FROM "users"
|
|
935
|
+
// With IF NOT EXISTS
|
|
936
|
+
const safeDDL = db.generateDDL({ ifNotExists: true })
|
|
800
937
|
```
|
|
801
938
|
|
|
939
|
+
> Compile any DDL node: `db.compileDDL(node)` returns `{ sql, params }`.
|
|
940
|
+
|
|
941
|
+
---
|
|
942
|
+
|
|
802
943
|
## Full-Text Search
|
|
803
944
|
|
|
804
|
-
Dialect-aware
|
|
945
|
+
Dialect-aware — same API, different SQL per dialect:
|
|
805
946
|
|
|
806
947
|
```ts
|
|
807
|
-
import { textSearch } from "sumak"
|
|
948
|
+
import { textSearch, val } from "sumak"
|
|
808
949
|
|
|
809
950
|
// PostgreSQL: to_tsvector("name") @@ to_tsquery('alice')
|
|
810
951
|
db.selectFrom("users")
|
|
811
952
|
.where(({ name }) => textSearch([name.toExpr()], val("alice")))
|
|
812
|
-
.
|
|
813
|
-
|
|
814
|
-
// With language config
|
|
815
|
-
db.selectFrom("users")
|
|
816
|
-
.where(({ name }) => textSearch([name.toExpr()], val("alice"), { language: "english" }))
|
|
817
|
-
.compile(db.printer())
|
|
953
|
+
.toSQL()
|
|
818
954
|
|
|
819
955
|
// MySQL: MATCH(`name`) AGAINST(? IN BOOLEAN MODE)
|
|
820
956
|
// SQLite: ("name" MATCH ?)
|
|
821
957
|
// MSSQL: CONTAINS(([name]), @p0)
|
|
822
958
|
```
|
|
823
959
|
|
|
824
|
-
|
|
960
|
+
---
|
|
825
961
|
|
|
826
|
-
|
|
962
|
+
## Temporal Tables (SQL:2011)
|
|
827
963
|
|
|
828
964
|
```ts
|
|
829
|
-
//
|
|
965
|
+
// Point-in-time query
|
|
830
966
|
db.selectFrom("users")
|
|
831
|
-
.forSystemTime({
|
|
832
|
-
|
|
833
|
-
timestamp: lit("2024-01-01"),
|
|
834
|
-
})
|
|
835
|
-
.compile(db.printer())
|
|
967
|
+
.forSystemTime({ kind: "as_of", timestamp: lit("2024-01-01") })
|
|
968
|
+
.toSQL()
|
|
836
969
|
|
|
837
|
-
//
|
|
970
|
+
// Time range
|
|
838
971
|
db.selectFrom("users")
|
|
839
|
-
.forSystemTime({
|
|
840
|
-
|
|
841
|
-
start: lit("2024-01-01"),
|
|
842
|
-
end: lit("2024-12-31"),
|
|
843
|
-
})
|
|
844
|
-
.compile(db.printer())
|
|
845
|
-
|
|
846
|
-
// ALL — full history
|
|
847
|
-
db.selectFrom("users").forSystemTime({ kind: "all" }).compile(db.printer())
|
|
848
|
-
```
|
|
849
|
-
|
|
850
|
-
Supported modes: `as_of`, `from_to`, `between`, `contained_in`, `all`.
|
|
851
|
-
|
|
852
|
-
## Tree Shaking
|
|
853
|
-
|
|
854
|
-
Import only the dialect you need:
|
|
972
|
+
.forSystemTime({ kind: "between", start: lit("2024-01-01"), end: lit("2024-12-31") })
|
|
973
|
+
.toSQL()
|
|
855
974
|
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
import { pgDialect } from "sumak/pg"
|
|
859
|
-
import { mssqlDialect } from "sumak/mssql"
|
|
860
|
-
import { mysqlDialect } from "sumak/mysql"
|
|
861
|
-
import { sqliteDialect } from "sumak/sqlite"
|
|
862
|
-
import { serial, text } from "sumak/schema"
|
|
863
|
-
```
|
|
864
|
-
|
|
865
|
-
## Dialects
|
|
866
|
-
|
|
867
|
-
Same query, different SQL:
|
|
868
|
-
|
|
869
|
-
```ts
|
|
870
|
-
// PostgreSQL → SELECT "id" FROM "users" WHERE ("id" = $1)
|
|
871
|
-
// MySQL → SELECT `id` FROM `users` WHERE (`id` = ?)
|
|
872
|
-
// SQLite → SELECT "id" FROM "users" WHERE ("id" = ?)
|
|
873
|
-
// MSSQL → SELECT [id] FROM [users] WHERE ([id] = @p0)
|
|
975
|
+
// Full history
|
|
976
|
+
db.selectFrom("users").forSystemTime({ kind: "all" }).toSQL()
|
|
874
977
|
```
|
|
875
978
|
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
```ts
|
|
879
|
-
import { mssqlDialect } from "sumak/mssql"
|
|
880
|
-
|
|
881
|
-
const db = sumak({
|
|
882
|
-
dialect: mssqlDialect(),
|
|
883
|
-
tables: { ... },
|
|
884
|
-
})
|
|
885
|
-
|
|
886
|
-
// LIMIT → TOP N
|
|
887
|
-
// SELECT TOP 10 * FROM [users]
|
|
888
|
-
|
|
889
|
-
// LIMIT + OFFSET → OFFSET/FETCH
|
|
890
|
-
// SELECT * FROM [users] ORDER BY [id] ASC OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY
|
|
891
|
-
|
|
892
|
-
// RETURNING → OUTPUT INSERTED.*
|
|
893
|
-
// INSERT INTO [users] ([name]) OUTPUT INSERTED.* VALUES (@p0)
|
|
979
|
+
Modes: `as_of`, `from_to`, `between`, `contained_in`, `all`.
|
|
894
980
|
|
|
895
|
-
|
|
896
|
-
// DELETE FROM [users] OUTPUT DELETED.* WHERE ([id] = @p0)
|
|
897
|
-
```
|
|
981
|
+
---
|
|
898
982
|
|
|
899
983
|
## Plugins
|
|
900
984
|
|
|
901
985
|
```ts
|
|
902
|
-
import {
|
|
903
|
-
WithSchemaPlugin,
|
|
904
|
-
SoftDeletePlugin,
|
|
905
|
-
CamelCasePlugin,
|
|
906
|
-
} from "sumak"
|
|
986
|
+
import { WithSchemaPlugin, SoftDeletePlugin, CamelCasePlugin } from "sumak"
|
|
907
987
|
|
|
908
988
|
const db = sumak({
|
|
909
989
|
dialect: pgDialect(),
|
|
910
990
|
plugins: [
|
|
911
|
-
new WithSchemaPlugin("public"),
|
|
912
|
-
new SoftDeletePlugin({ tables: ["users"] }),
|
|
991
|
+
new WithSchemaPlugin("public"), // auto "public"."users"
|
|
992
|
+
new SoftDeletePlugin({ tables: ["users"] }), // auto WHERE deleted_at IS NULL
|
|
913
993
|
],
|
|
914
994
|
tables: { ... },
|
|
915
995
|
})
|
|
916
|
-
|
|
917
|
-
// SELECT * FROM "public"."users" WHERE ("deleted_at" IS NULL)
|
|
918
996
|
```
|
|
919
997
|
|
|
998
|
+
---
|
|
999
|
+
|
|
920
1000
|
## Hooks
|
|
921
1001
|
|
|
922
1002
|
```ts
|
|
@@ -925,14 +1005,6 @@ db.hook("query:after", (ctx) => {
|
|
|
925
1005
|
console.log(`[SQL] ${ctx.query.sql}`)
|
|
926
1006
|
})
|
|
927
1007
|
|
|
928
|
-
// Add request tracing
|
|
929
|
-
db.hook("query:after", (ctx) => {
|
|
930
|
-
return {
|
|
931
|
-
...ctx.query,
|
|
932
|
-
sql: `${ctx.query.sql} /* request_id=${requestId} */`,
|
|
933
|
-
}
|
|
934
|
-
})
|
|
935
|
-
|
|
936
1008
|
// Modify AST before compilation
|
|
937
1009
|
db.hook("select:before", (ctx) => {
|
|
938
1010
|
// Add tenant isolation, audit filters, etc.
|
|
@@ -948,29 +1020,94 @@ const off = db.hook("query:before", handler)
|
|
|
948
1020
|
off()
|
|
949
1021
|
```
|
|
950
1022
|
|
|
951
|
-
|
|
1023
|
+
---
|
|
1024
|
+
|
|
1025
|
+
## Dialects
|
|
952
1026
|
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
1027
|
+
4 dialects supported. Same query, different SQL:
|
|
1028
|
+
|
|
1029
|
+
```ts
|
|
1030
|
+
// PostgreSQL → SELECT "id" FROM "users" WHERE ("id" = $1)
|
|
1031
|
+
// MySQL → SELECT `id` FROM `users` WHERE (`id` = ?)
|
|
1032
|
+
// SQLite → SELECT "id" FROM "users" WHERE ("id" = ?)
|
|
1033
|
+
// MSSQL → SELECT [id] FROM [users] WHERE ([id] = @p0)
|
|
1034
|
+
```
|
|
1035
|
+
|
|
1036
|
+
```ts
|
|
1037
|
+
import { pgDialect } from "sumak/pg"
|
|
1038
|
+
import { mysqlDialect } from "sumak/mysql"
|
|
1039
|
+
import { sqliteDialect } from "sumak/sqlite"
|
|
1040
|
+
import { mssqlDialect } from "sumak/mssql"
|
|
1041
|
+
```
|
|
1042
|
+
|
|
1043
|
+
### Tree Shaking
|
|
1044
|
+
|
|
1045
|
+
Import only the dialect you need — unused dialects are eliminated:
|
|
1046
|
+
|
|
1047
|
+
```ts
|
|
1048
|
+
import { sumak } from "sumak"
|
|
1049
|
+
import { pgDialect } from "sumak/pg"
|
|
1050
|
+
import { serial, text } from "sumak/schema"
|
|
1051
|
+
```
|
|
1052
|
+
|
|
1053
|
+
---
|
|
962
1054
|
|
|
963
1055
|
## Architecture
|
|
964
1056
|
|
|
1057
|
+
sumak uses a 5-layer pipeline. Your code never touches SQL strings — everything flows through an AST.
|
|
1058
|
+
|
|
965
1059
|
```
|
|
966
|
-
|
|
1060
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
1061
|
+
│ 1. SCHEMA │
|
|
1062
|
+
│ sumak({ dialect, tables: { users: { id: serial(), ... } } })│
|
|
1063
|
+
│ → DB type auto-inferred, zero codegen │
|
|
1064
|
+
├─────────────────────────────────────────────────────────────────┤
|
|
1065
|
+
│ 2. BUILDER │
|
|
1066
|
+
│ db.selectFrom("users").select("id").where(...) │
|
|
1067
|
+
│ → Immutable, chainable, fully type-checked │
|
|
1068
|
+
├─────────────────────────────────────────────────────────────────┤
|
|
1069
|
+
│ 3. AST │
|
|
1070
|
+
│ .build() → SelectNode (frozen, discriminated union) │
|
|
1071
|
+
│ → ~40 node types, Object.freeze on all outputs │
|
|
1072
|
+
├─────────────────────────────────────────────────────────────────┤
|
|
1073
|
+
│ 4. PLUGIN / HOOK │
|
|
1074
|
+
│ Plugin.transformNode() → Hook "query:before" │
|
|
1075
|
+
│ → AST rewriting, tenant isolation, soft delete, logging │
|
|
1076
|
+
├─────────────────────────────────────────────────────────────────┤
|
|
1077
|
+
│ 5. PRINTER │
|
|
1078
|
+
│ .toSQL() → { sql: "SELECT ...", params: [...] } │
|
|
1079
|
+
│ → Dialect-specific: PG ($1), MySQL (?), MSSQL (@p0) │
|
|
1080
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
967
1081
|
```
|
|
968
1082
|
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
- **
|
|
1083
|
+
### Why AST-first?
|
|
1084
|
+
|
|
1085
|
+
The query is never a string until the very last step. This means:
|
|
1086
|
+
|
|
1087
|
+
- **Plugins can rewrite queries** — add WHERE clauses, prefix schemas, transform joins
|
|
1088
|
+
- **Hooks can inspect/modify** — logging, tracing, tenant isolation
|
|
1089
|
+
- **Printers are swappable** — same AST, different SQL per dialect
|
|
1090
|
+
- **No SQL injection** — values are always parameterized
|
|
1091
|
+
|
|
1092
|
+
### Key design decisions
|
|
1093
|
+
|
|
1094
|
+
- **Params at print time** — no global state, no index tracking during build
|
|
1095
|
+
- **Immutable builders** — every method returns a new instance
|
|
1096
|
+
- **Proxy-based column access** — `({ age }) => age.gt(18)` with full type safety
|
|
1097
|
+
- **Phantom types** — `Expression<T>` carries type info with zero runtime cost
|
|
1098
|
+
|
|
1099
|
+
---
|
|
1100
|
+
|
|
1101
|
+
## Acknowledgments
|
|
1102
|
+
|
|
1103
|
+
sumak wouldn't exist without the incredible work of these projects:
|
|
1104
|
+
|
|
1105
|
+
- **[Kysely](https://github.com/kysely-org/kysely)** — Pioneered the AST-first approach for TypeScript query builders. The `DB/TB/O` generic threading pattern, immutable builder design, and visitor-based printer architecture are directly inspired by Kysely.
|
|
1106
|
+
- **[Drizzle ORM](https://github.com/drizzle-team/drizzle-orm)** — Proved that schema-as-code (no codegen) is the right developer experience. The `defineTable()` + column builder pattern in sumak follows Drizzle's lead.
|
|
1107
|
+
- **[JOOQ](https://github.com/jOOQ/jOOQ)** — The original AST-first SQL builder (Java). Showed that a clean AST layer makes multi-dialect support elegant.
|
|
1108
|
+
- **[SQLAlchemy](https://github.com/sqlalchemy/sqlalchemy)** — Demonstrated that separating the expression layer from the ORM layer gives maximum flexibility.
|
|
1109
|
+
|
|
1110
|
+
---
|
|
974
1111
|
|
|
975
1112
|
## License
|
|
976
1113
|
|