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/cjs/client.js
CHANGED
|
@@ -1033,7 +1033,7 @@ class TurbineClient {
|
|
|
1033
1033
|
if (value === undefined)
|
|
1034
1034
|
return undefined;
|
|
1035
1035
|
if (value !== 'null' && value !== 'preserve') {
|
|
1036
|
-
throw new errors_js_1.ValidationError(`Invalid temporalInfinity: ${JSON.stringify(value)}. Expected 'preserve' (default: read a Postgres ` +
|
|
1036
|
+
throw new errors_js_1.ValidationError(`[turbine] Invalid temporalInfinity: ${JSON.stringify(value)}. Expected 'preserve' (default: read a Postgres ` +
|
|
1037
1037
|
'temporal `infinity` as the JS number `Infinity` / `-Infinity`, which round-trips through a write ' +
|
|
1038
1038
|
"but breaks the declared `Date` type) or 'null' (read it as null, which serializes cleanly but " +
|
|
1039
1039
|
'makes it indistinguishable from a stored NULL, so a read-modify-write destroys the value).');
|
package/dist/cjs/dialect.js
CHANGED
|
@@ -116,7 +116,10 @@ exports.postgresDialect = {
|
|
|
116
116
|
},
|
|
117
117
|
buildBulkInsertStatement(input) {
|
|
118
118
|
if (!input.columnArrayTypes || input.columnArrayTypes.length !== input.columns.length) {
|
|
119
|
-
throw new errors_js_1.ValidationError(
|
|
119
|
+
throw new errors_js_1.ValidationError(`[turbine] createMany bulk insert into "${input.table}": columnArrayTypes must supply one UNNEST cast ` +
|
|
120
|
+
`per column, got ${input.columnArrayTypes?.length ?? 0} for ${input.columns.length} columns ` +
|
|
121
|
+
`(${input.columns.join(', ')}). Schema metadata is missing a pgType for at least one column; ` +
|
|
122
|
+
'regenerate it with `npx turbine generate`.');
|
|
120
123
|
}
|
|
121
124
|
// Row-major form: required when a target column is itself array-typed,
|
|
122
125
|
// because the UNNEST transpose below flattens nested arrays (see
|
package/dist/cjs/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/cjs/errors.js
CHANGED
|
@@ -32,23 +32,44 @@ exports.TurbineErrorCode = {
|
|
|
32
32
|
UNSUPPORTED_FEATURE: 'TURBINE_E017',
|
|
33
33
|
READ_ONLY: 'TURBINE_E018',
|
|
34
34
|
};
|
|
35
|
+
/**
|
|
36
|
+
* The docs page anchor for a code: `TURBINE_E003` -> `.../errors#e003`.
|
|
37
|
+
*
|
|
38
|
+
* Every code has a row on that page (enforced by a unit test that reads the
|
|
39
|
+
* MDX source, so a new code cannot ship without its docs entry). The URL is
|
|
40
|
+
* carried both in the message and as {@link TurbineError.docsUrl} so
|
|
41
|
+
* structured sinks (Sentry, pino) get it without parsing text.
|
|
42
|
+
*/
|
|
43
|
+
function docsUrlForCode(code) {
|
|
44
|
+
return `https://turbineorm.dev/errors#${code.slice('TURBINE_'.length).toLowerCase()}`;
|
|
45
|
+
}
|
|
35
46
|
/**
|
|
36
47
|
* Prefix a human message with its stable error code so logs are greppable
|
|
37
|
-
* without requiring structured field access
|
|
38
|
-
*
|
|
48
|
+
* without requiring structured field access, and suffix it with the docs URL
|
|
49
|
+
* for the code so a log line is one click from its explanation. Idempotent on
|
|
50
|
+
* both ends: a message already starting with `[TURBINE_E0NN]` keeps its tag,
|
|
51
|
+
* and one already carrying THIS code's link (a same-code re-wrap) does not
|
|
52
|
+
* gain a second. The check is code-specific on purpose: a message embedding a
|
|
53
|
+
* DIFFERENT code's error text (say an E003 wrapped into an E017) still gets
|
|
54
|
+
* its own code's link appended, so the trailing link always agrees with
|
|
55
|
+
* `.docsUrl` and the outermost code.
|
|
56
|
+
*
|
|
57
|
+
* STABILITY.md declares message TEXT non-contract (only the code tag is), so
|
|
58
|
+
* adding the suffix is not a breaking change; branch on `err.code`, never on
|
|
59
|
+
* the message.
|
|
39
60
|
*/
|
|
40
61
|
function formatErrorMessage(code, message) {
|
|
41
62
|
const tag = `[${code}]`;
|
|
42
|
-
|
|
43
|
-
return message;
|
|
63
|
+
const link = message.includes(docsUrlForCode(code)) ? '' : ` (${docsUrlForCode(code)})`;
|
|
44
64
|
// Empty message → just the code (defensive; callers always pass text today).
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
return `${tag} ${message}`;
|
|
65
|
+
const body = message.startsWith(tag) ? message : message ? `${tag} ${message}` : tag;
|
|
66
|
+
return `${body}${link}`;
|
|
48
67
|
}
|
|
49
68
|
/** Base error class for all Turbine errors */
|
|
50
69
|
class TurbineError extends Error {
|
|
51
70
|
code;
|
|
71
|
+
/** Docs page for this code, e.g. `https://turbineorm.dev/errors#e003`. */
|
|
72
|
+
docsUrl;
|
|
52
73
|
constructor(code, message, options) {
|
|
53
74
|
// The cause is redacted in 'safe' mode (see redactCauseForMode). Only pass
|
|
54
75
|
// an options object through when the caller actually supplied a `cause`
|
|
@@ -59,6 +80,7 @@ class TurbineError extends Error {
|
|
|
59
80
|
super(formatErrorMessage(code, message), opts);
|
|
60
81
|
this.name = 'TurbineError';
|
|
61
82
|
this.code = code;
|
|
83
|
+
this.docsUrl = docsUrlForCode(code);
|
|
62
84
|
}
|
|
63
85
|
}
|
|
64
86
|
exports.TurbineError = TurbineError;
|
package/dist/cjs/mysql.js
CHANGED
|
@@ -491,6 +491,25 @@ exports.mysqlDialect = {
|
|
|
491
491
|
castAggregate(expr, target) {
|
|
492
492
|
return `CAST(${expr} AS ${target === 'int' ? 'SIGNED' : 'DECIMAL(65,30)'})`;
|
|
493
493
|
},
|
|
494
|
+
// MySQL has no bare OFFSET: the grammar requires a LIMIT for OFFSET to
|
|
495
|
+
// attach to, so `offset` without `limit` (valid on Postgres, which the
|
|
496
|
+
// default path is written for) is a syntax error here. The MySQL manual's
|
|
497
|
+
// own idiom for "from this offset to the end" is a LIMIT of
|
|
498
|
+
// 18446744073709551615 (2^64-1). The other shapes emit byte-identically to
|
|
499
|
+
// the default path. Values arrive as inlined validated-integer literals
|
|
500
|
+
// (inlineLimitOffset), so this only concatenates text the builder already
|
|
501
|
+
// vetted.
|
|
502
|
+
buildLimitOffset(input) {
|
|
503
|
+
const { limitPlaceholder, offsetPlaceholder } = input;
|
|
504
|
+
if (limitPlaceholder === undefined && offsetPlaceholder === undefined)
|
|
505
|
+
return '';
|
|
506
|
+
if (limitPlaceholder === undefined)
|
|
507
|
+
return ` LIMIT 18446744073709551615 OFFSET ${offsetPlaceholder}`;
|
|
508
|
+
let s = ` LIMIT ${limitPlaceholder}`;
|
|
509
|
+
if (offsetPlaceholder !== undefined)
|
|
510
|
+
s += ` OFFSET ${offsetPlaceholder}`;
|
|
511
|
+
return s;
|
|
512
|
+
},
|
|
494
513
|
// No array params in MySQL. JSON_TABLE expands a single JSON-array param into a
|
|
495
514
|
// row set, keeping ONE placeholder (so the SQL cache stays valid regardless of
|
|
496
515
|
// list length) and handling the empty-list case (zero rows). MySQL coerces the
|
package/dist/cjs/observe.js
CHANGED
|
@@ -164,7 +164,9 @@ class ObserveEngine {
|
|
|
164
164
|
stopped = false;
|
|
165
165
|
constructor(config) {
|
|
166
166
|
if (!config.sink && !config.connectionString) {
|
|
167
|
-
throw new errors_js_1.ValidationError('ObserveEngine
|
|
167
|
+
throw new errors_js_1.ValidationError('[turbine] ObserveEngine: neither `connectionString` nor `sink` was provided, so there is nowhere to ' +
|
|
168
|
+
'flush metrics. Pass `connectionString` (a separate metrics database URL, often TURBINE_OBSERVE_URL) ' +
|
|
169
|
+
'or a custom `sink` implementing ObserveSink.');
|
|
168
170
|
}
|
|
169
171
|
this.sink =
|
|
170
172
|
config.sink ??
|
package/dist/cjs/powql.js
CHANGED
|
@@ -923,6 +923,23 @@ class PowqlInterface {
|
|
|
923
923
|
return this.column(field).name;
|
|
924
924
|
}
|
|
925
925
|
projectedColumns(select, omit, includePii) {
|
|
926
|
+
// Same two shape refusals as `resolveProjection` on the SQL engines, with
|
|
927
|
+
// the shared messages, and for a live reason here: this path used to
|
|
928
|
+
// APPLY select-minus-omit while the SQL engines ignored the `omit` half,
|
|
929
|
+
// so one query meant different columns depending on the backend. And a
|
|
930
|
+
// zero-key/all-false select used to fall through to the DEFAULT
|
|
931
|
+
// projection here while the SQL engines emitted broken or empty rows;
|
|
932
|
+
// both shapes are ambiguous and now refused identically on every engine.
|
|
933
|
+
// Presence-decided, exactly like resolveProjection (see its comment for
|
|
934
|
+
// the strategy-flip this prevents).
|
|
935
|
+
if (select) {
|
|
936
|
+
if (!Object.values(select).some(Boolean)) {
|
|
937
|
+
throw new errors_js_1.ValidationError((0, utils_js_1.selectNamesNothingMessage)(this.table));
|
|
938
|
+
}
|
|
939
|
+
if (omit && Object.values(omit).some(Boolean)) {
|
|
940
|
+
throw new errors_js_1.ValidationError((0, utils_js_1.selectOmitExclusiveMessage)(this.table));
|
|
941
|
+
}
|
|
942
|
+
}
|
|
926
943
|
const pk = new Set(this.meta.primaryKey);
|
|
927
944
|
let cols = this.meta.columns.map((c) => c.name);
|
|
928
945
|
const hasSelect = select && Object.keys(select).length;
|
|
@@ -1480,6 +1497,18 @@ class PowqlInterface {
|
|
|
1480
1497
|
// stitching (the join path already gets this for free via `__tpk`).
|
|
1481
1498
|
const userSelect = options.select;
|
|
1482
1499
|
const userOmit = options.omit;
|
|
1500
|
+
// The RAW shape rules, before the force-add below: the forced key makes
|
|
1501
|
+
// an all-falsy select look populated to the child's projectedColumns,
|
|
1502
|
+
// which would accept here what the nested-projection path refuses. Same
|
|
1503
|
+
// messages as the SQL engines' assertProjectionShape, same reason.
|
|
1504
|
+
if (userSelect) {
|
|
1505
|
+
if (!Object.values(userSelect).some(Boolean)) {
|
|
1506
|
+
throw new errors_js_1.ValidationError((0, utils_js_1.selectNamesNothingMessage)(targetMeta.name));
|
|
1507
|
+
}
|
|
1508
|
+
if (userOmit && Object.values(userOmit).some(Boolean)) {
|
|
1509
|
+
throw new errors_js_1.ValidationError((0, utils_js_1.selectOmitExclusiveMessage)(targetMeta.name));
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1483
1512
|
const fkProjected = userSelect ? Boolean(userSelect[childKeyField]) : userOmit ? !userOmit[childKeyField] : true;
|
|
1484
1513
|
let fetchOptions = options;
|
|
1485
1514
|
if (!fkProjected) {
|
|
@@ -91,7 +91,7 @@ function buildGroupBy(qi, args) {
|
|
|
91
91
|
if (meta) {
|
|
92
92
|
for (const key of args.by) {
|
|
93
93
|
if (typeof key === 'string' && !(key in meta.columnMap)) {
|
|
94
|
-
throw new errors_js_1.ValidationError(
|
|
94
|
+
throw new errors_js_1.ValidationError((0, utils_js_1.unknownFieldMessage)(qi.table, key, meta));
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
}
|
|
@@ -828,7 +828,7 @@ function buildAggregate(qi, args) {
|
|
|
828
828
|
if (group && typeof group === 'object') {
|
|
829
829
|
for (const key of Object.keys(group)) {
|
|
830
830
|
if (!(key in meta.columnMap)) {
|
|
831
|
-
throw new errors_js_1.ValidationError(
|
|
831
|
+
throw new errors_js_1.ValidationError((0, utils_js_1.unknownFieldMessage)(qi.table, key, meta));
|
|
832
832
|
}
|
|
833
833
|
}
|
|
834
834
|
}
|
|
@@ -839,7 +839,7 @@ function buildAggregate(qi, args) {
|
|
|
839
839
|
if (key === '_all')
|
|
840
840
|
continue;
|
|
841
841
|
if (!(key in meta.columnMap)) {
|
|
842
|
-
throw new errors_js_1.ValidationError(
|
|
842
|
+
throw new errors_js_1.ValidationError((0, utils_js_1.unknownFieldMessage)(qi.table, key, meta));
|
|
843
843
|
}
|
|
844
844
|
}
|
|
845
845
|
}
|
|
@@ -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,6 +56,7 @@
|
|
|
56
56
|
*/
|
|
57
57
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
58
58
|
exports.defaultProjectionFields = defaultProjectionFields;
|
|
59
|
+
exports.assertProjectionShape = assertProjectionShape;
|
|
59
60
|
exports.includeKeysForBatching = includeKeysForBatching;
|
|
60
61
|
exports.stripFields = stripFields;
|
|
61
62
|
exports.neededParentKeyFields = neededParentKeyFields;
|
|
@@ -112,6 +113,27 @@ includePii) {
|
|
|
112
113
|
* keys) so a caller's `select: { title: true }` on a relation still stitches even
|
|
113
114
|
* though the FK was not requested, and the FK never appears in the output.
|
|
114
115
|
*/
|
|
116
|
+
/**
|
|
117
|
+
* The two projection SHAPE rules from `resolveProjection`, checked on the RAW
|
|
118
|
+
* caller args BEFORE `includeKeysForBatching` adjusts them. The adjustment
|
|
119
|
+
* force-adds correlation keys to a `select`, so a shape that is invalid as
|
|
120
|
+
* written (an all-falsy select, or select + omit together) can look valid
|
|
121
|
+
* after it, and the batched plan would then accept a query the join plan
|
|
122
|
+
* refuses, with 'auto' picking between them on data. Same messages as the
|
|
123
|
+
* resolver so the two strategies refuse identically, word for word.
|
|
124
|
+
*/
|
|
125
|
+
function assertProjectionShape(table, select, omit) {
|
|
126
|
+
// Array shapes fall through: buildFindMany's resolver has their specific
|
|
127
|
+
// "must be an object" messages, and compiling is where they surface.
|
|
128
|
+
if (!select || Array.isArray(select))
|
|
129
|
+
return;
|
|
130
|
+
if (!Object.values(select).some(Boolean)) {
|
|
131
|
+
throw new errors_js_1.ValidationError((0, utils_js_1.selectNamesNothingMessage)(table));
|
|
132
|
+
}
|
|
133
|
+
if (omit && !Array.isArray(omit) && Object.values(omit).some(Boolean)) {
|
|
134
|
+
throw new errors_js_1.ValidationError((0, utils_js_1.selectOmitExclusiveMessage)(table));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
115
137
|
function includeKeysForBatching(select, omit, fields,
|
|
116
138
|
/**
|
|
117
139
|
* The default projection for this table when it is NOT `select`/`omit`-driven:
|
|
@@ -281,12 +303,19 @@ function rejectNestedPickOrder(withClause) {
|
|
|
281
303
|
async function loadRelationsBatched(ctx, parents, withClause, timeout, depth = 0, path = [ctx.parentMeta.name]) {
|
|
282
304
|
if (depth >= MAX_DEPTH)
|
|
283
305
|
throw new errors_js_1.CircularRelationError([...path, '…']);
|
|
284
|
-
// Scope-rule parity with the join strategy:
|
|
285
|
-
//
|
|
306
|
+
// Scope-rule parity with the join strategy: the whole tree validates even
|
|
307
|
+
// with zero parents, so accept/reject never depends on data. There is
|
|
308
|
+
// DELIBERATELY no `parents.length === 0` early return here: one used to sit
|
|
309
|
+
// below this line, and it skipped relation-NAME resolution and every child
|
|
310
|
+
// compile whenever the base query matched nothing, so a typo'd relation or
|
|
311
|
+
// child select threw on populated data and passed silently on empty. The
|
|
312
|
+
// loaders below all compile their child query before their own
|
|
313
|
+
// data-dependent exits (one SQL string build per relation node; makeChild
|
|
314
|
+
// creates a fresh QueryInterface, so this is NOT a template-cache hit, and
|
|
315
|
+
// nothing executes), which keeps this path's acceptance byte-aligned with
|
|
316
|
+
// the join plan's compile-time validation.
|
|
286
317
|
if (depth === 0)
|
|
287
318
|
rejectNestedPickOrder(withClause);
|
|
288
|
-
if (parents.length === 0)
|
|
289
|
-
return;
|
|
290
319
|
// Resolve the relations to load in the SAME order the join plan emits their
|
|
291
320
|
// columns (`sortedEntries` in buildSelectWithRelations), with the reserved
|
|
292
321
|
// `_count` key last.
|
|
@@ -296,7 +325,10 @@ async function loadRelationsBatched(ctx, parents, withClause, timeout, depth = 0
|
|
|
296
325
|
continue;
|
|
297
326
|
const rel = (0, utils_js_1.ownLookup)(ctx.parentMeta.relations, relName);
|
|
298
327
|
if (!rel) {
|
|
299
|
-
|
|
328
|
+
// RelationError (E005), NOT ValidationError: the join strategy throws
|
|
329
|
+
// E005 for this exact shape (relations.ts), and under 'auto' the two
|
|
330
|
+
// must refuse identically or the error CODE depends on table size.
|
|
331
|
+
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${relName}" on table "${ctx.parentMeta.name}". ` +
|
|
300
332
|
`Available: ${Object.keys(ctx.parentMeta.relations).join(', ')}`);
|
|
301
333
|
}
|
|
302
334
|
resolved.push({ relName, rel, options: spec === true ? {} : spec });
|
|
@@ -422,11 +454,6 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
|
|
|
422
454
|
assertCorrelationKeyProjected(parents, parentKeyField, relName, ctx.parentMeta.name);
|
|
423
455
|
const keys = uniqueKeys(parents, parentKeyField);
|
|
424
456
|
const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
|
|
425
|
-
if (keys.length === 0) {
|
|
426
|
-
for (const parent of parents)
|
|
427
|
-
parent[relName] = single ? null : [];
|
|
428
|
-
return;
|
|
429
|
-
}
|
|
430
457
|
// The follow-up must project the child correlation key even if the caller's
|
|
431
458
|
// select/omit excluded it; strip it back off afterwards so the shape matches join.
|
|
432
459
|
//
|
|
@@ -438,8 +465,26 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
|
|
|
438
465
|
// ones did not, so the defect needed a `select` (or an `omit` of the FK) on a
|
|
439
466
|
// to-many with a to-one inside it, which is why `include` and `join` were both
|
|
440
467
|
// clean and seventeen rounds of parity capture missed it.
|
|
468
|
+
assertProjectionShape(targetMeta.name, options.select, options.omit);
|
|
441
469
|
const proj = includeKeysForBatching(options.select, options.omit, [childKeyField, ...neededParentKeyFields(targetMeta, (options.with ?? {}))], defaultProjectionFields(targetMeta, ctx.includePii));
|
|
442
470
|
const child = ctx.makeChild(rel.to);
|
|
471
|
+
const buildChunk = (chunk) => child.buildFindMany({
|
|
472
|
+
where: mergeChildWhere(options.where, childKeyField, chunk),
|
|
473
|
+
select: proj.select,
|
|
474
|
+
omit: proj.omit,
|
|
475
|
+
orderBy: options.orderBy,
|
|
476
|
+
skipGlobalFilters: ctx.skipGlobalFilters,
|
|
477
|
+
includePii: ctx.includePii,
|
|
478
|
+
});
|
|
479
|
+
// Zero keys (no parents, or every parent's key is NULL): nothing to fetch,
|
|
480
|
+
// but the child query still COMPILES, because compiling is where the
|
|
481
|
+
// caller's select/omit/where/orderBy names are validated and the join plan
|
|
482
|
+
// validates them regardless of data. Skipping this made a typo'd child
|
|
483
|
+
// select throw or pass based on which rows the base query matched. Cost:
|
|
484
|
+
// one SQL string build (the child is a fresh QueryInterface with its own
|
|
485
|
+
// template LRU, so this is a build, not a cache hit); nothing executes.
|
|
486
|
+
if (keys.length === 0)
|
|
487
|
+
buildChunk([]);
|
|
443
488
|
const chunks = [];
|
|
444
489
|
for (let i = 0; i < keys.length; i += MAX_RELATION_KEYS)
|
|
445
490
|
chunks.push(keys.slice(i, i + MAX_RELATION_KEYS));
|
|
@@ -448,20 +493,16 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
|
|
|
448
493
|
// whole batch would cap TOTAL children, not children-per-parent. It is applied
|
|
449
494
|
// client-side per group after stitching (below).
|
|
450
495
|
const chunkResults = await Promise.all(chunks.map(async (chunk) => {
|
|
451
|
-
const deferred =
|
|
452
|
-
where: mergeChildWhere(options.where, childKeyField, chunk),
|
|
453
|
-
select: proj.select,
|
|
454
|
-
omit: proj.omit,
|
|
455
|
-
orderBy: options.orderBy,
|
|
456
|
-
skipGlobalFilters: ctx.skipGlobalFilters,
|
|
457
|
-
includePii: ctx.includePii,
|
|
458
|
-
});
|
|
496
|
+
const deferred = buildChunk(chunk);
|
|
459
497
|
const result = await ctx.exec(deferred.sql, deferred.params, deferred.preparedName);
|
|
460
498
|
return deferred.transform(result);
|
|
461
499
|
}));
|
|
462
500
|
const allChildren = chunkResults.flat();
|
|
463
|
-
// Recurse for nested `with` BEFORE stripping keys (children carry their own
|
|
464
|
-
|
|
501
|
+
// Recurse for nested `with` BEFORE stripping keys (children carry their own
|
|
502
|
+
// keys). Recursion runs even with zero children: it is the validation walk
|
|
503
|
+
// for the deeper levels of the tree (each level compiles its own child
|
|
504
|
+
// query above), so a typo three levels down throws with or without data.
|
|
505
|
+
if (options.with) {
|
|
465
506
|
await loadRelationsBatched({ ...ctx, parentMeta: targetMeta }, allChildren, options.with, timeout, depth + 1, [...path, relName]);
|
|
466
507
|
}
|
|
467
508
|
// Symmetric to the parent-side assertion above. If the CHILD key were ever
|
|
@@ -509,12 +550,11 @@ async function loadManyToMany(ctx, parents, rel, relName, options, timeout, dept
|
|
|
509
550
|
const parentRefField = ctx.parentMeta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
|
|
510
551
|
const targetPkField = targetMeta.reverseColumnMap[targetPkCol] ?? targetPkCol;
|
|
511
552
|
assertCorrelationKeyProjected(parents, parentRefField, relName, ctx.parentMeta.name);
|
|
553
|
+
// No early return on zero parent keys: the code below flows through
|
|
554
|
+
// naturally (zero junction chunks, zero target chunks), and the compile-only
|
|
555
|
+
// build further down still validates the caller's names, same rule as the
|
|
556
|
+
// to-one/to-many loader.
|
|
512
557
|
const parentKeys = uniqueKeys(parents, parentRefField);
|
|
513
|
-
if (parentKeys.length === 0) {
|
|
514
|
-
for (const parent of parents)
|
|
515
|
-
parent[relName] = [];
|
|
516
|
-
return;
|
|
517
|
-
}
|
|
518
558
|
// (1) Junction rows: sourceKeyVal → [targetKeyVal]. Raw SQL through the caller's
|
|
519
559
|
// executor (the junction table has no relations we need, so no child reader).
|
|
520
560
|
const targetsBySource = new Map();
|
|
@@ -550,28 +590,36 @@ async function loadManyToMany(ctx, parents, rel, relName, options, timeout, dept
|
|
|
550
590
|
// Plus the keys the target's own nested relations need, same rule and same
|
|
551
591
|
// reason as the to-many loader above: this level's PK is not the only key the
|
|
552
592
|
// recursion below will ask these rows for.
|
|
593
|
+
assertProjectionShape(targetMeta.name, options.select, options.omit);
|
|
553
594
|
const proj = includeKeysForBatching(options.select, options.omit, [targetPkField, ...neededParentKeyFields(targetMeta, (options.with ?? {}))], defaultProjectionFields(targetMeta, ctx.includePii));
|
|
554
595
|
const child = ctx.makeChild(rel.to);
|
|
596
|
+
const buildTargetChunk = (chunk) => child.buildFindMany({
|
|
597
|
+
where: mergeChildWhere(options.where, targetPkField, chunk),
|
|
598
|
+
select: proj.select,
|
|
599
|
+
omit: proj.omit,
|
|
600
|
+
orderBy: options.orderBy,
|
|
601
|
+
skipGlobalFilters: ctx.skipGlobalFilters,
|
|
602
|
+
includePii: ctx.includePii,
|
|
603
|
+
});
|
|
555
604
|
const targetVals = [...targetValSet];
|
|
605
|
+
// Compile-only when there is nothing to fetch: validation of the caller's
|
|
606
|
+
// names lives in the build, and it must not depend on whether any junction
|
|
607
|
+
// row matched (same rule as the to-one/to-many loader).
|
|
608
|
+
if (targetVals.length === 0)
|
|
609
|
+
buildTargetChunk([]);
|
|
556
610
|
const tChunks = [];
|
|
557
611
|
for (let i = 0; i < targetVals.length; i += MAX_RELATION_KEYS) {
|
|
558
612
|
tChunks.push(targetVals.slice(i, i + MAX_RELATION_KEYS));
|
|
559
613
|
}
|
|
560
614
|
const tResults = await Promise.all(tChunks.map(async (chunk) => {
|
|
561
|
-
const deferred =
|
|
562
|
-
where: mergeChildWhere(options.where, targetPkField, chunk),
|
|
563
|
-
select: proj.select,
|
|
564
|
-
omit: proj.omit,
|
|
565
|
-
orderBy: options.orderBy,
|
|
566
|
-
skipGlobalFilters: ctx.skipGlobalFilters,
|
|
567
|
-
includePii: ctx.includePii,
|
|
568
|
-
});
|
|
615
|
+
const deferred = buildTargetChunk(chunk);
|
|
569
616
|
const result = await ctx.exec(deferred.sql, deferred.params, deferred.preparedName);
|
|
570
617
|
return deferred.transform(result);
|
|
571
618
|
}));
|
|
572
619
|
const targetsInOrder = tResults.flat();
|
|
573
|
-
// Nested `with` on the target rows (before stripping their PK).
|
|
574
|
-
|
|
620
|
+
// Nested `with` on the target rows (before stripping their PK). Runs even
|
|
621
|
+
// with zero targets: it is the validation walk for the deeper levels.
|
|
622
|
+
if (options.with) {
|
|
575
623
|
await loadRelationsBatched({ ...ctx, parentMeta: targetMeta }, targetsInOrder, options.with, timeout, depth + 1, [...path, relName]);
|
|
576
624
|
}
|
|
577
625
|
const targetByPk = new Map();
|
|
@@ -803,10 +851,16 @@ function groupBy(rows, field) {
|
|
|
803
851
|
}
|
|
804
852
|
return map;
|
|
805
853
|
}
|
|
806
|
-
/**
|
|
854
|
+
/**
|
|
855
|
+
* Resolve a table's metadata or throw a clear relation error. E005
|
|
856
|
+
* (RelationError), matching the class the join path throws for its "Unknown
|
|
857
|
+
* relation target" twin in relations.ts: only corrupt/partial metadata can
|
|
858
|
+
* trigger either, but the error CODE must still not depend on which strategy
|
|
859
|
+
* ran (the same rule as the unknown relation NAME above).
|
|
860
|
+
*/
|
|
807
861
|
function requireTable(schema, table, relName) {
|
|
808
862
|
const meta = schema.tables[table];
|
|
809
863
|
if (!meta)
|
|
810
|
-
throw new errors_js_1.
|
|
864
|
+
throw new errors_js_1.RelationError(`[turbine] Unknown relation target "${table}" (relation "${relName}").`);
|
|
811
865
|
return meta;
|
|
812
866
|
}
|
|
@@ -1428,6 +1428,7 @@ class QueryInterface {
|
|
|
1428
1428
|
// refused one step later.
|
|
1429
1429
|
const includePii = (0, types_js_1.resolveUnsafeFlag)(args.includePii, 'includePii');
|
|
1430
1430
|
const needed = (0, batched_loader_js_1.neededParentKeyFields)(this.tableMeta, batchedWith);
|
|
1431
|
+
(0, batched_loader_js_1.assertProjectionShape)(this.table, args.select, args.omit);
|
|
1431
1432
|
const proj = (0, batched_loader_js_1.includeKeysForBatching)(args.select, args.omit, needed, (0, batched_loader_js_1.defaultProjectionFields)(this.tableMeta, includePii));
|
|
1432
1433
|
const hasJoin = Object.keys(joinWith).length > 0;
|
|
1433
1434
|
// Force the residual `with` onto the join plan so the base query never
|
|
@@ -1447,9 +1448,11 @@ class QueryInterface {
|
|
|
1447
1448
|
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, this.preparedNameFor(args, deferred.preparedName));
|
|
1448
1449
|
const rows = deferred.transform(result);
|
|
1449
1450
|
const entities = single ? (rows ? [rows] : []) : rows;
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1451
|
+
// Unconditionally, even for zero rows: with no parents the loader is a
|
|
1452
|
+
// pure validation walk over the `with` tree (compile-only child builds),
|
|
1453
|
+
// which is what keeps accept/reject identical to the join plan when the
|
|
1454
|
+
// base query matches nothing.
|
|
1455
|
+
await (0, batched_loader_js_1.loadRelationsBatched)(this.batchedContext(args.timeout, skip, args.includePii, args.forceCustomPlan === true), entities, batchedWith, args.timeout);
|
|
1453
1456
|
(0, batched_loader_js_1.stripFields)(entities, proj.strip);
|
|
1454
1457
|
return single ? (entities[0] ?? null) : entities;
|
|
1455
1458
|
}
|
|
@@ -1542,9 +1545,9 @@ class QueryInterface {
|
|
|
1542
1545
|
const deferred = this.buildFindMany(baseArgs);
|
|
1543
1546
|
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, this.preparedNameFor(args, deferred.preparedName));
|
|
1544
1547
|
const entities = deferred.transform(result);
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
+
// Unconditionally, even for zero rows: see the 'auto' path above, the
|
|
1549
|
+
// loader doubles as the compile-time validation walk of the `with` tree.
|
|
1550
|
+
await (0, batched_loader_js_1.loadRelationsBatched)(this.batchedContext(args.timeout, skip, args.includePii, args.forceCustomPlan === true), entities, withClause, args.timeout);
|
|
1548
1551
|
(0, batched_loader_js_1.stripFields)(entities, strip);
|
|
1549
1552
|
return entities;
|
|
1550
1553
|
}
|
|
@@ -1555,6 +1558,7 @@ class QueryInterface {
|
|
|
1555
1558
|
*/
|
|
1556
1559
|
prepareBatchedBase(args, withClause) {
|
|
1557
1560
|
const needed = (0, batched_loader_js_1.neededParentKeyFields)(this.tableMeta, withClause);
|
|
1561
|
+
(0, batched_loader_js_1.assertProjectionShape)(this.table, args.select, args.omit);
|
|
1558
1562
|
const proj = (0, batched_loader_js_1.includeKeysForBatching)(args.select, args.omit, needed, (0, batched_loader_js_1.defaultProjectionFields)(this.tableMeta, (0, types_js_1.resolveUnsafeFlag)(args.includePii, 'includePii')));
|
|
1559
1563
|
const baseArgs = {
|
|
1560
1564
|
...args,
|
|
@@ -1928,14 +1932,18 @@ class QueryInterface {
|
|
|
1928
1932
|
// Same scope-rule parity as runFindManyBatched: reject before querying.
|
|
1929
1933
|
(0, batched_loader_js_1.rejectNestedPickOrder)(withClause);
|
|
1930
1934
|
const needed = (0, batched_loader_js_1.neededParentKeyFields)(this.tableMeta, withClause);
|
|
1935
|
+
(0, batched_loader_js_1.assertProjectionShape)(this.table, args.select, args.omit);
|
|
1931
1936
|
const proj = (0, batched_loader_js_1.includeKeysForBatching)(args.select, args.omit, needed, (0, batched_loader_js_1.defaultProjectionFields)(this.tableMeta, (0, types_js_1.resolveUnsafeFlag)(args.includePii, 'includePii')));
|
|
1932
1937
|
const baseArgs = { ...args, with: undefined, select: proj.select, omit: proj.omit };
|
|
1933
1938
|
const deferred = this.buildFindUnique(baseArgs);
|
|
1934
1939
|
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, this.preparedNameFor(args, deferred.preparedName));
|
|
1935
1940
|
const entity = deferred.transform(result);
|
|
1941
|
+
// A miss still walks the `with` tree (compile-only child builds), so the
|
|
1942
|
+
// same args throw or pass identically whether or not the row exists,
|
|
1943
|
+
// matching the join plan which validates the whole statement up front.
|
|
1944
|
+
await (0, batched_loader_js_1.loadRelationsBatched)(this.batchedContext(args.timeout, args.skipGlobalFilters, args.includePii, args.forceCustomPlan === true), entity ? [entity] : [], withClause, args.timeout);
|
|
1936
1945
|
if (!entity)
|
|
1937
1946
|
return null;
|
|
1938
|
-
await (0, batched_loader_js_1.loadRelationsBatched)(this.batchedContext(args.timeout, args.skipGlobalFilters, args.includePii, args.forceCustomPlan === true), [entity], withClause, args.timeout);
|
|
1939
1947
|
(0, batched_loader_js_1.stripFields)([entity], proj.strip);
|
|
1940
1948
|
return entity;
|
|
1941
1949
|
}
|
|
@@ -153,6 +153,34 @@ function projectionColumn(table, meta, field, clause) {
|
|
|
153
153
|
* name handling would mean reimplementing those too.
|
|
154
154
|
*/
|
|
155
155
|
function resolveProjection(qi, table, meta, select, omit, includePii) {
|
|
156
|
+
// Projection SHAPE refusals, in order (array shapes fall through to their
|
|
157
|
+
// own, more specific messages below):
|
|
158
|
+
//
|
|
159
|
+
// 1. A `select` naming no fields (empty, or every value false) is refused,
|
|
160
|
+
// not resolved to an empty column list: at the top level that list used
|
|
161
|
+
// to emit `SELECT FROM`, invalid SQL, while a relation quietly returned
|
|
162
|
+
// `[{}]` rows, two silent third outcomes of the kind this function was
|
|
163
|
+
// merged to abolish.
|
|
164
|
+
// 2. `select` + `omit` together is refused, not half-resolved: a narrowed
|
|
165
|
+
// projection minus fields is ambiguous, Prisma refuses the pair, and
|
|
166
|
+
// prisma-compat here already did. Before this check the `select` branch
|
|
167
|
+
// below returned early, so the `omit` names were never validated at all
|
|
168
|
+
// (a typo in the `omit` half passed while the same typo alone threw).
|
|
169
|
+
//
|
|
170
|
+
// Check 1 runs first so both are PRESENCE-decided exactly like the branch
|
|
171
|
+
// below (`if (select)`): a truthiness-decided refusal here flipped verdicts
|
|
172
|
+
// between strategies when the batched loader force-added correlation keys
|
|
173
|
+
// to an all-falsy select (review-caught in 0.65). The batched loader
|
|
174
|
+
// asserts the same two rules on the RAW args (`assertProjectionShape`)
|
|
175
|
+
// before any adjustment, with these same messages.
|
|
176
|
+
if (select && !Array.isArray(select)) {
|
|
177
|
+
if (!Object.values(select).some(Boolean)) {
|
|
178
|
+
throw new errors_js_1.ValidationError((0, utils_js_1.selectNamesNothingMessage)(table));
|
|
179
|
+
}
|
|
180
|
+
if (omit && !Array.isArray(omit) && Object.values(omit).some(Boolean)) {
|
|
181
|
+
throw new errors_js_1.ValidationError((0, utils_js_1.selectOmitExclusiveMessage)(table));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
156
184
|
if (select) {
|
|
157
185
|
// An array here means a caller wrote `select: ['id', 'name']` (Drizzle/SQL
|
|
158
186
|
// style) instead of the object shape. Object.entries() would iterate the
|
|
@@ -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/cjs/query/utils.js
CHANGED
|
@@ -37,6 +37,8 @@ exports.closestName = closestName;
|
|
|
37
37
|
exports.suggestKey = suggestKey;
|
|
38
38
|
exports.unknownFieldMessage = unknownFieldMessage;
|
|
39
39
|
exports.relationInProjectionMessage = relationInProjectionMessage;
|
|
40
|
+
exports.selectNamesNothingMessage = selectNamesNothingMessage;
|
|
41
|
+
exports.selectOmitExclusiveMessage = selectOmitExclusiveMessage;
|
|
40
42
|
const pg_1 = __importDefault(require("pg"));
|
|
41
43
|
const schema_js_1 = require("../schema.js");
|
|
42
44
|
const warn_registry_js_1 = require("./warn-registry.js");
|
|
@@ -911,3 +913,22 @@ function relationInProjectionMessage(table, field, clause) {
|
|
|
911
913
|
: `${head} A relation is only present when you ask for it in \`with\`, so leave it out of \`with\` to leave it` +
|
|
912
914
|
' out of the result.';
|
|
913
915
|
}
|
|
916
|
+
/**
|
|
917
|
+
* The two projection SHAPE refusals (0.65), shared by the SQL engines' single
|
|
918
|
+
* resolver, the batched loader's raw-arg check, and PowDB, so every path that
|
|
919
|
+
* refuses these shapes does so with one message.
|
|
920
|
+
*
|
|
921
|
+
* Both checks look at TRUTHY keys, and the raw-arg check in the batched
|
|
922
|
+
* loader exists because the loader force-adds correlation keys to a `select`
|
|
923
|
+
* before the resolver sees it: evaluated after that adjustment, an all-falsy
|
|
924
|
+
* user `select` looks populated and the verdict flips between strategies,
|
|
925
|
+
* which is exactly the class 0.64/0.65 exist to kill.
|
|
926
|
+
*/
|
|
927
|
+
function selectNamesNothingMessage(table) {
|
|
928
|
+
return (`[turbine] "select" names no fields (on table "${table}"): every value is false or it is empty. ` +
|
|
929
|
+
`Pass at least one field as true, or drop "select" to get the default projection.`);
|
|
930
|
+
}
|
|
931
|
+
function selectOmitExclusiveMessage(table) {
|
|
932
|
+
return (`[turbine] "select" and "omit" are mutually exclusive (on table "${table}"). ` +
|
|
933
|
+
`A select already lists exactly the fields you want.`);
|
|
934
|
+
}
|
package/dist/cjs/sqlite.js
CHANGED
|
@@ -461,6 +461,22 @@ exports.sqliteDialect = {
|
|
|
461
461
|
castAggregate(expr, target) {
|
|
462
462
|
return `CAST(${expr} AS ${target === 'int' ? 'INTEGER' : 'REAL'})`;
|
|
463
463
|
},
|
|
464
|
+
// SQLite's grammar only allows OFFSET after LIMIT, so `offset` without
|
|
465
|
+
// `limit` (valid on Postgres, which the default path is written for) is a
|
|
466
|
+
// syntax error here. A negative LIMIT is SQLite's documented "no upper
|
|
467
|
+
// bound", so that shape becomes `LIMIT -1 OFFSET n`. The other shapes emit
|
|
468
|
+
// byte-identically to the default path.
|
|
469
|
+
buildLimitOffset(input) {
|
|
470
|
+
const { limitPlaceholder, offsetPlaceholder } = input;
|
|
471
|
+
if (limitPlaceholder === undefined && offsetPlaceholder === undefined)
|
|
472
|
+
return '';
|
|
473
|
+
if (limitPlaceholder === undefined)
|
|
474
|
+
return ` LIMIT -1 OFFSET ${offsetPlaceholder}`;
|
|
475
|
+
let s = ` LIMIT ${limitPlaceholder}`;
|
|
476
|
+
if (offsetPlaceholder !== undefined)
|
|
477
|
+
s += ` OFFSET ${offsetPlaceholder}`;
|
|
478
|
+
return s;
|
|
479
|
+
},
|
|
464
480
|
// No `= ANY(array)` in SQLite. `json_each` expands a single JSON-array param
|
|
465
481
|
// into a row set, keeping ONE placeholder (so the SQL cache stays valid
|
|
466
482
|
// regardless of list length) and handling the empty-list case correctly.
|