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 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").compile(db.printer())
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().compile(db.printer())
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
- .compile(db.printer())
105
+ .toSQL()
106
106
 
107
107
  // DISTINCT
108
- db.selectFrom("users").select("name").distinct().compile(db.printer())
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
- .compile(db.printer())
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" }).compile(db.printer())
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
- .compile(db.printer())
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).compile(db.printer())
140
+ db.insertInto("archive").fromSelect(source).toSQL()
144
141
 
145
142
  // DEFAULT VALUES
146
- db.insertInto("users").defaultValues().compile(db.printer())
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().compile(db.printer())
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
- .compile(db.printer())
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
- .compile(db.printer())
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
- .compile(db.printer())
171
+ .toSQL()
175
172
 
176
173
  // UPDATE with JOIN (MySQL)
177
- db.update("orders").set({ total: 0 }).innerJoin("users", onExpr).compile(db.printer())
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
- .compile(db.printer())
181
+ .toSQL()
185
182
 
186
183
  // ORDER BY + LIMIT (MySQL)
187
- db.update("users").set({ active: false }).orderBy("id").limit(lit(10)).compile(db.printer())
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
- .compile(db.printer())
194
+ .toSQL()
198
195
 
199
196
  // RETURNING
200
197
  db.deleteFrom("users")
201
198
  .where(({ id }) => id.eq(1))
202
199
  .returning("id")
203
- .compile(db.printer())
200
+ .toSQL()
204
201
 
205
202
  // DELETE ... USING (PostgreSQL)
206
- db.deleteFrom("orders").using("users").where(onExpr).compile(db.printer())
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
- .compile(db.printer())
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
- .compile(db.printer())
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
- .compile(db.printer())
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
- .compile(db.printer())
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
- .compile(db.printer())
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
- .compile(db.printer())
343
+ .toSQL()
347
344
 
348
345
  // CROSS JOIN
349
- db.selectFrom("users").crossJoin("posts").compile(db.printer())
346
+ db.selectFrom("users").crossJoin("posts").toSQL()
350
347
 
351
348
  // LATERAL JOINs (correlated subqueries)
352
- db.selectFrom("users").innerJoinLateral(subquery, "recent_posts", onExpr).compile(db.printer())
349
+ db.selectFrom("users").innerJoinLateral(subquery, "recent_posts", onExpr).toSQL()
353
350
 
354
- db.selectFrom("users").leftJoinLateral(subquery, "recent_posts", onExpr).compile(db.printer())
351
+ db.selectFrom("users").leftJoinLateral(subquery, "recent_posts", onExpr).toSQL()
355
352
 
356
- db.selectFrom("users").crossJoinLateral(subquery, "latest").compile(db.printer())
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").compile(db.printer())
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
- .compile(db.printer())
374
+ .toSQL()
378
375
 
379
376
  // CAST
380
377
  db.selectFrom("users")
381
378
  .selectExpr(cast(val(42), "text"), "idAsText")
382
- .compile(db.printer())
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").compile(db.printer())
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
- .compile(db.printer())
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
- .compile(db.printer())
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
- .compile(db.printer())
420
+ .toSQL()
424
421
 
425
422
  // JSON_AGG / TO_JSON
426
- db.selectFrom("users").selectExpr(jsonAgg(col.name), "namesJson").compile(db.printer())
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
- .compile(db.printer())
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").compile(db.printer())
452
- db.selectFrom("users").selectExpr(countDistinct(col.dept), "uniqueDepts").compile(db.printer())
453
- db.selectFrom("orders").selectExpr(sumDistinct(col.amount), "uniqueSum").compile(db.printer())
454
- db.selectFrom("orders").selectExpr(avg(col.amount), "avgAmount").compile(db.printer())
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
- .compile(db.printer())
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").compile(db.printer())
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
- .compile(db.printer())
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").compile(db.printer())
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
- .compile(db.printer())
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
- .compile(db.printer())
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().compile(db.printer())
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
- .compile(db.printer())
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).compile(db.printer()) // UNION
630
- active.unionAll(premium).compile(db.printer()) // UNION ALL
631
- active.intersect(premium).compile(db.printer()) // INTERSECT
632
- active.intersectAll(premium).compile(db.printer()) // INTERSECT ALL
633
- active.except(premium).compile(db.printer()) // EXCEPT
634
- active.exceptAll(premium).compile(db.printer()) // EXCEPT ALL
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).compile(db.printer())
644
+ db.selectFrom("users").with("active_users", activeCte).toSQL()
648
645
 
649
646
  // Recursive CTE
650
- db.selectFrom("categories").with("tree", recursiveQuery, true).compile(db.printer())
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
- .compile(db.printer())
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
- .compile(db.printer())
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
- .compile(db.printer())
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
- .compile(db.printer())
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
- .compile(db.printer())
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
- .compile(db.printer())
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
- .compile(db.printer())
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
- .compile(db.printer())
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
- .compile(db.printer())
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
- .compile(db.printer())
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
- .compile(db.printer())
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().compile(db.printer()) // FOR UPDATE
798
- db.selectFrom("users").select("id").forShare().compile(db.printer()) // FOR SHARE
799
- db.selectFrom("users").select("id").forNoKeyUpdate().compile(db.printer()) // FOR NO KEY UPDATE (PG)
800
- db.selectFrom("users").select("id").forKeyShare().compile(db.printer()) // FOR KEY SHARE (PG)
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().compile(db.printer()) // SKIP LOCKED
804
- db.selectFrom("users").select("id").forUpdate().noWait().compile(db.printer()) // 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().compile(db.printer())
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 }).compile(db.printer())
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" }).compile(db.printer())
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
- .compile(db.printer())
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
- .compile(db.printer())
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
- .compile(db.printer())
1005
+ .toSQL()
983
1006
 
984
1007
  // Full history
985
- db.selectFrom("users").forSystemTime({ kind: "all" }).compile(db.printer())
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
- import { WithSchemaPlugin, SoftDeletePlugin, CamelCasePlugin } from "sumak"
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"), // auto "public"."users"
1001
- new SoftDeletePlugin({ tables: ["users"] }), // auto WHERE deleted_at IS NULL
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
- User Code
1068
-
1069
- ├── sumak({ dialect, tables }) ← DB type inferred
1070
-
1071
- ├── db.selectFrom("users") ← TypedSelectBuilder<DB, "users", O>
1072
- │ .select("id", "name") ← O narrows to Pick<O, "id"|"name">
1073
- │ .where(({ age }) => age.gt(18))
1074
- │ .build() ← SelectNode (frozen AST)
1075
-
1076
- ├── db.compile(node) ← Plugin → Hooks → Printer
1077
-
1078
- └── { sql, params } ← Parameterized output
1079
- ```
1080
-
1081
- **5 layers:**
1082
-
1083
- - **Schema** — `defineTable()`, `ColumnType<S,I,U>`, auto type inference
1084
- - **Builder** — `TypedSelectBuilder<DB,TB,O>`, proxy-based expressions
1085
- - **AST** — Frozen node types, discriminated unions, visitor pattern
1086
- - **Plugin/Hook** — `SumakPlugin`, `Hookable` lifecycle hooks
1087
- - **Printer** — `BasePrinter` + 4 dialect subclasses, Wadler document algebra
1088
-
1089
- | | sumak | Drizzle | Kysely |
1090
- | ------------------ | --------------------- | ----------- | -------------- |
1091
- | **Architecture** | AST-first | Template | AST (98 nodes) |
1092
- | **Type inference** | Auto (no codegen) | Auto | Manual DB type |
1093
- | **Plugin system** | Hooks + plugins | None | Plugins only |
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