turbine-orm 0.19.2 → 0.22.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 +99 -18
- package/dist/adapters/cockroachdb.js +4 -9
- package/dist/cjs/adapters/cockroachdb.js +4 -9
- package/dist/cjs/cli/index.js +6 -3
- package/dist/cjs/cli/observe-ui.js +12 -2
- package/dist/cjs/cli/observe.js +6 -7
- package/dist/cjs/cli/studio.js +6 -7
- package/dist/cjs/client.js +44 -22
- package/dist/cjs/dialect.js +116 -0
- package/dist/cjs/errors.js +19 -1
- package/dist/cjs/generate.js +4 -0
- package/dist/cjs/index.js +3 -2
- package/dist/cjs/introspect.js +20 -0
- package/dist/cjs/mssql.js +1338 -0
- package/dist/cjs/mysql.js +1052 -0
- package/dist/cjs/powdb.js +851 -0
- package/dist/cjs/powql.js +903 -0
- package/dist/cjs/query/builder.js +293 -73
- package/dist/cjs/sqlite.js +849 -0
- package/dist/cjs/typed-sql.js +9 -7
- package/dist/cli/index.js +6 -3
- package/dist/cli/observe-ui.d.ts +1 -1
- package/dist/cli/observe-ui.js +12 -2
- package/dist/cli/observe.js +7 -8
- package/dist/cli/studio.js +7 -8
- package/dist/cli/ui.d.ts +1 -1
- package/dist/client.d.ts +23 -1
- package/dist/client.js +45 -23
- package/dist/dialect.d.ts +258 -1
- package/dist/dialect.js +83 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +17 -0
- package/dist/generate.js +4 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/introspect.d.ts +18 -0
- package/dist/introspect.js +19 -0
- package/dist/mssql.d.ts +233 -0
- package/dist/mssql.js +1298 -0
- package/dist/mysql.d.ts +174 -0
- package/dist/mysql.js +1012 -0
- package/dist/powdb.d.ts +338 -0
- package/dist/powdb.js +802 -0
- package/dist/powql.d.ts +153 -0
- package/dist/powql.js +900 -0
- package/dist/query/builder.d.ts +105 -0
- package/dist/query/builder.js +294 -74
- package/dist/query/index.d.ts +1 -1
- package/dist/sqlite.d.ts +144 -0
- package/dist/sqlite.js +842 -0
- package/dist/typed-sql.d.ts +7 -5
- package/dist/typed-sql.js +9 -7
- package/package.json +45 -1
|
@@ -0,0 +1,903 @@
|
|
|
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.
|
|
20
|
+
* - The PK is generated client-side (UUID) when the column has a default.
|
|
21
|
+
* - `with` (nested relations) degrades to **batched N+1 loaders** — D
|
|
22
|
+
* round-trips for depth D, not one query. manyToMany is Phase B.
|
|
23
|
+
* - pgvector / JSON / array filters and cursor streaming throw
|
|
24
|
+
* {@link UnsupportedFeatureError} (E017) — they have no PowDB equivalent.
|
|
25
|
+
*
|
|
26
|
+
* @module
|
|
27
|
+
*/
|
|
28
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
|
+
exports.PowqlInterface = void 0;
|
|
30
|
+
const node_crypto_1 = require("node:crypto");
|
|
31
|
+
const errors_js_1 = require("./errors.js");
|
|
32
|
+
const powdb_js_1 = require("./powdb.js");
|
|
33
|
+
const utils_js_1 = require("./query/utils.js");
|
|
34
|
+
const schema_js_1 = require("./schema.js");
|
|
35
|
+
/**
|
|
36
|
+
* Max parent keys per relation-loader `in (…)` query. A `with` over a large
|
|
37
|
+
* parent set is split into batches of this size so a single query never exceeds
|
|
38
|
+
* PowDB's per-statement param / row limits; the batches' children are merged
|
|
39
|
+
* before grouping. Mirrors the chunking the parity matrix documents.
|
|
40
|
+
*/
|
|
41
|
+
const MAX_RELATION_KEYS = 1000;
|
|
42
|
+
/** Operator keys recognised inside a `WhereOperator` object. */
|
|
43
|
+
const OPERATOR_KEYS = new Set([
|
|
44
|
+
'equals',
|
|
45
|
+
'gt',
|
|
46
|
+
'gte',
|
|
47
|
+
'lt',
|
|
48
|
+
'lte',
|
|
49
|
+
'not',
|
|
50
|
+
'in',
|
|
51
|
+
'notIn',
|
|
52
|
+
'contains',
|
|
53
|
+
'startsWith',
|
|
54
|
+
'endsWith',
|
|
55
|
+
'mode',
|
|
56
|
+
]);
|
|
57
|
+
/** Filters that have no PowDB representation and must throw E017. */
|
|
58
|
+
function rejectUnsupportedFilter(value, field) {
|
|
59
|
+
if ('distance' in value || 'metric' in value) {
|
|
60
|
+
throw new errors_js_1.UnsupportedFeatureError('pgvector distance filters', 'PowDB', `field "${field}"`);
|
|
61
|
+
}
|
|
62
|
+
if ('path' in value || 'hasKey' in value) {
|
|
63
|
+
throw new errors_js_1.UnsupportedFeatureError('JSON path/key filters', 'PowDB', `field "${field}"`);
|
|
64
|
+
}
|
|
65
|
+
if ('hasEvery' in value || 'hasSome' in value || 'isEmpty' in value) {
|
|
66
|
+
throw new errors_js_1.UnsupportedFeatureError('array filters', 'PowDB', `field "${field}"`);
|
|
67
|
+
}
|
|
68
|
+
if ('search' in value) {
|
|
69
|
+
throw new errors_js_1.UnsupportedFeatureError('full-text search filters', 'PowDB', `field "${field}"`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The PowQL query interface. Constructed by `turbinePowDB` via the
|
|
74
|
+
* `queryInterfaceFactory` seam and cast to `QueryInterface<object>` so
|
|
75
|
+
* `TurbineClient.table()` can return it transparently.
|
|
76
|
+
*/
|
|
77
|
+
class PowqlInterface {
|
|
78
|
+
pool;
|
|
79
|
+
table;
|
|
80
|
+
schema;
|
|
81
|
+
middlewares;
|
|
82
|
+
options;
|
|
83
|
+
meta;
|
|
84
|
+
defaultLimit;
|
|
85
|
+
warnOnUnlimited;
|
|
86
|
+
onQuery;
|
|
87
|
+
currentAction = 'raw';
|
|
88
|
+
warnedUnlimited = false;
|
|
89
|
+
constructor(pool, table, schema, middlewares = [], options = {}) {
|
|
90
|
+
this.pool = pool;
|
|
91
|
+
this.table = table;
|
|
92
|
+
this.schema = schema;
|
|
93
|
+
this.middlewares = middlewares;
|
|
94
|
+
this.options = options;
|
|
95
|
+
const meta = schema.tables[table];
|
|
96
|
+
if (!meta) {
|
|
97
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown table "${table}". Available: ${Object.keys(schema.tables).join(', ')}`);
|
|
98
|
+
}
|
|
99
|
+
this.meta = meta;
|
|
100
|
+
this.defaultLimit = options.defaultLimit;
|
|
101
|
+
this.warnOnUnlimited = options.warnOnUnlimited !== false;
|
|
102
|
+
this.onQuery = options._onQuery;
|
|
103
|
+
}
|
|
104
|
+
// -------------------------------------------------------------------------
|
|
105
|
+
// Column / value helpers
|
|
106
|
+
// -------------------------------------------------------------------------
|
|
107
|
+
/** Resolve a camelCase field name (or raw snake) to its column metadata. */
|
|
108
|
+
column(field) {
|
|
109
|
+
const snake = this.meta.columnMap[field] ?? field;
|
|
110
|
+
const col = this.meta.columns.find((c) => c.name === snake || c.field === field);
|
|
111
|
+
if (!col) {
|
|
112
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown column "${field}" on table "${this.table}". Known: ${this.meta.columns
|
|
113
|
+
.map((c) => c.field)
|
|
114
|
+
.join(', ')}`);
|
|
115
|
+
}
|
|
116
|
+
return col;
|
|
117
|
+
}
|
|
118
|
+
/** PowQL column reference (`.snake_name`) for a field. */
|
|
119
|
+
ref(field) {
|
|
120
|
+
return `.${this.column(field).name}`;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Push a value into the param array and return its `$N` placeholder. When the
|
|
124
|
+
* value targets a `float` column it is wrapped in {@link PowdbFloatParam} so
|
|
125
|
+
* the *embedded* literal encoder emits a float-form literal even for an
|
|
126
|
+
* integer value (PowQL's `42` is an int literal, `42.0` a float). The
|
|
127
|
+
* networked driver unwraps the marker back to the plain number in
|
|
128
|
+
* {@link toPowdbParam}, so the wire param is unchanged.
|
|
129
|
+
*/
|
|
130
|
+
param(value, params, col) {
|
|
131
|
+
const tagged = col && typeof value === 'number' && this.isFloatCol(col) ? new powdb_js_1.PowdbFloatParam(value) : value;
|
|
132
|
+
params.push(tagged);
|
|
133
|
+
return `$${params.length}`;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Render a value for a write *assignment* (`col := …`). Every value — float
|
|
137
|
+
* columns included — is sent as a positional `$N` param. PowDB ≥ 0.7.0 fixed
|
|
138
|
+
* the int→float UPDATE coercion bug (`score := $n` with an integer param now
|
|
139
|
+
* reads back the integer value, not the raw i64 bits), so the float-literal
|
|
140
|
+
* inlining workaround Turbine carried for ≤ 0.6.2 is gone. Marks the column as
|
|
141
|
+
* float-typed for the *embedded* literal encoder (which materializes params
|
|
142
|
+
* into PowQL text) so an integer-valued float still encodes as a float literal
|
|
143
|
+
* and coercion stays unambiguous. Non-finite floats are still rejected.
|
|
144
|
+
*/
|
|
145
|
+
writeRef(value, col, params) {
|
|
146
|
+
if (typeof value === 'number' && !Number.isFinite(value) && this.isFloatCol(col)) {
|
|
147
|
+
throw new errors_js_1.ValidationError(`[turbine] Non-finite value for float column "${col.name}" on "${this.table}".`);
|
|
148
|
+
}
|
|
149
|
+
return this.param(value, params, col);
|
|
150
|
+
}
|
|
151
|
+
isFloatCol(col) {
|
|
152
|
+
try {
|
|
153
|
+
return (0, powdb_js_1.powqlColumnType)(col) === 'float';
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/** A predicate that is always false — the empty-`in` / contradiction sentinel. */
|
|
160
|
+
alwaysFalse() {
|
|
161
|
+
const pk = this.meta.primaryKey[0] ?? this.meta.columns[0]?.name;
|
|
162
|
+
return `(.${pk} is null and .${pk} is not null)`;
|
|
163
|
+
}
|
|
164
|
+
// -------------------------------------------------------------------------
|
|
165
|
+
// WHERE builder
|
|
166
|
+
// -------------------------------------------------------------------------
|
|
167
|
+
/**
|
|
168
|
+
* Compile a {@link WhereClause} into a PowQL filter expression, pushing every
|
|
169
|
+
* value as a positional `$N` param. Returns `''` when there are no conditions.
|
|
170
|
+
*/
|
|
171
|
+
buildWhere(where, params) {
|
|
172
|
+
if (!where)
|
|
173
|
+
return '';
|
|
174
|
+
const parts = [];
|
|
175
|
+
for (const [key, value] of Object.entries(where)) {
|
|
176
|
+
if (value === undefined)
|
|
177
|
+
continue;
|
|
178
|
+
if (key === 'AND') {
|
|
179
|
+
const sub = value.map((w) => this.buildWhere(w, params)).filter(Boolean);
|
|
180
|
+
if (sub.length)
|
|
181
|
+
parts.push(`(${sub.join(' and ')})`);
|
|
182
|
+
}
|
|
183
|
+
else if (key === 'OR') {
|
|
184
|
+
const sub = value.map((w) => this.buildWhere(w, params)).filter(Boolean);
|
|
185
|
+
if (sub.length)
|
|
186
|
+
parts.push(`(${sub.join(' or ')})`);
|
|
187
|
+
}
|
|
188
|
+
else if (key === 'NOT') {
|
|
189
|
+
const sub = this.buildWhere(value, params);
|
|
190
|
+
if (sub)
|
|
191
|
+
parts.push(`not (${sub})`);
|
|
192
|
+
}
|
|
193
|
+
else if (this.meta.relations[key]) {
|
|
194
|
+
parts.push(this.buildRelationFilter(this.meta.relations[key], value, params));
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
parts.push(this.buildFieldCondition(key, value, params));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return parts.join(' and ');
|
|
201
|
+
}
|
|
202
|
+
/** Build a single `field: value | operator` condition. */
|
|
203
|
+
buildFieldCondition(field, value, params) {
|
|
204
|
+
const ref = this.ref(field);
|
|
205
|
+
if (value === null)
|
|
206
|
+
return `${ref} is null`;
|
|
207
|
+
if (value instanceof Date || typeof value !== 'object') {
|
|
208
|
+
return `${ref} = ${this.param(value, params)}`;
|
|
209
|
+
}
|
|
210
|
+
const op = value;
|
|
211
|
+
rejectUnsupportedFilter(op, field);
|
|
212
|
+
if (!Object.keys(op).some((k) => OPERATOR_KEYS.has(k))) {
|
|
213
|
+
// A bare object that is not an operator set — equality by value.
|
|
214
|
+
return `${ref} = ${this.param(value, params)}`;
|
|
215
|
+
}
|
|
216
|
+
const insensitive = op.mode === 'insensitive';
|
|
217
|
+
const lhs = insensitive ? `lower(${ref})` : ref;
|
|
218
|
+
const conds = [];
|
|
219
|
+
for (const [opName, opVal] of Object.entries(op)) {
|
|
220
|
+
if (opVal === undefined || opName === 'mode')
|
|
221
|
+
continue;
|
|
222
|
+
switch (opName) {
|
|
223
|
+
case 'equals':
|
|
224
|
+
conds.push(opVal === null ? `${ref} is null` : `${lhs} = ${this.bind(opVal, params, insensitive)}`);
|
|
225
|
+
break;
|
|
226
|
+
case 'not':
|
|
227
|
+
conds.push(opVal === null ? `${ref} is not null` : `not (${lhs} = ${this.bind(opVal, params, insensitive)})`);
|
|
228
|
+
break;
|
|
229
|
+
case 'gt':
|
|
230
|
+
conds.push(`${lhs} > ${this.bind(opVal, params, insensitive)}`);
|
|
231
|
+
break;
|
|
232
|
+
case 'gte':
|
|
233
|
+
conds.push(`${lhs} >= ${this.bind(opVal, params, insensitive)}`);
|
|
234
|
+
break;
|
|
235
|
+
case 'lt':
|
|
236
|
+
conds.push(`${lhs} < ${this.bind(opVal, params, insensitive)}`);
|
|
237
|
+
break;
|
|
238
|
+
case 'lte':
|
|
239
|
+
conds.push(`${lhs} <= ${this.bind(opVal, params, insensitive)}`);
|
|
240
|
+
break;
|
|
241
|
+
case 'in':
|
|
242
|
+
conds.push(this.buildInList(lhs, opVal, params, insensitive, false));
|
|
243
|
+
break;
|
|
244
|
+
case 'notIn':
|
|
245
|
+
conds.push(this.buildInList(lhs, opVal, params, insensitive, true));
|
|
246
|
+
break;
|
|
247
|
+
case 'contains':
|
|
248
|
+
conds.push(`${lhs} like ${this.bindLike(`%${(0, utils_js_1.escapeLike)(String(opVal))}%`, params, insensitive)}`);
|
|
249
|
+
break;
|
|
250
|
+
case 'startsWith':
|
|
251
|
+
conds.push(`${lhs} like ${this.bindLike(`${(0, utils_js_1.escapeLike)(String(opVal))}%`, params, insensitive)}`);
|
|
252
|
+
break;
|
|
253
|
+
case 'endsWith':
|
|
254
|
+
conds.push(`${lhs} like ${this.bindLike(`%${(0, utils_js_1.escapeLike)(String(opVal))}`, params, insensitive)}`);
|
|
255
|
+
break;
|
|
256
|
+
default:
|
|
257
|
+
throw new errors_js_1.ValidationError(`[turbine] Unsupported operator "${opName}" on PowDB.`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return conds.length > 1 ? `(${conds.join(' and ')})` : (conds[0] ?? this.alwaysFalse());
|
|
261
|
+
}
|
|
262
|
+
/** Bind a value, lowercasing for case-insensitive comparisons. */
|
|
263
|
+
bind(value, params, insensitive) {
|
|
264
|
+
const ph = this.param(value, params);
|
|
265
|
+
return insensitive ? `lower(${ph})` : ph;
|
|
266
|
+
}
|
|
267
|
+
/** Bind a LIKE pattern (already escaped), lowercasing for insensitive mode. */
|
|
268
|
+
bindLike(pattern, params, insensitive) {
|
|
269
|
+
const ph = this.param(pattern, params);
|
|
270
|
+
return insensitive ? `lower(${ph})` : ph;
|
|
271
|
+
}
|
|
272
|
+
/** `lhs [not] in ($1, $2, …)` — empty list collapses to a constant. */
|
|
273
|
+
buildInList(lhs, values, params, insensitive, negate) {
|
|
274
|
+
if (!Array.isArray(values) || values.length === 0) {
|
|
275
|
+
// `in []` matches nothing; `not in []` matches everything.
|
|
276
|
+
return negate ? '(1 = 1)' : this.alwaysFalse();
|
|
277
|
+
}
|
|
278
|
+
const items = values.map((v) => this.bind(v, params, insensitive)).join(', ');
|
|
279
|
+
return `${lhs} ${negate ? 'not in' : 'in'} (${items})`;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Relation filter (`some`/`none`/`every`) via the verified IN-subquery form
|
|
283
|
+
* `Outer filter .localKey in (Target filter <e> { .targetKey })`. `exists`
|
|
284
|
+
* correlation is unreliable on PowDB, so it is never used.
|
|
285
|
+
*/
|
|
286
|
+
buildRelationFilter(rel, filter, params) {
|
|
287
|
+
if (rel.type === 'manyToMany') {
|
|
288
|
+
throw new errors_js_1.UnsupportedFeatureError('manyToMany relation filters', 'PowDB', `relation "${rel.name}" — junction-table relation filters are Phase B`);
|
|
289
|
+
}
|
|
290
|
+
const fk = (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey);
|
|
291
|
+
const rk = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey);
|
|
292
|
+
if (fk.length > 1 || rk.length > 1) {
|
|
293
|
+
throw new errors_js_1.UnsupportedFeatureError('composite-key relation filters', 'PowDB', `relation "${rel.name}" uses a composite key`);
|
|
294
|
+
}
|
|
295
|
+
// hasMany/hasOne: outer.referenceKey ∈ target.foreignKey.
|
|
296
|
+
// belongsTo: outer.foreignKey ∈ target.referenceKey.
|
|
297
|
+
const localKey = rel.type === 'belongsTo' ? fk[0] : rk[0];
|
|
298
|
+
const targetKey = rel.type === 'belongsTo' ? rk[0] : fk[0];
|
|
299
|
+
const targetMeta = this.schema.tables[rel.to];
|
|
300
|
+
if (!targetMeta)
|
|
301
|
+
throw new errors_js_1.ValidationError(`[turbine] Relation "${rel.name}" targets unknown table "${rel.to}".`);
|
|
302
|
+
const sub = (whereObj, negateInner) => {
|
|
303
|
+
const innerQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
|
|
304
|
+
const inner = innerQi.buildWhere(whereObj, params);
|
|
305
|
+
const filterClause = inner ? ` filter ${negateInner ? `not (${inner})` : inner}` : '';
|
|
306
|
+
return `${rel.to}${filterClause} { .${targetKey} }`;
|
|
307
|
+
};
|
|
308
|
+
if (filter.some)
|
|
309
|
+
return `.${localKey} in (${sub(filter.some, false)})`;
|
|
310
|
+
if (filter.none)
|
|
311
|
+
return `.${localKey} not in (${sub(filter.none, false)})`;
|
|
312
|
+
if (filter.every) {
|
|
313
|
+
// Parents with no child failing the predicate.
|
|
314
|
+
return `.${localKey} not in (${sub(filter.every, true)})`;
|
|
315
|
+
}
|
|
316
|
+
return this.alwaysFalse();
|
|
317
|
+
}
|
|
318
|
+
// -------------------------------------------------------------------------
|
|
319
|
+
// Projection / order
|
|
320
|
+
// -------------------------------------------------------------------------
|
|
321
|
+
/** Resolve the set of columns to project, honouring `select` / `omit`. */
|
|
322
|
+
projectedColumns(select, omit) {
|
|
323
|
+
let cols = this.meta.columns.map((c) => c.name);
|
|
324
|
+
if (select && Object.keys(select).length) {
|
|
325
|
+
const picked = new Set(Object.entries(select)
|
|
326
|
+
.filter(([, v]) => v)
|
|
327
|
+
.map(([k]) => this.column(k).name));
|
|
328
|
+
// Always keep the PK so reselect / relation stitching has a key to work with.
|
|
329
|
+
for (const pk of this.meta.primaryKey)
|
|
330
|
+
picked.add(pk);
|
|
331
|
+
cols = cols.filter((c) => picked.has(c));
|
|
332
|
+
}
|
|
333
|
+
if (omit && Object.keys(omit).length) {
|
|
334
|
+
const dropped = new Set(Object.entries(omit)
|
|
335
|
+
.filter(([, v]) => v)
|
|
336
|
+
.map(([k]) => this.column(k).name));
|
|
337
|
+
cols = cols.filter((c) => !dropped.has(c));
|
|
338
|
+
}
|
|
339
|
+
return cols;
|
|
340
|
+
}
|
|
341
|
+
/** `{ .c1, .c2, … }` projection clause. */
|
|
342
|
+
projection(cols) {
|
|
343
|
+
return `{ ${cols.map((c) => `.${c}`).join(', ')} }`;
|
|
344
|
+
}
|
|
345
|
+
/** `order .c1 asc, .c2 desc` clause (empty string when no orderBy). */
|
|
346
|
+
buildOrder(orderBy) {
|
|
347
|
+
if (!orderBy)
|
|
348
|
+
return '';
|
|
349
|
+
const keys = Object.entries(orderBy).filter(([, dir]) => dir !== undefined);
|
|
350
|
+
if (!keys.length)
|
|
351
|
+
return '';
|
|
352
|
+
const parts = keys.map(([field, dir]) => {
|
|
353
|
+
if (dir && typeof dir === 'object') {
|
|
354
|
+
throw new errors_js_1.UnsupportedFeatureError('vector / distance ordering', 'PowDB', `field "${field}"`);
|
|
355
|
+
}
|
|
356
|
+
return `${this.ref(field)} ${dir === 'desc' ? 'desc' : 'asc'}`;
|
|
357
|
+
});
|
|
358
|
+
return ` order ${parts.join(', ')}`;
|
|
359
|
+
}
|
|
360
|
+
// -------------------------------------------------------------------------
|
|
361
|
+
// Execution plumbing
|
|
362
|
+
// -------------------------------------------------------------------------
|
|
363
|
+
/** Run PowQL with optional timeout, emitting a query event either way. */
|
|
364
|
+
async exec(powql, params, timeout) {
|
|
365
|
+
const start = performance.now();
|
|
366
|
+
const run = this.pool.query(powql, params);
|
|
367
|
+
try {
|
|
368
|
+
const result = timeout
|
|
369
|
+
? await Promise.race([
|
|
370
|
+
run,
|
|
371
|
+
new Promise((_, reject) => setTimeout(() => reject(new errors_js_1.TimeoutError(timeout)), timeout)),
|
|
372
|
+
])
|
|
373
|
+
: await run;
|
|
374
|
+
this.emit(powql, params, performance.now() - start, result.rowCount ?? result.rows.length);
|
|
375
|
+
return result;
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
this.emit(powql, params, performance.now() - start, 0, err);
|
|
379
|
+
throw err;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
emit(sql, params, duration, rows, error) {
|
|
383
|
+
if (!this.onQuery)
|
|
384
|
+
return;
|
|
385
|
+
try {
|
|
386
|
+
this.onQuery({
|
|
387
|
+
sql,
|
|
388
|
+
params,
|
|
389
|
+
duration,
|
|
390
|
+
model: this.table,
|
|
391
|
+
action: this.currentAction,
|
|
392
|
+
rows,
|
|
393
|
+
timestamp: new Date(),
|
|
394
|
+
error,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
/* listener errors never crash a query */
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
/** Run a method body through the middleware chain (mirrors QueryInterface). */
|
|
402
|
+
async withMiddleware(action, args, executor) {
|
|
403
|
+
this.currentAction = action;
|
|
404
|
+
if (this.middlewares.length === 0)
|
|
405
|
+
return executor();
|
|
406
|
+
let index = 0;
|
|
407
|
+
const next = async (p) => {
|
|
408
|
+
if (index < this.middlewares.length)
|
|
409
|
+
return this.middlewares[index++](p, next);
|
|
410
|
+
return executor();
|
|
411
|
+
};
|
|
412
|
+
return next({ model: this.table, action, args: { ...args } });
|
|
413
|
+
}
|
|
414
|
+
/** Map raw rows to typed entities. */
|
|
415
|
+
shape(rows) {
|
|
416
|
+
return rows.map((r) => (0, powdb_js_1.rowToEntity)(r, this.meta));
|
|
417
|
+
}
|
|
418
|
+
// -------------------------------------------------------------------------
|
|
419
|
+
// Reads
|
|
420
|
+
// -------------------------------------------------------------------------
|
|
421
|
+
async findMany(args = {}) {
|
|
422
|
+
return this.withMiddleware('findMany', args, async () => {
|
|
423
|
+
const rows = await this.runFind(args);
|
|
424
|
+
const entities = this.shape(rows);
|
|
425
|
+
if (args.with)
|
|
426
|
+
await this.loadRelations(entities, args.with, args.timeout);
|
|
427
|
+
return entities;
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
/** Build + run the flat findMany select; returns raw rows. */
|
|
431
|
+
async runFind(args) {
|
|
432
|
+
if (args.cursor) {
|
|
433
|
+
throw new errors_js_1.UnsupportedFeatureError('cursor pagination', 'PowDB', 'use limit/offset instead');
|
|
434
|
+
}
|
|
435
|
+
const params = [];
|
|
436
|
+
const where = this.buildWhere(args.where, params);
|
|
437
|
+
const cols = this.projectedColumns(args.select, args.omit);
|
|
438
|
+
const distinct = args.distinct?.length ? ' distinct' : '';
|
|
439
|
+
const filter = where ? ` filter ${where}` : '';
|
|
440
|
+
const order = this.buildOrder(args.orderBy);
|
|
441
|
+
const limit = args.limit ?? args.take ?? this.defaultLimit;
|
|
442
|
+
if (limit === undefined && this.warnOnUnlimited && !this.warnedUnlimited) {
|
|
443
|
+
this.warnedUnlimited = true;
|
|
444
|
+
console.warn(`[turbine] findMany on "${this.table}" has no limit — this scans the whole table.`);
|
|
445
|
+
}
|
|
446
|
+
const limitClause = limit !== undefined ? ` limit ${this.param(limit, params)}` : '';
|
|
447
|
+
const offsetClause = args.offset ? ` offset ${this.param(args.offset, params)}` : '';
|
|
448
|
+
const powql = `${this.table}${distinct}${filter}${order}${limitClause}${offsetClause} ${this.projection(cols)}`;
|
|
449
|
+
const { rows } = await this.exec(powql, params, args.timeout);
|
|
450
|
+
return rows;
|
|
451
|
+
}
|
|
452
|
+
async findUnique(args) {
|
|
453
|
+
return this.withMiddleware('findUnique', args, async () => {
|
|
454
|
+
const rows = await this.runFind({ ...args, limit: 1 });
|
|
455
|
+
if (!rows.length)
|
|
456
|
+
return null;
|
|
457
|
+
const entities = this.shape(rows);
|
|
458
|
+
if (args.with)
|
|
459
|
+
await this.loadRelations(entities, args.with, args.timeout);
|
|
460
|
+
return entities[0];
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
async findFirst(args = {}) {
|
|
464
|
+
return this.withMiddleware('findFirst', args, async () => {
|
|
465
|
+
const rows = await this.runFind({ ...args, limit: 1 });
|
|
466
|
+
if (!rows.length)
|
|
467
|
+
return null;
|
|
468
|
+
const entities = this.shape(rows);
|
|
469
|
+
if (args.with)
|
|
470
|
+
await this.loadRelations(entities, args.with, args.timeout);
|
|
471
|
+
return entities[0];
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
async findUniqueOrThrow(args) {
|
|
475
|
+
const row = await this.findUnique(args);
|
|
476
|
+
if (!row)
|
|
477
|
+
throw new errors_js_1.NotFoundError({ table: this.table, where: args.where });
|
|
478
|
+
return row;
|
|
479
|
+
}
|
|
480
|
+
async findFirstOrThrow(args = {}) {
|
|
481
|
+
const row = await this.findFirst(args);
|
|
482
|
+
if (!row)
|
|
483
|
+
throw new errors_js_1.NotFoundError({ table: this.table, where: (args.where ?? {}) });
|
|
484
|
+
return row;
|
|
485
|
+
}
|
|
486
|
+
// -------------------------------------------------------------------------
|
|
487
|
+
// Nested relations — batched N+1 loaders (hasMany / hasOne / belongsTo)
|
|
488
|
+
// -------------------------------------------------------------------------
|
|
489
|
+
/** Load each requested relation for `parents` and attach it onto each row. */
|
|
490
|
+
async loadRelations(parents, withClause, timeout, depth = 0) {
|
|
491
|
+
if (depth >= 10) {
|
|
492
|
+
throw new errors_js_1.ValidationError(`[turbine] Nested 'with' on PowDB exceeded depth 10 (relation cycle?).`);
|
|
493
|
+
}
|
|
494
|
+
if (!parents.length)
|
|
495
|
+
return;
|
|
496
|
+
for (const [relName, opt] of Object.entries(withClause)) {
|
|
497
|
+
if (!opt)
|
|
498
|
+
continue;
|
|
499
|
+
const rel = this.meta.relations[relName];
|
|
500
|
+
if (!rel)
|
|
501
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown relation "${relName}" on "${this.table}".`);
|
|
502
|
+
if (rel.type === 'manyToMany') {
|
|
503
|
+
throw new errors_js_1.UnsupportedFeatureError('manyToMany nested reads', 'PowDB', `relation "${relName}" — junction loading is Phase B`);
|
|
504
|
+
}
|
|
505
|
+
const fk = (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey);
|
|
506
|
+
const rk = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey);
|
|
507
|
+
if (fk.length > 1 || rk.length > 1) {
|
|
508
|
+
throw new errors_js_1.UnsupportedFeatureError('composite-key nested reads', 'PowDB', `relation "${relName}"`);
|
|
509
|
+
}
|
|
510
|
+
const options = (opt === true ? {} : opt);
|
|
511
|
+
const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
|
|
512
|
+
const targetMeta = this.schema.tables[rel.to];
|
|
513
|
+
// Local key on the parent, remote key on the target child.
|
|
514
|
+
const parentKeyCol = rel.type === 'belongsTo' ? fk[0] : rk[0];
|
|
515
|
+
const childKeyCol = rel.type === 'belongsTo' ? rk[0] : fk[0];
|
|
516
|
+
const parentKeyField = this.meta.reverseColumnMap[parentKeyCol] ?? parentKeyCol;
|
|
517
|
+
const childKeyField = targetMeta.reverseColumnMap[childKeyCol] ?? childKeyCol;
|
|
518
|
+
const keys = [
|
|
519
|
+
...new Set(parents.map((p) => p[parentKeyField]).filter((k) => k != null)),
|
|
520
|
+
];
|
|
521
|
+
const childByKey = new Map();
|
|
522
|
+
// Chunk the key set so a single `in (…)` never exceeds PowDB's
|
|
523
|
+
// per-statement param / row limits; merge each chunk's children.
|
|
524
|
+
for (let i = 0; i < keys.length; i += MAX_RELATION_KEYS) {
|
|
525
|
+
const chunk = keys.slice(i, i + MAX_RELATION_KEYS);
|
|
526
|
+
const childWhere = {
|
|
527
|
+
...options.where,
|
|
528
|
+
[childKeyField]: { in: chunk },
|
|
529
|
+
};
|
|
530
|
+
const children = (await targetQi.findMany({
|
|
531
|
+
...options,
|
|
532
|
+
where: childWhere,
|
|
533
|
+
with: options.with,
|
|
534
|
+
timeout: options.timeout ?? timeout,
|
|
535
|
+
}));
|
|
536
|
+
for (const child of children) {
|
|
537
|
+
const k = child[childKeyField];
|
|
538
|
+
const bucket = childByKey.get(k);
|
|
539
|
+
if (bucket)
|
|
540
|
+
bucket.push(child);
|
|
541
|
+
else
|
|
542
|
+
childByKey.set(k, [child]);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
|
|
546
|
+
for (const parent of parents) {
|
|
547
|
+
const k = parent[parentKeyField];
|
|
548
|
+
const matches = childByKey.get(k) ?? [];
|
|
549
|
+
parent[relName] = single ? (matches[0] ?? null) : matches;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
// -------------------------------------------------------------------------
|
|
554
|
+
// Writes (reselect — PowDB has no RETURNING)
|
|
555
|
+
// -------------------------------------------------------------------------
|
|
556
|
+
/** Split `data` into scalar assignments; reject relation (nested-write) keys. */
|
|
557
|
+
scalarData(data) {
|
|
558
|
+
const out = [];
|
|
559
|
+
for (const [field, value] of Object.entries(data)) {
|
|
560
|
+
if (value === undefined)
|
|
561
|
+
continue;
|
|
562
|
+
if (this.meta.relations[field]) {
|
|
563
|
+
throw new errors_js_1.UnsupportedFeatureError('nested writes', 'PowDB', `relation "${field}" in write data — Phase B`);
|
|
564
|
+
}
|
|
565
|
+
out.push({ col: this.column(field), value });
|
|
566
|
+
}
|
|
567
|
+
return out;
|
|
568
|
+
}
|
|
569
|
+
/** Fill in a client-generated UUID for a defaulted PK that wasn't supplied. */
|
|
570
|
+
applyPkDefault(data) {
|
|
571
|
+
const out = { ...data };
|
|
572
|
+
for (const pk of this.meta.primaryKey) {
|
|
573
|
+
const field = this.meta.reverseColumnMap[pk] ?? pk;
|
|
574
|
+
const col = this.meta.columns.find((c) => c.name === pk);
|
|
575
|
+
if (out[field] == null && col?.hasDefault && col.tsType.replace(/\s*\|\s*null$/, '').trim() === 'string') {
|
|
576
|
+
out[field] = (0, node_crypto_1.randomUUID)();
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
return out;
|
|
580
|
+
}
|
|
581
|
+
async create(args) {
|
|
582
|
+
return this.withMiddleware('create', args, async () => {
|
|
583
|
+
const data = this.applyPkDefault(args.data);
|
|
584
|
+
const assigns = this.scalarData(data);
|
|
585
|
+
const params = [];
|
|
586
|
+
const body = assigns.map((a) => `${a.col.name} := ${this.writeRef(a.value, a.col, params)}`).join(', ');
|
|
587
|
+
// `returning` surfaces the inserted row (all columns, schema order) in one round-trip.
|
|
588
|
+
const { rows } = await this.exec(`insert ${this.table} { ${body} } returning`, params, args.timeout);
|
|
589
|
+
const row = rows.length ? this.shape(rows)[0] : null;
|
|
590
|
+
if (!row)
|
|
591
|
+
throw new errors_js_1.NotFoundError({ table: this.table, where: data });
|
|
592
|
+
return row;
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
async createMany(args) {
|
|
596
|
+
return this.withMiddleware('createMany', args, async () => {
|
|
597
|
+
const inputs = args.data.map((d) => this.applyPkDefault(d));
|
|
598
|
+
if (!inputs.length)
|
|
599
|
+
return [];
|
|
600
|
+
const params = [];
|
|
601
|
+
const tuples = inputs.map((d) => {
|
|
602
|
+
const assigns = this.scalarData(d);
|
|
603
|
+
return `{ ${assigns.map((a) => `${a.col.name} := ${this.writeRef(a.value, a.col, params)}`).join(', ')} }`;
|
|
604
|
+
});
|
|
605
|
+
// Multi-row insert with `returning` hands back every inserted row in one round-trip.
|
|
606
|
+
const { rows } = await this.exec(`insert ${this.table} ${tuples.join(', ')} returning`, params, args.timeout);
|
|
607
|
+
return this.shape(rows);
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
async update(args) {
|
|
611
|
+
return this.withMiddleware('update', args, async () => {
|
|
612
|
+
const params = [];
|
|
613
|
+
const where = this.buildWhere(args.where, params);
|
|
614
|
+
this.assertCompiledWhere(where, false, 'update');
|
|
615
|
+
const setClause = this.buildUpdateAssignments(args.data, params);
|
|
616
|
+
// `returning` hands back the post-update row(s); take the first (single-row contract).
|
|
617
|
+
const { rows } = await this.exec(`${this.table} filter ${where} update { ${setClause} } returning`, params, args.timeout);
|
|
618
|
+
const row = rows.length ? this.shape(rows)[0] : null;
|
|
619
|
+
if (!row)
|
|
620
|
+
throw new errors_js_1.NotFoundError({ table: this.table, where: args.where });
|
|
621
|
+
return row;
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
async updateMany(args) {
|
|
625
|
+
return this.withMiddleware('updateMany', args, async () => {
|
|
626
|
+
const params = [];
|
|
627
|
+
const where = this.buildWhere(args.where, params);
|
|
628
|
+
this.assertCompiledWhere(where, args.allowFullTableScan, 'updateMany');
|
|
629
|
+
const setClause = this.buildUpdateAssignments(args.data, params);
|
|
630
|
+
const filter = where ? ` filter ${where}` : '';
|
|
631
|
+
const { rowCount } = await this.exec(`${this.table}${filter} update { ${setClause} }`, params, args.timeout);
|
|
632
|
+
return { count: rowCount };
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
/** Compile `data` into PowQL update assignments, including atomic operators. */
|
|
636
|
+
buildUpdateAssignments(data, params) {
|
|
637
|
+
const parts = [];
|
|
638
|
+
for (const [field, value] of Object.entries(data)) {
|
|
639
|
+
if (value === undefined)
|
|
640
|
+
continue;
|
|
641
|
+
if (this.meta.relations[field]) {
|
|
642
|
+
throw new errors_js_1.UnsupportedFeatureError('nested writes', 'PowDB', `relation "${field}" — Phase B`);
|
|
643
|
+
}
|
|
644
|
+
const colMeta = this.column(field);
|
|
645
|
+
const ref = this.ref(field);
|
|
646
|
+
const col = colMeta.name;
|
|
647
|
+
if (value !== null && typeof value === 'object' && !(value instanceof Date)) {
|
|
648
|
+
const opObj = value;
|
|
649
|
+
if ('set' in opObj)
|
|
650
|
+
parts.push(`${col} := ${this.writeRef(opObj.set, colMeta, params)}`);
|
|
651
|
+
else if ('increment' in opObj)
|
|
652
|
+
parts.push(`${col} := ${ref} + ${this.writeRef(opObj.increment, colMeta, params)}`);
|
|
653
|
+
else if ('decrement' in opObj)
|
|
654
|
+
parts.push(`${col} := ${ref} - ${this.writeRef(opObj.decrement, colMeta, params)}`);
|
|
655
|
+
else if ('multiply' in opObj)
|
|
656
|
+
parts.push(`${col} := ${ref} * ${this.writeRef(opObj.multiply, colMeta, params)}`);
|
|
657
|
+
else if ('divide' in opObj)
|
|
658
|
+
parts.push(`${col} := ${ref} / ${this.writeRef(opObj.divide, colMeta, params)}`);
|
|
659
|
+
else
|
|
660
|
+
throw new errors_js_1.ValidationError(`[turbine] Unsupported update operator on "${field}".`);
|
|
661
|
+
}
|
|
662
|
+
else {
|
|
663
|
+
parts.push(`${col} := ${this.writeRef(value, colMeta, params)}`);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
if (!parts.length)
|
|
667
|
+
throw new errors_js_1.ValidationError(`[turbine] update on "${this.table}" has no fields to set.`);
|
|
668
|
+
return parts.join(', ');
|
|
669
|
+
}
|
|
670
|
+
async delete(args) {
|
|
671
|
+
return this.withMiddleware('delete', args, async () => {
|
|
672
|
+
const params = [];
|
|
673
|
+
const where = this.buildWhere(args.where, params);
|
|
674
|
+
this.assertCompiledWhere(where, false, 'delete');
|
|
675
|
+
// `returning` hands back the deleted row(s) — no separate pre-image reselect needed.
|
|
676
|
+
const { rows } = await this.exec(`${this.table} filter ${where} delete returning`, params, args.timeout);
|
|
677
|
+
const row = rows.length ? this.shape(rows)[0] : null;
|
|
678
|
+
if (!row)
|
|
679
|
+
throw new errors_js_1.NotFoundError({ table: this.table, where: args.where });
|
|
680
|
+
return row;
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
async deleteMany(args) {
|
|
684
|
+
return this.withMiddleware('deleteMany', args, async () => {
|
|
685
|
+
const params = [];
|
|
686
|
+
const where = this.buildWhere(args.where, params);
|
|
687
|
+
this.assertCompiledWhere(where, args.allowFullTableScan, 'deleteMany');
|
|
688
|
+
const filter = where ? ` filter ${where}` : '';
|
|
689
|
+
const { rowCount } = await this.exec(`${this.table}${filter} delete`, params, args.timeout);
|
|
690
|
+
return { count: rowCount };
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
async upsert(args) {
|
|
694
|
+
return this.withMiddleware('upsert', args, async () => {
|
|
695
|
+
const createData = this.applyPkDefault(args.create);
|
|
696
|
+
const pkCol = this.meta.primaryKey[0];
|
|
697
|
+
if (!pkCol || this.meta.primaryKey.length !== 1) {
|
|
698
|
+
throw new errors_js_1.UnsupportedFeatureError('composite-key upsert', 'PowDB', `table "${this.table}"`);
|
|
699
|
+
}
|
|
700
|
+
const params = [];
|
|
701
|
+
const createBody = this.scalarData(createData)
|
|
702
|
+
.map((a) => `${a.col.name} := ${this.writeRef(a.value, a.col, params)}`)
|
|
703
|
+
.join(', ');
|
|
704
|
+
const updateBody = this.buildUpdateAssignments(args.update, params);
|
|
705
|
+
// PowDB 0.7.0's `upsert` statement does NOT accept a trailing `returning`
|
|
706
|
+
// (verified: "unexpected trailing token … 'returning'"), because it is one
|
|
707
|
+
// atomic insert-or-update, not two branches. So upsert alone keeps the
|
|
708
|
+
// reselect-by-PK fetch; create/update/delete all use `returning`.
|
|
709
|
+
await this.exec(`upsert ${this.table} on .${pkCol} { ${createBody} } on conflict { ${updateBody} }`, params, args.timeout);
|
|
710
|
+
const pkField = this.meta.reverseColumnMap[pkCol] ?? pkCol;
|
|
711
|
+
const row = await this.reselectByPk(createData[pkField], args.timeout);
|
|
712
|
+
if (!row)
|
|
713
|
+
throw new errors_js_1.NotFoundError({ table: this.table, where: createData });
|
|
714
|
+
return row;
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
// -------------------------------------------------------------------------
|
|
718
|
+
// Aggregates
|
|
719
|
+
// -------------------------------------------------------------------------
|
|
720
|
+
async count(args = {}) {
|
|
721
|
+
return this.withMiddleware('count', (args ?? {}), async () => {
|
|
722
|
+
const params = [];
|
|
723
|
+
const where = this.buildWhere(args.where, params);
|
|
724
|
+
const filter = where ? ` filter ${where}` : '';
|
|
725
|
+
const { rows } = await this.exec(`count(${this.table}${filter})`, params, args.timeout);
|
|
726
|
+
return Number((rows[0]?.value ?? rows[0]?.count ?? 0));
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
async aggregate(args) {
|
|
730
|
+
return this.withMiddleware('aggregate', args, async () => {
|
|
731
|
+
// One scalar query per aggregate — PowDB's bare-projection aggregate is broken.
|
|
732
|
+
const result = {};
|
|
733
|
+
const filterParams = [];
|
|
734
|
+
const where = this.buildWhere(args.where, filterParams);
|
|
735
|
+
const filter = where ? ` filter ${where}` : '';
|
|
736
|
+
const scalar = async (expr) => {
|
|
737
|
+
const params = [...filterParams];
|
|
738
|
+
const { rows } = await this.exec(expr, params, args.timeout);
|
|
739
|
+
const v = rows[0]?.value;
|
|
740
|
+
return v == null || v === 'null' ? null : Number(v);
|
|
741
|
+
};
|
|
742
|
+
if (args._count) {
|
|
743
|
+
if (args._count === true) {
|
|
744
|
+
result._count = (await scalar(`count(${this.table}${filter})`)) ?? 0;
|
|
745
|
+
}
|
|
746
|
+
else {
|
|
747
|
+
const counts = {};
|
|
748
|
+
for (const field of Object.keys(args._count).filter((f) => args._count[f])) {
|
|
749
|
+
counts[field] = (await scalar(`count(${this.table}${filter} { ${this.ref(field)} })`)) ?? 0;
|
|
750
|
+
}
|
|
751
|
+
result._count = counts;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
for (const fn of ['_sum', '_avg', '_min', '_max']) {
|
|
755
|
+
const spec = args[fn];
|
|
756
|
+
if (!spec)
|
|
757
|
+
continue;
|
|
758
|
+
const acc = {};
|
|
759
|
+
for (const field of Object.keys(spec).filter((f) => spec[f])) {
|
|
760
|
+
const powfn = fn.slice(1); // sum/avg/min/max
|
|
761
|
+
acc[field] = await scalar(`${powfn}(${this.table}${filter} { ${this.ref(field)} })`);
|
|
762
|
+
}
|
|
763
|
+
result[fn] = acc;
|
|
764
|
+
}
|
|
765
|
+
return result;
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
async groupBy(args) {
|
|
769
|
+
return this.withMiddleware('groupBy', args, async () => {
|
|
770
|
+
const params = [];
|
|
771
|
+
const where = this.buildWhere(args.where, params);
|
|
772
|
+
const filter = where ? ` filter ${where}` : '';
|
|
773
|
+
const groupKeys = args.by.map((f) => this.ref(f));
|
|
774
|
+
// Safe aliases — PowQL rejects reserved-word aliases (e.g. `count:`).
|
|
775
|
+
const aliasMap = [];
|
|
776
|
+
let n = 0;
|
|
777
|
+
const proj = args.by.map((f) => this.ref(f));
|
|
778
|
+
if (args._count) {
|
|
779
|
+
aliasMap.push({ alias: `agg_${n++}`, fn: 'count', field: null, outKey: '_count' });
|
|
780
|
+
}
|
|
781
|
+
for (const fn of ['_sum', '_avg', '_min', '_max']) {
|
|
782
|
+
const spec = args[fn];
|
|
783
|
+
if (!spec)
|
|
784
|
+
continue;
|
|
785
|
+
for (const field of Object.keys(spec).filter((f) => spec[f])) {
|
|
786
|
+
aliasMap.push({ alias: `agg_${n++}`, fn: fn.slice(1), field, outKey: `${fn}:${field}` });
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
for (const a of aliasMap) {
|
|
790
|
+
proj.push(`${a.alias}: ${a.fn}(${a.field ? this.ref(a.field) : `.${this.meta.primaryKey[0]}`})`);
|
|
791
|
+
}
|
|
792
|
+
const having = this.buildHaving(args.having, params);
|
|
793
|
+
const order = this.buildOrder(args.orderBy);
|
|
794
|
+
const powql = `${this.table}${filter} group ${groupKeys.join(', ')}${having}${order} { ${proj.join(', ')} }`;
|
|
795
|
+
const { rows } = await this.exec(powql, params, args.timeout);
|
|
796
|
+
// Reshape: group keys → camel fields + coerced; aggregates → nested {_sum:{field}}.
|
|
797
|
+
return rows.map((raw) => {
|
|
798
|
+
const out = {};
|
|
799
|
+
for (const f of args.by) {
|
|
800
|
+
const col = this.column(f);
|
|
801
|
+
out[f] =
|
|
802
|
+
typeof raw[col.name] === 'string' ? coerceScalar(raw[col.name], col.tsType) : raw[col.name];
|
|
803
|
+
}
|
|
804
|
+
for (const a of aliasMap) {
|
|
805
|
+
const val = raw[a.alias];
|
|
806
|
+
const num = val == null || val === 'null' ? null : Number(val);
|
|
807
|
+
if (a.outKey === '_count')
|
|
808
|
+
out._count = num ?? 0;
|
|
809
|
+
else {
|
|
810
|
+
const [bucket, field] = a.outKey.split(':');
|
|
811
|
+
out[bucket] ??= {};
|
|
812
|
+
out[bucket][field] = num;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
return out;
|
|
816
|
+
});
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
/** `having <expr>` over group aggregates (count/sum/avg/min/max). */
|
|
820
|
+
buildHaving(having, params) {
|
|
821
|
+
if (!having)
|
|
822
|
+
return '';
|
|
823
|
+
const conds = [];
|
|
824
|
+
const cmp = (expr, filter) => {
|
|
825
|
+
if (typeof filter === 'number')
|
|
826
|
+
return `${expr} = ${this.param(filter, params)}`;
|
|
827
|
+
const f = filter;
|
|
828
|
+
const ops = { equals: '=', gt: '>', gte: '>=', lt: '<', lte: '<=', not: '!=' };
|
|
829
|
+
return Object.entries(f)
|
|
830
|
+
.filter(([k]) => ops[k])
|
|
831
|
+
.map(([k, v]) => `${expr} ${ops[k]} ${this.param(v, params)}`)
|
|
832
|
+
.join(' and ');
|
|
833
|
+
};
|
|
834
|
+
for (const [key, spec] of Object.entries(having)) {
|
|
835
|
+
if (spec == null)
|
|
836
|
+
continue;
|
|
837
|
+
if (key === '_count') {
|
|
838
|
+
conds.push(cmp(`count(.${this.meta.primaryKey[0]})`, spec));
|
|
839
|
+
}
|
|
840
|
+
else {
|
|
841
|
+
for (const [fn, filter] of Object.entries(spec)) {
|
|
842
|
+
if (filter == null)
|
|
843
|
+
continue;
|
|
844
|
+
conds.push(cmp(`${fn.slice(1)}(${this.ref(key)})`, filter));
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
return conds.length ? ` having ${conds.join(' and ')}` : '';
|
|
849
|
+
}
|
|
850
|
+
// -------------------------------------------------------------------------
|
|
851
|
+
// Streaming / unsupported
|
|
852
|
+
// -------------------------------------------------------------------------
|
|
853
|
+
// biome-ignore lint/correctness/useYield: intentionally throws before yielding — PowDB has no server cursor.
|
|
854
|
+
async *findManyStream() {
|
|
855
|
+
throw new errors_js_1.UnsupportedFeatureError('cursor streaming (findManyStream)', 'PowDB', 'PowDB has no server-side cursor; page with findMany({ limit, offset }) instead');
|
|
856
|
+
}
|
|
857
|
+
// -------------------------------------------------------------------------
|
|
858
|
+
// Reselect helper (upsert only — PowDB's upsert has no `returning`)
|
|
859
|
+
// -------------------------------------------------------------------------
|
|
860
|
+
/** Reselect a single row by its single-column primary key value. */
|
|
861
|
+
async reselectByPk(pkValue, timeout) {
|
|
862
|
+
const pkField = this.meta.reverseColumnMap[this.meta.primaryKey[0]] ?? this.meta.primaryKey[0];
|
|
863
|
+
const rows = await this.runFind({
|
|
864
|
+
where: { [pkField]: pkValue },
|
|
865
|
+
limit: 1,
|
|
866
|
+
timeout,
|
|
867
|
+
});
|
|
868
|
+
return rows.length ? this.shape(rows)[0] : null;
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* Empty-where guard — blocks accidental whole-table writes. Mirrors the SQL
|
|
872
|
+
* path's `assertMutationHasPredicate` (query/builder.ts): it gates on the
|
|
873
|
+
* *compiled* PowQL filter fragment, NOT the shape of the `where` object. A
|
|
874
|
+
* `where` whose conditions all evaporate during compilation — `{}`,
|
|
875
|
+
* `{ id: undefined }`, `{ OR: [] }`, `{ AND: [] }`, `{ NOT: {} }`,
|
|
876
|
+
* `{ OR: [{ f: undefined }] }` — compiles to the empty string and is refused,
|
|
877
|
+
* because emitting a filter-less write would hit every row.
|
|
878
|
+
*/
|
|
879
|
+
assertCompiledWhere(compiledWhere, allow, action) {
|
|
880
|
+
if (allow === true)
|
|
881
|
+
return;
|
|
882
|
+
if (compiledWhere.length > 0)
|
|
883
|
+
return;
|
|
884
|
+
throw new errors_js_1.ValidationError(`[turbine] ${action} on "${this.table}" refused: the \`where\` clause is empty. ` +
|
|
885
|
+
`Pass \`allowFullTableScan: true\` to opt in, or check that your filter values are defined.`);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
exports.PowqlInterface = PowqlInterface;
|
|
889
|
+
/** Coerce a group-key scalar string by the column's TS type (numbers/bools). */
|
|
890
|
+
function coerceScalar(raw, tsType) {
|
|
891
|
+
const ts = tsType.replace(/\s*\|\s*null$/i, '').trim();
|
|
892
|
+
if (raw === 'null')
|
|
893
|
+
return null;
|
|
894
|
+
if (ts === 'number' || ts === 'bigint') {
|
|
895
|
+
const n = Number(raw);
|
|
896
|
+
return Number.isFinite(n) ? n : raw;
|
|
897
|
+
}
|
|
898
|
+
if (ts === 'boolean')
|
|
899
|
+
return raw === 'true';
|
|
900
|
+
if (ts === 'Date')
|
|
901
|
+
return new Date(Number(raw) / 1000);
|
|
902
|
+
return raw;
|
|
903
|
+
}
|