tempest-db-js 0.8.0 → 0.9.0
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 +8 -0
- package/dist/bin.cjs +1921 -12
- package/dist/bin.cjs.map +1 -1
- package/dist/bin.js +31 -2
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-NH6K5LTX.js → chunk-HXK6WIBP.js} +2996 -271
- package/dist/chunk-HXK6WIBP.js.map +1 -0
- package/dist/{chunk-MF6O56RO.js → chunk-ZBIHRVUS.js} +255 -11
- package/dist/chunk-ZBIHRVUS.js.map +1 -0
- package/dist/index.cjs +3051 -272
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3017 -556
- package/dist/index.d.ts +3017 -556
- package/dist/index.js +1 -1
- package/dist/migrations/index.cjs +1654 -11
- package/dist/migrations/index.cjs.map +1 -1
- package/dist/migrations/index.d.cts +30 -1
- package/dist/migrations/index.d.ts +30 -1
- package/dist/migrations/index.js +2 -2
- package/package.json +1 -1
- package/dist/chunk-MF6O56RO.js.map +0 -1
- package/dist/chunk-NH6K5LTX.js.map +0 -1
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { execFile } from 'child_process';
|
|
2
|
+
import { copyFile } from 'fs/promises';
|
|
3
|
+
import { promisify } from 'util';
|
|
1
4
|
import { createRequire } from 'module';
|
|
2
5
|
|
|
3
6
|
// src/conditions.ts
|
|
@@ -110,12 +113,34 @@ var Expression = class {
|
|
|
110
113
|
return this.compare("isNull", value);
|
|
111
114
|
}
|
|
112
115
|
};
|
|
116
|
+
function conditionFromNode(node) {
|
|
117
|
+
return wrap(node);
|
|
118
|
+
}
|
|
119
|
+
function expressionFromNode(node) {
|
|
120
|
+
return new Expression(node);
|
|
121
|
+
}
|
|
113
122
|
function col(name) {
|
|
114
123
|
return new Expression({ kind: "column", name });
|
|
115
124
|
}
|
|
116
125
|
function val(value) {
|
|
117
126
|
return new Expression({ kind: "value", value });
|
|
118
127
|
}
|
|
128
|
+
function caseWhen(branches, fallback) {
|
|
129
|
+
if (branches.length === 0) {
|
|
130
|
+
throw new Error("caseWhen() needs at least one [condition, result] branch.");
|
|
131
|
+
}
|
|
132
|
+
return new Expression({
|
|
133
|
+
kind: "case",
|
|
134
|
+
branches: branches.map(([when, result]) => ({
|
|
135
|
+
when: toCondNode(when),
|
|
136
|
+
result: toExprNode(result)
|
|
137
|
+
})),
|
|
138
|
+
fallback: fallback === void 0 ? null : toExprNode(fallback)
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
function cast(operand, to) {
|
|
142
|
+
return new Expression({ kind: "cast", operand: toArg(operand), to });
|
|
143
|
+
}
|
|
119
144
|
var FUNCTION_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
120
145
|
function toArg(arg) {
|
|
121
146
|
return typeof arg === "string" ? { kind: "column", name: arg } : arg.node;
|
|
@@ -163,7 +188,39 @@ function not(input) {
|
|
|
163
188
|
return wrap({ kind: "not", part: toCondNode(input) });
|
|
164
189
|
}
|
|
165
190
|
|
|
191
|
+
// src/aliased.ts
|
|
192
|
+
function aliased(model, alias) {
|
|
193
|
+
const aliasedModel = class extends model {
|
|
194
|
+
};
|
|
195
|
+
Object.defineProperty(aliasedModel, "tablename", { value: alias, writable: true });
|
|
196
|
+
Object.defineProperty(aliasedModel, "naming", {
|
|
197
|
+
value: model.naming,
|
|
198
|
+
writable: true
|
|
199
|
+
});
|
|
200
|
+
Object.defineProperty(aliasedModel, "tableArgs", {
|
|
201
|
+
value: void 0,
|
|
202
|
+
writable: true
|
|
203
|
+
});
|
|
204
|
+
Object.defineProperty(aliasedModel, "aliasOf", { value: model, writable: true });
|
|
205
|
+
return aliasedModel;
|
|
206
|
+
}
|
|
207
|
+
function aliasOf(model) {
|
|
208
|
+
return model.aliasOf ?? null;
|
|
209
|
+
}
|
|
210
|
+
|
|
166
211
|
// src/query.ts
|
|
212
|
+
function exists(subquery) {
|
|
213
|
+
return conditionFromNode({ kind: "exists", select: nodeOf(subquery), negate: false });
|
|
214
|
+
}
|
|
215
|
+
function notExists(subquery) {
|
|
216
|
+
return conditionFromNode({ kind: "exists", select: nodeOf(subquery), negate: true });
|
|
217
|
+
}
|
|
218
|
+
function scalar(subquery) {
|
|
219
|
+
return expressionFromNode({ kind: "scalar", select: subquery.node });
|
|
220
|
+
}
|
|
221
|
+
function nodeOf(subquery) {
|
|
222
|
+
return subquery.node;
|
|
223
|
+
}
|
|
167
224
|
function isSubquery(value) {
|
|
168
225
|
return typeof value === "object" && value !== null && value.node?.kind === "select";
|
|
169
226
|
}
|
|
@@ -177,6 +234,7 @@ var OPERATORS = [
|
|
|
177
234
|
"like",
|
|
178
235
|
"ilike",
|
|
179
236
|
"ieq",
|
|
237
|
+
"iContains",
|
|
180
238
|
"in",
|
|
181
239
|
"notIn",
|
|
182
240
|
"between",
|
|
@@ -197,16 +255,16 @@ function count() {
|
|
|
197
255
|
return new Agg("count", "*");
|
|
198
256
|
}
|
|
199
257
|
function sum(column2) {
|
|
200
|
-
return new Agg("sum", column2);
|
|
258
|
+
return new Agg("sum", typeof column2 === "string" ? column2 : column2.node);
|
|
201
259
|
}
|
|
202
260
|
function avg(column2) {
|
|
203
|
-
return new Agg("avg", column2);
|
|
261
|
+
return new Agg("avg", typeof column2 === "string" ? column2 : column2.node);
|
|
204
262
|
}
|
|
205
263
|
function min(column2) {
|
|
206
|
-
return new Agg("min", column2);
|
|
264
|
+
return new Agg("min", typeof column2 === "string" ? column2 : column2.node);
|
|
207
265
|
}
|
|
208
266
|
function max(column2) {
|
|
209
|
-
return new Agg("max", column2);
|
|
267
|
+
return new Agg("max", typeof column2 === "string" ? column2 : column2.node);
|
|
210
268
|
}
|
|
211
269
|
var SelectBuilder = class _SelectBuilder {
|
|
212
270
|
constructor(node, source) {
|
|
@@ -291,9 +349,35 @@ var SelectBuilder = class _SelectBuilder {
|
|
|
291
349
|
* @param direction `"asc"` (default) or `"desc"`.
|
|
292
350
|
* @returns A builder carrying the ordering term.
|
|
293
351
|
*/
|
|
352
|
+
/**
|
|
353
|
+
* Project extra expressions alongside the columns, by alias.
|
|
354
|
+
*
|
|
355
|
+
* This is where a window function lands: unlike `aggregate()`, it does **not**
|
|
356
|
+
* group — every row stays, with the computed value attached.
|
|
357
|
+
*
|
|
358
|
+
* @param map Alias → expression.
|
|
359
|
+
* @returns A builder whose row type carries the aliases.
|
|
360
|
+
*
|
|
361
|
+
* @example
|
|
362
|
+
* ```ts
|
|
363
|
+
* select(Sale).compute({
|
|
364
|
+
* rank: over(rowNumber(), { partitionBy: ["region"], orderBy: [["total", "desc"]] }),
|
|
365
|
+
* });
|
|
366
|
+
* ```
|
|
367
|
+
*/
|
|
368
|
+
compute(map) {
|
|
369
|
+
const computed = { ...this.node.computed ?? {} };
|
|
370
|
+
for (const [alias, expression2] of Object.entries(map)) {
|
|
371
|
+
computed[alias] = expression2.node;
|
|
372
|
+
}
|
|
373
|
+
return this.with({ computed });
|
|
374
|
+
}
|
|
294
375
|
orderBy(column2, direction = "asc") {
|
|
295
376
|
return this.with({
|
|
296
|
-
orderBy: [
|
|
377
|
+
orderBy: [
|
|
378
|
+
...this.node.orderBy,
|
|
379
|
+
{ column: typeof column2 === "string" ? column2 : column2.node, direction }
|
|
380
|
+
]
|
|
297
381
|
});
|
|
298
382
|
}
|
|
299
383
|
/** Limit the number of rows. */
|
|
@@ -386,10 +470,12 @@ function buildLock(strength, options) {
|
|
|
386
470
|
return { strength, wait, of: options?.of ?? [] };
|
|
387
471
|
}
|
|
388
472
|
function select(model, columns) {
|
|
473
|
+
const base = aliasOf(model);
|
|
389
474
|
return new SelectBuilder(
|
|
390
475
|
{
|
|
391
476
|
kind: "select",
|
|
392
|
-
table: model.tablename,
|
|
477
|
+
table: base ? base.tablename : model.tablename,
|
|
478
|
+
alias: base ? model.tablename : void 0,
|
|
393
479
|
columns: columns ?? "*",
|
|
394
480
|
distinct: false,
|
|
395
481
|
aggregates: [],
|
|
@@ -398,12 +484,67 @@ function select(model, columns) {
|
|
|
398
484
|
orderBy: [],
|
|
399
485
|
limit: void 0,
|
|
400
486
|
offset: void 0,
|
|
401
|
-
names: columnNamesOf(model) ?? void 0
|
|
487
|
+
names: columnNamesOf(model) ?? void 0,
|
|
488
|
+
codecs: codecsOf(model) ?? void 0
|
|
402
489
|
},
|
|
403
490
|
model
|
|
404
491
|
);
|
|
405
492
|
}
|
|
406
493
|
|
|
494
|
+
// src/window.ts
|
|
495
|
+
function functionNode(fn2) {
|
|
496
|
+
if ("call" in fn2) return fn2.call;
|
|
497
|
+
if (fn2 instanceof Agg) {
|
|
498
|
+
const arg = fn2.column === "*" ? { kind: "star" } : typeof fn2.column === "string" ? { kind: "column", name: fn2.column } : fn2.column;
|
|
499
|
+
return { kind: "fn", name: fn2.fn, args: [arg] };
|
|
500
|
+
}
|
|
501
|
+
return fn2.node;
|
|
502
|
+
}
|
|
503
|
+
function over(fn2, spec = {}) {
|
|
504
|
+
return expressionFromNode({
|
|
505
|
+
kind: "window",
|
|
506
|
+
fn: functionNode(fn2),
|
|
507
|
+
partitionBy: [...spec.partitionBy ?? []],
|
|
508
|
+
orderBy: (spec.orderBy ?? []).map(
|
|
509
|
+
(term) => typeof term === "string" ? { column: term, direction: "asc" } : { column: term[0], direction: term[1] }
|
|
510
|
+
),
|
|
511
|
+
frame: spec.frame ?? null
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
function windowFn(name, args = []) {
|
|
515
|
+
return { call: { kind: "fn", name, args } };
|
|
516
|
+
}
|
|
517
|
+
function rowNumber() {
|
|
518
|
+
return windowFn("row_number");
|
|
519
|
+
}
|
|
520
|
+
function rank() {
|
|
521
|
+
return windowFn("rank");
|
|
522
|
+
}
|
|
523
|
+
function denseRank() {
|
|
524
|
+
return windowFn("dense_rank");
|
|
525
|
+
}
|
|
526
|
+
function percentRank() {
|
|
527
|
+
return windowFn("percent_rank");
|
|
528
|
+
}
|
|
529
|
+
function lag(column2, offset = 1) {
|
|
530
|
+
return windowFn("lag", [
|
|
531
|
+
{ kind: "column", name: column2 },
|
|
532
|
+
{ kind: "value", value: offset }
|
|
533
|
+
]);
|
|
534
|
+
}
|
|
535
|
+
function lead(column2, offset = 1) {
|
|
536
|
+
return windowFn("lead", [
|
|
537
|
+
{ kind: "column", name: column2 },
|
|
538
|
+
{ kind: "value", value: offset }
|
|
539
|
+
]);
|
|
540
|
+
}
|
|
541
|
+
function firstValue(column2) {
|
|
542
|
+
return windowFn("first_value", [{ kind: "column", name: column2 }]);
|
|
543
|
+
}
|
|
544
|
+
function lastValue(column2) {
|
|
545
|
+
return windowFn("last_value", [{ kind: "column", name: column2 }]);
|
|
546
|
+
}
|
|
547
|
+
|
|
407
548
|
// src/serialize.ts
|
|
408
549
|
var ValidationError = class extends Error {
|
|
409
550
|
constructor(table, issues) {
|
|
@@ -429,8 +570,20 @@ function fromBase64(value) {
|
|
|
429
570
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
430
571
|
return bytes;
|
|
431
572
|
}
|
|
573
|
+
function baseOf(column2) {
|
|
574
|
+
return new Column(
|
|
575
|
+
column2.type,
|
|
576
|
+
column2.flags,
|
|
577
|
+
column2.defaultValue,
|
|
578
|
+
column2.onUpdateValue,
|
|
579
|
+
column2.reference,
|
|
580
|
+
column2.dbName,
|
|
581
|
+
null
|
|
582
|
+
);
|
|
583
|
+
}
|
|
432
584
|
function encodeValue(column2, value) {
|
|
433
585
|
if (value === null || value === void 0) return null;
|
|
586
|
+
if (column2.codec) return encodeValue(baseOf(column2), column2.codec.toDb(value));
|
|
434
587
|
switch (column2.type.kind) {
|
|
435
588
|
case "bigint":
|
|
436
589
|
return typeof value === "bigint" ? value.toString() : value;
|
|
@@ -446,6 +599,7 @@ function encodeValue(column2, value) {
|
|
|
446
599
|
}
|
|
447
600
|
function decodeValue(column2, value) {
|
|
448
601
|
if (value === null || value === void 0) return null;
|
|
602
|
+
if (column2.codec) return column2.codec.fromDb(decodeValue(baseOf(column2), value));
|
|
449
603
|
switch (column2.type.kind) {
|
|
450
604
|
case "bigint":
|
|
451
605
|
return typeof value === "bigint" ? value : BigInt(value);
|
|
@@ -565,6 +719,12 @@ function mapperFor(model) {
|
|
|
565
719
|
const decoders = /* @__PURE__ */ new Map();
|
|
566
720
|
for (const [prop, col2] of Object.entries(columnsOf(model))) {
|
|
567
721
|
const decoder = decoderFor(col2.type);
|
|
722
|
+
const codec = col2.codec;
|
|
723
|
+
if (codec) {
|
|
724
|
+
const decode2 = decoder ? (value) => value === null || value === void 0 ? value : codec.fromDb(decoder(value)) : (value) => value === null || value === void 0 ? value : codec.fromDb(value);
|
|
725
|
+
decoders.set(names?.[prop] ?? prop, decode2);
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
568
728
|
if (decoder) decoders.set(names?.[prop] ?? prop, decoder);
|
|
569
729
|
}
|
|
570
730
|
const mapper = { props, decoders };
|
|
@@ -600,7 +760,9 @@ function assertWritableValues(model, values, clause) {
|
|
|
600
760
|
issues.push(`${clause}: "${key}" is not a column of ${model.tablename}`);
|
|
601
761
|
continue;
|
|
602
762
|
}
|
|
603
|
-
if (isSqlExpression(value) || isBindableScalar(value))
|
|
763
|
+
if (isExpression(value) || isSqlExpression(value) || isBindableScalar(value)) {
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
604
766
|
if (typeof value === "object" && STRUCTURED_KINDS.has(col2.type.kind)) continue;
|
|
605
767
|
issues.push(
|
|
606
768
|
`${clause}: "${key}" got ${describeValue(value)}, which cannot be bound to a ${col2.type.kind} column \u2014 use sql.raw()/sql.expr\`...\` for a SQL expression`
|
|
@@ -608,11 +770,23 @@ function assertWritableValues(model, values, clause) {
|
|
|
608
770
|
}
|
|
609
771
|
if (issues.length > 0) throw new ValidationError(model.tablename, issues);
|
|
610
772
|
}
|
|
773
|
+
function encodeWriteValues(model, values) {
|
|
774
|
+
const codecs = codecsOf(model);
|
|
775
|
+
if (!codecs) return values;
|
|
776
|
+
const out = { ...values };
|
|
777
|
+
for (const [key, codec] of Object.entries(codecs)) {
|
|
778
|
+
if (!(key in out)) continue;
|
|
779
|
+
const value = out[key];
|
|
780
|
+
if (isSqlExpression(value) || isExpression(value)) continue;
|
|
781
|
+
out[key] = codec.toDb(value);
|
|
782
|
+
}
|
|
783
|
+
return out;
|
|
784
|
+
}
|
|
611
785
|
function assertConsistentRows(model, rows) {
|
|
612
786
|
if (rows.length < 2) return;
|
|
613
|
-
const
|
|
614
|
-
for (const row of rows) for (const key of Object.keys(row))
|
|
615
|
-
const inconsistent = [...
|
|
787
|
+
const union2 = /* @__PURE__ */ new Set();
|
|
788
|
+
for (const row of rows) for (const key of Object.keys(row)) union2.add(key);
|
|
789
|
+
const inconsistent = [...union2].filter((key) => rows.some((row) => !(key in row)));
|
|
616
790
|
if (inconsistent.length === 0) return;
|
|
617
791
|
const columns = columnsOf(model);
|
|
618
792
|
const defaulted = inconsistent.filter((key) => columns[key]?.flags.hasDefault);
|
|
@@ -650,9 +824,10 @@ var InsertBuilder = class _InsertBuilder {
|
|
|
650
824
|
*/
|
|
651
825
|
values(rows) {
|
|
652
826
|
const list = Array.isArray(rows) ? rows : [rows];
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
827
|
+
const encoded = list.map((row) => encodeWriteValues(this.source, row));
|
|
828
|
+
for (const row of encoded) assertWritableValues(this.source, row, "values");
|
|
829
|
+
assertConsistentRows(this.source, encoded);
|
|
830
|
+
return this.with({ values: encoded });
|
|
656
831
|
}
|
|
657
832
|
/**
|
|
658
833
|
* On a unique/PK conflict on `target`, do nothing (skip the row).
|
|
@@ -703,6 +878,34 @@ var InsertBuilder = class _InsertBuilder {
|
|
|
703
878
|
}
|
|
704
879
|
});
|
|
705
880
|
}
|
|
881
|
+
/**
|
|
882
|
+
* Fill the table from another query — `INSERT INTO t (a, b) SELECT …`.
|
|
883
|
+
*
|
|
884
|
+
* The rows never leave the database, which is the point: archiving or copying a
|
|
885
|
+
* million rows should not become a million round trips through this process.
|
|
886
|
+
*
|
|
887
|
+
* @param columns The target columns, in the order the query projects them.
|
|
888
|
+
* @param query The query producing the rows.
|
|
889
|
+
* @returns A builder ready to execute.
|
|
890
|
+
* @throws Error When no target column is given.
|
|
891
|
+
*
|
|
892
|
+
* @example
|
|
893
|
+
* ```ts
|
|
894
|
+
* insert(ArchivedOrder).fromSelect(
|
|
895
|
+
* ["id", "total"],
|
|
896
|
+
* select(Order, ["id", "total"]).where({ createdAt: { lt: cutoff } }),
|
|
897
|
+
* );
|
|
898
|
+
* ```
|
|
899
|
+
*/
|
|
900
|
+
fromSelect(columns, query) {
|
|
901
|
+
if (columns.length === 0) {
|
|
902
|
+
throw new Error("fromSelect() needs at least one target column.");
|
|
903
|
+
}
|
|
904
|
+
return this.with({
|
|
905
|
+
values: [],
|
|
906
|
+
fromSelect: { columns: [...columns], select: query.node }
|
|
907
|
+
});
|
|
908
|
+
}
|
|
706
909
|
returning(columns) {
|
|
707
910
|
return this.with({ returning: columns ?? "*" });
|
|
708
911
|
}
|
|
@@ -714,11 +917,21 @@ function insert(model) {
|
|
|
714
917
|
table: model.tablename,
|
|
715
918
|
values: [],
|
|
716
919
|
returning: null,
|
|
717
|
-
names: columnNamesOf(model) ?? void 0
|
|
920
|
+
names: columnNamesOf(model) ?? void 0,
|
|
921
|
+
codecs: codecsOf(model) ?? void 0
|
|
718
922
|
},
|
|
719
923
|
model
|
|
720
924
|
);
|
|
721
925
|
}
|
|
926
|
+
function withOnUpdateValues(model, values) {
|
|
927
|
+
const merged = { ...values };
|
|
928
|
+
for (const [name, col2] of Object.entries(columnsOf(model))) {
|
|
929
|
+
if (col2.onUpdateValue !== null && !(name in merged)) {
|
|
930
|
+
merged[name] = defaultAsWriteValue(col2.onUpdateValue);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
return merged;
|
|
934
|
+
}
|
|
722
935
|
var UpdateBuilder = class _UpdateBuilder {
|
|
723
936
|
constructor(node, source) {
|
|
724
937
|
this.node = node;
|
|
@@ -749,8 +962,30 @@ var UpdateBuilder = class _UpdateBuilder {
|
|
|
749
962
|
* ```
|
|
750
963
|
*/
|
|
751
964
|
set(values) {
|
|
752
|
-
|
|
753
|
-
|
|
965
|
+
const encoded = encodeWriteValues(this.source, values);
|
|
966
|
+
assertWritableValues(this.source, encoded, "set");
|
|
967
|
+
return this.with({
|
|
968
|
+
set: withOnUpdateValues(this.source, encoded)
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* Read from another table while updating — `UPDATE t SET … FROM other WHERE …`.
|
|
973
|
+
*
|
|
974
|
+
* The join condition goes in `where`, where SQL wants it:
|
|
975
|
+
* `.where({ customerId: col("c.id") })`.
|
|
976
|
+
*
|
|
977
|
+
* PostgreSQL and SQLite (3.33+) only. MySQL spells this as a multi-table
|
|
978
|
+
* `UPDATE a JOIN b`, which is out of this project's active scope, so it throws
|
|
979
|
+
* there rather than emitting something the server rejects.
|
|
980
|
+
*
|
|
981
|
+
* @param model The extra source.
|
|
982
|
+
* @param alias The name to reference it by.
|
|
983
|
+
* @returns A builder carrying the extra source.
|
|
984
|
+
*/
|
|
985
|
+
from(model, alias) {
|
|
986
|
+
return this.with({
|
|
987
|
+
from: [...this.node.from ?? [], { table: model.tablename, alias }]
|
|
988
|
+
});
|
|
754
989
|
}
|
|
755
990
|
/** Restrict the rows to update. Marks the builder safe to execute. */
|
|
756
991
|
where(input) {
|
|
@@ -776,7 +1011,8 @@ function update(model) {
|
|
|
776
1011
|
where: void 0,
|
|
777
1012
|
guarded: false,
|
|
778
1013
|
returning: null,
|
|
779
|
-
names: columnNamesOf(model) ?? void 0
|
|
1014
|
+
names: columnNamesOf(model) ?? void 0,
|
|
1015
|
+
codecs: codecsOf(model) ?? void 0
|
|
780
1016
|
},
|
|
781
1017
|
model
|
|
782
1018
|
);
|
|
@@ -791,6 +1027,22 @@ var DeleteBuilder = class _DeleteBuilder {
|
|
|
791
1027
|
with(patch) {
|
|
792
1028
|
return new _DeleteBuilder({ ...this.node, ...patch }, this.source);
|
|
793
1029
|
}
|
|
1030
|
+
/**
|
|
1031
|
+
* Delete by matching another table — `DELETE FROM t USING other WHERE …`.
|
|
1032
|
+
*
|
|
1033
|
+
* PostgreSQL only. SQLite and MySQL have no `USING` here; there the portable
|
|
1034
|
+
* form is `where({ id: { in: select(Other, ["id"]).where(...).asSubquery("id") } })`,
|
|
1035
|
+
* and this throws rather than pretending.
|
|
1036
|
+
*
|
|
1037
|
+
* @param model The extra source.
|
|
1038
|
+
* @param alias The name to reference it by.
|
|
1039
|
+
* @returns A builder carrying the extra source.
|
|
1040
|
+
*/
|
|
1041
|
+
using(model, alias) {
|
|
1042
|
+
return this.with({
|
|
1043
|
+
using: [...this.node.using ?? [], { table: model.tablename, alias }]
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
794
1046
|
/** Restrict the rows to delete. Marks the builder safe to execute. */
|
|
795
1047
|
where(input) {
|
|
796
1048
|
return this.with({
|
|
@@ -814,7 +1066,8 @@ function del(model) {
|
|
|
814
1066
|
where: void 0,
|
|
815
1067
|
guarded: false,
|
|
816
1068
|
returning: null,
|
|
817
|
-
names: columnNamesOf(model) ?? void 0
|
|
1069
|
+
names: columnNamesOf(model) ?? void 0,
|
|
1070
|
+
codecs: codecsOf(model) ?? void 0
|
|
818
1071
|
},
|
|
819
1072
|
model
|
|
820
1073
|
);
|
|
@@ -918,10 +1171,15 @@ function detectDialect(url) {
|
|
|
918
1171
|
}
|
|
919
1172
|
|
|
920
1173
|
// src/expressions.ts
|
|
1174
|
+
function renderExcluded(column2, bare, dialect) {
|
|
1175
|
+
return dialect === "mysql" ? `VALUES(${bare})` : `excluded.${column2}`;
|
|
1176
|
+
}
|
|
921
1177
|
function renderPortableToken(token, dialect) {
|
|
922
1178
|
switch (token) {
|
|
923
1179
|
case "now":
|
|
924
|
-
|
|
1180
|
+
if (dialect === "postgresql") return "now()";
|
|
1181
|
+
if (dialect === "sqlite") return "strftime('%Y-%m-%dT%H:%M:%fZ', 'now')";
|
|
1182
|
+
return "CURRENT_TIMESTAMP";
|
|
925
1183
|
case "current_date":
|
|
926
1184
|
return "CURRENT_DATE";
|
|
927
1185
|
case "current_time":
|
|
@@ -933,8 +1191,76 @@ function renderPortableToken(token, dialect) {
|
|
|
933
1191
|
}
|
|
934
1192
|
}
|
|
935
1193
|
|
|
1194
|
+
// src/search.ts
|
|
1195
|
+
function escapeLike(value) {
|
|
1196
|
+
return value.replace(/[\\%_]/g, (char) => `\\${char}`);
|
|
1197
|
+
}
|
|
1198
|
+
function tokenize(term) {
|
|
1199
|
+
return term.split(/\s+/).filter((token) => token.length > 0);
|
|
1200
|
+
}
|
|
1201
|
+
function contains(columns, term, options) {
|
|
1202
|
+
if (columns.length === 0) {
|
|
1203
|
+
throw new Error("contains() needs at least one column to search.");
|
|
1204
|
+
}
|
|
1205
|
+
const tokens = tokenize(term);
|
|
1206
|
+
if (tokens.length === 0) return alwaysFalse();
|
|
1207
|
+
const perToken = tokens.map(
|
|
1208
|
+
(token) => or(
|
|
1209
|
+
...columns.map((column2) => ({ [column2]: { iContains: token } }))
|
|
1210
|
+
)
|
|
1211
|
+
);
|
|
1212
|
+
if (options?.match === "any") return or(...perToken);
|
|
1213
|
+
return perToken.length === 1 ? perToken[0] : and(...perToken);
|
|
1214
|
+
}
|
|
1215
|
+
function alwaysFalse() {
|
|
1216
|
+
return conditionFromNode({
|
|
1217
|
+
kind: "compare",
|
|
1218
|
+
left: { kind: "value", value: 1 },
|
|
1219
|
+
op: "eq",
|
|
1220
|
+
right: { kind: "value", value: 0 }
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
function fullText(columns, term, options) {
|
|
1224
|
+
if (columns.length === 0) {
|
|
1225
|
+
throw new Error("fullText() needs at least one column to search.");
|
|
1226
|
+
}
|
|
1227
|
+
return conditionFromNode({
|
|
1228
|
+
kind: "fullText",
|
|
1229
|
+
columns: [...columns],
|
|
1230
|
+
term,
|
|
1231
|
+
language: options?.language ?? "english",
|
|
1232
|
+
fallback: contains(columns, term).node
|
|
1233
|
+
});
|
|
1234
|
+
}
|
|
1235
|
+
function fullTextRank(columns, term, options) {
|
|
1236
|
+
if (columns.length === 0) {
|
|
1237
|
+
throw new Error("fullTextRank() needs at least one column to rank on.");
|
|
1238
|
+
}
|
|
1239
|
+
return expressionFromNode({
|
|
1240
|
+
kind: "rank",
|
|
1241
|
+
columns: [...columns],
|
|
1242
|
+
term,
|
|
1243
|
+
language: options?.language ?? "english"
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
|
|
936
1247
|
// src/dialect.ts
|
|
937
1248
|
var OPERATOR_SET = new Set(OPERATORS);
|
|
1249
|
+
var MULTI_VALUE_OPERATORS = /* @__PURE__ */ new Set(["in", "notIn", "between"]);
|
|
1250
|
+
function encodeOperand(op, operand, encode) {
|
|
1251
|
+
if (op === "isNull") return operand;
|
|
1252
|
+
if (MULTI_VALUE_OPERATORS.has(op)) {
|
|
1253
|
+
return Array.isArray(operand) ? operand.map(encode) : operand;
|
|
1254
|
+
}
|
|
1255
|
+
return encode(operand);
|
|
1256
|
+
}
|
|
1257
|
+
function codecEncoder(codecs) {
|
|
1258
|
+
if (!codecs) return (_key, value) => value;
|
|
1259
|
+
return (key, value) => {
|
|
1260
|
+
const codec = codecs[key];
|
|
1261
|
+
return codec ? codec.toDb(value) : value;
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
938
1264
|
function isOperatorObject(value) {
|
|
939
1265
|
if (typeof value !== "object" || value === null || Array.isArray(value) || value instanceof Date || value instanceof Uint8Array) {
|
|
940
1266
|
return false;
|
|
@@ -953,6 +1279,18 @@ var Params = class {
|
|
|
953
1279
|
return this.placeholder(this.values.length);
|
|
954
1280
|
}
|
|
955
1281
|
};
|
|
1282
|
+
var LiteralParams = class extends Params {
|
|
1283
|
+
constructor() {
|
|
1284
|
+
super(() => "");
|
|
1285
|
+
}
|
|
1286
|
+
bind(value) {
|
|
1287
|
+
if (value === null || value === void 0) return "NULL";
|
|
1288
|
+
if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
|
|
1289
|
+
if (typeof value === "number" || typeof value === "bigint") return String(value);
|
|
1290
|
+
if (value instanceof Date) return `'${value.toISOString()}'`;
|
|
1291
|
+
return `'${String(value).replace(/'/g, "''")}'`;
|
|
1292
|
+
}
|
|
1293
|
+
};
|
|
956
1294
|
function insertColumns(rows) {
|
|
957
1295
|
const columns = [];
|
|
958
1296
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -988,6 +1326,20 @@ var BaseDialect = class _BaseDialect {
|
|
|
988
1326
|
static insertTemplates = /* @__PURE__ */ new Map();
|
|
989
1327
|
/** Quoted-identifier cache (see {@link quoteId}). Shared across dialects. */
|
|
990
1328
|
static quotedIds = /* @__PURE__ */ new Map();
|
|
1329
|
+
/**
|
|
1330
|
+
* Render a case-insensitive LIKE whose pattern carries escaped wildcards.
|
|
1331
|
+
*
|
|
1332
|
+
* The `ESCAPE` clause is not decoration: PostgreSQL treats `\` as the escape
|
|
1333
|
+
* character by default, **SQLite has none at all** until one is declared, so
|
|
1334
|
+
* without this the escaping done on our side would be meaningless there.
|
|
1335
|
+
*
|
|
1336
|
+
* @param column The rendered column.
|
|
1337
|
+
* @param param The bound pattern.
|
|
1338
|
+
* @returns The rendered comparison.
|
|
1339
|
+
*/
|
|
1340
|
+
ilikeEscaped(column2, param) {
|
|
1341
|
+
return `${this.ilike(column2, param)} ESCAPE '\\'`;
|
|
1342
|
+
}
|
|
991
1343
|
/**
|
|
992
1344
|
* Validate a subquery operand before it is rendered, for dialects that restrict
|
|
993
1345
|
* what an `IN (SELECT ...)` may contain. The default accepts everything.
|
|
@@ -1047,9 +1399,105 @@ var BaseDialect = class _BaseDialect {
|
|
|
1047
1399
|
case "join_select":
|
|
1048
1400
|
sql2 = this.compileJoin(node, params);
|
|
1049
1401
|
break;
|
|
1402
|
+
case "set_op":
|
|
1403
|
+
sql2 = this.compileSetOp(node, params);
|
|
1404
|
+
break;
|
|
1050
1405
|
}
|
|
1051
1406
|
return { sql: sql2, params: params.values };
|
|
1052
1407
|
}
|
|
1408
|
+
/**
|
|
1409
|
+
* Render a condition as schema SQL, with values inlined.
|
|
1410
|
+
*
|
|
1411
|
+
* Same compiler as a `WHERE`, different parameter strategy — so a `CHECK` and
|
|
1412
|
+
* the query language cannot drift apart in what they mean.
|
|
1413
|
+
*
|
|
1414
|
+
* @param node The condition.
|
|
1415
|
+
* @param params A {@link LiteralParams}.
|
|
1416
|
+
* @returns The rendered predicate.
|
|
1417
|
+
*/
|
|
1418
|
+
renderConditionLiteral(node, params) {
|
|
1419
|
+
return this.compileCondition(node, params, (key) => this.columnId(key, void 0));
|
|
1420
|
+
}
|
|
1421
|
+
/**
|
|
1422
|
+
* Render the `WITH` clause of a statement.
|
|
1423
|
+
*
|
|
1424
|
+
* `RECURSIVE` is a property of the **clause**, not of an entry: one recursive
|
|
1425
|
+
* entry makes the whole `WITH` recursive, which is what the SQL standard says
|
|
1426
|
+
* and what PostgreSQL and SQLite both implement.
|
|
1427
|
+
*
|
|
1428
|
+
* @param entries The `WITH` entries, if any.
|
|
1429
|
+
* @param params The parameter collector.
|
|
1430
|
+
* @returns The clause with a trailing space, or an empty string.
|
|
1431
|
+
*/
|
|
1432
|
+
compileWith(entries, params) {
|
|
1433
|
+
if (!entries || entries.length === 0) return "";
|
|
1434
|
+
const recursive = entries.some((entry) => entry.recursive) ? "RECURSIVE " : "";
|
|
1435
|
+
const rendered = entries.map((entry) => {
|
|
1436
|
+
const body = entry.body.kind === "set_op" ? this.compileSetOp(entry.body, params) : this.compileSelect(entry.body, params);
|
|
1437
|
+
const hint = entry.materialized === null ? "" : entry.materialized ? " MATERIALIZED" : " NOT MATERIALIZED";
|
|
1438
|
+
return `${this.quoteId(entry.name)} AS${hint} (${body})`;
|
|
1439
|
+
});
|
|
1440
|
+
return `WITH ${recursive}${rendered.join(", ")} `;
|
|
1441
|
+
}
|
|
1442
|
+
/**
|
|
1443
|
+
* The `EXPLAIN` prefix for this dialect.
|
|
1444
|
+
*
|
|
1445
|
+
* @param analyze Whether to measure by actually running the statement.
|
|
1446
|
+
* @returns The prefix to put in front of the statement.
|
|
1447
|
+
* @throws Error When the dialect cannot do what was asked.
|
|
1448
|
+
*/
|
|
1449
|
+
explainPrefix(analyze) {
|
|
1450
|
+
return analyze ? "EXPLAIN (FORMAT JSON, ANALYZE)" : "EXPLAIN (FORMAT JSON)";
|
|
1451
|
+
}
|
|
1452
|
+
/**
|
|
1453
|
+
* The SQL keyword for a set operation.
|
|
1454
|
+
*
|
|
1455
|
+
* @param op The operator.
|
|
1456
|
+
* @returns The keyword.
|
|
1457
|
+
* @throws Error On a dialect that does not implement it.
|
|
1458
|
+
*/
|
|
1459
|
+
setOperator(op) {
|
|
1460
|
+
switch (op) {
|
|
1461
|
+
case "union":
|
|
1462
|
+
return "UNION";
|
|
1463
|
+
case "unionAll":
|
|
1464
|
+
return "UNION ALL";
|
|
1465
|
+
case "intersect":
|
|
1466
|
+
return "INTERSECT";
|
|
1467
|
+
case "except":
|
|
1468
|
+
return "EXCEPT";
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
/**
|
|
1472
|
+
* Compile a set operation.
|
|
1473
|
+
*
|
|
1474
|
+
* A branch carrying its own `ORDER BY`/`LIMIT` is parenthesized: without the
|
|
1475
|
+
* parentheses those clauses bind to the **combined** result, which is a
|
|
1476
|
+
* different query and a classic source of silently wrong output.
|
|
1477
|
+
*
|
|
1478
|
+
* @param node The set-operation node.
|
|
1479
|
+
* @param params The parameter collector.
|
|
1480
|
+
* @returns The rendered statement.
|
|
1481
|
+
*/
|
|
1482
|
+
compileSetOp(node, params) {
|
|
1483
|
+
const keyword = ` ${this.setOperator(node.op)} `;
|
|
1484
|
+
const branches = node.branches.map((branch) => {
|
|
1485
|
+
const sql3 = branch.kind === "join_select" ? this.compileJoin(branch, params) : this.compileSelect(branch, params);
|
|
1486
|
+
const scoped = branch.orderBy.length > 0 || branch.limit !== void 0;
|
|
1487
|
+
return scoped ? `(${sql3})` : sql3;
|
|
1488
|
+
});
|
|
1489
|
+
let sql2 = branches.join(keyword);
|
|
1490
|
+
if (node.orderBy.length > 0) {
|
|
1491
|
+
const terms = node.orderBy.map((t) => {
|
|
1492
|
+
const id = typeof t.column === "string" ? this.quoteId(t.column) : this.renderExpr(t.column, params, (k) => this.quoteId(k));
|
|
1493
|
+
return `${id} ${t.direction === "desc" ? "DESC" : "ASC"}`;
|
|
1494
|
+
}).join(", ");
|
|
1495
|
+
sql2 += ` ORDER BY ${terms}`;
|
|
1496
|
+
}
|
|
1497
|
+
if (node.limit !== void 0) sql2 += ` LIMIT ${params.bind(node.limit)}`;
|
|
1498
|
+
if (node.offset !== void 0) sql2 += ` OFFSET ${params.bind(node.offset)}`;
|
|
1499
|
+
return sql2;
|
|
1500
|
+
}
|
|
1053
1501
|
/**
|
|
1054
1502
|
* Render a qualified `alias.column` ref as `"alias"."column"`, translating the
|
|
1055
1503
|
* property name to the real column name for that alias's model.
|
|
@@ -1077,7 +1525,13 @@ var BaseDialect = class _BaseDialect {
|
|
|
1077
1525
|
* @returns The quoted database identifier.
|
|
1078
1526
|
*/
|
|
1079
1527
|
columnId(prop, names) {
|
|
1080
|
-
|
|
1528
|
+
const mapped = names?.[prop];
|
|
1529
|
+
if (mapped !== void 0) return this.quoteId(mapped);
|
|
1530
|
+
const dot = prop.indexOf(".");
|
|
1531
|
+
if (dot > 0 && dot < prop.length - 1) {
|
|
1532
|
+
return `${this.quoteId(prop.slice(0, dot))}.${this.quoteId(prop.slice(dot + 1))}`;
|
|
1533
|
+
}
|
|
1534
|
+
return this.quoteId(prop);
|
|
1081
1535
|
}
|
|
1082
1536
|
/**
|
|
1083
1537
|
* Render a {@link SqlExpression} inline, binding the parameters it carries.
|
|
@@ -1094,6 +1548,9 @@ var BaseDialect = class _BaseDialect {
|
|
|
1094
1548
|
const token = expr.expression;
|
|
1095
1549
|
if (typeof token === "string") return renderPortableToken(token, this.name);
|
|
1096
1550
|
if ("raw" in token) return token.raw;
|
|
1551
|
+
if ("excluded" in token) {
|
|
1552
|
+
return renderExcluded(this.quoteId(token.excluded), token.excluded, this.name);
|
|
1553
|
+
}
|
|
1097
1554
|
const parts = token.parts;
|
|
1098
1555
|
let sql2 = parts[0] ?? "";
|
|
1099
1556
|
for (let i = 1; i < parts.length; i++) {
|
|
@@ -1103,7 +1560,29 @@ var BaseDialect = class _BaseDialect {
|
|
|
1103
1560
|
}
|
|
1104
1561
|
/** Render one write value: a SQL expression inline, anything else as a parameter. */
|
|
1105
1562
|
renderValue(value, params) {
|
|
1106
|
-
|
|
1563
|
+
if (isSqlExpression(value)) return this.renderExpression(value, params);
|
|
1564
|
+
if (isExpression(value)) {
|
|
1565
|
+
return this.renderExpr(value.node, params, (k) => this.columnId(k, void 0));
|
|
1566
|
+
}
|
|
1567
|
+
return params.bind(value);
|
|
1568
|
+
}
|
|
1569
|
+
/**
|
|
1570
|
+
* The statements that open a transaction with the requested characteristics.
|
|
1571
|
+
*
|
|
1572
|
+
* Returned as a list because the dialects disagree on shape: PostgreSQL takes
|
|
1573
|
+
* everything on the `BEGIN` itself, MySQL needs a separate `SET TRANSACTION`
|
|
1574
|
+
* before it, and SQLite has no syntax at all.
|
|
1575
|
+
*
|
|
1576
|
+
* @param options The requested isolation level and read-only flag.
|
|
1577
|
+
* @returns The statements to run, in order.
|
|
1578
|
+
* @throws Error When the dialect cannot honor what was asked.
|
|
1579
|
+
*/
|
|
1580
|
+
beginStatements(options) {
|
|
1581
|
+
const parts = ["BEGIN"];
|
|
1582
|
+
if (options?.isolation)
|
|
1583
|
+
parts.push(`ISOLATION LEVEL ${options.isolation.toUpperCase()}`);
|
|
1584
|
+
if (options?.readOnly) parts.push("READ ONLY");
|
|
1585
|
+
return [parts.join(" ")];
|
|
1107
1586
|
}
|
|
1108
1587
|
/**
|
|
1109
1588
|
* Render a row-level locking clause (`FOR UPDATE ...`).
|
|
@@ -1139,18 +1618,24 @@ var BaseDialect = class _BaseDialect {
|
|
|
1139
1618
|
if (node.aggregates.length > 0) {
|
|
1140
1619
|
const groupSel = node.groupBy.map((c) => this.columnId(c, names));
|
|
1141
1620
|
const aggSel = node.aggregates.map((a) => {
|
|
1142
|
-
const inner =
|
|
1621
|
+
const inner = this.aggregateOperand(a, params, names);
|
|
1143
1622
|
return `${a.fn.toUpperCase()}(${inner}) AS ${this.quoteId(a.alias)}`;
|
|
1144
1623
|
});
|
|
1145
1624
|
cols = [...groupSel, ...aggSel].join(", ");
|
|
1146
1625
|
} else {
|
|
1147
1626
|
cols = node.columns === "*" ? "*" : node.columns.map((c) => this.columnId(c, names)).join(", ");
|
|
1148
1627
|
}
|
|
1149
|
-
|
|
1628
|
+
const computed = Object.entries(node.computed ?? {}).map(
|
|
1629
|
+
([alias, expr]) => `${this.renderExpr(expr, params, (k) => this.columnId(k, names))} AS ${this.quoteId(alias)}`
|
|
1630
|
+
);
|
|
1631
|
+
if (computed.length > 0) cols = [cols, ...computed].join(", ");
|
|
1632
|
+
const from = node.alias ? `${this.quoteId(node.table)} AS ${this.quoteId(node.alias)}` : this.quoteId(node.table);
|
|
1633
|
+
let sql2 = `${this.compileWith(node.with, params)}SELECT ${node.distinct ? "DISTINCT " : ""}${cols} FROM ${from}`;
|
|
1150
1634
|
const where = this.compileCondition(
|
|
1151
1635
|
node.where,
|
|
1152
1636
|
params,
|
|
1153
|
-
(k) => this.columnId(k, names)
|
|
1637
|
+
(k) => this.columnId(k, names),
|
|
1638
|
+
codecEncoder(node.codecs)
|
|
1154
1639
|
);
|
|
1155
1640
|
if (where) sql2 += ` WHERE ${where}`;
|
|
1156
1641
|
if (node.groupBy.length > 0) {
|
|
@@ -1161,14 +1646,14 @@ var BaseDialect = class _BaseDialect {
|
|
|
1161
1646
|
const having = this.compileCondition(node.having, params, (key) => {
|
|
1162
1647
|
const agg = aggByAlias.get(key);
|
|
1163
1648
|
if (!agg) return this.columnId(key, names);
|
|
1164
|
-
const inner =
|
|
1649
|
+
const inner = this.aggregateOperand(agg, params, names);
|
|
1165
1650
|
return `${agg.fn.toUpperCase()}(${inner})`;
|
|
1166
1651
|
});
|
|
1167
1652
|
if (having) sql2 += ` HAVING ${having}`;
|
|
1168
1653
|
}
|
|
1169
1654
|
if (node.orderBy.length > 0) {
|
|
1170
1655
|
const terms = node.orderBy.map((t) => {
|
|
1171
|
-
const id = aggByAlias.has(t.column) ? this.quoteId(t.column) : this.columnId(t.column, names);
|
|
1656
|
+
const id = typeof t.column !== "string" ? this.renderExpr(t.column, params, (k) => this.columnId(k, names)) : aggByAlias.has(t.column) ? this.quoteId(t.column) : this.columnId(t.column, names);
|
|
1172
1657
|
return `${id} ${t.direction === "desc" ? "DESC" : "ASC"}`;
|
|
1173
1658
|
}).join(", ");
|
|
1174
1659
|
sql2 += ` ORDER BY ${terms}`;
|
|
@@ -1194,6 +1679,13 @@ var BaseDialect = class _BaseDialect {
|
|
|
1194
1679
|
* SQL order, so placeholder positions stay correct.
|
|
1195
1680
|
*/
|
|
1196
1681
|
compileInsert(node, params) {
|
|
1682
|
+
if (node.fromSelect) {
|
|
1683
|
+
const target = node.fromSelect.columns.map((c) => this.columnId(c, node.names)).join(", ");
|
|
1684
|
+
const source = node.fromSelect.select;
|
|
1685
|
+
const query = source.kind === "join_select" ? this.compileJoin(source, params) : source.kind === "set_op" ? this.compileSetOp(source, params) : this.compileSelect(source, params);
|
|
1686
|
+
const returning = this.compileReturning(node.returning, node.names);
|
|
1687
|
+
return `INSERT INTO ${this.quoteId(node.table)} (${target}) ${query}${returning}`;
|
|
1688
|
+
}
|
|
1197
1689
|
const columns = insertColumns(node.values);
|
|
1198
1690
|
const conflict = node.onConflict;
|
|
1199
1691
|
const cacheable = conflict?.targetWhere === void 0 && conflict?.updateWhere === void 0 && !insertHasExpression(node);
|
|
@@ -1309,10 +1801,12 @@ var BaseDialect = class _BaseDialect {
|
|
|
1309
1801
|
([col2, value]) => `${this.columnId(col2, names)} = ${this.renderValue(value, params)}`
|
|
1310
1802
|
).join(", ");
|
|
1311
1803
|
let sql2 = `UPDATE ${this.quoteId(node.table)} SET ${sets}`;
|
|
1804
|
+
sql2 += this.compileExtraSources("FROM", node.from);
|
|
1312
1805
|
const where = this.compileCondition(
|
|
1313
1806
|
node.where,
|
|
1314
1807
|
params,
|
|
1315
|
-
(k) => this.columnId(k, names)
|
|
1808
|
+
(k) => this.columnId(k, names),
|
|
1809
|
+
codecEncoder(node.codecs)
|
|
1316
1810
|
);
|
|
1317
1811
|
if (where) sql2 += ` WHERE ${where}`;
|
|
1318
1812
|
sql2 += this.compileReturning(node.returning, names);
|
|
@@ -1321,10 +1815,12 @@ var BaseDialect = class _BaseDialect {
|
|
|
1321
1815
|
compileDelete(node, params) {
|
|
1322
1816
|
const names = node.names;
|
|
1323
1817
|
let sql2 = `DELETE FROM ${this.quoteId(node.table)}`;
|
|
1818
|
+
sql2 += this.compileExtraSources("USING", node.using);
|
|
1324
1819
|
const where = this.compileCondition(
|
|
1325
1820
|
node.where,
|
|
1326
1821
|
params,
|
|
1327
|
-
(k) => this.columnId(k, names)
|
|
1822
|
+
(k) => this.columnId(k, names),
|
|
1823
|
+
codecEncoder(node.codecs)
|
|
1328
1824
|
);
|
|
1329
1825
|
if (where) sql2 += ` WHERE ${where}`;
|
|
1330
1826
|
sql2 += this.compileReturning(node.returning, names);
|
|
@@ -1334,9 +1830,10 @@ var BaseDialect = class _BaseDialect {
|
|
|
1334
1830
|
const names = node.names;
|
|
1335
1831
|
const cols = node.selections.map((s) => {
|
|
1336
1832
|
const ref = `${s.alias}.${s.column}`;
|
|
1337
|
-
|
|
1833
|
+
const label = node.pick ? s.column : ref;
|
|
1834
|
+
return `${this.qualify(ref, names)} AS ${this.quoteId(label)}`;
|
|
1338
1835
|
}).join(", ");
|
|
1339
|
-
let sql2 =
|
|
1836
|
+
let sql2 = `${this.compileWith(node.with, params)}SELECT ${cols} FROM ${this.quoteId(node.base.table)} AS ${this.quoteId(node.base.alias)}`;
|
|
1340
1837
|
for (const j of node.joins) {
|
|
1341
1838
|
const kw = j.kind === "left" ? "LEFT JOIN" : "INNER JOIN";
|
|
1342
1839
|
const on = j.on.map(([l, r]) => `${this.qualify(l, names)} = ${this.qualify(r, names)}`).join(" AND ");
|
|
@@ -1359,6 +1856,22 @@ var BaseDialect = class _BaseDialect {
|
|
|
1359
1856
|
return sql2;
|
|
1360
1857
|
}
|
|
1361
1858
|
// ---- clauses ----------------------------------------------------------
|
|
1859
|
+
/**
|
|
1860
|
+
* Render the extra sources of an `UPDATE ... FROM` / `DELETE ... USING`.
|
|
1861
|
+
*
|
|
1862
|
+
* The dialects that do not have the clause override this and throw: emitting it
|
|
1863
|
+
* anyway would produce a statement the server rejects, and quietly dropping it
|
|
1864
|
+
* would change which rows are written.
|
|
1865
|
+
*
|
|
1866
|
+
* @param keyword `FROM` or `USING`.
|
|
1867
|
+
* @param sources The extra tables, if any.
|
|
1868
|
+
* @returns The clause with a leading space, or an empty string.
|
|
1869
|
+
*/
|
|
1870
|
+
compileExtraSources(keyword, sources) {
|
|
1871
|
+
if (!sources || sources.length === 0) return "";
|
|
1872
|
+
const list = sources.map((s) => `${this.quoteId(s.table)} AS ${this.quoteId(s.alias)}`).join(", ");
|
|
1873
|
+
return ` ${keyword} ${list}`;
|
|
1874
|
+
}
|
|
1362
1875
|
compileReturning(returning, names) {
|
|
1363
1876
|
if (returning === null) return "";
|
|
1364
1877
|
if (returning === "*") return " RETURNING *";
|
|
@@ -1369,20 +1882,40 @@ var BaseDialect = class _BaseDialect {
|
|
|
1369
1882
|
* key to a quoted identifier — `quoteId` for single-table, `qualify` for joins —
|
|
1370
1883
|
* so select/update/delete/join all share this one compiler.
|
|
1371
1884
|
*/
|
|
1372
|
-
compileCondition(node, params, idFor) {
|
|
1885
|
+
compileCondition(node, params, idFor, encode = (_key, value) => value) {
|
|
1373
1886
|
if (!node) return "";
|
|
1374
1887
|
switch (node.kind) {
|
|
1375
1888
|
case "fields": {
|
|
1376
1889
|
const conditions = [];
|
|
1377
1890
|
for (const [key, value] of Object.entries(node.fields)) {
|
|
1378
1891
|
const id = idFor(key);
|
|
1379
|
-
if (
|
|
1892
|
+
if (isExpression(value)) {
|
|
1893
|
+
conditions.push(
|
|
1894
|
+
this.compileExprOperator(
|
|
1895
|
+
id,
|
|
1896
|
+
"eq",
|
|
1897
|
+
this.renderExpr(value.node, params, idFor)
|
|
1898
|
+
)
|
|
1899
|
+
);
|
|
1900
|
+
} else if (isOperatorObject(value)) {
|
|
1380
1901
|
for (const [op, operand] of Object.entries(value)) {
|
|
1381
|
-
conditions.push(
|
|
1902
|
+
conditions.push(
|
|
1903
|
+
isExpression(operand) ? this.compileExprOperator(
|
|
1904
|
+
id,
|
|
1905
|
+
op,
|
|
1906
|
+
this.renderExpr(operand.node, params, idFor)
|
|
1907
|
+
) : this.compileOperator(
|
|
1908
|
+
id,
|
|
1909
|
+
op,
|
|
1910
|
+
encodeOperand(op, operand, (v) => encode(key, v)),
|
|
1911
|
+
params
|
|
1912
|
+
)
|
|
1913
|
+
);
|
|
1382
1914
|
}
|
|
1383
1915
|
} else {
|
|
1916
|
+
const operand = encode(key, value);
|
|
1384
1917
|
conditions.push(
|
|
1385
|
-
|
|
1918
|
+
operand === null ? `${id} IS NULL` : `${id} = ${params.bind(operand)}`
|
|
1386
1919
|
);
|
|
1387
1920
|
}
|
|
1388
1921
|
}
|
|
@@ -1390,15 +1923,23 @@ var BaseDialect = class _BaseDialect {
|
|
|
1390
1923
|
}
|
|
1391
1924
|
case "and":
|
|
1392
1925
|
case "or": {
|
|
1393
|
-
const parts = node.parts.map((p) => this.compileCondition(p, params, idFor)).filter((s) => s.length > 0);
|
|
1926
|
+
const parts = node.parts.map((p) => this.compileCondition(p, params, idFor, encode)).filter((s) => s.length > 0);
|
|
1394
1927
|
if (parts.length === 0) return "";
|
|
1395
1928
|
const sep = node.kind === "and" ? " AND " : " OR ";
|
|
1396
1929
|
return parts.map((p) => `(${p})`).join(sep);
|
|
1397
1930
|
}
|
|
1398
1931
|
case "not": {
|
|
1399
|
-
const inner = this.compileCondition(node.part, params, idFor);
|
|
1932
|
+
const inner = this.compileCondition(node.part, params, idFor, encode);
|
|
1400
1933
|
return inner ? `NOT (${inner})` : "";
|
|
1401
1934
|
}
|
|
1935
|
+
case "exists": {
|
|
1936
|
+
const select2 = node.select;
|
|
1937
|
+
this.checkSubquery(select2);
|
|
1938
|
+
const keyword = node.negate ? "NOT EXISTS" : "EXISTS";
|
|
1939
|
+
return `${keyword} (${this.compileSelect(select2, params)})`;
|
|
1940
|
+
}
|
|
1941
|
+
case "fullText":
|
|
1942
|
+
return this.compileFullText(node, params, idFor);
|
|
1402
1943
|
case "compare": {
|
|
1403
1944
|
const left = this.renderExpr(node.left, params, idFor);
|
|
1404
1945
|
if (node.right.kind === "value") {
|
|
@@ -1412,6 +1953,22 @@ var BaseDialect = class _BaseDialect {
|
|
|
1412
1953
|
}
|
|
1413
1954
|
}
|
|
1414
1955
|
}
|
|
1956
|
+
/**
|
|
1957
|
+
* Compile a full-text condition.
|
|
1958
|
+
*
|
|
1959
|
+
* PostgreSQL gets the real thing (`@@ websearch_to_tsquery`); the dialects with
|
|
1960
|
+
* no text-search engine override this and compile the node's prebuilt substring
|
|
1961
|
+
* fallback instead, so the query still returns the right rows.
|
|
1962
|
+
*
|
|
1963
|
+
* @param node The full-text condition node.
|
|
1964
|
+
* @param params The parameter collector.
|
|
1965
|
+
* @param idFor Column-name resolver.
|
|
1966
|
+
* @returns The rendered condition.
|
|
1967
|
+
*/
|
|
1968
|
+
compileFullText(node, params, idFor) {
|
|
1969
|
+
const config = params.bind(node.language);
|
|
1970
|
+
return `${this.tsVector(node.columns, config, idFor)} @@ websearch_to_tsquery(${config}::regconfig, ${params.bind(node.term)})`;
|
|
1971
|
+
}
|
|
1415
1972
|
/**
|
|
1416
1973
|
* Render one side of a comparison.
|
|
1417
1974
|
*
|
|
@@ -1424,6 +1981,23 @@ var BaseDialect = class _BaseDialect {
|
|
|
1424
1981
|
* @param idFor The identifier resolver for the enclosing statement.
|
|
1425
1982
|
* @returns The SQL text of the expression.
|
|
1426
1983
|
*/
|
|
1984
|
+
/**
|
|
1985
|
+
* Render what an aggregate is applied to: `*`, a column, or an expression.
|
|
1986
|
+
*
|
|
1987
|
+
* An expression operand is what makes a conditional aggregate
|
|
1988
|
+
* (`SUM(CASE WHEN ... END)`) expressible — one pass over the table instead of a
|
|
1989
|
+
* query per bucket.
|
|
1990
|
+
*
|
|
1991
|
+
* @param agg The aggregate term.
|
|
1992
|
+
* @param params The parameter collector.
|
|
1993
|
+
* @param names The node's column-name map.
|
|
1994
|
+
* @returns The rendered operand.
|
|
1995
|
+
*/
|
|
1996
|
+
aggregateOperand(agg, params, names) {
|
|
1997
|
+
if (agg.column === "*") return "*";
|
|
1998
|
+
if (typeof agg.column === "string") return this.columnId(agg.column, names);
|
|
1999
|
+
return this.renderExpr(agg.column, params, (k) => this.columnId(k, names));
|
|
2000
|
+
}
|
|
1427
2001
|
renderExpr(node, params, idFor) {
|
|
1428
2002
|
switch (node.kind) {
|
|
1429
2003
|
case "column":
|
|
@@ -1434,6 +2008,109 @@ var BaseDialect = class _BaseDialect {
|
|
|
1434
2008
|
const args = node.args.map((a) => this.renderExpr(a, params, idFor)).join(", ");
|
|
1435
2009
|
return `${node.name}(${args})`;
|
|
1436
2010
|
}
|
|
2011
|
+
case "case": {
|
|
2012
|
+
const branches = node.branches.map(
|
|
2013
|
+
(b) => `WHEN ${this.compileCondition(b.when, params, idFor)} THEN ${this.renderExpr(b.result, params, idFor)}`
|
|
2014
|
+
).join(" ");
|
|
2015
|
+
const fallback = node.fallback === null ? "" : ` ELSE ${this.renderExpr(node.fallback, params, idFor)}`;
|
|
2016
|
+
return `CASE ${branches}${fallback} END`;
|
|
2017
|
+
}
|
|
2018
|
+
case "cast":
|
|
2019
|
+
return `CAST(${this.renderExpr(node.operand, params, idFor)} AS ${this.castTypeName(node.to)})`;
|
|
2020
|
+
case "scalar": {
|
|
2021
|
+
const select2 = node.select;
|
|
2022
|
+
this.checkSubquery(select2);
|
|
2023
|
+
return `(${this.compileSelect(select2, params)})`;
|
|
2024
|
+
}
|
|
2025
|
+
case "rank":
|
|
2026
|
+
return this.renderRank(node.columns, node.term, node.language, params, idFor);
|
|
2027
|
+
case "star":
|
|
2028
|
+
return "*";
|
|
2029
|
+
case "window": {
|
|
2030
|
+
const call2 = this.renderExpr(node.fn, params, idFor);
|
|
2031
|
+
const parts = [];
|
|
2032
|
+
if (node.partitionBy.length > 0) {
|
|
2033
|
+
parts.push(`PARTITION BY ${node.partitionBy.map(idFor).join(", ")}`);
|
|
2034
|
+
}
|
|
2035
|
+
if (node.orderBy.length > 0) {
|
|
2036
|
+
const terms = node.orderBy.map((t) => `${idFor(t.column)} ${t.direction === "desc" ? "DESC" : "ASC"}`).join(", ");
|
|
2037
|
+
parts.push(`ORDER BY ${terms}`);
|
|
2038
|
+
}
|
|
2039
|
+
if (node.frame) parts.push(node.frame);
|
|
2040
|
+
return `${call2} OVER (${parts.join(" ")})`;
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
/**
|
|
2045
|
+
* Render a full-text relevance score.
|
|
2046
|
+
*
|
|
2047
|
+
* PostgreSQL has `ts_rank`; the others have nothing equivalent, and they
|
|
2048
|
+
* override this to a constant so that ordering by it is a no-op rather than a
|
|
2049
|
+
* compile error — the fallback keeps returning the right rows, only unranked.
|
|
2050
|
+
*
|
|
2051
|
+
* @param columns The columns making up the document.
|
|
2052
|
+
* @param term The search term.
|
|
2053
|
+
* @param language The text-search configuration.
|
|
2054
|
+
* @param params The parameter collector.
|
|
2055
|
+
* @param idFor Column-name resolver.
|
|
2056
|
+
* @returns The rendered score expression.
|
|
2057
|
+
*/
|
|
2058
|
+
renderRank(columns, term, language, params, idFor) {
|
|
2059
|
+
const config = params.bind(language);
|
|
2060
|
+
return `ts_rank(${this.tsVector(columns, config, idFor)}, websearch_to_tsquery(${config}::regconfig, ${params.bind(term)}))`;
|
|
2061
|
+
}
|
|
2062
|
+
/**
|
|
2063
|
+
* Build the `to_tsvector(...)` document out of the searched columns.
|
|
2064
|
+
*
|
|
2065
|
+
* `coalesce(col, '')` matters: in SQL a `NULL` anywhere in a concatenation makes
|
|
2066
|
+
* the whole document `NULL`, so one empty column would silently exclude the row.
|
|
2067
|
+
*
|
|
2068
|
+
* @param columns The columns making up the document.
|
|
2069
|
+
* @param config The already-bound placeholder for the text-search config.
|
|
2070
|
+
* @param idFor Column-name resolver.
|
|
2071
|
+
* @returns The rendered `to_tsvector(...)` call.
|
|
2072
|
+
*/
|
|
2073
|
+
tsVector(columns, config, idFor) {
|
|
2074
|
+
const document = columns.map((c) => `coalesce(${idFor(c)}, '')`).join(" || ' ' || ");
|
|
2075
|
+
return `to_tsvector(${config}::regconfig, ${document})`;
|
|
2076
|
+
}
|
|
2077
|
+
/**
|
|
2078
|
+
* The SQL type name this dialect accepts in a `CAST`.
|
|
2079
|
+
*
|
|
2080
|
+
* The base mapping is the standard one PostgreSQL takes; SQLite and MySQL
|
|
2081
|
+
* override it, because the names genuinely differ (MySQL's `CAST(x AS SIGNED)`
|
|
2082
|
+
* is not `INTEGER`, and SQLite only has five storage classes to aim at).
|
|
2083
|
+
*
|
|
2084
|
+
* @param to The portable cast target.
|
|
2085
|
+
* @returns The dialect's own type name.
|
|
2086
|
+
*/
|
|
2087
|
+
castTypeName(to) {
|
|
2088
|
+
switch (to) {
|
|
2089
|
+
case "integer":
|
|
2090
|
+
return "INTEGER";
|
|
2091
|
+
case "bigint":
|
|
2092
|
+
return "BIGINT";
|
|
2093
|
+
case "real":
|
|
2094
|
+
return "DOUBLE PRECISION";
|
|
2095
|
+
case "numeric":
|
|
2096
|
+
return "NUMERIC";
|
|
2097
|
+
case "text":
|
|
2098
|
+
return "TEXT";
|
|
2099
|
+
case "boolean":
|
|
2100
|
+
return "BOOLEAN";
|
|
2101
|
+
case "date":
|
|
2102
|
+
return "DATE";
|
|
2103
|
+
case "datetime":
|
|
2104
|
+
case "timestamp":
|
|
2105
|
+
return "TIMESTAMP";
|
|
2106
|
+
case "uuid":
|
|
2107
|
+
return "UUID";
|
|
2108
|
+
case "json":
|
|
2109
|
+
return "JSON";
|
|
2110
|
+
case "jsonb":
|
|
2111
|
+
return "JSONB";
|
|
2112
|
+
case "blob":
|
|
2113
|
+
return "BYTEA";
|
|
1437
2114
|
}
|
|
1438
2115
|
}
|
|
1439
2116
|
/**
|
|
@@ -1470,6 +2147,10 @@ var BaseDialect = class _BaseDialect {
|
|
|
1470
2147
|
return this.ilike(left, right);
|
|
1471
2148
|
case "ieq":
|
|
1472
2149
|
return `lower(${left}) = lower(${right})`;
|
|
2150
|
+
case "iContains":
|
|
2151
|
+
throw new Error(
|
|
2152
|
+
'The "iContains" operator matches a literal, so it takes a value, not an expression.'
|
|
2153
|
+
);
|
|
1473
2154
|
case "contains":
|
|
1474
2155
|
case "containedBy":
|
|
1475
2156
|
case "overlaps":
|
|
@@ -1498,6 +2179,8 @@ var BaseDialect = class _BaseDialect {
|
|
|
1498
2179
|
return this.ilike(id, params.bind(operand));
|
|
1499
2180
|
case "ieq":
|
|
1500
2181
|
return operand === null ? `${id} IS NULL` : `lower(${id}) = lower(${params.bind(operand)})`;
|
|
2182
|
+
case "iContains":
|
|
2183
|
+
return this.ilikeEscaped(id, params.bind(`%${escapeLike(String(operand))}%`));
|
|
1501
2184
|
case "contains":
|
|
1502
2185
|
return `${id} ${this.arrayOperator("contains")} ${params.bind(operand)}`;
|
|
1503
2186
|
case "containedBy":
|
|
@@ -1549,12 +2232,93 @@ var BaseDialect = class _BaseDialect {
|
|
|
1549
2232
|
};
|
|
1550
2233
|
var SqliteDialect = class extends BaseDialect {
|
|
1551
2234
|
name = "sqlite";
|
|
2235
|
+
/**
|
|
2236
|
+
* SQLite explains with `EXPLAIN QUERY PLAN`, and has no `ANALYZE` — the plain
|
|
2237
|
+
* `EXPLAIN` there dumps bytecode, which answers a different question.
|
|
2238
|
+
*/
|
|
2239
|
+
explainPrefix(analyze) {
|
|
2240
|
+
if (analyze) {
|
|
2241
|
+
throw new Error(
|
|
2242
|
+
"SQLite has no EXPLAIN ANALYZE; use EXPLAIN QUERY PLAN (analyze: false)."
|
|
2243
|
+
);
|
|
2244
|
+
}
|
|
2245
|
+
return "EXPLAIN QUERY PLAN";
|
|
2246
|
+
}
|
|
2247
|
+
/**
|
|
2248
|
+
* SQLite has `UPDATE ... FROM` (3.33+) but no `DELETE ... USING`.
|
|
2249
|
+
*
|
|
2250
|
+
* The portable shape there is a subquery — `where({ id: { in: … } })` — so this
|
|
2251
|
+
* throws and says so, rather than emitting a clause SQLite does not parse.
|
|
2252
|
+
*/
|
|
2253
|
+
compileExtraSources(keyword, sources) {
|
|
2254
|
+
if (keyword === "USING" && sources && sources.length > 0) {
|
|
2255
|
+
throw new Error(
|
|
2256
|
+
'SQLite has no DELETE ... USING; filter with a subquery instead: where({ id: { in: select(Other, ["id"]).asSubquery("id") } }).'
|
|
2257
|
+
);
|
|
2258
|
+
}
|
|
2259
|
+
return super.compileExtraSources(keyword, sources);
|
|
2260
|
+
}
|
|
2261
|
+
/**
|
|
2262
|
+
* SQLite has no text-search engine, so the prebuilt substring fallback is
|
|
2263
|
+
* compiled instead. The rows are right; the ranking is what is missing.
|
|
2264
|
+
*/
|
|
2265
|
+
compileFullText(node, params, idFor) {
|
|
2266
|
+
return this.compileCondition(node.fallback, params, idFor);
|
|
2267
|
+
}
|
|
2268
|
+
/** No text-search engine means no score: a constant, so ordering by it is inert. */
|
|
2269
|
+
renderRank() {
|
|
2270
|
+
return "0";
|
|
2271
|
+
}
|
|
2272
|
+
/**
|
|
2273
|
+
* SQLite has five storage classes, so most targets collapse onto `TEXT` or
|
|
2274
|
+
* `INTEGER`. Naming a type it does not know would not fail — SQLite applies the
|
|
2275
|
+
* closest affinity — but it would make the cast mean something different here
|
|
2276
|
+
* than on the other databases, which is what this mapping avoids.
|
|
2277
|
+
*/
|
|
2278
|
+
castTypeName(to) {
|
|
2279
|
+
switch (to) {
|
|
2280
|
+
case "integer":
|
|
2281
|
+
case "bigint":
|
|
2282
|
+
case "boolean":
|
|
2283
|
+
return "INTEGER";
|
|
2284
|
+
case "real":
|
|
2285
|
+
return "REAL";
|
|
2286
|
+
case "numeric":
|
|
2287
|
+
return "NUMERIC";
|
|
2288
|
+
case "blob":
|
|
2289
|
+
return "BLOB";
|
|
2290
|
+
default:
|
|
2291
|
+
return "TEXT";
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
1552
2294
|
placeholder() {
|
|
1553
2295
|
return "?";
|
|
1554
2296
|
}
|
|
1555
2297
|
ilike(column2, param) {
|
|
1556
2298
|
return `${column2} LIKE ${param}`;
|
|
1557
2299
|
}
|
|
2300
|
+
/**
|
|
2301
|
+
* SQLite runs one writer at a time, so its only isolation level **is**
|
|
2302
|
+
* serializable — there is no syntax to ask for another, and no weaker level to
|
|
2303
|
+
* fall back to. Asking for one is an error rather than a silent no-op, since a
|
|
2304
|
+
* caller who wrote `repeatable read` was reasoning about a guarantee.
|
|
2305
|
+
*
|
|
2306
|
+
* `readOnly` likewise has no per-transaction form here (`PRAGMA query_only` is
|
|
2307
|
+
* per connection), so it is refused instead of quietly ignored.
|
|
2308
|
+
*/
|
|
2309
|
+
beginStatements(options) {
|
|
2310
|
+
if (options?.isolation && options.isolation !== "serializable") {
|
|
2311
|
+
throw new Error(
|
|
2312
|
+
`SQLite only implements the "serializable" isolation level; ${JSON.stringify(options.isolation)} has no equivalent here.`
|
|
2313
|
+
);
|
|
2314
|
+
}
|
|
2315
|
+
if (options?.readOnly) {
|
|
2316
|
+
throw new Error(
|
|
2317
|
+
"SQLite has no read-only transaction; open the engine with { sqlite: { ... } } on a read-only connection instead."
|
|
2318
|
+
);
|
|
2319
|
+
}
|
|
2320
|
+
return ["BEGIN"];
|
|
2321
|
+
}
|
|
1558
2322
|
/**
|
|
1559
2323
|
* SQLite has no row-level locking, so a lock request is an error rather than a
|
|
1560
2324
|
* silently unlocked `SELECT` — a lock that does not exist only shows up as
|
|
@@ -1568,8 +2332,8 @@ var SqliteDialect = class extends BaseDialect {
|
|
|
1568
2332
|
};
|
|
1569
2333
|
var PostgresDialect = class extends BaseDialect {
|
|
1570
2334
|
name = "postgresql";
|
|
1571
|
-
placeholder(
|
|
1572
|
-
return `$${
|
|
2335
|
+
placeholder(index2) {
|
|
2336
|
+
return `$${index2}`;
|
|
1573
2337
|
}
|
|
1574
2338
|
ilike(column2, param) {
|
|
1575
2339
|
return `${column2} ILIKE ${param}`;
|
|
@@ -1589,30 +2353,114 @@ var MysqlDialect = class extends BaseDialect {
|
|
|
1589
2353
|
ilike(column2, param) {
|
|
1590
2354
|
return `${column2} LIKE ${param}`;
|
|
1591
2355
|
}
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
const quoted = `\`${name.replace(/`/g, "``")}\``;
|
|
1596
|
-
mysqlQuotedIds.set(name, quoted);
|
|
1597
|
-
return quoted;
|
|
2356
|
+
/** MySQL's `EXPLAIN FORMAT=JSON` spells the option differently. */
|
|
2357
|
+
explainPrefix(analyze) {
|
|
2358
|
+
return analyze ? "EXPLAIN ANALYZE" : "EXPLAIN FORMAT=JSON";
|
|
1598
2359
|
}
|
|
1599
2360
|
/**
|
|
1600
|
-
* MySQL
|
|
1601
|
-
*
|
|
1602
|
-
*
|
|
1603
|
-
* instead of surfacing that error from the driver at runtime.
|
|
2361
|
+
* MySQL only gained `INTERSECT`/`EXCEPT` in 8.0.31, and this project does not
|
|
2362
|
+
* invest in MySQL beyond what already works — so they are refused here rather
|
|
2363
|
+
* than emitted against a server that may reject them.
|
|
1604
2364
|
*/
|
|
1605
|
-
|
|
1606
|
-
if (
|
|
2365
|
+
setOperator(op) {
|
|
2366
|
+
if (op === "intersect" || op === "except") {
|
|
1607
2367
|
throw new Error(
|
|
1608
|
-
|
|
2368
|
+
`MySQL support for ${op.toUpperCase()} is out of scope for tempest-db-js; express it with a join or NOT EXISTS.`
|
|
1609
2369
|
);
|
|
1610
2370
|
}
|
|
2371
|
+
return super.setOperator(op);
|
|
1611
2372
|
}
|
|
1612
|
-
|
|
1613
|
-
|
|
2373
|
+
/**
|
|
2374
|
+
* MySQL writes multi-table updates as `UPDATE a JOIN b`, and has no
|
|
2375
|
+
* `DELETE ... USING` in this shape. Both are out of the project's active scope,
|
|
2376
|
+
* so they are refused rather than emitted against a server that rejects them.
|
|
2377
|
+
*/
|
|
2378
|
+
compileExtraSources(keyword, sources) {
|
|
2379
|
+
if (sources && sources.length > 0) {
|
|
1614
2380
|
throw new Error(
|
|
1615
|
-
|
|
2381
|
+
`MySQL does not take ${keyword} on a write in this form; it spells multi-table writes as UPDATE a JOIN b, which is out of scope for tempest-db-js.`
|
|
2382
|
+
);
|
|
2383
|
+
}
|
|
2384
|
+
return "";
|
|
2385
|
+
}
|
|
2386
|
+
/**
|
|
2387
|
+
* MySQL's full-text search needs a `FULLTEXT` index and different syntax, and it
|
|
2388
|
+
* is outside this project's active scope — the substring fallback is compiled,
|
|
2389
|
+
* like on SQLite.
|
|
2390
|
+
*/
|
|
2391
|
+
compileFullText(node, params, idFor) {
|
|
2392
|
+
return this.compileCondition(node.fallback, params, idFor);
|
|
2393
|
+
}
|
|
2394
|
+
/** No `ts_rank` equivalent in scope: a constant, so ordering by it is inert. */
|
|
2395
|
+
renderRank() {
|
|
2396
|
+
return "0";
|
|
2397
|
+
}
|
|
2398
|
+
/**
|
|
2399
|
+
* MySQL sets the level with a statement **before** the transaction opens, and
|
|
2400
|
+
* spells the read-only flag on `START TRANSACTION` rather than on `BEGIN`.
|
|
2401
|
+
*/
|
|
2402
|
+
beginStatements(options) {
|
|
2403
|
+
const statements = [];
|
|
2404
|
+
if (options?.isolation) {
|
|
2405
|
+
statements.push(
|
|
2406
|
+
`SET TRANSACTION ISOLATION LEVEL ${options.isolation.toUpperCase()}`
|
|
2407
|
+
);
|
|
2408
|
+
}
|
|
2409
|
+
statements.push(options?.readOnly ? "START TRANSACTION READ ONLY" : "BEGIN");
|
|
2410
|
+
return statements;
|
|
2411
|
+
}
|
|
2412
|
+
/**
|
|
2413
|
+
* MySQL's `CAST` takes its own vocabulary — `SIGNED`, not `INTEGER`; `CHAR`,
|
|
2414
|
+
* not `TEXT` — and rejects the standard names outright.
|
|
2415
|
+
*/
|
|
2416
|
+
castTypeName(to) {
|
|
2417
|
+
switch (to) {
|
|
2418
|
+
case "integer":
|
|
2419
|
+
case "bigint":
|
|
2420
|
+
case "boolean":
|
|
2421
|
+
return "SIGNED";
|
|
2422
|
+
case "real":
|
|
2423
|
+
case "numeric":
|
|
2424
|
+
return "DECIMAL";
|
|
2425
|
+
case "text":
|
|
2426
|
+
case "uuid":
|
|
2427
|
+
return "CHAR";
|
|
2428
|
+
case "date":
|
|
2429
|
+
return "DATE";
|
|
2430
|
+
case "datetime":
|
|
2431
|
+
case "timestamp":
|
|
2432
|
+
return "DATETIME";
|
|
2433
|
+
case "json":
|
|
2434
|
+
case "jsonb":
|
|
2435
|
+
return "JSON";
|
|
2436
|
+
case "blob":
|
|
2437
|
+
return "BINARY";
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
quoteId(name) {
|
|
2441
|
+
const cached = mysqlQuotedIds.get(name);
|
|
2442
|
+
if (cached !== void 0) return cached;
|
|
2443
|
+
const quoted = `\`${name.replace(/`/g, "``")}\``;
|
|
2444
|
+
mysqlQuotedIds.set(name, quoted);
|
|
2445
|
+
return quoted;
|
|
2446
|
+
}
|
|
2447
|
+
/**
|
|
2448
|
+
* MySQL rejects `LIMIT` inside an `IN` subquery with
|
|
2449
|
+
* `ER_NOT_SUPPORTED_YET: This version of MySQL doesn't yet support
|
|
2450
|
+
* 'LIMIT & IN/ALL/ANY/SOME subquery'`. Failing at compile time names the fix
|
|
2451
|
+
* instead of surfacing that error from the driver at runtime.
|
|
2452
|
+
*/
|
|
2453
|
+
checkSubquery(node) {
|
|
2454
|
+
if (node.limit !== void 0 || node.offset !== void 0) {
|
|
2455
|
+
throw new Error(
|
|
2456
|
+
"MySQL does not support LIMIT/OFFSET inside an IN subquery. Select the ids first and pass them as a list, or wrap the subquery in a derived table."
|
|
2457
|
+
);
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
renderConflict(onConflict, conflictCols, nextValue, names) {
|
|
2461
|
+
if (onConflict.targetWhere || onConflict.updateWhere) {
|
|
2462
|
+
throw new Error(
|
|
2463
|
+
"MySQL's ON DUPLICATE KEY UPDATE has no conflict-target predicate \u2014 a partial unique index is PostgreSQL/SQLite only."
|
|
1616
2464
|
);
|
|
1617
2465
|
}
|
|
1618
2466
|
if (onConflict.update === "nothing") {
|
|
@@ -1679,7 +2527,7 @@ var JoinBuilder = class _JoinBuilder {
|
|
|
1679
2527
|
clause(kind, model, alias, on) {
|
|
1680
2528
|
return {
|
|
1681
2529
|
kind,
|
|
1682
|
-
table: model.tablename,
|
|
2530
|
+
table: aliasOf(model)?.tablename ?? model.tablename,
|
|
1683
2531
|
alias,
|
|
1684
2532
|
on: Object.entries(on)
|
|
1685
2533
|
};
|
|
@@ -1698,6 +2546,38 @@ var JoinBuilder = class _JoinBuilder {
|
|
|
1698
2546
|
model
|
|
1699
2547
|
);
|
|
1700
2548
|
}
|
|
2549
|
+
/**
|
|
2550
|
+
* Project **one** source, flat, instead of the composite row.
|
|
2551
|
+
*
|
|
2552
|
+
* The join still happens — it just stops being what comes back. Needed wherever
|
|
2553
|
+
* the result has to match a single table's shape: the recursive branch of a
|
|
2554
|
+
* CTE, an `INSERT ... SELECT`, a `UNION` branch.
|
|
2555
|
+
*
|
|
2556
|
+
* @param alias The source to project.
|
|
2557
|
+
* @returns A builder whose rows are that source's rows.
|
|
2558
|
+
*
|
|
2559
|
+
* @example
|
|
2560
|
+
* ```ts
|
|
2561
|
+
* join(Category, "c").innerJoin(subtree, "s", { "c.parentId": "s.id" }).pick("c");
|
|
2562
|
+
* // SELECT "c"."id" AS "id", "c"."name" AS "name" ... — not "c.id"
|
|
2563
|
+
* ```
|
|
2564
|
+
*/
|
|
2565
|
+
pick(alias) {
|
|
2566
|
+
const model = this.sources[alias];
|
|
2567
|
+
if (!model) {
|
|
2568
|
+
throw new Error(
|
|
2569
|
+
`pick(${JSON.stringify(alias)}): no source is joined under that alias.`
|
|
2570
|
+
);
|
|
2571
|
+
}
|
|
2572
|
+
return new _JoinBuilder(
|
|
2573
|
+
{
|
|
2574
|
+
...this.node,
|
|
2575
|
+
selections: selectionsFor(alias, model),
|
|
2576
|
+
pick: alias
|
|
2577
|
+
},
|
|
2578
|
+
this.sources
|
|
2579
|
+
);
|
|
2580
|
+
}
|
|
1701
2581
|
/** Filter by `alias.column` references (object form) or an `and`/`or`/`not`. */
|
|
1702
2582
|
where(input) {
|
|
1703
2583
|
return new _JoinBuilder(
|
|
@@ -1726,7 +2606,7 @@ function join(model, alias) {
|
|
|
1726
2606
|
return new JoinBuilder(
|
|
1727
2607
|
{
|
|
1728
2608
|
kind: "join_select",
|
|
1729
|
-
base: { table: model.tablename, alias },
|
|
2609
|
+
base: { table: aliasOf(model)?.tablename ?? model.tablename, alias },
|
|
1730
2610
|
joins: [],
|
|
1731
2611
|
selections: selectionsFor(alias, model),
|
|
1732
2612
|
where: void 0,
|
|
@@ -1739,46 +2619,388 @@ function join(model, alias) {
|
|
|
1739
2619
|
);
|
|
1740
2620
|
}
|
|
1741
2621
|
|
|
2622
|
+
// src/mixins.ts
|
|
2623
|
+
function withTimestamps(Base) {
|
|
2624
|
+
class WithTimestamps extends Base {
|
|
2625
|
+
/** When the row was inserted. */
|
|
2626
|
+
createdAt = column.datetime().notNull().default(sql.now());
|
|
2627
|
+
/** When the row was last updated — refreshed by every `UPDATE`. */
|
|
2628
|
+
updatedAt = column.datetime().notNull().default(sql.now()).onUpdate(sql.now());
|
|
2629
|
+
}
|
|
2630
|
+
return WithTimestamps;
|
|
2631
|
+
}
|
|
2632
|
+
function withSoftDelete(Base) {
|
|
2633
|
+
class WithSoftDelete extends Base {
|
|
2634
|
+
/** When the row was soft-deleted, or `null` while it is alive. */
|
|
2635
|
+
deletedAt = column.datetime();
|
|
2636
|
+
}
|
|
2637
|
+
return WithSoftDelete;
|
|
2638
|
+
}
|
|
2639
|
+
function notDeleted() {
|
|
2640
|
+
return { deletedAt: { isNull: true } };
|
|
2641
|
+
}
|
|
2642
|
+
function onlyDeleted() {
|
|
2643
|
+
return { deletedAt: { isNull: false } };
|
|
2644
|
+
}
|
|
2645
|
+
function withAudit(Base, actor = () => column.uuid()) {
|
|
2646
|
+
class WithAudit extends Base {
|
|
2647
|
+
/** Who created the row, or `null` when nobody was attributed. */
|
|
2648
|
+
createdBy = actor();
|
|
2649
|
+
/** Who last updated the row, or `null`. */
|
|
2650
|
+
updatedBy = actor();
|
|
2651
|
+
}
|
|
2652
|
+
return WithAudit;
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
// src/integrity.ts
|
|
2656
|
+
var POSTGRES_VIOLATIONS = {
|
|
2657
|
+
"23505": "unique",
|
|
2658
|
+
"23503": "foreignKey",
|
|
2659
|
+
"23502": "notNull",
|
|
2660
|
+
"23514": "check",
|
|
2661
|
+
"23P01": "exclusion"
|
|
2662
|
+
};
|
|
2663
|
+
var SQLITE_ERRCODES = {
|
|
2664
|
+
1555: "unique",
|
|
2665
|
+
// SQLITE_CONSTRAINT_PRIMARYKEY
|
|
2666
|
+
2067: "unique",
|
|
2667
|
+
// SQLITE_CONSTRAINT_UNIQUE
|
|
2668
|
+
787: "foreignKey",
|
|
2669
|
+
// SQLITE_CONSTRAINT_FOREIGNKEY
|
|
2670
|
+
1299: "notNull",
|
|
2671
|
+
// SQLITE_CONSTRAINT_NOTNULL
|
|
2672
|
+
275: "check"
|
|
2673
|
+
// SQLITE_CONSTRAINT_CHECK
|
|
2674
|
+
};
|
|
2675
|
+
var SQLITE_CODES = {
|
|
2676
|
+
SQLITE_CONSTRAINT_PRIMARYKEY: "unique",
|
|
2677
|
+
SQLITE_CONSTRAINT_UNIQUE: "unique",
|
|
2678
|
+
SQLITE_CONSTRAINT_FOREIGNKEY: "foreignKey",
|
|
2679
|
+
SQLITE_CONSTRAINT_NOTNULL: "notNull",
|
|
2680
|
+
SQLITE_CONSTRAINT_CHECK: "check"
|
|
2681
|
+
};
|
|
2682
|
+
function str(error, key) {
|
|
2683
|
+
const value = error[key];
|
|
2684
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
2685
|
+
}
|
|
2686
|
+
function postgresDetailColumns(detail) {
|
|
2687
|
+
if (!detail) return [];
|
|
2688
|
+
const match = /Key \(([^)]+)\)=/.exec(detail);
|
|
2689
|
+
if (!match?.[1]) return [];
|
|
2690
|
+
return match[1].split(",").map((c) => c.trim().replace(/^"|"$/g, ""));
|
|
2691
|
+
}
|
|
2692
|
+
function sqliteTargets(list) {
|
|
2693
|
+
const columns = [];
|
|
2694
|
+
let table = null;
|
|
2695
|
+
for (const entry of list.split(",")) {
|
|
2696
|
+
const [left, right] = entry.trim().split(".");
|
|
2697
|
+
if (right === void 0) {
|
|
2698
|
+
if (left) columns.push(left);
|
|
2699
|
+
continue;
|
|
2700
|
+
}
|
|
2701
|
+
table = left ?? null;
|
|
2702
|
+
columns.push(right);
|
|
2703
|
+
}
|
|
2704
|
+
return { table, columns };
|
|
2705
|
+
}
|
|
2706
|
+
function fromPostgres(error) {
|
|
2707
|
+
const code = str(error, "code");
|
|
2708
|
+
const violation = code ? POSTGRES_VIOLATIONS[code] : void 0;
|
|
2709
|
+
if (!violation) return null;
|
|
2710
|
+
const detail = str(error, "detail");
|
|
2711
|
+
const columns = postgresDetailColumns(detail);
|
|
2712
|
+
const column2 = str(error, "column_name");
|
|
2713
|
+
return {
|
|
2714
|
+
violation,
|
|
2715
|
+
constraint: str(error, "constraint_name"),
|
|
2716
|
+
table: str(error, "table_name"),
|
|
2717
|
+
columns: columns.length > 0 ? columns : column2 ? [column2] : [],
|
|
2718
|
+
detail: str(error, "message") ?? detail ?? ""
|
|
2719
|
+
};
|
|
2720
|
+
}
|
|
2721
|
+
function fromSqlite(error) {
|
|
2722
|
+
const code = str(error, "code");
|
|
2723
|
+
const errcode = error.errcode;
|
|
2724
|
+
const violation = (code ? SQLITE_CODES[code] : void 0) ?? (typeof errcode === "number" ? SQLITE_ERRCODES[errcode] : void 0);
|
|
2725
|
+
if (!violation) return null;
|
|
2726
|
+
const message = str(error, "message") ?? "";
|
|
2727
|
+
if (violation === "check") {
|
|
2728
|
+
const named = /CHECK constraint failed: (.+)$/.exec(message);
|
|
2729
|
+
return {
|
|
2730
|
+
violation,
|
|
2731
|
+
constraint: named?.[1] ?? null,
|
|
2732
|
+
table: null,
|
|
2733
|
+
columns: [],
|
|
2734
|
+
detail: message
|
|
2735
|
+
};
|
|
2736
|
+
}
|
|
2737
|
+
const targets = /constraint failed: (.+)$/.exec(message);
|
|
2738
|
+
if (!targets?.[1]) {
|
|
2739
|
+
return { violation, constraint: null, table: null, columns: [], detail: message };
|
|
2740
|
+
}
|
|
2741
|
+
const { table, columns } = sqliteTargets(targets[1]);
|
|
2742
|
+
return { violation, constraint: null, table, columns, detail: message };
|
|
2743
|
+
}
|
|
2744
|
+
function parseIntegrityError(error, model) {
|
|
2745
|
+
let current = error;
|
|
2746
|
+
for (let depth = 0; depth < 5 && current !== null && current !== void 0; depth++) {
|
|
2747
|
+
if (typeof current === "object") {
|
|
2748
|
+
const record = current;
|
|
2749
|
+
const failure = fromPostgres(record) ?? fromSqlite(record);
|
|
2750
|
+
if (failure) return model ? withModelNames(failure, model) : failure;
|
|
2751
|
+
current = record.cause;
|
|
2752
|
+
continue;
|
|
2753
|
+
}
|
|
2754
|
+
return null;
|
|
2755
|
+
}
|
|
2756
|
+
return null;
|
|
2757
|
+
}
|
|
2758
|
+
function withModelNames(failure, model) {
|
|
2759
|
+
const props = columnPropsOf(model);
|
|
2760
|
+
if (!props || failure.columns.length === 0) return failure;
|
|
2761
|
+
return { ...failure, columns: failure.columns.map((c) => props[c] ?? c) };
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
// src/signals.ts
|
|
2765
|
+
var registry = /* @__PURE__ */ new WeakMap();
|
|
2766
|
+
var registered = /* @__PURE__ */ new Set();
|
|
2767
|
+
function onSignal(model, signal, handler) {
|
|
2768
|
+
let bySignal = registry.get(model);
|
|
2769
|
+
if (!bySignal) {
|
|
2770
|
+
bySignal = /* @__PURE__ */ new Map();
|
|
2771
|
+
registry.set(model, bySignal);
|
|
2772
|
+
registered.add(model);
|
|
2773
|
+
}
|
|
2774
|
+
let handlers = bySignal.get(signal);
|
|
2775
|
+
if (!handlers) {
|
|
2776
|
+
handlers = /* @__PURE__ */ new Set();
|
|
2777
|
+
bySignal.set(signal, handlers);
|
|
2778
|
+
}
|
|
2779
|
+
handlers.add(handler);
|
|
2780
|
+
return () => {
|
|
2781
|
+
handlers?.delete(handler);
|
|
2782
|
+
};
|
|
2783
|
+
}
|
|
2784
|
+
function hasHandlers(model, signal) {
|
|
2785
|
+
return (registry.get(model)?.get(signal)?.size ?? 0) > 0;
|
|
2786
|
+
}
|
|
2787
|
+
async function emitSignal(signal, payload) {
|
|
2788
|
+
const handlers = registry.get(payload.model)?.get(signal);
|
|
2789
|
+
if (!handlers || handlers.size === 0) return;
|
|
2790
|
+
for (const handler of [...handlers]) {
|
|
2791
|
+
await handler(payload);
|
|
2792
|
+
}
|
|
2793
|
+
}
|
|
2794
|
+
function clearSignals(model) {
|
|
2795
|
+
if (model) {
|
|
2796
|
+
registry.delete(model);
|
|
2797
|
+
registered.delete(model);
|
|
2798
|
+
return;
|
|
2799
|
+
}
|
|
2800
|
+
for (const registeredModel of registered) registry.delete(registeredModel);
|
|
2801
|
+
registered.clear();
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
// src/setops.ts
|
|
2805
|
+
var SetBuilder = class _SetBuilder {
|
|
2806
|
+
constructor(node, source) {
|
|
2807
|
+
this.node = node;
|
|
2808
|
+
this.source = source;
|
|
2809
|
+
}
|
|
2810
|
+
node;
|
|
2811
|
+
source;
|
|
2812
|
+
with(patch) {
|
|
2813
|
+
return new _SetBuilder({ ...this.node, ...patch }, this.source);
|
|
2814
|
+
}
|
|
2815
|
+
/**
|
|
2816
|
+
* Order the combined result.
|
|
2817
|
+
*
|
|
2818
|
+
* @param column A column of the projected row.
|
|
2819
|
+
* @param direction Sort direction (default ascending).
|
|
2820
|
+
* @returns A builder carrying the ordering.
|
|
2821
|
+
*/
|
|
2822
|
+
orderBy(column2, direction = "asc") {
|
|
2823
|
+
return this.with({ orderBy: [...this.node.orderBy, { column: column2, direction }] });
|
|
2824
|
+
}
|
|
2825
|
+
/** Limit the combined result. */
|
|
2826
|
+
limit(count2) {
|
|
2827
|
+
return this.with({ limit: count2 });
|
|
2828
|
+
}
|
|
2829
|
+
/** Skip rows of the combined result. */
|
|
2830
|
+
offset(count2) {
|
|
2831
|
+
return this.with({ offset: count2 });
|
|
2832
|
+
}
|
|
2833
|
+
};
|
|
2834
|
+
function branchSource(branch) {
|
|
2835
|
+
if (branch.source) return branch.source;
|
|
2836
|
+
const pick = branch.node.pick;
|
|
2837
|
+
return pick ? branch.sources?.[pick] : void 0;
|
|
2838
|
+
}
|
|
2839
|
+
function combine(op, branches) {
|
|
2840
|
+
if (branches.length < 2) {
|
|
2841
|
+
throw new Error(`${op}() combines at least two queries.`);
|
|
2842
|
+
}
|
|
2843
|
+
const first = branches[0];
|
|
2844
|
+
const source = branchSource(first);
|
|
2845
|
+
if (!source) {
|
|
2846
|
+
throw new Error(
|
|
2847
|
+
`${op}(): the first branch must project a single source \u2014 narrow a join with .pick(alias).`
|
|
2848
|
+
);
|
|
2849
|
+
}
|
|
2850
|
+
return new SetBuilder(
|
|
2851
|
+
{
|
|
2852
|
+
kind: "set_op",
|
|
2853
|
+
op,
|
|
2854
|
+
branches: branches.map((b) => b.node),
|
|
2855
|
+
orderBy: [],
|
|
2856
|
+
limit: void 0,
|
|
2857
|
+
offset: void 0
|
|
2858
|
+
},
|
|
2859
|
+
source
|
|
2860
|
+
);
|
|
2861
|
+
}
|
|
2862
|
+
function union(...branches) {
|
|
2863
|
+
return combine("union", branches);
|
|
2864
|
+
}
|
|
2865
|
+
function unionAll(...branches) {
|
|
2866
|
+
return combine("unionAll", branches);
|
|
2867
|
+
}
|
|
2868
|
+
function intersect(...branches) {
|
|
2869
|
+
return combine("intersect", branches);
|
|
2870
|
+
}
|
|
2871
|
+
function except(...branches) {
|
|
2872
|
+
return combine("except", branches);
|
|
2873
|
+
}
|
|
2874
|
+
|
|
1742
2875
|
// src/repository.ts
|
|
1743
|
-
var
|
|
1744
|
-
constructor(
|
|
1745
|
-
super(
|
|
1746
|
-
this.name = "
|
|
2876
|
+
var InvalidCursor = class extends Error {
|
|
2877
|
+
constructor(reason) {
|
|
2878
|
+
super(`Invalid pagination cursor: ${reason}`);
|
|
2879
|
+
this.name = "InvalidCursor";
|
|
1747
2880
|
}
|
|
1748
2881
|
};
|
|
1749
|
-
function
|
|
1750
|
-
|
|
1751
|
-
|
|
2882
|
+
function encodeCursor(columns, keys, row) {
|
|
2883
|
+
const k = {};
|
|
2884
|
+
for (const key of keys) {
|
|
2885
|
+
k[key] = encodeValue(columns[key], row[key]);
|
|
2886
|
+
}
|
|
2887
|
+
const payload = { v: 1, k };
|
|
2888
|
+
return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
|
2889
|
+
}
|
|
2890
|
+
function decodeCursor(columns, keys, cursor) {
|
|
2891
|
+
let payload;
|
|
2892
|
+
try {
|
|
2893
|
+
payload = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
2894
|
+
} catch {
|
|
2895
|
+
throw new InvalidCursor("not a cursor this repository produced");
|
|
2896
|
+
}
|
|
2897
|
+
if (payload === null || typeof payload !== "object" || payload.v !== 1) {
|
|
2898
|
+
throw new InvalidCursor("unknown cursor version");
|
|
2899
|
+
}
|
|
2900
|
+
const values = {};
|
|
2901
|
+
for (const key of keys) {
|
|
2902
|
+
if (!(key in payload.k)) {
|
|
2903
|
+
throw new InvalidCursor(
|
|
2904
|
+
`it does not carry ${JSON.stringify(key)} \u2014 the ordering changed between pages`
|
|
2905
|
+
);
|
|
2906
|
+
}
|
|
2907
|
+
values[key] = decodeValue(columns[key], payload.k[key]);
|
|
1752
2908
|
}
|
|
1753
|
-
|
|
2909
|
+
return values;
|
|
1754
2910
|
}
|
|
2911
|
+
function afterCursor(keys, values, ascending) {
|
|
2912
|
+
const op = ascending ? "gt" : "lt";
|
|
2913
|
+
const build = (index2) => {
|
|
2914
|
+
const key = keys[index2];
|
|
2915
|
+
const strict = { [key]: { [op]: values[key] } };
|
|
2916
|
+
if (index2 === keys.length - 1) return strict;
|
|
2917
|
+
return or(
|
|
2918
|
+
strict,
|
|
2919
|
+
and({ [key]: values[key] }, build(index2 + 1))
|
|
2920
|
+
);
|
|
2921
|
+
};
|
|
2922
|
+
return build(0);
|
|
2923
|
+
}
|
|
2924
|
+
var RecordNotFound = class extends Error {
|
|
2925
|
+
constructor(table, key) {
|
|
2926
|
+
super(`${table} not found for key ${JSON.stringify(key)}`);
|
|
2927
|
+
this.name = "RecordNotFound";
|
|
2928
|
+
}
|
|
2929
|
+
};
|
|
1755
2930
|
var BaseRepository = class {
|
|
1756
2931
|
constructor(model, session) {
|
|
1757
2932
|
this.model = model;
|
|
1758
2933
|
this.session = session;
|
|
1759
|
-
this.
|
|
2934
|
+
this.pks = primaryKeysOf(model);
|
|
1760
2935
|
}
|
|
1761
2936
|
model;
|
|
1762
2937
|
session;
|
|
1763
|
-
|
|
2938
|
+
pks;
|
|
2939
|
+
/**
|
|
2940
|
+
* Narrow every read this repository performs.
|
|
2941
|
+
*
|
|
2942
|
+
* The extension point a scoped repository overrides: returning a filter that
|
|
2943
|
+
* always carries the scope's predicate is what makes it impossible for one
|
|
2944
|
+
* query site to forget it. The base implementation adds nothing.
|
|
2945
|
+
*
|
|
2946
|
+
* @param filters The caller's filters.
|
|
2947
|
+
* @returns The filters actually sent to the database.
|
|
2948
|
+
*/
|
|
2949
|
+
scopeFilters(filters) {
|
|
2950
|
+
return filters;
|
|
2951
|
+
}
|
|
2952
|
+
/**
|
|
2953
|
+
* Stamp every row this repository writes.
|
|
2954
|
+
*
|
|
2955
|
+
* The write-side counterpart of {@link scopeFilters}. The base implementation
|
|
2956
|
+
* writes the row unchanged.
|
|
2957
|
+
*
|
|
2958
|
+
* @param data The row being written.
|
|
2959
|
+
* @returns The row actually written.
|
|
2960
|
+
*/
|
|
2961
|
+
scopeWrite(data) {
|
|
2962
|
+
return data;
|
|
2963
|
+
}
|
|
1764
2964
|
/** All rows matching `filters` (or everything). Empty list when none match. */
|
|
1765
|
-
async list(
|
|
2965
|
+
async list(input) {
|
|
2966
|
+
const filters = this.scopeFilters(input);
|
|
1766
2967
|
const query = filters ? select(this.model).where(filters) : select(this.model);
|
|
1767
2968
|
return this.session.execute(query).all();
|
|
1768
2969
|
}
|
|
1769
2970
|
/** The first row matching `filters`, or `null`. */
|
|
1770
|
-
async first(
|
|
2971
|
+
async first(input) {
|
|
2972
|
+
const filters = this.scopeFilters(input);
|
|
1771
2973
|
const query = filters ? select(this.model).where(filters) : select(this.model);
|
|
1772
2974
|
return this.session.execute(query).first();
|
|
1773
2975
|
}
|
|
1774
|
-
/**
|
|
2976
|
+
/**
|
|
2977
|
+
* A single row by primary key, or `null`.
|
|
2978
|
+
*
|
|
2979
|
+
* @param id The key — a bare value for a single-column key, an object
|
|
2980
|
+
* (`{ orderId, lineNumber }`) for a composite one.
|
|
2981
|
+
* @returns The row, or `null` when nothing matches.
|
|
2982
|
+
* @throws Error When a scalar is given for a composite key, or the key is
|
|
2983
|
+
* incomplete.
|
|
2984
|
+
*/
|
|
1775
2985
|
async getByIdOrNull(id) {
|
|
1776
|
-
|
|
2986
|
+
const filter = this.scopeFilters(
|
|
2987
|
+
primaryKeyFilter(this.model, id)
|
|
2988
|
+
);
|
|
2989
|
+
return this.session.execute(select(this.model).where(filter)).first();
|
|
1777
2990
|
}
|
|
1778
|
-
/**
|
|
2991
|
+
/**
|
|
2992
|
+
* A single row by primary key; throws `RecordNotFound` when absent.
|
|
2993
|
+
*
|
|
2994
|
+
* @param id The key — see {@link getByIdOrNull}.
|
|
2995
|
+
* @returns The row.
|
|
2996
|
+
* @throws RecordNotFound When no row carries that key.
|
|
2997
|
+
*/
|
|
1779
2998
|
async getById(id) {
|
|
1780
|
-
const
|
|
1781
|
-
|
|
2999
|
+
const filter = this.scopeFilters(
|
|
3000
|
+
primaryKeyFilter(this.model, id)
|
|
3001
|
+
);
|
|
3002
|
+
const row = await this.session.execute(select(this.model).where(filter)).first();
|
|
3003
|
+
if (row === null) throw new RecordNotFound(this.model.tablename, filter);
|
|
1782
3004
|
return row;
|
|
1783
3005
|
}
|
|
1784
3006
|
/** Whether any row matches `filters`. */
|
|
@@ -1786,199 +3008,905 @@ var BaseRepository = class {
|
|
|
1786
3008
|
return await this.first(filters) !== null;
|
|
1787
3009
|
}
|
|
1788
3010
|
/** How many rows match `filters` (or the whole table). */
|
|
1789
|
-
async count(
|
|
1790
|
-
const
|
|
3011
|
+
async count(input) {
|
|
3012
|
+
const filters = this.scopeFilters(input);
|
|
3013
|
+
const query = filters ? select(this.model, [this.pks[0]]).where(filters) : select(this.model, [this.pks[0]]);
|
|
1791
3014
|
return (await this.session.execute(query).all()).length;
|
|
1792
3015
|
}
|
|
1793
|
-
/**
|
|
3016
|
+
/**
|
|
3017
|
+
* Insert one row, returning the created row.
|
|
3018
|
+
*
|
|
3019
|
+
* Fires `preSave` (which can veto by throwing) and then `postSave`.
|
|
3020
|
+
*
|
|
3021
|
+
* @param data The row to insert.
|
|
3022
|
+
* @returns The stored row.
|
|
3023
|
+
*/
|
|
1794
3024
|
async create(data) {
|
|
1795
|
-
|
|
3025
|
+
const scoped = this.scopeWrite(data);
|
|
3026
|
+
await this.fire("preSave", scoped, true);
|
|
3027
|
+
const row = await this.session.execute(
|
|
3028
|
+
insert(this.model).values(scoped).returning()
|
|
3029
|
+
).one();
|
|
3030
|
+
await this.fire("postSave", row, true);
|
|
3031
|
+
return row;
|
|
1796
3032
|
}
|
|
1797
|
-
/**
|
|
3033
|
+
/**
|
|
3034
|
+
* Insert many rows, returning the created rows.
|
|
3035
|
+
*
|
|
3036
|
+
* Signals fire per row, so a handler sees one row at a time whether it was
|
|
3037
|
+
* inserted alone or in a batch.
|
|
3038
|
+
*
|
|
3039
|
+
* @param data The rows to insert.
|
|
3040
|
+
* @returns The stored rows.
|
|
3041
|
+
*/
|
|
1798
3042
|
async createMany(data) {
|
|
1799
3043
|
if (data.length === 0) return [];
|
|
1800
|
-
|
|
3044
|
+
const scoped = data.map(
|
|
3045
|
+
(row) => this.scopeWrite(row)
|
|
3046
|
+
);
|
|
3047
|
+
for (const row of scoped) {
|
|
3048
|
+
await this.fire("preSave", row, true);
|
|
3049
|
+
}
|
|
3050
|
+
const rows = await this.session.execute(
|
|
3051
|
+
insert(this.model).values(scoped).returning()
|
|
3052
|
+
).all();
|
|
3053
|
+
for (const row of rows) await this.fire("postSave", row, true);
|
|
3054
|
+
return rows;
|
|
3055
|
+
}
|
|
3056
|
+
/**
|
|
3057
|
+
* Update rows matching `filters`; returns the number of rows affected.
|
|
3058
|
+
*
|
|
3059
|
+
* With a `preSave`/`postSave` handler registered, the matching rows are read
|
|
3060
|
+
* first so the handler can see them — that extra `SELECT` is skipped entirely
|
|
3061
|
+
* when nothing is listening.
|
|
3062
|
+
*
|
|
3063
|
+
* @param filters Which rows to update.
|
|
3064
|
+
* @param set The columns to change.
|
|
3065
|
+
* @returns The number of rows affected.
|
|
3066
|
+
*/
|
|
3067
|
+
async update(input, set) {
|
|
3068
|
+
const filters = this.scopeFilters(input);
|
|
3069
|
+
const listening = hasHandlers(this.model, "preSave") || hasHandlers(this.model, "postSave");
|
|
3070
|
+
const before = listening ? await this.list(filters) : [];
|
|
3071
|
+
for (const row of before) await this.fire("preSave", { ...row, ...set }, false);
|
|
3072
|
+
const affected = await this.session.execute(update(this.model).set(set).where(filters)).rowsAffected();
|
|
3073
|
+
if (hasHandlers(this.model, "postSave")) {
|
|
3074
|
+
for (const row of await this.list(filters)) await this.fire("postSave", row, false);
|
|
3075
|
+
}
|
|
3076
|
+
return affected;
|
|
3077
|
+
}
|
|
3078
|
+
/**
|
|
3079
|
+
* Delete rows matching `filters`; returns the number of rows affected.
|
|
3080
|
+
*
|
|
3081
|
+
* `preDelete` and `postDelete` receive the row as it was **before** the delete —
|
|
3082
|
+
* the only chance to see it. Reading it costs a `SELECT`, which is skipped when
|
|
3083
|
+
* no handler is registered.
|
|
3084
|
+
*
|
|
3085
|
+
* @param filters Which rows to delete.
|
|
3086
|
+
* @returns The number of rows affected.
|
|
3087
|
+
*/
|
|
3088
|
+
async delete(input) {
|
|
3089
|
+
const filters = this.scopeFilters(input);
|
|
3090
|
+
const listening = hasHandlers(this.model, "preDelete") || hasHandlers(this.model, "postDelete");
|
|
3091
|
+
const doomed = listening ? await this.list(filters) : [];
|
|
3092
|
+
for (const row of doomed) await this.fire("preDelete", row, false);
|
|
3093
|
+
const affected = await this.session.execute(del(this.model).where(filters)).rowsAffected();
|
|
3094
|
+
for (const row of doomed) await this.fire("postDelete", row, false);
|
|
3095
|
+
return affected;
|
|
3096
|
+
}
|
|
3097
|
+
/**
|
|
3098
|
+
* Fire one signal for one row on this repository's model and session.
|
|
3099
|
+
*
|
|
3100
|
+
* @param signal Which signal.
|
|
3101
|
+
* @param row The row it is about.
|
|
3102
|
+
* @param isInsert Whether the write was an insert.
|
|
3103
|
+
*/
|
|
3104
|
+
async fire(signal, row, isInsert) {
|
|
3105
|
+
await emitSignal(signal, {
|
|
3106
|
+
row,
|
|
3107
|
+
model: this.model,
|
|
3108
|
+
session: this.session,
|
|
3109
|
+
isInsert
|
|
3110
|
+
});
|
|
3111
|
+
}
|
|
3112
|
+
/**
|
|
3113
|
+
* A page of rows plus metadata. `total` counts all matching rows.
|
|
3114
|
+
*
|
|
3115
|
+
* @param filter Page, size, ordering and filters.
|
|
3116
|
+
* @returns The page and pagination metadata.
|
|
3117
|
+
*/
|
|
3118
|
+
async paginate(filter = {}) {
|
|
3119
|
+
const page = Math.max(1, filter.page ?? 1);
|
|
3120
|
+
const pageSize = Math.max(1, filter.pageSize ?? 20);
|
|
3121
|
+
const where = this.scopeFilters(filter.filters);
|
|
3122
|
+
let query = where ? select(this.model).where(where) : select(this.model);
|
|
3123
|
+
if (filter.orderBy) {
|
|
3124
|
+
query = query.orderBy(filter.orderBy, filter.ascending === false ? "desc" : "asc");
|
|
3125
|
+
}
|
|
3126
|
+
query = query.limit(pageSize).offset((page - 1) * pageSize);
|
|
3127
|
+
const items = await this.session.execute(query).all();
|
|
3128
|
+
const total = await this.count(where);
|
|
3129
|
+
return {
|
|
3130
|
+
items,
|
|
3131
|
+
total,
|
|
3132
|
+
page,
|
|
3133
|
+
pageSize,
|
|
3134
|
+
pages: Math.max(1, Math.ceil(total / pageSize))
|
|
3135
|
+
};
|
|
3136
|
+
}
|
|
3137
|
+
/**
|
|
3138
|
+
* A cursor-paginated page: the rows after `cursor`, plus the cursor for the
|
|
3139
|
+
* next page.
|
|
3140
|
+
*
|
|
3141
|
+
* No `COUNT(*)` runs, and the page boundary is stable under concurrent inserts —
|
|
3142
|
+
* the two reasons to reach for this instead of {@link paginate} on a large table.
|
|
3143
|
+
* The trade-off is losing random access: there is no "page 7".
|
|
3144
|
+
*
|
|
3145
|
+
* The primary key is always appended as a tie-break, so rows sharing an
|
|
3146
|
+
* `orderBy` value cannot be skipped or repeated across pages.
|
|
3147
|
+
*
|
|
3148
|
+
* @param filter Cursor, page size, ordering and filters.
|
|
3149
|
+
* @returns The page and the next cursor (`null` on the last page).
|
|
3150
|
+
* @throws InvalidCursor When the cursor is malformed or was built for a
|
|
3151
|
+
* different ordering.
|
|
3152
|
+
*/
|
|
3153
|
+
async cursorPaginate(filter = {}) {
|
|
3154
|
+
const limit = Math.max(1, Math.trunc(filter.limit ?? 20));
|
|
3155
|
+
const ascending = filter.ascending === true;
|
|
3156
|
+
const columns = columnsOf(this.model);
|
|
3157
|
+
const primary = filter.orderBy ?? this.pks[0];
|
|
3158
|
+
const keys = [primary, ...this.pks.filter((k) => k !== primary)];
|
|
3159
|
+
const parts = [];
|
|
3160
|
+
const scoped = this.scopeFilters(filter.filters);
|
|
3161
|
+
if (scoped) parts.push(scoped);
|
|
3162
|
+
if (filter.cursor) {
|
|
3163
|
+
parts.push(
|
|
3164
|
+
afterCursor(
|
|
3165
|
+
keys,
|
|
3166
|
+
decodeCursor(columns, keys, filter.cursor),
|
|
3167
|
+
ascending
|
|
3168
|
+
)
|
|
3169
|
+
);
|
|
3170
|
+
}
|
|
3171
|
+
let query = parts.length === 0 ? select(this.model) : select(this.model).where(
|
|
3172
|
+
parts.length === 1 ? parts[0] : and(...parts)
|
|
3173
|
+
);
|
|
3174
|
+
for (const key of keys) {
|
|
3175
|
+
query = query.orderBy(
|
|
3176
|
+
key,
|
|
3177
|
+
ascending ? "asc" : "desc"
|
|
3178
|
+
);
|
|
3179
|
+
}
|
|
3180
|
+
const rows = await this.session.execute(query.limit(limit + 1)).all();
|
|
3181
|
+
const items = rows.slice(0, limit);
|
|
3182
|
+
const last = items[items.length - 1];
|
|
3183
|
+
return {
|
|
3184
|
+
items,
|
|
3185
|
+
nextCursor: rows.length > limit && last !== void 0 ? encodeCursor(columns, keys, last) : null
|
|
3186
|
+
};
|
|
3187
|
+
}
|
|
3188
|
+
/**
|
|
3189
|
+
* Whether any **other** row matches `filters`.
|
|
3190
|
+
*
|
|
3191
|
+
* The uniqueness check for an update: "is this e-mail taken by somebody else?"
|
|
3192
|
+
* A plain `exists` would find the row being edited and report a false conflict.
|
|
3193
|
+
*
|
|
3194
|
+
* @param filters What to look for.
|
|
3195
|
+
* @param key The primary key to exclude — the row being updated.
|
|
3196
|
+
* @returns True when a different row matches.
|
|
3197
|
+
*/
|
|
3198
|
+
async existsExcluding(filters, key) {
|
|
3199
|
+
const excluded = primaryKeyFilter(this.model, key);
|
|
3200
|
+
const condition = and(filters, not(excluded));
|
|
3201
|
+
return await this.first(condition) !== null;
|
|
3202
|
+
}
|
|
3203
|
+
/**
|
|
3204
|
+
* The rows that changed since a high-water mark — the delta-sync read.
|
|
3205
|
+
*
|
|
3206
|
+
* Rows come back oldest change first, tie-broken by primary key, so a client
|
|
3207
|
+
* can advance its watermark monotonically and resume mid-stream with the
|
|
3208
|
+
* cursor. The filter is **strict** (`updatedAt > since`).
|
|
3209
|
+
*
|
|
3210
|
+
* Soft-deleted rows are included on purpose: they are the tombstones that tell
|
|
3211
|
+
* the client to delete its local copy. Filtering them out would strand deleted
|
|
3212
|
+
* rows on the device forever.
|
|
3213
|
+
*
|
|
3214
|
+
* @param filter The watermark, cursor, page size and domain filters.
|
|
3215
|
+
* @returns The page, plus the `serverTime` to persist as the next watermark.
|
|
3216
|
+
* @throws Error When the model has no `updatedAt` column (see `withTimestamps`).
|
|
3217
|
+
*/
|
|
3218
|
+
async changesSince(filter = {}) {
|
|
3219
|
+
this.requireColumn("updatedAt", "changesSince");
|
|
3220
|
+
const serverTime = /* @__PURE__ */ new Date();
|
|
3221
|
+
const clauses = [];
|
|
3222
|
+
if (filter.filters) clauses.push(filter.filters);
|
|
3223
|
+
if (filter.since) {
|
|
3224
|
+
clauses.push({
|
|
3225
|
+
updatedAt: { gt: filter.since }
|
|
3226
|
+
});
|
|
3227
|
+
}
|
|
3228
|
+
const page = await this.cursorPaginate({
|
|
3229
|
+
cursor: filter.cursor ?? null,
|
|
3230
|
+
limit: filter.limit ?? 50,
|
|
3231
|
+
orderBy: "updatedAt",
|
|
3232
|
+
ascending: true,
|
|
3233
|
+
...clauses.length === 0 ? {} : {
|
|
3234
|
+
filters: clauses.length === 1 ? clauses[0] : and(...clauses)
|
|
3235
|
+
}
|
|
3236
|
+
});
|
|
3237
|
+
return { ...page, serverTime };
|
|
3238
|
+
}
|
|
3239
|
+
/**
|
|
3240
|
+
* Insert many rows, overwriting the ones that conflict — one statement.
|
|
3241
|
+
*
|
|
3242
|
+
* @param rows The rows to write.
|
|
3243
|
+
* @param options The conflict target, and optionally which columns to overwrite.
|
|
3244
|
+
* @returns The stored rows.
|
|
3245
|
+
* @throws Error When no conflict column is given.
|
|
3246
|
+
*/
|
|
3247
|
+
async bulkUpsert(rows, options) {
|
|
3248
|
+
if (rows.length === 0) return [];
|
|
3249
|
+
if (options.conflictColumns.length === 0) {
|
|
3250
|
+
throw new Error("bulkUpsert needs at least one conflict column.");
|
|
3251
|
+
}
|
|
3252
|
+
const target = new Set(options.conflictColumns);
|
|
3253
|
+
const columns = options.update ?? [
|
|
3254
|
+
...new Set(rows.flatMap((row) => Object.keys(row)))
|
|
3255
|
+
].filter((name) => !target.has(name));
|
|
3256
|
+
const patch = {};
|
|
3257
|
+
for (const name of columns) patch[name] = sql.excluded(name);
|
|
3258
|
+
return this.session.execute(
|
|
3259
|
+
insert(this.model).values(rows).onConflictDoUpdate(options.conflictColumns, patch).returning()
|
|
3260
|
+
).all();
|
|
3261
|
+
}
|
|
3262
|
+
/**
|
|
3263
|
+
* Mark a row deleted without removing it (`deletedAt = now()`).
|
|
3264
|
+
*
|
|
3265
|
+
* @param key The primary key.
|
|
3266
|
+
* @returns The updated row.
|
|
3267
|
+
* @throws Error When the model has no `deletedAt` column (see `withSoftDelete`).
|
|
3268
|
+
* @throws RecordNotFound When no row carries that key.
|
|
3269
|
+
*/
|
|
3270
|
+
async softDelete(key) {
|
|
3271
|
+
this.requireColumn("deletedAt", "softDelete");
|
|
3272
|
+
const filter = primaryKeyFilter(this.model, key);
|
|
3273
|
+
const affected = await this.update(filter, {
|
|
3274
|
+
deletedAt: sql.now()
|
|
3275
|
+
});
|
|
3276
|
+
if (affected === 0) throw new RecordNotFound(this.model.tablename, filter);
|
|
3277
|
+
return this.getById(key);
|
|
3278
|
+
}
|
|
3279
|
+
/**
|
|
3280
|
+
* Bring a soft-deleted row back (`deletedAt = null`).
|
|
3281
|
+
*
|
|
3282
|
+
* @param key The primary key.
|
|
3283
|
+
* @returns The updated row.
|
|
3284
|
+
* @throws Error When the model has no `deletedAt` column.
|
|
3285
|
+
* @throws RecordNotFound When no row carries that key.
|
|
3286
|
+
*/
|
|
3287
|
+
async restore(key) {
|
|
3288
|
+
this.requireColumn("deletedAt", "restore");
|
|
3289
|
+
const filter = primaryKeyFilter(this.model, key);
|
|
3290
|
+
const affected = await this.update(filter, {
|
|
3291
|
+
deletedAt: null
|
|
3292
|
+
});
|
|
3293
|
+
if (affected === 0) throw new RecordNotFound(this.model.tablename, filter);
|
|
3294
|
+
return this.getById(key);
|
|
3295
|
+
}
|
|
3296
|
+
/**
|
|
3297
|
+
* Delete many rows by primary key, in one statement.
|
|
3298
|
+
*
|
|
3299
|
+
* @param keys The primary keys.
|
|
3300
|
+
* @returns The number of rows actually deleted (keys that matched nothing are
|
|
3301
|
+
* not an error — deleting what is already gone is the desired end state).
|
|
3302
|
+
* @throws Error When the model has a composite primary key: `IN` over a tuple is
|
|
3303
|
+
* not portable, and the caller should loop or build the condition explicitly.
|
|
3304
|
+
*/
|
|
3305
|
+
async deleteBatch(keys) {
|
|
3306
|
+
if (keys.length === 0) return 0;
|
|
3307
|
+
if (this.pks.length > 1) {
|
|
3308
|
+
throw new Error(
|
|
3309
|
+
`${this.model.tablename} has a composite primary key (${this.pks.join(", ")}); deleteBatch takes single-column keys only.`
|
|
3310
|
+
);
|
|
3311
|
+
}
|
|
3312
|
+
const pk = this.pks[0];
|
|
3313
|
+
const values = keys.map(
|
|
3314
|
+
(key) => primaryKeyFilter(this.model, key)[pk]
|
|
3315
|
+
);
|
|
3316
|
+
return this.delete({ [pk]: { in: values } });
|
|
3317
|
+
}
|
|
3318
|
+
/**
|
|
3319
|
+
* Fail loudly when a method needs a column the model does not declare.
|
|
3320
|
+
*
|
|
3321
|
+
* @param column The property name required.
|
|
3322
|
+
* @param method The method asking, for the message.
|
|
3323
|
+
* @throws Error When the column is absent.
|
|
3324
|
+
*/
|
|
3325
|
+
requireColumn(column2, method) {
|
|
3326
|
+
if (column2 in columnsOf(this.model)) return;
|
|
3327
|
+
throw new Error(
|
|
3328
|
+
`${this.model.tablename} has no "${column2}" column, which ${method}() requires \u2014 add it with the matching mixin.`
|
|
3329
|
+
);
|
|
3330
|
+
}
|
|
3331
|
+
};
|
|
3332
|
+
|
|
3333
|
+
// src/outbox.ts
|
|
3334
|
+
function outboxModel(name) {
|
|
3335
|
+
class OutboxBase extends Model {
|
|
3336
|
+
static tablename = name;
|
|
3337
|
+
/** Monotonic id; also the claim order, so events publish in the order written. */
|
|
3338
|
+
id = column.bigInteger().primaryKey();
|
|
3339
|
+
/** The routing key the relay publishes under. */
|
|
3340
|
+
topic = column.varchar(200).notNull();
|
|
3341
|
+
/** The event body. */
|
|
3342
|
+
payload = column.json().notNull();
|
|
3343
|
+
/** Lifecycle state. */
|
|
3344
|
+
status = column.enum("pending", "sending", "sent", "failed").notNull().default("pending");
|
|
3345
|
+
/** How many publish attempts have been made. */
|
|
3346
|
+
attempts = column.integer().notNull().default(0);
|
|
3347
|
+
/** Epoch milliseconds before which the row must not be claimed (backoff). */
|
|
3348
|
+
availableAt = column.bigInteger().notNull().default(0n);
|
|
3349
|
+
/** When the row was written. */
|
|
3350
|
+
createdAt = column.datetime().notNull().default(sql.now());
|
|
3351
|
+
/** When the relay confirmed the publish. */
|
|
3352
|
+
sentAt = column.datetime();
|
|
3353
|
+
/** The last failure's message, kept for triage. */
|
|
3354
|
+
lastError = column.text();
|
|
3355
|
+
}
|
|
3356
|
+
return OutboxBase;
|
|
3357
|
+
}
|
|
3358
|
+
var OutboxRepository = class extends BaseRepository {
|
|
3359
|
+
/**
|
|
3360
|
+
* Write events to the outbox.
|
|
3361
|
+
*
|
|
3362
|
+
* Call it inside the same `transaction()` as the business write — that is the
|
|
3363
|
+
* whole point, and it is why this does not open a transaction of its own.
|
|
3364
|
+
*
|
|
3365
|
+
* @param events One event, or many.
|
|
3366
|
+
* @returns The stored rows.
|
|
3367
|
+
*/
|
|
3368
|
+
async publish(events) {
|
|
3369
|
+
const list = Array.isArray(events) ? events : [events];
|
|
3370
|
+
if (list.length === 0) return [];
|
|
3371
|
+
const now2 = Date.now();
|
|
3372
|
+
return this.createMany(
|
|
3373
|
+
list.map(
|
|
3374
|
+
(event) => ({
|
|
3375
|
+
topic: event.topic,
|
|
3376
|
+
payload: event.payload,
|
|
3377
|
+
status: "pending",
|
|
3378
|
+
attempts: 0,
|
|
3379
|
+
availableAt: BigInt(now2 + (event.delayMs ?? 0))
|
|
3380
|
+
})
|
|
3381
|
+
)
|
|
3382
|
+
);
|
|
3383
|
+
}
|
|
3384
|
+
/**
|
|
3385
|
+
* Claim a batch of due events for this relay, in one statement.
|
|
3386
|
+
*
|
|
3387
|
+
* Uses `FOR UPDATE SKIP LOCKED` over a subquery, so two relays running at once
|
|
3388
|
+
* take **disjoint** batches instead of fighting over the same rows. SQLite has
|
|
3389
|
+
* no row locking and throws — a single-process relay there can claim with
|
|
3390
|
+
* `pending()` plus an update.
|
|
3391
|
+
*
|
|
3392
|
+
* @param limit How many events to take.
|
|
3393
|
+
* @param options Topic filter, and a clock override for tests.
|
|
3394
|
+
* @returns The claimed rows, oldest first.
|
|
3395
|
+
*/
|
|
3396
|
+
async claim(limit, options) {
|
|
3397
|
+
const now2 = options?.now ?? Date.now();
|
|
3398
|
+
const due = this.dueQuery(now2, options?.topics).orderBy("id").limit(limit).forUpdate({ skipLocked: true }).asSubquery("id");
|
|
3399
|
+
return this.session.execute(
|
|
3400
|
+
update(this.model).set({
|
|
3401
|
+
status: "sending",
|
|
3402
|
+
attempts: sql.raw("attempts + 1")
|
|
3403
|
+
}).where({ id: { in: due } }).returning()
|
|
3404
|
+
).all();
|
|
3405
|
+
}
|
|
3406
|
+
/**
|
|
3407
|
+
* The events that are due, without claiming them.
|
|
3408
|
+
*
|
|
3409
|
+
* @param limit How many to read.
|
|
3410
|
+
* @param options Topic filter and clock override.
|
|
3411
|
+
* @returns The due rows, oldest first.
|
|
3412
|
+
*/
|
|
3413
|
+
async pending(limit, options) {
|
|
3414
|
+
const now2 = options?.now ?? Date.now();
|
|
3415
|
+
return this.session.execute(
|
|
3416
|
+
this.dueQuery(now2, options?.topics).orderBy("id").limit(limit)
|
|
3417
|
+
).all();
|
|
3418
|
+
}
|
|
3419
|
+
/**
|
|
3420
|
+
* Confirm that events were published.
|
|
3421
|
+
*
|
|
3422
|
+
* @param ids The claimed ids.
|
|
3423
|
+
* @returns How many rows were marked sent.
|
|
3424
|
+
*/
|
|
3425
|
+
async markSent(ids) {
|
|
3426
|
+
if (ids.length === 0) return 0;
|
|
3427
|
+
return this.update(
|
|
3428
|
+
{ id: { in: ids } },
|
|
3429
|
+
{
|
|
3430
|
+
status: "sent",
|
|
3431
|
+
sentAt: sql.now()
|
|
3432
|
+
}
|
|
3433
|
+
);
|
|
3434
|
+
}
|
|
3435
|
+
/**
|
|
3436
|
+
* Record a failed publish, scheduling a retry unless it is permanent.
|
|
3437
|
+
*
|
|
3438
|
+
* The attempt counter was already incremented by {@link claim}, so a row that
|
|
3439
|
+
* keeps failing carries its own history — which is what a dead-letter policy
|
|
3440
|
+
* reads.
|
|
3441
|
+
*
|
|
3442
|
+
* @param id The event's id.
|
|
3443
|
+
* @param error The failure, for triage.
|
|
3444
|
+
* @param options Backoff delay, or `permanent` to stop retrying.
|
|
3445
|
+
* @returns How many rows were updated (0 or 1).
|
|
3446
|
+
*/
|
|
3447
|
+
async markFailed(id, error, options) {
|
|
3448
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3449
|
+
return this.update(
|
|
3450
|
+
{ id },
|
|
3451
|
+
{
|
|
3452
|
+
status: options?.permanent ? "failed" : "pending",
|
|
3453
|
+
availableAt: BigInt(Date.now() + (options?.retryInMs ?? 0)),
|
|
3454
|
+
lastError: message.slice(0, 1e3)
|
|
3455
|
+
}
|
|
3456
|
+
);
|
|
3457
|
+
}
|
|
3458
|
+
/**
|
|
3459
|
+
* The SELECT of events that may be claimed now.
|
|
3460
|
+
*
|
|
3461
|
+
* @param now The current epoch milliseconds.
|
|
3462
|
+
* @param topics Optional topic filter.
|
|
3463
|
+
* @returns The builder, unordered.
|
|
3464
|
+
*/
|
|
3465
|
+
dueQuery(now2, topics) {
|
|
3466
|
+
const where = {
|
|
3467
|
+
status: "pending",
|
|
3468
|
+
availableAt: { lte: BigInt(now2) }
|
|
3469
|
+
};
|
|
3470
|
+
if (topics && topics.length > 0) where.topic = { in: topics };
|
|
3471
|
+
return select(this.model).where(where);
|
|
1801
3472
|
}
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
3473
|
+
};
|
|
3474
|
+
|
|
3475
|
+
// src/explain.ts
|
|
3476
|
+
function isReadOnlyStatement(sql2) {
|
|
3477
|
+
return /^\s*(select|with)\b/i.test(sql2);
|
|
3478
|
+
}
|
|
3479
|
+
function summarizePlan(plan) {
|
|
3480
|
+
if (Array.isArray(plan)) {
|
|
3481
|
+
const rows = plan;
|
|
3482
|
+
const details = rows.map((row) => row.detail ?? row["QUERY PLAN"]).filter((detail) => typeof detail === "string");
|
|
3483
|
+
if (details.length > 0) return details.join(" | ");
|
|
3484
|
+
}
|
|
3485
|
+
const node = plan?.Plan;
|
|
3486
|
+
if (node) return describePostgresNode(node);
|
|
3487
|
+
return JSON.stringify(plan);
|
|
3488
|
+
}
|
|
3489
|
+
function describePostgresNode(node) {
|
|
3490
|
+
const parts = [];
|
|
3491
|
+
const type = node["Node Type"];
|
|
3492
|
+
const relation = node["Relation Name"];
|
|
3493
|
+
const index2 = node["Index Name"];
|
|
3494
|
+
parts.push(
|
|
3495
|
+
[type, relation ? `on ${relation}` : "", index2 ? `using ${index2}` : ""].filter((piece) => piece !== "").join(" ")
|
|
3496
|
+
);
|
|
3497
|
+
const children = node.Plans;
|
|
3498
|
+
if (Array.isArray(children)) {
|
|
3499
|
+
for (const child of children) {
|
|
3500
|
+
parts.push(describePostgresNode(child));
|
|
3501
|
+
}
|
|
1805
3502
|
}
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
3503
|
+
return parts.join(" -> ");
|
|
3504
|
+
}
|
|
3505
|
+
async function buildReport(session, statements, prefix, options) {
|
|
3506
|
+
const analyze = options?.analyze === true;
|
|
3507
|
+
const plans = [];
|
|
3508
|
+
for (const statement of statements) {
|
|
3509
|
+
if (options?.filter && !options.filter(statement.sql)) continue;
|
|
3510
|
+
if (analyze && !isReadOnlyStatement(statement.sql)) {
|
|
3511
|
+
throw new Error(
|
|
3512
|
+
`EXPLAIN ANALYZE executes the statement, so it is refused for a write: ${statement.sql.slice(0, 80)}`
|
|
3513
|
+
);
|
|
3514
|
+
}
|
|
3515
|
+
const rows = await session.raw(`${prefix(analyze)} ${statement.sql}`, statement.params).all();
|
|
3516
|
+
const plan = extractPlan(rows);
|
|
3517
|
+
plans.push({
|
|
3518
|
+
sql: statement.sql,
|
|
3519
|
+
params: statement.params,
|
|
3520
|
+
plan,
|
|
3521
|
+
summary: () => summarizePlan(plan)
|
|
3522
|
+
});
|
|
3523
|
+
}
|
|
3524
|
+
return {
|
|
3525
|
+
plans,
|
|
3526
|
+
summary: () => plans.map((p) => `${p.summary()}
|
|
3527
|
+
${p.sql}`).join("\n")
|
|
3528
|
+
};
|
|
3529
|
+
}
|
|
3530
|
+
function extractPlan(rows) {
|
|
3531
|
+
const first = rows[0];
|
|
3532
|
+
if (rows.length === 1 && first) {
|
|
3533
|
+
const value = first["QUERY PLAN"] ?? Object.values(first)[0];
|
|
3534
|
+
const parsed = typeof value === "string" ? tryParseJson(value) : value;
|
|
3535
|
+
if (Array.isArray(parsed) && parsed.length === 1) return parsed[0];
|
|
3536
|
+
if (parsed !== void 0 && typeof parsed === "object") return parsed;
|
|
3537
|
+
}
|
|
3538
|
+
return rows;
|
|
3539
|
+
}
|
|
3540
|
+
function tryParseJson(value) {
|
|
3541
|
+
try {
|
|
3542
|
+
return JSON.parse(value);
|
|
3543
|
+
} catch {
|
|
3544
|
+
return value;
|
|
3545
|
+
}
|
|
3546
|
+
}
|
|
3547
|
+
|
|
3548
|
+
// src/tenant.ts
|
|
3549
|
+
var TenantScopedRepository = class extends BaseRepository {
|
|
3550
|
+
scope;
|
|
3551
|
+
/**
|
|
3552
|
+
* Bind a repository to one tenant.
|
|
3553
|
+
*
|
|
3554
|
+
* @param model The model class.
|
|
3555
|
+
* @param session The session to run on.
|
|
3556
|
+
* @param scope The tenant column and id.
|
|
3557
|
+
* @throws Error When the model has no such column — a scope that silently
|
|
3558
|
+
* matches nothing is worse than no scope at all.
|
|
3559
|
+
*/
|
|
3560
|
+
constructor(model, session, scope) {
|
|
3561
|
+
super(model, session);
|
|
3562
|
+
if (!(scope.column in columnsOf(model))) {
|
|
3563
|
+
throw new Error(
|
|
3564
|
+
`${model.tablename} has no "${scope.column}" column to scope by tenant.`
|
|
3565
|
+
);
|
|
3566
|
+
}
|
|
3567
|
+
this.scope = scope;
|
|
3568
|
+
}
|
|
3569
|
+
/** The tenant this repository is bound to. */
|
|
3570
|
+
get tenantId() {
|
|
3571
|
+
return this.scope.id;
|
|
3572
|
+
}
|
|
3573
|
+
/**
|
|
3574
|
+
* Merge the tenant predicate into every read.
|
|
3575
|
+
*
|
|
3576
|
+
* The caller's filters are **added to**, never replaced: passing another
|
|
3577
|
+
* tenant's id produces a contradiction that matches nothing, which is the safe
|
|
3578
|
+
* outcome.
|
|
3579
|
+
*
|
|
3580
|
+
* @param filters The caller's filters.
|
|
3581
|
+
* @returns The filters plus the tenant predicate.
|
|
3582
|
+
*/
|
|
3583
|
+
scopeFilters(filters) {
|
|
3584
|
+
const tenant = { [this.scope.column]: this.scope.id };
|
|
3585
|
+
if (!filters) return tenant;
|
|
3586
|
+
return and(filters, tenant);
|
|
1809
3587
|
}
|
|
1810
3588
|
/**
|
|
1811
|
-
*
|
|
3589
|
+
* Stamp the tenant onto every row written.
|
|
1812
3590
|
*
|
|
1813
|
-
*
|
|
1814
|
-
*
|
|
3591
|
+
* A row that arrives carrying a **different** tenant is refused rather than
|
|
3592
|
+
* overwritten: silently rewriting it would turn a caller's bug into data that
|
|
3593
|
+
* looks deliberate.
|
|
3594
|
+
*
|
|
3595
|
+
* @param data The row being written.
|
|
3596
|
+
* @returns The row with the tenant column set.
|
|
3597
|
+
* @throws Error When the row names another tenant.
|
|
1815
3598
|
*/
|
|
1816
|
-
|
|
1817
|
-
const
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
query = query.orderBy(filter.orderBy, filter.ascending === false ? "desc" : "asc");
|
|
3599
|
+
scopeWrite(data) {
|
|
3600
|
+
const given = data[this.scope.column];
|
|
3601
|
+
if (given !== void 0 && given !== null && given !== this.scope.id) {
|
|
3602
|
+
throw new Error(
|
|
3603
|
+
`Refusing to write ${this.model.tablename}.${this.scope.column} = ${JSON.stringify(given)} from a repository scoped to ${JSON.stringify(this.scope.id)}.`
|
|
3604
|
+
);
|
|
1823
3605
|
}
|
|
1824
|
-
|
|
1825
|
-
const items = await this.session.execute(query).all();
|
|
1826
|
-
const total = await this.count(where);
|
|
1827
|
-
return {
|
|
1828
|
-
items,
|
|
1829
|
-
total,
|
|
1830
|
-
page,
|
|
1831
|
-
pageSize,
|
|
1832
|
-
pages: Math.max(1, Math.ceil(total / pageSize))
|
|
1833
|
-
};
|
|
3606
|
+
return { ...data, [this.scope.column]: this.scope.id };
|
|
1834
3607
|
}
|
|
1835
3608
|
};
|
|
1836
3609
|
|
|
1837
|
-
// src/
|
|
1838
|
-
function
|
|
1839
|
-
|
|
1840
|
-
|
|
3610
|
+
// src/audit.ts
|
|
3611
|
+
function auditLogModel(name) {
|
|
3612
|
+
class AuditBase extends Model {
|
|
3613
|
+
static tablename = name;
|
|
3614
|
+
/** Monotonic id — also the order the changes happened in. */
|
|
3615
|
+
id = column.bigInteger().primaryKey();
|
|
3616
|
+
/** The audited table. */
|
|
3617
|
+
tableName = column.varchar(200).notNull();
|
|
3618
|
+
/** The audited row's primary key, as an object. */
|
|
3619
|
+
rowKey = column.json().notNull();
|
|
3620
|
+
/** What happened. */
|
|
3621
|
+
action = column.enum("insert", "update", "delete").notNull();
|
|
3622
|
+
/** Who did it, when the caller could say. */
|
|
3623
|
+
actor = column.varchar(200);
|
|
3624
|
+
/** The changed columns as `[before, after]`; the whole row on insert/delete. */
|
|
3625
|
+
changes = column.json().notNull();
|
|
3626
|
+
/** When the entry was written. */
|
|
3627
|
+
at = column.datetime().notNull().default(sql.now());
|
|
3628
|
+
}
|
|
3629
|
+
return AuditBase;
|
|
3630
|
+
}
|
|
3631
|
+
function snapshot(model, row, exclude = []) {
|
|
3632
|
+
const columns = columnsOf(model);
|
|
3633
|
+
const out = {};
|
|
3634
|
+
for (const [name, col2] of Object.entries(columns)) {
|
|
3635
|
+
if (exclude.includes(name)) continue;
|
|
3636
|
+
if (!(name in row)) continue;
|
|
3637
|
+
out[name] = encodeValue(col2, row[name]);
|
|
1841
3638
|
}
|
|
1842
|
-
|
|
3639
|
+
return out;
|
|
1843
3640
|
}
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
3641
|
+
function diffSnapshots(before, after) {
|
|
3642
|
+
const diff = {};
|
|
3643
|
+
for (const key of /* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)])) {
|
|
3644
|
+
const from = before[key] ?? null;
|
|
3645
|
+
const to = after[key] ?? null;
|
|
3646
|
+
if (JSON.stringify(from) !== JSON.stringify(to)) diff[key] = [from, to];
|
|
3647
|
+
}
|
|
3648
|
+
return diff;
|
|
3649
|
+
}
|
|
3650
|
+
var pending = /* @__PURE__ */ new WeakMap();
|
|
3651
|
+
function pendingKey(model, key) {
|
|
3652
|
+
return `${model.tablename}:${JSON.stringify(key)}`;
|
|
3653
|
+
}
|
|
3654
|
+
function enableAudit(model, options) {
|
|
3655
|
+
const exclude = options.exclude ?? [];
|
|
3656
|
+
const write = async (session, action, row, changes) => {
|
|
3657
|
+
await new BaseRepository(options.log, session).create({
|
|
3658
|
+
tableName: model.tablename,
|
|
3659
|
+
rowKey: primaryKeyFilter(model, row),
|
|
3660
|
+
action,
|
|
3661
|
+
actor: options.actor?.() ?? null,
|
|
3662
|
+
changes
|
|
3663
|
+
});
|
|
3664
|
+
};
|
|
3665
|
+
const offs = [
|
|
3666
|
+
onSignal(model, "preSave", async ({ row, session, isInsert }) => {
|
|
3667
|
+
if (isInsert) return;
|
|
3668
|
+
const key = primaryKeyFilter(model, row);
|
|
3669
|
+
const before = await new BaseRepository(model, session).first(key);
|
|
3670
|
+
if (!before) return;
|
|
3671
|
+
let bySession = pending.get(session);
|
|
3672
|
+
if (!bySession) {
|
|
3673
|
+
bySession = /* @__PURE__ */ new Map();
|
|
3674
|
+
pending.set(session, bySession);
|
|
3675
|
+
}
|
|
3676
|
+
bySession.set(
|
|
3677
|
+
pendingKey(model, key),
|
|
3678
|
+
snapshot(model, before, exclude)
|
|
3679
|
+
);
|
|
3680
|
+
}),
|
|
3681
|
+
onSignal(model, "postSave", async ({ row, session, isInsert }) => {
|
|
3682
|
+
const record = row;
|
|
3683
|
+
const after = snapshot(model, record, exclude);
|
|
3684
|
+
if (isInsert) {
|
|
3685
|
+
await write(session, "insert", record, toInsertDiff(after));
|
|
3686
|
+
return;
|
|
3687
|
+
}
|
|
3688
|
+
const key = pendingKey(model, primaryKeyFilter(model, record));
|
|
3689
|
+
const before = pending.get(session)?.get(key) ?? {};
|
|
3690
|
+
pending.get(session)?.delete(key);
|
|
3691
|
+
const changes = diffSnapshots(before, after);
|
|
3692
|
+
if (Object.keys(changes).length === 0) return;
|
|
3693
|
+
await write(session, "update", record, changes);
|
|
3694
|
+
}),
|
|
3695
|
+
onSignal(model, "preDelete", async ({ row, session }) => {
|
|
3696
|
+
const record = row;
|
|
3697
|
+
await write(
|
|
3698
|
+
session,
|
|
3699
|
+
"delete",
|
|
3700
|
+
record,
|
|
3701
|
+
toDeleteDiff(snapshot(model, record, exclude))
|
|
3702
|
+
);
|
|
3703
|
+
})
|
|
3704
|
+
];
|
|
3705
|
+
return () => {
|
|
3706
|
+
for (const off of offs) off();
|
|
3707
|
+
};
|
|
3708
|
+
}
|
|
3709
|
+
function toInsertDiff(after) {
|
|
3710
|
+
const diff = {};
|
|
3711
|
+
for (const [key, value] of Object.entries(after)) diff[key] = [null, value];
|
|
3712
|
+
return diff;
|
|
3713
|
+
}
|
|
3714
|
+
function toDeleteDiff(before) {
|
|
3715
|
+
const diff = {};
|
|
3716
|
+
for (const [key, value] of Object.entries(before)) diff[key] = [value, null];
|
|
3717
|
+
return diff;
|
|
3718
|
+
}
|
|
3719
|
+
|
|
3720
|
+
// src/unit-of-work.ts
|
|
3721
|
+
var UnitOfWork = class {
|
|
3722
|
+
constructor(session) {
|
|
1847
3723
|
this.session = session;
|
|
1848
|
-
this.data = data;
|
|
1849
|
-
this.pk = primaryKeyOf2(model);
|
|
1850
3724
|
}
|
|
1851
|
-
model;
|
|
1852
3725
|
session;
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
return this.data[this.pk];
|
|
1858
|
-
}
|
|
1859
|
-
pkFilter() {
|
|
1860
|
-
return { [this.pk]: this.pkValue() };
|
|
3726
|
+
entries = /* @__PURE__ */ new Map();
|
|
3727
|
+
/** How many rows are being tracked. */
|
|
3728
|
+
get size() {
|
|
3729
|
+
return this.entries.size;
|
|
1861
3730
|
}
|
|
1862
3731
|
/**
|
|
1863
|
-
*
|
|
1864
|
-
* row (upsert on the primary key). Refreshes `data` from the returned row.
|
|
3732
|
+
* Load a row by primary key, or return the one already loaded.
|
|
1865
3733
|
*
|
|
1866
|
-
*
|
|
3734
|
+
* The second call for the same key does **not** hit the database and returns
|
|
3735
|
+
* the **same object** as the first.
|
|
3736
|
+
*
|
|
3737
|
+
* @param model The model class.
|
|
3738
|
+
* @param key The primary key — a value, or an object for a composite key.
|
|
3739
|
+
* @returns The tracked row, or `null` when there is none.
|
|
1867
3740
|
*/
|
|
1868
|
-
async
|
|
1869
|
-
const
|
|
1870
|
-
const
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
const
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
return
|
|
3741
|
+
async get(model, key) {
|
|
3742
|
+
const id = this.identity(model, key);
|
|
3743
|
+
const known = this.entries.get(id);
|
|
3744
|
+
if (known)
|
|
3745
|
+
return known.state === "removed" ? null : known.row;
|
|
3746
|
+
const row = await new BaseRepository(model, this.session).getByIdOrNull(key);
|
|
3747
|
+
if (row === null) return null;
|
|
3748
|
+
const tracked = { ...row };
|
|
3749
|
+
this.entries.set(id, {
|
|
3750
|
+
model,
|
|
3751
|
+
row: tracked,
|
|
3752
|
+
snapshot: { ...tracked },
|
|
3753
|
+
state: "clean"
|
|
3754
|
+
});
|
|
3755
|
+
return tracked;
|
|
1883
3756
|
}
|
|
1884
3757
|
/**
|
|
1885
|
-
*
|
|
3758
|
+
* Track an existing row that was loaded elsewhere.
|
|
1886
3759
|
*
|
|
1887
|
-
* @param
|
|
1888
|
-
* @
|
|
3760
|
+
* @param model The model class.
|
|
3761
|
+
* @param row The row, as loaded.
|
|
3762
|
+
* @returns The tracked object — the same one, if this row is already known.
|
|
1889
3763
|
*/
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
return
|
|
3764
|
+
track(model, row) {
|
|
3765
|
+
const id = this.identity(model, row);
|
|
3766
|
+
const known = this.entries.get(id);
|
|
3767
|
+
if (known) return known.row;
|
|
3768
|
+
const tracked = { ...row };
|
|
3769
|
+
this.entries.set(id, {
|
|
3770
|
+
model,
|
|
3771
|
+
row: tracked,
|
|
3772
|
+
snapshot: { ...tracked },
|
|
3773
|
+
state: "clean"
|
|
3774
|
+
});
|
|
3775
|
+
return tracked;
|
|
1894
3776
|
}
|
|
1895
3777
|
/**
|
|
1896
|
-
*
|
|
3778
|
+
* Schedule an insert.
|
|
1897
3779
|
*
|
|
1898
|
-
* @
|
|
3780
|
+
* @param model The model class.
|
|
3781
|
+
* @param data The row to insert; it must carry the primary key, since the
|
|
3782
|
+
* identity map is keyed by it and a database-generated id is not known yet.
|
|
3783
|
+
* @returns The tracked row.
|
|
3784
|
+
* @throws Error When the key is incomplete.
|
|
1899
3785
|
*/
|
|
1900
|
-
|
|
1901
|
-
|
|
3786
|
+
add(model, data) {
|
|
3787
|
+
const row = { ...data };
|
|
3788
|
+
const id = this.identity(model, row);
|
|
3789
|
+
this.entries.set(id, { model, row, snapshot: null, state: "new" });
|
|
3790
|
+
return row;
|
|
1902
3791
|
}
|
|
1903
3792
|
/**
|
|
1904
|
-
*
|
|
3793
|
+
* Schedule a delete.
|
|
1905
3794
|
*
|
|
1906
|
-
*
|
|
1907
|
-
*
|
|
3795
|
+
* A row added and then removed before the flush simply disappears — no
|
|
3796
|
+
* statement is emitted for it.
|
|
3797
|
+
*
|
|
3798
|
+
* @param model The model class.
|
|
3799
|
+
* @param row The row to delete.
|
|
1908
3800
|
*/
|
|
1909
|
-
|
|
1910
|
-
const
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
3801
|
+
remove(model, row) {
|
|
3802
|
+
const id = this.identity(model, row);
|
|
3803
|
+
const known = this.entries.get(id);
|
|
3804
|
+
if (known?.state === "new") {
|
|
3805
|
+
this.entries.delete(id);
|
|
3806
|
+
return;
|
|
1915
3807
|
}
|
|
1916
|
-
|
|
1917
|
-
|
|
3808
|
+
if (known) {
|
|
3809
|
+
known.state = "removed";
|
|
3810
|
+
return;
|
|
3811
|
+
}
|
|
3812
|
+
this.entries.set(id, {
|
|
3813
|
+
model,
|
|
3814
|
+
row: { ...row },
|
|
3815
|
+
snapshot: { ...row },
|
|
3816
|
+
state: "removed"
|
|
3817
|
+
});
|
|
1918
3818
|
}
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
3819
|
+
/**
|
|
3820
|
+
* Write every pending change, in one transaction.
|
|
3821
|
+
*
|
|
3822
|
+
* Order is inserts, then updates, then deletes — the order that keeps a
|
|
3823
|
+
* foreign key satisfied when a new parent and its children are flushed
|
|
3824
|
+
* together. It is **not** a topological sort: a graph that needs one should be
|
|
3825
|
+
* flushed in stages.
|
|
3826
|
+
*
|
|
3827
|
+
* A statement that fails takes the whole flush with it, since it all runs in
|
|
3828
|
+
* one transaction. The tracked state is left untouched in that case, so the
|
|
3829
|
+
* caller can fix and flush again.
|
|
3830
|
+
*
|
|
3831
|
+
* @returns How many rows were inserted, updated and deleted.
|
|
3832
|
+
*/
|
|
3833
|
+
async flush() {
|
|
3834
|
+
const inserts = [...this.entries.values()].filter((e) => e.state === "new");
|
|
3835
|
+
const removals = [...this.entries.values()].filter((e) => e.state === "removed");
|
|
3836
|
+
const updates = [...this.entries.values()].filter((e) => e.state === "clean" && e.snapshot !== null).map((entry) => ({ entry, patch: diffRow(entry) })).filter(({ patch }) => Object.keys(patch).length > 0);
|
|
3837
|
+
if (inserts.length === 0 && updates.length === 0 && removals.length === 0) {
|
|
3838
|
+
return { inserted: 0, updated: 0, deleted: 0 };
|
|
1928
3839
|
}
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
foreignKey: keys.foreignKey
|
|
1939
|
-
};
|
|
1940
|
-
}
|
|
1941
|
-
function belongsTo(target, keys) {
|
|
1942
|
-
return {
|
|
1943
|
-
kind: "belongsTo",
|
|
1944
|
-
target,
|
|
1945
|
-
localKey: keys.localKey,
|
|
1946
|
-
foreignKey: keys.foreignKey
|
|
1947
|
-
};
|
|
1948
|
-
}
|
|
1949
|
-
async function loadRelations(session, rows, spec) {
|
|
1950
|
-
const out = rows.map((r) => ({ ...r }));
|
|
1951
|
-
for (const [name, rel] of Object.entries(spec)) {
|
|
1952
|
-
const target = rel.target();
|
|
1953
|
-
const localValues = [...new Set(rows.map((r) => r[rel.localKey]))];
|
|
1954
|
-
const related = localValues.length > 0 ? await session.execute(
|
|
1955
|
-
select(target).where({
|
|
1956
|
-
[rel.foreignKey]: { in: localValues }
|
|
1957
|
-
})
|
|
1958
|
-
).all() : [];
|
|
1959
|
-
if (rel.kind === "hasMany") {
|
|
1960
|
-
const grouped = /* @__PURE__ */ new Map();
|
|
1961
|
-
for (const row of related) {
|
|
1962
|
-
const key = row[rel.foreignKey];
|
|
1963
|
-
const list = grouped.get(key) ?? [];
|
|
1964
|
-
list.push(row);
|
|
1965
|
-
grouped.set(key, list);
|
|
3840
|
+
const result = await this.session.transaction(async (tx) => {
|
|
3841
|
+
let inserted = 0;
|
|
3842
|
+
let updated = 0;
|
|
3843
|
+
let deleted = 0;
|
|
3844
|
+
for (const entry of inserts) {
|
|
3845
|
+
await new BaseRepository(entry.model, tx).create(
|
|
3846
|
+
entry.row
|
|
3847
|
+
);
|
|
3848
|
+
inserted += 1;
|
|
1966
3849
|
}
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
for (const row of related) {
|
|
1973
|
-
byKey.set(row[rel.foreignKey], row);
|
|
3850
|
+
for (const { entry, patch } of updates) {
|
|
3851
|
+
updated += await new BaseRepository(entry.model, tx).update(
|
|
3852
|
+
primaryKeyFilter(entry.model, entry.row),
|
|
3853
|
+
patch
|
|
3854
|
+
);
|
|
1974
3855
|
}
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
3856
|
+
for (const entry of removals) {
|
|
3857
|
+
deleted += await new BaseRepository(entry.model, tx).delete(
|
|
3858
|
+
primaryKeyFilter(entry.model, entry.row)
|
|
3859
|
+
);
|
|
3860
|
+
}
|
|
3861
|
+
return { inserted, updated, deleted };
|
|
3862
|
+
});
|
|
3863
|
+
for (const entry of inserts) entry.state = "clean";
|
|
3864
|
+
for (const { entry } of updates) entry.snapshot = { ...entry.row };
|
|
3865
|
+
for (const entry of inserts) entry.snapshot = { ...entry.row };
|
|
3866
|
+
for (const entry of removals) {
|
|
3867
|
+
this.entries.delete(this.identity(entry.model, entry.row));
|
|
1978
3868
|
}
|
|
3869
|
+
return result;
|
|
1979
3870
|
}
|
|
1980
|
-
|
|
3871
|
+
/** Forget everything tracked, without writing. */
|
|
3872
|
+
clear() {
|
|
3873
|
+
this.entries.clear();
|
|
3874
|
+
}
|
|
3875
|
+
/**
|
|
3876
|
+
* The identity-map key for a row or a primary key.
|
|
3877
|
+
*
|
|
3878
|
+
* @param model The model class.
|
|
3879
|
+
* @param key The row, or the key.
|
|
3880
|
+
* @returns A stable string key.
|
|
3881
|
+
*/
|
|
3882
|
+
identity(model, key) {
|
|
3883
|
+
return `${model.tablename}:${JSON.stringify(primaryKeyFilter(model, key))}`;
|
|
3884
|
+
}
|
|
3885
|
+
};
|
|
3886
|
+
function diffRow(entry) {
|
|
3887
|
+
const patch = {};
|
|
3888
|
+
const snapshot2 = entry.snapshot ?? {};
|
|
3889
|
+
for (const name of Object.keys(columnsOf(entry.model))) {
|
|
3890
|
+
if (!(name in entry.row)) continue;
|
|
3891
|
+
const before = snapshot2[name] ?? null;
|
|
3892
|
+
const after = entry.row[name] ?? null;
|
|
3893
|
+
if (!sameValue(before, after)) patch[name] = entry.row[name];
|
|
3894
|
+
}
|
|
3895
|
+
return patch;
|
|
3896
|
+
}
|
|
3897
|
+
function sameValue(a, b) {
|
|
3898
|
+
if (a === b) return true;
|
|
3899
|
+
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
|
|
3900
|
+
if (a instanceof Uint8Array && b instanceof Uint8Array) {
|
|
3901
|
+
return a.length === b.length && a.every((byte, i) => byte === b[i]);
|
|
3902
|
+
}
|
|
3903
|
+
if (typeof a === "object" && typeof b === "object" && a !== null && b !== null) {
|
|
3904
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
3905
|
+
}
|
|
3906
|
+
return false;
|
|
1981
3907
|
}
|
|
3908
|
+
|
|
3909
|
+
// src/engine.ts
|
|
1982
3910
|
var nodeRequire = createRequire(import.meta.url);
|
|
1983
3911
|
function encodeSqliteParam(value) {
|
|
1984
3912
|
if (value === void 0 || value === null) return null;
|
|
@@ -2107,7 +4035,7 @@ var BetterSqliteDriver = class _BetterSqliteDriver {
|
|
|
2107
4035
|
}
|
|
2108
4036
|
};
|
|
2109
4037
|
function returnsRows(sql2) {
|
|
2110
|
-
return /^\s*(select|pragma)/i.test(sql2) || /\breturning\b/i.test(sql2);
|
|
4038
|
+
return /^\s*(select|with|values|table|pragma|explain)\b/i.test(sql2) || /\breturning\b/i.test(sql2);
|
|
2111
4039
|
}
|
|
2112
4040
|
function splitJoinRow(node, sources, raw) {
|
|
2113
4041
|
const leftAliases = new Set(
|
|
@@ -2131,6 +4059,10 @@ function coerceOne(builder, raw) {
|
|
|
2131
4059
|
const node = builder.node;
|
|
2132
4060
|
if (node.kind === "join_select") {
|
|
2133
4061
|
const b2 = builder;
|
|
4062
|
+
if (node.pick) {
|
|
4063
|
+
const model = b2.sources[node.pick];
|
|
4064
|
+
if (model) return coerceRow(model, raw);
|
|
4065
|
+
}
|
|
2134
4066
|
return splitJoinRow(b2.node, b2.sources, raw);
|
|
2135
4067
|
}
|
|
2136
4068
|
const b = builder;
|
|
@@ -2168,13 +4100,30 @@ var QueryExecutionError = class extends Error {
|
|
|
2168
4100
|
sql;
|
|
2169
4101
|
params;
|
|
2170
4102
|
};
|
|
2171
|
-
function emitLog(
|
|
4103
|
+
function emitLog(hooks, sql2, params) {
|
|
4104
|
+
const logger = hooks?.onQuery;
|
|
2172
4105
|
if (!logger) return;
|
|
2173
4106
|
try {
|
|
2174
4107
|
logger({ sql: sql2, params });
|
|
2175
4108
|
} catch {
|
|
2176
4109
|
}
|
|
2177
4110
|
}
|
|
4111
|
+
function now() {
|
|
4112
|
+
return performance.now();
|
|
4113
|
+
}
|
|
4114
|
+
function emitQueryEnd(hooks, event) {
|
|
4115
|
+
const logger = hooks?.onQueryEnd;
|
|
4116
|
+
if (!logger) return;
|
|
4117
|
+
const threshold = hooks?.slowQueryMs;
|
|
4118
|
+
if (threshold !== void 0 && event.durationMs < threshold) return;
|
|
4119
|
+
try {
|
|
4120
|
+
logger(event);
|
|
4121
|
+
} catch {
|
|
4122
|
+
}
|
|
4123
|
+
}
|
|
4124
|
+
function resultRowCount(result) {
|
|
4125
|
+
return result.rows.length > 0 ? result.rows.length : result.changes;
|
|
4126
|
+
}
|
|
2178
4127
|
function firstScalar(row) {
|
|
2179
4128
|
if (!row) return null;
|
|
2180
4129
|
const keys = Object.keys(row);
|
|
@@ -2262,21 +4211,45 @@ function assertRawParams(params) {
|
|
|
2262
4211
|
);
|
|
2263
4212
|
}
|
|
2264
4213
|
}
|
|
4214
|
+
function assertNoNestedOptions(options) {
|
|
4215
|
+
if (options?.isolation || options?.readOnly) {
|
|
4216
|
+
throw new Error(
|
|
4217
|
+
"Transaction characteristics can only be set on the outermost transaction() \u2014 a nested block joins the one already open."
|
|
4218
|
+
);
|
|
4219
|
+
}
|
|
4220
|
+
}
|
|
2265
4221
|
var SyncSession = class {
|
|
2266
|
-
constructor(driver, dialect,
|
|
4222
|
+
constructor(driver, dialect, hooks) {
|
|
2267
4223
|
this.driver = driver;
|
|
2268
4224
|
this.dialect = dialect;
|
|
2269
|
-
this.
|
|
4225
|
+
this.hooks = hooks;
|
|
2270
4226
|
}
|
|
2271
4227
|
driver;
|
|
2272
4228
|
dialect;
|
|
2273
|
-
|
|
2274
|
-
/**
|
|
4229
|
+
hooks;
|
|
4230
|
+
/** Open `transaction()` blocks on this session; only the outermost commits. */
|
|
4231
|
+
depth = 0;
|
|
4232
|
+
/** Log, run, time, and error-wrap one raw statement. */
|
|
2275
4233
|
exec(sql2, params) {
|
|
2276
|
-
emitLog(this.
|
|
4234
|
+
emitLog(this.hooks, sql2, params);
|
|
4235
|
+
const startedAt = now();
|
|
2277
4236
|
try {
|
|
2278
|
-
|
|
4237
|
+
const result = this.driver.execute(sql2, params);
|
|
4238
|
+
emitQueryEnd(this.hooks, {
|
|
4239
|
+
sql: sql2,
|
|
4240
|
+
params,
|
|
4241
|
+
durationMs: now() - startedAt,
|
|
4242
|
+
rowCount: resultRowCount(result)
|
|
4243
|
+
});
|
|
4244
|
+
return result;
|
|
2279
4245
|
} catch (error) {
|
|
4246
|
+
emitQueryEnd(this.hooks, {
|
|
4247
|
+
sql: sql2,
|
|
4248
|
+
params,
|
|
4249
|
+
durationMs: now() - startedAt,
|
|
4250
|
+
rowCount: 0,
|
|
4251
|
+
error
|
|
4252
|
+
});
|
|
2280
4253
|
throw new QueryExecutionError(error, sql2, params);
|
|
2281
4254
|
}
|
|
2282
4255
|
}
|
|
@@ -2327,8 +4300,44 @@ var SyncSession = class {
|
|
|
2327
4300
|
return new SyncResult(rows, result.changes);
|
|
2328
4301
|
}
|
|
2329
4302
|
/** Run `fn` inside a transaction: commit on success, rollback on throw. */
|
|
2330
|
-
|
|
2331
|
-
|
|
4303
|
+
/**
|
|
4304
|
+
* How many `transaction()` blocks are open on this session.
|
|
4305
|
+
*
|
|
4306
|
+
* The counter is what makes a service that orchestrates two repositories work:
|
|
4307
|
+
* both hold the same session, so an inner block **joins** the outer one instead
|
|
4308
|
+
* of emitting a second `BEGIN`, and only the outermost exit commits.
|
|
4309
|
+
*/
|
|
4310
|
+
get transactionDepth() {
|
|
4311
|
+
return this.depth;
|
|
4312
|
+
}
|
|
4313
|
+
/** Whether a `transaction()` block is currently open on this session. */
|
|
4314
|
+
get inTransaction() {
|
|
4315
|
+
return this.depth > 0;
|
|
4316
|
+
}
|
|
4317
|
+
/**
|
|
4318
|
+
* Run `fn` inside a transaction, committing on a clean exit and rolling back on
|
|
4319
|
+
* a throw.
|
|
4320
|
+
*
|
|
4321
|
+
* **Re-entrant:** a nested call joins the block already open on this session —
|
|
4322
|
+
* one `BEGIN`, one `COMMIT`, and an inner failure rolls the whole thing back.
|
|
4323
|
+
* To recover from an inner failure without discarding the outer work, use
|
|
4324
|
+
* {@link beginNested}, which is a real savepoint.
|
|
4325
|
+
*
|
|
4326
|
+
* @param fn The body; receives the session to work through.
|
|
4327
|
+
* @returns Whatever `fn` returned.
|
|
4328
|
+
*/
|
|
4329
|
+
transaction(fn2, options) {
|
|
4330
|
+
if (this.depth > 0) {
|
|
4331
|
+
assertNoNestedOptions(options);
|
|
4332
|
+
this.depth += 1;
|
|
4333
|
+
try {
|
|
4334
|
+
return fn2(this);
|
|
4335
|
+
} finally {
|
|
4336
|
+
this.depth -= 1;
|
|
4337
|
+
}
|
|
4338
|
+
}
|
|
4339
|
+
for (const stmt of this.dialect.beginStatements(options)) this.exec(stmt, []);
|
|
4340
|
+
this.depth = 1;
|
|
2332
4341
|
try {
|
|
2333
4342
|
const out = fn2(this);
|
|
2334
4343
|
this.exec("COMMIT", []);
|
|
@@ -2336,6 +4345,8 @@ var SyncSession = class {
|
|
|
2336
4345
|
} catch (error) {
|
|
2337
4346
|
this.exec("ROLLBACK", []);
|
|
2338
4347
|
throw error;
|
|
4348
|
+
} finally {
|
|
4349
|
+
this.depth = 0;
|
|
2339
4350
|
}
|
|
2340
4351
|
}
|
|
2341
4352
|
/** Run `fn` inside a SAVEPOINT (nested transaction). */
|
|
@@ -2359,15 +4370,31 @@ var SyncSession = class {
|
|
|
2359
4370
|
*stream(builder) {
|
|
2360
4371
|
const node = builder.node;
|
|
2361
4372
|
const { sql: sql2, params } = this.dialect.compile(node);
|
|
2362
|
-
emitLog(this.
|
|
4373
|
+
emitLog(this.hooks, sql2, params);
|
|
2363
4374
|
if (this.driver.iterate) {
|
|
4375
|
+
const startedAt = now();
|
|
4376
|
+
let rowCount = 0;
|
|
2364
4377
|
try {
|
|
2365
4378
|
for (const raw of this.driver.iterate(sql2, params)) {
|
|
4379
|
+
rowCount++;
|
|
2366
4380
|
yield coerceOne(builder, raw);
|
|
2367
4381
|
}
|
|
2368
4382
|
} catch (error) {
|
|
4383
|
+
emitQueryEnd(this.hooks, {
|
|
4384
|
+
sql: sql2,
|
|
4385
|
+
params,
|
|
4386
|
+
durationMs: now() - startedAt,
|
|
4387
|
+
rowCount,
|
|
4388
|
+
error
|
|
4389
|
+
});
|
|
2369
4390
|
throw new QueryExecutionError(error, sql2, params);
|
|
2370
4391
|
}
|
|
4392
|
+
emitQueryEnd(this.hooks, {
|
|
4393
|
+
sql: sql2,
|
|
4394
|
+
params,
|
|
4395
|
+
durationMs: now() - startedAt,
|
|
4396
|
+
rowCount
|
|
4397
|
+
});
|
|
2371
4398
|
return;
|
|
2372
4399
|
}
|
|
2373
4400
|
for (const raw of this.exec(sql2, params).rows) {
|
|
@@ -2383,20 +4410,37 @@ var SyncSession = class {
|
|
|
2383
4410
|
}
|
|
2384
4411
|
};
|
|
2385
4412
|
var AsyncSession = class _AsyncSession {
|
|
2386
|
-
constructor(driver, dialect,
|
|
4413
|
+
constructor(driver, dialect, hooks) {
|
|
2387
4414
|
this.driver = driver;
|
|
2388
4415
|
this.dialect = dialect;
|
|
2389
|
-
this.
|
|
4416
|
+
this.hooks = hooks;
|
|
2390
4417
|
}
|
|
2391
4418
|
driver;
|
|
2392
4419
|
dialect;
|
|
2393
|
-
|
|
2394
|
-
/**
|
|
4420
|
+
hooks;
|
|
4421
|
+
/** Open `transaction()` blocks on this session; only the outermost commits. */
|
|
4422
|
+
depth = 0;
|
|
4423
|
+
/** Log, run, time, and error-wrap one raw statement. */
|
|
2395
4424
|
async exec(sql2, params) {
|
|
2396
|
-
emitLog(this.
|
|
4425
|
+
emitLog(this.hooks, sql2, params);
|
|
4426
|
+
const startedAt = now();
|
|
2397
4427
|
try {
|
|
2398
|
-
|
|
4428
|
+
const result = await this.driver.execute(sql2, params);
|
|
4429
|
+
emitQueryEnd(this.hooks, {
|
|
4430
|
+
sql: sql2,
|
|
4431
|
+
params,
|
|
4432
|
+
durationMs: now() - startedAt,
|
|
4433
|
+
rowCount: resultRowCount(result)
|
|
4434
|
+
});
|
|
4435
|
+
return result;
|
|
2399
4436
|
} catch (error) {
|
|
4437
|
+
emitQueryEnd(this.hooks, {
|
|
4438
|
+
sql: sql2,
|
|
4439
|
+
params,
|
|
4440
|
+
durationMs: now() - startedAt,
|
|
4441
|
+
rowCount: 0,
|
|
4442
|
+
error
|
|
4443
|
+
});
|
|
2400
4444
|
throw new QueryExecutionError(error, sql2, params);
|
|
2401
4445
|
}
|
|
2402
4446
|
}
|
|
@@ -2486,17 +4530,17 @@ var AsyncSession = class _AsyncSession {
|
|
|
2486
4530
|
const selectSql = this.dialect.compile(
|
|
2487
4531
|
node.returning === "*" || node.returning === null ? readBack.node : { ...readBack.node, columns: node.returning }
|
|
2488
4532
|
);
|
|
2489
|
-
const
|
|
2490
|
-
const scoped = new _AsyncSession(driver, this.dialect, this.
|
|
4533
|
+
const run2 = async (driver) => {
|
|
4534
|
+
const scoped = new _AsyncSession(driver, this.dialect, this.hooks);
|
|
2491
4535
|
const written = await scoped.exec(insertSql.sql, insertSql.params);
|
|
2492
4536
|
const read = await scoped.exec(selectSql.sql, selectSql.params);
|
|
2493
4537
|
const rows = read.rows.map((row) => coerceRow(model, row));
|
|
2494
4538
|
return new SyncResult(rows, written.changes);
|
|
2495
4539
|
};
|
|
2496
|
-
if (!this.driver.reserve) return
|
|
4540
|
+
if (!this.driver.reserve) return run2(this.driver);
|
|
2497
4541
|
const reserved = await this.driver.reserve();
|
|
2498
4542
|
try {
|
|
2499
|
-
return await
|
|
4543
|
+
return await run2(reserved);
|
|
2500
4544
|
} finally {
|
|
2501
4545
|
await reserved.release();
|
|
2502
4546
|
}
|
|
@@ -2505,15 +4549,31 @@ var AsyncSession = class _AsyncSession {
|
|
|
2505
4549
|
async *stream(builder) {
|
|
2506
4550
|
const node = builder.node;
|
|
2507
4551
|
const { sql: sql2, params } = this.dialect.compile(node);
|
|
2508
|
-
emitLog(this.
|
|
4552
|
+
emitLog(this.hooks, sql2, params);
|
|
2509
4553
|
if (this.driver.iterate) {
|
|
4554
|
+
const startedAt = now();
|
|
4555
|
+
let rowCount = 0;
|
|
2510
4556
|
try {
|
|
2511
4557
|
for await (const raw of this.driver.iterate(sql2, params)) {
|
|
4558
|
+
rowCount++;
|
|
2512
4559
|
yield coerceOne(builder, raw);
|
|
2513
4560
|
}
|
|
2514
4561
|
} catch (error) {
|
|
4562
|
+
emitQueryEnd(this.hooks, {
|
|
4563
|
+
sql: sql2,
|
|
4564
|
+
params,
|
|
4565
|
+
durationMs: now() - startedAt,
|
|
4566
|
+
rowCount,
|
|
4567
|
+
error
|
|
4568
|
+
});
|
|
2515
4569
|
throw new QueryExecutionError(error, sql2, params);
|
|
2516
4570
|
}
|
|
4571
|
+
emitQueryEnd(this.hooks, {
|
|
4572
|
+
sql: sql2,
|
|
4573
|
+
params,
|
|
4574
|
+
durationMs: now() - startedAt,
|
|
4575
|
+
rowCount
|
|
4576
|
+
});
|
|
2517
4577
|
return;
|
|
2518
4578
|
}
|
|
2519
4579
|
const result = await this.exec(sql2, params);
|
|
@@ -2521,12 +4581,56 @@ var AsyncSession = class _AsyncSession {
|
|
|
2521
4581
|
yield coerceOne(builder, raw);
|
|
2522
4582
|
}
|
|
2523
4583
|
}
|
|
2524
|
-
|
|
4584
|
+
/**
|
|
4585
|
+
* How many `transaction()` blocks are open on this session.
|
|
4586
|
+
*
|
|
4587
|
+
* The counter is what makes a service that orchestrates two repositories work:
|
|
4588
|
+
* both hold the same session, so an inner block **joins** the outer one instead
|
|
4589
|
+
* of emitting a second `BEGIN`, and only the outermost exit commits.
|
|
4590
|
+
*/
|
|
4591
|
+
get transactionDepth() {
|
|
4592
|
+
return this.depth;
|
|
4593
|
+
}
|
|
4594
|
+
/** Whether a `transaction()` block is currently open on this session. */
|
|
4595
|
+
get inTransaction() {
|
|
4596
|
+
return this.depth > 0;
|
|
4597
|
+
}
|
|
4598
|
+
/**
|
|
4599
|
+
* Run `fn` inside a transaction, committing on a clean exit and rolling back on
|
|
4600
|
+
* a throw.
|
|
4601
|
+
*
|
|
4602
|
+
* **Re-entrant:** a nested call joins the block already open on this session,
|
|
4603
|
+
* so a service orchestrating several repositories bound to the same session
|
|
4604
|
+
* gets one `BEGIN` and one `COMMIT`, not two of each. An inner failure rolls the
|
|
4605
|
+
* whole block back; use {@link beginNested} for a savepoint that can be
|
|
4606
|
+
* recovered from.
|
|
4607
|
+
*
|
|
4608
|
+
* Pooled drivers (PostgreSQL) pin one connection for the block: `BEGIN`/`COMMIT`
|
|
4609
|
+
* and every statement between them have to run on the same connection, or
|
|
4610
|
+
* postgres.js rejects the raw transaction. Single-connection drivers (SQLite)
|
|
4611
|
+
* skip the reservation.
|
|
4612
|
+
*
|
|
4613
|
+
* @param fn The body; receives the session to work through (the pinned one, on a
|
|
4614
|
+
* pooled driver).
|
|
4615
|
+
* @returns Whatever `fn` returned.
|
|
4616
|
+
*/
|
|
4617
|
+
async transaction(fn2, options) {
|
|
4618
|
+
if (this.depth > 0) {
|
|
4619
|
+
assertNoNestedOptions(options);
|
|
4620
|
+
this.depth += 1;
|
|
4621
|
+
try {
|
|
4622
|
+
return await fn2(this);
|
|
4623
|
+
} finally {
|
|
4624
|
+
this.depth -= 1;
|
|
4625
|
+
}
|
|
4626
|
+
}
|
|
4627
|
+
const begin = this.dialect.beginStatements(options);
|
|
2525
4628
|
if (this.driver.reserve) {
|
|
2526
4629
|
const reserved = await this.driver.reserve();
|
|
2527
|
-
const scoped = new _AsyncSession(reserved, this.dialect, this.
|
|
4630
|
+
const scoped = new _AsyncSession(reserved, this.dialect, this.hooks);
|
|
2528
4631
|
try {
|
|
2529
|
-
await scoped.exec(
|
|
4632
|
+
for (const stmt of begin) await scoped.exec(stmt, []);
|
|
4633
|
+
scoped.depth = 1;
|
|
2530
4634
|
const out = await fn2(scoped);
|
|
2531
4635
|
await scoped.exec("COMMIT", []);
|
|
2532
4636
|
return out;
|
|
@@ -2534,10 +4638,12 @@ var AsyncSession = class _AsyncSession {
|
|
|
2534
4638
|
await scoped.exec("ROLLBACK", []);
|
|
2535
4639
|
throw error;
|
|
2536
4640
|
} finally {
|
|
4641
|
+
scoped.depth = 0;
|
|
2537
4642
|
await reserved.release();
|
|
2538
4643
|
}
|
|
2539
4644
|
}
|
|
2540
|
-
await this.exec(
|
|
4645
|
+
for (const stmt of begin) await this.exec(stmt, []);
|
|
4646
|
+
this.depth = 1;
|
|
2541
4647
|
try {
|
|
2542
4648
|
const out = await fn2(this);
|
|
2543
4649
|
await this.exec("COMMIT", []);
|
|
@@ -2545,8 +4651,49 @@ var AsyncSession = class _AsyncSession {
|
|
|
2545
4651
|
} catch (error) {
|
|
2546
4652
|
await this.exec("ROLLBACK", []);
|
|
2547
4653
|
throw error;
|
|
4654
|
+
} finally {
|
|
4655
|
+
this.depth = 0;
|
|
4656
|
+
}
|
|
4657
|
+
}
|
|
4658
|
+
/**
|
|
4659
|
+
* Run `fn` inside a `SAVEPOINT` (a nested transaction that can be rolled back on
|
|
4660
|
+
* its own).
|
|
4661
|
+
*
|
|
4662
|
+
* This is the difference from a nested {@link transaction}: a savepoint that
|
|
4663
|
+
* fails discards **only** its own work, so the enclosing block can catch the
|
|
4664
|
+
* error and carry on. A nested `transaction()` joins the outer block, and its
|
|
4665
|
+
* failure takes the whole block down.
|
|
4666
|
+
*
|
|
4667
|
+
* Must run inside an open transaction — PostgreSQL rejects a savepoint outside a
|
|
4668
|
+
* transaction block.
|
|
4669
|
+
*
|
|
4670
|
+
* @param fn The body; receives the same session.
|
|
4671
|
+
* @returns Whatever `fn` returned.
|
|
4672
|
+
*/
|
|
4673
|
+
async beginNested(fn2) {
|
|
4674
|
+
savepointCounter += 1;
|
|
4675
|
+
const name = `qsp_${savepointCounter}`;
|
|
4676
|
+
await this.exec(`SAVEPOINT ${name}`, []);
|
|
4677
|
+
try {
|
|
4678
|
+
const out = await fn2(this);
|
|
4679
|
+
await this.exec(`RELEASE ${name}`, []);
|
|
4680
|
+
return out;
|
|
4681
|
+
} catch (error) {
|
|
4682
|
+
await this.exec(`ROLLBACK TO ${name}`, []);
|
|
4683
|
+
throw error;
|
|
2548
4684
|
}
|
|
2549
4685
|
}
|
|
4686
|
+
/**
|
|
4687
|
+
* Open an opt-in {@link UnitOfWork} over this session.
|
|
4688
|
+
*
|
|
4689
|
+
* The default stays a plain object written when you ask; this is the other
|
|
4690
|
+
* model, for code that prefers to mutate and flush once.
|
|
4691
|
+
*
|
|
4692
|
+
* @returns A new, empty unit of work.
|
|
4693
|
+
*/
|
|
4694
|
+
unitOfWork() {
|
|
4695
|
+
return new UnitOfWork(this);
|
|
4696
|
+
}
|
|
2550
4697
|
async close() {
|
|
2551
4698
|
await this.driver.close();
|
|
2552
4699
|
}
|
|
@@ -2555,6 +4702,14 @@ var AsyncSession = class _AsyncSession {
|
|
|
2555
4702
|
await this.close();
|
|
2556
4703
|
}
|
|
2557
4704
|
};
|
|
4705
|
+
function queryHooks(options) {
|
|
4706
|
+
if (!options?.onQuery && !options?.onQueryEnd) return void 0;
|
|
4707
|
+
return {
|
|
4708
|
+
onQuery: options.onQuery,
|
|
4709
|
+
onQueryEnd: options.onQueryEnd,
|
|
4710
|
+
slowQueryMs: options.slowQueryMs
|
|
4711
|
+
};
|
|
4712
|
+
}
|
|
2558
4713
|
function emitNotice(logger, notice) {
|
|
2559
4714
|
if (!logger) return;
|
|
2560
4715
|
try {
|
|
@@ -2563,18 +4718,18 @@ function emitNotice(logger, notice) {
|
|
|
2563
4718
|
}
|
|
2564
4719
|
}
|
|
2565
4720
|
var SyncEngine = class {
|
|
2566
|
-
constructor(driver,
|
|
4721
|
+
constructor(driver, hooks) {
|
|
2567
4722
|
this.driver = driver;
|
|
2568
|
-
this.
|
|
4723
|
+
this.hooks = hooks;
|
|
2569
4724
|
}
|
|
2570
4725
|
driver;
|
|
2571
|
-
|
|
4726
|
+
hooks;
|
|
2572
4727
|
dialect = "sqlite";
|
|
2573
4728
|
session() {
|
|
2574
|
-
return new SyncSession(this.driver, getDialect("sqlite"), this.
|
|
4729
|
+
return new SyncSession(this.driver, getDialect("sqlite"), this.hooks);
|
|
2575
4730
|
}
|
|
2576
|
-
transaction(fn2) {
|
|
2577
|
-
return this.session().transaction(fn2);
|
|
4731
|
+
transaction(fn2, options) {
|
|
4732
|
+
return this.session().transaction(fn2, options);
|
|
2578
4733
|
}
|
|
2579
4734
|
close() {
|
|
2580
4735
|
this.driver.close();
|
|
@@ -2585,19 +4740,68 @@ var SyncEngine = class {
|
|
|
2585
4740
|
}
|
|
2586
4741
|
};
|
|
2587
4742
|
var AsyncEngine = class {
|
|
2588
|
-
constructor(driver, dialect,
|
|
4743
|
+
constructor(driver, dialect, hooks) {
|
|
2589
4744
|
this.driver = driver;
|
|
2590
4745
|
this.dialect = dialect;
|
|
2591
|
-
this.
|
|
4746
|
+
this.hooks = hooks;
|
|
2592
4747
|
}
|
|
2593
4748
|
driver;
|
|
2594
4749
|
dialect;
|
|
2595
|
-
|
|
4750
|
+
hooks;
|
|
2596
4751
|
session() {
|
|
2597
|
-
return new AsyncSession(this.driver, getDialect(this.dialect), this.
|
|
4752
|
+
return new AsyncSession(this.driver, getDialect(this.dialect), this.hooks);
|
|
2598
4753
|
}
|
|
2599
|
-
transaction(fn2) {
|
|
2600
|
-
return this.session().transaction(fn2);
|
|
4754
|
+
transaction(fn2, options) {
|
|
4755
|
+
return this.session().transaction(fn2, options);
|
|
4756
|
+
}
|
|
4757
|
+
/**
|
|
4758
|
+
* Run `fn` and return the query plan of every statement it ran.
|
|
4759
|
+
*
|
|
4760
|
+
* The block gets its own session over a **recording** driver, so the plans are
|
|
4761
|
+
* built from the statements and the parameters the code really used — not from
|
|
4762
|
+
* SQL copied out of a log by hand. A development tool: it runs the block once
|
|
4763
|
+
* and then one `EXPLAIN` per statement, so keep it out of the hot path.
|
|
4764
|
+
*
|
|
4765
|
+
* @param fn The code to observe; receives the recording session.
|
|
4766
|
+
* @param options `analyze: true` to measure (refused for writes), `filter` to
|
|
4767
|
+
* explain only some statements.
|
|
4768
|
+
* @returns The report, one plan per statement in execution order.
|
|
4769
|
+
* @throws Error When `analyze` is requested for a statement that writes, or on
|
|
4770
|
+
* a dialect that has no `EXPLAIN ANALYZE`.
|
|
4771
|
+
*
|
|
4772
|
+
* @example
|
|
4773
|
+
* ```ts
|
|
4774
|
+
* const report = await engine.explain(async (session) => {
|
|
4775
|
+
* await new BaseRepository(Order, session).paginate({ page: 3 });
|
|
4776
|
+
* });
|
|
4777
|
+
* console.log(report.summary());
|
|
4778
|
+
* ```
|
|
4779
|
+
*/
|
|
4780
|
+
async explain(fn2, options) {
|
|
4781
|
+
const statements = [];
|
|
4782
|
+
const recorder = {
|
|
4783
|
+
execute: async (sql2, params) => {
|
|
4784
|
+
statements.push({ sql: sql2, params });
|
|
4785
|
+
return this.driver.execute(sql2, params);
|
|
4786
|
+
},
|
|
4787
|
+
close: () => Promise.resolve(),
|
|
4788
|
+
...this.driver.iterate ? {
|
|
4789
|
+
iterate: (sql2, params) => {
|
|
4790
|
+
statements.push({ sql: sql2, params });
|
|
4791
|
+
return this.driver.iterate(
|
|
4792
|
+
sql2,
|
|
4793
|
+
params
|
|
4794
|
+
);
|
|
4795
|
+
}
|
|
4796
|
+
} : {}
|
|
4797
|
+
};
|
|
4798
|
+
await fn2(new AsyncSession(recorder, getDialect(this.dialect), this.hooks));
|
|
4799
|
+
return buildReport(
|
|
4800
|
+
this.session(),
|
|
4801
|
+
statements,
|
|
4802
|
+
(analyze) => getDialect(this.dialect).explainPrefix(analyze),
|
|
4803
|
+
options
|
|
4804
|
+
);
|
|
2601
4805
|
}
|
|
2602
4806
|
async close() {
|
|
2603
4807
|
await this.driver.close();
|
|
@@ -2663,9 +4867,76 @@ function resolveSqliteDriver(parsed, options) {
|
|
|
2663
4867
|
const fromUrl = parsed.driver ? SQLITE_DRIVER_ALIASES[parsed.driver.toLowerCase()] : void 0;
|
|
2664
4868
|
return fromUrl ?? "node:sqlite";
|
|
2665
4869
|
}
|
|
4870
|
+
var SQLITE_SYNCHRONOUS_LEVELS = [
|
|
4871
|
+
"off",
|
|
4872
|
+
"normal",
|
|
4873
|
+
"full",
|
|
4874
|
+
"extra"
|
|
4875
|
+
];
|
|
4876
|
+
function readPragma(driver, name) {
|
|
4877
|
+
const { rows } = driver.execute(`PRAGMA ${name}`, []);
|
|
4878
|
+
const first = rows[0];
|
|
4879
|
+
if (!first) return null;
|
|
4880
|
+
return Object.values(first)[0] ?? null;
|
|
4881
|
+
}
|
|
4882
|
+
function applySqlitePragmas(driver, options, path) {
|
|
4883
|
+
const foreignKeys = options?.foreignKeys ?? true;
|
|
4884
|
+
driver.execute(`PRAGMA foreign_keys = ${foreignKeys ? "ON" : "OFF"}`, []);
|
|
4885
|
+
if (Number(readPragma(driver, "foreign_keys")) !== (foreignKeys ? 1 : 0)) {
|
|
4886
|
+
throw new Error(
|
|
4887
|
+
`SQLite refused PRAGMA foreign_keys = ${foreignKeys ? "ON" : "OFF"} \u2014 the build may lack foreign-key support.`
|
|
4888
|
+
);
|
|
4889
|
+
}
|
|
4890
|
+
const journalMode = options?.journalMode;
|
|
4891
|
+
if (journalMode) {
|
|
4892
|
+
driver.execute(`PRAGMA journal_mode = ${journalMode}`, []);
|
|
4893
|
+
const actual = String(readPragma(driver, "journal_mode") ?? "").toLowerCase();
|
|
4894
|
+
if (actual !== journalMode) {
|
|
4895
|
+
const hint = journalMode === "wal" && actual === "memory" ? " \u2014 an in-memory database cannot use WAL." : ".";
|
|
4896
|
+
throw new Error(
|
|
4897
|
+
`SQLite refused PRAGMA journal_mode = ${journalMode} for ${JSON.stringify(path)} and stayed on ${JSON.stringify(actual)}${hint}`
|
|
4898
|
+
);
|
|
4899
|
+
}
|
|
4900
|
+
}
|
|
4901
|
+
const busyTimeoutMs = options?.busyTimeoutMs;
|
|
4902
|
+
if (busyTimeoutMs !== void 0) {
|
|
4903
|
+
if (!Number.isInteger(busyTimeoutMs) || busyTimeoutMs < 0) {
|
|
4904
|
+
throw new Error(
|
|
4905
|
+
`busyTimeoutMs must be a non-negative integer, got ${JSON.stringify(busyTimeoutMs)}.`
|
|
4906
|
+
);
|
|
4907
|
+
}
|
|
4908
|
+
driver.execute(`PRAGMA busy_timeout = ${busyTimeoutMs}`, []);
|
|
4909
|
+
if (Number(readPragma(driver, "busy_timeout")) !== busyTimeoutMs) {
|
|
4910
|
+
throw new Error(`SQLite refused PRAGMA busy_timeout = ${busyTimeoutMs}.`);
|
|
4911
|
+
}
|
|
4912
|
+
}
|
|
4913
|
+
const synchronous = options?.synchronous;
|
|
4914
|
+
if (synchronous) {
|
|
4915
|
+
driver.execute(`PRAGMA synchronous = ${synchronous}`, []);
|
|
4916
|
+
const actual = SQLITE_SYNCHRONOUS_LEVELS[Number(readPragma(driver, "synchronous"))];
|
|
4917
|
+
if (actual !== synchronous) {
|
|
4918
|
+
throw new Error(
|
|
4919
|
+
`SQLite refused PRAGMA synchronous = ${synchronous} and stayed on ${JSON.stringify(actual ?? "unknown")}.`
|
|
4920
|
+
);
|
|
4921
|
+
}
|
|
4922
|
+
}
|
|
4923
|
+
}
|
|
4924
|
+
function checkSqliteOptions(dialect, options) {
|
|
4925
|
+
if (!options?.sqlite) return;
|
|
4926
|
+
throw new Error(
|
|
4927
|
+
`The "sqlite" engine options are SQLite-only; ${dialect} has no per-connection pragmas.`
|
|
4928
|
+
);
|
|
4929
|
+
}
|
|
2666
4930
|
function openSqliteDriver(parsed, options) {
|
|
2667
4931
|
const path = parsed.database ?? ":memory:";
|
|
2668
|
-
|
|
4932
|
+
const driver = resolveSqliteDriver(parsed, options) === "better-sqlite3" ? BetterSqliteDriver.open(path, options?.driverOptions) : NodeSqliteDriver.open(path, options?.driverOptions);
|
|
4933
|
+
try {
|
|
4934
|
+
applySqlitePragmas(driver, options?.sqlite, path);
|
|
4935
|
+
} catch (error) {
|
|
4936
|
+
driver.close();
|
|
4937
|
+
throw error;
|
|
4938
|
+
}
|
|
4939
|
+
return driver;
|
|
2669
4940
|
}
|
|
2670
4941
|
function createSyncEngine(url, options) {
|
|
2671
4942
|
const parsed = parseDatabaseUrl(url);
|
|
@@ -2674,7 +4945,7 @@ function createSyncEngine(url, options) {
|
|
|
2674
4945
|
`createSyncEngine supports only SQLite; ${parsed.dialect} is async-only \u2014 use createEngine.`
|
|
2675
4946
|
);
|
|
2676
4947
|
}
|
|
2677
|
-
return new SyncEngine(openSqliteDriver(parsed, options), options
|
|
4948
|
+
return new SyncEngine(openSqliteDriver(parsed, options), queryHooks(options));
|
|
2678
4949
|
}
|
|
2679
4950
|
function createEngine(url, options) {
|
|
2680
4951
|
const parsed = parseDatabaseUrl(url);
|
|
@@ -2682,22 +4953,24 @@ function createEngine(url, options) {
|
|
|
2682
4953
|
return new AsyncEngine(
|
|
2683
4954
|
asAsync(openSqliteDriver(parsed, options)),
|
|
2684
4955
|
"sqlite",
|
|
2685
|
-
options
|
|
4956
|
+
queryHooks(options)
|
|
2686
4957
|
);
|
|
2687
4958
|
}
|
|
2688
4959
|
if (parsed.dialect === "mysql") {
|
|
2689
4960
|
checkServerDriver("mysql", options?.driver);
|
|
4961
|
+
checkSqliteOptions("mysql", options);
|
|
2690
4962
|
return new AsyncEngine(
|
|
2691
4963
|
createMysqlDriver(parsed.raw, options),
|
|
2692
4964
|
"mysql",
|
|
2693
|
-
options
|
|
4965
|
+
queryHooks(options)
|
|
2694
4966
|
);
|
|
2695
4967
|
}
|
|
2696
4968
|
checkServerDriver("postgresql", options?.driver);
|
|
4969
|
+
checkSqliteOptions("postgresql", options);
|
|
2697
4970
|
return new AsyncEngine(
|
|
2698
4971
|
createPostgresDriver(parsed.raw, options),
|
|
2699
4972
|
"postgresql",
|
|
2700
|
-
options
|
|
4973
|
+
queryHooks(options)
|
|
2701
4974
|
);
|
|
2702
4975
|
}
|
|
2703
4976
|
function encodeMysqlParam(value) {
|
|
@@ -2726,6 +4999,11 @@ function createMysqlDriver(url, options) {
|
|
|
2726
4999
|
moduleName
|
|
2727
5000
|
);
|
|
2728
5001
|
const opts = { uri: url };
|
|
5002
|
+
if (pool?.prePing || pool?.recycleMs !== void 0) {
|
|
5003
|
+
throw new Error(
|
|
5004
|
+
"pool.prePing and pool.recycleMs are PostgreSQL-only; mysql2 has no equivalent knob."
|
|
5005
|
+
);
|
|
5006
|
+
}
|
|
2729
5007
|
if (pool?.size !== void 0) opts.connectionLimit = pool.size;
|
|
2730
5008
|
if (pool?.idleTimeoutMs !== void 0) opts.idleTimeout = pool.idleTimeoutMs;
|
|
2731
5009
|
if (pool?.connectTimeoutMs !== void 0) opts.connectTimeout = pool.connectTimeoutMs;
|
|
@@ -2780,6 +5058,9 @@ function createPostgresDriver(url, options) {
|
|
|
2780
5058
|
if (pool?.connectTimeoutMs !== void 0) {
|
|
2781
5059
|
opts.connect_timeout = Math.ceil(pool.connectTimeoutMs / 1e3);
|
|
2782
5060
|
}
|
|
5061
|
+
if (pool?.recycleMs !== void 0) {
|
|
5062
|
+
opts.max_lifetime = Math.ceil(pool.recycleMs / 1e3);
|
|
5063
|
+
}
|
|
2783
5064
|
opts.onnotice = (notice) => emitNotice(options?.onNotice, notice);
|
|
2784
5065
|
Object.assign(opts, options?.driverOptions ?? {});
|
|
2785
5066
|
client = (mod.default ?? mod)(url, opts);
|
|
@@ -2791,7 +5072,16 @@ function createPostgresDriver(url, options) {
|
|
|
2791
5072
|
},
|
|
2792
5073
|
async reserve() {
|
|
2793
5074
|
await ensure();
|
|
2794
|
-
|
|
5075
|
+
let conn = await client.reserve();
|
|
5076
|
+
if (pool?.prePing) {
|
|
5077
|
+
try {
|
|
5078
|
+
await conn.unsafe("SELECT 1", []);
|
|
5079
|
+
} catch {
|
|
5080
|
+
conn.release();
|
|
5081
|
+
conn = await client.reserve();
|
|
5082
|
+
await conn.unsafe("SELECT 1", []);
|
|
5083
|
+
}
|
|
5084
|
+
}
|
|
2795
5085
|
return {
|
|
2796
5086
|
async execute(sql2, params) {
|
|
2797
5087
|
return toPostgresResult(await conn.unsafe(sql2, params));
|
|
@@ -2810,6 +5100,302 @@ function createPostgresDriver(url, options) {
|
|
|
2810
5100
|
};
|
|
2811
5101
|
}
|
|
2812
5102
|
|
|
5103
|
+
// src/backup.ts
|
|
5104
|
+
var run = promisify(execFile);
|
|
5105
|
+
var BackupToolMissing = class extends Error {
|
|
5106
|
+
constructor(tool) {
|
|
5107
|
+
super(`${tool} is not on the PATH; install the PostgreSQL client tools to back up.`);
|
|
5108
|
+
this.name = "BackupToolMissing";
|
|
5109
|
+
}
|
|
5110
|
+
};
|
|
5111
|
+
var UnsupportedBackupBackend = class extends Error {
|
|
5112
|
+
constructor(dialect) {
|
|
5113
|
+
super(
|
|
5114
|
+
`Backups are implemented for PostgreSQL and SQLite; ${dialect} is not covered.`
|
|
5115
|
+
);
|
|
5116
|
+
this.name = "UnsupportedBackupBackend";
|
|
5117
|
+
}
|
|
5118
|
+
};
|
|
5119
|
+
function backupFormat(file) {
|
|
5120
|
+
return file.endsWith(".sql") ? "plain" : "custom";
|
|
5121
|
+
}
|
|
5122
|
+
function postgresEnv(password) {
|
|
5123
|
+
return password ? { ...process.env, PGPASSWORD: password } : { ...process.env };
|
|
5124
|
+
}
|
|
5125
|
+
function toolUrl(url) {
|
|
5126
|
+
return url.replace(/^([a-z0-9]+)\+[a-z0-9_-]+:/i, "$1:");
|
|
5127
|
+
}
|
|
5128
|
+
async function spawn(tool, args, env) {
|
|
5129
|
+
try {
|
|
5130
|
+
await run(tool, [...args], { env, maxBuffer: 1024 * 1024 * 64 });
|
|
5131
|
+
} catch (error) {
|
|
5132
|
+
if (error.code === "ENOENT") {
|
|
5133
|
+
throw new BackupToolMissing(tool);
|
|
5134
|
+
}
|
|
5135
|
+
throw error;
|
|
5136
|
+
}
|
|
5137
|
+
}
|
|
5138
|
+
async function backupDatabase(url, file, options) {
|
|
5139
|
+
const parsed = parseDatabaseUrl(url);
|
|
5140
|
+
if (parsed.dialect === "sqlite") {
|
|
5141
|
+
const source = parsed.database ?? ":memory:";
|
|
5142
|
+
const driver = NodeSqliteDriver.open(source);
|
|
5143
|
+
try {
|
|
5144
|
+
driver.execute("VACUUM INTO ?", [file]);
|
|
5145
|
+
} finally {
|
|
5146
|
+
driver.close();
|
|
5147
|
+
}
|
|
5148
|
+
return { file, dialect: "sqlite", via: "VACUUM INTO" };
|
|
5149
|
+
}
|
|
5150
|
+
if (parsed.dialect !== "postgresql") {
|
|
5151
|
+
throw new UnsupportedBackupBackend(parsed.dialect);
|
|
5152
|
+
}
|
|
5153
|
+
const format = backupFormat(file);
|
|
5154
|
+
await spawn(
|
|
5155
|
+
"pg_dump",
|
|
5156
|
+
[
|
|
5157
|
+
"--dbname",
|
|
5158
|
+
toolUrl(url),
|
|
5159
|
+
...format === "custom" ? ["--format=custom"] : [],
|
|
5160
|
+
"--file",
|
|
5161
|
+
file,
|
|
5162
|
+
...options?.extraArgs ?? []
|
|
5163
|
+
],
|
|
5164
|
+
postgresEnv(parsed.password)
|
|
5165
|
+
);
|
|
5166
|
+
return { file, dialect: "postgresql", via: "pg_dump" };
|
|
5167
|
+
}
|
|
5168
|
+
async function restoreDatabase(url, file, options) {
|
|
5169
|
+
const parsed = parseDatabaseUrl(url);
|
|
5170
|
+
if (parsed.dialect === "sqlite") {
|
|
5171
|
+
const target = parsed.database ?? ":memory:";
|
|
5172
|
+
await copyFile(file, target);
|
|
5173
|
+
return { file, dialect: "sqlite", via: "copy" };
|
|
5174
|
+
}
|
|
5175
|
+
if (parsed.dialect !== "postgresql") {
|
|
5176
|
+
throw new UnsupportedBackupBackend(parsed.dialect);
|
|
5177
|
+
}
|
|
5178
|
+
const env = postgresEnv(parsed.password);
|
|
5179
|
+
if (backupFormat(file) === "plain") {
|
|
5180
|
+
await spawn(
|
|
5181
|
+
"psql",
|
|
5182
|
+
["--dbname", toolUrl(url), "--file", file, ...options?.extraArgs ?? []],
|
|
5183
|
+
env
|
|
5184
|
+
);
|
|
5185
|
+
return { file, dialect: "postgresql", via: "psql" };
|
|
5186
|
+
}
|
|
5187
|
+
await spawn(
|
|
5188
|
+
"pg_restore",
|
|
5189
|
+
[
|
|
5190
|
+
"--dbname",
|
|
5191
|
+
toolUrl(url),
|
|
5192
|
+
...options?.force ? ["--clean", "--if-exists"] : [],
|
|
5193
|
+
file,
|
|
5194
|
+
...options?.extraArgs ?? []
|
|
5195
|
+
],
|
|
5196
|
+
env
|
|
5197
|
+
);
|
|
5198
|
+
return { file, dialect: "postgresql", via: "pg_restore" };
|
|
5199
|
+
}
|
|
5200
|
+
|
|
5201
|
+
// src/cte.ts
|
|
5202
|
+
function cteModel(model, name) {
|
|
5203
|
+
const relation = class extends model {
|
|
5204
|
+
};
|
|
5205
|
+
Object.defineProperty(relation, "tablename", { value: name, writable: true });
|
|
5206
|
+
Object.defineProperty(relation, "naming", {
|
|
5207
|
+
value: model.naming,
|
|
5208
|
+
writable: true
|
|
5209
|
+
});
|
|
5210
|
+
Object.defineProperty(relation, "tableArgs", { value: void 0, writable: true });
|
|
5211
|
+
return relation;
|
|
5212
|
+
}
|
|
5213
|
+
var Cte = class {
|
|
5214
|
+
constructor(model, node) {
|
|
5215
|
+
this.model = model;
|
|
5216
|
+
this.node = node;
|
|
5217
|
+
}
|
|
5218
|
+
model;
|
|
5219
|
+
node;
|
|
5220
|
+
select(columns) {
|
|
5221
|
+
const builder = columns ? select(this.model, columns) : select(this.model);
|
|
5222
|
+
return attach(builder, this.node);
|
|
5223
|
+
}
|
|
5224
|
+
};
|
|
5225
|
+
function attach(builder, node) {
|
|
5226
|
+
const current = builder.node.with ?? [];
|
|
5227
|
+
const next = { ...builder.node, with: [...current, node] };
|
|
5228
|
+
return Object.assign(Object.create(Object.getPrototypeOf(builder)), builder, {
|
|
5229
|
+
node: next
|
|
5230
|
+
});
|
|
5231
|
+
}
|
|
5232
|
+
function cte(name, model, body, options) {
|
|
5233
|
+
return new Cte(cteModel(model, name), {
|
|
5234
|
+
name,
|
|
5235
|
+
recursive: false,
|
|
5236
|
+
body: body.node,
|
|
5237
|
+
materialized: options?.materialized ?? null
|
|
5238
|
+
});
|
|
5239
|
+
}
|
|
5240
|
+
function cteRecursive(name, model, build, options) {
|
|
5241
|
+
const self = cteModel(model, name);
|
|
5242
|
+
return new Cte(self, {
|
|
5243
|
+
name,
|
|
5244
|
+
recursive: true,
|
|
5245
|
+
body: build(self).node,
|
|
5246
|
+
materialized: options?.materialized ?? null
|
|
5247
|
+
});
|
|
5248
|
+
}
|
|
5249
|
+
|
|
5250
|
+
// src/active-record.ts
|
|
5251
|
+
var ActiveRecord = class {
|
|
5252
|
+
constructor(model, session, data) {
|
|
5253
|
+
this.model = model;
|
|
5254
|
+
this.session = session;
|
|
5255
|
+
this.data = data;
|
|
5256
|
+
this.pks = primaryKeysOf(model);
|
|
5257
|
+
}
|
|
5258
|
+
model;
|
|
5259
|
+
session;
|
|
5260
|
+
data;
|
|
5261
|
+
pks;
|
|
5262
|
+
/**
|
|
5263
|
+
* The primary-key values of the wrapped row, as an object.
|
|
5264
|
+
*
|
|
5265
|
+
* Composite keys are the reason this is an object and not a value: identifying
|
|
5266
|
+
* the row by one column of a two-column key hits the wrong row.
|
|
5267
|
+
*/
|
|
5268
|
+
pkValue() {
|
|
5269
|
+
const row = this.data;
|
|
5270
|
+
const key = {};
|
|
5271
|
+
for (const name of this.pks) key[name] = row[name];
|
|
5272
|
+
return key;
|
|
5273
|
+
}
|
|
5274
|
+
pkFilter() {
|
|
5275
|
+
return this.pkValue();
|
|
5276
|
+
}
|
|
5277
|
+
/**
|
|
5278
|
+
* Persist the current `data` — insert if new, otherwise overwrite the existing
|
|
5279
|
+
* row (upsert on the primary key). Refreshes `data` from the returned row.
|
|
5280
|
+
*
|
|
5281
|
+
* @returns This wrapper, for chaining.
|
|
5282
|
+
*/
|
|
5283
|
+
async save() {
|
|
5284
|
+
const cols = columnsOf(this.model);
|
|
5285
|
+
const rowData = this.data;
|
|
5286
|
+
const setPatch = {};
|
|
5287
|
+
for (const c of Object.keys(rowData)) {
|
|
5288
|
+
if (!this.pks.includes(c) && c in cols) setPatch[c] = rowData[c];
|
|
5289
|
+
}
|
|
5290
|
+
const saved = await this.session.execute(
|
|
5291
|
+
insert(this.model).values(this.data).onConflictDoUpdate(
|
|
5292
|
+
this.pks,
|
|
5293
|
+
setPatch
|
|
5294
|
+
).returning()
|
|
5295
|
+
).one();
|
|
5296
|
+
this.data = saved;
|
|
5297
|
+
return this;
|
|
5298
|
+
}
|
|
5299
|
+
/**
|
|
5300
|
+
* Update the given columns for this row and merge them into `data`.
|
|
5301
|
+
*
|
|
5302
|
+
* @param patch The columns to change.
|
|
5303
|
+
* @returns This wrapper, for chaining.
|
|
5304
|
+
*/
|
|
5305
|
+
async update(patch) {
|
|
5306
|
+
await this.session.execute(update(this.model).set(patch).where(this.pkFilter()));
|
|
5307
|
+
this.data = { ...this.data, ...patch };
|
|
5308
|
+
return this;
|
|
5309
|
+
}
|
|
5310
|
+
/**
|
|
5311
|
+
* Delete this row.
|
|
5312
|
+
*
|
|
5313
|
+
* @returns The number of rows affected (0 or 1).
|
|
5314
|
+
*/
|
|
5315
|
+
async delete() {
|
|
5316
|
+
return this.session.execute(del(this.model).where(this.pkFilter())).rowsAffected();
|
|
5317
|
+
}
|
|
5318
|
+
/**
|
|
5319
|
+
* Re-fetch this row by primary key and refresh `data`.
|
|
5320
|
+
*
|
|
5321
|
+
* @returns This wrapper, for chaining.
|
|
5322
|
+
* @throws When the row no longer exists.
|
|
5323
|
+
*/
|
|
5324
|
+
async reload() {
|
|
5325
|
+
const fresh = await this.session.execute(select(this.model).where(this.pkFilter())).first();
|
|
5326
|
+
if (fresh === null) {
|
|
5327
|
+
throw new Error(
|
|
5328
|
+
`${this.model.tablename} row ${JSON.stringify(this.pkValue())} not found on reload`
|
|
5329
|
+
);
|
|
5330
|
+
}
|
|
5331
|
+
this.data = fresh;
|
|
5332
|
+
return this;
|
|
5333
|
+
}
|
|
5334
|
+
};
|
|
5335
|
+
function activeRecord(model, session) {
|
|
5336
|
+
primaryKeysOf(model);
|
|
5337
|
+
return {
|
|
5338
|
+
wrap: (row) => new ActiveRecord(model, session, row),
|
|
5339
|
+
create: (data) => new ActiveRecord(model, session, data),
|
|
5340
|
+
async get(id) {
|
|
5341
|
+
const filter = primaryKeyFilter(model, id);
|
|
5342
|
+
const row = await session.execute(select(model).where(filter)).first();
|
|
5343
|
+
return row === null ? null : new ActiveRecord(model, session, row);
|
|
5344
|
+
}
|
|
5345
|
+
};
|
|
5346
|
+
}
|
|
5347
|
+
|
|
5348
|
+
// src/relations.ts
|
|
5349
|
+
function hasMany(target, keys) {
|
|
5350
|
+
return {
|
|
5351
|
+
kind: "hasMany",
|
|
5352
|
+
target,
|
|
5353
|
+
localKey: keys.localKey,
|
|
5354
|
+
foreignKey: keys.foreignKey
|
|
5355
|
+
};
|
|
5356
|
+
}
|
|
5357
|
+
function belongsTo(target, keys) {
|
|
5358
|
+
return {
|
|
5359
|
+
kind: "belongsTo",
|
|
5360
|
+
target,
|
|
5361
|
+
localKey: keys.localKey,
|
|
5362
|
+
foreignKey: keys.foreignKey
|
|
5363
|
+
};
|
|
5364
|
+
}
|
|
5365
|
+
async function loadRelations(session, rows, spec) {
|
|
5366
|
+
const out = rows.map((r) => ({ ...r }));
|
|
5367
|
+
for (const [name, rel] of Object.entries(spec)) {
|
|
5368
|
+
const target = rel.target();
|
|
5369
|
+
const localValues = [...new Set(rows.map((r) => r[rel.localKey]))];
|
|
5370
|
+
const related = localValues.length > 0 ? await session.execute(
|
|
5371
|
+
select(target).where({
|
|
5372
|
+
[rel.foreignKey]: { in: localValues }
|
|
5373
|
+
})
|
|
5374
|
+
).all() : [];
|
|
5375
|
+
if (rel.kind === "hasMany") {
|
|
5376
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
5377
|
+
for (const row of related) {
|
|
5378
|
+
const key = row[rel.foreignKey];
|
|
5379
|
+
const list = grouped.get(key) ?? [];
|
|
5380
|
+
list.push(row);
|
|
5381
|
+
grouped.set(key, list);
|
|
5382
|
+
}
|
|
5383
|
+
out.forEach((r, i) => {
|
|
5384
|
+
r[name] = grouped.get(rows[i]?.[rel.localKey]) ?? [];
|
|
5385
|
+
});
|
|
5386
|
+
} else {
|
|
5387
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
5388
|
+
for (const row of related) {
|
|
5389
|
+
byKey.set(row[rel.foreignKey], row);
|
|
5390
|
+
}
|
|
5391
|
+
out.forEach((r, i) => {
|
|
5392
|
+
r[name] = byKey.get(rows[i]?.[rel.localKey]) ?? null;
|
|
5393
|
+
});
|
|
5394
|
+
}
|
|
5395
|
+
}
|
|
5396
|
+
return out;
|
|
5397
|
+
}
|
|
5398
|
+
|
|
2813
5399
|
// src/index.ts
|
|
2814
5400
|
var DEFAULT_FLAGS = {
|
|
2815
5401
|
primaryKey: false,
|
|
@@ -2821,6 +5407,9 @@ var EXPRESSION = /* @__PURE__ */ Symbol.for("tempest-db-js.expression");
|
|
|
2821
5407
|
function expression(token, params = []) {
|
|
2822
5408
|
return { [EXPRESSION]: true, kind: "expression", expression: token, params };
|
|
2823
5409
|
}
|
|
5410
|
+
function defaultAsWriteValue(value) {
|
|
5411
|
+
return value.kind === "literal" ? value.value : expression(value.expression);
|
|
5412
|
+
}
|
|
2824
5413
|
function isSqlExpression(value) {
|
|
2825
5414
|
return typeof value === "object" && value !== null && value[EXPRESSION] === true;
|
|
2826
5415
|
}
|
|
@@ -2833,6 +5422,18 @@ var sql = {
|
|
|
2833
5422
|
currentTime: () => expression("current_time"),
|
|
2834
5423
|
/** A freshly generated UUID v4 (`gen_random_uuid()` / portable fallback). */
|
|
2835
5424
|
uuidv4: () => expression("uuidv4"),
|
|
5425
|
+
/**
|
|
5426
|
+
* The **incoming** value of a column, valid only inside an upsert's
|
|
5427
|
+
* `onConflictDoUpdate` patch.
|
|
5428
|
+
*
|
|
5429
|
+
* A multi-row upsert cannot spell the new value as a literal — each row has its
|
|
5430
|
+
* own — so the assignment has to name the row being inserted:
|
|
5431
|
+
* `EXCLUDED."total"` on PostgreSQL and SQLite, `VALUES(total)` on MySQL.
|
|
5432
|
+
*
|
|
5433
|
+
* @param column The column's property name.
|
|
5434
|
+
* @returns The expression, for use as a write value.
|
|
5435
|
+
*/
|
|
5436
|
+
excluded: (column2) => expression({ excluded: column2 }),
|
|
2836
5437
|
/**
|
|
2837
5438
|
* Escape hatch: a verbatim SQL expression rendered as-is, with no parameters.
|
|
2838
5439
|
*
|
|
@@ -2882,13 +5483,14 @@ function parseReference(ref, options) {
|
|
|
2882
5483
|
};
|
|
2883
5484
|
}
|
|
2884
5485
|
var Column = class _Column {
|
|
2885
|
-
constructor(type, flags, defaultValue = null, onUpdateValue = null, reference = null, dbName = null) {
|
|
5486
|
+
constructor(type, flags, defaultValue = null, onUpdateValue = null, reference = null, dbName = null, codec = null) {
|
|
2886
5487
|
this.type = type;
|
|
2887
5488
|
this.flags = flags;
|
|
2888
5489
|
this.defaultValue = defaultValue;
|
|
2889
5490
|
this.onUpdateValue = onUpdateValue;
|
|
2890
5491
|
this.reference = reference;
|
|
2891
5492
|
this.dbName = dbName;
|
|
5493
|
+
this.codec = codec;
|
|
2892
5494
|
}
|
|
2893
5495
|
type;
|
|
2894
5496
|
flags;
|
|
@@ -2896,6 +5498,7 @@ var Column = class _Column {
|
|
|
2896
5498
|
onUpdateValue;
|
|
2897
5499
|
reference;
|
|
2898
5500
|
dbName;
|
|
5501
|
+
codec;
|
|
2899
5502
|
/** Clone this column with one facet replaced, carrying every other over. */
|
|
2900
5503
|
derive(patch) {
|
|
2901
5504
|
return new _Column(
|
|
@@ -2904,7 +5507,8 @@ var Column = class _Column {
|
|
|
2904
5507
|
patch.defaultValue !== void 0 ? patch.defaultValue : this.defaultValue,
|
|
2905
5508
|
patch.onUpdateValue !== void 0 ? patch.onUpdateValue : this.onUpdateValue,
|
|
2906
5509
|
patch.reference !== void 0 ? patch.reference : this.reference,
|
|
2907
|
-
patch.dbName !== void 0 ? patch.dbName : this.dbName
|
|
5510
|
+
patch.dbName !== void 0 ? patch.dbName : this.dbName,
|
|
5511
|
+
patch.codec !== void 0 ? patch.codec : this.codec
|
|
2908
5512
|
);
|
|
2909
5513
|
}
|
|
2910
5514
|
primaryKey() {
|
|
@@ -3000,6 +5604,40 @@ var Column = class _Column {
|
|
|
3000
5604
|
return this.derive({ onUpdateValue: resolved });
|
|
3001
5605
|
}
|
|
3002
5606
|
};
|
|
5607
|
+
function customType(spec) {
|
|
5608
|
+
const codec = {
|
|
5609
|
+
toDb: (value) => value === null || value === void 0 ? value : spec.toDb(value),
|
|
5610
|
+
fromDb: (value) => value === null || value === void 0 ? value : spec.fromDb(value)
|
|
5611
|
+
};
|
|
5612
|
+
return () => {
|
|
5613
|
+
const base = spec.base();
|
|
5614
|
+
return new Column(
|
|
5615
|
+
base.type,
|
|
5616
|
+
base.flags,
|
|
5617
|
+
base.defaultValue,
|
|
5618
|
+
base.onUpdateValue,
|
|
5619
|
+
base.reference,
|
|
5620
|
+
base.dbName,
|
|
5621
|
+
codec
|
|
5622
|
+
);
|
|
5623
|
+
};
|
|
5624
|
+
}
|
|
5625
|
+
var codecCache = /* @__PURE__ */ new WeakMap();
|
|
5626
|
+
function codecsOf(model) {
|
|
5627
|
+
const cached = codecCache.get(model);
|
|
5628
|
+
if (cached !== void 0) return cached;
|
|
5629
|
+
const codecs = {};
|
|
5630
|
+
let found = false;
|
|
5631
|
+
for (const [prop, col2] of Object.entries(columnsOf(model))) {
|
|
5632
|
+
if (col2.codec) {
|
|
5633
|
+
codecs[prop] = col2.codec;
|
|
5634
|
+
found = true;
|
|
5635
|
+
}
|
|
5636
|
+
}
|
|
5637
|
+
const result = found ? codecs : null;
|
|
5638
|
+
codecCache.set(model, result);
|
|
5639
|
+
return result;
|
|
5640
|
+
}
|
|
3003
5641
|
function makeColumn(kind, meta = {}) {
|
|
3004
5642
|
return new Column({ kind, meta }, DEFAULT_FLAGS);
|
|
3005
5643
|
}
|
|
@@ -3077,6 +5715,62 @@ function unique(...columns) {
|
|
|
3077
5715
|
}
|
|
3078
5716
|
return { kind: "unique", columns };
|
|
3079
5717
|
}
|
|
5718
|
+
function check(expression2, options) {
|
|
5719
|
+
const node = toCondNode(expression2);
|
|
5720
|
+
return {
|
|
5721
|
+
kind: "check",
|
|
5722
|
+
name: options?.name,
|
|
5723
|
+
expression: node,
|
|
5724
|
+
columns: options?.columns ?? conditionColumns(node)
|
|
5725
|
+
};
|
|
5726
|
+
}
|
|
5727
|
+
function index(columns, options) {
|
|
5728
|
+
if (columns.length === 0) {
|
|
5729
|
+
throw new Error("index() requires at least one column.");
|
|
5730
|
+
}
|
|
5731
|
+
return {
|
|
5732
|
+
kind: "index",
|
|
5733
|
+
name: options?.name,
|
|
5734
|
+
columns: [...columns],
|
|
5735
|
+
unique: options?.unique,
|
|
5736
|
+
where: options?.where === void 0 ? void 0 : toCondNode(options.where)
|
|
5737
|
+
};
|
|
5738
|
+
}
|
|
5739
|
+
function conditionColumns(node) {
|
|
5740
|
+
const found = [];
|
|
5741
|
+
const visitExpr = (expr) => {
|
|
5742
|
+
if (expr.kind === "column") {
|
|
5743
|
+
if (!found.includes(expr.name)) found.push(expr.name);
|
|
5744
|
+
return;
|
|
5745
|
+
}
|
|
5746
|
+
if (expr.kind === "fn") for (const arg of expr.args) visitExpr(arg);
|
|
5747
|
+
if (expr.kind === "cast") visitExpr(expr.operand);
|
|
5748
|
+
};
|
|
5749
|
+
const visit = (current) => {
|
|
5750
|
+
switch (current.kind) {
|
|
5751
|
+
case "fields":
|
|
5752
|
+
for (const key of Object.keys(current.fields)) {
|
|
5753
|
+
if (!found.includes(key)) found.push(key);
|
|
5754
|
+
}
|
|
5755
|
+
return;
|
|
5756
|
+
case "and":
|
|
5757
|
+
case "or":
|
|
5758
|
+
for (const part of current.parts) visit(part);
|
|
5759
|
+
return;
|
|
5760
|
+
case "not":
|
|
5761
|
+
visit(current.part);
|
|
5762
|
+
return;
|
|
5763
|
+
case "compare":
|
|
5764
|
+
visitExpr(current.left);
|
|
5765
|
+
visitExpr(current.right);
|
|
5766
|
+
return;
|
|
5767
|
+
default:
|
|
5768
|
+
return;
|
|
5769
|
+
}
|
|
5770
|
+
};
|
|
5771
|
+
visit(node);
|
|
5772
|
+
return found;
|
|
5773
|
+
}
|
|
3080
5774
|
function foreignKey(columns, refTable, refColumns, options) {
|
|
3081
5775
|
if (columns.length === 0 || columns.length !== refColumns.length) {
|
|
3082
5776
|
throw new Error(
|
|
@@ -3166,7 +5860,38 @@ function columnPropsOf(model) {
|
|
|
3166
5860
|
function dbColumn(names, prop) {
|
|
3167
5861
|
return names?.[prop] ?? prop;
|
|
3168
5862
|
}
|
|
5863
|
+
function primaryKeysOf(model) {
|
|
5864
|
+
const keys = [];
|
|
5865
|
+
for (const [name, col2] of Object.entries(columnsOf(model))) {
|
|
5866
|
+
if (col2.flags.primaryKey) keys.push(name);
|
|
5867
|
+
}
|
|
5868
|
+
if (keys.length === 0) throw new Error(`${model.tablename} has no primary key`);
|
|
5869
|
+
return keys;
|
|
5870
|
+
}
|
|
5871
|
+
function primaryKeyFilter(model, id) {
|
|
5872
|
+
const keys = primaryKeysOf(model);
|
|
5873
|
+
const asObject = typeof id === "object" && id !== null && !Array.isArray(id) && !(id instanceof Date) ? id : null;
|
|
5874
|
+
const first = keys[0];
|
|
5875
|
+
if (keys.length === 1) {
|
|
5876
|
+
return { [first]: asObject && first in asObject ? asObject[first] : id };
|
|
5877
|
+
}
|
|
5878
|
+
if (asObject === null) {
|
|
5879
|
+
throw new Error(
|
|
5880
|
+
`${model.tablename} has a composite primary key (${keys.join(", ")}); pass an object like { ${keys.join(", ")} } instead of a scalar.`
|
|
5881
|
+
);
|
|
5882
|
+
}
|
|
5883
|
+
const filter = {};
|
|
5884
|
+
for (const key of keys) {
|
|
5885
|
+
if (!(key in asObject)) {
|
|
5886
|
+
throw new Error(
|
|
5887
|
+
`${model.tablename} has a composite primary key (${keys.join(", ")}); the key is missing ${JSON.stringify(key)}.`
|
|
5888
|
+
);
|
|
5889
|
+
}
|
|
5890
|
+
filter[key] = asObject[key];
|
|
5891
|
+
}
|
|
5892
|
+
return filter;
|
|
5893
|
+
}
|
|
3169
5894
|
|
|
3170
|
-
export { ActiveRecord, Agg, AsyncEngine, AsyncResult, AsyncSession, BaseDialect, BaseRepository, BetterSqliteDriver, Column, DeleteBuilder, Expression, InsertBuilder, InvalidDatabaseUrl, JoinBuilder, Model, MysqlDialect, NoResultError, NodeSqliteDriver, OPERATORS, Params, PostgresDialect, QueryExecutionError, RecordNotFound, SelectBuilder, SqliteDialect, SyncEngine, SyncResult, SyncSession, UpdateBuilder, ValidationError, activeRecord, and, avg, belongsTo, col, column, columnNamesOf, columnPropsOf, columnsOf, count, createEngine, createSyncEngine, dbColumn, del, detectDialect, fn, foreignKey, fromDict, getDialect, hasMany, insert, isCondition, isExpression, isSqlExpression, isSubquery, join, loadRelations, max, min, not, or, parse, parseDatabaseUrl, renderPortableToken, select, sql, stringify, sum, toAsyncDriver, toCondNode, toDict, toJSON, toSnakeCase, unique, update, val };
|
|
3171
|
-
//# sourceMappingURL=chunk-
|
|
3172
|
-
//# sourceMappingURL=chunk-
|
|
5895
|
+
export { ActiveRecord, Agg, AsyncEngine, AsyncResult, AsyncSession, BackupToolMissing, BaseDialect, BaseRepository, BetterSqliteDriver, Column, Cte, DeleteBuilder, Expression, InsertBuilder, InvalidCursor, InvalidDatabaseUrl, JoinBuilder, LiteralParams, Model, MysqlDialect, NoResultError, NodeSqliteDriver, OPERATORS, OutboxRepository, Params, PostgresDialect, QueryExecutionError, RecordNotFound, SelectBuilder, SetBuilder, SqliteDialect, SyncEngine, SyncResult, SyncSession, TenantScopedRepository, UnitOfWork, UnsupportedBackupBackend, UpdateBuilder, ValidationError, activeRecord, aliasOf, aliased, and, attach, auditLogModel, avg, backupDatabase, backupFormat, belongsTo, caseWhen, cast, check, clearSignals, codecsOf, col, column, columnNamesOf, columnPropsOf, columnsOf, contains, count, createEngine, createSyncEngine, cte, cteRecursive, customType, dbColumn, decodeValue, defaultAsWriteValue, del, denseRank, detectDialect, diffSnapshots, emitSignal, enableAudit, encodeValue, escapeLike, except, exists, firstValue, fn, foreignKey, fromDict, fullText, fullTextRank, getDialect, hasHandlers, hasMany, index, insert, intersect, isCondition, isExpression, isReadOnlyStatement, isSqlExpression, isSubquery, join, lag, lastValue, lead, loadRelations, max, min, not, notDeleted, notExists, onSignal, onlyDeleted, or, outboxModel, over, parse, parseDatabaseUrl, parseIntegrityError, percentRank, primaryKeyFilter, primaryKeysOf, rank, renderPortableToken, restoreDatabase, rowNumber, scalar, select, snapshot, sql, stringify, sum, summarizePlan, toAsyncDriver, toCondNode, toDict, toJSON, toSnakeCase, tokenize, toolUrl, union, unionAll, unique, update, val, withAudit, withSoftDelete, withTimestamps };
|
|
5896
|
+
//# sourceMappingURL=chunk-HXK6WIBP.js.map
|
|
5897
|
+
//# sourceMappingURL=chunk-HXK6WIBP.js.map
|