turbine-orm 0.21.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/README.md +19 -2
- package/dist/cjs/client.js +11 -2
- package/dist/cjs/generate.js +4 -0
- package/dist/cjs/introspect.js +6 -0
- package/dist/cjs/powdb.js +865 -0
- package/dist/cjs/powql.js +1262 -0
- package/dist/cli/ui.d.ts +1 -1
- package/dist/client.js +11 -2
- package/dist/generate.js +4 -0
- package/dist/introspect.js +6 -0
- package/dist/powdb.d.ts +341 -0
- package/dist/powdb.js +816 -0
- package/dist/powql.d.ts +205 -0
- package/dist/powql.js +1226 -0
- package/dist/query/builder.d.ts +8 -0
- package/dist/schema.d.ts +10 -0
- package/package.json +16 -1
|
@@ -0,0 +1,1262 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* PowqlInterface — Turbine's PowQL query generator (the PowDB analogue of
|
|
4
|
+
* {@link QueryInterface}). It exposes the same public method surface as the SQL
|
|
5
|
+
* `QueryInterface` (`findMany`, `create`, `update`, …) but emits **PowQL** — a
|
|
6
|
+
* pipeline language, not SQL — executed through {@link PowdbPool}.
|
|
7
|
+
*
|
|
8
|
+
* It is a *parallel* implementation rather than a `Dialect` of the SQL builder:
|
|
9
|
+
* PowQL's grammar (`T filter <e> order <k> { .col }`) shares no surface with
|
|
10
|
+
* `SELECT … FROM … WHERE`, so the SQL `Dialect` seam cannot express it. Keeping
|
|
11
|
+
* it separate also means the four SQL engines are untouched.
|
|
12
|
+
*
|
|
13
|
+
* Behavioural deltas from the SQL path, all driven by PowDB's wire reality (see
|
|
14
|
+
* `docs/strategy/powdb-parity-matrix.md`, every row verified against a live
|
|
15
|
+
* server):
|
|
16
|
+
* - `create`/`createMany`/`update`/`delete` use PowDB 0.7.0's trailing
|
|
17
|
+
* `returning` keyword (`RETURNING *`, all columns) to surface affected rows
|
|
18
|
+
* in one round-trip. `upsert` is the lone exception — its statement does not
|
|
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).
|
|
33
|
+
* - pgvector / JSON / array filters and cursor streaming throw
|
|
34
|
+
* {@link UnsupportedFeatureError} (E017) — they have no PowDB equivalent.
|
|
35
|
+
*
|
|
36
|
+
* @module
|
|
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
|
+
})();
|
|
71
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
72
|
+
exports.PowqlInterface = void 0;
|
|
73
|
+
const node_crypto_1 = require("node:crypto");
|
|
74
|
+
const errors_js_1 = require("./errors.js");
|
|
75
|
+
const nested_write_js_1 = require("./nested-write.js");
|
|
76
|
+
const powdb_js_1 = require("./powdb.js");
|
|
77
|
+
const utils_js_1 = require("./query/utils.js");
|
|
78
|
+
const schema_js_1 = require("./schema.js");
|
|
79
|
+
/**
|
|
80
|
+
* Max parent keys per relation-loader `in (…)` query. A `with` over a large
|
|
81
|
+
* parent set is split into batches of this size so a single query never exceeds
|
|
82
|
+
* PowDB's per-statement param / row limits; the batches' children are merged
|
|
83
|
+
* before grouping. Mirrors the chunking the parity matrix documents.
|
|
84
|
+
*/
|
|
85
|
+
const MAX_RELATION_KEYS = 1000;
|
|
86
|
+
/** Operator keys recognised inside a `WhereOperator` object. */
|
|
87
|
+
const OPERATOR_KEYS = new Set([
|
|
88
|
+
'equals',
|
|
89
|
+
'gt',
|
|
90
|
+
'gte',
|
|
91
|
+
'lt',
|
|
92
|
+
'lte',
|
|
93
|
+
'not',
|
|
94
|
+
'in',
|
|
95
|
+
'notIn',
|
|
96
|
+
'contains',
|
|
97
|
+
'startsWith',
|
|
98
|
+
'endsWith',
|
|
99
|
+
'mode',
|
|
100
|
+
]);
|
|
101
|
+
/** Filters that have no PowDB representation and must throw E017. */
|
|
102
|
+
function rejectUnsupportedFilter(value, field) {
|
|
103
|
+
if ('distance' in value || 'metric' in value) {
|
|
104
|
+
throw new errors_js_1.UnsupportedFeatureError('pgvector distance filters', 'PowDB', `field "${field}"`);
|
|
105
|
+
}
|
|
106
|
+
if ('path' in value || 'hasKey' in value) {
|
|
107
|
+
throw new errors_js_1.UnsupportedFeatureError('JSON path/key filters', 'PowDB', `field "${field}"`);
|
|
108
|
+
}
|
|
109
|
+
if ('hasEvery' in value || 'hasSome' in value || 'isEmpty' in value) {
|
|
110
|
+
throw new errors_js_1.UnsupportedFeatureError('array filters', 'PowDB', `field "${field}"`);
|
|
111
|
+
}
|
|
112
|
+
if ('search' in value) {
|
|
113
|
+
throw new errors_js_1.UnsupportedFeatureError('full-text search filters', 'PowDB', `field "${field}"`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The PowQL query interface. Constructed by `turbinePowDB` via the
|
|
118
|
+
* `queryInterfaceFactory` seam and cast to `QueryInterface<object>` so
|
|
119
|
+
* `TurbineClient.table()` can return it transparently.
|
|
120
|
+
*/
|
|
121
|
+
class PowqlInterface {
|
|
122
|
+
pool;
|
|
123
|
+
table;
|
|
124
|
+
schema;
|
|
125
|
+
middlewares;
|
|
126
|
+
options;
|
|
127
|
+
meta;
|
|
128
|
+
defaultLimit;
|
|
129
|
+
warnOnUnlimited;
|
|
130
|
+
onQuery;
|
|
131
|
+
currentAction = 'raw';
|
|
132
|
+
warnedUnlimited = false;
|
|
133
|
+
constructor(pool, table, schema, middlewares = [], options = {}) {
|
|
134
|
+
this.pool = pool;
|
|
135
|
+
this.table = table;
|
|
136
|
+
this.schema = schema;
|
|
137
|
+
this.middlewares = middlewares;
|
|
138
|
+
this.options = options;
|
|
139
|
+
const meta = schema.tables[table];
|
|
140
|
+
if (!meta) {
|
|
141
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown table "${table}". Available: ${Object.keys(schema.tables).join(', ')}`);
|
|
142
|
+
}
|
|
143
|
+
this.meta = meta;
|
|
144
|
+
this.defaultLimit = options.defaultLimit;
|
|
145
|
+
this.warnOnUnlimited = options.warnOnUnlimited !== false;
|
|
146
|
+
this.onQuery = options._onQuery;
|
|
147
|
+
}
|
|
148
|
+
// -------------------------------------------------------------------------
|
|
149
|
+
// Column / value helpers
|
|
150
|
+
// -------------------------------------------------------------------------
|
|
151
|
+
/** Resolve a camelCase field name (or raw snake) to its column metadata. */
|
|
152
|
+
column(field) {
|
|
153
|
+
const snake = this.meta.columnMap[field] ?? field;
|
|
154
|
+
const col = this.meta.columns.find((c) => c.name === snake || c.field === field);
|
|
155
|
+
if (!col) {
|
|
156
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown column "${field}" on table "${this.table}". Known: ${this.meta.columns
|
|
157
|
+
.map((c) => c.field)
|
|
158
|
+
.join(', ')}`);
|
|
159
|
+
}
|
|
160
|
+
return col;
|
|
161
|
+
}
|
|
162
|
+
/** PowQL column reference (`.snake_name`) for a field. */
|
|
163
|
+
ref(field) {
|
|
164
|
+
return `.${this.column(field).name}`;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Push a value into the param array and return its `$N` placeholder. When the
|
|
168
|
+
* value targets a `float` column it is wrapped in {@link PowdbFloatParam} so
|
|
169
|
+
* the *embedded* literal encoder emits a float-form literal even for an
|
|
170
|
+
* integer value (PowQL's `42` is an int literal, `42.0` a float). The
|
|
171
|
+
* networked driver unwraps the marker back to the plain number in
|
|
172
|
+
* {@link toPowdbParam}, so the wire param is unchanged.
|
|
173
|
+
*/
|
|
174
|
+
param(value, params, col) {
|
|
175
|
+
const tagged = col && typeof value === 'number' && this.isFloatCol(col) ? new powdb_js_1.PowdbFloatParam(value) : value;
|
|
176
|
+
params.push(tagged);
|
|
177
|
+
return `$${params.length}`;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Render a value for a write *assignment* (`col := …`). Every value — float
|
|
181
|
+
* columns included — is sent as a positional `$N` param. PowDB ≥ 0.7.0 fixed
|
|
182
|
+
* the int→float UPDATE coercion bug (`score := $n` with an integer param now
|
|
183
|
+
* reads back the integer value, not the raw i64 bits), so the float-literal
|
|
184
|
+
* inlining workaround Turbine carried for ≤ 0.6.2 is gone. Marks the column as
|
|
185
|
+
* float-typed for the *embedded* literal encoder (which materializes params
|
|
186
|
+
* into PowQL text) so an integer-valued float still encodes as a float literal
|
|
187
|
+
* and coercion stays unambiguous. Non-finite floats are still rejected.
|
|
188
|
+
*/
|
|
189
|
+
writeRef(value, col, params) {
|
|
190
|
+
if (typeof value === 'number' && !Number.isFinite(value) && this.isFloatCol(col)) {
|
|
191
|
+
throw new errors_js_1.ValidationError(`[turbine] Non-finite value for float column "${col.name}" on "${this.table}".`);
|
|
192
|
+
}
|
|
193
|
+
return this.param(value, params, col);
|
|
194
|
+
}
|
|
195
|
+
isFloatCol(col) {
|
|
196
|
+
try {
|
|
197
|
+
return (0, powdb_js_1.powqlColumnType)(col) === 'float';
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/** A predicate that is always false — the empty-`in` / contradiction sentinel. */
|
|
204
|
+
alwaysFalse() {
|
|
205
|
+
const pk = this.meta.primaryKey[0] ?? this.meta.columns[0]?.name;
|
|
206
|
+
return `(.${pk} is null and .${pk} is not null)`;
|
|
207
|
+
}
|
|
208
|
+
// -------------------------------------------------------------------------
|
|
209
|
+
// WHERE builder
|
|
210
|
+
// -------------------------------------------------------------------------
|
|
211
|
+
/**
|
|
212
|
+
* Compile a {@link WhereClause} into a PowQL filter expression, pushing every
|
|
213
|
+
* value as a positional `$N` param. Returns `''` when there are no conditions.
|
|
214
|
+
*/
|
|
215
|
+
buildWhere(where, params) {
|
|
216
|
+
if (!where)
|
|
217
|
+
return '';
|
|
218
|
+
const parts = [];
|
|
219
|
+
for (const [key, value] of Object.entries(where)) {
|
|
220
|
+
if (value === undefined)
|
|
221
|
+
continue;
|
|
222
|
+
if (key === 'AND') {
|
|
223
|
+
const sub = value.map((w) => this.buildWhere(w, params)).filter(Boolean);
|
|
224
|
+
if (sub.length)
|
|
225
|
+
parts.push(`(${sub.join(' and ')})`);
|
|
226
|
+
}
|
|
227
|
+
else if (key === 'OR') {
|
|
228
|
+
const sub = value.map((w) => this.buildWhere(w, params)).filter(Boolean);
|
|
229
|
+
if (sub.length)
|
|
230
|
+
parts.push(`(${sub.join(' or ')})`);
|
|
231
|
+
}
|
|
232
|
+
else if (key === 'NOT') {
|
|
233
|
+
const sub = this.buildWhere(value, params);
|
|
234
|
+
if (sub)
|
|
235
|
+
parts.push(`not (${sub})`);
|
|
236
|
+
}
|
|
237
|
+
else if (this.meta.relations[key]) {
|
|
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()).`);
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
parts.push(this.buildFieldCondition(key, value, params));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return parts.join(' and ');
|
|
248
|
+
}
|
|
249
|
+
/** Build a single `field: value | operator` condition. */
|
|
250
|
+
buildFieldCondition(field, value, params) {
|
|
251
|
+
const ref = this.ref(field);
|
|
252
|
+
if (value === null)
|
|
253
|
+
return `${ref} is null`;
|
|
254
|
+
if (value instanceof Date || typeof value !== 'object') {
|
|
255
|
+
return `${ref} = ${this.param(value, params)}`;
|
|
256
|
+
}
|
|
257
|
+
const op = value;
|
|
258
|
+
rejectUnsupportedFilter(op, field);
|
|
259
|
+
if (!Object.keys(op).some((k) => OPERATOR_KEYS.has(k))) {
|
|
260
|
+
// A bare object that is not an operator set — equality by value.
|
|
261
|
+
return `${ref} = ${this.param(value, params)}`;
|
|
262
|
+
}
|
|
263
|
+
const insensitive = op.mode === 'insensitive';
|
|
264
|
+
const lhs = insensitive ? `lower(${ref})` : ref;
|
|
265
|
+
const conds = [];
|
|
266
|
+
for (const [opName, opVal] of Object.entries(op)) {
|
|
267
|
+
if (opVal === undefined || opName === 'mode')
|
|
268
|
+
continue;
|
|
269
|
+
switch (opName) {
|
|
270
|
+
case 'equals':
|
|
271
|
+
conds.push(opVal === null ? `${ref} is null` : `${lhs} = ${this.bind(opVal, params, insensitive)}`);
|
|
272
|
+
break;
|
|
273
|
+
case 'not':
|
|
274
|
+
conds.push(opVal === null ? `${ref} is not null` : `not (${lhs} = ${this.bind(opVal, params, insensitive)})`);
|
|
275
|
+
break;
|
|
276
|
+
case 'gt':
|
|
277
|
+
conds.push(`${lhs} > ${this.bind(opVal, params, insensitive)}`);
|
|
278
|
+
break;
|
|
279
|
+
case 'gte':
|
|
280
|
+
conds.push(`${lhs} >= ${this.bind(opVal, params, insensitive)}`);
|
|
281
|
+
break;
|
|
282
|
+
case 'lt':
|
|
283
|
+
conds.push(`${lhs} < ${this.bind(opVal, params, insensitive)}`);
|
|
284
|
+
break;
|
|
285
|
+
case 'lte':
|
|
286
|
+
conds.push(`${lhs} <= ${this.bind(opVal, params, insensitive)}`);
|
|
287
|
+
break;
|
|
288
|
+
case 'in':
|
|
289
|
+
conds.push(this.buildInList(lhs, opVal, params, insensitive, false));
|
|
290
|
+
break;
|
|
291
|
+
case 'notIn':
|
|
292
|
+
conds.push(this.buildInList(lhs, opVal, params, insensitive, true));
|
|
293
|
+
break;
|
|
294
|
+
case 'contains':
|
|
295
|
+
conds.push(`${lhs} like ${this.bindLike(`%${(0, utils_js_1.escapeLike)(String(opVal))}%`, params, insensitive)}`);
|
|
296
|
+
break;
|
|
297
|
+
case 'startsWith':
|
|
298
|
+
conds.push(`${lhs} like ${this.bindLike(`${(0, utils_js_1.escapeLike)(String(opVal))}%`, params, insensitive)}`);
|
|
299
|
+
break;
|
|
300
|
+
case 'endsWith':
|
|
301
|
+
conds.push(`${lhs} like ${this.bindLike(`%${(0, utils_js_1.escapeLike)(String(opVal))}`, params, insensitive)}`);
|
|
302
|
+
break;
|
|
303
|
+
default:
|
|
304
|
+
throw new errors_js_1.ValidationError(`[turbine] Unsupported operator "${opName}" on PowDB.`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return conds.length > 1 ? `(${conds.join(' and ')})` : (conds[0] ?? this.alwaysFalse());
|
|
308
|
+
}
|
|
309
|
+
/** Bind a value, lowercasing for case-insensitive comparisons. */
|
|
310
|
+
bind(value, params, insensitive) {
|
|
311
|
+
const ph = this.param(value, params);
|
|
312
|
+
return insensitive ? `lower(${ph})` : ph;
|
|
313
|
+
}
|
|
314
|
+
/** Bind a LIKE pattern (already escaped), lowercasing for insensitive mode. */
|
|
315
|
+
bindLike(pattern, params, insensitive) {
|
|
316
|
+
const ph = this.param(pattern, params);
|
|
317
|
+
return insensitive ? `lower(${ph})` : ph;
|
|
318
|
+
}
|
|
319
|
+
/** `lhs [not] in ($1, $2, …)` — empty list collapses to a constant. */
|
|
320
|
+
buildInList(lhs, values, params, insensitive, negate) {
|
|
321
|
+
if (!Array.isArray(values) || values.length === 0) {
|
|
322
|
+
// `in []` matches nothing; `not in []` matches everything.
|
|
323
|
+
return negate ? '(1 = 1)' : this.alwaysFalse();
|
|
324
|
+
}
|
|
325
|
+
const items = values.map((v) => this.bind(v, params, insensitive)).join(', ');
|
|
326
|
+
return `${lhs} ${negate ? 'not in' : 'in'} (${items})`;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
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.
|
|
342
|
+
*/
|
|
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;
|
|
376
|
+
if (rel.type === 'manyToMany') {
|
|
377
|
+
return this.resolveManyToManyCondition(rel, mode, innerWhere, innerEmpty, timeout);
|
|
378
|
+
}
|
|
379
|
+
const fk = (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey);
|
|
380
|
+
const rk = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey);
|
|
381
|
+
if (fk.length > 1 || rk.length > 1) {
|
|
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`);
|
|
383
|
+
}
|
|
384
|
+
const targetMeta = this.schema.tables[rel.to];
|
|
385
|
+
if (!targetMeta)
|
|
386
|
+
throw new errors_js_1.ValidationError(`[turbine] Relation "${rel.name}" targets unknown table "${rel.to}".`);
|
|
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];
|
|
458
|
+
};
|
|
459
|
+
if (mode === 'some') {
|
|
460
|
+
return { [sourceRefField]: { in: await sourcesForTargets(await collectTargetPks(innerWhere)) } };
|
|
461
|
+
}
|
|
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 } };
|
|
470
|
+
}
|
|
471
|
+
// -------------------------------------------------------------------------
|
|
472
|
+
// Projection / order
|
|
473
|
+
// -------------------------------------------------------------------------
|
|
474
|
+
/** Resolve the set of columns to project, honouring `select` / `omit`. */
|
|
475
|
+
projectedColumns(select, omit) {
|
|
476
|
+
let cols = this.meta.columns.map((c) => c.name);
|
|
477
|
+
if (select && Object.keys(select).length) {
|
|
478
|
+
const picked = new Set(Object.entries(select)
|
|
479
|
+
.filter(([, v]) => v)
|
|
480
|
+
.map(([k]) => this.column(k).name));
|
|
481
|
+
// Always keep the PK so reselect / relation stitching has a key to work with.
|
|
482
|
+
for (const pk of this.meta.primaryKey)
|
|
483
|
+
picked.add(pk);
|
|
484
|
+
cols = cols.filter((c) => picked.has(c));
|
|
485
|
+
}
|
|
486
|
+
if (omit && Object.keys(omit).length) {
|
|
487
|
+
const dropped = new Set(Object.entries(omit)
|
|
488
|
+
.filter(([, v]) => v)
|
|
489
|
+
.map(([k]) => this.column(k).name));
|
|
490
|
+
cols = cols.filter((c) => !dropped.has(c));
|
|
491
|
+
}
|
|
492
|
+
return cols;
|
|
493
|
+
}
|
|
494
|
+
/** `{ .c1, .c2, … }` projection clause. */
|
|
495
|
+
projection(cols) {
|
|
496
|
+
return `{ ${cols.map((c) => `.${c}`).join(', ')} }`;
|
|
497
|
+
}
|
|
498
|
+
/** `order .c1 asc, .c2 desc` clause (empty string when no orderBy). */
|
|
499
|
+
buildOrder(orderBy) {
|
|
500
|
+
if (!orderBy)
|
|
501
|
+
return '';
|
|
502
|
+
const keys = Object.entries(orderBy).filter(([, dir]) => dir !== undefined);
|
|
503
|
+
if (!keys.length)
|
|
504
|
+
return '';
|
|
505
|
+
const parts = keys.map(([field, dir]) => {
|
|
506
|
+
if (dir && typeof dir === 'object') {
|
|
507
|
+
throw new errors_js_1.UnsupportedFeatureError('vector / distance ordering', 'PowDB', `field "${field}"`);
|
|
508
|
+
}
|
|
509
|
+
return `${this.ref(field)} ${dir === 'desc' ? 'desc' : 'asc'}`;
|
|
510
|
+
});
|
|
511
|
+
return ` order ${parts.join(', ')}`;
|
|
512
|
+
}
|
|
513
|
+
// -------------------------------------------------------------------------
|
|
514
|
+
// Execution plumbing
|
|
515
|
+
// -------------------------------------------------------------------------
|
|
516
|
+
/** Run PowQL with optional timeout, emitting a query event either way. */
|
|
517
|
+
async exec(powql, params, timeout) {
|
|
518
|
+
const start = performance.now();
|
|
519
|
+
const run = this.pool.query(powql, params);
|
|
520
|
+
try {
|
|
521
|
+
const result = timeout
|
|
522
|
+
? await Promise.race([
|
|
523
|
+
run,
|
|
524
|
+
new Promise((_, reject) => setTimeout(() => reject(new errors_js_1.TimeoutError(timeout)), timeout)),
|
|
525
|
+
])
|
|
526
|
+
: await run;
|
|
527
|
+
this.emit(powql, params, performance.now() - start, result.rowCount ?? result.rows.length);
|
|
528
|
+
return result;
|
|
529
|
+
}
|
|
530
|
+
catch (err) {
|
|
531
|
+
this.emit(powql, params, performance.now() - start, 0, err);
|
|
532
|
+
throw err;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
emit(sql, params, duration, rows, error) {
|
|
536
|
+
if (!this.onQuery)
|
|
537
|
+
return;
|
|
538
|
+
try {
|
|
539
|
+
this.onQuery({
|
|
540
|
+
sql,
|
|
541
|
+
params,
|
|
542
|
+
duration,
|
|
543
|
+
model: this.table,
|
|
544
|
+
action: this.currentAction,
|
|
545
|
+
rows,
|
|
546
|
+
timestamp: new Date(),
|
|
547
|
+
error,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
catch {
|
|
551
|
+
/* listener errors never crash a query */
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
/** Run a method body through the middleware chain (mirrors QueryInterface). */
|
|
555
|
+
async withMiddleware(action, args, executor) {
|
|
556
|
+
this.currentAction = action;
|
|
557
|
+
if (this.middlewares.length === 0)
|
|
558
|
+
return executor();
|
|
559
|
+
let index = 0;
|
|
560
|
+
const next = async (p) => {
|
|
561
|
+
if (index < this.middlewares.length)
|
|
562
|
+
return this.middlewares[index++](p, next);
|
|
563
|
+
return executor();
|
|
564
|
+
};
|
|
565
|
+
return next({ model: this.table, action, args: { ...args } });
|
|
566
|
+
}
|
|
567
|
+
/** Map raw rows to typed entities. */
|
|
568
|
+
shape(rows) {
|
|
569
|
+
return rows.map((r) => (0, powdb_js_1.rowToEntity)(r, this.meta));
|
|
570
|
+
}
|
|
571
|
+
// -------------------------------------------------------------------------
|
|
572
|
+
// Reads
|
|
573
|
+
// -------------------------------------------------------------------------
|
|
574
|
+
async findMany(args = {}) {
|
|
575
|
+
return this.withMiddleware('findMany', args, async () => {
|
|
576
|
+
const rows = await this.runFind(args);
|
|
577
|
+
const entities = this.shape(rows);
|
|
578
|
+
if (args.with)
|
|
579
|
+
await this.loadRelations(entities, args.with, args.timeout);
|
|
580
|
+
return entities;
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
/** Build + run the flat findMany select; returns raw rows. */
|
|
584
|
+
async runFind(args) {
|
|
585
|
+
if (args.cursor) {
|
|
586
|
+
throw new errors_js_1.UnsupportedFeatureError('cursor pagination', 'PowDB', 'use limit/offset instead');
|
|
587
|
+
}
|
|
588
|
+
const params = [];
|
|
589
|
+
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
590
|
+
const where = this.buildWhere(resolvedWhere, params);
|
|
591
|
+
const cols = this.projectedColumns(args.select, args.omit);
|
|
592
|
+
const distinct = args.distinct?.length ? ' distinct' : '';
|
|
593
|
+
const filter = where ? ` filter ${where}` : '';
|
|
594
|
+
const order = this.buildOrder(args.orderBy);
|
|
595
|
+
const limit = args.limit ?? args.take ?? this.defaultLimit;
|
|
596
|
+
if (limit === undefined && this.warnOnUnlimited && !this.warnedUnlimited) {
|
|
597
|
+
this.warnedUnlimited = true;
|
|
598
|
+
console.warn(`[turbine] findMany on "${this.table}" has no limit — this scans the whole table.`);
|
|
599
|
+
}
|
|
600
|
+
const limitClause = limit !== undefined ? ` limit ${this.param(limit, params)}` : '';
|
|
601
|
+
const offsetClause = args.offset ? ` offset ${this.param(args.offset, params)}` : '';
|
|
602
|
+
const powql = `${this.table}${distinct}${filter}${order}${limitClause}${offsetClause} ${this.projection(cols)}`;
|
|
603
|
+
const { rows } = await this.exec(powql, params, args.timeout);
|
|
604
|
+
return rows;
|
|
605
|
+
}
|
|
606
|
+
async findUnique(args) {
|
|
607
|
+
return this.withMiddleware('findUnique', args, async () => {
|
|
608
|
+
const rows = await this.runFind({ ...args, limit: 1 });
|
|
609
|
+
if (!rows.length)
|
|
610
|
+
return null;
|
|
611
|
+
const entities = this.shape(rows);
|
|
612
|
+
if (args.with)
|
|
613
|
+
await this.loadRelations(entities, args.with, args.timeout);
|
|
614
|
+
return entities[0];
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
async findFirst(args = {}) {
|
|
618
|
+
return this.withMiddleware('findFirst', args, async () => {
|
|
619
|
+
const rows = await this.runFind({ ...args, limit: 1 });
|
|
620
|
+
if (!rows.length)
|
|
621
|
+
return null;
|
|
622
|
+
const entities = this.shape(rows);
|
|
623
|
+
if (args.with)
|
|
624
|
+
await this.loadRelations(entities, args.with, args.timeout);
|
|
625
|
+
return entities[0];
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
async findUniqueOrThrow(args) {
|
|
629
|
+
const row = await this.findUnique(args);
|
|
630
|
+
if (!row)
|
|
631
|
+
throw new errors_js_1.NotFoundError({ table: this.table, where: args.where });
|
|
632
|
+
return row;
|
|
633
|
+
}
|
|
634
|
+
async findFirstOrThrow(args = {}) {
|
|
635
|
+
const row = await this.findFirst(args);
|
|
636
|
+
if (!row)
|
|
637
|
+
throw new errors_js_1.NotFoundError({ table: this.table, where: (args.where ?? {}) });
|
|
638
|
+
return row;
|
|
639
|
+
}
|
|
640
|
+
// -------------------------------------------------------------------------
|
|
641
|
+
// Nested relations — batched N+1 loaders (hasMany / hasOne / belongsTo)
|
|
642
|
+
// -------------------------------------------------------------------------
|
|
643
|
+
/** Load each requested relation for `parents` and attach it onto each row. */
|
|
644
|
+
async loadRelations(parents, withClause, timeout, depth = 0) {
|
|
645
|
+
if (depth >= 10) {
|
|
646
|
+
throw new errors_js_1.ValidationError(`[turbine] Nested 'with' on PowDB exceeded depth 10 (relation cycle?).`);
|
|
647
|
+
}
|
|
648
|
+
if (!parents.length)
|
|
649
|
+
return;
|
|
650
|
+
for (const [relName, opt] of Object.entries(withClause)) {
|
|
651
|
+
if (!opt)
|
|
652
|
+
continue;
|
|
653
|
+
const rel = this.meta.relations[relName];
|
|
654
|
+
if (!rel)
|
|
655
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown relation "${relName}" on "${this.table}".`);
|
|
656
|
+
if (rel.type === 'manyToMany') {
|
|
657
|
+
await this.loadManyToMany(parents, rel, relName, opt, timeout);
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
const fk = (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey);
|
|
661
|
+
const rk = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey);
|
|
662
|
+
if (fk.length > 1 || rk.length > 1) {
|
|
663
|
+
throw new errors_js_1.UnsupportedFeatureError('composite-key nested reads', 'PowDB', `relation "${relName}"`);
|
|
664
|
+
}
|
|
665
|
+
const options = (opt === true ? {} : opt);
|
|
666
|
+
const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
|
|
667
|
+
const targetMeta = this.schema.tables[rel.to];
|
|
668
|
+
// Local key on the parent, remote key on the target child.
|
|
669
|
+
const parentKeyCol = rel.type === 'belongsTo' ? fk[0] : rk[0];
|
|
670
|
+
const childKeyCol = rel.type === 'belongsTo' ? rk[0] : fk[0];
|
|
671
|
+
const parentKeyField = this.meta.reverseColumnMap[parentKeyCol] ?? parentKeyCol;
|
|
672
|
+
const childKeyField = targetMeta.reverseColumnMap[childKeyCol] ?? childKeyCol;
|
|
673
|
+
const keys = [
|
|
674
|
+
...new Set(parents.map((p) => p[parentKeyField]).filter((k) => k != null)),
|
|
675
|
+
];
|
|
676
|
+
const childByKey = new Map();
|
|
677
|
+
// Chunk the key set so a single `in (…)` never exceeds PowDB's
|
|
678
|
+
// per-statement param / row limits; merge each chunk's children.
|
|
679
|
+
for (let i = 0; i < keys.length; i += MAX_RELATION_KEYS) {
|
|
680
|
+
const chunk = keys.slice(i, i + MAX_RELATION_KEYS);
|
|
681
|
+
const childWhere = {
|
|
682
|
+
...options.where,
|
|
683
|
+
[childKeyField]: { in: chunk },
|
|
684
|
+
};
|
|
685
|
+
const children = (await targetQi.findMany({
|
|
686
|
+
...options,
|
|
687
|
+
where: childWhere,
|
|
688
|
+
with: options.with,
|
|
689
|
+
timeout: options.timeout ?? timeout,
|
|
690
|
+
}));
|
|
691
|
+
for (const child of children) {
|
|
692
|
+
const k = child[childKeyField];
|
|
693
|
+
const bucket = childByKey.get(k);
|
|
694
|
+
if (bucket)
|
|
695
|
+
bucket.push(child);
|
|
696
|
+
else
|
|
697
|
+
childByKey.set(k, [child]);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
|
|
701
|
+
for (const parent of parents) {
|
|
702
|
+
const k = parent[parentKeyField];
|
|
703
|
+
const matches = childByKey.get(k) ?? [];
|
|
704
|
+
parent[relName] = single ? (matches[0] ?? null) : matches;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
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
|
+
}
|
|
797
|
+
// -------------------------------------------------------------------------
|
|
798
|
+
// Writes (reselect — PowDB has no RETURNING)
|
|
799
|
+
// -------------------------------------------------------------------------
|
|
800
|
+
/** Split `data` into scalar assignments; reject relation (nested-write) keys. */
|
|
801
|
+
scalarData(data) {
|
|
802
|
+
const out = [];
|
|
803
|
+
for (const [field, value] of Object.entries(data)) {
|
|
804
|
+
if (value === undefined)
|
|
805
|
+
continue;
|
|
806
|
+
if (this.meta.relations[field]) {
|
|
807
|
+
throw new errors_js_1.UnsupportedFeatureError('nested writes', 'PowDB', `relation "${field}" — nested writes need create()/update(), not createMany()/upsert()`);
|
|
808
|
+
}
|
|
809
|
+
out.push({ col: this.column(field), value });
|
|
810
|
+
}
|
|
811
|
+
return out;
|
|
812
|
+
}
|
|
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
|
+
*/
|
|
819
|
+
applyPkDefault(data) {
|
|
820
|
+
const out = { ...data };
|
|
821
|
+
for (const pk of this.meta.primaryKey) {
|
|
822
|
+
const field = this.meta.reverseColumnMap[pk] ?? pk;
|
|
823
|
+
const col = this.meta.columns.find((c) => c.name === pk);
|
|
824
|
+
if (out[field] == null &&
|
|
825
|
+
col?.hasDefault &&
|
|
826
|
+
!col.isGenerated &&
|
|
827
|
+
col.tsType.replace(/\s*\|\s*null$/, '').trim() === 'string') {
|
|
828
|
+
out[field] = (0, node_crypto_1.randomUUID)();
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
return out;
|
|
832
|
+
}
|
|
833
|
+
async create(args) {
|
|
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
|
+
}
|
|
838
|
+
const data = this.applyPkDefault(args.data);
|
|
839
|
+
const assigns = this.scalarData(data);
|
|
840
|
+
const params = [];
|
|
841
|
+
const body = assigns.map((a) => `${a.col.name} := ${this.writeRef(a.value, a.col, params)}`).join(', ');
|
|
842
|
+
// `returning` surfaces the inserted row (all columns, schema order) in one round-trip.
|
|
843
|
+
const { rows } = await this.exec(`insert ${this.table} { ${body} } returning`, params, args.timeout);
|
|
844
|
+
const row = rows.length ? this.shape(rows)[0] : null;
|
|
845
|
+
if (!row)
|
|
846
|
+
throw new errors_js_1.NotFoundError({ table: this.table, where: data });
|
|
847
|
+
return row;
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
async createMany(args) {
|
|
851
|
+
return this.withMiddleware('createMany', args, async () => {
|
|
852
|
+
const inputs = args.data.map((d) => this.applyPkDefault(d));
|
|
853
|
+
if (!inputs.length)
|
|
854
|
+
return [];
|
|
855
|
+
const params = [];
|
|
856
|
+
const tuples = inputs.map((d) => {
|
|
857
|
+
const assigns = this.scalarData(d);
|
|
858
|
+
return `{ ${assigns.map((a) => `${a.col.name} := ${this.writeRef(a.value, a.col, params)}`).join(', ')} }`;
|
|
859
|
+
});
|
|
860
|
+
// Multi-row insert with `returning` hands back every inserted row in one round-trip.
|
|
861
|
+
const { rows } = await this.exec(`insert ${this.table} ${tuples.join(', ')} returning`, params, args.timeout);
|
|
862
|
+
return this.shape(rows);
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
async update(args) {
|
|
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
|
+
}
|
|
870
|
+
const params = [];
|
|
871
|
+
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
872
|
+
const where = this.buildWhere(resolvedWhere, params);
|
|
873
|
+
this.assertCompiledWhere(where, false, 'update');
|
|
874
|
+
const setClause = this.buildUpdateAssignments(args.data, params);
|
|
875
|
+
// `returning` hands back the post-update row(s); take the first (single-row contract).
|
|
876
|
+
const { rows } = await this.exec(`${this.table} filter ${where} update { ${setClause} } returning`, params, args.timeout);
|
|
877
|
+
const row = rows.length ? this.shape(rows)[0] : null;
|
|
878
|
+
if (!row)
|
|
879
|
+
throw new errors_js_1.NotFoundError({ table: this.table, where: args.where });
|
|
880
|
+
return row;
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
async updateMany(args) {
|
|
884
|
+
return this.withMiddleware('updateMany', args, async () => {
|
|
885
|
+
const params = [];
|
|
886
|
+
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
887
|
+
const where = this.buildWhere(resolvedWhere, params);
|
|
888
|
+
this.assertCompiledWhere(where, args.allowFullTableScan, 'updateMany');
|
|
889
|
+
const setClause = this.buildUpdateAssignments(args.data, params);
|
|
890
|
+
const filter = where ? ` filter ${where}` : '';
|
|
891
|
+
const { rowCount } = await this.exec(`${this.table}${filter} update { ${setClause} }`, params, args.timeout);
|
|
892
|
+
return { count: rowCount };
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
/** Compile `data` into PowQL update assignments, including atomic operators. */
|
|
896
|
+
buildUpdateAssignments(data, params) {
|
|
897
|
+
const parts = [];
|
|
898
|
+
for (const [field, value] of Object.entries(data)) {
|
|
899
|
+
if (value === undefined)
|
|
900
|
+
continue;
|
|
901
|
+
if (this.meta.relations[field]) {
|
|
902
|
+
throw new errors_js_1.UnsupportedFeatureError('nested writes', 'PowDB', `relation "${field}" — nested writes need create()/update(), not updateMany()/upsert()`);
|
|
903
|
+
}
|
|
904
|
+
const colMeta = this.column(field);
|
|
905
|
+
const ref = this.ref(field);
|
|
906
|
+
const col = colMeta.name;
|
|
907
|
+
if (value !== null && typeof value === 'object' && !(value instanceof Date)) {
|
|
908
|
+
const opObj = value;
|
|
909
|
+
if ('set' in opObj)
|
|
910
|
+
parts.push(`${col} := ${this.writeRef(opObj.set, colMeta, params)}`);
|
|
911
|
+
else if ('increment' in opObj)
|
|
912
|
+
parts.push(`${col} := ${ref} + ${this.writeRef(opObj.increment, colMeta, params)}`);
|
|
913
|
+
else if ('decrement' in opObj)
|
|
914
|
+
parts.push(`${col} := ${ref} - ${this.writeRef(opObj.decrement, colMeta, params)}`);
|
|
915
|
+
else if ('multiply' in opObj)
|
|
916
|
+
parts.push(`${col} := ${ref} * ${this.writeRef(opObj.multiply, colMeta, params)}`);
|
|
917
|
+
else if ('divide' in opObj)
|
|
918
|
+
parts.push(`${col} := ${ref} / ${this.writeRef(opObj.divide, colMeta, params)}`);
|
|
919
|
+
else
|
|
920
|
+
throw new errors_js_1.ValidationError(`[turbine] Unsupported update operator on "${field}".`);
|
|
921
|
+
}
|
|
922
|
+
else {
|
|
923
|
+
parts.push(`${col} := ${this.writeRef(value, colMeta, params)}`);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
if (!parts.length)
|
|
927
|
+
throw new errors_js_1.ValidationError(`[turbine] update on "${this.table}" has no fields to set.`);
|
|
928
|
+
return parts.join(', ');
|
|
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
|
+
}
|
|
994
|
+
async delete(args) {
|
|
995
|
+
return this.withMiddleware('delete', args, async () => {
|
|
996
|
+
const params = [];
|
|
997
|
+
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
998
|
+
const where = this.buildWhere(resolvedWhere, params);
|
|
999
|
+
this.assertCompiledWhere(where, false, 'delete');
|
|
1000
|
+
// `returning` hands back the deleted row(s) — no separate pre-image reselect needed.
|
|
1001
|
+
const { rows } = await this.exec(`${this.table} filter ${where} delete returning`, params, args.timeout);
|
|
1002
|
+
const row = rows.length ? this.shape(rows)[0] : null;
|
|
1003
|
+
if (!row)
|
|
1004
|
+
throw new errors_js_1.NotFoundError({ table: this.table, where: args.where });
|
|
1005
|
+
return row;
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
async deleteMany(args) {
|
|
1009
|
+
return this.withMiddleware('deleteMany', args, async () => {
|
|
1010
|
+
const params = [];
|
|
1011
|
+
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
1012
|
+
const where = this.buildWhere(resolvedWhere, params);
|
|
1013
|
+
this.assertCompiledWhere(where, args.allowFullTableScan, 'deleteMany');
|
|
1014
|
+
const filter = where ? ` filter ${where}` : '';
|
|
1015
|
+
const { rowCount } = await this.exec(`${this.table}${filter} delete`, params, args.timeout);
|
|
1016
|
+
return { count: rowCount };
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
async upsert(args) {
|
|
1020
|
+
return this.withMiddleware('upsert', args, async () => {
|
|
1021
|
+
const createData = this.applyPkDefault(args.create);
|
|
1022
|
+
const pkCol = this.meta.primaryKey[0];
|
|
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);
|
|
1027
|
+
}
|
|
1028
|
+
const params = [];
|
|
1029
|
+
const createBody = this.scalarData(createData)
|
|
1030
|
+
.map((a) => `${a.col.name} := ${this.writeRef(a.value, a.col, params)}`)
|
|
1031
|
+
.join(', ');
|
|
1032
|
+
const updateBody = this.buildUpdateAssignments(args.update, params);
|
|
1033
|
+
// PowDB 0.7.0's `upsert` statement does NOT accept a trailing `returning`
|
|
1034
|
+
// (verified: "unexpected trailing token … 'returning'"), because it is one
|
|
1035
|
+
// atomic insert-or-update, not two branches. So upsert alone keeps the
|
|
1036
|
+
// reselect-by-PK fetch; create/update/delete all use `returning`.
|
|
1037
|
+
await this.exec(`upsert ${this.table} on .${pkCol} { ${createBody} } on conflict { ${updateBody} }`, params, args.timeout);
|
|
1038
|
+
const pkField = this.meta.reverseColumnMap[pkCol] ?? pkCol;
|
|
1039
|
+
const row = await this.reselectByPk(createData[pkField], args.timeout);
|
|
1040
|
+
if (!row)
|
|
1041
|
+
throw new errors_js_1.NotFoundError({ table: this.table, where: createData });
|
|
1042
|
+
return row;
|
|
1043
|
+
});
|
|
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
|
+
}
|
|
1073
|
+
// -------------------------------------------------------------------------
|
|
1074
|
+
// Aggregates
|
|
1075
|
+
// -------------------------------------------------------------------------
|
|
1076
|
+
async count(args = {}) {
|
|
1077
|
+
return this.withMiddleware('count', (args ?? {}), async () => {
|
|
1078
|
+
const params = [];
|
|
1079
|
+
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
1080
|
+
const where = this.buildWhere(resolvedWhere, params);
|
|
1081
|
+
const filter = where ? ` filter ${where}` : '';
|
|
1082
|
+
const { rows } = await this.exec(`count(${this.table}${filter})`, params, args.timeout);
|
|
1083
|
+
return Number((rows[0]?.value ?? rows[0]?.count ?? 0));
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
async aggregate(args) {
|
|
1087
|
+
return this.withMiddleware('aggregate', args, async () => {
|
|
1088
|
+
// One scalar query per aggregate — PowDB's bare-projection aggregate is broken.
|
|
1089
|
+
const result = {};
|
|
1090
|
+
const filterParams = [];
|
|
1091
|
+
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
1092
|
+
const where = this.buildWhere(resolvedWhere, filterParams);
|
|
1093
|
+
const filter = where ? ` filter ${where}` : '';
|
|
1094
|
+
const scalar = async (expr) => {
|
|
1095
|
+
const params = [...filterParams];
|
|
1096
|
+
const { rows } = await this.exec(expr, params, args.timeout);
|
|
1097
|
+
const v = rows[0]?.value;
|
|
1098
|
+
return v == null || v === 'null' ? null : Number(v);
|
|
1099
|
+
};
|
|
1100
|
+
if (args._count) {
|
|
1101
|
+
if (args._count === true) {
|
|
1102
|
+
result._count = (await scalar(`count(${this.table}${filter})`)) ?? 0;
|
|
1103
|
+
}
|
|
1104
|
+
else {
|
|
1105
|
+
const counts = {};
|
|
1106
|
+
for (const field of Object.keys(args._count).filter((f) => args._count[f])) {
|
|
1107
|
+
counts[field] = (await scalar(`count(${this.table}${filter} { ${this.ref(field)} })`)) ?? 0;
|
|
1108
|
+
}
|
|
1109
|
+
result._count = counts;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
for (const fn of ['_sum', '_avg', '_min', '_max']) {
|
|
1113
|
+
const spec = args[fn];
|
|
1114
|
+
if (!spec)
|
|
1115
|
+
continue;
|
|
1116
|
+
const acc = {};
|
|
1117
|
+
for (const field of Object.keys(spec).filter((f) => spec[f])) {
|
|
1118
|
+
const powfn = fn.slice(1); // sum/avg/min/max
|
|
1119
|
+
acc[field] = await scalar(`${powfn}(${this.table}${filter} { ${this.ref(field)} })`);
|
|
1120
|
+
}
|
|
1121
|
+
result[fn] = acc;
|
|
1122
|
+
}
|
|
1123
|
+
return result;
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
async groupBy(args) {
|
|
1127
|
+
return this.withMiddleware('groupBy', args, async () => {
|
|
1128
|
+
const params = [];
|
|
1129
|
+
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
1130
|
+
const where = this.buildWhere(resolvedWhere, params);
|
|
1131
|
+
const filter = where ? ` filter ${where}` : '';
|
|
1132
|
+
const groupKeys = args.by.map((f) => this.ref(f));
|
|
1133
|
+
// Safe aliases — PowQL rejects reserved-word aliases (e.g. `count:`).
|
|
1134
|
+
const aliasMap = [];
|
|
1135
|
+
let n = 0;
|
|
1136
|
+
const proj = args.by.map((f) => this.ref(f));
|
|
1137
|
+
if (args._count) {
|
|
1138
|
+
aliasMap.push({ alias: `agg_${n++}`, fn: 'count', field: null, outKey: '_count' });
|
|
1139
|
+
}
|
|
1140
|
+
for (const fn of ['_sum', '_avg', '_min', '_max']) {
|
|
1141
|
+
const spec = args[fn];
|
|
1142
|
+
if (!spec)
|
|
1143
|
+
continue;
|
|
1144
|
+
for (const field of Object.keys(spec).filter((f) => spec[f])) {
|
|
1145
|
+
aliasMap.push({ alias: `agg_${n++}`, fn: fn.slice(1), field, outKey: `${fn}:${field}` });
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
for (const a of aliasMap) {
|
|
1149
|
+
proj.push(`${a.alias}: ${a.fn}(${a.field ? this.ref(a.field) : `.${this.meta.primaryKey[0]}`})`);
|
|
1150
|
+
}
|
|
1151
|
+
const having = this.buildHaving(args.having, params);
|
|
1152
|
+
const order = this.buildOrder(args.orderBy);
|
|
1153
|
+
const powql = `${this.table}${filter} group ${groupKeys.join(', ')}${having}${order} { ${proj.join(', ')} }`;
|
|
1154
|
+
const { rows } = await this.exec(powql, params, args.timeout);
|
|
1155
|
+
// Reshape: group keys → camel fields + coerced; aggregates → nested {_sum:{field}}.
|
|
1156
|
+
return rows.map((raw) => {
|
|
1157
|
+
const out = {};
|
|
1158
|
+
for (const f of args.by) {
|
|
1159
|
+
const col = this.column(f);
|
|
1160
|
+
out[f] =
|
|
1161
|
+
typeof raw[col.name] === 'string' ? coerceScalar(raw[col.name], col.tsType) : raw[col.name];
|
|
1162
|
+
}
|
|
1163
|
+
for (const a of aliasMap) {
|
|
1164
|
+
const val = raw[a.alias];
|
|
1165
|
+
const num = val == null || val === 'null' ? null : Number(val);
|
|
1166
|
+
if (a.outKey === '_count')
|
|
1167
|
+
out._count = num ?? 0;
|
|
1168
|
+
else {
|
|
1169
|
+
const [bucket, field] = a.outKey.split(':');
|
|
1170
|
+
out[bucket] ??= {};
|
|
1171
|
+
out[bucket][field] = num;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
return out;
|
|
1175
|
+
});
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
/** `having <expr>` over group aggregates (count/sum/avg/min/max). */
|
|
1179
|
+
buildHaving(having, params) {
|
|
1180
|
+
if (!having)
|
|
1181
|
+
return '';
|
|
1182
|
+
const conds = [];
|
|
1183
|
+
const cmp = (expr, filter) => {
|
|
1184
|
+
if (typeof filter === 'number')
|
|
1185
|
+
return `${expr} = ${this.param(filter, params)}`;
|
|
1186
|
+
const f = filter;
|
|
1187
|
+
const ops = { equals: '=', gt: '>', gte: '>=', lt: '<', lte: '<=', not: '!=' };
|
|
1188
|
+
return Object.entries(f)
|
|
1189
|
+
.filter(([k]) => ops[k])
|
|
1190
|
+
.map(([k, v]) => `${expr} ${ops[k]} ${this.param(v, params)}`)
|
|
1191
|
+
.join(' and ');
|
|
1192
|
+
};
|
|
1193
|
+
for (const [key, spec] of Object.entries(having)) {
|
|
1194
|
+
if (spec == null)
|
|
1195
|
+
continue;
|
|
1196
|
+
if (key === '_count') {
|
|
1197
|
+
conds.push(cmp(`count(.${this.meta.primaryKey[0]})`, spec));
|
|
1198
|
+
}
|
|
1199
|
+
else {
|
|
1200
|
+
for (const [fn, filter] of Object.entries(spec)) {
|
|
1201
|
+
if (filter == null)
|
|
1202
|
+
continue;
|
|
1203
|
+
conds.push(cmp(`${fn.slice(1)}(${this.ref(key)})`, filter));
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
return conds.length ? ` having ${conds.join(' and ')}` : '';
|
|
1208
|
+
}
|
|
1209
|
+
// -------------------------------------------------------------------------
|
|
1210
|
+
// Streaming / unsupported
|
|
1211
|
+
// -------------------------------------------------------------------------
|
|
1212
|
+
// biome-ignore lint/correctness/useYield: intentionally throws before yielding — PowDB has no server cursor.
|
|
1213
|
+
async *findManyStream() {
|
|
1214
|
+
throw new errors_js_1.UnsupportedFeatureError('cursor streaming (findManyStream)', 'PowDB', 'PowDB has no server-side cursor; page with findMany({ limit, offset }) instead');
|
|
1215
|
+
}
|
|
1216
|
+
// -------------------------------------------------------------------------
|
|
1217
|
+
// Reselect helper (upsert only — PowDB's upsert has no `returning`)
|
|
1218
|
+
// -------------------------------------------------------------------------
|
|
1219
|
+
/** Reselect a single row by its single-column primary key value. */
|
|
1220
|
+
async reselectByPk(pkValue, timeout) {
|
|
1221
|
+
const pkField = this.meta.reverseColumnMap[this.meta.primaryKey[0]] ?? this.meta.primaryKey[0];
|
|
1222
|
+
const rows = await this.runFind({
|
|
1223
|
+
where: { [pkField]: pkValue },
|
|
1224
|
+
limit: 1,
|
|
1225
|
+
timeout,
|
|
1226
|
+
});
|
|
1227
|
+
return rows.length ? this.shape(rows)[0] : null;
|
|
1228
|
+
}
|
|
1229
|
+
/**
|
|
1230
|
+
* Empty-where guard — blocks accidental whole-table writes. Mirrors the SQL
|
|
1231
|
+
* path's `assertMutationHasPredicate` (query/builder.ts): it gates on the
|
|
1232
|
+
* *compiled* PowQL filter fragment, NOT the shape of the `where` object. A
|
|
1233
|
+
* `where` whose conditions all evaporate during compilation — `{}`,
|
|
1234
|
+
* `{ id: undefined }`, `{ OR: [] }`, `{ AND: [] }`, `{ NOT: {} }`,
|
|
1235
|
+
* `{ OR: [{ f: undefined }] }` — compiles to the empty string and is refused,
|
|
1236
|
+
* because emitting a filter-less write would hit every row.
|
|
1237
|
+
*/
|
|
1238
|
+
assertCompiledWhere(compiledWhere, allow, action) {
|
|
1239
|
+
if (allow === true)
|
|
1240
|
+
return;
|
|
1241
|
+
if (compiledWhere.length > 0)
|
|
1242
|
+
return;
|
|
1243
|
+
throw new errors_js_1.ValidationError(`[turbine] ${action} on "${this.table}" refused: the \`where\` clause is empty. ` +
|
|
1244
|
+
`Pass \`allowFullTableScan: true\` to opt in, or check that your filter values are defined.`);
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
exports.PowqlInterface = PowqlInterface;
|
|
1248
|
+
/** Coerce a group-key scalar string by the column's TS type (numbers/bools). */
|
|
1249
|
+
function coerceScalar(raw, tsType) {
|
|
1250
|
+
const ts = tsType.replace(/\s*\|\s*null$/i, '').trim();
|
|
1251
|
+
if (raw === 'null')
|
|
1252
|
+
return null;
|
|
1253
|
+
if (ts === 'number' || ts === 'bigint') {
|
|
1254
|
+
const n = Number(raw);
|
|
1255
|
+
return Number.isFinite(n) ? n : raw;
|
|
1256
|
+
}
|
|
1257
|
+
if (ts === 'boolean')
|
|
1258
|
+
return raw === 'true';
|
|
1259
|
+
if (ts === 'Date')
|
|
1260
|
+
return new Date(Number(raw) / 1000);
|
|
1261
|
+
return raw;
|
|
1262
|
+
}
|