sumak 0.0.8 → 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 +166 -3
- 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 +5 -0
- package/dist/index.mjs +5 -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 +6 -0
- package/dist/sumak.mjs +15 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -689,6 +689,38 @@ Available: `clearWhere()`, `clearOrderBy()`, `clearLimit()`, `clearOffset()`, `c
|
|
|
689
689
|
|
|
690
690
|
---
|
|
691
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
|
+
|
|
692
724
|
## Raw SQL
|
|
693
725
|
|
|
694
726
|
### `sql` tagged template
|
|
@@ -982,14 +1014,145 @@ Modes: `as_of`, `from_to`, `between`, `contained_in`, `all`.
|
|
|
982
1014
|
|
|
983
1015
|
## Plugins
|
|
984
1016
|
|
|
1017
|
+
### WithSchemaPlugin
|
|
1018
|
+
|
|
985
1019
|
```ts
|
|
986
|
-
|
|
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
|
+
```
|
|
987
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
|
+
|
|
1131
|
+
```ts
|
|
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
|
|
1146
|
+
|
|
1147
|
+
```ts
|
|
988
1148
|
const db = sumak({
|
|
989
1149
|
dialect: pgDialect(),
|
|
990
1150
|
plugins: [
|
|
991
|
-
new WithSchemaPlugin("public"),
|
|
992
|
-
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 }),
|
|
993
1156
|
],
|
|
994
1157
|
tables: { ... },
|
|
995
1158
|
})
|
|
@@ -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. */
|
|
@@ -7,24 +7,27 @@ export class TypedSelectBuilder {
|
|
|
7
7
|
_builder;
|
|
8
8
|
_table;
|
|
9
9
|
_printer;
|
|
10
|
-
|
|
10
|
+
|
|
11
|
+
_compile;
|
|
12
|
+
constructor(builder, table, printer, compile) {
|
|
11
13
|
this._builder = builder;
|
|
12
14
|
this._table = table ?? "";
|
|
13
15
|
this._printer = printer;
|
|
16
|
+
this._compile = compile;
|
|
14
17
|
}
|
|
15
18
|
|
|
16
19
|
select(...cols) {
|
|
17
|
-
return new TypedSelectBuilder(this._builder.columns(...cols), this._table, this._printer);
|
|
20
|
+
return new TypedSelectBuilder(this._builder.columns(...cols), this._table, this._printer, this._compile);
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
selectAll() {
|
|
21
|
-
return new TypedSelectBuilder(this._builder.allColumns(), this._table, this._printer);
|
|
24
|
+
return new TypedSelectBuilder(this._builder.allColumns(), this._table, this._printer, this._compile);
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
selectExpr(expr, alias) {
|
|
25
28
|
const node = unwrap(expr);
|
|
26
29
|
const aliased = aliasExpr(node, alias);
|
|
27
|
-
return new TypedSelectBuilder(this._builder.columns(aliased), this._table, this._printer);
|
|
30
|
+
return new TypedSelectBuilder(this._builder.columns(aliased), this._table, this._printer, this._compile);
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
selectExprs(exprs) {
|
|
@@ -34,22 +37,22 @@ export class TypedSelectBuilder {
|
|
|
34
37
|
const aliased = aliasExpr(node, alias);
|
|
35
38
|
builder = builder.columns(aliased);
|
|
36
39
|
}
|
|
37
|
-
return new TypedSelectBuilder(builder, this._table, this._printer);
|
|
40
|
+
return new TypedSelectBuilder(builder, this._table, this._printer, this._compile);
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
distinct() {
|
|
41
|
-
return new TypedSelectBuilder(this._builder.distinct(), this._table, this._printer);
|
|
44
|
+
return new TypedSelectBuilder(this._builder.distinct(), this._table, this._printer, this._compile);
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
distinctOn(...cols) {
|
|
45
|
-
return new TypedSelectBuilder(this._builder.distinctOn(...cols), this._table, this._printer);
|
|
48
|
+
return new TypedSelectBuilder(this._builder.distinctOn(...cols), this._table, this._printer, this._compile);
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
where(exprOrCallback) {
|
|
49
52
|
if (typeof exprOrCallback === "function") {
|
|
50
53
|
const cols = createColumnProxies(this._table);
|
|
51
54
|
const result = exprOrCallback(cols);
|
|
52
|
-
return new TypedSelectBuilder(this._builder.where(unwrap(result)), this._table, this._printer);
|
|
55
|
+
return new TypedSelectBuilder(this._builder.where(unwrap(result)), this._table, this._printer, this._compile);
|
|
53
56
|
}
|
|
54
57
|
return new TypedSelectBuilder(this._builder.where(unwrap(exprOrCallback)), this._table, this._printer);
|
|
55
58
|
}
|
|
@@ -80,7 +83,7 @@ export class TypedSelectBuilder {
|
|
|
80
83
|
|
|
81
84
|
groupBy(...cols) {
|
|
82
85
|
const resolved = cols.map((c) => typeof c === "string" ? c : unwrap(c));
|
|
83
|
-
return new TypedSelectBuilder(this._builder.groupBy(...resolved), this._table, this._printer);
|
|
86
|
+
return new TypedSelectBuilder(this._builder.groupBy(...resolved), this._table, this._printer, this._compile);
|
|
84
87
|
}
|
|
85
88
|
|
|
86
89
|
having(exprOrCallback) {
|
|
@@ -112,31 +115,31 @@ export class TypedSelectBuilder {
|
|
|
112
115
|
}
|
|
113
116
|
|
|
114
117
|
forSystemTime(clause) {
|
|
115
|
-
return new TypedSelectBuilder(this._builder.forSystemTime(clause), this._table, this._printer);
|
|
118
|
+
return new TypedSelectBuilder(this._builder.forSystemTime(clause), this._table, this._printer, this._compile);
|
|
116
119
|
}
|
|
117
120
|
|
|
118
121
|
forUpdate() {
|
|
119
|
-
return new TypedSelectBuilder(this._builder.forUpdate(), this._table, this._printer);
|
|
122
|
+
return new TypedSelectBuilder(this._builder.forUpdate(), this._table, this._printer, this._compile);
|
|
120
123
|
}
|
|
121
124
|
|
|
122
125
|
forShare() {
|
|
123
|
-
return new TypedSelectBuilder(this._builder.forShare(), this._table, this._printer);
|
|
126
|
+
return new TypedSelectBuilder(this._builder.forShare(), this._table, this._printer, this._compile);
|
|
124
127
|
}
|
|
125
128
|
|
|
126
129
|
forNoKeyUpdate() {
|
|
127
|
-
return new TypedSelectBuilder(this._builder.forNoKeyUpdate(), this._table, this._printer);
|
|
130
|
+
return new TypedSelectBuilder(this._builder.forNoKeyUpdate(), this._table, this._printer, this._compile);
|
|
128
131
|
}
|
|
129
132
|
|
|
130
133
|
forKeyShare() {
|
|
131
|
-
return new TypedSelectBuilder(this._builder.forKeyShare(), this._table, this._printer);
|
|
134
|
+
return new TypedSelectBuilder(this._builder.forKeyShare(), this._table, this._printer, this._compile);
|
|
132
135
|
}
|
|
133
136
|
|
|
134
137
|
skipLocked() {
|
|
135
|
-
return new TypedSelectBuilder(this._builder.skipLocked(), this._table, this._printer);
|
|
138
|
+
return new TypedSelectBuilder(this._builder.skipLocked(), this._table, this._printer, this._compile);
|
|
136
139
|
}
|
|
137
140
|
|
|
138
141
|
noWait() {
|
|
139
|
-
return new TypedSelectBuilder(this._builder.noWait(), this._table, this._printer);
|
|
142
|
+
return new TypedSelectBuilder(this._builder.noWait(), this._table, this._printer, this._compile);
|
|
140
143
|
}
|
|
141
144
|
|
|
142
145
|
with(name, query, recursive = false) {
|
|
@@ -144,11 +147,11 @@ export class TypedSelectBuilder {
|
|
|
144
147
|
}
|
|
145
148
|
|
|
146
149
|
union(query) {
|
|
147
|
-
return new TypedSelectBuilder(this._builder.union(query.build()), this._table, this._printer);
|
|
150
|
+
return new TypedSelectBuilder(this._builder.union(query.build()), this._table, this._printer, this._compile);
|
|
148
151
|
}
|
|
149
152
|
|
|
150
153
|
unionAll(query) {
|
|
151
|
-
return new TypedSelectBuilder(this._builder.unionAll(query.build()), this._table, this._printer);
|
|
154
|
+
return new TypedSelectBuilder(this._builder.unionAll(query.build()), this._table, this._printer, this._compile);
|
|
152
155
|
}
|
|
153
156
|
|
|
154
157
|
intersect(query) {
|
|
@@ -160,7 +163,7 @@ export class TypedSelectBuilder {
|
|
|
160
163
|
}
|
|
161
164
|
|
|
162
165
|
except(query) {
|
|
163
|
-
return new TypedSelectBuilder(this._builder.except(query.build()), this._table, this._printer);
|
|
166
|
+
return new TypedSelectBuilder(this._builder.except(query.build()), this._table, this._printer, this._compile);
|
|
164
167
|
}
|
|
165
168
|
|
|
166
169
|
exceptAll(query) {
|
|
@@ -219,7 +222,7 @@ export class TypedSelectBuilder {
|
|
|
219
222
|
}
|
|
220
223
|
|
|
221
224
|
crossJoin(table) {
|
|
222
|
-
return new TypedSelectBuilder(this._builder.join("CROSS", table), this._table, this._printer);
|
|
225
|
+
return new TypedSelectBuilder(this._builder.join("CROSS", table), this._table, this._printer, this._compile);
|
|
223
226
|
}
|
|
224
227
|
|
|
225
228
|
crossJoinLateral(subquery, alias) {
|
|
@@ -228,7 +231,7 @@ export class TypedSelectBuilder {
|
|
|
228
231
|
query: subquery.build(),
|
|
229
232
|
alias
|
|
230
233
|
};
|
|
231
|
-
return new TypedSelectBuilder(this._builder.crossJoinLateral(sub), this._table, this._printer);
|
|
234
|
+
return new TypedSelectBuilder(this._builder.crossJoinLateral(sub), this._table, this._printer, this._compile);
|
|
232
235
|
}
|
|
233
236
|
|
|
234
237
|
clearWhere() {
|
|
@@ -291,6 +294,49 @@ export class TypedSelectBuilder {
|
|
|
291
294
|
return this;
|
|
292
295
|
}
|
|
293
296
|
|
|
297
|
+
cursorPaginate(options) {
|
|
298
|
+
const { column, after, before, pageSize } = options;
|
|
299
|
+
let builder = this._builder;
|
|
300
|
+
if (after !== undefined) {
|
|
301
|
+
const condition = {
|
|
302
|
+
type: "binary_op",
|
|
303
|
+
op: ">",
|
|
304
|
+
left: {
|
|
305
|
+
type: "column_ref",
|
|
306
|
+
column
|
|
307
|
+
},
|
|
308
|
+
right: {
|
|
309
|
+
type: "param",
|
|
310
|
+
index: 0,
|
|
311
|
+
value: after
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
builder = builder.where(condition);
|
|
315
|
+
builder = builder.orderBy(column, "ASC");
|
|
316
|
+
} else if (before !== undefined) {
|
|
317
|
+
const condition = {
|
|
318
|
+
type: "binary_op",
|
|
319
|
+
op: "<",
|
|
320
|
+
left: {
|
|
321
|
+
type: "column_ref",
|
|
322
|
+
column
|
|
323
|
+
},
|
|
324
|
+
right: {
|
|
325
|
+
type: "param",
|
|
326
|
+
index: 0,
|
|
327
|
+
value: before
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
builder = builder.where(condition);
|
|
331
|
+
builder = builder.orderBy(column, "DESC");
|
|
332
|
+
}
|
|
333
|
+
builder = builder.limit({
|
|
334
|
+
type: "literal",
|
|
335
|
+
value: pageSize + 1
|
|
336
|
+
});
|
|
337
|
+
return new TypedSelectBuilder(builder, this._table, this._printer, this._compile);
|
|
338
|
+
}
|
|
339
|
+
|
|
294
340
|
build() {
|
|
295
341
|
return this._builder.build();
|
|
296
342
|
}
|
|
@@ -300,6 +346,9 @@ export class TypedSelectBuilder {
|
|
|
300
346
|
}
|
|
301
347
|
|
|
302
348
|
toSQL() {
|
|
349
|
+
if (this._compile) {
|
|
350
|
+
return this._compile(this.build());
|
|
351
|
+
}
|
|
303
352
|
if (!this._printer) {
|
|
304
353
|
throw new Error("toSQL() requires a printer. Use db.selectFrom() or pass a printer to compile().");
|
|
305
354
|
}
|
|
@@ -8,6 +8,8 @@ export class TypedUpdateBuilder {
|
|
|
8
8
|
_builder;
|
|
9
9
|
|
|
10
10
|
_printer;
|
|
11
|
+
|
|
12
|
+
_compile;
|
|
11
13
|
constructor(table) {
|
|
12
14
|
this._builder = new UpdateBuilder().table(table);
|
|
13
15
|
}
|
|
@@ -16,6 +18,7 @@ export class TypedUpdateBuilder {
|
|
|
16
18
|
const t = new TypedUpdateBuilder("");
|
|
17
19
|
t._builder = builder;
|
|
18
20
|
t._printer = this._printer;
|
|
21
|
+
t._compile = this._compile;
|
|
19
22
|
return t;
|
|
20
23
|
}
|
|
21
24
|
|
|
@@ -93,6 +96,7 @@ export class TypedUpdateBuilder {
|
|
|
93
96
|
}
|
|
94
97
|
|
|
95
98
|
toSQL() {
|
|
99
|
+
if (this._compile) return this._compile(this.build());
|
|
96
100
|
if (!this._printer) {
|
|
97
101
|
throw new Error("toSQL() requires a printer. Use db.update() or pass a printer to compile().");
|
|
98
102
|
}
|
package/dist/index.d.mts
CHANGED
|
@@ -45,6 +45,11 @@ export { PluginManager } from "./plugin/plugin-manager.mjs";
|
|
|
45
45
|
export { WithSchemaPlugin } from "./plugin/with-schema.mjs";
|
|
46
46
|
export { SoftDeletePlugin } from "./plugin/soft-delete.mjs";
|
|
47
47
|
export { CamelCasePlugin } from "./plugin/camel-case.mjs";
|
|
48
|
+
export { AuditTimestampPlugin } from "./plugin/audit-timestamp.mjs";
|
|
49
|
+
export { OptimisticLockPlugin } from "./plugin/optimistic-lock.mjs";
|
|
50
|
+
export { DataMaskingPlugin } from "./plugin/data-masking.mjs";
|
|
51
|
+
export { MultiTenantPlugin } from "./plugin/multi-tenant.mjs";
|
|
52
|
+
export { QueryLimitPlugin } from "./plugin/query-limit.mjs";
|
|
48
53
|
export { Hookable } from "./plugin/hooks.mjs";
|
|
49
54
|
export type { HookContext, HookName, SumakHooks } from "./plugin/hooks.mjs";
|
|
50
55
|
export { CreateTableBuilder, ColumnDefBuilder, ForeignKeyBuilder } from "./builder/ddl/create-table.mjs";
|
package/dist/index.mjs
CHANGED
|
@@ -42,6 +42,11 @@ export { PluginManager } from "./plugin/plugin-manager.mjs";
|
|
|
42
42
|
export { WithSchemaPlugin } from "./plugin/with-schema.mjs";
|
|
43
43
|
export { SoftDeletePlugin } from "./plugin/soft-delete.mjs";
|
|
44
44
|
export { CamelCasePlugin } from "./plugin/camel-case.mjs";
|
|
45
|
+
export { AuditTimestampPlugin } from "./plugin/audit-timestamp.mjs";
|
|
46
|
+
export { OptimisticLockPlugin } from "./plugin/optimistic-lock.mjs";
|
|
47
|
+
export { DataMaskingPlugin } from "./plugin/data-masking.mjs";
|
|
48
|
+
export { MultiTenantPlugin } from "./plugin/multi-tenant.mjs";
|
|
49
|
+
export { QueryLimitPlugin } from "./plugin/query-limit.mjs";
|
|
45
50
|
|
|
46
51
|
export { Hookable } from "./plugin/hooks.mjs";
|
|
47
52
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ASTNode } from "../ast/nodes.mjs";
|
|
2
|
+
import type { SumakPlugin } from "./types.mjs";
|
|
3
|
+
interface AuditTimestampConfig {
|
|
4
|
+
tables: string[];
|
|
5
|
+
createdAt?: string;
|
|
6
|
+
updatedAt?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Plugin that auto-injects created_at/updated_at timestamps.
|
|
10
|
+
*
|
|
11
|
+
* - INSERT: adds created_at and updated_at columns with NOW()
|
|
12
|
+
* - UPDATE: adds updated_at = NOW() to the SET clause
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* const plugin = new AuditTimestampPlugin({ tables: ["users", "posts"] })
|
|
16
|
+
* // INSERT INTO "users" ("name") VALUES ('Ada')
|
|
17
|
+
* // → INSERT INTO "users" ("name", "created_at", "updated_at") VALUES ('Ada', NOW(), NOW())
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export declare class AuditTimestampPlugin implements SumakPlugin {
|
|
21
|
+
readonly name = "audit-timestamp";
|
|
22
|
+
private tables;
|
|
23
|
+
private createdAt;
|
|
24
|
+
private updatedAt;
|
|
25
|
+
constructor(config: AuditTimestampConfig);
|
|
26
|
+
transformNode(node: ASTNode): ASTNode;
|
|
27
|
+
private isTargetTable;
|
|
28
|
+
private transformInsert;
|
|
29
|
+
private transformUpdate;
|
|
30
|
+
}
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { fn } from "../ast/expression.mjs";
|
|
2
|
+
|
|
3
|
+
export class AuditTimestampPlugin {
|
|
4
|
+
name = "audit-timestamp";
|
|
5
|
+
tables;
|
|
6
|
+
createdAt;
|
|
7
|
+
updatedAt;
|
|
8
|
+
constructor(config) {
|
|
9
|
+
this.tables = new Set(config.tables);
|
|
10
|
+
this.createdAt = config.createdAt ?? "created_at";
|
|
11
|
+
this.updatedAt = config.updatedAt ?? "updated_at";
|
|
12
|
+
}
|
|
13
|
+
transformNode(node) {
|
|
14
|
+
switch (node.type) {
|
|
15
|
+
case "insert": return this.transformInsert(node);
|
|
16
|
+
case "update": return this.transformUpdate(node);
|
|
17
|
+
default: return node;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
isTargetTable(tableName) {
|
|
21
|
+
return this.tables.has(tableName);
|
|
22
|
+
}
|
|
23
|
+
transformInsert(node) {
|
|
24
|
+
if (!this.isTargetTable(node.table.name)) return node;
|
|
25
|
+
const now = fn("NOW", []);
|
|
26
|
+
const columns = [
|
|
27
|
+
...node.columns,
|
|
28
|
+
this.createdAt,
|
|
29
|
+
this.updatedAt
|
|
30
|
+
];
|
|
31
|
+
const values = node.values.map((row) => [
|
|
32
|
+
...row,
|
|
33
|
+
now,
|
|
34
|
+
now
|
|
35
|
+
]);
|
|
36
|
+
return {
|
|
37
|
+
...node,
|
|
38
|
+
columns,
|
|
39
|
+
values
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
transformUpdate(node) {
|
|
43
|
+
if (!this.isTargetTable(node.table.name)) return node;
|
|
44
|
+
const now = fn("NOW", []);
|
|
45
|
+
const set = [...node.set, {
|
|
46
|
+
column: this.updatedAt,
|
|
47
|
+
value: now
|
|
48
|
+
}];
|
|
49
|
+
return {
|
|
50
|
+
...node,
|
|
51
|
+
set
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { SumakPlugin } from "./types.mjs";
|
|
2
|
+
type MaskFunction = (value: unknown) => unknown;
|
|
3
|
+
interface DataMaskingConfig {
|
|
4
|
+
rules: {
|
|
5
|
+
column: string;
|
|
6
|
+
mask: MaskFunction | "email" | "phone" | "partial";
|
|
7
|
+
}[];
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Result-transform plugin that masks sensitive data in query results.
|
|
11
|
+
*
|
|
12
|
+
* Supports built-in mask types (`"email"`, `"phone"`, `"partial"`) and
|
|
13
|
+
* custom mask functions.
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* const plugin = new DataMaskingPlugin({
|
|
17
|
+
* rules: [
|
|
18
|
+
* { column: "email", mask: "email" },
|
|
19
|
+
* { column: "phone", mask: "phone" },
|
|
20
|
+
* { column: "name", mask: "partial" },
|
|
21
|
+
* ],
|
|
22
|
+
* })
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export declare class DataMaskingPlugin implements SumakPlugin {
|
|
26
|
+
readonly name = "data-masking";
|
|
27
|
+
private rules;
|
|
28
|
+
constructor(config: DataMaskingConfig);
|
|
29
|
+
transformResult(rows: Record<string, unknown>[]): Record<string, unknown>[];
|
|
30
|
+
}
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
function maskEmail(value) {
|
|
2
|
+
if (typeof value !== "string") return value;
|
|
3
|
+
const atIndex = value.indexOf("@");
|
|
4
|
+
if (atIndex < 0) return value;
|
|
5
|
+
const local = value.slice(0, atIndex);
|
|
6
|
+
const domain = value.slice(atIndex);
|
|
7
|
+
const keep = local.slice(0, 2);
|
|
8
|
+
return `${keep}***${domain}`;
|
|
9
|
+
}
|
|
10
|
+
function maskPhone(value) {
|
|
11
|
+
if (typeof value !== "string") return value;
|
|
12
|
+
const last4 = value.slice(-4);
|
|
13
|
+
return `***${last4}`;
|
|
14
|
+
}
|
|
15
|
+
function maskPartial(value) {
|
|
16
|
+
if (typeof value !== "string") return value;
|
|
17
|
+
const keep = value.slice(0, 2);
|
|
18
|
+
return `${keep}***`;
|
|
19
|
+
}
|
|
20
|
+
const builtinMasks = {
|
|
21
|
+
email: maskEmail,
|
|
22
|
+
phone: maskPhone,
|
|
23
|
+
partial: maskPartial
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export class DataMaskingPlugin {
|
|
27
|
+
name = "data-masking";
|
|
28
|
+
rules;
|
|
29
|
+
constructor(config) {
|
|
30
|
+
this.rules = new Map();
|
|
31
|
+
for (const rule of config.rules) {
|
|
32
|
+
const fn = typeof rule.mask === "string" ? builtinMasks[rule.mask] : rule.mask;
|
|
33
|
+
if (fn) {
|
|
34
|
+
this.rules.set(rule.column, fn);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
transformResult(rows) {
|
|
39
|
+
return rows.map((row) => {
|
|
40
|
+
const masked = { ...row };
|
|
41
|
+
for (const [column, fn] of this.rules) {
|
|
42
|
+
if (column in masked) {
|
|
43
|
+
masked[column] = fn(masked[column]);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return masked;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ASTNode } from "../ast/nodes.mjs";
|
|
2
|
+
import type { SumakPlugin } from "./types.mjs";
|
|
3
|
+
/**
|
|
4
|
+
* Plugin that auto-injects tenant_id filtering on all queries for configured tables.
|
|
5
|
+
*
|
|
6
|
+
* - SELECT/UPDATE/DELETE: adds `WHERE tenant_id = ?` (ANDed with existing WHERE)
|
|
7
|
+
* - INSERT: adds tenant_id column and value to each row
|
|
8
|
+
*
|
|
9
|
+
* `tenantId` accepts a value OR a function that returns the value per-query.
|
|
10
|
+
* Use a function for per-request tenant resolution (JWT, session, etc.):
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* new MultiTenantPlugin({
|
|
14
|
+
* tables: ["users", "posts"],
|
|
15
|
+
* tenantId: () => getCurrentTenantId(),
|
|
16
|
+
* })
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export declare class MultiTenantPlugin implements SumakPlugin {
|
|
20
|
+
readonly name = "multi-tenant";
|
|
21
|
+
private tables;
|
|
22
|
+
private column;
|
|
23
|
+
private getTenantId;
|
|
24
|
+
constructor(config: {
|
|
25
|
+
tables: string[];
|
|
26
|
+
column?: string;
|
|
27
|
+
tenantId: unknown | (() => unknown);
|
|
28
|
+
});
|
|
29
|
+
transformNode(node: ASTNode): ASTNode;
|
|
30
|
+
private isTargetTable;
|
|
31
|
+
private tenantCondition;
|
|
32
|
+
private addCondition;
|
|
33
|
+
private transformSelect;
|
|
34
|
+
private transformUpdate;
|
|
35
|
+
private transformDelete;
|
|
36
|
+
private transformInsert;
|
|
37
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { and, col, eq, param } from "../ast/expression.mjs";
|
|
2
|
+
|
|
3
|
+
export class MultiTenantPlugin {
|
|
4
|
+
name = "multi-tenant";
|
|
5
|
+
tables;
|
|
6
|
+
column;
|
|
7
|
+
getTenantId;
|
|
8
|
+
constructor(config) {
|
|
9
|
+
this.tables = new Set(config.tables);
|
|
10
|
+
this.column = config.column ?? "tenant_id";
|
|
11
|
+
this.getTenantId = typeof config.tenantId === "function" ? config.tenantId : () => config.tenantId;
|
|
12
|
+
}
|
|
13
|
+
transformNode(node) {
|
|
14
|
+
switch (node.type) {
|
|
15
|
+
case "select": return this.transformSelect(node);
|
|
16
|
+
case "update": return this.transformUpdate(node);
|
|
17
|
+
case "delete": return this.transformDelete(node);
|
|
18
|
+
case "insert": return this.transformInsert(node);
|
|
19
|
+
default: return node;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
isTargetTable(tableName) {
|
|
23
|
+
return this.tables.has(tableName);
|
|
24
|
+
}
|
|
25
|
+
tenantCondition() {
|
|
26
|
+
return eq(col(this.column), param(0, this.getTenantId()));
|
|
27
|
+
}
|
|
28
|
+
addCondition(existing) {
|
|
29
|
+
const condition = this.tenantCondition();
|
|
30
|
+
return existing ? and(existing, condition) : condition;
|
|
31
|
+
}
|
|
32
|
+
transformSelect(node) {
|
|
33
|
+
if (!node.from || node.from.type !== "table_ref" || !this.isTargetTable(node.from.name)) {
|
|
34
|
+
return node;
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
...node,
|
|
38
|
+
where: this.addCondition(node.where)
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
transformUpdate(node) {
|
|
42
|
+
if (!this.isTargetTable(node.table.name)) return node;
|
|
43
|
+
return {
|
|
44
|
+
...node,
|
|
45
|
+
where: this.addCondition(node.where)
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
transformDelete(node) {
|
|
49
|
+
if (!this.isTargetTable(node.table.name)) return node;
|
|
50
|
+
return {
|
|
51
|
+
...node,
|
|
52
|
+
where: this.addCondition(node.where)
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
transformInsert(node) {
|
|
56
|
+
if (!this.isTargetTable(node.table.name)) return node;
|
|
57
|
+
const tenantId = this.getTenantId();
|
|
58
|
+
const columns = [...node.columns, this.column];
|
|
59
|
+
const values = node.values.map((row) => [...row, param(0, tenantId)]);
|
|
60
|
+
return {
|
|
61
|
+
...node,
|
|
62
|
+
columns,
|
|
63
|
+
values
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ASTNode } from "../ast/nodes.mjs";
|
|
2
|
+
import type { SumakPlugin } from "./types.mjs";
|
|
3
|
+
/**
|
|
4
|
+
* Plugin that implements optimistic locking for configured tables.
|
|
5
|
+
*
|
|
6
|
+
* On UPDATE for configured tables:
|
|
7
|
+
* 1. Adds `WHERE version = :currentVersion` (ANDed with existing WHERE)
|
|
8
|
+
* 2. Adds `SET version = version + 1` to the SET clause
|
|
9
|
+
*
|
|
10
|
+
* `currentVersion` accepts a value OR a function that returns the value per-query.
|
|
11
|
+
* Use a function when the version varies per row/request:
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* let rowVersion = 3
|
|
15
|
+
* const plugin = new OptimisticLockPlugin({
|
|
16
|
+
* tables: ["users"],
|
|
17
|
+
* currentVersion: () => rowVersion,
|
|
18
|
+
* })
|
|
19
|
+
*
|
|
20
|
+
* // Before each update, set rowVersion to the row's current version
|
|
21
|
+
* rowVersion = fetchedRow.version
|
|
22
|
+
* db.update("users").set({ name: "Bob" }).where(...).toSQL()
|
|
23
|
+
* // UPDATE ... SET "version" = "version" + 1 WHERE ... AND "version" = $N
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export declare class OptimisticLockPlugin implements SumakPlugin {
|
|
27
|
+
readonly name = "optimistic-lock";
|
|
28
|
+
private tables;
|
|
29
|
+
private column;
|
|
30
|
+
private getVersion;
|
|
31
|
+
constructor(config: {
|
|
32
|
+
tables: string[];
|
|
33
|
+
column?: string;
|
|
34
|
+
currentVersion: number | (() => number);
|
|
35
|
+
});
|
|
36
|
+
transformNode(node: ASTNode): ASTNode;
|
|
37
|
+
private transformUpdate;
|
|
38
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { and, binOp, col, eq, param } from "../ast/expression.mjs";
|
|
2
|
+
|
|
3
|
+
export class OptimisticLockPlugin {
|
|
4
|
+
name = "optimistic-lock";
|
|
5
|
+
tables;
|
|
6
|
+
column;
|
|
7
|
+
getVersion;
|
|
8
|
+
constructor(config) {
|
|
9
|
+
this.tables = new Set(config.tables);
|
|
10
|
+
this.column = config.column ?? "version";
|
|
11
|
+
this.getVersion = typeof config.currentVersion === "function" ? config.currentVersion : () => config.currentVersion;
|
|
12
|
+
}
|
|
13
|
+
transformNode(node) {
|
|
14
|
+
if (node.type !== "update") return node;
|
|
15
|
+
return this.transformUpdate(node);
|
|
16
|
+
}
|
|
17
|
+
transformUpdate(node) {
|
|
18
|
+
if (!this.tables.has(node.table.name)) return node;
|
|
19
|
+
const versionCondition = eq(col(this.column), param(0, this.getVersion()));
|
|
20
|
+
const where = node.where ? and(node.where, versionCondition) : versionCondition;
|
|
21
|
+
const versionIncrement = binOp("+", col(this.column), {
|
|
22
|
+
type: "literal",
|
|
23
|
+
value: 1
|
|
24
|
+
});
|
|
25
|
+
const set = [...node.set, {
|
|
26
|
+
column: this.column,
|
|
27
|
+
value: versionIncrement
|
|
28
|
+
}];
|
|
29
|
+
return {
|
|
30
|
+
...node,
|
|
31
|
+
set,
|
|
32
|
+
where
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ASTNode } from "../ast/nodes.mjs";
|
|
2
|
+
import type { SumakPlugin } from "./types.mjs";
|
|
3
|
+
/**
|
|
4
|
+
* Plugin that auto-injects a LIMIT on SELECT queries that don't already have one.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* const plugin = new QueryLimitPlugin({ maxRows: 500 })
|
|
8
|
+
* // SELECT * FROM "users"
|
|
9
|
+
* // → SELECT * FROM "users" LIMIT 500
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
export declare class QueryLimitPlugin implements SumakPlugin {
|
|
13
|
+
readonly name = "query-limit";
|
|
14
|
+
private maxRows;
|
|
15
|
+
constructor(config?: {
|
|
16
|
+
maxRows?: number;
|
|
17
|
+
});
|
|
18
|
+
transformNode(node: ASTNode): ASTNode;
|
|
19
|
+
private transformSelect;
|
|
20
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
|
|
2
|
+
export class QueryLimitPlugin {
|
|
3
|
+
name = "query-limit";
|
|
4
|
+
maxRows;
|
|
5
|
+
constructor(config) {
|
|
6
|
+
this.maxRows = config?.maxRows ?? 1e3;
|
|
7
|
+
}
|
|
8
|
+
transformNode(node) {
|
|
9
|
+
if (node.type !== "select") return node;
|
|
10
|
+
return this.transformSelect(node);
|
|
11
|
+
}
|
|
12
|
+
transformSelect(node) {
|
|
13
|
+
if (node.limit) return node;
|
|
14
|
+
const limit = {
|
|
15
|
+
type: "literal",
|
|
16
|
+
value: this.maxRows
|
|
17
|
+
};
|
|
18
|
+
return {
|
|
19
|
+
...node,
|
|
20
|
+
limit
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -1,21 +1,30 @@
|
|
|
1
1
|
import type { ASTNode } from "../ast/nodes.mjs";
|
|
2
2
|
import type { SumakPlugin } from "./types.mjs";
|
|
3
3
|
/**
|
|
4
|
-
* Plugin that automatically
|
|
5
|
-
*
|
|
4
|
+
* Plugin that automatically handles soft deletes for configured tables.
|
|
5
|
+
*
|
|
6
|
+
* In "convert" mode (default):
|
|
7
|
+
* - SELECT/UPDATE: adds `WHERE deleted_at IS NULL`
|
|
8
|
+
* - DELETE: converts to `UPDATE SET deleted_at = NOW() WHERE ... AND deleted_at IS NULL`
|
|
9
|
+
*
|
|
10
|
+
* In "filter" mode:
|
|
11
|
+
* - SELECT/UPDATE/DELETE: adds `WHERE deleted_at IS NULL`
|
|
6
12
|
*
|
|
7
13
|
* ```ts
|
|
8
|
-
* const plugin = new SoftDeletePlugin({ tables: ["users", "posts"] })
|
|
9
|
-
* //
|
|
14
|
+
* const plugin = new SoftDeletePlugin({ tables: ["users", "posts"] })
|
|
15
|
+
* // DELETE FROM "users" WHERE id = 1
|
|
16
|
+
* // → UPDATE "users" SET "deleted_at" = NOW() WHERE id = 1 AND "deleted_at" IS NULL
|
|
10
17
|
* ```
|
|
11
18
|
*/
|
|
12
19
|
export declare class SoftDeletePlugin implements SumakPlugin {
|
|
13
20
|
readonly name = "soft-delete";
|
|
14
21
|
private tables;
|
|
15
22
|
private column;
|
|
23
|
+
private mode;
|
|
16
24
|
constructor(config: {
|
|
17
25
|
tables: string[];
|
|
18
26
|
column?: string;
|
|
27
|
+
mode?: "filter" | "convert";
|
|
19
28
|
});
|
|
20
29
|
transformNode(node: ASTNode): ASTNode;
|
|
21
30
|
private isTargetTable;
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
import { and, col, isNull } from "../ast/expression.mjs";
|
|
1
|
+
import { and, col, fn, isNull } from "../ast/expression.mjs";
|
|
2
2
|
|
|
3
3
|
export class SoftDeletePlugin {
|
|
4
4
|
name = "soft-delete";
|
|
5
5
|
tables;
|
|
6
6
|
column;
|
|
7
|
+
mode;
|
|
7
8
|
constructor(config) {
|
|
8
9
|
this.tables = new Set(config.tables);
|
|
9
10
|
this.column = config.column ?? "deleted_at";
|
|
11
|
+
this.mode = config.mode ?? "convert";
|
|
10
12
|
}
|
|
11
13
|
transformNode(node) {
|
|
12
14
|
switch (node.type) {
|
|
@@ -44,9 +46,24 @@ export class SoftDeletePlugin {
|
|
|
44
46
|
}
|
|
45
47
|
transformDelete(node) {
|
|
46
48
|
if (!this.isTargetTable(node.table.name)) return node;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
if (this.mode === "filter") {
|
|
50
|
+
return {
|
|
51
|
+
...node,
|
|
52
|
+
where: this.addCondition(node.where)
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const updateNode = {
|
|
56
|
+
type: "update",
|
|
57
|
+
table: node.table,
|
|
58
|
+
set: [{
|
|
59
|
+
column: this.column,
|
|
60
|
+
value: fn("NOW", [])
|
|
61
|
+
}],
|
|
62
|
+
where: this.addCondition(node.where),
|
|
63
|
+
returning: node.returning,
|
|
64
|
+
joins: node.joins,
|
|
65
|
+
ctes: node.ctes
|
|
50
66
|
};
|
|
67
|
+
return updateNode;
|
|
51
68
|
}
|
|
52
69
|
}
|
package/dist/sumak.d.mts
CHANGED
|
@@ -78,6 +78,12 @@ export declare class Sumak<DB> {
|
|
|
78
78
|
selectFromSubquery<Alias extends string>(subquery: {
|
|
79
79
|
build(): import("./ast/nodes.ts").SelectNode;
|
|
80
80
|
}, alias: Alias): TypedSelectBuilder<DB, keyof DB & string, Record<string, unknown>>;
|
|
81
|
+
/**
|
|
82
|
+
* SELECT COUNT(*) FROM table — convenience shorthand.
|
|
83
|
+
*/
|
|
84
|
+
selectCount<T extends keyof DB & string>(table: T): TypedSelectBuilder<DB, T, {
|
|
85
|
+
count: number;
|
|
86
|
+
}>;
|
|
81
87
|
insertInto<T extends keyof DB & string>(table: T): TypedInsertBuilder<DB, T>;
|
|
82
88
|
update<T extends keyof DB & string>(table: T): TypedUpdateBuilder<DB, T>;
|
|
83
89
|
deleteFrom<T extends keyof DB & string>(table: T): TypedDeleteBuilder<DB, T>;
|
package/dist/sumak.mjs
CHANGED
|
@@ -35,7 +35,7 @@ export class Sumak {
|
|
|
35
35
|
return this._hooks.hook(name, handler);
|
|
36
36
|
}
|
|
37
37
|
selectFrom(table, alias) {
|
|
38
|
-
return new TypedSelectBuilder(new SelectBuilder().from(table, alias), table, this._dialect.createPrinter());
|
|
38
|
+
return new TypedSelectBuilder(new SelectBuilder().from(table, alias), table, this._dialect.createPrinter(), (node) => this.compile(node));
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
selectFromSubquery(subquery, alias) {
|
|
@@ -46,19 +46,33 @@ export class Sumak {
|
|
|
46
46
|
};
|
|
47
47
|
return new TypedSelectBuilder(new SelectBuilder().from(sub), alias);
|
|
48
48
|
}
|
|
49
|
+
|
|
50
|
+
selectCount(table) {
|
|
51
|
+
const star = { type: "star" };
|
|
52
|
+
const countFn = {
|
|
53
|
+
type: "function_call",
|
|
54
|
+
name: "COUNT",
|
|
55
|
+
args: [star],
|
|
56
|
+
alias: "count"
|
|
57
|
+
};
|
|
58
|
+
return new TypedSelectBuilder(new SelectBuilder().columns(countFn).from(table), table, this._dialect.createPrinter(), (node) => this.compile(node));
|
|
59
|
+
}
|
|
49
60
|
insertInto(table) {
|
|
50
61
|
const b = new TypedInsertBuilder(table);
|
|
51
62
|
b._printer = this._dialect.createPrinter();
|
|
63
|
+
b._compile = (node) => this.compile(node);
|
|
52
64
|
return b;
|
|
53
65
|
}
|
|
54
66
|
update(table) {
|
|
55
67
|
const b = new TypedUpdateBuilder(table);
|
|
56
68
|
b._printer = this._dialect.createPrinter();
|
|
69
|
+
b._compile = (node) => this.compile(node);
|
|
57
70
|
return b;
|
|
58
71
|
}
|
|
59
72
|
deleteFrom(table) {
|
|
60
73
|
const b = new TypedDeleteBuilder(table);
|
|
61
74
|
b._printer = this._dialect.createPrinter();
|
|
75
|
+
b._compile = (node) => this.compile(node);
|
|
62
76
|
return b;
|
|
63
77
|
}
|
|
64
78
|
|