turbine-orm 0.22.0 → 0.23.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/dist/cjs/generate.js +4 -0
- package/dist/cjs/introspect.js +6 -0
- package/dist/cjs/powdb.js +16 -2
- package/dist/cjs/powql.js +402 -43
- package/dist/generate.js +4 -0
- package/dist/introspect.js +6 -0
- package/dist/powdb.d.ts +4 -1
- package/dist/powdb.js +16 -2
- package/dist/powql.d.ts +61 -9
- package/dist/powql.js +369 -43
- package/dist/schema.d.ts +10 -0
- package/package.json +1 -1
package/dist/cjs/powql.js
CHANGED
|
@@ -16,19 +16,63 @@
|
|
|
16
16
|
* - `create`/`createMany`/`update`/`delete` use PowDB 0.7.0's trailing
|
|
17
17
|
* `returning` keyword (`RETURNING *`, all columns) to surface affected rows
|
|
18
18
|
* in one round-trip. `upsert` is the lone exception — its statement does not
|
|
19
|
-
* accept `returning`, so it reselects the row by PK
|
|
20
|
-
*
|
|
21
|
-
* -
|
|
22
|
-
*
|
|
19
|
+
* accept `returning`, so it reselects the row by PK (a composite-PK upsert
|
|
20
|
+
* reselects-or-writes inside one flat transaction).
|
|
21
|
+
* - The PK is server-assigned when the column is `isGenerated` (PowDB's `auto`
|
|
22
|
+
* int — read back via `returning`); otherwise a defaulted **string** PK is
|
|
23
|
+
* generated client-side (UUID).
|
|
24
|
+
* - `with` (nested relations) uses **batched N+1 loaders** — D round-trips for
|
|
25
|
+
* depth D, not one query — including manyToMany (junction → targets).
|
|
26
|
+
* - **Relation filters** (`some`/`none`/`every`, all cardinalities incl. m2m)
|
|
27
|
+
* are resolved client-side to a literal `in (…)` list, never an IN-subquery:
|
|
28
|
+
* PowDB's executor caches a subquery's result by plan shape and would return
|
|
29
|
+
* a stale prior result for a later subquery of the same shape.
|
|
30
|
+
* - **Nested writes** (relation ops in `create`/`update` data) run through the
|
|
31
|
+
* shared nested-write engine as one flat top-level transaction (PowDB is
|
|
32
|
+
* single-writer, no savepoints).
|
|
23
33
|
* - pgvector / JSON / array filters and cursor streaming throw
|
|
24
34
|
* {@link UnsupportedFeatureError} (E017) — they have no PowDB equivalent.
|
|
25
35
|
*
|
|
26
36
|
* @module
|
|
27
37
|
*/
|
|
38
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
39
|
+
if (k2 === undefined) k2 = k;
|
|
40
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
41
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
42
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
43
|
+
}
|
|
44
|
+
Object.defineProperty(o, k2, desc);
|
|
45
|
+
}) : (function(o, m, k, k2) {
|
|
46
|
+
if (k2 === undefined) k2 = k;
|
|
47
|
+
o[k2] = m[k];
|
|
48
|
+
}));
|
|
49
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
50
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
51
|
+
}) : function(o, v) {
|
|
52
|
+
o["default"] = v;
|
|
53
|
+
});
|
|
54
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
55
|
+
var ownKeys = function(o) {
|
|
56
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
57
|
+
var ar = [];
|
|
58
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
59
|
+
return ar;
|
|
60
|
+
};
|
|
61
|
+
return ownKeys(o);
|
|
62
|
+
};
|
|
63
|
+
return function (mod) {
|
|
64
|
+
if (mod && mod.__esModule) return mod;
|
|
65
|
+
var result = {};
|
|
66
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
67
|
+
__setModuleDefault(result, mod);
|
|
68
|
+
return result;
|
|
69
|
+
};
|
|
70
|
+
})();
|
|
28
71
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
72
|
exports.PowqlInterface = void 0;
|
|
30
73
|
const node_crypto_1 = require("node:crypto");
|
|
31
74
|
const errors_js_1 = require("./errors.js");
|
|
75
|
+
const nested_write_js_1 = require("./nested-write.js");
|
|
32
76
|
const powdb_js_1 = require("./powdb.js");
|
|
33
77
|
const utils_js_1 = require("./query/utils.js");
|
|
34
78
|
const schema_js_1 = require("./schema.js");
|
|
@@ -191,7 +235,10 @@ class PowqlInterface {
|
|
|
191
235
|
parts.push(`not (${sub})`);
|
|
192
236
|
}
|
|
193
237
|
else if (this.meta.relations[key]) {
|
|
194
|
-
|
|
238
|
+
// Relation filters are pre-resolved to scalar in/notIn by
|
|
239
|
+
// resolveRelationFilters() before buildWhere runs — reaching here means
|
|
240
|
+
// a caller skipped that step (an internal bug, not user error).
|
|
241
|
+
throw new errors_js_1.ValidationError(`[turbine] internal: relation filter "${key}" reached buildWhere unresolved (missing resolveRelationFilters()).`);
|
|
195
242
|
}
|
|
196
243
|
else {
|
|
197
244
|
parts.push(this.buildFieldCondition(key, value, params));
|
|
@@ -279,41 +326,147 @@ class PowqlInterface {
|
|
|
279
326
|
return `${lhs} ${negate ? 'not in' : 'in'} (${items})`;
|
|
280
327
|
}
|
|
281
328
|
/**
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
329
|
+
* Pre-resolve every relation filter (`some`/`none`/`every`) in a where clause
|
|
330
|
+
* into a plain scalar `in`/`notIn` condition on the **local key**, by running
|
|
331
|
+
* the inner predicate as its own query and materializing the matching keys as
|
|
332
|
+
* a literal list. The compiled where is then relation-free and `buildWhere`
|
|
333
|
+
* emits only `in (<literal list>)`.
|
|
334
|
+
*
|
|
335
|
+
* Why not an IN-subquery (`.k in (Target filter <e> { .fk })`)? PowDB's
|
|
336
|
+
* executor caches a subquery's result by **plan shape, ignoring the literal**,
|
|
337
|
+
* so a second subquery of the same shape with a different value returns the
|
|
338
|
+
* first one's stale rows (reproduced live on the embedded engine; the
|
|
339
|
+
* single-statement literal `in (list)` form is always correct). Resolving
|
|
340
|
+
* client-side trades extra round-trips for correctness, and recurses — nested
|
|
341
|
+
* relation filters in the inner predicate resolve when the target query runs.
|
|
285
342
|
*/
|
|
286
|
-
|
|
343
|
+
async resolveRelationFilters(where, timeout) {
|
|
344
|
+
if (!where)
|
|
345
|
+
return where;
|
|
346
|
+
const scalar = {};
|
|
347
|
+
const relConds = [];
|
|
348
|
+
for (const [key, value] of Object.entries(where)) {
|
|
349
|
+
if (value === undefined)
|
|
350
|
+
continue;
|
|
351
|
+
if (key === 'AND' || key === 'OR') {
|
|
352
|
+
scalar[key] = await Promise.all(value.map((w) => this.resolveRelationFilters(w, timeout)));
|
|
353
|
+
}
|
|
354
|
+
else if (key === 'NOT') {
|
|
355
|
+
scalar[key] = await this.resolveRelationFilters(value, timeout);
|
|
356
|
+
}
|
|
357
|
+
else if (this.meta.relations[key]) {
|
|
358
|
+
relConds.push(await this.resolveRelationCondition(this.meta.relations[key], value, timeout));
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
scalar[key] = value;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (!relConds.length)
|
|
365
|
+
return scalar;
|
|
366
|
+
const hasScalar = Object.keys(scalar).length > 0;
|
|
367
|
+
return { AND: [...(hasScalar ? [scalar] : []), ...relConds] };
|
|
368
|
+
}
|
|
369
|
+
/** Resolve one hasMany/hasOne/belongsTo filter to `{ localField: { in|notIn: [...] } }`. */
|
|
370
|
+
async resolveRelationCondition(rel, filter, timeout) {
|
|
371
|
+
const mode = filter.some ? 'some' : filter.none ? 'none' : filter.every ? 'every' : null;
|
|
372
|
+
if (!mode)
|
|
373
|
+
return {};
|
|
374
|
+
const innerWhere = filter[mode];
|
|
375
|
+
const innerEmpty = !innerWhere || Object.keys(innerWhere).length === 0;
|
|
287
376
|
if (rel.type === 'manyToMany') {
|
|
288
|
-
|
|
377
|
+
return this.resolveManyToManyCondition(rel, mode, innerWhere, innerEmpty, timeout);
|
|
289
378
|
}
|
|
290
379
|
const fk = (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey);
|
|
291
380
|
const rk = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey);
|
|
292
381
|
if (fk.length > 1 || rk.length > 1) {
|
|
293
|
-
throw new errors_js_1.UnsupportedFeatureError('composite-key relation filters', 'PowDB', `relation "${rel.name}" uses a composite key`);
|
|
382
|
+
throw new errors_js_1.UnsupportedFeatureError('composite-key relation filters', 'PowDB', `relation "${rel.name}" uses a composite key — PowQL has no tuple-\`in\` to express it`);
|
|
294
383
|
}
|
|
295
|
-
// hasMany/hasOne: outer.referenceKey ∈ target.foreignKey.
|
|
296
|
-
// belongsTo: outer.foreignKey ∈ target.referenceKey.
|
|
297
|
-
const localKey = rel.type === 'belongsTo' ? fk[0] : rk[0];
|
|
298
|
-
const targetKey = rel.type === 'belongsTo' ? rk[0] : fk[0];
|
|
299
384
|
const targetMeta = this.schema.tables[rel.to];
|
|
300
385
|
if (!targetMeta)
|
|
301
386
|
throw new errors_js_1.ValidationError(`[turbine] Relation "${rel.name}" targets unknown table "${rel.to}".`);
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
387
|
+
// hasMany/hasOne: localKey = our referenceKey, collect the child's foreignKey.
|
|
388
|
+
// belongsTo: localKey = our foreignKey, collect the target's referenceKey.
|
|
389
|
+
const localCol = rel.type === 'belongsTo' ? fk[0] : rk[0];
|
|
390
|
+
const childCol = rel.type === 'belongsTo' ? rk[0] : fk[0];
|
|
391
|
+
const localField = this.meta.reverseColumnMap[localCol] ?? localCol;
|
|
392
|
+
const childField = targetMeta.reverseColumnMap[childCol] ?? childCol;
|
|
393
|
+
const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
|
|
394
|
+
const collect = async (w) => {
|
|
395
|
+
const rows = await targetQi.findMany({
|
|
396
|
+
where: w,
|
|
397
|
+
select: { [childField]: true },
|
|
398
|
+
timeout,
|
|
399
|
+
});
|
|
400
|
+
return [...new Set(rows.map((r) => r[childField]).filter((v) => v != null))];
|
|
401
|
+
};
|
|
402
|
+
if (mode === 'some')
|
|
403
|
+
return { [localField]: { in: await collect(innerWhere) } };
|
|
404
|
+
if (mode === 'none')
|
|
405
|
+
return { [localField]: { notIn: await collect(innerWhere) } };
|
|
406
|
+
// every: a parent qualifies unless it has a child FAILING the predicate.
|
|
407
|
+
if (innerEmpty)
|
|
408
|
+
return {}; // every:{} ⇒ trivially true for all parents
|
|
409
|
+
return { [localField]: { notIn: await collect({ NOT: innerWhere }) } };
|
|
410
|
+
}
|
|
411
|
+
/** Resolve a manyToMany filter through the junction to `{ sourceRefField: { in|notIn: [...] } }`. */
|
|
412
|
+
async resolveManyToManyCondition(rel, mode, innerWhere, innerEmpty, timeout) {
|
|
413
|
+
const through = rel.through;
|
|
414
|
+
if (!through)
|
|
415
|
+
throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${rel.name}" is missing its junction (\`through\`).`);
|
|
416
|
+
const sourceJ = (0, schema_js_1.normalizeKeyColumns)(through.sourceKey);
|
|
417
|
+
const targetJ = (0, schema_js_1.normalizeKeyColumns)(through.targetKey);
|
|
418
|
+
const sourceRef = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey);
|
|
419
|
+
const targetMeta = this.schema.tables[rel.to];
|
|
420
|
+
if (!targetMeta)
|
|
421
|
+
throw new errors_js_1.ValidationError(`[turbine] Relation "${rel.name}" targets unknown table "${rel.to}".`);
|
|
422
|
+
if (sourceJ.length > 1 || targetJ.length > 1 || sourceRef.length > 1 || targetMeta.primaryKey.length > 1) {
|
|
423
|
+
throw new errors_js_1.UnsupportedFeatureError('composite-key manyToMany filters', 'PowDB', `relation "${rel.name}" — PowQL has no tuple-\`in\` for composite junction/target keys`);
|
|
424
|
+
}
|
|
425
|
+
const sourceJCol = sourceJ[0];
|
|
426
|
+
const targetJCol = targetJ[0];
|
|
427
|
+
const sourceRefCol = sourceRef[0];
|
|
428
|
+
const targetPkCol = targetMeta.primaryKey[0];
|
|
429
|
+
const sourceRefField = this.meta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
|
|
430
|
+
const sourceRefColMeta = this.meta.columns.find((c) => c.name === sourceRefCol);
|
|
431
|
+
const targetPkField = targetMeta.reverseColumnMap[targetPkCol] ?? targetPkCol;
|
|
432
|
+
const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
|
|
433
|
+
const collectTargetPks = async (w) => {
|
|
434
|
+
const rows = await targetQi.findMany({
|
|
435
|
+
where: w,
|
|
436
|
+
select: { [targetPkField]: true },
|
|
437
|
+
timeout,
|
|
438
|
+
});
|
|
439
|
+
return [...new Set(rows.map((r) => r[targetPkField]).filter((v) => v != null))];
|
|
440
|
+
};
|
|
441
|
+
// Junction source keys linking any of `targetPks` (literal IN-list — never a subquery).
|
|
442
|
+
const sourcesForTargets = async (targetPks) => {
|
|
443
|
+
if (!targetPks.length)
|
|
444
|
+
return [];
|
|
445
|
+
const out = new Set();
|
|
446
|
+
for (let i = 0; i < targetPks.length; i += MAX_RELATION_KEYS) {
|
|
447
|
+
const chunk = targetPks.slice(i, i + MAX_RELATION_KEYS);
|
|
448
|
+
const params = [];
|
|
449
|
+
const ph = chunk.map((v) => this.param(v, params)).join(', ');
|
|
450
|
+
const { rows } = await this.exec(`${through.table} filter .${targetJCol} in (${ph}) { .${sourceJCol} }`, params, timeout);
|
|
451
|
+
for (const r of rows) {
|
|
452
|
+
const v = r[sourceJCol];
|
|
453
|
+
if (v != null)
|
|
454
|
+
out.add(sourceRefColMeta ? coerceScalar(String(v), sourceRefColMeta.tsType) : v);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return [...out];
|
|
307
458
|
};
|
|
308
|
-
if (
|
|
309
|
-
return
|
|
310
|
-
if (filter.none)
|
|
311
|
-
return `.${localKey} not in (${sub(filter.none, false)})`;
|
|
312
|
-
if (filter.every) {
|
|
313
|
-
// Parents with no child failing the predicate.
|
|
314
|
-
return `.${localKey} not in (${sub(filter.every, true)})`;
|
|
459
|
+
if (mode === 'some') {
|
|
460
|
+
return { [sourceRefField]: { in: await sourcesForTargets(await collectTargetPks(innerWhere)) } };
|
|
315
461
|
}
|
|
316
|
-
|
|
462
|
+
if (mode === 'none') {
|
|
463
|
+
return { [sourceRefField]: { notIn: await sourcesForTargets(await collectTargetPks(innerWhere)) } };
|
|
464
|
+
}
|
|
465
|
+
// every: exclude parents that link a target FAILING the predicate.
|
|
466
|
+
if (innerEmpty)
|
|
467
|
+
return {}; // every:{} ⇒ trivially true
|
|
468
|
+
const failing = await sourcesForTargets(await collectTargetPks({ NOT: innerWhere }));
|
|
469
|
+
return { [sourceRefField]: { notIn: failing } };
|
|
317
470
|
}
|
|
318
471
|
// -------------------------------------------------------------------------
|
|
319
472
|
// Projection / order
|
|
@@ -433,7 +586,8 @@ class PowqlInterface {
|
|
|
433
586
|
throw new errors_js_1.UnsupportedFeatureError('cursor pagination', 'PowDB', 'use limit/offset instead');
|
|
434
587
|
}
|
|
435
588
|
const params = [];
|
|
436
|
-
const
|
|
589
|
+
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
590
|
+
const where = this.buildWhere(resolvedWhere, params);
|
|
437
591
|
const cols = this.projectedColumns(args.select, args.omit);
|
|
438
592
|
const distinct = args.distinct?.length ? ' distinct' : '';
|
|
439
593
|
const filter = where ? ` filter ${where}` : '';
|
|
@@ -500,7 +654,8 @@ class PowqlInterface {
|
|
|
500
654
|
if (!rel)
|
|
501
655
|
throw new errors_js_1.ValidationError(`[turbine] Unknown relation "${relName}" on "${this.table}".`);
|
|
502
656
|
if (rel.type === 'manyToMany') {
|
|
503
|
-
|
|
657
|
+
await this.loadManyToMany(parents, rel, relName, opt, timeout);
|
|
658
|
+
continue;
|
|
504
659
|
}
|
|
505
660
|
const fk = (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey);
|
|
506
661
|
const rk = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey);
|
|
@@ -550,6 +705,95 @@ class PowqlInterface {
|
|
|
550
705
|
}
|
|
551
706
|
}
|
|
552
707
|
}
|
|
708
|
+
/**
|
|
709
|
+
* manyToMany nested read — a three-hop batched loader (no `json_agg`/join
|
|
710
|
+
* pushdown): (1) read the junction rows for all parents in `sourceKey in (…)`
|
|
711
|
+
* chunks, (2) read the target rows for the collected `targetKey`s, (3) stitch
|
|
712
|
+
* each parent → its junction rows → its targets in memory. Mirrors the
|
|
713
|
+
* single-key N+1 loaders; the junction's source/target columns must be single
|
|
714
|
+
* (composite junction keys would need PowQL tuple-`in`, which it lacks).
|
|
715
|
+
*/
|
|
716
|
+
async loadManyToMany(parents, rel, relName, opt, timeout) {
|
|
717
|
+
const through = rel.through;
|
|
718
|
+
if (!through)
|
|
719
|
+
throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relName}" is missing its junction (\`through\`).`);
|
|
720
|
+
const sourceJ = (0, schema_js_1.normalizeKeyColumns)(through.sourceKey);
|
|
721
|
+
const targetJ = (0, schema_js_1.normalizeKeyColumns)(through.targetKey);
|
|
722
|
+
const sourceRef = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey);
|
|
723
|
+
const targetMeta = this.schema.tables[rel.to];
|
|
724
|
+
if (!targetMeta)
|
|
725
|
+
throw new errors_js_1.ValidationError(`[turbine] Relation "${relName}" targets unknown table "${rel.to}".`);
|
|
726
|
+
if (sourceJ.length > 1 || targetJ.length > 1 || sourceRef.length > 1 || targetMeta.primaryKey.length > 1) {
|
|
727
|
+
throw new errors_js_1.UnsupportedFeatureError('composite-key manyToMany', 'PowDB', `relation "${relName}" — PowQL has no tuple-\`in\`, so composite junction/target keys can't be loaded`);
|
|
728
|
+
}
|
|
729
|
+
const sourceJCol = sourceJ[0];
|
|
730
|
+
const targetJCol = targetJ[0];
|
|
731
|
+
const sourceRefCol = sourceRef[0];
|
|
732
|
+
const targetPkCol = targetMeta.primaryKey[0];
|
|
733
|
+
const parentRefField = this.meta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
|
|
734
|
+
const targetPkField = targetMeta.reverseColumnMap[targetPkCol] ?? targetPkCol;
|
|
735
|
+
const targetPkColMeta = targetMeta.columns.find((c) => c.name === targetPkCol);
|
|
736
|
+
const parentKeys = [
|
|
737
|
+
...new Set(parents.map((p) => p[parentRefField]).filter((k) => k != null)),
|
|
738
|
+
];
|
|
739
|
+
if (!parentKeys.length) {
|
|
740
|
+
for (const parent of parents)
|
|
741
|
+
parent[relName] = [];
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
// (1) Junction rows: sourceKeyVal(String) → [targetKeyVal(String)].
|
|
745
|
+
const targetsBySource = new Map();
|
|
746
|
+
const allTargetVals = new Set();
|
|
747
|
+
for (let i = 0; i < parentKeys.length; i += MAX_RELATION_KEYS) {
|
|
748
|
+
const chunk = parentKeys.slice(i, i + MAX_RELATION_KEYS);
|
|
749
|
+
const params = [];
|
|
750
|
+
const placeholders = chunk.map((v) => this.param(v, params)).join(', ');
|
|
751
|
+
const powql = `${through.table} filter .${sourceJCol} in (${placeholders}) { .${sourceJCol}, .${targetJCol} }`;
|
|
752
|
+
const { rows } = await this.exec(powql, params, timeout);
|
|
753
|
+
for (const row of rows) {
|
|
754
|
+
const sv = String(row[sourceJCol]);
|
|
755
|
+
const tv = String(row[targetJCol]);
|
|
756
|
+
const bucket = targetsBySource.get(sv);
|
|
757
|
+
if (bucket)
|
|
758
|
+
bucket.push(tv);
|
|
759
|
+
else
|
|
760
|
+
targetsBySource.set(sv, [tv]);
|
|
761
|
+
allTargetVals.add(tv);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
// (2) Target rows by PK, honouring the relation's own where/with/select/…
|
|
765
|
+
const options = (opt === true ? {} : opt);
|
|
766
|
+
const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
|
|
767
|
+
const targetByPk = new Map();
|
|
768
|
+
const targetValList = [...allTargetVals].map((v) => targetPkColMeta ? coerceScalar(v, targetPkColMeta.tsType) : v);
|
|
769
|
+
for (let i = 0; i < targetValList.length; i += MAX_RELATION_KEYS) {
|
|
770
|
+
const chunk = targetValList.slice(i, i + MAX_RELATION_KEYS);
|
|
771
|
+
const where = {
|
|
772
|
+
...options.where,
|
|
773
|
+
[targetPkField]: { in: chunk },
|
|
774
|
+
};
|
|
775
|
+
const targets = (await targetQi.findMany({
|
|
776
|
+
...options,
|
|
777
|
+
where,
|
|
778
|
+
with: options.with,
|
|
779
|
+
timeout: options.timeout ?? timeout,
|
|
780
|
+
}));
|
|
781
|
+
for (const t of targets)
|
|
782
|
+
targetByPk.set(String(t[targetPkField]), t);
|
|
783
|
+
}
|
|
784
|
+
// (3) Stitch: each parent → its junction targets (m2m is always a list).
|
|
785
|
+
for (const parent of parents) {
|
|
786
|
+
const sv = String(parent[parentRefField]);
|
|
787
|
+
const tvs = targetsBySource.get(sv) ?? [];
|
|
788
|
+
const children = [];
|
|
789
|
+
for (const tv of tvs) {
|
|
790
|
+
const child = targetByPk.get(tv);
|
|
791
|
+
if (child)
|
|
792
|
+
children.push(child);
|
|
793
|
+
}
|
|
794
|
+
parent[relName] = children;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
553
797
|
// -------------------------------------------------------------------------
|
|
554
798
|
// Writes (reselect — PowDB has no RETURNING)
|
|
555
799
|
// -------------------------------------------------------------------------
|
|
@@ -560,19 +804,27 @@ class PowqlInterface {
|
|
|
560
804
|
if (value === undefined)
|
|
561
805
|
continue;
|
|
562
806
|
if (this.meta.relations[field]) {
|
|
563
|
-
throw new errors_js_1.UnsupportedFeatureError('nested writes', 'PowDB', `relation "${field}"
|
|
807
|
+
throw new errors_js_1.UnsupportedFeatureError('nested writes', 'PowDB', `relation "${field}" — nested writes need create()/update(), not createMany()/upsert()`);
|
|
564
808
|
}
|
|
565
809
|
out.push({ col: this.column(field), value });
|
|
566
810
|
}
|
|
567
811
|
return out;
|
|
568
812
|
}
|
|
569
|
-
/**
|
|
813
|
+
/**
|
|
814
|
+
* Fill in a client-generated UUID for a defaulted **string** PK that wasn't
|
|
815
|
+
* supplied. A server-generated PK ({@link ColumnMetadata.isGenerated}, e.g. an
|
|
816
|
+
* `int` column with PowDB's `auto` modifier) is left untouched — PowDB assigns
|
|
817
|
+
* it and the trailing `returning` reads it back — as is any non-string PK.
|
|
818
|
+
*/
|
|
570
819
|
applyPkDefault(data) {
|
|
571
820
|
const out = { ...data };
|
|
572
821
|
for (const pk of this.meta.primaryKey) {
|
|
573
822
|
const field = this.meta.reverseColumnMap[pk] ?? pk;
|
|
574
823
|
const col = this.meta.columns.find((c) => c.name === pk);
|
|
575
|
-
if (out[field] == null &&
|
|
824
|
+
if (out[field] == null &&
|
|
825
|
+
col?.hasDefault &&
|
|
826
|
+
!col.isGenerated &&
|
|
827
|
+
col.tsType.replace(/\s*\|\s*null$/, '').trim() === 'string') {
|
|
576
828
|
out[field] = (0, node_crypto_1.randomUUID)();
|
|
577
829
|
}
|
|
578
830
|
}
|
|
@@ -580,6 +832,9 @@ class PowqlInterface {
|
|
|
580
832
|
}
|
|
581
833
|
async create(args) {
|
|
582
834
|
return this.withMiddleware('create', args, async () => {
|
|
835
|
+
if ((0, nested_write_js_1.hasRelationFields)(args.data, this.meta)) {
|
|
836
|
+
return this.nestedCreate(args);
|
|
837
|
+
}
|
|
583
838
|
const data = this.applyPkDefault(args.data);
|
|
584
839
|
const assigns = this.scalarData(data);
|
|
585
840
|
const params = [];
|
|
@@ -609,8 +864,12 @@ class PowqlInterface {
|
|
|
609
864
|
}
|
|
610
865
|
async update(args) {
|
|
611
866
|
return this.withMiddleware('update', args, async () => {
|
|
867
|
+
if ((0, nested_write_js_1.hasRelationFields)(args.data, this.meta)) {
|
|
868
|
+
return this.nestedUpdate(args);
|
|
869
|
+
}
|
|
612
870
|
const params = [];
|
|
613
|
-
const
|
|
871
|
+
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
872
|
+
const where = this.buildWhere(resolvedWhere, params);
|
|
614
873
|
this.assertCompiledWhere(where, false, 'update');
|
|
615
874
|
const setClause = this.buildUpdateAssignments(args.data, params);
|
|
616
875
|
// `returning` hands back the post-update row(s); take the first (single-row contract).
|
|
@@ -624,7 +883,8 @@ class PowqlInterface {
|
|
|
624
883
|
async updateMany(args) {
|
|
625
884
|
return this.withMiddleware('updateMany', args, async () => {
|
|
626
885
|
const params = [];
|
|
627
|
-
const
|
|
886
|
+
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
887
|
+
const where = this.buildWhere(resolvedWhere, params);
|
|
628
888
|
this.assertCompiledWhere(where, args.allowFullTableScan, 'updateMany');
|
|
629
889
|
const setClause = this.buildUpdateAssignments(args.data, params);
|
|
630
890
|
const filter = where ? ` filter ${where}` : '';
|
|
@@ -639,7 +899,7 @@ class PowqlInterface {
|
|
|
639
899
|
if (value === undefined)
|
|
640
900
|
continue;
|
|
641
901
|
if (this.meta.relations[field]) {
|
|
642
|
-
throw new errors_js_1.UnsupportedFeatureError('nested writes', 'PowDB', `relation "${field}" —
|
|
902
|
+
throw new errors_js_1.UnsupportedFeatureError('nested writes', 'PowDB', `relation "${field}" — nested writes need create()/update(), not updateMany()/upsert()`);
|
|
643
903
|
}
|
|
644
904
|
const colMeta = this.column(field);
|
|
645
905
|
const ref = this.ref(field);
|
|
@@ -667,10 +927,75 @@ class PowqlInterface {
|
|
|
667
927
|
throw new errors_js_1.ValidationError(`[turbine] update on "${this.table}" has no fields to set.`);
|
|
668
928
|
return parts.join(', ');
|
|
669
929
|
}
|
|
930
|
+
// -------------------------------------------------------------------------
|
|
931
|
+
// Nested writes — create/update whose `data` carries relation ops (create,
|
|
932
|
+
// connect, connectOrCreate, disconnect, set, delete, update, upsert). Reuses
|
|
933
|
+
// the engine-agnostic nested-write engine (it only needs ctx.schema + the
|
|
934
|
+
// table accessors PowqlInterface already provides). Runs as ONE flat top-level
|
|
935
|
+
// PowDB transaction — single global write lock, no savepoints, so the whole
|
|
936
|
+
// tree commits or rolls back together (mirrors the SQL path's coverage:
|
|
937
|
+
// hasMany / hasOne / belongsTo; manyToMany nested writes are not handled by
|
|
938
|
+
// the shared engine on any backend).
|
|
939
|
+
// -------------------------------------------------------------------------
|
|
940
|
+
isTxScoped() {
|
|
941
|
+
return this.options._txScoped === true;
|
|
942
|
+
}
|
|
943
|
+
async nestedCreate(args) {
|
|
944
|
+
const data = args.data;
|
|
945
|
+
if (this.isTxScoped()) {
|
|
946
|
+
return (0, nested_write_js_1.executeNestedCreate)(this.buildNestedCtx(), this.table, data);
|
|
947
|
+
}
|
|
948
|
+
return this.runInImplicitTx((ctx) => (0, nested_write_js_1.executeNestedCreate)(ctx, this.table, data));
|
|
949
|
+
}
|
|
950
|
+
async nestedUpdate(args) {
|
|
951
|
+
const data = args.data;
|
|
952
|
+
const where = args.where;
|
|
953
|
+
if (this.isTxScoped()) {
|
|
954
|
+
return (0, nested_write_js_1.executeNestedUpdate)(this.buildNestedCtx(), this.table, where, data);
|
|
955
|
+
}
|
|
956
|
+
return this.runInImplicitTx((ctx) => (0, nested_write_js_1.executeNestedUpdate)(ctx, this.table, where, data));
|
|
957
|
+
}
|
|
958
|
+
/** Open a flat PowDB transaction on a pinned connection and run `fn` inside it. */
|
|
959
|
+
async runInImplicitTx(fn) {
|
|
960
|
+
// Route tx keywords through the dialect (like the SQL path) so this never
|
|
961
|
+
// drifts from `powdbDialect`; falls back to the literal lowercase keywords.
|
|
962
|
+
const d = this.options.dialect;
|
|
963
|
+
const client = await this.pool.connect();
|
|
964
|
+
try {
|
|
965
|
+
await client.query(d?.beginStatement?.() ?? 'begin');
|
|
966
|
+
const { TransactionClient } = await Promise.resolve().then(() => __importStar(require('./client.js')));
|
|
967
|
+
const tx = new TransactionClient(client, this.schema, this.middlewares, this.options);
|
|
968
|
+
const ctx = { schema: this.schema, tx: tx };
|
|
969
|
+
const result = await fn(ctx);
|
|
970
|
+
await client.query(d?.commitStatement?.() ?? 'commit');
|
|
971
|
+
return result;
|
|
972
|
+
}
|
|
973
|
+
catch (err) {
|
|
974
|
+
try {
|
|
975
|
+
await client.query(d?.rollbackStatement?.() ?? 'rollback');
|
|
976
|
+
}
|
|
977
|
+
catch {
|
|
978
|
+
/* best-effort — the connection may be gone */
|
|
979
|
+
}
|
|
980
|
+
throw err;
|
|
981
|
+
}
|
|
982
|
+
finally {
|
|
983
|
+
client.release();
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
/** Already inside a transaction: build a context whose table accessors reuse the pinned pool. */
|
|
987
|
+
buildNestedCtx() {
|
|
988
|
+
const opts = { ...this.options, _txScoped: true };
|
|
989
|
+
const tx = {
|
|
990
|
+
table: (name) => new PowqlInterface(this.pool, name, this.schema, this.middlewares, opts),
|
|
991
|
+
};
|
|
992
|
+
return { schema: this.schema, tx: tx };
|
|
993
|
+
}
|
|
670
994
|
async delete(args) {
|
|
671
995
|
return this.withMiddleware('delete', args, async () => {
|
|
672
996
|
const params = [];
|
|
673
|
-
const
|
|
997
|
+
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
998
|
+
const where = this.buildWhere(resolvedWhere, params);
|
|
674
999
|
this.assertCompiledWhere(where, false, 'delete');
|
|
675
1000
|
// `returning` hands back the deleted row(s) — no separate pre-image reselect needed.
|
|
676
1001
|
const { rows } = await this.exec(`${this.table} filter ${where} delete returning`, params, args.timeout);
|
|
@@ -683,7 +1008,8 @@ class PowqlInterface {
|
|
|
683
1008
|
async deleteMany(args) {
|
|
684
1009
|
return this.withMiddleware('deleteMany', args, async () => {
|
|
685
1010
|
const params = [];
|
|
686
|
-
const
|
|
1011
|
+
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
1012
|
+
const where = this.buildWhere(resolvedWhere, params);
|
|
687
1013
|
this.assertCompiledWhere(where, args.allowFullTableScan, 'deleteMany');
|
|
688
1014
|
const filter = where ? ` filter ${where}` : '';
|
|
689
1015
|
const { rowCount } = await this.exec(`${this.table}${filter} delete`, params, args.timeout);
|
|
@@ -694,8 +1020,10 @@ class PowqlInterface {
|
|
|
694
1020
|
return this.withMiddleware('upsert', args, async () => {
|
|
695
1021
|
const createData = this.applyPkDefault(args.create);
|
|
696
1022
|
const pkCol = this.meta.primaryKey[0];
|
|
697
|
-
if (
|
|
698
|
-
|
|
1023
|
+
if (this.meta.primaryKey.length !== 1 || !pkCol) {
|
|
1024
|
+
// PowQL's native `upsert … on .col` takes a single conflict column, so a
|
|
1025
|
+
// composite PK falls back to an atomic reselect-or-write transaction.
|
|
1026
|
+
return this.upsertComposite(createData, args.update);
|
|
699
1027
|
}
|
|
700
1028
|
const params = [];
|
|
701
1029
|
const createBody = this.scalarData(createData)
|
|
@@ -714,13 +1042,42 @@ class PowqlInterface {
|
|
|
714
1042
|
return row;
|
|
715
1043
|
});
|
|
716
1044
|
}
|
|
1045
|
+
/**
|
|
1046
|
+
* Composite-key upsert: PowQL's `upsert … on .col` only takes one conflict
|
|
1047
|
+
* column, so reselect by the full composite PK and update-or-create inside one
|
|
1048
|
+
* flat transaction (PowDB single-writer makes the read-then-write safe from
|
|
1049
|
+
* concurrent writers; the transaction makes it atomic with the write).
|
|
1050
|
+
*/
|
|
1051
|
+
async upsertComposite(createData, updateData) {
|
|
1052
|
+
// Accept either the camelCase field or the snake_case column in `create`
|
|
1053
|
+
// (create() resolves both), and key the where by field name.
|
|
1054
|
+
const pkPairs = this.meta.primaryKey.map((pk) => {
|
|
1055
|
+
const field = this.meta.reverseColumnMap[pk] ?? pk;
|
|
1056
|
+
return { field, value: createData[field] ?? createData[pk] };
|
|
1057
|
+
});
|
|
1058
|
+
if (pkPairs.some((p) => p.value == null)) {
|
|
1059
|
+
throw new errors_js_1.ValidationError(`[turbine] upsert on "${this.table}" needs every composite-PK field in \`create\` (${pkPairs
|
|
1060
|
+
.map((p) => p.field)
|
|
1061
|
+
.join(', ')}).`);
|
|
1062
|
+
}
|
|
1063
|
+
const pkWhere = Object.fromEntries(pkPairs.map((p) => [p.field, p.value]));
|
|
1064
|
+
const run = async (ctx) => {
|
|
1065
|
+
const tbl = ctx.tx.table(this.table);
|
|
1066
|
+
const existing = await tbl.findUnique({ where: pkWhere });
|
|
1067
|
+
return existing
|
|
1068
|
+
? (await tbl.update({ where: pkWhere, data: updateData }))
|
|
1069
|
+
: (await tbl.create({ data: createData }));
|
|
1070
|
+
};
|
|
1071
|
+
return this.isTxScoped() ? run(this.buildNestedCtx()) : this.runInImplicitTx(run);
|
|
1072
|
+
}
|
|
717
1073
|
// -------------------------------------------------------------------------
|
|
718
1074
|
// Aggregates
|
|
719
1075
|
// -------------------------------------------------------------------------
|
|
720
1076
|
async count(args = {}) {
|
|
721
1077
|
return this.withMiddleware('count', (args ?? {}), async () => {
|
|
722
1078
|
const params = [];
|
|
723
|
-
const
|
|
1079
|
+
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
1080
|
+
const where = this.buildWhere(resolvedWhere, params);
|
|
724
1081
|
const filter = where ? ` filter ${where}` : '';
|
|
725
1082
|
const { rows } = await this.exec(`count(${this.table}${filter})`, params, args.timeout);
|
|
726
1083
|
return Number((rows[0]?.value ?? rows[0]?.count ?? 0));
|
|
@@ -731,7 +1088,8 @@ class PowqlInterface {
|
|
|
731
1088
|
// One scalar query per aggregate — PowDB's bare-projection aggregate is broken.
|
|
732
1089
|
const result = {};
|
|
733
1090
|
const filterParams = [];
|
|
734
|
-
const
|
|
1091
|
+
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
1092
|
+
const where = this.buildWhere(resolvedWhere, filterParams);
|
|
735
1093
|
const filter = where ? ` filter ${where}` : '';
|
|
736
1094
|
const scalar = async (expr) => {
|
|
737
1095
|
const params = [...filterParams];
|
|
@@ -768,7 +1126,8 @@ class PowqlInterface {
|
|
|
768
1126
|
async groupBy(args) {
|
|
769
1127
|
return this.withMiddleware('groupBy', args, async () => {
|
|
770
1128
|
const params = [];
|
|
771
|
-
const
|
|
1129
|
+
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
1130
|
+
const where = this.buildWhere(resolvedWhere, params);
|
|
772
1131
|
const filter = where ? ` filter ${where}` : '';
|
|
773
1132
|
const groupKeys = args.by.map((f) => this.ref(f));
|
|
774
1133
|
// Safe aliases — PowQL rejects reserved-word aliases (e.g. `count:`).
|
package/dist/generate.js
CHANGED
|
@@ -504,6 +504,10 @@ function serializeColumn(col) {
|
|
|
504
504
|
`arrayType: '${escSQ(col.arrayType ?? col.pgArrayType)}'`,
|
|
505
505
|
`pgArrayType: '${escSQ(col.pgArrayType)}'`,
|
|
506
506
|
];
|
|
507
|
+
// Emit isGenerated only when set (server-generated serial/identity), so the
|
|
508
|
+
// output stays byte-identical for the common client-default columns.
|
|
509
|
+
if (col.isGenerated)
|
|
510
|
+
parts.push(`isGenerated: true`);
|
|
507
511
|
if (col.maxLength !== undefined)
|
|
508
512
|
parts.push(`maxLength: ${col.maxLength}`);
|
|
509
513
|
return `{ ${parts.join(', ')} }`;
|
package/dist/introspect.js
CHANGED
|
@@ -28,6 +28,7 @@ const SQL_COLUMNS = `
|
|
|
28
28
|
data_type,
|
|
29
29
|
is_nullable,
|
|
30
30
|
column_default,
|
|
31
|
+
is_identity,
|
|
31
32
|
ordinal_position,
|
|
32
33
|
character_maximum_length
|
|
33
34
|
FROM information_schema.columns
|
|
@@ -163,6 +164,11 @@ export async function introspectPostgresCatalog(options) {
|
|
|
163
164
|
pgTypeToTs(isArray ? dialectType : baseType, isNullable),
|
|
164
165
|
nullable: isNullable,
|
|
165
166
|
hasDefault: row.column_default !== null,
|
|
167
|
+
// Server-generated = a sequence default (serial/BIGSERIAL → nextval(…))
|
|
168
|
+
// or an IDENTITY column. Distinct from a client-side default expression
|
|
169
|
+
// (gen_random_uuid(), now()), which Turbine must still synthesize.
|
|
170
|
+
isGenerated: (typeof row.column_default === 'string' && row.column_default.includes('nextval(')) ||
|
|
171
|
+
row.is_identity === 'YES',
|
|
166
172
|
isArray,
|
|
167
173
|
arrayType,
|
|
168
174
|
pgArrayType: arrayType,
|
package/dist/powdb.d.ts
CHANGED
|
@@ -150,7 +150,10 @@ export declare function powqlColumnType(col: ColumnMetadata): PowqlType;
|
|
|
150
150
|
* Generate PowQL DDL (`type T { … }`) for every table in a schema. Used to
|
|
151
151
|
* provision a PowDB database from a code-first `defineSchema`/`SchemaMetadata`
|
|
152
152
|
* (PowDB has no migration runner yet). The primary key column is declared
|
|
153
|
-
* `required unique`; non-nullable columns are `required`.
|
|
153
|
+
* `required unique`; non-nullable columns are `required`. A server-generated
|
|
154
|
+
* column ({@link ColumnMetadata.isGenerated}) that maps to PowQL `int` gets the
|
|
155
|
+
* `auto` modifier, so PowDB assigns a monotonic id on insert and Turbine stops
|
|
156
|
+
* synthesizing a client-side value for it.
|
|
154
157
|
*/
|
|
155
158
|
export declare function powqlSchemaDDL(schema: SchemaMetadata): string[];
|
|
156
159
|
/**
|