turbine-orm 0.71.0 → 0.72.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 +2 -2
- package/dist/cjs/client.d.ts +0 -18
- package/dist/cjs/client.js +2 -29
- package/dist/cjs/connection-url.d.ts +30 -0
- package/dist/cjs/connection-url.js +15 -17
- package/dist/cjs/powql.d.ts +38 -1
- package/dist/cjs/powql.js +106 -18
- package/dist/cjs/query/aggregates.d.ts +0 -13
- package/dist/cjs/query/aggregates.js +81 -33
- package/dist/cjs/query/batched-loader.d.ts +13 -1
- package/dist/cjs/query/batched-loader.js +46 -11
- package/dist/cjs/query/builder.d.ts +13 -0
- package/dist/cjs/query/builder.js +104 -14
- package/dist/cjs/query/compound-unique.js +29 -5
- package/dist/cjs/query/relation-names.d.ts +52 -0
- package/dist/cjs/query/relation-names.js +120 -0
- package/dist/cjs/query/relations.d.ts +11 -6
- package/dist/cjs/query/relations.js +45 -27
- package/dist/cjs/query/utils.d.ts +107 -3
- package/dist/cjs/query/utils.js +408 -7
- package/dist/cjs/query/where-compile.js +9 -4
- package/dist/cjs/query/where.js +9 -5
- package/dist/client.d.ts +0 -18
- package/dist/client.js +2 -29
- package/dist/connection-url.d.ts +30 -0
- package/dist/connection-url.js +15 -18
- package/dist/powql.d.ts +38 -1
- package/dist/powql.js +107 -19
- package/dist/query/aggregates.d.ts +0 -13
- package/dist/query/aggregates.js +82 -34
- package/dist/query/batched-loader.d.ts +13 -1
- package/dist/query/batched-loader.js +47 -12
- package/dist/query/builder.d.ts +13 -0
- package/dist/query/builder.js +105 -15
- package/dist/query/compound-unique.js +30 -6
- package/dist/query/relation-names.d.ts +52 -0
- package/dist/query/relation-names.js +117 -0
- package/dist/query/relations.d.ts +11 -6
- package/dist/query/relations.js +47 -29
- package/dist/query/utils.d.ts +107 -3
- package/dist/query/utils.js +404 -8
- package/dist/query/where-compile.js +10 -5
- package/dist/query/where.js +10 -6
- package/package.json +5 -3
package/dist/connection-url.d.ts
CHANGED
|
@@ -158,3 +158,33 @@ export interface ConnectionOptionsConfig {
|
|
|
158
158
|
* a value that can carry a space can carry a second `-c`.
|
|
159
159
|
*/
|
|
160
160
|
export declare function withStatementTimeoutOption(config: ConnectionOptionsConfig, statementTimeoutMs: number): ConnectionOptionsConfig;
|
|
161
|
+
/**
|
|
162
|
+
* `connectionString` with `setting` appended to an existing `options` query
|
|
163
|
+
* parameter, or `null` when it carries none (in which case the caller uses the
|
|
164
|
+
* `options` config field, which the connection string does not override).
|
|
165
|
+
*
|
|
166
|
+
* Only the query string is rewritten, never the userinfo or host, so a
|
|
167
|
+
* percent-encoded password cannot be mangled by a round trip through `URL`. The
|
|
168
|
+
* split is on the first `?`, which is also where pg's own parser puts the
|
|
169
|
+
* query-string boundary: a connection string with an unencoded `?` inside the
|
|
170
|
+
* password is not parseable by pg either, so there is no shape this handles
|
|
171
|
+
* differently from the driver.
|
|
172
|
+
*
|
|
173
|
+
* Shared with `client.ts`, which needs the identical merge for
|
|
174
|
+
* `plan_cache_mode`. It was duplicated there until 2026-08-16, and the reason
|
|
175
|
+
* is worth keeping because it was wrong in an instructive way: importing it
|
|
176
|
+
* from `client.ts` adds an import edge from a module ~100 test processes load,
|
|
177
|
+
* and this file's merged coverage read 40% while measuring 100% in isolation,
|
|
178
|
+
* which moved the aggregate gate 75.48% -> 75.03% against a 75% floor. The
|
|
179
|
+
* conclusion drawn was that the refactor cost the gate's headroom.
|
|
180
|
+
*
|
|
181
|
+
* The 40% was a c8 merge defect, not an import cost: c8's V8-level merge is not
|
|
182
|
+
* monotonic, and once the report merges at the istanbul level instead this file
|
|
183
|
+
* reads 100% with the edge in place (see `//merge-bug` in .c8rc.json). So the
|
|
184
|
+
* duplication was paying for an artifact, and the two copies are now one.
|
|
185
|
+
*
|
|
186
|
+
* Keep this module import-free regardless. That part was never about coverage:
|
|
187
|
+
* `query/` and `cli/` both reach it, and an edge back to `client.ts` would be
|
|
188
|
+
* the cycle `scripts/check-import-cycles.mjs` exists to refuse.
|
|
189
|
+
*/
|
|
190
|
+
export declare function mergeConnectionStringOptions(connectionString: string, setting: string): string | null;
|
package/dist/connection-url.js
CHANGED
|
@@ -256,27 +256,24 @@ export function withStatementTimeoutOption(config, statementTimeoutMs) {
|
|
|
256
256
|
* password is not parseable by pg either, so there is no shape this handles
|
|
257
257
|
* differently from the driver.
|
|
258
258
|
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
259
|
+
* Shared with `client.ts`, which needs the identical merge for
|
|
260
|
+
* `plan_cache_mode`. It was duplicated there until 2026-08-16, and the reason
|
|
261
|
+
* is worth keeping because it was wrong in an instructive way: importing it
|
|
262
|
+
* from `client.ts` adds an import edge from a module ~100 test processes load,
|
|
263
|
+
* and this file's merged coverage read 40% while measuring 100% in isolation,
|
|
264
|
+
* which moved the aggregate gate 75.48% -> 75.03% against a 75% floor. The
|
|
265
|
+
* conclusion drawn was that the refactor cost the gate's headroom.
|
|
264
266
|
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
* reports 40% in the merged report while measuring 100% in isolation. Real
|
|
270
|
-
* coverage is unchanged either way, but the aggregate gate moved 75.48% ->
|
|
271
|
-
* 75.03% against a 75% floor, i.e. the refactor spent almost all the headroom
|
|
272
|
-
* on a reporting artifact.
|
|
267
|
+
* The 40% was a c8 merge defect, not an import cost: c8's V8-level merge is not
|
|
268
|
+
* monotonic, and once the report merges at the istanbul level instead this file
|
|
269
|
+
* reads 100% with the edge in place (see `//merge-bug` in .c8rc.json). So the
|
|
270
|
+
* duplication was paying for an artifact, and the two copies are now one.
|
|
273
271
|
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
* import-free regardless.
|
|
272
|
+
* Keep this module import-free regardless. That part was never about coverage:
|
|
273
|
+
* `query/` and `cli/` both reach it, and an edge back to `client.ts` would be
|
|
274
|
+
* the cycle `scripts/check-import-cycles.mjs` exists to refuse.
|
|
278
275
|
*/
|
|
279
|
-
function mergeConnectionStringOptions(connectionString, setting) {
|
|
276
|
+
export function mergeConnectionStringOptions(connectionString, setting) {
|
|
280
277
|
const q = connectionString.indexOf('?');
|
|
281
278
|
if (q === -1)
|
|
282
279
|
return null;
|
package/dist/powql.d.ts
CHANGED
|
@@ -67,8 +67,34 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
|
|
|
67
67
|
private readonly onQuery?;
|
|
68
68
|
private warnedUnlimited;
|
|
69
69
|
constructor(pool: PowdbPool, table: string, schema: SchemaMetadata, middlewares?: MiddlewareFn[], options?: QueryInterfaceOptions);
|
|
70
|
-
/**
|
|
70
|
+
/**
|
|
71
|
+
* Resolve a camelCase field name (or raw snake) to its column metadata.
|
|
72
|
+
*
|
|
73
|
+
* THE single key→column decision on this engine: every PowQL surface
|
|
74
|
+
* reaches a column through here, which is why PowDB never grew the
|
|
75
|
+
* per-argument asymmetry the SQL builders did. It resolves through the
|
|
76
|
+
* shared {@link resolveColumnName} so both engines answer alike; the direct
|
|
77
|
+
* name/field match stays as a fallback, since a PowDB schema may carry names
|
|
78
|
+
* outside the snake_case↔camelCase round trip and narrowing that would be a
|
|
79
|
+
* silent break rather than a fix.
|
|
80
|
+
*/
|
|
71
81
|
private column;
|
|
82
|
+
/**
|
|
83
|
+
* {@link lookupGroupKey} for the `${aggKey}:${field}` composite the aggregate
|
|
84
|
+
* ordering registry is keyed by: only the FIELD half needs canonicalizing.
|
|
85
|
+
*/
|
|
86
|
+
private aggOrderExpr;
|
|
87
|
+
/**
|
|
88
|
+
* Read a caller-supplied groupBy result key out of a registry keyed by the
|
|
89
|
+
* canonical FIELD name.
|
|
90
|
+
*
|
|
91
|
+
* `by`, `orderBy` and `having` are three arguments of one call, each free to
|
|
92
|
+
* spell a column either way, so `by`'s choice must not decide what the others
|
|
93
|
+
* may name. Try the key as written (which is what carries a JSON group-key
|
|
94
|
+
* ALIAS, not a column), then its canonical field. Mirrors `lookupGroupKey` in
|
|
95
|
+
* query/aggregates.ts.
|
|
96
|
+
*/
|
|
97
|
+
private lookupGroupKey;
|
|
72
98
|
/**
|
|
73
99
|
* PowQL column reference for a field: a dotted field reference
|
|
74
100
|
* (`.snake_name`), or `alias.snake_name` when an `alias` is supplied (the F2
|
|
@@ -149,6 +175,17 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
|
|
|
149
175
|
* which is precisely the "reports a guarantee that was never made" failure the
|
|
150
176
|
* option's refusal exists to prevent.
|
|
151
177
|
*/
|
|
178
|
+
/**
|
|
179
|
+
* `args` with every `with` relation key replaced by the relation's DECLARED
|
|
180
|
+
* spelling, so PowDB accepts a snake_case relation name exactly as the SQL
|
|
181
|
+
* engines do. Returns `args` by reference when nothing needed rewriting.
|
|
182
|
+
*
|
|
183
|
+
* PowqlInterface is a parallel implementation rather than a subclass, so
|
|
184
|
+
* nothing makes this happen here automatically: a rule adopted only on the
|
|
185
|
+
* SQL side becomes an engine that disagrees about which queries are valid,
|
|
186
|
+
* which is the divergence class the projection resolver already cost.
|
|
187
|
+
*/
|
|
188
|
+
private withDeclaredRelationNames;
|
|
152
189
|
private assertNoForceCustomPlan;
|
|
153
190
|
private assertPagination;
|
|
154
191
|
/** A predicate that is always false, the empty-`in` / contradiction sentinel. */
|
package/dist/powql.js
CHANGED
|
@@ -41,11 +41,12 @@ import { ALL_POWDB_CAPABILITIES, coerceNativeValue, isJsonColumn, isPowdbDatetim
|
|
|
41
41
|
import { assertAggregatePiiOptIn } from './query/aggregates.js';
|
|
42
42
|
import { expandCompoundUniqueWhere } from './query/compound-unique.js';
|
|
43
43
|
import { isJsonFilter, isRelationPickOrderBy, orderByEntries } from './query/filters.js';
|
|
44
|
+
import { normalizeWithClause } from './query/relation-names.js';
|
|
44
45
|
// The privilege sentinel and its resolver: `includePii` / `allowFullTableScan`
|
|
45
46
|
// are unlocked ONLY by the UNSAFE symbol, on this engine exactly as on the SQL
|
|
46
47
|
// engines, so a spread request body cannot turn either on here either.
|
|
47
48
|
import { assertDirectionToken, resolveUnsafeFlag, UNSAFE } from './query/types.js';
|
|
48
|
-
import { escapeLike, ownLookup, relationInProjectionMessage, selectNamesNothingMessage, selectOmitExclusiveMessage, } from './query/utils.js';
|
|
49
|
+
import { escapeLike, ownLookup, relationInProjectionMessage, resolveColumnName, resolveRelationDef, selectNamesNothingMessage, selectOmitExclusiveMessage, } from './query/utils.js';
|
|
49
50
|
import { assertJsonFilterKeys, jsonStringEntries } from './query/where.js';
|
|
50
51
|
import { normalizeKeyColumns, snakeToCamel, } from './schema.js';
|
|
51
52
|
/**
|
|
@@ -195,9 +196,20 @@ export class PowqlInterface {
|
|
|
195
196
|
// -------------------------------------------------------------------------
|
|
196
197
|
// Column / value helpers
|
|
197
198
|
// -------------------------------------------------------------------------
|
|
198
|
-
/**
|
|
199
|
+
/**
|
|
200
|
+
* Resolve a camelCase field name (or raw snake) to its column metadata.
|
|
201
|
+
*
|
|
202
|
+
* THE single key→column decision on this engine: every PowQL surface
|
|
203
|
+
* reaches a column through here, which is why PowDB never grew the
|
|
204
|
+
* per-argument asymmetry the SQL builders did. It resolves through the
|
|
205
|
+
* shared {@link resolveColumnName} so both engines answer alike; the direct
|
|
206
|
+
* name/field match stays as a fallback, since a PowDB schema may carry names
|
|
207
|
+
* outside the snake_case↔camelCase round trip and narrowing that would be a
|
|
208
|
+
* silent break rather than a fix.
|
|
209
|
+
*/
|
|
199
210
|
column(field) {
|
|
200
|
-
const
|
|
211
|
+
const resolved = resolveColumnName(this.meta, field);
|
|
212
|
+
const snake = resolved ?? ownLookup(this.meta.columnMap, field) ?? field;
|
|
201
213
|
const col = this.meta.columns.find((c) => c.name === snake || c.field === field);
|
|
202
214
|
if (!col) {
|
|
203
215
|
throw new ValidationError(`[turbine] Unknown column "${field}" on table "${this.table}". Known: ${this.meta.columns
|
|
@@ -206,6 +218,41 @@ export class PowqlInterface {
|
|
|
206
218
|
}
|
|
207
219
|
return col;
|
|
208
220
|
}
|
|
221
|
+
/**
|
|
222
|
+
* {@link lookupGroupKey} for the `${aggKey}:${field}` composite the aggregate
|
|
223
|
+
* ordering registry is keyed by: only the FIELD half needs canonicalizing.
|
|
224
|
+
*/
|
|
225
|
+
aggOrderExpr(registry, aggKey, field) {
|
|
226
|
+
try {
|
|
227
|
+
return registry.get(`${aggKey}:${this.column(field).field}`);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return undefined; // not a column: the caller's own E003 is the right error
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Read a caller-supplied groupBy result key out of a registry keyed by the
|
|
235
|
+
* canonical FIELD name.
|
|
236
|
+
*
|
|
237
|
+
* `by`, `orderBy` and `having` are three arguments of one call, each free to
|
|
238
|
+
* spell a column either way, so `by`'s choice must not decide what the others
|
|
239
|
+
* may name. Try the key as written (which is what carries a JSON group-key
|
|
240
|
+
* ALIAS, not a column), then its canonical field. Mirrors `lookupGroupKey` in
|
|
241
|
+
* query/aggregates.ts.
|
|
242
|
+
*/
|
|
243
|
+
lookupGroupKey(registry, key) {
|
|
244
|
+
const direct = registry.get(key);
|
|
245
|
+
if (direct !== undefined)
|
|
246
|
+
return direct;
|
|
247
|
+
let field;
|
|
248
|
+
try {
|
|
249
|
+
field = this.column(key).field;
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return undefined; // not a column: the caller's own E003 below is the right error
|
|
253
|
+
}
|
|
254
|
+
return registry.get(field);
|
|
255
|
+
}
|
|
209
256
|
/**
|
|
210
257
|
* PowQL column reference for a field: a dotted field reference
|
|
211
258
|
* (`.snake_name`), or `alias.snake_name` when an `alias` is supplied (the F2
|
|
@@ -320,6 +367,22 @@ export class PowqlInterface {
|
|
|
320
367
|
* which is precisely the "reports a guarantee that was never made" failure the
|
|
321
368
|
* option's refusal exists to prevent.
|
|
322
369
|
*/
|
|
370
|
+
/**
|
|
371
|
+
* `args` with every `with` relation key replaced by the relation's DECLARED
|
|
372
|
+
* spelling, so PowDB accepts a snake_case relation name exactly as the SQL
|
|
373
|
+
* engines do. Returns `args` by reference when nothing needed rewriting.
|
|
374
|
+
*
|
|
375
|
+
* PowqlInterface is a parallel implementation rather than a subclass, so
|
|
376
|
+
* nothing makes this happen here automatically: a rule adopted only on the
|
|
377
|
+
* SQL side becomes an engine that disagrees about which queries are valid,
|
|
378
|
+
* which is the divergence class the projection resolver already cost.
|
|
379
|
+
*/
|
|
380
|
+
withDeclaredRelationNames(args) {
|
|
381
|
+
if (!args?.with)
|
|
382
|
+
return args;
|
|
383
|
+
const normalized = normalizeWithClause(this.schema, this.table, args.with);
|
|
384
|
+
return normalized === args.with ? args : { ...args, with: normalized };
|
|
385
|
+
}
|
|
323
386
|
assertNoForceCustomPlan(args) {
|
|
324
387
|
if (args?.forceCustomPlan !== true)
|
|
325
388
|
return;
|
|
@@ -760,8 +823,8 @@ export class PowqlInterface {
|
|
|
760
823
|
else if (key === 'NOT') {
|
|
761
824
|
scalar[key] = await this.resolveRelationFilters(value, timeout);
|
|
762
825
|
}
|
|
763
|
-
else if (this.meta.relations
|
|
764
|
-
relConds.push(await this.resolveRelationCondition(this.meta.relations
|
|
826
|
+
else if (resolveRelationDef(this.meta.relations, key)) {
|
|
827
|
+
relConds.push(await this.resolveRelationCondition(resolveRelationDef(this.meta.relations, key), value, timeout));
|
|
765
828
|
}
|
|
766
829
|
else {
|
|
767
830
|
scalar[key] = value;
|
|
@@ -896,7 +959,7 @@ export class PowqlInterface {
|
|
|
896
959
|
* for a misspelling that is not there. Same message as the SQL engines.
|
|
897
960
|
*/
|
|
898
961
|
projectionColumn(field, clause) {
|
|
899
|
-
if (
|
|
962
|
+
if (resolveRelationDef(this.meta.relations, field)) {
|
|
900
963
|
throw new ValidationError(relationInProjectionMessage(this.table, field, clause));
|
|
901
964
|
}
|
|
902
965
|
return this.column(field).name;
|
|
@@ -1264,6 +1327,7 @@ export class PowqlInterface {
|
|
|
1264
1327
|
// -------------------------------------------------------------------------
|
|
1265
1328
|
async findMany(args = {}) {
|
|
1266
1329
|
this.assertNoForceCustomPlan(args);
|
|
1330
|
+
args = this.withDeclaredRelationNames(args);
|
|
1267
1331
|
return this.withMiddleware('findMany', args, async () => {
|
|
1268
1332
|
// `limit: 0` means "no rows" (SQL `LIMIT 0`), and answering it client-side
|
|
1269
1333
|
// is correct on every engine version: PowDB's projection fast path returned
|
|
@@ -1345,6 +1409,12 @@ export class PowqlInterface {
|
|
|
1345
1409
|
const nest = nestedPlans.length > 0 || linkPlans.length > 0;
|
|
1346
1410
|
const alias = nest ? 't0' : undefined;
|
|
1347
1411
|
const where = this.buildWhere(resolvedWhere, params, alias);
|
|
1412
|
+
// PowQL's `distinct` is row-wide, so these names never reach the emitted
|
|
1413
|
+
// statement. They are still caller-supplied names, and a name resolves or
|
|
1414
|
+
// throws: reading the array for its LENGTH alone let `distinct: ['nope']`
|
|
1415
|
+
// succeed here while every SQL engine refuses it. Validation only.
|
|
1416
|
+
for (const key of args.distinct ?? [])
|
|
1417
|
+
this.column(key);
|
|
1348
1418
|
const distinct = args.distinct?.length ? ' distinct' : '';
|
|
1349
1419
|
const filter = where ? ` filter ${where}` : '';
|
|
1350
1420
|
const order = this.buildOrder(args.orderBy, params, alias);
|
|
@@ -1409,6 +1479,7 @@ export class PowqlInterface {
|
|
|
1409
1479
|
}
|
|
1410
1480
|
async findUnique(args) {
|
|
1411
1481
|
this.assertNoForceCustomPlan(args);
|
|
1482
|
+
args = this.withDeclaredRelationNames(args);
|
|
1412
1483
|
// Prisma compound-unique selector → column conjunction (engine parity with
|
|
1413
1484
|
// the SQL findUnique family; pure metadata, so this is a one-line adoption).
|
|
1414
1485
|
if (args.where) {
|
|
@@ -1433,6 +1504,7 @@ export class PowqlInterface {
|
|
|
1433
1504
|
});
|
|
1434
1505
|
}
|
|
1435
1506
|
async findFirst(args = {}) {
|
|
1507
|
+
args = this.withDeclaredRelationNames(args);
|
|
1436
1508
|
this.assertNoForceCustomPlan(args);
|
|
1437
1509
|
return this.withMiddleware('findFirst', args, async () => {
|
|
1438
1510
|
const { rows, native, nestedPlans, linkPlans, residualWith, forcedPk } = await this.runFind({ ...args, limit: 1 }, 'findFirst');
|
|
@@ -2364,7 +2436,7 @@ export class PowqlInterface {
|
|
|
2364
2436
|
for (const [field, value] of Object.entries(data)) {
|
|
2365
2437
|
if (value === undefined)
|
|
2366
2438
|
continue;
|
|
2367
|
-
if (this.meta.relations
|
|
2439
|
+
if (resolveRelationDef(this.meta.relations, field)) {
|
|
2368
2440
|
throw new UnsupportedFeatureError('nested writes', 'PowDB', `relation "${field}", nested writes need create()/update(), not createMany()/upsert()`);
|
|
2369
2441
|
}
|
|
2370
2442
|
out.push({ col: this.column(field), value });
|
|
@@ -2382,7 +2454,11 @@ export class PowqlInterface {
|
|
|
2382
2454
|
for (const pk of this.meta.primaryKey) {
|
|
2383
2455
|
const field = this.meta.reverseColumnMap[pk] ?? pk;
|
|
2384
2456
|
const col = this.meta.columns.find((c) => c.name === pk);
|
|
2457
|
+
// "Supplied" means under EITHER spelling: `create` resolves both, so
|
|
2458
|
+
// testing the camelCase field alone made `{ user_id: 'x' }` look absent,
|
|
2459
|
+
// generated a UUID under `userId`, and wrote the column twice.
|
|
2385
2460
|
if (out[field] == null &&
|
|
2461
|
+
(field === pk || out[pk] == null) &&
|
|
2386
2462
|
col?.hasDefault &&
|
|
2387
2463
|
!col.isGenerated &&
|
|
2388
2464
|
col.tsType.replace(/\s*\|\s*null$/, '').trim() === 'string') {
|
|
@@ -2521,7 +2597,7 @@ export class PowqlInterface {
|
|
|
2521
2597
|
for (const [field, value] of Object.entries(data)) {
|
|
2522
2598
|
if (value === undefined)
|
|
2523
2599
|
continue;
|
|
2524
|
-
if (this.meta.relations
|
|
2600
|
+
if (resolveRelationDef(this.meta.relations, field)) {
|
|
2525
2601
|
throw new UnsupportedFeatureError('nested writes', 'PowDB', `relation "${field}", nested writes need create()/update(), not updateMany()/upsert()`);
|
|
2526
2602
|
}
|
|
2527
2603
|
const colMeta = this.column(field);
|
|
@@ -2684,7 +2760,10 @@ export class PowqlInterface {
|
|
|
2684
2760
|
// reselect-by-PK fetch; create/update/delete all use `returning`.
|
|
2685
2761
|
await this.exec(`upsert ${this.qt} on ${this.colRefName(pkCol)} { ${createBody} } on conflict { ${updateBody} }`, params, args.timeout, 'upsert');
|
|
2686
2762
|
const pkField = this.meta.reverseColumnMap[pkCol] ?? pkCol;
|
|
2687
|
-
|
|
2763
|
+
// Either spelling, as `upsertComposite` already does below: reading the
|
|
2764
|
+
// camelCase field alone reselected `undefined` for a snake-spelled PK and
|
|
2765
|
+
// reported a write that had SUCCEEDED as a NotFoundError.
|
|
2766
|
+
const row = await this.reselectByPk(createData[pkField] ?? createData[pkCol], args.timeout);
|
|
2688
2767
|
if (!row)
|
|
2689
2768
|
throw new NotFoundError({ table: this.table, where: createData });
|
|
2690
2769
|
return row;
|
|
@@ -2852,13 +2931,18 @@ export class PowqlInterface {
|
|
|
2852
2931
|
if (typeof entry === 'string') {
|
|
2853
2932
|
const col = this.column(entry);
|
|
2854
2933
|
assertAggregatePiiOptIn(this.table, this.meta, entry, col.name, 'groupBy `by` key', resolveUnsafeFlag(args.includePii, 'includePii'));
|
|
2855
|
-
|
|
2856
|
-
|
|
2934
|
+
// Keyed by the canonical FIELD, never by whichever of the column's
|
|
2935
|
+
// two legal spellings the caller wrote (`column` accepts both), so
|
|
2936
|
+
// neither the result shape nor the keys `orderBy` accepts depends on
|
|
2937
|
+
// that choice; `lookupGroupKey` reconciles the other spelling. Same
|
|
2938
|
+
// rule and same result shape as the SQL groupBy (query/aggregates.ts).
|
|
2939
|
+
claim(col.field, `column "${col.name}"`);
|
|
2940
|
+
if (col.name !== col.field)
|
|
2857
2941
|
claim(col.name, `column "${col.name}"`);
|
|
2858
2942
|
groupExprs.push(this.colRefName(col.name));
|
|
2859
2943
|
proj.push(this.colRefName(col.name));
|
|
2860
|
-
byOrderExprs.set(
|
|
2861
|
-
byReaders.push({ kind: 'plain', resultKey:
|
|
2944
|
+
byOrderExprs.set(col.field, this.colRefName(col.name));
|
|
2945
|
+
byReaders.push({ kind: 'plain', resultKey: col.field, rowKey: col.name, col });
|
|
2862
2946
|
}
|
|
2863
2947
|
else {
|
|
2864
2948
|
const col = this.column(entry.field);
|
|
@@ -2921,9 +3005,11 @@ export class PowqlInterface {
|
|
|
2921
3005
|
claim(`${fn}_${col.name}`, `${fn} of column "${col.name}"`);
|
|
2922
3006
|
const inner = this.colRefName(col.name);
|
|
2923
3007
|
proj.push(`${alias}: ${powfn}(${inner})`);
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
3008
|
+
// Canonical field, so the result bucket and the keys `orderBy` /
|
|
3009
|
+
// `having` accept are the same whichever spelling was requested.
|
|
3010
|
+
aggReaders.push({ alias, outKey: `${fn}:${col.field}`, numeric: true });
|
|
3011
|
+
aggOrderExprs.set(`${fn}:${col.field}`, `.${alias}`);
|
|
3012
|
+
aggInner.set(col.field, inner);
|
|
2927
3013
|
}
|
|
2928
3014
|
else {
|
|
2929
3015
|
const col = this.column(target.field);
|
|
@@ -3059,7 +3145,7 @@ export class PowqlInterface {
|
|
|
3059
3145
|
throw new ValidationError(`[turbine] Unknown aggregate "${aggKey}" in having for field "${key}" on table "${this.table}". ` +
|
|
3060
3146
|
`Supported: ${[...POWQL_HAVING_AGG_FNS.keys()].join(', ')}.`);
|
|
3061
3147
|
}
|
|
3062
|
-
const inner =
|
|
3148
|
+
const inner = this.lookupGroupKey(aggInner, key) ?? this.ref(key);
|
|
3063
3149
|
conds.push(cmp(`${fn}(${inner})`, filter));
|
|
3064
3150
|
}
|
|
3065
3151
|
}
|
|
@@ -3107,7 +3193,9 @@ export class PowqlInterface {
|
|
|
3107
3193
|
for (const [field, dirSpec] of Object.entries(value)) {
|
|
3108
3194
|
if (dirSpec === undefined)
|
|
3109
3195
|
continue;
|
|
3110
|
-
|
|
3196
|
+
// `field` is the caller's spelling of the aggregate's target column;
|
|
3197
|
+
// the registry is keyed by the canonical one.
|
|
3198
|
+
const expr = aggOrderExprs.get(`${key}:${field}`) ?? this.aggOrderExpr(aggOrderExprs, key, field);
|
|
3111
3199
|
if (!expr) {
|
|
3112
3200
|
throw new ValidationError(`[turbine] Cannot order groupBy by "${key}.${field}" on table "${this.table}": ` +
|
|
3113
3201
|
`that aggregate is not requested in this call. Orderable keys: ${validKeys()}.`);
|
|
@@ -3116,7 +3204,7 @@ export class PowqlInterface {
|
|
|
3116
3204
|
}
|
|
3117
3205
|
continue;
|
|
3118
3206
|
}
|
|
3119
|
-
const expr =
|
|
3207
|
+
const expr = this.lookupGroupKey(byOrderExprs, key);
|
|
3120
3208
|
if (!expr) {
|
|
3121
3209
|
throw new ValidationError(`[turbine] Unknown field "${key}" in groupBy orderBy on table "${this.table}". Orderable keys: ${validKeys()}.`);
|
|
3122
3210
|
}
|
|
@@ -30,19 +30,6 @@ import type { BuilderCtx } from './where.js';
|
|
|
30
30
|
*/
|
|
31
31
|
export declare function assertAggregatePiiOptIn(table: string, meta: TableMetadata | undefined, field: string, column: string, usage: string, includePii: boolean | undefined): void;
|
|
32
32
|
export declare function buildGroupBy<T extends object>(qi: BuilderCtx, args: GroupByArgs<T>): DeferredQuery<Record<string, unknown>[]>;
|
|
33
|
-
/**
|
|
34
|
-
* Compile a groupBy `orderBy` into an ORDER BY body. Unlike findMany ORDER BY
|
|
35
|
-
* ({@link buildOrderBy}, which validates keys against the table's physical
|
|
36
|
-
* columns), groupBy ordering targets the columns the RESULT actually
|
|
37
|
-
* contains: plain by-fields, JSON group-key aliases, and requested aggregates
|
|
38
|
-
* (`_count` / `_sum` / `_avg` / `_min` / `_max`). Each key re-emits the exact
|
|
39
|
-
* SELECT expression that produced it (`byOrderExprs` / `aggOrderExprs`),
|
|
40
|
-
* mirroring how HAVING re-emits aggregate expressions, so no dialect ever has
|
|
41
|
-
* to accept a SELECT-alias reference in ORDER BY, and any already-bound
|
|
42
|
-
* JSON-path placeholder is reused verbatim (ORDER BY is the last clause, so
|
|
43
|
-
* no `$n` renumbering). An aggregate key that was not requested, or an unknown
|
|
44
|
-
* by-key, throws {@link ValidationError} E003 listing the valid keys.
|
|
45
|
-
*/
|
|
46
33
|
export declare function buildGroupByOrderBy(qi: BuilderCtx, orderBy: GroupByOrderBy | GroupByOrderBy[], byOrderExprs: Map<string, string>, aggOrderExprs: Map<string, string>): string;
|
|
47
34
|
/**
|
|
48
35
|
* Validate a JSON-path target (group key or aggregate target) in groupBy:
|
package/dist/query/aggregates.js
CHANGED
|
@@ -12,7 +12,7 @@ import { UnsupportedFeatureError, ValidationError } from '../errors.js';
|
|
|
12
12
|
import { snakeToCamel } from '../schema.js';
|
|
13
13
|
import { dedupeColumnList, isJsonPathOrderBy, isUnmatchedPlainObject, isVectorOrderBy, isWhereOperator, normalizeOrderBy, orderByEntries, } from './filters.js';
|
|
14
14
|
import { assertOrderDirection, resolveSkipGlobalFilters, resolveUnsafeFlag } from './types.js';
|
|
15
|
-
import { isTemporalInfinity, ownLookup, parseDbDate,
|
|
15
|
+
import { isTemporalInfinity, ownLookup, parseDbDate, resolveColumnName, warnRedundantSortTerm } from './utils.js';
|
|
16
16
|
import * as whereMod from './where.js';
|
|
17
17
|
import { assertWhereDepth } from './where-compile.js';
|
|
18
18
|
/**
|
|
@@ -46,12 +46,14 @@ export function assertAggregatePiiOptIn(table, meta, field, column, usage, inclu
|
|
|
46
46
|
}
|
|
47
47
|
export function buildGroupBy(qi, args) {
|
|
48
48
|
const meta = qi.schema.tables[qi.table];
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
49
|
+
// Up-front, so a bad `by` key is reported before anything else in the call,
|
|
50
|
+
// and through `qi.toColumn` (the ONE `resolveColumnName` rule) rather than
|
|
51
|
+
// `key in meta.columnMap`, which knows only the FIELD spelling and so
|
|
52
|
+
// rejected the snake_case COLUMN name that `where` / `select` / `distinct`
|
|
53
|
+
// accept and that an introspected schema's DDL declares.
|
|
54
|
+
for (const key of args.by) {
|
|
55
|
+
if (typeof key === 'string')
|
|
56
|
+
qi.toColumn(key);
|
|
55
57
|
}
|
|
56
58
|
qi.currentSkip = resolveSkipGlobalFilters(args.skipGlobalFilters);
|
|
57
59
|
// Resolve the PII opt-in ONCE, here, so the sentinel check runs on every
|
|
@@ -107,18 +109,30 @@ export function buildGroupBy(qi, args) {
|
|
|
107
109
|
for (const entry of args.by) {
|
|
108
110
|
if (typeof entry === 'string') {
|
|
109
111
|
const col = qi.toColumn(entry);
|
|
112
|
+
// The group key's identity is the COLUMN, so everything keyed off it
|
|
113
|
+
// uses the canonical FIELD name rather than whichever spelling the caller
|
|
114
|
+
// wrote. The result key above all: rows are read through `parseRow`,
|
|
115
|
+
// whose keys are field names, so `by: ['created_at']` keyed by the
|
|
116
|
+
// caller's spelling read `parsed['created_at']` and returned `undefined`
|
|
117
|
+
// for every group. `_count` / `_sum` / `_min` in the same transform
|
|
118
|
+
// already map their alias back through `reverseColumnMap`.
|
|
119
|
+
const field = qi.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
|
|
110
120
|
assertAggregatePiiOptIn(qi.table, meta, entry, col, 'groupBy `by` key', includePii);
|
|
111
|
-
claimResultKey(
|
|
121
|
+
claimResultKey(field, `column "${col}"`);
|
|
112
122
|
// The emitted output column is the snake_case name; claim it too (when
|
|
113
123
|
// it differs from the result key) so a JSON alias like 'created_at'
|
|
114
124
|
// cannot silently shadow the 'createdAt' group key on the wire.
|
|
115
|
-
if (col !==
|
|
125
|
+
if (col !== field)
|
|
116
126
|
claimResultKey(col, `column "${col}"`);
|
|
117
127
|
groupExprs.push(qi.q(col));
|
|
118
128
|
selectExprs.push(qi.q(col));
|
|
119
|
-
byReaders.push({ resultKey:
|
|
120
|
-
|
|
121
|
-
|
|
129
|
+
byReaders.push({ resultKey: field, rowKey: col, raw: false });
|
|
130
|
+
// Registered ONCE, under the canonical field: `orderBy` and `having`
|
|
131
|
+
// may spell the same group key the other way, and {@link lookupGroupKey}
|
|
132
|
+
// reconciles that at lookup time rather than doubling every
|
|
133
|
+
// "orderable keys" list.
|
|
134
|
+
byOrderExprs.set(field, qi.q(col));
|
|
135
|
+
havingGroupKeys.set(field, { kind: 'column', field });
|
|
122
136
|
}
|
|
123
137
|
else {
|
|
124
138
|
const col = resolveJsonPathTarget(qi, 'group key', entry.field, entry.path);
|
|
@@ -199,7 +213,9 @@ export function buildGroupBy(qi, args) {
|
|
|
199
213
|
const inner = `${sqlFn}(${qi.q(col)})`;
|
|
200
214
|
const expr = aggKey === '_avg' ? qi.castAgg(inner, 'float') : inner;
|
|
201
215
|
selectExprs.push(`${expr} AS ${qi.q(`${aggKey}_${col}`)}`);
|
|
202
|
-
|
|
216
|
+
// Canonical field, matching the result bucket the transform fills;
|
|
217
|
+
// `orderBy` may spell it either way (see {@link lookupGroupKey}).
|
|
218
|
+
aggOrderExprs.set(`${aggKey}:${qi.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col)}`, expr);
|
|
203
219
|
continue;
|
|
204
220
|
}
|
|
205
221
|
const col = resolveJsonPathTarget(qi, `${aggKey} target "${key}"`, target.field, target.path);
|
|
@@ -354,6 +370,37 @@ export function buildGroupBy(qi, args) {
|
|
|
354
370
|
* no `$n` renumbering). An aggregate key that was not requested, or an unknown
|
|
355
371
|
* by-key, throws {@link ValidationError} E003 listing the valid keys.
|
|
356
372
|
*/
|
|
373
|
+
/**
|
|
374
|
+
* Read a caller-supplied groupBy result key out of a registry keyed by the
|
|
375
|
+
* CANONICAL name (the field for a `by` column / aggregate target, the alias
|
|
376
|
+
* for a JSON group key).
|
|
377
|
+
*
|
|
378
|
+
* `by`, `orderBy` and `having` are three arguments of one call, each free to
|
|
379
|
+
* spell a column either way, so `by`'s choice must not decide what the other
|
|
380
|
+
* two may name. Reconciled here rather than by registering both spellings,
|
|
381
|
+
* which would list every group key twice in the "orderable keys" text: try the
|
|
382
|
+
* key as written (which is what carries a JSON alias, not a column), then its
|
|
383
|
+
* canonical field.
|
|
384
|
+
*/
|
|
385
|
+
function lookupGroupKey(qi, registry, key) {
|
|
386
|
+
const direct = registry.get(key);
|
|
387
|
+
if (direct !== undefined)
|
|
388
|
+
return direct;
|
|
389
|
+
const canonical = canonicalFieldName(qi, key);
|
|
390
|
+
return canonical === undefined ? undefined : registry.get(canonical);
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* The canonical FIELD name for a caller-supplied column name, or `undefined`
|
|
394
|
+
* when it names no column (a JSON group-key alias, a typo). One hop through
|
|
395
|
+
* {@link resolveColumnName} and back via `reverseColumnMap`, so both spellings
|
|
396
|
+
* land on one string.
|
|
397
|
+
*/
|
|
398
|
+
function canonicalFieldName(qi, key) {
|
|
399
|
+
const column = resolveColumnName(qi.tableMeta, key);
|
|
400
|
+
if (column === undefined)
|
|
401
|
+
return undefined;
|
|
402
|
+
return qi.tableMeta.reverseColumnMap[column] ?? snakeToCamel(column);
|
|
403
|
+
}
|
|
357
404
|
export function buildGroupByOrderBy(qi, orderBy, byOrderExprs, aggOrderExprs) {
|
|
358
405
|
const aggBlocks = new Set(['_count', '_sum', '_avg', '_min', '_max']);
|
|
359
406
|
/** Human-readable list of every key this call can order by (for E003). */
|
|
@@ -416,7 +463,11 @@ export function buildGroupByOrderBy(qi, orderBy, byOrderExprs, aggOrderExprs) {
|
|
|
416
463
|
for (const [field, dirSpec] of Object.entries(value)) {
|
|
417
464
|
if (dirSpec === undefined)
|
|
418
465
|
continue;
|
|
419
|
-
|
|
466
|
+
// `field` is the caller's spelling of the aggregate's target column;
|
|
467
|
+
// the registry is keyed by the canonical one.
|
|
468
|
+
const canonical = canonicalFieldName(qi, field);
|
|
469
|
+
const expr = aggOrderExprs.get(`${key}:${field}`) ??
|
|
470
|
+
(canonical === undefined ? undefined : aggOrderExprs.get(`${key}:${canonical}`));
|
|
420
471
|
if (!expr) {
|
|
421
472
|
throw new ValidationError(`[turbine] Cannot order groupBy by "${key}.${field}" on table "${qi.table}": ` +
|
|
422
473
|
`that aggregate is not requested in this call. Orderable keys: ${validKeys()}.`);
|
|
@@ -427,7 +478,7 @@ export function buildGroupByOrderBy(qi, orderBy, byOrderExprs, aggOrderExprs) {
|
|
|
427
478
|
continue;
|
|
428
479
|
}
|
|
429
480
|
// Plain by-field name or JSON group-key alias.
|
|
430
|
-
const expr = byOrderExprs
|
|
481
|
+
const expr = lookupGroupKey(qi, byOrderExprs, key);
|
|
431
482
|
if (!expr) {
|
|
432
483
|
throw new ValidationError(`[turbine] Unknown field "${key}" in groupBy orderBy on table "${qi.table}". ` +
|
|
433
484
|
`Orderable keys: ${validKeys()}.`);
|
|
@@ -691,7 +742,7 @@ function buildHavingCombinator(qi, key, value, params, jsonAggExprs, groupKeys,
|
|
|
691
742
|
* its re-emitted extract expression.
|
|
692
743
|
*/
|
|
693
744
|
function buildHavingScalarClauses(qi, field, value, params, groupKeys) {
|
|
694
|
-
const ref = groupKeys
|
|
745
|
+
const ref = groupKeys ? lookupGroupKey(qi, groupKeys, field) : undefined;
|
|
695
746
|
if (!ref) {
|
|
696
747
|
const known = groupKeys ? [...groupKeys.keys()] : [];
|
|
697
748
|
throw new ValidationError(`[turbine] having on "${field}" (table "${qi.table}") filters the grouped value itself, but ` +
|
|
@@ -842,25 +893,22 @@ export function buildAggregate(qi, args) {
|
|
|
842
893
|
? whereMod.buildWhere(qi, aggWhere)
|
|
843
894
|
: { sql: '', params: [] };
|
|
844
895
|
const meta = qi.schema.tables[qi.table];
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
896
|
+
// Every target is validated up front, including one whose falsy value the
|
|
897
|
+
// builders below skip, and through `qi.toColumn` (the ONE
|
|
898
|
+
// `resolveColumnName` rule). `key in meta.columnMap` knows only the FIELD
|
|
899
|
+
// spelling, so the snake_case COLUMN name that `where` / `select` and even
|
|
900
|
+
// `groupBy`'s own `_min` accepted was rejected here.
|
|
901
|
+
for (const group of [args._sum, args._avg, args._min, args._max]) {
|
|
902
|
+
if (group && typeof group === 'object') {
|
|
903
|
+
for (const key of Object.keys(group))
|
|
904
|
+
qi.toColumn(key);
|
|
854
905
|
}
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
throw new ValidationError(unknownFieldMessage(qi.table, key, meta));
|
|
862
|
-
}
|
|
863
|
-
}
|
|
906
|
+
}
|
|
907
|
+
if (args._count && typeof args._count === 'object') {
|
|
908
|
+
for (const key of Object.keys(args._count)) {
|
|
909
|
+
// `_all` is the reserved COUNT(*) selector, not a column.
|
|
910
|
+
if (key !== '_all')
|
|
911
|
+
qi.toColumn(key);
|
|
864
912
|
}
|
|
865
913
|
}
|
|
866
914
|
const selectExprs = [];
|
|
@@ -58,6 +58,7 @@ import type { PgCompatQueryResult } from '../pg-types.js';
|
|
|
58
58
|
import { type RelationDef, type SchemaMetadata, type TableMetadata } from '../schema.js';
|
|
59
59
|
import type { ReselectExecutor } from './builder.js';
|
|
60
60
|
import type { SkipGlobalFilters, Unsafe, WithClause, WithCount } from './types.js';
|
|
61
|
+
import { type ColumnNameSource } from './utils.js';
|
|
61
62
|
/**
|
|
62
63
|
* A DeferredQuery, minimally typed for what the loader consumes. Kept local to
|
|
63
64
|
* avoid a value import of builder.ts (which imports this module).
|
|
@@ -166,7 +167,18 @@ export declare function defaultProjectionFields(meta: TableMetadata, includePii:
|
|
|
166
167
|
* resolver so the two strategies refuse identically, word for word.
|
|
167
168
|
*/
|
|
168
169
|
export declare function assertProjectionShape(table: string, select: Record<string, boolean> | undefined, omit: Record<string, boolean> | undefined): void;
|
|
169
|
-
export declare function includeKeysForBatching(
|
|
170
|
+
export declare function includeKeysForBatching(
|
|
171
|
+
/**
|
|
172
|
+
* The table the projection is compiled against. `select` / `omit` keys are
|
|
173
|
+
* the CALLER's, and a column has two legal spellings there, so matching by
|
|
174
|
+
* raw key made "is the correlation key already projected?" depend on which
|
|
175
|
+
* was used: `select: { user_id: true }` with a `userId` key looked
|
|
176
|
+
* unprojected, so the key was force-added AND marked stitch-only and
|
|
177
|
+
* `stripFields` deleted the very column the caller asked for, while
|
|
178
|
+
* `omit: { user_id: true }` failed to un-omit and tripped
|
|
179
|
+
* `assertCorrelationKeyProjected`'s "bug in turbine" path on a legal query.
|
|
180
|
+
*/
|
|
181
|
+
meta: ColumnNameSource, select: Record<string, boolean> | undefined, omit: Record<string, boolean> | undefined, fields: string[],
|
|
170
182
|
/**
|
|
171
183
|
* The default projection for this table when it is NOT `select`/`omit`-driven:
|
|
172
184
|
* `hidden` are fields the default projection leaves out (today: PII-tagged
|