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.
Files changed (42) hide show
  1. package/README.md +703 -566
  2. package/dist/ast/ddl-nodes.d.mts +153 -0
  3. package/dist/ast/ddl-nodes.mjs +1 -0
  4. package/dist/builder/ddl/alter-table.d.mts +25 -0
  5. package/dist/builder/ddl/alter-table.mjs +146 -0
  6. package/dist/builder/ddl/create-index.d.mts +19 -0
  7. package/dist/builder/ddl/create-index.mjs +67 -0
  8. package/dist/builder/ddl/create-table.d.mts +40 -0
  9. package/dist/builder/ddl/create-table.mjs +186 -0
  10. package/dist/builder/ddl/create-view.d.mts +15 -0
  11. package/dist/builder/ddl/create-view.mjs +57 -0
  12. package/dist/builder/ddl/drop.d.mts +38 -0
  13. package/dist/builder/ddl/drop.mjs +133 -0
  14. package/dist/builder/delete.d.mts +1 -0
  15. package/dist/builder/delete.mjs +17 -0
  16. package/dist/builder/eb.d.mts +14 -1
  17. package/dist/builder/eb.mjs +26 -5
  18. package/dist/builder/select.d.mts +1 -0
  19. package/dist/builder/select.mjs +17 -0
  20. package/dist/builder/typed-delete.d.mts +10 -0
  21. package/dist/builder/typed-delete.mjs +25 -2
  22. package/dist/builder/typed-insert.d.mts +15 -6
  23. package/dist/builder/typed-insert.mjs +57 -28
  24. package/dist/builder/typed-merge.d.mts +1 -2
  25. package/dist/builder/typed-merge.mjs +9 -22
  26. package/dist/builder/typed-select.d.mts +27 -2
  27. package/dist/builder/typed-select.mjs +91 -47
  28. package/dist/builder/typed-update.d.mts +11 -2
  29. package/dist/builder/typed-update.mjs +36 -18
  30. package/dist/builder/update.d.mts +1 -0
  31. package/dist/builder/update.mjs +17 -0
  32. package/dist/index.d.mts +10 -2
  33. package/dist/index.mjs +10 -2
  34. package/dist/printer/ddl.d.mts +21 -0
  35. package/dist/printer/ddl.mjs +223 -0
  36. package/dist/schema/column.d.mts +10 -1
  37. package/dist/schema/column.mjs +33 -2
  38. package/dist/schema/index.d.mts +1 -1
  39. package/dist/schema/index.mjs +1 -1
  40. package/dist/sumak.d.mts +47 -1
  41. package/dist/sumak.mjs +104 -6
  42. 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
- ## Quick Start
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
- ## Query Building
84
+ That's it. `db` now knows every table, column, and type. All queries are fully type-checked.
47
85
 
48
- ### SELECT
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, active }) => and(age.gte(18), active.eq(true)))
101
+ .where(({ age }) => age.gte(18))
54
102
  .orderBy("name")
55
103
  .limit(10)
56
- .compile(db.printer())
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
- ### INSERT
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
- .values({
64
- name: "Alice",
65
- email: "alice@example.com",
66
- })
67
- .returningAll()
68
- .compile(db.printer())
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
- ### UPDATE
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
- .compile(db.printer())
78
- ```
158
+ .toSQL()
79
159
 
80
- ### DELETE
160
+ // SET with expression
161
+ db.update("users")
162
+ .setExpr("name", val("Anonymous"))
163
+ .where(({ active }) => active.eq(false))
164
+ .toSQL()
81
165
 
82
- ```ts
83
- db.deleteFrom("users")
166
+ // UPDATE ... FROM (PostgreSQL)
167
+ db.update("users")
168
+ .set({ name: "Bob" })
169
+ .from("posts")
84
170
  .where(({ id }) => id.eq(1))
