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/powql.js
CHANGED
|
@@ -15,10 +15,20 @@
|
|
|
15
15
|
* - `create`/`createMany`/`update`/`delete` use PowDB 0.7.0's trailing
|
|
16
16
|
* `returning` keyword (`RETURNING *`, all columns) to surface affected rows
|
|
17
17
|
* in one round-trip. `upsert` is the lone exception — its statement does not
|
|
18
|
-
* accept `returning`, so it reselects the row by PK
|
|
19
|
-
*
|
|
20
|
-
* -
|
|
21
|
-
*
|
|
18
|
+
* accept `returning`, so it reselects the row by PK (a composite-PK upsert
|
|
19
|
+
* reselects-or-writes inside one flat transaction).
|
|
20
|
+
* - The PK is server-assigned when the column is `isGenerated` (PowDB's `auto`
|
|
21
|
+
* int — read back via `returning`); otherwise a defaulted **string** PK is
|
|
22
|
+
* generated client-side (UUID).
|
|
23
|
+
* - `with` (nested relations) uses **batched N+1 loaders** — D round-trips for
|
|
24
|
+
* depth D, not one query — including manyToMany (junction → targets).
|
|
25
|
+
* - **Relation filters** (`some`/`none`/`every`, all cardinalities incl. m2m)
|
|
26
|
+
* are resolved client-side to a literal `in (…)` list, never an IN-subquery:
|
|
27
|
+
* PowDB's executor caches a subquery's result by plan shape and would return
|
|
28
|
+
* a stale prior result for a later subquery of the same shape.
|
|
29
|
+
* - **Nested writes** (relation ops in `create`/`update` data) run through the
|
|
30
|
+
* shared nested-write engine as one flat top-level transaction (PowDB is
|
|
31
|
+
* single-writer, no savepoints).
|
|
22
32
|
* - pgvector / JSON / array filters and cursor streaming throw
|
|
23
33
|
* {@link UnsupportedFeatureError} (E017) — they have no PowDB equivalent.
|
|
24
34
|
*
|
|
@@ -26,6 +36,7 @@
|
|
|
26
36
|
*/
|
|
27
37
|
import { randomUUID } from 'node:crypto';
|
|
28
38
|
import { NotFoundError, TimeoutError, UnsupportedFeatureError, ValidationError } from './errors.js';
|
|
39
|
+
import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from './nested-write.js';
|
|
29
40
|
import { PowdbFloatParam, powqlColumnType, rowToEntity } from './powdb.js';
|
|
30
41
|
import { escapeLike } from './query/utils.js';
|
|
31
42
|
import { normalizeKeyColumns, } from './schema.js';
|
|
@@ -188,7 +199,10 @@ export class PowqlInterface {
|
|
|
188
199
|
parts.push(`not (${sub})`);
|
|
189
200
|
}
|
|
190
201
|
else if (this.meta.relations[key]) {
|
|
191
|
-
|
|
202
|
+
// Relation filters are pre-resolved to scalar in/notIn by
|
|
203
|
+
// resolveRelationFilters() before buildWhere runs — reaching here means
|
|
204
|
+
// a caller skipped that step (an internal bug, not user error).
|
|
205
|
+
throw new ValidationError(`[turbine] internal: relation filter "${key}" reached buildWhere unresolved (missing resolveRelationFilters()).`);
|
|
192
206
|
}
|
|
193
207
|
else {
|
|
194
208
|
parts.push(this.buildFieldCondition(key, value, params));
|
|
@@ -276,41 +290,147 @@ export class PowqlInterface {
|
|
|
276
290
|
return `${lhs} ${negate ? 'not in' : 'in'} (${items})`;
|
|
277
291
|
}
|
|
278
292
|
/**
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
293
|
+
* Pre-resolve every relation filter (`some`/`none`/`every`) in a where clause
|
|
294
|
+
* into a plain scalar `in`/`notIn` condition on the **local key**, by running
|
|
295
|
+
* the inner predicate as its own query and materializing the matching keys as
|
|
296
|
+
* a literal list. The compiled where is then relation-free and `buildWhere`
|
|
297
|
+
* emits only `in (<literal list>)`.
|
|
298
|
+
*
|
|
299
|
+
* Why not an IN-subquery (`.k in (Target filter <e> { .fk })`)? PowDB's
|
|
300
|
+
* executor caches a subquery's result by **plan shape, ignoring the literal**,
|
|
301
|
+
* so a second subquery of the same shape with a different value returns the
|
|
302
|
+
* first one's stale rows (reproduced live on the embedded engine; the
|
|
303
|
+
* single-statement literal `in (list)` form is always correct). Resolving
|
|
304
|
+
* client-side trades extra round-trips for correctness, and recurses — nested
|
|
305
|
+
* relation filters in the inner predicate resolve when the target query runs.
|
|
282
306
|
*/
|
|
283
|
-
|
|
307
|
+
async resolveRelationFilters(where, timeout) {
|
|
308
|
+
if (!where)
|
|
309
|
+
return where;
|
|
310
|
+
const scalar = {};
|
|
311
|
+
const relConds = [];
|
|
312
|
+
for (const [key, value] of Object.entries(where)) {
|
|
313
|
+
if (value === undefined)
|
|
314
|
+
continue;
|
|
315
|
+
if (key === 'AND' || key === 'OR') {
|
|
316
|
+
scalar[key] = await Promise.all(value.map((w) => this.resolveRelationFilters(w, timeout)));
|
|
317
|
+
}
|
|
318
|
+
else if (key === 'NOT') {
|
|
319
|
+
scalar[key] = await this.resolveRelationFilters(value, timeout);
|
|
320
|
+
}
|
|
321
|
+
else if (this.meta.relations[key]) {
|
|
322
|
+
relConds.push(await this.resolveRelationCondition(this.meta.relations[key], value, timeout));
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
scalar[key] = value;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (!relConds.length)
|
|
329
|
+
return scalar;
|
|
330
|
+
const hasScalar = Object.keys(scalar).length > 0;
|
|
331
|
+
return { AND: [...(hasScalar ? [scalar] : []), ...relConds] };
|
|
332
|
+
}
|
|
333
|
+
/** Resolve one hasMany/hasOne/belongsTo filter to `{ localField: { in|notIn: [...] } }`. */
|
|
334
|
+
async resolveRelationCondition(rel, filter, timeout) {
|
|
335
|
+
const mode = filter.some ? 'some' : filter.none ? 'none' : filter.every ? 'every' : null;
|
|
336
|
+
if (!mode)
|
|
337
|
+
return {};
|
|
338
|
+
const innerWhere = filter[mode];
|
|
339
|
+
const innerEmpty = !innerWhere || Object.keys(innerWhere).length === 0;
|
|
284
340
|
if (rel.type === 'manyToMany') {
|
|
285
|
-
|
|
341
|
+
return this.resolveManyToManyCondition(rel, mode, innerWhere, innerEmpty, timeout);
|
|
286
342
|
}
|
|
287
343
|
const fk = normalizeKeyColumns(rel.foreignKey);
|
|
288
344
|
const rk = normalizeKeyColumns(rel.referenceKey);
|
|
289
345
|
if (fk.length > 1 || rk.length > 1) {
|
|
290
|
-
throw new UnsupportedFeatureError('composite-key relation filters', 'PowDB', `relation "${rel.name}" uses a composite key`);
|
|
346
|
+
throw new UnsupportedFeatureError('composite-key relation filters', 'PowDB', `relation "${rel.name}" uses a composite key — PowQL has no tuple-\`in\` to express it`);
|
|
291
347
|
}
|
|
292
|
-
// hasMany/hasOne: outer.referenceKey ∈ target.foreignKey.
|
|
293
|
-
// belongsTo: outer.foreignKey ∈ target.referenceKey.
|
|
294
|
-
const localKey = rel.type === 'belongsTo' ? fk[0] : rk[0];
|
|
295
|
-
const targetKey = rel.type === 'belongsTo' ? rk[0] : fk[0];
|
|
296
348
|
const targetMeta = this.schema.tables[rel.to];
|
|
297
349
|
if (!targetMeta)
|
|
298
350
|
throw new ValidationError(`[turbine] Relation "${rel.name}" targets unknown table "${rel.to}".`);
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
351
|
+
// hasMany/hasOne: localKey = our referenceKey, collect the child's foreignKey.
|
|
352
|
+
// belongsTo: localKey = our foreignKey, collect the target's referenceKey.
|
|
353
|
+
const localCol = rel.type === 'belongsTo' ? fk[0] : rk[0];
|
|
354
|
+
const childCol = rel.type === 'belongsTo' ? rk[0] : fk[0];
|
|
355
|
+
const localField = this.meta.reverseColumnMap[localCol] ?? localCol;
|
|
356
|
+
const childField = targetMeta.reverseColumnMap[childCol] ?? childCol;
|
|
357
|
+
const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
|
|
358
|
+
const collect = async (w) => {
|
|
359
|
+
const rows = await targetQi.findMany({
|
|
360
|
+
where: w,
|
|
361
|
+
select: { [childField]: true },
|
|
362
|
+
timeout,
|
|
363
|
+
});
|
|
364
|
+
return [...new Set(rows.map((r) => r[childField]).filter((v) => v != null))];
|
|
304
365
|
};
|
|
305
|
-
if (
|
|
306
|
-
return
|
|
307
|
-
if (
|
|
308
|
-
return
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
return
|
|
366
|
+
if (mode === 'some')
|
|
367
|
+
return { [localField]: { in: await collect(innerWhere) } };
|
|
368
|
+
if (mode === 'none')
|
|
369
|
+
return { [localField]: { notIn: await collect(innerWhere) } };
|
|
370
|
+
// every: a parent qualifies unless it has a child FAILING the predicate.
|
|
371
|
+
if (innerEmpty)
|
|
372
|
+
return {}; // every:{} ⇒ trivially true for all parents
|
|
373
|
+
return { [localField]: { notIn: await collect({ NOT: innerWhere }) } };
|
|
374
|
+
}
|
|
375
|
+
/** Resolve a manyToMany filter through the junction to `{ sourceRefField: { in|notIn: [...] } }`. */
|
|
376
|
+
async resolveManyToManyCondition(rel, mode, innerWhere, innerEmpty, timeout) {
|
|
377
|
+
const through = rel.through;
|
|
378
|
+
if (!through)
|
|
379
|
+
throw new ValidationError(`[turbine] manyToMany relation "${rel.name}" is missing its junction (\`through\`).`);
|
|
380
|
+
const sourceJ = normalizeKeyColumns(through.sourceKey);
|
|
381
|
+
const targetJ = normalizeKeyColumns(through.targetKey);
|
|
382
|
+
const sourceRef = normalizeKeyColumns(rel.referenceKey);
|
|
383
|
+
const targetMeta = this.schema.tables[rel.to];
|
|
384
|
+
if (!targetMeta)
|
|
385
|
+
throw new ValidationError(`[turbine] Relation "${rel.name}" targets unknown table "${rel.to}".`);
|
|
386
|
+
if (sourceJ.length > 1 || targetJ.length > 1 || sourceRef.length > 1 || targetMeta.primaryKey.length > 1) {
|
|
387
|
+
throw new UnsupportedFeatureError('composite-key manyToMany filters', 'PowDB', `relation "${rel.name}" — PowQL has no tuple-\`in\` for composite junction/target keys`);
|
|
312
388
|
}
|
|
313
|
-
|
|
389
|
+
const sourceJCol = sourceJ[0];
|
|
390
|
+
const targetJCol = targetJ[0];
|
|
391
|
+
const sourceRefCol = sourceRef[0];
|
|
392
|
+
const targetPkCol = targetMeta.primaryKey[0];
|
|
393
|
+
const sourceRefField = this.meta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
|
|
394
|
+
const sourceRefColMeta = this.meta.columns.find((c) => c.name === sourceRefCol);
|
|
395
|
+
const targetPkField = targetMeta.reverseColumnMap[targetPkCol] ?? targetPkCol;
|
|
396
|
+
const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
|
|
397
|
+
const collectTargetPks = async (w) => {
|
|
398
|
+
const rows = await targetQi.findMany({
|
|
399
|
+
where: w,
|
|
400
|
+
select: { [targetPkField]: true },
|
|
401
|
+
timeout,
|
|
402
|
+
});
|
|
403
|
+
return [...new Set(rows.map((r) => r[targetPkField]).filter((v) => v != null))];
|
|
404
|
+
};
|
|
405
|
+
// Junction source keys linking any of `targetPks` (literal IN-list — never a subquery).
|
|
406
|
+
const sourcesForTargets = async (targetPks) => {
|
|
407
|
+
if (!targetPks.length)
|
|
408
|
+
return [];
|
|
409
|
+
const out = new Set();
|
|
410
|
+
for (let i = 0; i < targetPks.length; i += MAX_RELATION_KEYS) {
|
|
411
|
+
const chunk = targetPks.slice(i, i + MAX_RELATION_KEYS);
|
|
412
|
+
const params = [];
|
|
413
|
+
const ph = chunk.map((v) => this.param(v, params)).join(', ');
|
|
414
|
+
const { rows } = await this.exec(`${through.table} filter .${targetJCol} in (${ph}) { .${sourceJCol} }`, params, timeout);
|
|
415
|
+
for (const r of rows) {
|
|
416
|
+
const v = r[sourceJCol];
|
|
417
|
+
if (v != null)
|
|
418
|
+
out.add(sourceRefColMeta ? coerceScalar(String(v), sourceRefColMeta.tsType) : v);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return [...out];
|
|
422
|
+
};
|
|
423
|
+
if (mode === 'some') {
|
|
424
|
+
return { [sourceRefField]: { in: await sourcesForTargets(await collectTargetPks(innerWhere)) } };
|
|
425
|
+
}
|
|
426
|
+
if (mode === 'none') {
|
|
427
|
+
return { [sourceRefField]: { notIn: await sourcesForTargets(await collectTargetPks(innerWhere)) } };
|
|
428
|
+
}
|
|
429
|
+
// every: exclude parents that link a target FAILING the predicate.
|
|
430
|
+
if (innerEmpty)
|
|
431
|
+
return {}; // every:{} ⇒ trivially true
|
|
432
|
+
const failing = await sourcesForTargets(await collectTargetPks({ NOT: innerWhere }));
|
|
433
|
+
return { [sourceRefField]: { notIn: failing } };
|
|
314
434
|
}
|
|
315
435
|
// -------------------------------------------------------------------------
|
|
316
436
|
// Projection / order
|
|
@@ -430,7 +550,8 @@ export class PowqlInterface {
|
|
|
430
550
|
throw new UnsupportedFeatureError('cursor pagination', 'PowDB', 'use limit/offset instead');
|
|
431
551
|
}
|
|
432
552
|
const params = [];
|
|
433
|
-
const
|
|
553
|
+
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
554
|
+
const where = this.buildWhere(resolvedWhere, params);
|
|
434
555
|
const cols = this.projectedColumns(args.select, args.omit);
|
|
435
556
|
const distinct = args.distinct?.length ? ' distinct' : '';
|
|
436
557
|
const filter = where ? ` filter ${where}` : '';
|
|
@@ -497,7 +618,8 @@ export class PowqlInterface {
|
|
|
497
618
|
if (!rel)
|
|
498
619
|
throw new ValidationError(`[turbine] Unknown relation "${relName}" on "${this.table}".`);
|
|
499
620
|
if (rel.type === 'manyToMany') {
|
|
500
|
-
|
|
621
|
+
await this.loadManyToMany(parents, rel, relName, opt, timeout);
|
|
622
|
+
continue;
|
|
501
623
|
}
|
|
502
624
|
const fk = normalizeKeyColumns(rel.foreignKey);
|
|
503
625
|
const rk = normalizeKeyColumns(rel.referenceKey);
|
|
@@ -547,6 +669,95 @@ export class PowqlInterface {
|
|
|
547
669
|
}
|
|
548
670
|
}
|
|
549
671
|
}
|
|
672
|
+
/**
|
|
673
|
+
* manyToMany nested read — a three-hop batched loader (no `json_agg`/join
|
|
674
|
+
* pushdown): (1) read the junction rows for all parents in `sourceKey in (…)`
|
|
675
|
+
* chunks, (2) read the target rows for the collected `targetKey`s, (3) stitch
|
|
676
|
+
* each parent → its junction rows → its targets in memory. Mirrors the
|
|
677
|
+
* single-key N+1 loaders; the junction's source/target columns must be single
|
|
678
|
+
* (composite junction keys would need PowQL tuple-`in`, which it lacks).
|
|
679
|
+
*/
|
|
680
|
+
async loadManyToMany(parents, rel, relName, opt, timeout) {
|
|
681
|
+
const through = rel.through;
|
|
682
|
+
if (!through)
|
|
683
|
+
throw new ValidationError(`[turbine] manyToMany relation "${relName}" is missing its junction (\`through\`).`);
|
|
684
|
+
const sourceJ = normalizeKeyColumns(through.sourceKey);
|
|
685
|
+
const targetJ = normalizeKeyColumns(through.targetKey);
|
|
686
|
+
const sourceRef = normalizeKeyColumns(rel.referenceKey);
|
|
687
|
+
const targetMeta = this.schema.tables[rel.to];
|
|
688
|
+
if (!targetMeta)
|
|
689
|
+
throw new ValidationError(`[turbine] Relation "${relName}" targets unknown table "${rel.to}".`);
|
|
690
|
+
if (sourceJ.length > 1 || targetJ.length > 1 || sourceRef.length > 1 || targetMeta.primaryKey.length > 1) {
|
|
691
|
+
throw new UnsupportedFeatureError('composite-key manyToMany', 'PowDB', `relation "${relName}" — PowQL has no tuple-\`in\`, so composite junction/target keys can't be loaded`);
|
|
692
|
+
}
|
|
693
|
+
const sourceJCol = sourceJ[0];
|
|
694
|
+
const targetJCol = targetJ[0];
|
|
695
|
+
const sourceRefCol = sourceRef[0];
|
|
696
|
+
const targetPkCol = targetMeta.primaryKey[0];
|
|
697
|
+
const parentRefField = this.meta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
|
|
698
|
+
const targetPkField = targetMeta.reverseColumnMap[targetPkCol] ?? targetPkCol;
|
|
699
|
+
const targetPkColMeta = targetMeta.columns.find((c) => c.name === targetPkCol);
|
|
700
|
+
const parentKeys = [
|
|
701
|
+
...new Set(parents.map((p) => p[parentRefField]).filter((k) => k != null)),
|
|
702
|
+
];
|
|
703
|
+
if (!parentKeys.length) {
|
|
704
|
+
for (const parent of parents)
|
|
705
|
+
parent[relName] = [];
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
// (1) Junction rows: sourceKeyVal(String) → [targetKeyVal(String)].
|
|
709
|
+
const targetsBySource = new Map();
|
|
710
|
+
const allTargetVals = new Set();
|
|
711
|
+
for (let i = 0; i < parentKeys.length; i += MAX_RELATION_KEYS) {
|
|
712
|
+
const chunk = parentKeys.slice(i, i + MAX_RELATION_KEYS);
|
|
713
|
+
const params = [];
|
|
714
|
+
const placeholders = chunk.map((v) => this.param(v, params)).join(', ');
|
|
715
|
+
const powql = `${through.table} filter .${sourceJCol} in (${placeholders}) { .${sourceJCol}, .${targetJCol} }`;
|
|
716
|
+
const { rows } = await this.exec(powql, params, timeout);
|
|
717
|
+
for (const row of rows) {
|
|
718
|
+
const sv = String(row[sourceJCol]);
|
|
719
|
+
const tv = String(row[targetJCol]);
|
|
720
|
+
const bucket = targetsBySource.get(sv);
|
|
721
|
+
if (bucket)
|
|
722
|
+
bucket.push(tv);
|
|
723
|
+
else
|
|
724
|
+
targetsBySource.set(sv, [tv]);
|
|
725
|
+
allTargetVals.add(tv);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
// (2) Target rows by PK, honouring the relation's own where/with/select/…
|
|
729
|
+
const options = (opt === true ? {} : opt);
|
|
730
|
+
const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
|
|
731
|
+
const targetByPk = new Map();
|
|
732
|
+
const targetValList = [...allTargetVals].map((v) => targetPkColMeta ? coerceScalar(v, targetPkColMeta.tsType) : v);
|
|
733
|
+
for (let i = 0; i < targetValList.length; i += MAX_RELATION_KEYS) {
|
|
734
|
+
const chunk = targetValList.slice(i, i + MAX_RELATION_KEYS);
|
|
735
|
+
const where = {
|
|
736
|
+
...options.where,
|
|
737
|
+
[targetPkField]: { in: chunk },
|
|
738
|
+
};
|
|
739
|
+
const targets = (await targetQi.findMany({
|
|
740
|
+
...options,
|
|
741
|
+
where,
|
|
742
|
+
with: options.with,
|
|
743
|
+
timeout: options.timeout ?? timeout,
|
|
744
|
+
}));
|
|
745
|
+
for (const t of targets)
|
|
746
|
+
targetByPk.set(String(t[targetPkField]), t);
|
|
747
|
+
}
|
|
748
|
+
// (3) Stitch: each parent → its junction targets (m2m is always a list).
|
|
749
|
+
for (const parent of parents) {
|
|
750
|
+
const sv = String(parent[parentRefField]);
|
|
751
|
+
const tvs = targetsBySource.get(sv) ?? [];
|
|
752
|
+
const children = [];
|
|
753
|
+
for (const tv of tvs) {
|
|
754
|
+
const child = targetByPk.get(tv);
|
|
755
|
+
if (child)
|
|
756
|
+
children.push(child);
|
|
757
|
+
}
|
|
758
|
+
parent[relName] = children;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
550
761
|
// -------------------------------------------------------------------------
|
|
551
762
|
// Writes (reselect — PowDB has no RETURNING)
|
|
552
763
|
// -------------------------------------------------------------------------
|
|
@@ -557,19 +768,27 @@ export class PowqlInterface {
|
|
|
557
768
|
if (value === undefined)
|
|
558
769
|
continue;
|
|
559
770
|
if (this.meta.relations[field]) {
|
|
560
|
-
throw new UnsupportedFeatureError('nested writes', 'PowDB', `relation "${field}"
|
|
771
|
+
throw new UnsupportedFeatureError('nested writes', 'PowDB', `relation "${field}" — nested writes need create()/update(), not createMany()/upsert()`);
|
|
561
772
|
}
|
|
562
773
|
out.push({ col: this.column(field), value });
|
|
563
774
|
}
|
|
564
775
|
return out;
|
|
565
776
|
}
|
|
566
|
-
/**
|
|
777
|
+
/**
|
|
778
|
+
* Fill in a client-generated UUID for a defaulted **string** PK that wasn't
|
|
779
|
+
* supplied. A server-generated PK ({@link ColumnMetadata.isGenerated}, e.g. an
|
|
780
|
+
* `int` column with PowDB's `auto` modifier) is left untouched — PowDB assigns
|
|
781
|
+
* it and the trailing `returning` reads it back — as is any non-string PK.
|
|
782
|
+
*/
|
|
567
783
|
applyPkDefault(data) {
|
|
568
784
|
const out = { ...data };
|
|
569
785
|
for (const pk of this.meta.primaryKey) {
|
|
570
786
|
const field = this.meta.reverseColumnMap[pk] ?? pk;
|
|
571
787
|
const col = this.meta.columns.find((c) => c.name === pk);
|
|
572
|
-
if (out[field] == null &&
|
|
788
|
+
if (out[field] == null &&
|
|
789
|
+
col?.hasDefault &&
|
|
790
|
+
!col.isGenerated &&
|
|
791
|
+
col.tsType.replace(/\s*\|\s*null$/, '').trim() === 'string') {
|
|
573
792
|
out[field] = randomUUID();
|
|
574
793
|
}
|
|
575
794
|
}
|
|
@@ -577,6 +796,9 @@ export class PowqlInterface {
|
|
|
577
796
|
}
|
|
578
797
|
async create(args) {
|
|
579
798
|
return this.withMiddleware('create', args, async () => {
|
|
799
|
+
if (hasRelationFields(args.data, this.meta)) {
|
|
800
|
+
return this.nestedCreate(args);
|
|
801
|
+
}
|
|
580
802
|
const data = this.applyPkDefault(args.data);
|
|
581
803
|
const assigns = this.scalarData(data);
|
|
582
804
|
const params = [];
|
|
@@ -606,8 +828,12 @@ export class PowqlInterface {
|
|
|
606
828
|
}
|
|
607
829
|
async update(args) {
|
|
608
830
|
return this.withMiddleware('update', args, async () => {
|
|
831
|
+
if (hasRelationFields(args.data, this.meta)) {
|
|
832
|
+
return this.nestedUpdate(args);
|
|
833
|
+
}
|
|
609
834
|
const params = [];
|
|
610
|
-
const
|
|
835
|
+
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
836
|
+
const where = this.buildWhere(resolvedWhere, params);
|
|
611
837
|
this.assertCompiledWhere(where, false, 'update');
|
|
612
838
|
const setClause = this.buildUpdateAssignments(args.data, params);
|
|
613
839
|
// `returning` hands back the post-update row(s); take the first (single-row contract).
|
|
@@ -621,7 +847,8 @@ export class PowqlInterface {
|
|
|
621
847
|
async updateMany(args) {
|
|
622
848
|
return this.withMiddleware('updateMany', args, async () => {
|
|
623
849
|
const params = [];
|
|
624
|
-
const
|
|
850
|
+
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
851
|
+
const where = this.buildWhere(resolvedWhere, params);
|
|
625
852
|
this.assertCompiledWhere(where, args.allowFullTableScan, 'updateMany');
|
|
626
853
|
const setClause = this.buildUpdateAssignments(args.data, params);
|
|
627
854
|
const filter = where ? ` filter ${where}` : '';
|
|
@@ -636,7 +863,7 @@ export class PowqlInterface {
|
|
|
636
863
|
if (value === undefined)
|
|
637
864
|
continue;
|
|
638
865
|
if (this.meta.relations[field]) {
|
|
639
|
-
throw new UnsupportedFeatureError('nested writes', 'PowDB', `relation "${field}" —
|
|
866
|
+
throw new UnsupportedFeatureError('nested writes', 'PowDB', `relation "${field}" — nested writes need create()/update(), not updateMany()/upsert()`);
|
|
640
867
|
}
|
|
641
868
|
const colMeta = this.column(field);
|
|
642
869
|
const ref = this.ref(field);
|
|
@@ -664,10 +891,75 @@ export class PowqlInterface {
|
|
|
664
891
|
throw new ValidationError(`[turbine] update on "${this.table}" has no fields to set.`);
|
|
665
892
|
return parts.join(', ');
|
|
666
893
|
}
|
|
894
|
+
// -------------------------------------------------------------------------
|
|
895
|
+
// Nested writes — create/update whose `data` carries relation ops (create,
|
|
896
|
+
// connect, connectOrCreate, disconnect, set, delete, update, upsert). Reuses
|
|
897
|
+
// the engine-agnostic nested-write engine (it only needs ctx.schema + the
|
|
898
|
+
// table accessors PowqlInterface already provides). Runs as ONE flat top-level
|
|
899
|
+
// PowDB transaction — single global write lock, no savepoints, so the whole
|
|
900
|
+
// tree commits or rolls back together (mirrors the SQL path's coverage:
|
|
901
|
+
// hasMany / hasOne / belongsTo; manyToMany nested writes are not handled by
|
|
902
|
+
// the shared engine on any backend).
|
|
903
|
+
// -------------------------------------------------------------------------
|
|
904
|
+
isTxScoped() {
|
|
905
|
+
return this.options._txScoped === true;
|
|
906
|
+
}
|
|
907
|
+
async nestedCreate(args) {
|
|
908
|
+
const data = args.data;
|
|
909
|
+
if (this.isTxScoped()) {
|
|
910
|
+
return executeNestedCreate(this.buildNestedCtx(), this.table, data);
|
|
911
|
+
}
|
|
912
|
+
return this.runInImplicitTx((ctx) => executeNestedCreate(ctx, this.table, data));
|
|
913
|
+
}
|
|
914
|
+
async nestedUpdate(args) {
|
|
915
|
+
const data = args.data;
|
|
916
|
+
const where = args.where;
|
|
917
|
+
if (this.isTxScoped()) {
|
|
918
|
+
return executeNestedUpdate(this.buildNestedCtx(), this.table, where, data);
|
|
919
|
+
}
|
|
920
|
+
return this.runInImplicitTx((ctx) => executeNestedUpdate(ctx, this.table, where, data));
|
|
921
|
+
}
|
|
922
|
+
/** Open a flat PowDB transaction on a pinned connection and run `fn` inside it. */
|
|
923
|
+
async runInImplicitTx(fn) {
|
|
924
|
+
// Route tx keywords through the dialect (like the SQL path) so this never
|
|
925
|
+
// drifts from `powdbDialect`; falls back to the literal lowercase keywords.
|
|
926
|
+
const d = this.options.dialect;
|
|
927
|
+
const client = await this.pool.connect();
|
|
928
|
+
try {
|
|
929
|
+
await client.query(d?.beginStatement?.() ?? 'begin');
|
|
930
|
+
const { TransactionClient } = await import('./client.js');
|
|
931
|
+
const tx = new TransactionClient(client, this.schema, this.middlewares, this.options);
|
|
932
|
+
const ctx = { schema: this.schema, tx: tx };
|
|
933
|
+
const result = await fn(ctx);
|
|
934
|
+
await client.query(d?.commitStatement?.() ?? 'commit');
|
|
935
|
+
return result;
|
|
936
|
+
}
|
|
937
|
+
catch (err) {
|
|
938
|
+
try {
|
|
939
|
+
await client.query(d?.rollbackStatement?.() ?? 'rollback');
|
|
940
|
+
}
|
|
941
|
+
catch {
|
|
942
|
+
/* best-effort — the connection may be gone */
|
|
943
|
+
}
|
|
944
|
+
throw err;
|
|
945
|
+
}
|
|
946
|
+
finally {
|
|
947
|
+
client.release();
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
/** Already inside a transaction: build a context whose table accessors reuse the pinned pool. */
|
|
951
|
+
buildNestedCtx() {
|
|
952
|
+
const opts = { ...this.options, _txScoped: true };
|
|
953
|
+
const tx = {
|
|
954
|
+
table: (name) => new PowqlInterface(this.pool, name, this.schema, this.middlewares, opts),
|
|
955
|
+
};
|
|
956
|
+
return { schema: this.schema, tx: tx };
|
|
957
|
+
}
|
|
667
958
|
async delete(args) {
|
|
668
959
|
return this.withMiddleware('delete', args, async () => {
|
|
669
960
|
const params = [];
|
|
670
|
-
const
|
|
961
|
+
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
962
|
+
const where = this.buildWhere(resolvedWhere, params);
|
|
671
963
|
this.assertCompiledWhere(where, false, 'delete');
|
|
672
964
|
// `returning` hands back the deleted row(s) — no separate pre-image reselect needed.
|
|
673
965
|
const { rows } = await this.exec(`${this.table} filter ${where} delete returning`, params, args.timeout);
|
|
@@ -680,7 +972,8 @@ export class PowqlInterface {
|
|
|
680
972
|
async deleteMany(args) {
|
|
681
973
|
return this.withMiddleware('deleteMany', args, async () => {
|
|
682
974
|
const params = [];
|
|
683
|
-
const
|
|
975
|
+
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
976
|
+
const where = this.buildWhere(resolvedWhere, params);
|
|
684
977
|
this.assertCompiledWhere(where, args.allowFullTableScan, 'deleteMany');
|
|
685
978
|
const filter = where ? ` filter ${where}` : '';
|
|
686
979
|
const { rowCount } = await this.exec(`${this.table}${filter} delete`, params, args.timeout);
|
|
@@ -691,8 +984,10 @@ export class PowqlInterface {
|
|
|
691
984
|
return this.withMiddleware('upsert', args, async () => {
|
|
692
985
|
const createData = this.applyPkDefault(args.create);
|
|
693
986
|
const pkCol = this.meta.primaryKey[0];
|
|
694
|
-
if (
|
|
695
|
-
|
|
987
|
+
if (this.meta.primaryKey.length !== 1 || !pkCol) {
|
|
988
|
+
// PowQL's native `upsert … on .col` takes a single conflict column, so a
|
|
989
|
+
// composite PK falls back to an atomic reselect-or-write transaction.
|
|
990
|
+
return this.upsertComposite(createData, args.update);
|
|
696
991
|
}
|
|
697
992
|
const params = [];
|
|
698
993
|
const createBody = this.scalarData(createData)
|
|
@@ -711,13 +1006,42 @@ export class PowqlInterface {
|
|
|
711
1006
|
return row;
|
|
712
1007
|
});
|
|
713
1008
|
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Composite-key upsert: PowQL's `upsert … on .col` only takes one conflict
|
|
1011
|
+
* column, so reselect by the full composite PK and update-or-create inside one
|
|
1012
|
+
* flat transaction (PowDB single-writer makes the read-then-write safe from
|
|
1013
|
+
* concurrent writers; the transaction makes it atomic with the write).
|
|
1014
|
+
*/
|
|
1015
|
+
async upsertComposite(createData, updateData) {
|
|
1016
|
+
// Accept either the camelCase field or the snake_case column in `create`
|
|
1017
|
+
// (create() resolves both), and key the where by field name.
|
|
1018
|
+
const pkPairs = this.meta.primaryKey.map((pk) => {
|
|
1019
|
+
const field = this.meta.reverseColumnMap[pk] ?? pk;
|
|
1020
|
+
return { field, value: createData[field] ?? createData[pk] };
|
|
1021
|
+
});
|
|
1022
|
+
if (pkPairs.some((p) => p.value == null)) {
|
|
1023
|
+
throw new ValidationError(`[turbine] upsert on "${this.table}" needs every composite-PK field in \`create\` (${pkPairs
|
|
1024
|
+
.map((p) => p.field)
|
|
1025
|
+
.join(', ')}).`);
|
|
1026
|
+
}
|
|
1027
|
+
const pkWhere = Object.fromEntries(pkPairs.map((p) => [p.field, p.value]));
|
|
1028
|
+
const run = async (ctx) => {
|
|
1029
|
+
const tbl = ctx.tx.table(this.table);
|
|
1030
|
+
const existing = await tbl.findUnique({ where: pkWhere });
|
|
1031
|
+
return existing
|
|
1032
|
+
? (await tbl.update({ where: pkWhere, data: updateData }))
|
|
1033
|
+
: (await tbl.create({ data: createData }));
|
|
1034
|
+
};
|
|
1035
|
+
return this.isTxScoped() ? run(this.buildNestedCtx()) : this.runInImplicitTx(run);
|
|
1036
|
+
}
|
|
714
1037
|
// -------------------------------------------------------------------------
|
|
715
1038
|
// Aggregates
|
|
716
1039
|
// -------------------------------------------------------------------------
|
|
717
1040
|
async count(args = {}) {
|
|
718
1041
|
return this.withMiddleware('count', (args ?? {}), async () => {
|
|
719
1042
|
const params = [];
|
|
720
|
-
const
|
|
1043
|
+
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
1044
|
+
const where = this.buildWhere(resolvedWhere, params);
|
|
721
1045
|
const filter = where ? ` filter ${where}` : '';
|
|
722
1046
|
const { rows } = await this.exec(`count(${this.table}${filter})`, params, args.timeout);
|
|
723
1047
|
return Number((rows[0]?.value ?? rows[0]?.count ?? 0));
|
|
@@ -728,7 +1052,8 @@ export class PowqlInterface {
|
|
|
728
1052
|
// One scalar query per aggregate — PowDB's bare-projection aggregate is broken.
|
|
729
1053
|
const result = {};
|
|
730
1054
|
const filterParams = [];
|
|
731
|
-
const
|
|
1055
|
+
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
1056
|
+
const where = this.buildWhere(resolvedWhere, filterParams);
|
|
732
1057
|
const filter = where ? ` filter ${where}` : '';
|
|
733
1058
|
const scalar = async (expr) => {
|
|
734
1059
|
const params = [...filterParams];
|
|
@@ -765,7 +1090,8 @@ export class PowqlInterface {
|
|
|
765
1090
|
async groupBy(args) {
|
|
766
1091
|
return this.withMiddleware('groupBy', args, async () => {
|
|
767
1092
|
const params = [];
|
|
768
|
-
const
|
|
1093
|
+
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
1094
|
+
const where = this.buildWhere(resolvedWhere, params);
|
|
769
1095
|
const filter = where ? ` filter ${where}` : '';
|
|
770
1096
|
const groupKeys = args.by.map((f) => this.ref(f));
|
|
771
1097
|
// Safe aliases — PowQL rejects reserved-word aliases (e.g. `count:`).
|
package/dist/schema.d.ts
CHANGED
|
@@ -51,6 +51,16 @@ export interface ColumnMetadata {
|
|
|
51
51
|
nullable: boolean;
|
|
52
52
|
/** Whether the column has a DEFAULT, is serial, or is generated */
|
|
53
53
|
hasDefault: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Whether the **database server** generates this column's value on insert —
|
|
56
|
+
* a `serial`/`BIGSERIAL` sequence, an `IDENTITY` column, or PowDB's `auto`
|
|
57
|
+
* modifier. This is a strict subset of {@link hasDefault} (a server-generated
|
|
58
|
+
* column always reports `hasDefault: true`), but unlike a client-side default
|
|
59
|
+
* expression (`gen_random_uuid()`, `now()`) the value is assigned by the
|
|
60
|
+
* engine, so Turbine must NOT synthesize one client-side and the PowDB DDL
|
|
61
|
+
* emits the `auto` modifier. Optional / defaults to `false` for back-compat.
|
|
62
|
+
*/
|
|
63
|
+
isGenerated?: boolean;
|
|
54
64
|
/** Whether this is an array column */
|
|
55
65
|
isArray: boolean;
|
|
56
66
|
/** Dialect-specific array/bulk-insert type token when needed. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "turbine-orm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "Postgres-native TypeScript ORM — runs on Neon, Vercel Postgres, Cloudflare, Supabase. Streaming cursors, typed errors, single-query nested relations. One dependency, no WASM engine",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|