turbine-orm 0.64.1 → 0.65.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 -0
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/client.js +1 -1
- package/dist/cjs/dialect.js +4 -1
- package/dist/cjs/errors.d.ts +2 -0
- package/dist/cjs/errors.js +29 -7
- package/dist/cjs/mysql.js +19 -0
- package/dist/cjs/observe.js +3 -1
- package/dist/cjs/powql.js +29 -0
- package/dist/cjs/query/aggregates.js +3 -3
- package/dist/cjs/query/batched-loader.d.ts +10 -0
- package/dist/cjs/query/batched-loader.js +91 -37
- package/dist/cjs/query/builder.js +15 -7
- package/dist/cjs/query/relations.js +28 -0
- package/dist/cjs/query/utils.d.ts +13 -0
- package/dist/cjs/query/utils.js +21 -0
- package/dist/cjs/sqlite.js +16 -0
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/client.js +1 -1
- package/dist/dialect.js +4 -1
- package/dist/errors.d.ts +2 -0
- package/dist/errors.js +29 -7
- package/dist/mysql.js +19 -0
- package/dist/observe.js +3 -1
- package/dist/powql.js +30 -1
- package/dist/query/aggregates.js +4 -4
- package/dist/query/batched-loader.d.ts +10 -0
- package/dist/query/batched-loader.js +91 -38
- package/dist/query/builder.js +16 -8
- package/dist/query/relations.js +29 -1
- package/dist/query/utils.d.ts +13 -0
- package/dist/query/utils.js +19 -0
- package/dist/sqlite.js +16 -0
- package/package.json +5 -3
package/dist/client.js
CHANGED
|
@@ -1025,7 +1025,7 @@ export class TurbineClient {
|
|
|
1025
1025
|
if (value === undefined)
|
|
1026
1026
|
return undefined;
|
|
1027
1027
|
if (value !== 'null' && value !== 'preserve') {
|
|
1028
|
-
throw new ValidationError(`Invalid temporalInfinity: ${JSON.stringify(value)}. Expected 'preserve' (default: read a Postgres ` +
|
|
1028
|
+
throw new ValidationError(`[turbine] Invalid temporalInfinity: ${JSON.stringify(value)}. Expected 'preserve' (default: read a Postgres ` +
|
|
1029
1029
|
'temporal `infinity` as the JS number `Infinity` / `-Infinity`, which round-trips through a write ' +
|
|
1030
1030
|
"but breaks the declared `Date` type) or 'null' (read it as null, which serializes cleanly but " +
|
|
1031
1031
|
'makes it indistinguishable from a stored NULL, so a read-modify-write destroys the value).');
|
package/dist/dialect.js
CHANGED
|
@@ -80,7 +80,10 @@ export const postgresDialect = {
|
|
|
80
80
|
},
|
|
81
81
|
buildBulkInsertStatement(input) {
|
|
82
82
|
if (!input.columnArrayTypes || input.columnArrayTypes.length !== input.columns.length) {
|
|
83
|
-
throw new ValidationError(
|
|
83
|
+
throw new ValidationError(`[turbine] createMany bulk insert into "${input.table}": columnArrayTypes must supply one UNNEST cast ` +
|
|
84
|
+
`per column, got ${input.columnArrayTypes?.length ?? 0} for ${input.columns.length} columns ` +
|
|
85
|
+
`(${input.columns.join(', ')}). Schema metadata is missing a pgType for at least one column; ` +
|
|
86
|
+
'regenerate it with `npx turbine generate`.');
|
|
84
87
|
}
|
|
85
88
|
// Row-major form: required when a target column is itself array-typed,
|
|
86
89
|
// because the UNNEST transpose below flattens nested arrays (see
|
package/dist/errors.d.ts
CHANGED
|
@@ -29,6 +29,8 @@ export type TurbineErrorCode = (typeof TurbineErrorCode)[keyof typeof TurbineErr
|
|
|
29
29
|
/** Base error class for all Turbine errors */
|
|
30
30
|
export declare class TurbineError extends Error {
|
|
31
31
|
readonly code: TurbineErrorCode;
|
|
32
|
+
/** Docs page for this code, e.g. `https://turbineorm.dev/errors#e003`. */
|
|
33
|
+
readonly docsUrl: string;
|
|
32
34
|
constructor(code: TurbineErrorCode, message: string, options?: {
|
|
33
35
|
cause?: unknown;
|
|
34
36
|
});
|
package/dist/errors.js
CHANGED
|
@@ -25,23 +25,44 @@ export const TurbineErrorCode = {
|
|
|
25
25
|
UNSUPPORTED_FEATURE: 'TURBINE_E017',
|
|
26
26
|
READ_ONLY: 'TURBINE_E018',
|
|
27
27
|
};
|
|
28
|
+
/**
|
|
29
|
+
* The docs page anchor for a code: `TURBINE_E003` -> `.../errors#e003`.
|
|
30
|
+
*
|
|
31
|
+
* Every code has a row on that page (enforced by a unit test that reads the
|
|
32
|
+
* MDX source, so a new code cannot ship without its docs entry). The URL is
|
|
33
|
+
* carried both in the message and as {@link TurbineError.docsUrl} so
|
|
34
|
+
* structured sinks (Sentry, pino) get it without parsing text.
|
|
35
|
+
*/
|
|
36
|
+
function docsUrlForCode(code) {
|
|
37
|
+
return `https://turbineorm.dev/errors#${code.slice('TURBINE_'.length).toLowerCase()}`;
|
|
38
|
+
}
|
|
28
39
|
/**
|
|
29
40
|
* Prefix a human message with its stable error code so logs are greppable
|
|
30
|
-
* without requiring structured field access
|
|
31
|
-
*
|
|
41
|
+
* without requiring structured field access, and suffix it with the docs URL
|
|
42
|
+
* for the code so a log line is one click from its explanation. Idempotent on
|
|
43
|
+
* both ends: a message already starting with `[TURBINE_E0NN]` keeps its tag,
|
|
44
|
+
* and one already carrying THIS code's link (a same-code re-wrap) does not
|
|
45
|
+
* gain a second. The check is code-specific on purpose: a message embedding a
|
|
46
|
+
* DIFFERENT code's error text (say an E003 wrapped into an E017) still gets
|
|
47
|
+
* its own code's link appended, so the trailing link always agrees with
|
|
48
|
+
* `.docsUrl` and the outermost code.
|
|
49
|
+
*
|
|
50
|
+
* STABILITY.md declares message TEXT non-contract (only the code tag is), so
|
|
51
|
+
* adding the suffix is not a breaking change; branch on `err.code`, never on
|
|
52
|
+
* the message.
|
|
32
53
|
*/
|
|
33
54
|
function formatErrorMessage(code, message) {
|
|
34
55
|
const tag = `[${code}]`;
|
|
35
|
-
|
|
36
|
-
return message;
|
|
56
|
+
const link = message.includes(docsUrlForCode(code)) ? '' : ` (${docsUrlForCode(code)})`;
|
|
37
57
|
// Empty message → just the code (defensive; callers always pass text today).
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
return `${tag} ${message}`;
|
|
58
|
+
const body = message.startsWith(tag) ? message : message ? `${tag} ${message}` : tag;
|
|
59
|
+
return `${body}${link}`;
|
|
41
60
|
}
|
|
42
61
|
/** Base error class for all Turbine errors */
|
|
43
62
|
export class TurbineError extends Error {
|
|
44
63
|
code;
|
|
64
|
+
/** Docs page for this code, e.g. `https://turbineorm.dev/errors#e003`. */
|
|
65
|
+
docsUrl;
|
|
45
66
|
constructor(code, message, options) {
|
|
46
67
|
// The cause is redacted in 'safe' mode (see redactCauseForMode). Only pass
|
|
47
68
|
// an options object through when the caller actually supplied a `cause`
|
|
@@ -52,6 +73,7 @@ export class TurbineError extends Error {
|
|
|
52
73
|
super(formatErrorMessage(code, message), opts);
|
|
53
74
|
this.name = 'TurbineError';
|
|
54
75
|
this.code = code;
|
|
76
|
+
this.docsUrl = docsUrlForCode(code);
|
|
55
77
|
}
|
|
56
78
|
}
|
|
57
79
|
let errorMessageMode = 'safe';
|
package/dist/mysql.js
CHANGED
|
@@ -480,6 +480,25 @@ export const mysqlDialect = {
|
|
|
480
480
|
castAggregate(expr, target) {
|
|
481
481
|
return `CAST(${expr} AS ${target === 'int' ? 'SIGNED' : 'DECIMAL(65,30)'})`;
|
|
482
482
|
},
|
|
483
|
+
// MySQL has no bare OFFSET: the grammar requires a LIMIT for OFFSET to
|
|
484
|
+
// attach to, so `offset` without `limit` (valid on Postgres, which the
|
|
485
|
+
// default path is written for) is a syntax error here. The MySQL manual's
|
|
486
|
+
// own idiom for "from this offset to the end" is a LIMIT of
|
|
487
|
+
// 18446744073709551615 (2^64-1). The other shapes emit byte-identically to
|
|
488
|
+
// the default path. Values arrive as inlined validated-integer literals
|
|
489
|
+
// (inlineLimitOffset), so this only concatenates text the builder already
|
|
490
|
+
// vetted.
|
|
491
|
+
buildLimitOffset(input) {
|
|
492
|
+
const { limitPlaceholder, offsetPlaceholder } = input;
|
|
493
|
+
if (limitPlaceholder === undefined && offsetPlaceholder === undefined)
|
|
494
|
+
return '';
|
|
495
|
+
if (limitPlaceholder === undefined)
|
|
496
|
+
return ` LIMIT 18446744073709551615 OFFSET ${offsetPlaceholder}`;
|
|
497
|
+
let s = ` LIMIT ${limitPlaceholder}`;
|
|
498
|
+
if (offsetPlaceholder !== undefined)
|
|
499
|
+
s += ` OFFSET ${offsetPlaceholder}`;
|
|
500
|
+
return s;
|
|
501
|
+
},
|
|
483
502
|
// No array params in MySQL. JSON_TABLE expands a single JSON-array param into a
|
|
484
503
|
// row set, keeping ONE placeholder (so the SQL cache stays valid regardless of
|
|
485
504
|
// list length) and handling the empty-list case (zero rows). MySQL coerces the
|
package/dist/observe.js
CHANGED
|
@@ -154,7 +154,9 @@ export class ObserveEngine {
|
|
|
154
154
|
stopped = false;
|
|
155
155
|
constructor(config) {
|
|
156
156
|
if (!config.sink && !config.connectionString) {
|
|
157
|
-
throw new ValidationError('ObserveEngine
|
|
157
|
+
throw new ValidationError('[turbine] ObserveEngine: neither `connectionString` nor `sink` was provided, so there is nowhere to ' +
|
|
158
|
+
'flush metrics. Pass `connectionString` (a separate metrics database URL, often TURBINE_OBSERVE_URL) ' +
|
|
159
|
+
'or a custom `sink` implementing ObserveSink.');
|
|
158
160
|
}
|
|
159
161
|
this.sink =
|
|
160
162
|
config.sink ??
|
package/dist/powql.js
CHANGED
|
@@ -45,7 +45,7 @@ import { isJsonFilter, isRelationPickOrderBy, orderByEntries } from './query/fil
|
|
|
45
45
|
// are unlocked ONLY by the UNSAFE symbol, on this engine exactly as on the SQL
|
|
46
46
|
// engines, so a spread request body cannot turn either on here either.
|
|
47
47
|
import { assertDirectionToken, resolveUnsafeFlag, UNSAFE } from './query/types.js';
|
|
48
|
-
import { escapeLike, ownLookup, relationInProjectionMessage } from './query/utils.js';
|
|
48
|
+
import { escapeLike, ownLookup, relationInProjectionMessage, selectNamesNothingMessage, selectOmitExclusiveMessage, } from './query/utils.js';
|
|
49
49
|
import { assertJsonFilterKeys, jsonStringEntries } from './query/where.js';
|
|
50
50
|
import { normalizeKeyColumns, snakeToCamel, } from './schema.js';
|
|
51
51
|
/**
|
|
@@ -887,6 +887,23 @@ export class PowqlInterface {
|
|
|
887
887
|
return this.column(field).name;
|
|
888
888
|
}
|
|
889
889
|
projectedColumns(select, omit, includePii) {
|
|
890
|
+
// Same two shape refusals as `resolveProjection` on the SQL engines, with
|
|
891
|
+
// the shared messages, and for a live reason here: this path used to
|
|
892
|
+
// APPLY select-minus-omit while the SQL engines ignored the `omit` half,
|
|
893
|
+
// so one query meant different columns depending on the backend. And a
|
|
894
|
+
// zero-key/all-false select used to fall through to the DEFAULT
|
|
895
|
+
// projection here while the SQL engines emitted broken or empty rows;
|
|
896
|
+
// both shapes are ambiguous and now refused identically on every engine.
|
|
897
|
+
// Presence-decided, exactly like resolveProjection (see its comment for
|
|
898
|
+
// the strategy-flip this prevents).
|
|
899
|
+
if (select) {
|
|
900
|
+
if (!Object.values(select).some(Boolean)) {
|
|
901
|
+
throw new ValidationError(selectNamesNothingMessage(this.table));
|
|
902
|
+
}
|
|
903
|
+
if (omit && Object.values(omit).some(Boolean)) {
|
|
904
|
+
throw new ValidationError(selectOmitExclusiveMessage(this.table));
|
|
905
|
+
}
|
|
906
|
+
}
|
|
890
907
|
const pk = new Set(this.meta.primaryKey);
|
|
891
908
|
let cols = this.meta.columns.map((c) => c.name);
|
|
892
909
|
const hasSelect = select && Object.keys(select).length;
|
|
@@ -1444,6 +1461,18 @@ export class PowqlInterface {
|
|
|
1444
1461
|
// stitching (the join path already gets this for free via `__tpk`).
|
|
1445
1462
|
const userSelect = options.select;
|
|
1446
1463
|
const userOmit = options.omit;
|
|
1464
|
+
// The RAW shape rules, before the force-add below: the forced key makes
|
|
1465
|
+
// an all-falsy select look populated to the child's projectedColumns,
|
|
1466
|
+
// which would accept here what the nested-projection path refuses. Same
|
|
1467
|
+
// messages as the SQL engines' assertProjectionShape, same reason.
|
|
1468
|
+
if (userSelect) {
|
|
1469
|
+
if (!Object.values(userSelect).some(Boolean)) {
|
|
1470
|
+
throw new ValidationError(selectNamesNothingMessage(targetMeta.name));
|
|
1471
|
+
}
|
|
1472
|
+
if (userOmit && Object.values(userOmit).some(Boolean)) {
|
|
1473
|
+
throw new ValidationError(selectOmitExclusiveMessage(targetMeta.name));
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1447
1476
|
const fkProjected = userSelect ? Boolean(userSelect[childKeyField]) : userOmit ? !userOmit[childKeyField] : true;
|
|
1448
1477
|
let fetchOptions = options;
|
|
1449
1478
|
if (!fkProjected) {
|
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 { isJsonPathOrderBy, isUnmatchedPlainObject, isVectorOrderBy, isWhereOperator, normalizeOrderBy, orderByEntries, } from './filters.js';
|
|
14
14
|
import { assertOrderDirection, resolveSkipGlobalFilters, resolveUnsafeFlag } from './types.js';
|
|
15
|
-
import { isTemporalInfinity, ownLookup } from './utils.js';
|
|
15
|
+
import { isTemporalInfinity, ownLookup, unknownFieldMessage } from './utils.js';
|
|
16
16
|
import * as whereMod from './where.js';
|
|
17
17
|
/**
|
|
18
18
|
* Enforce the PII contract on the aggregate surface. A PII-tagged
|
|
@@ -48,7 +48,7 @@ export function buildGroupBy(qi, args) {
|
|
|
48
48
|
if (meta) {
|
|
49
49
|
for (const key of args.by) {
|
|
50
50
|
if (typeof key === 'string' && !(key in meta.columnMap)) {
|
|
51
|
-
throw new ValidationError(
|
|
51
|
+
throw new ValidationError(unknownFieldMessage(qi.table, key, meta));
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
}
|
|
@@ -785,7 +785,7 @@ export function buildAggregate(qi, args) {
|
|
|
785
785
|
if (group && typeof group === 'object') {
|
|
786
786
|
for (const key of Object.keys(group)) {
|
|
787
787
|
if (!(key in meta.columnMap)) {
|
|
788
|
-
throw new ValidationError(
|
|
788
|
+
throw new ValidationError(unknownFieldMessage(qi.table, key, meta));
|
|
789
789
|
}
|
|
790
790
|
}
|
|
791
791
|
}
|
|
@@ -796,7 +796,7 @@ export function buildAggregate(qi, args) {
|
|
|
796
796
|
if (key === '_all')
|
|
797
797
|
continue;
|
|
798
798
|
if (!(key in meta.columnMap)) {
|
|
799
|
-
throw new ValidationError(
|
|
799
|
+
throw new ValidationError(unknownFieldMessage(qi.table, key, meta));
|
|
800
800
|
}
|
|
801
801
|
}
|
|
802
802
|
}
|
|
@@ -146,6 +146,16 @@ export declare function defaultProjectionFields(meta: TableMetadata, includePii:
|
|
|
146
146
|
* keys) so a caller's `select: { title: true }` on a relation still stitches even
|
|
147
147
|
* though the FK was not requested, and the FK never appears in the output.
|
|
148
148
|
*/
|
|
149
|
+
/**
|
|
150
|
+
* The two projection SHAPE rules from `resolveProjection`, checked on the RAW
|
|
151
|
+
* caller args BEFORE `includeKeysForBatching` adjusts them. The adjustment
|
|
152
|
+
* force-adds correlation keys to a `select`, so a shape that is invalid as
|
|
153
|
+
* written (an all-falsy select, or select + omit together) can look valid
|
|
154
|
+
* after it, and the batched plan would then accept a query the join plan
|
|
155
|
+
* refuses, with 'auto' picking between them on data. Same messages as the
|
|
156
|
+
* resolver so the two strategies refuse identically, word for word.
|
|
157
|
+
*/
|
|
158
|
+
export declare function assertProjectionShape(table: string, select: Record<string, boolean> | undefined, omit: Record<string, boolean> | undefined): void;
|
|
149
159
|
export declare function includeKeysForBatching(select: Record<string, boolean> | undefined, omit: Record<string, boolean> | undefined, fields: string[],
|
|
150
160
|
/**
|
|
151
161
|
* The default projection for this table when it is NOT `select`/`omit`-driven:
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
import { CircularRelationError, RelationError, UnsupportedFeatureError, ValidationError } from '../errors.js';
|
|
57
57
|
import { normalizeKeyColumns } from '../schema.js';
|
|
58
58
|
import { isRelationPickOrderBy, sortedEntries } from './filters.js';
|
|
59
|
-
import { ownLookup } from './utils.js';
|
|
59
|
+
import { ownLookup, selectNamesNothingMessage, selectOmitExclusiveMessage } from './utils.js';
|
|
60
60
|
/**
|
|
61
61
|
* Max parent keys per follow-up query. On Postgres the whole key set travels as
|
|
62
62
|
* ONE array parameter (`= ANY($1)`), so this is not a bind-parameter limit, it
|
|
@@ -103,6 +103,27 @@ includePii) {
|
|
|
103
103
|
* keys) so a caller's `select: { title: true }` on a relation still stitches even
|
|
104
104
|
* though the FK was not requested, and the FK never appears in the output.
|
|
105
105
|
*/
|
|
106
|
+
/**
|
|
107
|
+
* The two projection SHAPE rules from `resolveProjection`, checked on the RAW
|
|
108
|
+
* caller args BEFORE `includeKeysForBatching` adjusts them. The adjustment
|
|
109
|
+
* force-adds correlation keys to a `select`, so a shape that is invalid as
|
|
110
|
+
* written (an all-falsy select, or select + omit together) can look valid
|
|
111
|
+
* after it, and the batched plan would then accept a query the join plan
|
|
112
|
+
* refuses, with 'auto' picking between them on data. Same messages as the
|
|
113
|
+
* resolver so the two strategies refuse identically, word for word.
|
|
114
|
+
*/
|
|
115
|
+
export function assertProjectionShape(table, select, omit) {
|
|
116
|
+
// Array shapes fall through: buildFindMany's resolver has their specific
|
|
117
|
+
// "must be an object" messages, and compiling is where they surface.
|
|
118
|
+
if (!select || Array.isArray(select))
|
|
119
|
+
return;
|
|
120
|
+
if (!Object.values(select).some(Boolean)) {
|
|
121
|
+
throw new ValidationError(selectNamesNothingMessage(table));
|
|
122
|
+
}
|
|
123
|
+
if (omit && !Array.isArray(omit) && Object.values(omit).some(Boolean)) {
|
|
124
|
+
throw new ValidationError(selectOmitExclusiveMessage(table));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
106
127
|
export function includeKeysForBatching(select, omit, fields,
|
|
107
128
|
/**
|
|
108
129
|
* The default projection for this table when it is NOT `select`/`omit`-driven:
|
|
@@ -272,12 +293,19 @@ export function rejectNestedPickOrder(withClause) {
|
|
|
272
293
|
export async function loadRelationsBatched(ctx, parents, withClause, timeout, depth = 0, path = [ctx.parentMeta.name]) {
|
|
273
294
|
if (depth >= MAX_DEPTH)
|
|
274
295
|
throw new CircularRelationError([...path, '…']);
|
|
275
|
-
// Scope-rule parity with the join strategy:
|
|
276
|
-
//
|
|
296
|
+
// Scope-rule parity with the join strategy: the whole tree validates even
|
|
297
|
+
// with zero parents, so accept/reject never depends on data. There is
|
|
298
|
+
// DELIBERATELY no `parents.length === 0` early return here: one used to sit
|
|
299
|
+
// below this line, and it skipped relation-NAME resolution and every child
|
|
300
|
+
// compile whenever the base query matched nothing, so a typo'd relation or
|
|
301
|
+
// child select threw on populated data and passed silently on empty. The
|
|
302
|
+
// loaders below all compile their child query before their own
|
|
303
|
+
// data-dependent exits (one SQL string build per relation node; makeChild
|
|
304
|
+
// creates a fresh QueryInterface, so this is NOT a template-cache hit, and
|
|
305
|
+
// nothing executes), which keeps this path's acceptance byte-aligned with
|
|
306
|
+
// the join plan's compile-time validation.
|
|
277
307
|
if (depth === 0)
|
|
278
308
|
rejectNestedPickOrder(withClause);
|
|
279
|
-
if (parents.length === 0)
|
|
280
|
-
return;
|
|
281
309
|
// Resolve the relations to load in the SAME order the join plan emits their
|
|
282
310
|
// columns (`sortedEntries` in buildSelectWithRelations), with the reserved
|
|
283
311
|
// `_count` key last.
|
|
@@ -287,7 +315,10 @@ export async function loadRelationsBatched(ctx, parents, withClause, timeout, de
|
|
|
287
315
|
continue;
|
|
288
316
|
const rel = ownLookup(ctx.parentMeta.relations, relName);
|
|
289
317
|
if (!rel) {
|
|
290
|
-
|
|
318
|
+
// RelationError (E005), NOT ValidationError: the join strategy throws
|
|
319
|
+
// E005 for this exact shape (relations.ts), and under 'auto' the two
|
|
320
|
+
// must refuse identically or the error CODE depends on table size.
|
|
321
|
+
throw new RelationError(`[turbine] Unknown relation "${relName}" on table "${ctx.parentMeta.name}". ` +
|
|
291
322
|
`Available: ${Object.keys(ctx.parentMeta.relations).join(', ')}`);
|
|
292
323
|
}
|
|
293
324
|
resolved.push({ relName, rel, options: spec === true ? {} : spec });
|
|
@@ -413,11 +444,6 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
|
|
|
413
444
|
assertCorrelationKeyProjected(parents, parentKeyField, relName, ctx.parentMeta.name);
|
|
414
445
|
const keys = uniqueKeys(parents, parentKeyField);
|
|
415
446
|
const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
|
|
416
|
-
if (keys.length === 0) {
|
|
417
|
-
for (const parent of parents)
|
|
418
|
-
parent[relName] = single ? null : [];
|
|
419
|
-
return;
|
|
420
|
-
}
|
|
421
447
|
// The follow-up must project the child correlation key even if the caller's
|
|
422
448
|
// select/omit excluded it; strip it back off afterwards so the shape matches join.
|
|
423
449
|
//
|
|
@@ -429,8 +455,26 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
|
|
|
429
455
|
// ones did not, so the defect needed a `select` (or an `omit` of the FK) on a
|
|
430
456
|
// to-many with a to-one inside it, which is why `include` and `join` were both
|
|
431
457
|
// clean and seventeen rounds of parity capture missed it.
|
|
458
|
+
assertProjectionShape(targetMeta.name, options.select, options.omit);
|
|
432
459
|
const proj = includeKeysForBatching(options.select, options.omit, [childKeyField, ...neededParentKeyFields(targetMeta, (options.with ?? {}))], defaultProjectionFields(targetMeta, ctx.includePii));
|
|
433
460
|
const child = ctx.makeChild(rel.to);
|
|
461
|
+
const buildChunk = (chunk) => child.buildFindMany({
|
|
462
|
+
where: mergeChildWhere(options.where, childKeyField, chunk),
|
|
463
|
+
select: proj.select,
|
|
464
|
+
omit: proj.omit,
|
|
465
|
+
orderBy: options.orderBy,
|
|
466
|
+
skipGlobalFilters: ctx.skipGlobalFilters,
|
|
467
|
+
includePii: ctx.includePii,
|
|
468
|
+
});
|
|
469
|
+
// Zero keys (no parents, or every parent's key is NULL): nothing to fetch,
|
|
470
|
+
// but the child query still COMPILES, because compiling is where the
|
|
471
|
+
// caller's select/omit/where/orderBy names are validated and the join plan
|
|
472
|
+
// validates them regardless of data. Skipping this made a typo'd child
|
|
473
|
+
// select throw or pass based on which rows the base query matched. Cost:
|
|
474
|
+
// one SQL string build (the child is a fresh QueryInterface with its own
|
|
475
|
+
// template LRU, so this is a build, not a cache hit); nothing executes.
|
|
476
|
+
if (keys.length === 0)
|
|
477
|
+
buildChunk([]);
|
|
434
478
|
const chunks = [];
|
|
435
479
|
for (let i = 0; i < keys.length; i += MAX_RELATION_KEYS)
|
|
436
480
|
chunks.push(keys.slice(i, i + MAX_RELATION_KEYS));
|
|
@@ -439,20 +483,16 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
|
|
|
439
483
|
// whole batch would cap TOTAL children, not children-per-parent. It is applied
|
|
440
484
|
// client-side per group after stitching (below).
|
|
441
485
|
const chunkResults = await Promise.all(chunks.map(async (chunk) => {
|
|
442
|
-
const deferred =
|
|
443
|
-
where: mergeChildWhere(options.where, childKeyField, chunk),
|
|
444
|
-
select: proj.select,
|
|
445
|
-
omit: proj.omit,
|
|
446
|
-
orderBy: options.orderBy,
|
|
447
|
-
skipGlobalFilters: ctx.skipGlobalFilters,
|
|
448
|
-
includePii: ctx.includePii,
|
|
449
|
-
});
|
|
486
|
+
const deferred = buildChunk(chunk);
|
|
450
487
|
const result = await ctx.exec(deferred.sql, deferred.params, deferred.preparedName);
|
|
451
488
|
return deferred.transform(result);
|
|
452
489
|
}));
|
|
453
490
|
const allChildren = chunkResults.flat();
|
|
454
|
-
// Recurse for nested `with` BEFORE stripping keys (children carry their own
|
|
455
|
-
|
|
491
|
+
// Recurse for nested `with` BEFORE stripping keys (children carry their own
|
|
492
|
+
// keys). Recursion runs even with zero children: it is the validation walk
|
|
493
|
+
// for the deeper levels of the tree (each level compiles its own child
|
|
494
|
+
// query above), so a typo three levels down throws with or without data.
|
|
495
|
+
if (options.with) {
|
|
456
496
|
await loadRelationsBatched({ ...ctx, parentMeta: targetMeta }, allChildren, options.with, timeout, depth + 1, [...path, relName]);
|
|
457
497
|
}
|
|
458
498
|
// Symmetric to the parent-side assertion above. If the CHILD key were ever
|
|
@@ -500,12 +540,11 @@ async function loadManyToMany(ctx, parents, rel, relName, options, timeout, dept
|
|
|
500
540
|
const parentRefField = ctx.parentMeta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
|
|
501
541
|
const targetPkField = targetMeta.reverseColumnMap[targetPkCol] ?? targetPkCol;
|
|
502
542
|
assertCorrelationKeyProjected(parents, parentRefField, relName, ctx.parentMeta.name);
|
|
543
|
+
// No early return on zero parent keys: the code below flows through
|
|
544
|
+
// naturally (zero junction chunks, zero target chunks), and the compile-only
|
|
545
|
+
// build further down still validates the caller's names, same rule as the
|
|
546
|
+
// to-one/to-many loader.
|
|
503
547
|
const parentKeys = uniqueKeys(parents, parentRefField);
|
|
504
|
-
if (parentKeys.length === 0) {
|
|
505
|
-
for (const parent of parents)
|
|
506
|
-
parent[relName] = [];
|
|
507
|
-
return;
|
|
508
|
-
}
|
|
509
548
|
// (1) Junction rows: sourceKeyVal → [targetKeyVal]. Raw SQL through the caller's
|
|
510
549
|
// executor (the junction table has no relations we need, so no child reader).
|
|
511
550
|
const targetsBySource = new Map();
|
|
@@ -541,28 +580,36 @@ async function loadManyToMany(ctx, parents, rel, relName, options, timeout, dept
|
|
|
541
580
|
// Plus the keys the target's own nested relations need, same rule and same
|
|
542
581
|
// reason as the to-many loader above: this level's PK is not the only key the
|
|
543
582
|
// recursion below will ask these rows for.
|
|
583
|
+
assertProjectionShape(targetMeta.name, options.select, options.omit);
|
|
544
584
|
const proj = includeKeysForBatching(options.select, options.omit, [targetPkField, ...neededParentKeyFields(targetMeta, (options.with ?? {}))], defaultProjectionFields(targetMeta, ctx.includePii));
|
|
545
585
|
const child = ctx.makeChild(rel.to);
|
|
586
|
+
const buildTargetChunk = (chunk) => child.buildFindMany({
|
|
587
|
+
where: mergeChildWhere(options.where, targetPkField, chunk),
|
|
588
|
+
select: proj.select,
|
|
589
|
+
omit: proj.omit,
|
|
590
|
+
orderBy: options.orderBy,
|
|
591
|
+
skipGlobalFilters: ctx.skipGlobalFilters,
|
|
592
|
+
includePii: ctx.includePii,
|
|
593
|
+
});
|
|
546
594
|
const targetVals = [...targetValSet];
|
|
595
|
+
// Compile-only when there is nothing to fetch: validation of the caller's
|
|
596
|
+
// names lives in the build, and it must not depend on whether any junction
|
|
597
|
+
// row matched (same rule as the to-one/to-many loader).
|
|
598
|
+
if (targetVals.length === 0)
|
|
599
|
+
buildTargetChunk([]);
|
|
547
600
|
const tChunks = [];
|
|
548
601
|
for (let i = 0; i < targetVals.length; i += MAX_RELATION_KEYS) {
|
|
549
602
|
tChunks.push(targetVals.slice(i, i + MAX_RELATION_KEYS));
|
|
550
603
|
}
|
|
551
604
|
const tResults = await Promise.all(tChunks.map(async (chunk) => {
|
|
552
|
-
const deferred =
|
|
553
|
-
where: mergeChildWhere(options.where, targetPkField, chunk),
|
|
554
|
-
select: proj.select,
|
|
555
|
-
omit: proj.omit,
|
|
556
|
-
orderBy: options.orderBy,
|
|
557
|
-
skipGlobalFilters: ctx.skipGlobalFilters,
|
|
558
|
-
includePii: ctx.includePii,
|
|
559
|
-
});
|
|
605
|
+
const deferred = buildTargetChunk(chunk);
|
|
560
606
|
const result = await ctx.exec(deferred.sql, deferred.params, deferred.preparedName);
|
|
561
607
|
return deferred.transform(result);
|
|
562
608
|
}));
|
|
563
609
|
const targetsInOrder = tResults.flat();
|
|
564
|
-
// Nested `with` on the target rows (before stripping their PK).
|
|
565
|
-
|
|
610
|
+
// Nested `with` on the target rows (before stripping their PK). Runs even
|
|
611
|
+
// with zero targets: it is the validation walk for the deeper levels.
|
|
612
|
+
if (options.with) {
|
|
566
613
|
await loadRelationsBatched({ ...ctx, parentMeta: targetMeta }, targetsInOrder, options.with, timeout, depth + 1, [...path, relName]);
|
|
567
614
|
}
|
|
568
615
|
const targetByPk = new Map();
|
|
@@ -794,10 +841,16 @@ function groupBy(rows, field) {
|
|
|
794
841
|
}
|
|
795
842
|
return map;
|
|
796
843
|
}
|
|
797
|
-
/**
|
|
844
|
+
/**
|
|
845
|
+
* Resolve a table's metadata or throw a clear relation error. E005
|
|
846
|
+
* (RelationError), matching the class the join path throws for its "Unknown
|
|
847
|
+
* relation target" twin in relations.ts: only corrupt/partial metadata can
|
|
848
|
+
* trigger either, but the error CODE must still not depend on which strategy
|
|
849
|
+
* ran (the same rule as the unknown relation NAME above).
|
|
850
|
+
*/
|
|
798
851
|
function requireTable(schema, table, relName) {
|
|
799
852
|
const meta = schema.tables[table];
|
|
800
853
|
if (!meta)
|
|
801
|
-
throw new
|
|
854
|
+
throw new RelationError(`[turbine] Unknown relation target "${table}" (relation "${relName}").`);
|
|
802
855
|
return meta;
|
|
803
856
|
}
|
package/dist/query/builder.js
CHANGED
|
@@ -16,7 +16,7 @@ import { missingIndexForRelation, schemaHasIndexInfo } from '../index-advisor.js
|
|
|
16
16
|
import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from '../nested-write.js';
|
|
17
17
|
import { normalizeKeyColumns, snakeToCamel } from '../schema.js';
|
|
18
18
|
import * as aggMod from './aggregates.js';
|
|
19
|
-
import { defaultProjectionFields, includeKeysForBatching, loadRelationsBatched, neededParentKeyFields, rejectNestedPickOrder, resolveCountRelations, stripFields, } from './batched-loader.js';
|
|
19
|
+
import { assertProjectionShape, defaultProjectionFields, includeKeysForBatching, loadRelationsBatched, neededParentKeyFields, rejectNestedPickOrder, resolveCountRelations, stripFields, } from './batched-loader.js';
|
|
20
20
|
import { expandCompoundUniqueWhere } from './compound-unique.js';
|
|
21
21
|
import { isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, isVectorOrderBy, isWhereOperator, orderByEntries, sortedEntries, } from './filters.js';
|
|
22
22
|
import * as relationsMod from './relations.js';
|
|
@@ -1391,6 +1391,7 @@ export class QueryInterface {
|
|
|
1391
1391
|
// refused one step later.
|
|
1392
1392
|
const includePii = resolveUnsafeFlag(args.includePii, 'includePii');
|
|
1393
1393
|
const needed = neededParentKeyFields(this.tableMeta, batchedWith);
|
|
1394
|
+
assertProjectionShape(this.table, args.select, args.omit);
|
|
1394
1395
|
const proj = includeKeysForBatching(args.select, args.omit, needed, defaultProjectionFields(this.tableMeta, includePii));
|
|
1395
1396
|
const hasJoin = Object.keys(joinWith).length > 0;
|
|
1396
1397
|
// Force the residual `with` onto the join plan so the base query never
|
|
@@ -1410,9 +1411,11 @@ export class QueryInterface {
|
|
|
1410
1411
|
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, this.preparedNameFor(args, deferred.preparedName));
|
|
1411
1412
|
const rows = deferred.transform(result);
|
|
1412
1413
|
const entities = single ? (rows ? [rows] : []) : rows;
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1414
|
+
// Unconditionally, even for zero rows: with no parents the loader is a
|
|
1415
|
+
// pure validation walk over the `with` tree (compile-only child builds),
|
|
1416
|
+
// which is what keeps accept/reject identical to the join plan when the
|
|
1417
|
+
// base query matches nothing.
|
|
1418
|
+
await loadRelationsBatched(this.batchedContext(args.timeout, skip, args.includePii, args.forceCustomPlan === true), entities, batchedWith, args.timeout);
|
|
1416
1419
|
stripFields(entities, proj.strip);
|
|
1417
1420
|
return single ? (entities[0] ?? null) : entities;
|
|
1418
1421
|
}
|
|
@@ -1505,9 +1508,9 @@ export class QueryInterface {
|
|
|
1505
1508
|
const deferred = this.buildFindMany(baseArgs);
|
|
1506
1509
|
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, this.preparedNameFor(args, deferred.preparedName));
|
|
1507
1510
|
const entities = deferred.transform(result);
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
+
// Unconditionally, even for zero rows: see the 'auto' path above, the
|
|
1512
|
+
// loader doubles as the compile-time validation walk of the `with` tree.
|
|
1513
|
+
await loadRelationsBatched(this.batchedContext(args.timeout, skip, args.includePii, args.forceCustomPlan === true), entities, withClause, args.timeout);
|
|
1511
1514
|
stripFields(entities, strip);
|
|
1512
1515
|
return entities;
|
|
1513
1516
|
}
|
|
@@ -1518,6 +1521,7 @@ export class QueryInterface {
|
|
|
1518
1521
|
*/
|
|
1519
1522
|
prepareBatchedBase(args, withClause) {
|
|
1520
1523
|
const needed = neededParentKeyFields(this.tableMeta, withClause);
|
|
1524
|
+
assertProjectionShape(this.table, args.select, args.omit);
|
|
1521
1525
|
const proj = includeKeysForBatching(args.select, args.omit, needed, defaultProjectionFields(this.tableMeta, resolveUnsafeFlag(args.includePii, 'includePii')));
|
|
1522
1526
|
const baseArgs = {
|
|
1523
1527
|
...args,
|
|
@@ -1891,14 +1895,18 @@ export class QueryInterface {
|
|
|
1891
1895
|
// Same scope-rule parity as runFindManyBatched: reject before querying.
|
|
1892
1896
|
rejectNestedPickOrder(withClause);
|
|
1893
1897
|
const needed = neededParentKeyFields(this.tableMeta, withClause);
|
|
1898
|
+
assertProjectionShape(this.table, args.select, args.omit);
|
|
1894
1899
|
const proj = includeKeysForBatching(args.select, args.omit, needed, defaultProjectionFields(this.tableMeta, resolveUnsafeFlag(args.includePii, 'includePii')));
|
|
1895
1900
|
const baseArgs = { ...args, with: undefined, select: proj.select, omit: proj.omit };
|
|
1896
1901
|
const deferred = this.buildFindUnique(baseArgs);
|
|
1897
1902
|
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, this.preparedNameFor(args, deferred.preparedName));
|
|
1898
1903
|
const entity = deferred.transform(result);
|
|
1904
|
+
// A miss still walks the `with` tree (compile-only child builds), so the
|
|
1905
|
+
// same args throw or pass identically whether or not the row exists,
|
|
1906
|
+
// matching the join plan which validates the whole statement up front.
|
|
1907
|
+
await loadRelationsBatched(this.batchedContext(args.timeout, args.skipGlobalFilters, args.includePii, args.forceCustomPlan === true), entity ? [entity] : [], withClause, args.timeout);
|
|
1899
1908
|
if (!entity)
|
|
1900
1909
|
return null;
|
|
1901
|
-
await loadRelationsBatched(this.batchedContext(args.timeout, args.skipGlobalFilters, args.includePii, args.forceCustomPlan === true), [entity], withClause, args.timeout);
|
|
1902
1910
|
stripFields([entity], proj.strip);
|
|
1903
1911
|
return entity;
|
|
1904
1912
|
}
|
package/dist/query/relations.js
CHANGED
|
@@ -18,7 +18,7 @@ import { camelToSnake, normalizeKeyColumns, snakeToCamel } from '../schema.js';
|
|
|
18
18
|
import { resolveCountRelations } from './batched-loader.js';
|
|
19
19
|
import { isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, isVectorOrderBy, normalizeOrderBy, orderByEntries, sortedEntries, } from './filters.js';
|
|
20
20
|
import { assertDirectionToken, assertOrderDirection } from './types.js';
|
|
21
|
-
import { ownLookup, relationInProjectionMessage, resolveColumnName, unknownFieldMessage } from './utils.js';
|
|
21
|
+
import { ownLookup, relationInProjectionMessage, resolveColumnName, selectNamesNothingMessage, selectOmitExclusiveMessage, unknownFieldMessage, } from './utils.js';
|
|
22
22
|
import { hasWarnedOnce, shouldWarnOnce, WARN_NS } from './warn-registry.js';
|
|
23
23
|
import * as whereMod from './where.js';
|
|
24
24
|
import * as writesMod from './writes.js';
|
|
@@ -75,6 +75,34 @@ function projectionColumn(table, meta, field, clause) {
|
|
|
75
75
|
* name handling would mean reimplementing those too.
|
|
76
76
|
*/
|
|
77
77
|
export function resolveProjection(qi, table, meta, select, omit, includePii) {
|
|
78
|
+
// Projection SHAPE refusals, in order (array shapes fall through to their
|
|
79
|
+
// own, more specific messages below):
|
|
80
|
+
//
|
|
81
|
+
// 1. A `select` naming no fields (empty, or every value false) is refused,
|
|
82
|
+
// not resolved to an empty column list: at the top level that list used
|
|
83
|
+
// to emit `SELECT FROM`, invalid SQL, while a relation quietly returned
|
|
84
|
+
// `[{}]` rows, two silent third outcomes of the kind this function was
|
|
85
|
+
// merged to abolish.
|
|
86
|
+
// 2. `select` + `omit` together is refused, not half-resolved: a narrowed
|
|
87
|
+
// projection minus fields is ambiguous, Prisma refuses the pair, and
|
|
88
|
+
// prisma-compat here already did. Before this check the `select` branch
|
|
89
|
+
// below returned early, so the `omit` names were never validated at all
|
|
90
|
+
// (a typo in the `omit` half passed while the same typo alone threw).
|
|
91
|
+
//
|
|
92
|
+
// Check 1 runs first so both are PRESENCE-decided exactly like the branch
|
|
93
|
+
// below (`if (select)`): a truthiness-decided refusal here flipped verdicts
|
|
94
|
+
// between strategies when the batched loader force-added correlation keys
|
|
95
|
+
// to an all-falsy select (review-caught in 0.65). The batched loader
|
|
96
|
+
// asserts the same two rules on the RAW args (`assertProjectionShape`)
|
|
97
|
+
// before any adjustment, with these same messages.
|
|
98
|
+
if (select && !Array.isArray(select)) {
|
|
99
|
+
if (!Object.values(select).some(Boolean)) {
|
|
100
|
+
throw new ValidationError(selectNamesNothingMessage(table));
|
|
101
|
+
}
|
|
102
|
+
if (omit && !Array.isArray(omit) && Object.values(omit).some(Boolean)) {
|
|
103
|
+
throw new ValidationError(selectOmitExclusiveMessage(table));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
78
106
|
if (select) {
|
|
79
107
|
// An array here means a caller wrote `select: ['id', 'name']` (Drizzle/SQL
|
|
80
108
|
// style) instead of the object shape. Object.entries() would iterate the
|
package/dist/query/utils.d.ts
CHANGED
|
@@ -467,3 +467,16 @@ export declare function unknownFieldMessage(table: string, field: string, meta:
|
|
|
467
467
|
* it. Naming the fix costs one sentence and saves a search.
|
|
468
468
|
*/
|
|
469
469
|
export declare function relationInProjectionMessage(table: string, field: string, clause: 'select' | 'omit'): string;
|
|
470
|
+
/**
|
|
471
|
+
* The two projection SHAPE refusals (0.65), shared by the SQL engines' single
|
|
472
|
+
* resolver, the batched loader's raw-arg check, and PowDB, so every path that
|
|
473
|
+
* refuses these shapes does so with one message.
|
|
474
|
+
*
|
|
475
|
+
* Both checks look at TRUTHY keys, and the raw-arg check in the batched
|
|
476
|
+
* loader exists because the loader force-adds correlation keys to a `select`
|
|
477
|
+
* before the resolver sees it: evaluated after that adjustment, an all-falsy
|
|
478
|
+
* user `select` looks populated and the verdict flips between strategies,
|
|
479
|
+
* which is exactly the class 0.64/0.65 exist to kill.
|
|
480
|
+
*/
|
|
481
|
+
export declare function selectNamesNothingMessage(table: string): string;
|
|
482
|
+
export declare function selectOmitExclusiveMessage(table: string): string;
|
package/dist/query/utils.js
CHANGED
|
@@ -876,3 +876,22 @@ export function relationInProjectionMessage(table, field, clause) {
|
|
|
876
876
|
: `${head} A relation is only present when you ask for it in \`with\`, so leave it out of \`with\` to leave it` +
|
|
877
877
|
' out of the result.';
|
|
878
878
|
}
|
|
879
|
+
/**
|
|
880
|
+
* The two projection SHAPE refusals (0.65), shared by the SQL engines' single
|
|
881
|
+
* resolver, the batched loader's raw-arg check, and PowDB, so every path that
|
|
882
|
+
* refuses these shapes does so with one message.
|
|
883
|
+
*
|
|
884
|
+
* Both checks look at TRUTHY keys, and the raw-arg check in the batched
|
|
885
|
+
* loader exists because the loader force-adds correlation keys to a `select`
|
|
886
|
+
* before the resolver sees it: evaluated after that adjustment, an all-falsy
|
|
887
|
+
* user `select` looks populated and the verdict flips between strategies,
|
|
888
|
+
* which is exactly the class 0.64/0.65 exist to kill.
|
|
889
|
+
*/
|
|
890
|
+
export function selectNamesNothingMessage(table) {
|
|
891
|
+
return (`[turbine] "select" names no fields (on table "${table}"): every value is false or it is empty. ` +
|
|
892
|
+
`Pass at least one field as true, or drop "select" to get the default projection.`);
|
|
893
|
+
}
|
|
894
|
+
export function selectOmitExclusiveMessage(table) {
|
|
895
|
+
return (`[turbine] "select" and "omit" are mutually exclusive (on table "${table}"). ` +
|
|
896
|
+
`A select already lists exactly the fields you want.`);
|
|
897
|
+
}
|