85
- .returning("id")
86
- .compile(db.printer())
87
- ```
88
-
89
- ## Joins
171
+ .toSQL()
90
172
 
91
- ```ts
92
- // INNER JOIN
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
- // LEFT JOIN — joined columns become nullable
99
- db.selectFrom("users")
100
- .leftJoin("posts", ({ users, posts }) => users.id.eqCol(posts.userId))
101
- .compile(db.printer())
102
-
103
- // RIGHT JOIN
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
- // CROSS JOIN cartesian product
114
- db.selectFrom("users").crossJoin("posts").compile(db.printer())
183
+ // ORDER BY + LIMIT (MySQL)
184
+ db.update("users").set({ active: false }).orderBy("id").limit(lit(10)).toSQL()
115
185
  ```
116
186
 
117
- ## Expression API
187
+ ---
118
188
 
119
- ### Comparisons
189
+ ## DELETE
120
190
 
121
191
  ```ts
122
- .where(({ id }) =>
123
- id.eq(42),
124
- )
192
+ db.deleteFrom("users")
193
+ .where(({ id }) => id.eq(1))
194
+ .toSQL()
125
195
 
126
- .where(({ age }) =>
127
- age.gt(18),
128
- )
196
+ // RETURNING
197
+ db.deleteFrom("users")
198
+ .where(({ id }) => id.eq(1))
199
+ .returning("id")
200
+ .toSQL()
129
201
 
130
- .where(({ age }) =>
131
- age.gte(18),
132
- )
202
+ // DELETE ... USING (PostgreSQL)
203
+ db.deleteFrom("orders").using("users").where(onExpr).toSQL()
133
204
 
