sumak 0.0.7 → 0.0.9
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 +302 -126
- package/dist/builder/eb.d.mts +0 -1
- package/dist/builder/eb.mjs +1 -5
- package/dist/builder/typed-delete.d.mts +2 -0
- package/dist/builder/typed-delete.mjs +15 -2
- package/dist/builder/typed-insert.d.mts +3 -6
- package/dist/builder/typed-insert.mjs +34 -29
- package/dist/builder/typed-merge.d.mts +1 -2
- package/dist/builder/typed-merge.mjs +9 -22
- package/dist/builder/typed-select.d.mts +24 -2
- package/dist/builder/typed-select.mjs +107 -52
- package/dist/builder/typed-update.d.mts +3 -2
- package/dist/builder/typed-update.mjs +26 -18
- package/dist/index.d.mts +6 -1
- package/dist/index.mjs +6 -1
- package/dist/plugin/audit-timestamp.d.mts +31 -0
- package/dist/plugin/audit-timestamp.mjs +54 -0
- package/dist/plugin/data-masking.d.mts +31 -0
- package/dist/plugin/data-masking.mjs +49 -0
- package/dist/plugin/multi-tenant.d.mts +37 -0
- package/dist/plugin/multi-tenant.mjs +66 -0
- package/dist/plugin/optimistic-lock.d.mts +38 -0
- package/dist/plugin/optimistic-lock.mjs +35 -0
- package/dist/plugin/query-limit.d.mts +20 -0
- package/dist/plugin/query-limit.mjs +23 -0
- package/dist/plugin/soft-delete.d.mts +13 -4
- package/dist/plugin/soft-delete.mjs +21 -4
- package/dist/sumak.d.mts +6 -0
- package/dist/sumak.mjs +24 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -89,11 +89,11 @@ That's it. `db` now knows every table, column, and type. All queries are fully t
|
|
|
89
89
|
|
|
90
90
|
```ts
|
|
91
91
|
// Basic select
|
|
92
|
-
db.selectFrom("users").select("id", "name").
|
|
92
|
+
db.selectFrom("users").select("id", "name").toSQL()
|
|
93
93
|
// SELECT "id", "name" FROM "users"
|
|
94
94
|
|
|
95
95
|
// Select all columns
|
|
96
|
-
db.selectFrom("users").selectAll().
|
|
96
|
+
db.selectFrom("users").selectAll().toSQL()
|
|
97
97
|
|
|
98
98
|
// With WHERE, ORDER BY, LIMIT, OFFSET
|
|
99
99
|
db.selectFrom("users")
|
|
@@ -102,10 +102,10 @@ db.selectFrom("users")
|
|
|
102
102
|
.orderBy("name")
|
|
103
103
|
.limit(10)
|
|
104
104
|
.offset(20)
|
|
105
|
-
.
|
|
105
|
+
.toSQL()
|
|
106
106
|
|
|
107
107
|
// DISTINCT
|
|
108
|
-
db.selectFrom("users").select("name").distinct().
|
|
108
|
+
db.selectFrom("users").select("name").distinct().toSQL()
|
|
109
109
|
|
|
110
110
|
// DISTINCT ON (PostgreSQL)
|
|
111
111
|
db.selectFrom("users")
|
|
@@ -113,7 +113,7 @@ db.selectFrom("users")
|
|
|
113
113
|
.distinctOn("dept")
|
|
114
114
|
.orderBy("dept")
|
|
115
115
|
.orderBy("salary", "DESC")
|
|
116
|
-
.
|
|
116
|
+
.toSQL()
|
|
117
117
|
```
|
|
118
118
|
|
|
119
119
|
---
|
|
@@ -122,7 +122,7 @@ db.selectFrom("users")
|
|
|
122
122
|
|
|
123
123
|
```ts
|
|
124
124
|
// Single row
|
|
125
|
-
db.insertInto("users").values({ name: "Alice", email: "alice@example.com" }).
|
|
125
|
+
db.insertInto("users").values({ name: "Alice", email: "alice@example.com" }).toSQL()
|
|
126
126
|
|
|
127
127
|
// Multiple rows
|
|
128
128
|
db.insertInto("users")
|
|
@@ -130,23 +130,20 @@ db.insertInto("users")
|
|
|
130
130
|
{ name: "Alice", email: "a@b.com" },
|
|
131
131
|
{ name: "Bob", email: "b@b.com" },
|
|
132
132
|
])
|
|
133
|
-
.
|
|
133
|
+
.toSQL()
|
|
134
134
|
|
|
135
135
|
// RETURNING
|
|
136
|
-
db.insertInto("users")
|
|
137
|
-
.values({ name: "Alice", email: "a@b.com" })
|
|
138
|
-
.returningAll()
|
|
139
|
-
.compile(db.printer())
|
|
136
|
+
db.insertInto("users").values({ name: "Alice", email: "a@b.com" }).returningAll().toSQL()
|
|
140
137
|
|
|
141
138
|
// INSERT ... SELECT
|
|
142
139
|
const source = db.selectFrom("users").select("name", "email").build()
|
|
143
|
-
db.insertInto("archive").fromSelect(source).
|
|
140
|
+
db.insertInto("archive").fromSelect(source).toSQL()
|
|
144
141
|
|
|
145
142
|
// DEFAULT VALUES
|
|
146
|
-
db.insertInto("users").defaultValues().
|
|
143
|
+
db.insertInto("users").defaultValues().toSQL()
|
|
147
144
|
|
|
148
145
|
// SQLite: INSERT OR IGNORE / INSERT OR REPLACE
|
|
149
|
-
db.insertInto("users").values({ name: "Alice" }).orIgnore().
|
|
146
|
+
db.insertInto("users").values({ name: "Alice" }).orIgnore().toSQL()
|
|
150
147
|
```
|
|
151
148
|
|
|
152
149
|
---
|
|
@@ -158,33 +155,33 @@ db.insertInto("users").values({ name: "Alice" }).orIgnore().compile(db.printer()
|
|
|
158
155
|
db.update("users")
|
|
159
156
|
.set({ active: false })
|
|
160
157
|
.where(({ id }) => id.eq(1))
|
|
161
|
-
.
|
|
158
|
+
.toSQL()
|
|
162
159
|
|
|
163
160
|
// SET with expression
|
|
164
161
|
db.update("users")
|
|
165
162
|
.setExpr("name", val("Anonymous"))
|
|
166
163
|
.where(({ active }) => active.eq(false))
|
|
167
|
-
.
|
|
164
|
+
.toSQL()
|
|
168
165
|
|
|
169
166
|
// UPDATE ... FROM (PostgreSQL)
|
|
170
167
|
db.update("users")
|
|
171
168
|
.set({ name: "Bob" })
|
|
172
169
|
.from("posts")
|
|
173
170
|
.where(({ id }) => id.eq(1))
|
|
174
|
-
.
|
|
171
|
+
.toSQL()
|
|
175
172
|
|
|
176
173
|
// UPDATE with JOIN (MySQL)
|
|
177
|
-
db.update("orders").set({ total: 0 }).innerJoin("users", onExpr).
|
|
174
|
+
db.update("orders").set({ total: 0 }).innerJoin("users", onExpr).toSQL()
|
|
178
175
|
|
|
179
176
|
// RETURNING
|
|
180
177
|
db.update("users")
|
|
181
178
|
.set({ active: false })
|
|
182
179
|
.where(({ id }) => id.eq(1))
|
|
183
180
|
.returningAll()
|
|
184
|
-
.
|
|
181
|
+
.toSQL()
|
|
185
182
|
|
|
186
183
|
// ORDER BY + LIMIT (MySQL)
|
|
187
|
-
db.update("users").set({ active: false }).orderBy("id").limit(lit(10)).
|
|
184
|
+
db.update("users").set({ active: false }).orderBy("id").limit(lit(10)).toSQL()
|
|
188
185
|
```
|
|
189
186
|
|
|
190
187
|
---
|
|
@@ -194,22 +191,22 @@ db.update("users").set({ active: false }).orderBy("id").limit(lit(10)).compile(d
|
|
|
194
191
|
```ts
|
|
195
192
|
db.deleteFrom("users")
|
|
196
193
|
.where(({ id }) => id.eq(1))
|
|
197
|
-
.
|
|
194
|
+
.toSQL()
|
|
198
195
|
|
|
199
196
|
// RETURNING
|
|
200
197
|
db.deleteFrom("users")
|
|
201
198
|
.where(({ id }) => id.eq(1))
|
|
202
199
|
.returning("id")
|
|
203
|
-
.
|
|
200
|
+
.toSQL()
|
|
204
201
|
|
|
205
202
|
// DELETE ... USING (PostgreSQL)
|
|
206
|
-
db.deleteFrom("orders").using("users").where(onExpr).
|
|
203
|
+
db.deleteFrom("orders").using("users").where(onExpr).toSQL()
|
|
207
204
|
|
|
208
205
|
// DELETE with JOIN (MySQL)
|
|
209
206
|
db.deleteFrom("orders")
|
|
210
207
|
.innerJoin("users", onExpr)
|
|
211
208
|
.where(({ id }) => id.eq(1))
|
|
212
|
-
.
|
|
209
|
+
.toSQL()
|
|
213
210
|
```
|
|
214
211
|
|
|
215
212
|
---
|
|
@@ -304,7 +301,7 @@ db.selectFrom("users")
|
|
|
304
301
|
.select("id")
|
|
305
302
|
.where(({ age }) => age.gt(18))
|
|
306
303
|
.where(({ active }) => active.eq(true))
|
|
307
|
-
.
|
|
304
|
+
.toSQL()
|
|
308
305
|
// WHERE ("age" > $1) AND ("active" = $2)
|
|
309
306
|
```
|
|
310
307
|
|
|
@@ -328,32 +325,32 @@ db.selectFrom("users")
|
|
|
328
325
|
db.selectFrom("users")
|
|
329
326
|
.innerJoin("posts", ({ users, posts }) => users.id.eqCol(posts.userId))
|
|
330
327
|
.select("id", "title")
|
|
331
|
-
.
|
|
328
|
+
.toSQL()
|
|
332
329
|
|
|
333
330
|
// LEFT JOIN — joined columns become nullable
|
|
334
331
|
db.selectFrom("users")
|
|
335
332
|
.leftJoin("posts", ({ users, posts }) => users.id.eqCol(posts.userId))
|
|
336
|
-
.
|
|
333
|
+
.toSQL()
|
|
337
334
|
|
|
338
335
|
// RIGHT JOIN
|
|
339
336
|
db.selectFrom("users")
|
|
340
337
|
.rightJoin("posts", ({ users, posts }) => users.id.eqCol(posts.userId))
|
|
341
|
-
.
|
|
338
|
+
.toSQL()
|
|
342
339
|
|
|
343
340
|
// FULL JOIN — both sides nullable
|
|
344
341
|
db.selectFrom("users")
|
|
345
342
|
.fullJoin("posts", ({ users, posts }) => users.id.eqCol(posts.userId))
|
|
346
|
-
.
|
|
343
|
+
.toSQL()
|
|
347
344
|
|
|
348
345
|
// CROSS JOIN
|
|
349
|
-
db.selectFrom("users").crossJoin("posts").
|
|
346
|
+
db.selectFrom("users").crossJoin("posts").toSQL()
|
|
350
347
|
|
|
351
348
|
// LATERAL JOINs (correlated subqueries)
|
|
352
|
-
db.selectFrom("users").innerJoinLateral(subquery, "recent_posts", onExpr).
|
|
349
|
+
db.selectFrom("users").innerJoinLateral(subquery, "recent_posts", onExpr).toSQL()
|
|
353
350
|
|
|
354
|
-
db.selectFrom("users").leftJoinLateral(subquery, "recent_posts", onExpr).
|
|
351
|
+
db.selectFrom("users").leftJoinLateral(subquery, "recent_posts", onExpr).toSQL()
|
|
355
352
|
|
|
356
|
-
db.selectFrom("users").crossJoinLateral(subquery, "latest").
|
|
353
|
+
db.selectFrom("users").crossJoinLateral(subquery, "latest").toSQL()
|
|
357
354
|
```
|
|
358
355
|
|
|
359
356
|
---
|
|
@@ -366,7 +363,7 @@ db.selectFrom("users").crossJoinLateral(subquery, "latest").compile(db.printer()
|
|
|
366
363
|
import { val, cast, rawExpr } from "sumak"
|
|
367
364
|
|
|
368
365
|
// Add a computed column with alias
|
|
369
|
-
db.selectFrom("users").selectExpr(val("hello"), "greeting").
|
|
366
|
+
db.selectFrom("users").selectExpr(val("hello"), "greeting").toSQL()
|
|
370
367
|
|
|
371
368
|
// Multiple expressions at once
|
|
372
369
|
db.selectFrom("users")
|
|
@@ -374,12 +371,12 @@ db.selectFrom("users")
|
|
|
374
371
|
total: count(),
|
|
375
372
|
greeting: val("hello"),
|
|
376
373
|
})
|
|
377
|
-
.
|
|
374
|
+
.toSQL()
|
|
378
375
|
|
|
379
376
|
// CAST
|
|
380
377
|
db.selectFrom("users")
|
|
381
378
|
.selectExpr(cast(val(42), "text"), "idAsText")
|
|
382
|
-
.
|
|
379
|
+
.toSQL()
|
|
383
380
|
```
|
|
384
381
|
|
|
385
382
|
### Arithmetic
|
|
@@ -387,12 +384,12 @@ db.selectFrom("users")
|
|
|
387
384
|
```ts
|
|
388
385
|
import { add, sub, mul, div, mod, neg } from "sumak"
|
|
389
386
|
|
|
390
|
-
db.selectFrom("orders").selectExpr(mul(col.price, col.qty), "total").
|
|
387
|
+
db.selectFrom("orders").selectExpr(mul(col.price, col.qty), "total").toSQL()
|
|
391
388
|
// ("price" * "qty") AS "total"
|
|
392
389
|
|
|
393
390
|
db.selectFrom("orders")
|
|
394
391
|
.selectExpr(add(col.price, val(10)), "adjusted")
|
|
395
|
-
.
|
|
392
|
+
.toSQL()
|
|
396
393
|
```
|
|
397
394
|
|
|
398
395
|
### CASE / WHEN
|
|
@@ -409,7 +406,7 @@ db.selectFrom("users")
|
|
|
409
406
|
.end(),
|
|
410
407
|
"status",
|
|
411
408
|
)
|
|
412
|
-
.
|
|
409
|
+
.toSQL()
|
|
413
410
|
```
|
|
414
411
|
|
|
415
412
|
### JSON Operations
|
|
@@ -420,15 +417,15 @@ import { jsonRef, jsonAgg, toJson, jsonBuildObject } from "sumak"
|
|
|
420
417
|
// Access: -> (JSON object), ->> (text value)
|
|
421
418
|
db.selectFrom("users")
|
|
422
419
|
.selectExpr(jsonRef(col.meta, "name", "->>"), "metaName")
|
|
423
|
-
.
|
|
420
|
+
.toSQL()
|
|
424
421
|
|
|
425
422
|
// JSON_AGG / TO_JSON
|
|
426
|
-
db.selectFrom("users").selectExpr(jsonAgg(col.name), "namesJson").
|
|
423
|
+
db.selectFrom("users").selectExpr(jsonAgg(col.name), "namesJson").toSQL()
|
|
427
424
|
|
|
428
425
|
// JSON_BUILD_OBJECT
|
|
429
426
|
db.selectFrom("users")
|
|
430
427
|
.selectExpr(jsonBuildObject(["name", col.name], ["age", col.age]), "obj")
|
|
431
|
-
.
|
|
428
|
+
.toSQL()
|
|
432
429
|
```
|
|
433
430
|
|
|
434
431
|
### PostgreSQL Array Operators
|
|
@@ -448,15 +445,15 @@ import { arrayContains, arrayContainedBy, arrayOverlaps, rawExpr } from "sumak"
|
|
|
448
445
|
```ts
|
|
449
446
|
import { count, countDistinct, sum, sumDistinct, avg, avgDistinct, min, max, coalesce } from "sumak"
|
|
450
447
|
|
|
451
|
-
db.selectFrom("users").selectExpr(count(), "total").
|
|
452
|
-
db.selectFrom("users").selectExpr(countDistinct(col.dept), "uniqueDepts").
|
|
453
|
-
db.selectFrom("orders").selectExpr(sumDistinct(col.amount), "uniqueSum").
|
|
454
|
-
db.selectFrom("orders").selectExpr(avg(col.amount), "avgAmount").
|
|
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()
|
|
455
452
|
|
|
456
453
|
// COALESCE (variadic)
|
|
457
454
|
db.selectFrom("users")
|
|
458
455
|
.selectExpr(coalesce(col.nick, col.name, val("Anonymous")), "displayName")
|
|
459
|
-
.
|
|
456
|
+
.toSQL()
|
|
460
457
|
```
|
|
461
458
|
|
|
462
459
|
### Aggregate with FILTER (PostgreSQL)
|
|
@@ -464,7 +461,7 @@ db.selectFrom("users")
|
|
|
464
461
|
```ts
|
|
465
462
|
import { filter, count } from "sumak"
|
|
466
463
|
|
|
467
|
-
db.selectFrom("users").selectExpr(filter(count(), activeExpr), "activeCount").
|
|
464
|
+
db.selectFrom("users").selectExpr(filter(count(), activeExpr), "activeCount").toSQL()
|
|
468
465
|
// COUNT(*) FILTER (WHERE ...)
|
|
469
466
|
```
|
|
470
467
|
|
|
@@ -476,11 +473,11 @@ import { stringAgg, arrayAgg } from "sumak"
|
|
|
476
473
|
// STRING_AGG with ORDER BY
|
|
477
474
|
db.selectFrom("users")
|
|
478
475
|
.selectExpr(stringAgg(col.name, ", ", [{ expr: col.name, direction: "ASC" }]), "names")
|
|
479
|
-
.
|
|
476
|
+
.toSQL()
|
|
480
477
|
// STRING_AGG("name", ', ' ORDER BY "name" ASC)
|
|
481
478
|
|
|
482
479
|
// ARRAY_AGG
|
|
483
|
-
db.selectFrom("users").selectExpr(arrayAgg(col.id), "ids").
|
|
480
|
+
db.selectFrom("users").selectExpr(arrayAgg(col.id), "ids").toSQL()
|
|
484
481
|
```
|
|
485
482
|
|
|
486
483
|
---
|
|
@@ -496,7 +493,7 @@ db.selectFrom("employees")
|
|
|
496
493
|
over(rowNumber(), (w) => w.partitionBy("dept").orderBy("salary", "DESC")),
|
|
497
494
|
"rn",
|
|
498
495
|
)
|
|
499
|
-
.
|
|
496
|
+
.toSQL()
|
|
500
497
|
|
|
501
498
|
// RANK / DENSE_RANK
|
|
502
499
|
over(rank(), (w) => w.orderBy("score", "DESC"))
|
|
@@ -587,7 +584,7 @@ db.selectFrom("users")
|
|
|
587
584
|
.build(),
|
|
588
585
|
),
|
|
589
586
|
)
|
|
590
|
-
.
|
|
587
|
+
.toSQL()
|
|
591
588
|
```
|
|
592
589
|
|
|
593
590
|
### Derived Tables (Subquery in FROM)
|
|
@@ -598,7 +595,7 @@ const sub = db
|
|
|
598
595
|
.select("id", "name")
|
|
599
596
|
.where(({ age }) => age.gt(18))
|
|
600
597
|
|
|
601
|
-
db.selectFromSubquery(sub, "adults").selectAll().
|
|
598
|
+
db.selectFromSubquery(sub, "adults").selectAll().toSQL()
|
|
602
599
|
// SELECT * FROM (SELECT ...) AS "adults"
|
|
603
600
|
```
|
|
604
601
|
|
|
@@ -609,7 +606,7 @@ const deptIds = db.selectFrom("departments").select("id").build()
|
|
|
609
606
|
|
|
610
607
|
db.selectFrom("users")
|
|
611
608
|
.where(({ dept_id }) => dept_id.inSubquery(deptIds))
|
|
612
|
-
.
|
|
609
|
+
.toSQL()
|
|
613
610
|
```
|
|
614
611
|
|
|
615
612
|
---
|
|
@@ -626,12 +623,12 @@ const premium = db
|
|
|
626
623
|
.select("id")
|
|
627
624
|
.where(({ tier }) => tier.eq("premium"))
|
|
628
625
|
|
|
629
|
-
active.union(premium).
|
|
630
|
-
active.unionAll(premium).
|
|
631
|
-
active.intersect(premium).
|
|
632
|
-
active.intersectAll(premium).
|
|
633
|
-
active.except(premium).
|
|
634
|
-
active.exceptAll(premium).
|
|
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
|
|
635
632
|
```
|
|
636
633
|
|
|
637
634
|
---
|
|
@@ -644,10 +641,10 @@ const activeCte = db
|
|
|
644
641
|
.where(({ active }) => active.eq(true))
|
|
645
642
|
.build()
|
|
646
643
|
|
|
647
|
-
db.selectFrom("users").with("active_users", activeCte).
|
|
644
|
+
db.selectFrom("users").with("active_users", activeCte).toSQL()
|
|
648
645
|
|
|
649
646
|
// Recursive CTE
|
|
650
|
-
db.selectFrom("categories").with("tree", recursiveQuery, true).
|
|
647
|
+
db.selectFrom("categories").with("tree", recursiveQuery, true).toSQL()
|
|
651
648
|
```
|
|
652
649
|
|
|
653
650
|
---
|
|
@@ -664,7 +661,7 @@ db.selectFrom("users")
|
|
|
664
661
|
.select("id", "name")
|
|
665
662
|
.$if(withFilter, (qb) => qb.where(({ age }) => age.gt(18)))
|
|
666
663
|
.$if(withOrder, (qb) => qb.orderBy("name"))
|
|
667
|
-
.
|
|
664
|
+
.toSQL()
|
|
668
665
|
// WHERE applied, ORDER BY skipped
|
|
669
666
|
```
|
|
670
667
|
|
|
@@ -674,11 +671,7 @@ db.selectFrom("users")
|
|
|
674
671
|
const withPagination = (qb) => qb.limit(10).offset(20)
|
|
675
672
|
const onlyActive = (qb) => qb.where(({ active }) => active.eq(true))
|
|
676
673
|
|
|
677
|
-
db.selectFrom("users")
|
|
678
|
-
.select("id", "name")
|
|
679
|
-
.$call(onlyActive)
|
|
680
|
-
.$call(withPagination)
|
|
681
|
-
.compile(db.printer())
|
|
674
|
+
db.selectFrom("users").select("id", "name").$call(onlyActive).$call(withPagination).toSQL()
|
|
682
675
|
```
|
|
683
676
|
|
|
684
677
|
### `clear*()` — reset clauses
|
|
@@ -689,13 +682,45 @@ db.selectFrom("users")
|
|
|
689
682
|
.orderBy("name")
|
|
690
683
|
.clearOrderBy() // removes ORDER BY
|
|
691
684
|
.orderBy("id", "DESC") // re-add different order
|
|
692
|
-
.
|
|
685
|
+
.toSQL()
|
|
693
686
|
```
|
|
694
687
|
|
|
695
688
|
Available: `clearWhere()`, `clearOrderBy()`, `clearLimit()`, `clearOffset()`, `clearGroupBy()`, `clearHaving()`, `clearSelect()`.
|
|
696
689
|
|
|
697
690
|
---
|
|
698
691
|
|
|
692
|
+
## Cursor Pagination
|
|
693
|
+
|
|
694
|
+
```ts
|
|
695
|
+
// Forward pagination (after cursor)
|
|
696
|
+
db.selectFrom("users")
|
|
697
|
+
.select("id", "name")
|
|
698
|
+
.cursorPaginate({ column: "id", after: 42, pageSize: 20 })
|
|
699
|
+
.toSQL()
|
|
700
|
+
// SELECT "id", "name" FROM "users" WHERE ("id" > $1) ORDER BY "id" ASC LIMIT 21
|
|
701
|
+
// params: [42] — pageSize + 1 for hasNextPage detection
|
|
702
|
+
|
|
703
|
+
// Backward pagination (before cursor)
|
|
704
|
+
db.selectFrom("users")
|
|
705
|
+
.select("id", "name")
|
|
706
|
+
.cursorPaginate({ column: "id", before: 100, pageSize: 20 })
|
|
707
|
+
.toSQL()
|
|
708
|
+
// WHERE ("id" < $1) ORDER BY "id" DESC LIMIT 21
|
|
709
|
+
|
|
710
|
+
// First page (no cursor)
|
|
711
|
+
db.selectFrom("users").select("id", "name").cursorPaginate({ column: "id", pageSize: 20 }).toSQL()
|
|
712
|
+
// LIMIT 21
|
|
713
|
+
|
|
714
|
+
// With existing WHERE — ANDs together
|
|
715
|
+
db.selectFrom("users")
|
|
716
|
+
.select("id", "name")
|
|
717
|
+
.where(({ active }) => active.eq(true))
|
|
718
|
+
.cursorPaginate({ column: "id", after: lastId, pageSize: 20 })
|
|
719
|
+
.toSQL()
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
---
|
|
723
|
+
|
|
699
724
|
## Raw SQL
|
|
700
725
|
|
|
701
726
|
### `sql` tagged template
|
|
@@ -718,7 +743,7 @@ sql`SELECT ${sql.ref("id")} FROM ${sql.table("users", "public")}`
|
|
|
718
743
|
// In queries
|
|
719
744
|
db.selectFrom("users")
|
|
720
745
|
.selectExpr(sql`CURRENT_DATE`, "today")
|
|
721
|
-
.
|
|
746
|
+
.toSQL()
|
|
722
747
|
```
|
|
723
748
|
|
|
724
749
|
### `rawExpr()` escape hatch
|
|
@@ -729,12 +754,10 @@ import { rawExpr } from "sumak"
|
|
|
729
754
|
// In WHERE
|
|
730
755
|
db.selectFrom("users")
|
|
731
756
|
.where(() => rawExpr<boolean>("age > 18"))
|
|
732
|
-
.
|
|
757
|
+
.toSQL()
|
|
733
758
|
|
|
734
759
|
// In SELECT
|
|
735
|
-
db.selectFrom("users")
|
|
736
|
-
.selectExpr(rawExpr<number>("EXTRACT(YEAR FROM created_at)"), "year")
|
|
737
|
-
.compile(db.printer())
|
|
760
|
+
db.selectFrom("users").selectExpr(rawExpr<number>("EXTRACT(YEAR FROM created_at)"), "year").toSQL()
|
|
738
761
|
```
|
|
739
762
|
|
|
740
763
|
---
|
|
@@ -746,31 +769,31 @@ db.selectFrom("users")
|
|
|
746
769
|
db.insertInto("users")
|
|
747
770
|
.values({ name: "Alice", email: "a@b.com" })
|
|
748
771
|
.onConflictDoNothing("email")
|
|
749
|
-
.
|
|
772
|
+
.toSQL()
|
|
750
773
|
|
|
751
774
|
// ON CONFLICT DO UPDATE (with Expression)
|
|
752
775
|
db.insertInto("users")
|
|
753
776
|
.values({ name: "Alice", email: "a@b.com" })
|
|
754
777
|
.onConflictDoUpdate(["email"], [{ column: "name", value: val("Updated") }])
|
|
755
|
-
.
|
|
778
|
+
.toSQL()
|
|
756
779
|
|
|
757
780
|
// ON CONFLICT DO UPDATE (with plain object — auto-parameterized)
|
|
758
781
|
db.insertInto("users")
|
|
759
782
|
.values({ name: "Alice", email: "a@b.com" })
|
|
760
783
|
.onConflictDoUpdateSet(["email"], { name: "Alice Updated" })
|
|
761
|
-
.
|
|
784
|
+
.toSQL()
|
|
762
785
|
|
|
763
786
|
// ON CONFLICT ON CONSTRAINT
|
|
764
787
|
db.insertInto("users")
|
|
765
788
|
.values({ name: "Alice", email: "a@b.com" })
|
|
766
789
|
.onConflictConstraintDoNothing("users_email_key")
|
|
767
|
-
.
|
|
790
|
+
.toSQL()
|
|
768
791
|
|
|
769
792
|
// MySQL: ON DUPLICATE KEY UPDATE
|
|
770
793
|
db.insertInto("users")
|
|
771
794
|
.values({ name: "Alice" })
|
|
772
795
|
.onDuplicateKeyUpdate([{ column: "name", value: val("Alice") }])
|
|
773
|
-
.
|
|
796
|
+
.toSQL()
|
|
774
797
|
```
|
|
775
798
|
|
|
776
799
|
---
|
|
@@ -781,12 +804,12 @@ db.insertInto("users")
|
|
|
781
804
|
db.mergeInto("users", "staging", "s", ({ target, source }) => target.id.eqCol(source.id))
|
|
782
805
|
.whenMatchedThenUpdate({ name: "updated" })
|
|
783
806
|
.whenNotMatchedThenInsert({ name: "Alice", email: "a@b.com" })
|
|
784
|
-
.
|
|
807
|
+
.toSQL()
|
|
785
808
|
|
|
786
809
|
// Conditional delete
|
|
787
810
|
db.mergeInto("users", "staging", "s", ({ target, source }) => target.id.eqCol(source.id))
|
|
788
811
|
.whenMatchedThenDelete()
|
|
789
|
-
.
|
|
812
|
+
.toSQL()
|
|
790
813
|
```
|
|
791
814
|
|
|
792
815
|
---
|
|
@@ -794,14 +817,14 @@ db.mergeInto("users", "staging", "s", ({ target, source }) => target.id.eqCol(so
|
|
|
794
817
|
## Row Locking
|
|
795
818
|
|
|
796
819
|
```ts
|
|
797
|
-
db.selectFrom("users").select("id").forUpdate().
|
|
798
|
-
db.selectFrom("users").select("id").forShare().
|
|
799
|
-
db.selectFrom("users").select("id").forNoKeyUpdate().
|
|
800
|
-
db.selectFrom("users").select("id").forKeyShare().
|
|
820
|
+
db.selectFrom("users").select("id").forUpdate().toSQL() // FOR UPDATE
|
|
821
|
+
db.selectFrom("users").select("id").forShare().toSQL() // FOR SHARE
|
|
822
|
+
db.selectFrom("users").select("id").forNoKeyUpdate().toSQL() // FOR NO KEY UPDATE (PG)
|
|
823
|
+
db.selectFrom("users").select("id").forKeyShare().toSQL() // FOR KEY SHARE (PG)
|
|
801
824
|
|
|
802
825
|
// Modifiers
|
|
803
|
-
db.selectFrom("users").select("id").forUpdate().skipLocked().
|
|
804
|
-
db.selectFrom("users").select("id").forUpdate().noWait().
|
|
826
|
+
db.selectFrom("users").select("id").forUpdate().skipLocked().toSQL() // SKIP LOCKED
|
|
827
|
+
db.selectFrom("users").select("id").forUpdate().noWait().toSQL() // NOWAIT
|
|
805
828
|
```
|
|
806
829
|
|
|
807
830
|
---
|
|
@@ -809,13 +832,13 @@ db.selectFrom("users").select("id").forUpdate().noWait().compile(db.printer()) /
|
|
|
809
832
|
## EXPLAIN
|
|
810
833
|
|
|
811
834
|
```ts
|
|
812
|
-
db.selectFrom("users").select("id").explain().
|
|
835
|
+
db.selectFrom("users").select("id").explain().toSQL()
|
|
813
836
|
// EXPLAIN SELECT "id" FROM "users"
|
|
814
837
|
|
|
815
|
-
db.selectFrom("users").select("id").explain({ analyze: true }).
|
|
838
|
+
db.selectFrom("users").select("id").explain({ analyze: true }).toSQL()
|
|
816
839
|
// EXPLAIN ANALYZE SELECT ...
|
|
817
840
|
|
|
818
|
-
db.selectFrom("users").select("id").explain({ format: "JSON" }).
|
|
841
|
+
db.selectFrom("users").select("id").explain({ format: "JSON" }).toSQL()
|
|
819
842
|
// EXPLAIN (FORMAT JSON) SELECT ...
|
|
820
843
|
```
|
|
821
844
|
|
|
@@ -959,7 +982,7 @@ import { textSearch, val } from "sumak"
|
|
|
959
982
|
// PostgreSQL: to_tsvector("name") @@ to_tsquery('alice')
|
|
960
983
|
db.selectFrom("users")
|
|
961
984
|
.where(({ name }) => textSearch([name.toExpr()], val("alice")))
|
|
962
|
-
.
|
|
985
|
+
.toSQL()
|
|
963
986
|
|
|
964
987
|
// MySQL: MATCH(`name`) AGAINST(? IN BOOLEAN MODE)
|
|
965
988
|
// SQLite: ("name" MATCH ?)
|
|
@@ -974,15 +997,15 @@ db.selectFrom("users")
|
|
|
974
997
|
// Point-in-time query
|
|
975
998
|
db.selectFrom("users")
|
|
976
999
|
.forSystemTime({ kind: "as_of", timestamp: lit("2024-01-01") })
|
|
977
|
-
.
|
|
1000
|
+
.toSQL()
|
|
978
1001
|
|
|
979
1002
|
// Time range
|
|
980
1003
|
db.selectFrom("users")
|
|
981
1004
|
.forSystemTime({ kind: "between", start: lit("2024-01-01"), end: lit("2024-12-31") })
|
|
982
|
-
.
|
|
1005
|
+
.toSQL()
|
|
983
1006
|
|
|
984
1007
|
// Full history
|
|
985
|
-
db.selectFrom("users").forSystemTime({ kind: "all" }).
|
|
1008
|
+
db.selectFrom("users").forSystemTime({ kind: "all" }).toSQL()
|
|
986
1009
|
```
|
|
987
1010
|
|
|
988
1011
|
Modes: `as_of`, `from_to`, `between`, `contained_in`, `all`.
|
|
@@ -991,14 +1014,145 @@ Modes: `as_of`, `from_to`, `between`, `contained_in`, `all`.
|
|
|
991
1014
|
|
|
992
1015
|
## Plugins
|
|
993
1016
|
|
|
1017
|
+
### WithSchemaPlugin
|
|
1018
|
+
|
|
1019
|
+
```ts
|
|
1020
|
+
const db = sumak({
|
|
1021
|
+
plugins: [new WithSchemaPlugin("public")],
|
|
1022
|
+
...
|
|
1023
|
+
})
|
|
1024
|
+
// SELECT * FROM "public"."users"
|
|
1025
|
+
```
|
|
1026
|
+
|
|
1027
|
+
### SoftDeletePlugin
|
|
1028
|
+
|
|
1029
|
+
```ts
|
|
1030
|
+
// Mode "convert" (default) — DELETE becomes UPDATE SET deleted_at = NOW()
|
|
1031
|
+
const db = sumak({
|
|
1032
|
+
plugins: [new SoftDeletePlugin({ tables: ["users"], mode: "convert" })],
|
|
1033
|
+
...
|
|
1034
|
+
})
|
|
1035
|
+
|
|
1036
|
+
db.deleteFrom("users").where(({ id }) => id.eq(1)).toSQL()
|
|
1037
|
+
// UPDATE "users" SET "deleted_at" = NOW() WHERE ("id" = $1) AND ("deleted_at" IS NULL)
|
|
1038
|
+
|
|
1039
|
+
// Mode "filter" — just adds WHERE deleted_at IS NULL (no DELETE conversion)
|
|
1040
|
+
new SoftDeletePlugin({ tables: ["users"], mode: "filter" })
|
|
1041
|
+
```
|
|
1042
|
+
|
|
1043
|
+
### AuditTimestampPlugin
|
|
1044
|
+
|
|
1045
|
+
```ts
|
|
1046
|
+
// Auto-inject created_at/updated_at timestamps
|
|
1047
|
+
const db = sumak({
|
|
1048
|
+
plugins: [new AuditTimestampPlugin({ tables: ["users"] })],
|
|
1049
|
+
...
|
|
1050
|
+
})
|
|
1051
|
+
|
|
1052
|
+
db.insertInto("users").values({ name: "Alice" }).toSQL()
|
|
1053
|
+
// INSERT INTO "users" ("name", "created_at", "updated_at") VALUES ($1, NOW(), NOW())
|
|
1054
|
+
|
|
1055
|
+
db.update("users").set({ name: "Bob" }).where(({ id }) => id.eq(1)).toSQL()
|
|
1056
|
+
// UPDATE "users" SET "name" = $1, "updated_at" = NOW() WHERE ...
|
|
1057
|
+
```
|
|
1058
|
+
|
|
1059
|
+
### MultiTenantPlugin
|
|
1060
|
+
|
|
1061
|
+
```ts
|
|
1062
|
+
// Auto-inject tenant_id on all queries
|
|
1063
|
+
// Use a callback for per-request tenant resolution:
|
|
1064
|
+
const db = sumak({
|
|
1065
|
+
plugins: [
|
|
1066
|
+
new MultiTenantPlugin({
|
|
1067
|
+
tables: ["users", "posts"],
|
|
1068
|
+
tenantId: () => getCurrentTenantId(), // called per query
|
|
1069
|
+
}),
|
|
1070
|
+
],
|
|
1071
|
+
...
|
|
1072
|
+
})
|
|
1073
|
+
|
|
1074
|
+
db.selectFrom("users").select("id").toSQL()
|
|
1075
|
+
// SELECT "id" FROM "users" WHERE ("tenant_id" = $1)
|
|
1076
|
+
|
|
1077
|
+
db.insertInto("users").values({ name: "Alice" }).toSQL()
|
|
1078
|
+
// INSERT INTO "users" ("name", "tenant_id") VALUES ($1, $2)
|
|
1079
|
+
```
|
|
1080
|
+
|
|
1081
|
+
### QueryLimitPlugin
|
|
1082
|
+
|
|
1083
|
+
```ts
|
|
1084
|
+
// Auto-inject LIMIT on unbounded SELECTs
|
|
1085
|
+
const db = sumak({
|
|
1086
|
+
plugins: [new QueryLimitPlugin({ maxRows: 1000 })],
|
|
1087
|
+
...
|
|
1088
|
+
})
|
|
1089
|
+
|
|
1090
|
+
db.selectFrom("users").select("id").toSQL()
|
|
1091
|
+
// SELECT "id" FROM "users" LIMIT 1000
|
|
1092
|
+
|
|
1093
|
+
db.selectFrom("users").select("id").limit(5).toSQL()
|
|
1094
|
+
// SELECT "id" FROM "users" LIMIT 5 — explicit limit preserved
|
|
1095
|
+
```
|
|
1096
|
+
|
|
1097
|
+
### CamelCasePlugin
|
|
1098
|
+
|
|
1099
|
+
```ts
|
|
1100
|
+
// Transform snake_case result columns to camelCase
|
|
1101
|
+
const db = sumak({
|
|
1102
|
+
plugins: [new CamelCasePlugin()],
|
|
1103
|
+
...
|
|
1104
|
+
})
|
|
1105
|
+
```
|
|
1106
|
+
|
|
1107
|
+
### OptimisticLockPlugin
|
|
1108
|
+
|
|
1109
|
+
```ts
|
|
1110
|
+
// Auto-inject WHERE version = N and SET version = version + 1 on UPDATE
|
|
1111
|
+
// Use a callback for per-row version:
|
|
1112
|
+
let rowVersion = 3
|
|
1113
|
+
const db = sumak({
|
|
1114
|
+
plugins: [
|
|
1115
|
+
new OptimisticLockPlugin({
|
|
1116
|
+
tables: ["users"],
|
|
1117
|
+
currentVersion: () => rowVersion, // called per query
|
|
1118
|
+
}),
|
|
1119
|
+
],
|
|
1120
|
+
...
|
|
1121
|
+
})
|
|
1122
|
+
|
|
1123
|
+
rowVersion = fetchedRow.version // set before each update
|
|
1124
|
+
db.update("users").set({ name: "Bob" }).where(({ id }) => id.eq(1)).toSQL()
|
|
1125
|
+
// UPDATE "users" SET "name" = $1, "version" = ("version" + 1)
|
|
1126
|
+
// WHERE ("id" = $2) AND ("version" = $3)
|
|
1127
|
+
```
|
|
1128
|
+
|
|
1129
|
+
### DataMaskingPlugin
|
|
1130
|
+
|
|
994
1131
|
```ts
|
|
995
|
-
|
|
1132
|
+
// Mask sensitive data in query results
|
|
1133
|
+
const plugin = new DataMaskingPlugin({
|
|
1134
|
+
rules: [
|
|
1135
|
+
{ column: "email", mask: "email" }, // "alice@example.com" → "al***@example.com"
|
|
1136
|
+
{ column: "phone", mask: "phone" }, // "+1234567890" → "***7890"
|
|
1137
|
+
{ column: "name", mask: "partial" }, // "John Doe" → "Jo***"
|
|
1138
|
+
{ column: "ssn", mask: (v) => `***-**-${String(v).slice(-4)}` }, // custom
|
|
1139
|
+
],
|
|
1140
|
+
})
|
|
1141
|
+
|
|
1142
|
+
const db = sumak({ plugins: [plugin], ... })
|
|
1143
|
+
```
|
|
1144
|
+
|
|
1145
|
+
### Combining Plugins
|
|
996
1146
|
|
|
1147
|
+
```ts
|
|
997
1148
|
const db = sumak({
|
|
998
1149
|
dialect: pgDialect(),
|
|
999
1150
|
plugins: [
|
|
1000
|
-
new WithSchemaPlugin("public"),
|
|
1001
|
-
new SoftDeletePlugin({ tables: ["users"] }),
|
|
1151
|
+
new WithSchemaPlugin("public"),
|
|
1152
|
+
new SoftDeletePlugin({ tables: ["users"] }),
|
|
1153
|
+
new AuditTimestampPlugin({ tables: ["users", "posts"] }),
|
|
1154
|
+
new MultiTenantPlugin({ tables: ["users", "posts"], tenantId: () => currentTenantId }),
|
|
1155
|
+
new QueryLimitPlugin({ maxRows: 5000 }),
|
|
1002
1156
|
],
|
|
1003
1157
|
tables: { ... },
|
|
1004
1158
|
})
|
|
@@ -1063,36 +1217,58 @@ import { serial, text } from "sumak/schema"
|
|
|
1063
1217
|
|
|
1064
1218
|
## Architecture
|
|
1065
1219
|
|
|
1220
|
+
sumak uses a 5-layer pipeline. Your code never touches SQL strings — everything flows through an AST.
|
|
1221
|
+
|
|
1222
|
+
```
|
|
1223
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
1224
|
+
│ 1. SCHEMA │
|
|
1225
|
+
│ sumak({ dialect, tables: { users: { id: serial(), ... } } })│
|
|
1226
|
+
│ → DB type auto-inferred, zero codegen │
|
|
1227
|
+
├─────────────────────────────────────────────────────────────────┤
|
|
1228
|
+
│ 2. BUILDER │
|
|
1229
|
+
│ db.selectFrom("users").select("id").where(...) │
|
|
1230
|
+
│ → Immutable, chainable, fully type-checked │
|
|
1231
|
+
├─────────────────────────────────────────────────────────────────┤
|
|
1232
|
+
│ 3. AST │
|
|
1233
|
+
│ .build() → SelectNode (frozen, discriminated union) │
|
|
1234
|
+
│ → ~40 node types, Object.freeze on all outputs │
|
|
1235
|
+
├─────────────────────────────────────────────────────────────────┤
|
|
1236
|
+
│ 4. PLUGIN / HOOK │
|
|
1237
|
+
│ Plugin.transformNode() → Hook "query:before" │
|
|
1238
|
+
│ → AST rewriting, tenant isolation, soft delete, logging │
|
|
1239
|
+
├─────────────────────────────────────────────────────────────────┤
|
|
1240
|
+
│ 5. PRINTER │
|
|
1241
|
+
│ .toSQL() → { sql: "SELECT ...", params: [...] } │
|
|
1242
|
+
│ → Dialect-specific: PG ($1), MySQL (?), MSSQL (@p0) │
|
|
1243
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
1066
1244
|
```
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
**
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
| **DDL support** | Full (schema builder) | drizzle-kit | Full |
|
|
1095
|
-
| **Dependencies** | 0 | 0 | 0 |
|
|
1245
|
+
|
|
1246
|
+
### Why AST-first?
|
|
1247
|
+
|
|
1248
|
+
The query is never a string until the very last step. This means:
|
|
1249
|
+
|
|
1250
|
+
- **Plugins can rewrite queries** — add WHERE clauses, prefix schemas, transform joins
|
|
1251
|
+
- **Hooks can inspect/modify** — logging, tracing, tenant isolation
|
|
1252
|
+
- **Printers are swappable** — same AST, different SQL per dialect
|
|
1253
|
+
- **No SQL injection** — values are always parameterized
|
|
1254
|
+
|
|
1255
|
+
### Key design decisions
|
|
1256
|
+
|
|
1257
|
+
- **Params at print time** — no global state, no index tracking during build
|
|
1258
|
+
- **Immutable builders** — every method returns a new instance
|
|
1259
|
+
- **Proxy-based column access** — `({ age }) => age.gt(18)` with full type safety
|
|
1260
|
+
- **Phantom types** — `Expression<T>` carries type info with zero runtime cost
|
|
1261
|
+
|
|
1262
|
+
---
|
|
1263
|
+
|
|
1264
|
+
## Acknowledgments
|
|
1265
|
+
|
|
1266
|
+
sumak wouldn't exist without the incredible work of these projects:
|
|
1267
|
+
|
|
1268
|
+
- **[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.
|
|
1269
|
+
- **[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.
|
|
1270
|
+
- **[JOOQ](https://github.com/jOOQ/jOOQ)** — The original AST-first SQL builder (Java). Showed that a clean AST layer makes multi-dialect support elegant.
|
|
1271
|
+
- **[SQLAlchemy](https://github.com/sqlalchemy/sqlalchemy)** — Demonstrated that separating the expression layer from the ORM layer gives maximum flexibility.
|
|
1096
1272
|
|
|
1097
1273
|
---
|
|
1098
1274
|
|