sumak 0.0.8 → 0.0.10
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 +315 -5
- package/dist/builder/json-optics.d.mts +106 -0
- package/dist/builder/json-optics.mjs +110 -0
- package/dist/builder/typed-delete.mjs +4 -0
- package/dist/builder/typed-insert.d.mts +1 -1
- package/dist/builder/typed-insert.mjs +4 -0
- package/dist/builder/typed-select.d.mts +20 -1
- package/dist/builder/typed-select.mjs +70 -21
- package/dist/builder/typed-update.mjs +4 -0
- package/dist/index.d.mts +12 -0
- package/dist/index.mjs +13 -0
- package/dist/normalize/expression.d.mts +26 -0
- package/dist/normalize/expression.mjs +330 -0
- package/dist/normalize/index.d.mts +4 -0
- package/dist/normalize/index.mjs +3 -0
- package/dist/normalize/query.d.mts +14 -0
- package/dist/normalize/query.mjs +126 -0
- package/dist/normalize/types.d.mts +38 -0
- package/dist/normalize/types.mjs +7 -0
- package/dist/optimize/index.d.mts +3 -0
- package/dist/optimize/index.mjs +2 -0
- package/dist/optimize/optimizer.d.mts +28 -0
- package/dist/optimize/optimizer.mjs +37 -0
- package/dist/optimize/rules.d.mts +31 -0
- package/dist/optimize/rules.mjs +161 -0
- package/dist/optimize/types.d.mts +29 -0
- package/dist/optimize/types.mjs +1 -0
- 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 +27 -3
- package/dist/sumak.mjs +41 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -41,6 +41,9 @@
|
|
|
41
41
|
- [Schema Builder (DDL)](#schema-builder-ddl)
|
|
42
42
|
- [Full-Text Search](#full-text-search)
|
|
43
43
|
- [Temporal Tables](#temporal-tables-sql2011)
|
|
44
|
+
- [JSON Optics](#json-optics)
|
|
45
|
+
- [Compiled Queries](#compiled-queries)
|
|
46
|
+
- [Query Optimization](#query-optimization)
|
|
44
47
|
- [Plugins](#plugins)
|
|
45
48
|
- [Hooks](#hooks)
|
|
46
49
|
- [Dialects](#dialects)
|
|
@@ -428,6 +431,8 @@ db.selectFrom("users")
|
|
|
428
431
|
.toSQL()
|
|
429
432
|
```
|
|
430
433
|
|
|
434
|
+
> For composable, type-tracked JSON navigation, see [JSON Optics](#json-optics).
|
|
435
|
+
|
|
431
436
|
### PostgreSQL Array Operators
|
|
432
437
|
|
|
433
438
|
```ts
|
|
@@ -689,6 +694,38 @@ Available: `clearWhere()`, `clearOrderBy()`, `clearLimit()`, `clearOffset()`, `c
|
|
|
689
694
|
|
|
690
695
|
---
|
|
691
696
|
|
|
697
|
+
## Cursor Pagination
|
|
698
|
+
|
|
699
|
+
```ts
|
|
700
|
+
// Forward pagination (after cursor)
|
|
701
|
+
db.selectFrom("users")
|
|
702
|
+
.select("id", "name")
|
|
703
|
+
.cursorPaginate({ column: "id", after: 42, pageSize: 20 })
|
|
704
|
+
.toSQL()
|
|
705
|
+
// SELECT "id", "name" FROM "users" WHERE ("id" > $1) ORDER BY "id" ASC LIMIT 21
|
|
706
|
+
// params: [42] — pageSize + 1 for hasNextPage detection
|
|
707
|
+
|
|
708
|
+
// Backward pagination (before cursor)
|
|
709
|
+
db.selectFrom("users")
|
|
710
|
+
.select("id", "name")
|
|
711
|
+
.cursorPaginate({ column: "id", before: 100, pageSize: 20 })
|
|
712
|
+
.toSQL()
|
|
713
|
+
// WHERE ("id" < $1) ORDER BY "id" DESC LIMIT 21
|
|
714
|
+
|
|
715
|
+
// First page (no cursor)
|
|
716
|
+
db.selectFrom("users").select("id", "name").cursorPaginate({ column: "id", pageSize: 20 }).toSQL()
|
|
717
|
+
// LIMIT 21
|
|
718
|
+
|
|
719
|
+
// With existing WHERE — ANDs together
|
|
720
|
+
db.selectFrom("users")
|
|
721
|
+
.select("id", "name")
|
|
722
|
+
.where(({ active }) => active.eq(true))
|
|
723
|
+
.cursorPaginate({ column: "id", after: lastId, pageSize: 20 })
|
|
724
|
+
.toSQL()
|
|
725
|
+
```
|
|
726
|
+
|
|
727
|
+
---
|
|
728
|
+
|
|
692
729
|
## Raw SQL
|
|
693
730
|
|
|
694
731
|
### `sql` tagged template
|
|
@@ -980,16 +1017,277 @@ Modes: `as_of`, `from_to`, `between`, `contained_in`, `all`.
|
|
|
980
1017
|
|
|
981
1018
|
---
|
|
982
1019
|
|
|
1020
|
+
## JSON Optics
|
|
1021
|
+
|
|
1022
|
+
Composable, type-tracked JSON column navigation. Each `.at()` step tracks the type at that level.
|
|
1023
|
+
|
|
1024
|
+
```ts
|
|
1025
|
+
import { jsonCol } from "sumak"
|
|
1026
|
+
|
|
1027
|
+
// Navigate into JSON: -> (returns JSON)
|
|
1028
|
+
db.selectFrom("users")
|
|
1029
|
+
.selectExprs(jsonCol("data").at("address").at("city").asText().as("city"))
|
|
1030
|
+
.toSQL()
|
|
1031
|
+
// SELECT "data"->'address'->>'city' AS "city" FROM "users"
|
|
1032
|
+
|
|
1033
|
+
// Text extraction: ->> (returns text)
|
|
1034
|
+
db.selectFrom("users").selectExprs(jsonCol("meta").text("name").as("metaName")).toSQL()
|
|
1035
|
+
// SELECT "meta"->>'name' AS "metaName" FROM "users"
|
|
1036
|
+
|
|
1037
|
+
// PG path operators: #> and #>>
|
|
1038
|
+
jsonCol("data").atPath("address.city") // #> (returns JSON)
|
|
1039
|
+
jsonCol("data").textPath("address.city") // #>> (returns text)
|
|
1040
|
+
|
|
1041
|
+
// With table prefix
|
|
1042
|
+
jsonCol("data", "users").at("settings").asText()
|
|
1043
|
+
```
|
|
1044
|
+
|
|
1045
|
+
Type-safe with generics:
|
|
1046
|
+
|
|
1047
|
+
```ts
|
|
1048
|
+
interface UserProfile {
|
|
1049
|
+
address: { city: string; zip: string }
|
|
1050
|
+
preferences: { theme: string }
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// Type narrows at each level
|
|
1054
|
+
jsonCol<UserProfile>("profile")
|
|
1055
|
+
.at("address") // JsonOptic<{ city: string; zip: string }>
|
|
1056
|
+
.at("city") // JsonOptic<string>
|
|
1057
|
+
.asText() // JsonExpr<string>
|
|
1058
|
+
```
|
|
1059
|
+
|
|
1060
|
+
---
|
|
1061
|
+
|
|
1062
|
+
## Compiled Queries
|
|
1063
|
+
|
|
1064
|
+
Pre-bake SQL at setup time. At runtime, only fill parameters — zero AST traversal.
|
|
1065
|
+
|
|
1066
|
+
```ts
|
|
1067
|
+
import { placeholder, compileQuery } from "sumak"
|
|
1068
|
+
|
|
1069
|
+
// Define query with named placeholders
|
|
1070
|
+
const findUser = compileQuery<{ userId: number }>(
|
|
1071
|
+
db
|
|
1072
|
+
.selectFrom("users")
|
|
1073
|
+
.select("id", "name")
|
|
1074
|
+
.where(({ id }) => id.eq(placeholder("userId")))
|
|
1075
|
+
.build(),
|
|
1076
|
+
db.printer(),
|
|
1077
|
+
)
|
|
1078
|
+
|
|
1079
|
+
// Runtime — same SQL string, different params:
|
|
1080
|
+
findUser({ userId: 42 })
|
|
1081
|
+
// → { sql: 'SELECT "id", "name" FROM "users" WHERE "id" = $1', params: [42] }
|
|
1082
|
+
|
|
1083
|
+
findUser({ userId: 99 })
|
|
1084
|
+
// → { sql: 'SELECT "id", "name" FROM "users" WHERE "id" = $1', params: [99] }
|
|
1085
|
+
|
|
1086
|
+
// Inspect the pre-baked SQL
|
|
1087
|
+
findUser.sql // 'SELECT "id", "name" FROM "users" WHERE "id" = $1'
|
|
1088
|
+
```
|
|
1089
|
+
|
|
1090
|
+
---
|
|
1091
|
+
|
|
1092
|
+
## Query Optimization
|
|
1093
|
+
|
|
1094
|
+
sumak automatically normalizes and optimizes queries through two new pipeline layers.
|
|
1095
|
+
|
|
1096
|
+
### Normalization (NbE)
|
|
1097
|
+
|
|
1098
|
+
Enabled by default. Reduces expressions to canonical form:
|
|
1099
|
+
|
|
1100
|
+
- **Flatten AND/OR:** `(a AND (b AND c))` → `(a AND b AND c)`
|
|
1101
|
+
- **Deduplicate:** `a = 1 AND b = 2 AND a = 1` → `a = 1 AND b = 2`
|
|
1102
|
+
- **Simplify tautologies:** `x AND true` → `x`, `x OR false` → `x`
|
|
1103
|
+
- **Constant folding:** `1 + 2` → `3`
|
|
1104
|
+
- **Double negation:** `NOT NOT x` → `x`
|
|
1105
|
+
- **Comparison normalization:** `1 = x` → `x = 1`
|
|
1106
|
+
|
|
1107
|
+
### Optimization (Rewrite Rules)
|
|
1108
|
+
|
|
1109
|
+
Built-in rules applied after normalization:
|
|
1110
|
+
|
|
1111
|
+
- **Predicate pushdown:** Moves WHERE conditions into JOIN ON when they reference a single table
|
|
1112
|
+
- **Subquery flattening:** `SELECT * FROM (SELECT * FROM t)` → `SELECT * FROM t`
|
|
1113
|
+
- **WHERE true removal:** Cleans up `WHERE true` left by plugins
|
|
1114
|
+
|
|
1115
|
+
### Configuration
|
|
1116
|
+
|
|
1117
|
+
```ts
|
|
1118
|
+
// Default: both enabled
|
|
1119
|
+
const db = sumak({ dialect: pgDialect(), tables: { ... } })
|
|
1120
|
+
|
|
1121
|
+
// Disable normalization
|
|
1122
|
+
const db = sumak({ dialect: pgDialect(), normalize: false, tables: { ... } })
|
|
1123
|
+
|
|
1124
|
+
// Disable optimization
|
|
1125
|
+
const db = sumak({ dialect: pgDialect(), optimizeQueries: false, tables: { ... } })
|
|
1126
|
+
```
|
|
1127
|
+
|
|
1128
|
+
### Custom Rewrite Rules
|
|
1129
|
+
|
|
1130
|
+
```ts
|
|
1131
|
+
import { createRule } from "sumak"
|
|
1132
|
+
|
|
1133
|
+
const defaultLimit = createRule({
|
|
1134
|
+
name: "default-limit",
|
|
1135
|
+
match: (node) => node.type === "select" && !node.limit,
|
|
1136
|
+
apply: (node) => ({ ...node, limit: { type: "literal", value: 1000 } }),
|
|
1137
|
+
})
|
|
1138
|
+
|
|
1139
|
+
const db = sumak({
|
|
1140
|
+
dialect: pgDialect(),
|
|
1141
|
+
rules: [defaultLimit],
|
|
1142
|
+
tables: { ... },
|
|
1143
|
+
})
|
|
1144
|
+
```
|
|
1145
|
+
|
|
1146
|
+
Rules are applied bottom-up until a fixpoint (no more changes). Max 10 iterations by default.
|
|
1147
|
+
|
|
1148
|
+
---
|
|
1149
|
+
|
|
983
1150
|
## Plugins
|
|
984
1151
|
|
|
1152
|
+
### WithSchemaPlugin
|
|
1153
|
+
|
|
1154
|
+
```ts
|
|
1155
|
+
const db = sumak({
|
|
1156
|
+
plugins: [new WithSchemaPlugin("public")],
|
|
1157
|
+
...
|
|
1158
|
+
})
|
|
1159
|
+
// SELECT * FROM "public"."users"
|
|
1160
|
+
```
|
|
1161
|
+
|
|
1162
|
+
### SoftDeletePlugin
|
|
1163
|
+
|
|
1164
|
+
```ts
|
|
1165
|
+
// Mode "convert" (default) — DELETE becomes UPDATE SET deleted_at = NOW()
|
|
1166
|
+
const db = sumak({
|
|
1167
|
+
plugins: [new SoftDeletePlugin({ tables: ["users"], mode: "convert" })],
|
|
1168
|
+
...
|
|
1169
|
+
})
|
|
1170
|
+
|
|
1171
|
+
db.deleteFrom("users").where(({ id }) => id.eq(1)).toSQL()
|
|
1172
|
+
// UPDATE "users" SET "deleted_at" = NOW() WHERE ("id" = $1) AND ("deleted_at" IS NULL)
|
|
1173
|
+
|
|
1174
|
+
// Mode "filter" — just adds WHERE deleted_at IS NULL (no DELETE conversion)
|
|
1175
|
+
new SoftDeletePlugin({ tables: ["users"], mode: "filter" })
|
|
1176
|
+
```
|
|
1177
|
+
|
|
1178
|
+
### AuditTimestampPlugin
|
|
1179
|
+
|
|
1180
|
+
```ts
|
|
1181
|
+
// Auto-inject created_at/updated_at timestamps
|
|
1182
|
+
const db = sumak({
|
|
1183
|
+
plugins: [new AuditTimestampPlugin({ tables: ["users"] })],
|
|
1184
|
+
...
|
|
1185
|
+
})
|
|
1186
|
+
|
|
1187
|
+
db.insertInto("users").values({ name: "Alice" }).toSQL()
|
|
1188
|
+
// INSERT INTO "users" ("name", "created_at", "updated_at") VALUES ($1, NOW(), NOW())
|
|
1189
|
+
|
|
1190
|
+
db.update("users").set({ name: "Bob" }).where(({ id }) => id.eq(1)).toSQL()
|
|
1191
|
+
// UPDATE "users" SET "name" = $1, "updated_at" = NOW() WHERE ...
|
|
1192
|
+
```
|
|
1193
|
+
|
|
1194
|
+
### MultiTenantPlugin
|
|
1195
|
+
|
|
1196
|
+
```ts
|
|
1197
|
+
// Auto-inject tenant_id on all queries
|
|
1198
|
+
// Use a callback for per-request tenant resolution:
|
|
1199
|
+
const db = sumak({
|
|
1200
|
+
plugins: [
|
|
1201
|
+
new MultiTenantPlugin({
|
|
1202
|
+
tables: ["users", "posts"],
|
|
1203
|
+
tenantId: () => getCurrentTenantId(), // called per query
|
|
1204
|
+
}),
|
|
1205
|
+
],
|
|
1206
|
+
...
|
|
1207
|
+
})
|
|
1208
|
+
|
|
1209
|
+
db.selectFrom("users").select("id").toSQL()
|
|
1210
|
+
// SELECT "id" FROM "users" WHERE ("tenant_id" = $1)
|
|
1211
|
+
|
|
1212
|
+
db.insertInto("users").values({ name: "Alice" }).toSQL()
|
|
1213
|
+
// INSERT INTO "users" ("name", "tenant_id") VALUES ($1, $2)
|
|
1214
|
+
```
|
|
1215
|
+
|
|
1216
|
+
### QueryLimitPlugin
|
|
1217
|
+
|
|
1218
|
+
```ts
|
|
1219
|
+
// Auto-inject LIMIT on unbounded SELECTs
|
|
1220
|
+
const db = sumak({
|
|
1221
|
+
plugins: [new QueryLimitPlugin({ maxRows: 1000 })],
|
|
1222
|
+
...
|
|
1223
|
+
})
|
|
1224
|
+
|
|
1225
|
+
db.selectFrom("users").select("id").toSQL()
|
|
1226
|
+
// SELECT "id" FROM "users" LIMIT 1000
|
|
1227
|
+
|
|
1228
|
+
db.selectFrom("users").select("id").limit(5).toSQL()
|
|
1229
|
+
// SELECT "id" FROM "users" LIMIT 5 — explicit limit preserved
|
|
1230
|
+
```
|
|
1231
|
+
|
|
1232
|
+
### CamelCasePlugin
|
|
1233
|
+
|
|
985
1234
|
```ts
|
|
986
|
-
|
|
1235
|
+
// Transform snake_case result columns to camelCase
|
|
1236
|
+
const db = sumak({
|
|
1237
|
+
plugins: [new CamelCasePlugin()],
|
|
1238
|
+
...
|
|
1239
|
+
})
|
|
1240
|
+
```
|
|
1241
|
+
|
|
1242
|
+
### OptimisticLockPlugin
|
|
987
1243
|
|
|
1244
|
+
```ts
|
|
1245
|
+
// Auto-inject WHERE version = N and SET version = version + 1 on UPDATE
|
|
1246
|
+
// Use a callback for per-row version:
|
|
1247
|
+
let rowVersion = 3
|
|
1248
|
+
const db = sumak({
|
|
1249
|
+
plugins: [
|
|
1250
|
+
new OptimisticLockPlugin({
|
|
1251
|
+
tables: ["users"],
|
|
1252
|
+
currentVersion: () => rowVersion, // called per query
|
|
1253
|
+
}),
|
|
1254
|
+
],
|
|
1255
|
+
...
|
|
1256
|
+
})
|
|
1257
|
+
|
|
1258
|
+
rowVersion = fetchedRow.version // set before each update
|
|
1259
|
+
db.update("users").set({ name: "Bob" }).where(({ id }) => id.eq(1)).toSQL()
|
|
1260
|
+
// UPDATE "users" SET "name" = $1, "version" = ("version" + 1)
|
|
1261
|
+
// WHERE ("id" = $2) AND ("version" = $3)
|
|
1262
|
+
```
|
|
1263
|
+
|
|
1264
|
+
### DataMaskingPlugin
|
|
1265
|
+
|
|
1266
|
+
```ts
|
|
1267
|
+
// Mask sensitive data in query results
|
|
1268
|
+
const plugin = new DataMaskingPlugin({
|
|
1269
|
+
rules: [
|
|
1270
|
+
{ column: "email", mask: "email" }, // "alice@example.com" → "al***@example.com"
|
|
1271
|
+
{ column: "phone", mask: "phone" }, // "+1234567890" → "***7890"
|
|
1272
|
+
{ column: "name", mask: "partial" }, // "John Doe" → "Jo***"
|
|
1273
|
+
{ column: "ssn", mask: (v) => `***-**-${String(v).slice(-4)}` }, // custom
|
|
1274
|
+
],
|
|
1275
|
+
})
|
|
1276
|
+
|
|
1277
|
+
const db = sumak({ plugins: [plugin], ... })
|
|
1278
|
+
```
|
|
1279
|
+
|
|
1280
|
+
### Combining Plugins
|
|
1281
|
+
|
|
1282
|
+
```ts
|
|
988
1283
|
const db = sumak({
|
|
989
1284
|
dialect: pgDialect(),
|
|
990
1285
|
plugins: [
|
|
991
|
-
new WithSchemaPlugin("public"),
|
|
992
|
-
new SoftDeletePlugin({ tables: ["users"] }),
|
|
1286
|
+
new WithSchemaPlugin("public"),
|
|
1287
|
+
new SoftDeletePlugin({ tables: ["users"] }),
|
|
1288
|
+
new AuditTimestampPlugin({ tables: ["users", "posts"] }),
|
|
1289
|
+
new MultiTenantPlugin({ tables: ["users", "posts"], tenantId: () => currentTenantId }),
|
|
1290
|
+
new QueryLimitPlugin({ maxRows: 5000 }),
|
|
993
1291
|
],
|
|
994
1292
|
tables: { ... },
|
|
995
1293
|
})
|
|
@@ -1054,7 +1352,7 @@ import { serial, text } from "sumak/schema"
|
|
|
1054
1352
|
|
|
1055
1353
|
## Architecture
|
|
1056
1354
|
|
|
1057
|
-
sumak uses a
|
|
1355
|
+
sumak uses a 7-layer pipeline. Your code never touches SQL strings — everything flows through an AST.
|
|
1058
1356
|
|
|
1059
1357
|
```
|
|
1060
1358
|
┌─────────────────────────────────────────────────────────────────┐
|
|
@@ -1074,7 +1372,15 @@ sumak uses a 5-layer pipeline. Your code never touches SQL strings — everythin
|
|
|
1074
1372
|
│ Plugin.transformNode() → Hook "query:before" │
|
|
1075
1373
|
│ → AST rewriting, tenant isolation, soft delete, logging │
|
|
1076
1374
|
├─────────────────────────────────────────────────────────────────┤
|
|
1077
|
-
│ 5.
|
|
1375
|
+
│ 5. NORMALIZE (NbE) │
|
|
1376
|
+
│ Predicate simplification, constant folding, deduplication │
|
|
1377
|
+
│ → Canonical form via Normalization by Evaluation │
|
|
1378
|
+
├─────────────────────────────────────────────────────────────────┤
|
|
1379
|
+
│ 6. OPTIMIZE (Rewrite Rules) │
|
|
1380
|
+
│ Predicate pushdown, subquery flattening, user rules │
|
|
1381
|
+
│ → Declarative rules applied to fixpoint │
|
|
1382
|
+
├─────────────────────────────────────────────────────────────────┤
|
|
1383
|
+
│ 7. PRINTER │
|
|
1078
1384
|
│ .toSQL() → { sql: "SELECT ...", params: [...] } │
|
|
1079
1385
|
│ → Dialect-specific: PG ($1), MySQL (?), MSSQL (@p0) │
|
|
1080
1386
|
└─────────────────────────────────────────────────────────────────┘
|
|
@@ -1086,6 +1392,8 @@ The query is never a string until the very last step. This means:
|
|
|
1086
1392
|
|
|
1087
1393
|
- **Plugins can rewrite queries** — add WHERE clauses, prefix schemas, transform joins
|
|
1088
1394
|
- **Hooks can inspect/modify** — logging, tracing, tenant isolation
|
|
1395
|
+
- **Normalize simplifies** — duplicate predicates, tautologies, constant expressions
|
|
1396
|
+
- **Optimize rewrites** — predicate pushdown, subquery flattening, custom rules
|
|
1089
1397
|
- **Printers are swappable** — same AST, different SQL per dialect
|
|
1090
1398
|
- **No SQL injection** — values are always parameterized
|
|
1091
1399
|
|
|
@@ -1095,6 +1403,8 @@ The query is never a string until the very last step. This means:
|
|
|
1095
1403
|
- **Immutable builders** — every method returns a new instance
|
|
1096
1404
|
- **Proxy-based column access** — `({ age }) => age.gt(18)` with full type safety
|
|
1097
1405
|
- **Phantom types** — `Expression<T>` carries type info with zero runtime cost
|
|
1406
|
+
- **NbE normalization** — expressions reduced to canonical form before printing
|
|
1407
|
+
- **Compiled queries** — pre-bake SQL at setup, zero AST walk at runtime
|
|
1098
1408
|
|
|
1099
1409
|
---
|
|
1100
1410
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { ExpressionNode } from "../ast/nodes.mjs";
|
|
2
|
+
import type { Expression } from "../ast/typed-expression.mjs";
|
|
3
|
+
/**
|
|
4
|
+
* JSON optics — composable, type-tracked JSON column navigation.
|
|
5
|
+
*
|
|
6
|
+
* Each `.at()` step creates a new optic that tracks the type at that level.
|
|
7
|
+
* The final expression is a chain of JSON access operators.
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* // Type-tracked navigation:
|
|
11
|
+
* jsonCol<UserProfile>("profile")
|
|
12
|
+
* .at("address") // JsonOptic<Address>
|
|
13
|
+
* .at("city") // JsonOptic<string>
|
|
14
|
+
* .asText() // Expression<string>
|
|
15
|
+
*
|
|
16
|
+
* // Use in SELECT:
|
|
17
|
+
* db.selectFrom("users")
|
|
18
|
+
* .selectExprs(jsonCol("data").at("name").asText().as("name"))
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* **Dialect-aware operators:**
|
|
22
|
+
* - `.at("key")` → `->` (returns JSON)
|
|
23
|
+
* - `.asText()` → `->>` (returns text)
|
|
24
|
+
* - `.atPath("a.b.c")` → `#>` (PG path operator)
|
|
25
|
+
* - `.asTextPath("a.b.c")` → `#>>` (PG text path operator)
|
|
26
|
+
*/
|
|
27
|
+
export declare class JsonOptic<T = unknown> {
|
|
28
|
+
readonly _type: T;
|
|
29
|
+
constructor(node: ExpressionNode);
|
|
30
|
+
/**
|
|
31
|
+
* Navigate into a JSON object key. Returns JSON type.
|
|
32
|
+
*
|
|
33
|
+
* ```ts
|
|
34
|
+
* jsonCol("data").at("address") // → data->'address'
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
at<K extends string>(key: K): JsonOptic<T extends Record<K, infer V> ? V : unknown>;
|
|
38
|
+
/**
|
|
39
|
+
* Navigate into a JSON object key and extract as text.
|
|
40
|
+
*
|
|
41
|
+
* ```ts
|
|
42
|
+
* jsonCol("data").text("name") // → data->>'name' (returns string)
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
text<K extends string>(key: K): JsonExpr<string>;
|
|
46
|
+
/**
|
|
47
|
+
* Navigate by PG JSON path operator `#>`.
|
|
48
|
+
*
|
|
49
|
+
* ```ts
|
|
50
|
+
* jsonCol("data").atPath("address.city") // → data#>'{address,city}'
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
atPath(path: string): JsonOptic<unknown>;
|
|
54
|
+
/**
|
|
55
|
+
* Navigate by PG JSON text path operator `#>>`.
|
|
56
|
+
*
|
|
57
|
+
* ```ts
|
|
58
|
+
* jsonCol("data").textPath("address.city") // → data#>>'{address,city}'
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
textPath(path: string): JsonExpr<string>;
|
|
62
|
+
/**
|
|
63
|
+
* Cast current JSON value to text (`->>`).
|
|
64
|
+
*/
|
|
65
|
+
asText(): JsonExpr<string>;
|
|
66
|
+
/**
|
|
67
|
+
* Get the underlying expression node.
|
|
68
|
+
*/
|
|
69
|
+
toExpression(): Expression<T>;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* A JSON expression that can be aliased and used in SELECT/WHERE.
|
|
73
|
+
* This is the "leaf" of the optics chain.
|
|
74
|
+
*/
|
|
75
|
+
export declare class JsonExpr<T> {
|
|
76
|
+
readonly _type: T;
|
|
77
|
+
constructor(node: ExpressionNode);
|
|
78
|
+
/**
|
|
79
|
+
* Alias this expression for use in SELECT.
|
|
80
|
+
*
|
|
81
|
+
* ```ts
|
|
82
|
+
* jsonCol("data").text("name").as("userName")
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
as(alias: string): Expression<T>;
|
|
86
|
+
/**
|
|
87
|
+
* Get the underlying expression node.
|
|
88
|
+
*/
|
|
89
|
+
toExpression(): Expression<T>;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Create a JSON optic from a column name.
|
|
93
|
+
*
|
|
94
|
+
* ```ts
|
|
95
|
+
* const profileCity = jsonCol<UserProfile>("profile").at("address").at("city").asText()
|
|
96
|
+
* ```
|
|
97
|
+
*/
|
|
98
|
+
export declare function jsonCol<T = unknown>(column: string, table?: string): JsonOptic<T>;
|
|
99
|
+
/**
|
|
100
|
+
* Create a JSON optic from an existing expression.
|
|
101
|
+
*
|
|
102
|
+
* ```ts
|
|
103
|
+
* const optic = jsonExpr<Config>(someExpression).at("settings")
|
|
104
|
+
* ```
|
|
105
|
+
*/
|
|
106
|
+
export declare function jsonExpr<T = unknown>(expr: Expression<any>): JsonOptic<T>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
|
|
2
|
+
export class JsonOptic {
|
|
3
|
+
|
|
4
|
+
_node;
|
|
5
|
+
constructor(node) {
|
|
6
|
+
this._node = node;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
at(key) {
|
|
10
|
+
const node = {
|
|
11
|
+
type: "json_access",
|
|
12
|
+
expr: this._node,
|
|
13
|
+
path: key,
|
|
14
|
+
operator: "->"
|
|
15
|
+
};
|
|
16
|
+
return new JsonOptic(node);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
text(key) {
|
|
20
|
+
const node = {
|
|
21
|
+
type: "json_access",
|
|
22
|
+
expr: this._node,
|
|
23
|
+
path: key,
|
|
24
|
+
operator: "->>"
|
|
25
|
+
};
|
|
26
|
+
return new JsonExpr(node);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
atPath(path) {
|
|
30
|
+
const node = {
|
|
31
|
+
type: "json_access",
|
|
32
|
+
expr: this._node,
|
|
33
|
+
path,
|
|
34
|
+
operator: "#>"
|
|
35
|
+
};
|
|
36
|
+
return new JsonOptic(node);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
textPath(path) {
|
|
40
|
+
const node = {
|
|
41
|
+
type: "json_access",
|
|
42
|
+
expr: this._node,
|
|
43
|
+
path,
|
|
44
|
+
operator: "#>>"
|
|
45
|
+
};
|
|
46
|
+
return new JsonExpr(node);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
asText() {
|
|
50
|
+
|
|
51
|
+
if (this._node.type === "json_access") {
|
|
52
|
+
const ja = this._node;
|
|
53
|
+
if (ja.operator === "->") {
|
|
54
|
+
return new JsonExpr({
|
|
55
|
+
...ja,
|
|
56
|
+
operator: "->>"
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return new JsonExpr({
|
|
62
|
+
type: "cast",
|
|
63
|
+
expr: this._node,
|
|
64
|
+
dataType: "text"
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
toExpression() {
|
|
69
|
+
return this._node;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class JsonExpr {
|
|
74
|
+
|
|
75
|
+
_node;
|
|
76
|
+
constructor(node) {
|
|
77
|
+
this._node = node;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
as(alias) {
|
|
81
|
+
if (this._node.type === "json_access") {
|
|
82
|
+
return {
|
|
83
|
+
...this._node,
|
|
84
|
+
alias
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
type: "aliased_expr",
|
|
89
|
+
expr: this._node,
|
|
90
|
+
alias
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
toExpression() {
|
|
95
|
+
return this._node;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function jsonCol(column, table) {
|
|
100
|
+
const node = {
|
|
101
|
+
type: "column_ref",
|
|
102
|
+
column,
|
|
103
|
+
table
|
|
104
|
+
};
|
|
105
|
+
return new JsonOptic(node);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function jsonExpr(expr) {
|
|
109
|
+
return new JsonOptic(expr._node ?? expr);
|
|
110
|
+
}
|
|
@@ -8,6 +8,8 @@ export class TypedDeleteBuilder {
|
|
|
8
8
|
_builder;
|
|
9
9
|
|
|
10
10
|
_printer;
|
|
11
|
+
|
|
12
|
+
_compile;
|
|
11
13
|
constructor(table) {
|
|
12
14
|
this._builder = new DeleteBuilder().from(table);
|
|
13
15
|
}
|
|
@@ -16,6 +18,7 @@ export class TypedDeleteBuilder {
|
|
|
16
18
|
const t = new TypedDeleteBuilder("");
|
|
17
19
|
t._builder = builder;
|
|
18
20
|
t._printer = this._printer;
|
|
21
|
+
t._compile = this._compile;
|
|
19
22
|
return t;
|
|
20
23
|
}
|
|
21
24
|
|
|
@@ -77,6 +80,7 @@ export class TypedDeleteBuilder {
|
|
|
77
80
|
}
|
|
78
81
|
|
|
79
82
|
toSQL() {
|
|
83
|
+
if (this._compile) return this._compile(this.build());
|
|
80
84
|
if (!this._printer) {
|
|
81
85
|
throw new Error("toSQL() requires a printer. Use db.deleteFrom() or pass a printer to compile().");
|
|
82
86
|
}
|
|
@@ -73,7 +73,7 @@ export declare class TypedInsertBuilder<
|
|
|
73
73
|
$if(condition: boolean, fn: (qb: TypedInsertBuilder<DB, TB>) => TypedInsertBuilder<DB, TB>): TypedInsertBuilder<DB, TB>;
|
|
74
74
|
build(): InsertNode;
|
|
75
75
|
compile(printer: Printer): CompiledQuery;
|
|
76
|
-
/** Compile to SQL using the
|
|
76
|
+
/** Compile to SQL using the full pipeline. */
|
|
77
77
|
toSQL(): CompiledQuery;
|
|
78
78
|
/** EXPLAIN this query. */
|
|
79
79
|
explain(options?: {
|
|
@@ -8,6 +8,8 @@ export class TypedInsertBuilder {
|
|
|
8
8
|
_builder;
|
|
9
9
|
|
|
10
10
|
_printer;
|
|
11
|
+
|
|
12
|
+
_compile;
|
|
11
13
|
constructor(table) {
|
|
12
14
|
this._builder = new InsertBuilder().into(table);
|
|
13
15
|
}
|
|
@@ -16,6 +18,7 @@ export class TypedInsertBuilder {
|
|
|
16
18
|
const t = new TypedInsertBuilder("");
|
|
17
19
|
t._builder = builder;
|
|
18
20
|
t._printer = this._printer;
|
|
21
|
+
t._compile = this._compile;
|
|
19
22
|
return t;
|
|
20
23
|
}
|
|
21
24
|
|
|
@@ -153,6 +156,7 @@ export class TypedInsertBuilder {
|
|
|
153
156
|
}
|
|
154
157
|
|
|
155
158
|
toSQL() {
|
|
159
|
+
if (this._compile) return this._compile(this.build());
|
|
156
160
|
if (!this._printer) {
|
|
157
161
|
throw new Error("toSQL() requires a printer. Use db.insertInto() or pass a printer to compile().");
|
|
158
162
|
}
|
|
@@ -19,7 +19,7 @@ export declare class TypedSelectBuilder<
|
|
|
19
19
|
> {
|
|
20
20
|
private _table;
|
|
21
21
|
private _printer?;
|
|
22
|
-
constructor(builder: SelectBuilder, table?: string, printer?: Printer);
|
|
22
|
+
constructor(builder: SelectBuilder, table?: string, printer?: Printer, compile?: (node: import("../ast/nodes.ts").ASTNode) => CompiledQuery);
|
|
23
23
|
/** Select specific columns. Narrows O. */
|
|
24
24
|
select<K extends keyof O & string>(...cols: K[]): TypedSelectBuilder<DB, TB, Pick<O, K>>;
|
|
25
25
|
/** Select all columns. */
|
|
@@ -165,6 +165,25 @@ export declare class TypedSelectBuilder<
|
|
|
165
165
|
$call<R>(fn: (qb: TypedSelectBuilder<DB, TB, O>) => R): R;
|
|
166
166
|
/** Conditionally apply a transformation. */
|
|
167
167
|
$if<O2>(condition: boolean, fn: (qb: TypedSelectBuilder<DB, TB, O>) => TypedSelectBuilder<DB, TB, O2>): TypedSelectBuilder<DB, TB, O | O2>;
|
|
168
|
+
/**
|
|
169
|
+
* Cursor-based (keyset) pagination.
|
|
170
|
+
*
|
|
171
|
+
* Adds WHERE column > cursor (ASC) or column < cursor (DESC),
|
|
172
|
+
* ORDER BY column, and LIMIT pageSize + 1 (for hasNextPage detection).
|
|
173
|
+
*
|
|
174
|
+
* ```ts
|
|
175
|
+
* db.selectFrom("users")
|
|
176
|
+
* .select("id", "name")
|
|
177
|
+
* .cursorPaginate({ column: "id", after: 42, pageSize: 20 })
|
|
178
|
+
* .toSQL()
|
|
179
|
+
* ```
|
|
180
|
+
*/
|
|
181
|
+
cursorPaginate(options: {
|
|
182
|
+
column: keyof O & string;
|
|
183
|
+
after?: unknown;
|
|
184
|
+
before?: unknown;
|
|
185
|
+
pageSize: number;
|
|
186
|
+
}): TypedSelectBuilder<DB, TB, O>;
|
|
168
187
|
/** Build the AST node. */
|
|
169
188
|
build(): SelectNode;
|
|
170
189
|
/** Compile to SQL with explicit printer. */
|