134
- .where(({ age }) =>
135
- age.lt(65),
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
- .where(({ age }) =>
139
- age.lte(65),
140
- )
212
+ ---
141
213
 
142
- .where(({ active }) =>
143
- active.neq(false),
144
- )
145
- ```
214
+ ## WHERE Conditions
146
215
 
147
- ### String Matching
216
+ Every `.where()` takes a callback with typed column proxies.
148
217
 
149
- ```ts
150
- .where(({ name }) =>
151
- name.like("%ali%"),
152
- )
218
+ ### Comparisons
153
219
 
154
- .where(({ name }) =>
155
- name.notLike("%bob%"),
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
- // Case-insensitive (PG)
159
- .where(({ name }) =>
160
- name.ilike("%alice%"),
161
- )
229
+ ### Pattern Matching
162
230
 
163
- .where(({ email }) =>
164
- email.notIlike("%spam%"),
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 & List
238
+ ### Range & Lists
169
239
 
170
240
  ```ts
171
- .where(({ age }) =>
172
- age.between(18, 65),
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
- .where(({ age }) =>
176
- age.notBetween(18, 65),
177
- )
248
+ ### Null Checks
178
249
 
179
- // Order-independent (PG)
180
- .where(({ age }) =>
181
- age.betweenSymmetric(65, 18),
182
- )
250
+ ```ts
251
+ .where(({ bio }) => bio.isNull()) // IS NULL
252
+ .where(({ email }) => email.isNotNull()) // IS NOT NULL
253
+ ```
183
254
 
184
- .where(({ id }) =>
185
- id.in([1, 2, 3]),
186
- )
255
+ ### Null-Safe Comparisons
187
256
 
188
- .where(({ id }) =>
189
- id.notIn([99, 100]),
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
- ### Null Checks
262
+ ### IN Subquery
194
263
 
195
264
  ```ts
196
- .where(({ bio }) =>
197
- bio.isNull(),
198
- )
265
+ const deptIds = db
266
+ .selectFrom("departments")
267
+ .select("id")
268
+ .build()
199
269
 
200
- .where(({ email }) =>
201
- email.isNotNull(),
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
- age.gt(0),
212
- active.eq(true),
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
- ### Null-Safe Comparisons
296
+ ### Multiple WHERE (implicit AND)
231
297
 
232
298
  ```ts
233
- // IS DISTINCT FROM null-safe inequality
234
- .where(({ age }) =>
235
- age.isDistinctFrom(null),
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
- // IS NOT DISTINCT FROM — null-safe equality
239
- .where(({ age }) =>
240
- age.isNotDistinctFrom(25),
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
- ### Aggregates
319
+ ---
320
+
321
+ ## Joins
245
322
 
246
323
  ```ts
247
- import { count, countDistinct, sumDistinct, avgDistinct, sum, avg, min, max, coalesce } from "sumak"
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
- db.selectFrom("users").selectExpr(count(), "total").compile(db.printer())
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
- db.selectFrom("users").selectExpr(countDistinct(col.dept), "uniqueDepts").compile(db.printer())
252
- // SELECT COUNT(DISTINCT "dept") AS "uniqueDepts" FROM "users"
335
+ // RIGHT JOIN
336
+ db.selectFrom("users")
337
+ .rightJoin("posts", ({ users, posts }) => users.id.eqCol(posts.userId))
338
+ .toSQL()
253
339
 
254
- db.selectFrom("orders").selectExpr(sum(col.amount), "totalAmount").compile(db.printer())
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
- db.selectFrom("orders").selectExpr(avg(col.amount), "avgAmount").compile(db.printer())
345
+ // CROSS JOIN
346
+ db.selectFrom("users").crossJoin("posts").toSQL()
257
347
 
258
- db.selectFrom("orders")
259
- .selectExpr(coalesce(col.discount, val(0)), "safeDiscount")
260
- .compile(db.printer())
348
+ // LATERAL JOINs (correlated subqueries)
349
+ db.selectFrom("users").innerJoinLateral(subquery, "recent_posts", onExpr).toSQL()
261
350
 
262
- // SUM(DISTINCT), AVG(DISTINCT)
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
- // COALESCE with multiple fallbacks
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
- ### String & JSON Aggregates
356
+ ---
273
357
 
274
- ```ts
275
- import { stringAgg, arrayAgg, jsonAgg, jsonBuildObject } from "sumak"
358
+ ## Expressions
276
359
 
277
- // STRING_AGG with ORDER BY
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
- // ARRAY_AGG
284
- db.selectFrom("users").selectExpr(arrayAgg(col.id), "ids").compile(db.printer())
362
+ ```ts
363
+ import { val, cast, rawExpr } from "sumak"
285
364
 
286
- // JSON_AGG / JSON_BUILD_OBJECT
287
- db.selectFrom("users").selectExpr(jsonAgg(col.name), "namesJson").compile(db.printer())
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
- .selectExpr(jsonBuildObject(["name", col.name], ["age", col.age]), "obj")
291
- .compile(db.printer())
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").compile(db.printer())
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
- .compile(db.printer())
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 Expression
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
- .compile(db.printer())
409
+ .toSQL()
350
410
  ```
351
411
 
352
- ### CAST
412
+ ### JSON Operations
353
413
 
354
414
  ```ts
355
- import { cast, val } from "sumak"
415
+ import { jsonRef, jsonAgg, toJson, jsonBuildObject } from "sumak"
356
416
 
417
+ // Access: -> (JSON object), ->> (text value)
357
418
  db.selectFrom("users")
358
- .selectExpr(cast(val(42), "text"), "idAsText")
359
- .compile(db.printer())
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
- ### JSON Operations
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 { jsonRef } from "sumak"
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
- // -> (JSON object)
453
+ // COALESCE (variadic)
368
454
  db.selectFrom("users")
369
- .selectExpr(jsonRef(col.meta, "address", "->"), "address")
370
- .compile(db.printer())
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
- // ->> (text value)
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(jsonRef(col.meta, "name", "->>"), "metaName")
375
- .compile(db.printer())
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() OVER (PARTITION BY dept ORDER BY salary DESC)
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
- .compile(db.printer())
496
+ .toSQL()
390
497
 
391
- // RANK() OVER (ORDER BY score DESC)
392
- db.selectFrom("students")
393
- .selectExpr(
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
- db.selectFrom("orders")
401
- .selectExpr(
402
- over(sum(col.amount), (w) =>
403
- w
404
- .partitionBy("userId")
405
- .orderBy("createdAt")
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
- // LAG / LEAD
413
- db.selectFrom("prices")
414
- .selectExpr(
415
- over(lag(col.price, 1), (w) => w.orderBy("date")),
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(4)
421
- db.selectFrom("employees")
422
- .selectExpr(
423
- over(ntile(4), (w) => w.orderBy("salary", "DESC")),
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 Functions
525
+ ### String
432
526
 
433
527
  ```ts
434
528
  import { upper, lower, concat, substring, trim, length } from "sumak"
435
529
 
436
- db.selectFrom("users").selectExpr(upper(col.name), "upperName").compile(db.printer())
437
- // SELECT UPPER("name") AS "upperName" FROM "users"
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
- db.selectFrom("users").selectExpr(lower(col.email), "lowerEmail").compile(db.printer())
538
+ ### Numeric
440
539
 
441
- db.selectFrom("users")
442
- .selectExpr(concat(col.firstName, val(" "), col.lastName), "fullName")
443
- .compile(db.printer())
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
- db.selectFrom("users")
446
- .selectExpr(substring(col.name, 1, 3), "prefix")
447
- .compile(db.printer())
551
+ ### Conditional
448
552
 
449
- db.selectFrom("users").selectExpr(trim(col.name), "trimmed").compile(db.printer())
553
+ ```ts
554
+ import { nullif, coalesce } from "sumak"
450
555
 
451
- db.selectFrom("users").selectExpr(length(col.name), "nameLen").compile(db.printer())
556
+ nullif(col.age, val(0)) // NULLIF("age", 0)
557
+ coalesce(col.nick, col.name, val("Anonymous")) // COALESCE(...)
452
558
  ```
453
559
 
454
- ### Numeric Functions
560
+ ### Date/Time
455
561
 
456
562
  ```ts
457
- import { abs, round, ceil, floor } from "sumak"
458
-
459
- db.selectFrom("orders").selectExpr(abs(col.balance), "absBalance").compile(db.printer())
563
+ import { now, currentTimestamp } from "sumak"
460
564
 
461
- db.selectFrom("orders").selectExpr(round(col.price, 2), "rounded").compile(db.printer())
565
+ now() // NOW()
566
+ currentTimestamp() // CURRENT_TIMESTAMP()
567
+ ```
462
568
 
463
- db.selectFrom("orders").selectExpr(ceil(col.amount), "ceiling").compile(db.printer())
569
+ ---
464
570
 
465
- db.selectFrom("orders").selectExpr(floor(col.amount), "floored").compile(db.printer())
466
- ```
571
+ ## Subqueries
467
572
 
468
- ### Conditional Functions
573
+ ### EXISTS / NOT EXISTS
469
574
 
470
575
  ```ts
471
- import { nullif, greatest, least } from "sumak"
576
+ import { exists, notExists } from "sumak"
472
577
 
473
578
  db.selectFrom("users")
474
- .selectExpr(nullif(col.age, val(0)), "ageOrNull")
475
- .compile(db.printer())
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
- db.selectFrom("products")
478
- .selectExpr(greatest(col.price, col.minPrice), "effectivePrice")
479
- .compile(db.printer())
592
+ ```ts
593
+ const sub = db
594
+ .selectFrom("users")
595
+ .select("id", "name")
596
+ .where(({ age }) => age.gt(18))
480
597
 
481
- db.selectFrom("products")
482
- .selectExpr(least(col.price, col.maxPrice), "cappedPrice")
483
- .compile(db.printer())
598
+ db.selectFromSubquery(sub, "adults").selectAll().toSQL()
599
+ // SELECT * FROM (SELECT ...) AS "adults"
484
600
  ```
485
601
 
486
- ### Date/Time Functions
602
+ ### IN Subquery
487
603
 
488
604
  ```ts
489
- import { now, currentTimestamp } from "sumak"
605
+ const deptIds = db.selectFrom("departments").select("id").build()
490
606
 
491
- db.selectFrom("users").selectExpr(now(), "currentTime").compile(db.printer())
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(({ active }) => active.eq(true))
506
-
507
- // UNION
508
- active.union(premium).compile(db.printer())
509
-
510
- // UNION ALL
511
- active.unionAll(premium).compile(db.printer())
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
- // SELECT with CTE
526
- db.selectFrom("users")
527
- .with(
528
- "active_users",
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
- // DELETE with CTE
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("users").with("tree", recursiveQuery, true).compile(db.printer())
647
+ db.selectFrom("categories").with("tree", recursiveQuery, true).toSQL()
553
648
  ```
554
649
 
555
- ## UPDATE FROM
650
+ ---
556
651
 
557
- ```ts
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
- ## Conditional Query Building
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
- .compile(db.printer())
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
- // Multiple .where() calls are AND'd together
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
- .where(({ age }) => age.gt(18))
583
- .where(({ active }) => active.eq(true))
584
- .compile(db.printer())
585
- // WHERE ("age" > $1) AND ("active" = $2)
682
+ .orderBy("name")
683
+ .clearOrderBy() // removes ORDER BY
684
+ .orderBy("id", "DESC") // re-add different order
685
+ .toSQL()
586
686
  ```
587
687
 
588
- ## Reusable Query Fragments
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
- // $call pipe builder through a function
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
- db.selectFrom("users")
596
- .select("id", "name")
597
- .$call(withActiveFilter)
598
- .$call(withPagination)
599
- .compile(db.printer())
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
- // selectExprs — multiple aliased expressions at once
711
+ // In queries
602
712
  db.selectFrom("users")
603
- .selectExprs({
604
- total: count(),
605
- greeting: val("hello"),
606
- })
607
- .compile(db.printer())
713
+ .selectExpr(sql`CURRENT_DATE`, "today")
714
+ .toSQL()
608
715
  ```
609
716
 
610
- ## INSERT Advanced
717
+ ### `rawExpr()` escape hatch
611
718
 
612
719
  ```ts
613
- // INSERT ... SELECT
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
- // SQLite: INSERT OR IGNORE / INSERT OR REPLACE
621
- db.insertInto("users").values({ name: "Alice" }).orIgnore().compile(db.printer())
622
- // INSERT OR IGNORE INTO "users" ...
722
+ // In WHERE
723
+ db.selectFrom("users")
724
+ .where(() => rawExpr<boolean>("age > 18"))
725
+ .toSQL()
623
726
 
624
- db.insertInto("users").values({ name: "Alice" }).orReplace().compile(db.printer())
625
- // INSERT OR REPLACE INTO "users" ...
727
+ // In SELECT
728
+ db.selectFrom("users").selectExpr(rawExpr<number>("EXTRACT(YEAR FROM created_at)"), "year").toSQL()
626
729
  ```
627
730
 
628
- ## ON CONFLICT
731
+ ---
732
+
733
+ ## ON CONFLICT / Upsert
629
734
 
630
735
  ```ts
631
- // DO NOTHING (by columns)
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
- .compile(db.printer())
740
+ .toSQL()
636
741
 
637
- // DO UPDATE (by columns)
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("Alice") }])
641
- .compile(db.printer())
745
+ .onConflictDoUpdate(["email"], [{ column: "name", value: val("Updated") }])
746
+ .toSQL()
642
747
 
643
- // DO NOTHING (by constraint name)
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
- .compile(db.printer())
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
- .compile(db.printer())
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
- name: "Alice",
664
- email: "alice@example.com",
665
- })
666
- .compile(db.printer())
774
+ .whenNotMatchedThenInsert({ name: "Alice", email: "a@b.com" })
775
+ .toSQL()
667
776
 
668
- // MERGE with conditional delete
777
+ // Conditional delete
669
778
  db.mergeInto("users", "staging", "s", ({ target, source }) => target.id.eqCol(source.id))
670
779
  .whenMatchedThenDelete()
671
- .compile(db.printer())
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").forUpdate().compile(db.printer())
679
-
680
- // FOR SHARE
681
- db.selectFrom("users").select("id").forShare().compile(db.printer())
682
-
683
- // FOR NO KEY UPDATE / FOR KEY SHARE (PG)
684
- db.selectFrom("users").select("id").forNoKeyUpdate().compile(db.printer())
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
- ## DISTINCT ON (PG)
798
+ ---
693
799
 
694
- ```ts
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
- // PG: DELETE ... USING
708
- db.deleteFrom("orders")
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
- // MySQL: DELETE with JOIN
714
- db.deleteFrom("orders")
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
- // MySQL: UPDATE with JOIN
720
- db.update("orders")
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
- ## Lateral JOIN
813
+ ---
727
814
 
728
- ```ts
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
- db.selectFrom("users").innerJoinLateral(recentPosts, "rp", onExpr).compile(db.printer())
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
- // LEFT JOIN LATERAL
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
- import { tuple, val } from "sumak"
747
-
748
- // Row-value comparison: (id, age) = (1, 25)
749
- db.selectFrom("users")
750
- .selectExpr(tuple(val(1), val(2), val(3)), "triple")
751
- .compile(db.printer())
752
- // (1, 2, 3)
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
- ## SQL Template Literal
847
+ ### ALTER TABLE
756
848
 
757
849
  ```ts
758
- import { sql, val } from "sumak"
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
- // Tagged template with auto-parameterization
761
- sql`SELECT * FROM users WHERE name = ${"Alice"}`
762
- // params: ["Alice"]
866
+ ### CREATE INDEX
763
867
 
764
- // Inline Expression values
765
- sql`SELECT * FROM users WHERE active = ${val(true)}`
766
- // → SELECT * FROM users WHERE active = TRUE
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
- // Helpers
769
- sql`SELECT ${sql.ref("id")} FROM ${sql.table("users", "public")}`
770
- // → SELECT "id" FROM "public"."users"
892
+ ### CREATE VIEW
771
893
 
772
- // Use in selectExpr
773
- db.selectFrom("users")
774
- .selectExpr(sql`CURRENT_DATE`, "today")
775
- .compile(db.printer())
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
- ## Aggregate FILTER (WHERE)
900
+ ### DROP
779
901
 
780
902
  ```ts
781
- import { filter, count, sum } from "sumak"
782
-
783
- // COUNT(*) FILTER (WHERE active = true)
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
- ## EXPLAIN
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
- // EXPLAIN
791
- db.selectFrom("users").select("id").explain().compile(db.printer())
792
- // EXPLAIN SELECT "id" FROM "users"
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
- // EXPLAIN ANALYZE
795
- db.selectFrom("users").select("id").explain({ analyze: true }).compile(db.printer())
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
- // EXPLAIN with format
798
- db.selectFrom("users").select("id").explain({ format: "JSON" }).compile(db.printer())
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 FTS — same API, different SQL per dialect:
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
- .compile(db.printer())
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
- ## Temporal Tables (SQL:2011)
960
+ ---
825
961
 
826
- Query historical data with `FOR SYSTEM_TIME`:
962
+ ## Temporal Tables (SQL:2011)
827
963
 
828
964
  ```ts
829
- // AS OF — point-in-time query
965
+ // Point-in-time query
830
966
  db.selectFrom("users")
831
- .forSystemTime({
832
- kind: "as_of",
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
- // BETWEEN — time range
970
+ // Time range
838
971
  db.selectFrom("users")
839
- .forSystemTime({
840
- kind: "between",
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
- ```ts
857
- import { sumak } from "sumak"
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
- ### MSSQL Specifics
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
- // DELETE RETURNING → OUTPUT DELETED.*
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
- ## Why sumak?
1023
+ ---
1024
+
1025
+ ## Dialects
952
1026
 
953
- | | sumak | Drizzle | Kysely |
954
- | ------------------ | ----------------- | --------------- | -------------- |
955
- | **Architecture** | AST-first | Template | AST (98 nodes) |
956
- | **Type inference** | Auto (no codegen) | Auto | Manual DB type |
957
- | **Plugin system** | Hooks + plugins | None | Plugins only |
958
- | **SQL printer** | Wadler algebra | Template concat | String append |
959
- | **Dependencies** | 0 | 0 | 0 |
960
- | **Node types** | ~35 (focused) | Config objects | 98 (complex) |
961
- | **API style** | Callback proxy | Method chain | Method chain |
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
- Schema → Builder → AST → Plugin/Hook → Printer → SQL
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
- - **Schema Layer** — `defineTable()`, `ColumnType<S,I,U>`, auto type inference
970
- - **Builder Layer** — `Sumak<DB>`, `TypedSelectBuilder<DB,TB,O>`, proxy-based expressions
971
- - **AST Layer** ~35 frozen node types, discriminated unions, visitor pattern
972
- - **Plugin Layer** — `SumakPlugin` interface, `Hookable` lifecycle hooks
973
- - **Printer Layer** — `BasePrinter` with 4 dialect subclasses (PG, MySQL, SQLite, MSSQL), Wadler document algebra
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