sumak 0.0.9 → 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 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
@@ -1012,6 +1017,136 @@ Modes: `as_of`, `from_to`, `between`, `contained_in`, `all`.
1012
1017
 
1013
1018
  ---
1014
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
+
1015
1150
  ## Plugins
1016
1151
 
1017
1152
  ### WithSchemaPlugin
@@ -1217,7 +1352,7 @@ import { serial, text } from "sumak/schema"
1217
1352
 
1218
1353
  ## Architecture
1219
1354
 
1220
- sumak uses a 5-layer pipeline. Your code never touches SQL strings — everything flows through an AST.
1355
+ sumak uses a 7-layer pipeline. Your code never touches SQL strings — everything flows through an AST.
1221
1356
 
1222
1357
  ```
1223
1358
  ┌─────────────────────────────────────────────────────────────────┐
@@ -1237,7 +1372,15 @@ sumak uses a 5-layer pipeline. Your code never touches SQL strings — everythin
1237
1372
  │ Plugin.transformNode() → Hook "query:before" │
1238
1373
  │ → AST rewriting, tenant isolation, soft delete, logging │
1239
1374
  ├─────────────────────────────────────────────────────────────────┤
1240
- │ 5. PRINTER
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 │
1241
1384
  │ .toSQL() → { sql: "SELECT ...", params: [...] } │
1242
1385
  │ → Dialect-specific: PG ($1), MySQL (?), MSSQL (@p0) │
1243
1386
  └─────────────────────────────────────────────────────────────────┘
@@ -1249,6 +1392,8 @@ The query is never a string until the very last step. This means:
1249
1392
 
1250
1393
  - **Plugins can rewrite queries** — add WHERE clauses, prefix schemas, transform joins
1251
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
1252
1397
  - **Printers are swappable** — same AST, different SQL per dialect
1253
1398
  - **No SQL injection** — values are always parameterized
1254
1399
 
@@ -1258,6 +1403,8 @@ The query is never a string until the very last step. This means:
1258
1403
  - **Immutable builders** — every method returns a new instance
1259
1404
  - **Proxy-based column access** — `({ age }) => age.gt(18)` with full type safety
1260
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
1261
1408
 
1262
1409
  ---
1263
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
+ }
package/dist/index.d.mts CHANGED
@@ -60,4 +60,11 @@ export { DropTableBuilder, DropIndexBuilder, DropViewBuilder, TruncateTableBuild
60
60
  export { DDLPrinter } from "./printer/ddl.mjs";
61
61
  export { SchemaBuilder } from "./sumak.mjs";
62
62
  export type { AlterColumnSet, AlterTableAction, AlterTableNode, CheckConstraintNode, ColumnDefinitionNode, CreateIndexNode, CreateTableNode, CreateViewNode, DDLNode, DropIndexNode, DropTableNode, DropViewNode, ForeignKeyAction, ForeignKeyConstraintNode, PrimaryKeyConstraintNode, TableConstraintNode, TruncateTableNode, UniqueConstraintNode } from "./ast/ddl-nodes.mjs";
63
+ export { normalizeExpression, normalizeQuery, toCNF, fromCNF } from "./normalize/index.mjs";
64
+ export type { CNF, NormalizeOptions } from "./normalize/index.mjs";
65
+ export { optimize, createRule, predicatePushdown, subqueryFlattening, removeWhereTrue, BUILTIN_RULES } from "./optimize/index.mjs";
66
+ export type { RewriteRule, OptimizeOptions } from "./optimize/index.mjs";
67
+ export { placeholder, compileQuery, collectPlaceholders, isPlaceholder } from "./builder/compiled.mjs";
68
+ export type { CompiledQueryFn, PlaceholderMarker } from "./builder/compiled.mjs";
69
+ export { JsonOptic, JsonExpr, jsonCol, jsonExpr } from "./builder/json-optics.mjs";
63
70
  export { EmptyQueryError, InvalidExpressionError, SumakError, UnsupportedDialectFeatureError } from "./errors.mjs";
package/dist/index.mjs CHANGED
@@ -58,4 +58,12 @@ export { DropTableBuilder, DropIndexBuilder, DropViewBuilder, TruncateTableBuild
58
58
  export { DDLPrinter } from "./printer/ddl.mjs";
59
59
  export { SchemaBuilder } from "./sumak.mjs";
60
60
 
61
+ export { normalizeExpression, normalizeQuery, toCNF, fromCNF } from "./normalize/index.mjs";
62
+
63
+ export { optimize, createRule, predicatePushdown, subqueryFlattening, removeWhereTrue, BUILTIN_RULES } from "./optimize/index.mjs";
64
+
65
+ export { placeholder, compileQuery, collectPlaceholders, isPlaceholder } from "./builder/compiled.mjs";
66
+
67
+ export { JsonOptic, JsonExpr, jsonCol, jsonExpr } from "./builder/json-optics.mjs";
68
+
61
69
  export { EmptyQueryError, InvalidExpressionError, SumakError, UnsupportedDialectFeatureError } from "./errors.mjs";
@@ -0,0 +1,26 @@
1
+ import type { ExpressionNode } from "../ast/nodes.mjs";
2
+ import type { CNF, NormalizeOptions } from "./types.mjs";
3
+ /**
4
+ * Normalize an expression node using NbE (Normalization by Evaluation).
5
+ *
6
+ * Pipeline: Expression → evaluate (semantic domain) → reify (canonical AST)
7
+ *
8
+ * Transformations:
9
+ * - Flatten nested AND/OR
10
+ * - Remove duplicate predicates
11
+ * - Simplify tautologies: `x AND true → x`, `x OR false → x`
12
+ * - Simplify contradictions: `x AND false → false`, `x OR true → true`
13
+ * - Fold constants: `1 + 2 → 3`
14
+ * - Simplify negation: `NOT NOT x → x`, `NOT true → false`
15
+ * - Normalize comparison direction: `1 = x → x = 1` (literal always on right)
16
+ */
17
+ export declare function normalizeExpression(expr: ExpressionNode, opts?: NormalizeOptions): ExpressionNode;
18
+ /**
19
+ * Convert a WHERE expression to Conjunctive Normal Form.
20
+ * Top-level AND, inner OR.
21
+ */
22
+ export declare function toCNF(expr: ExpressionNode): CNF;
23
+ /**
24
+ * Reify a CNF back to an ExpressionNode.
25
+ */
26
+ export declare function fromCNF(cnf: CNF): ExpressionNode | undefined;