turbine-orm 0.49.0 → 0.50.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 +122 -39
- package/dist/cjs/adapters/cockroachdb.d.ts +39 -0
- package/dist/cjs/adapters/index.d.ts +110 -0
- package/dist/cjs/adapters/yugabytedb.d.ts +51 -0
- package/dist/cjs/cli/config.d.ts +181 -0
- package/dist/cjs/cli/config.js +32 -6
- package/dist/cjs/cli/destructive.d.ts +38 -0
- package/dist/cjs/cli/index.d.ts +359 -0
- package/dist/cjs/cli/index.js +228 -56
- package/dist/cjs/cli/loader.d.ts +61 -0
- package/dist/cjs/cli/mcp.d.ts +42 -0
- package/dist/cjs/cli/migrate.d.ts +356 -0
- package/dist/cjs/cli/migrate.js +131 -40
- package/dist/cjs/cli/observe-ui.d.ts +1 -0
- package/dist/cjs/cli/observe-ui.js +14 -5
- package/dist/cjs/cli/observe.d.ts +25 -0
- package/dist/cjs/cli/observe.js +49 -12
- package/dist/cjs/cli/pii-tags.d.ts +53 -0
- package/dist/cjs/cli/prisma-report.d.ts +33 -0
- package/dist/cjs/cli/prisma-report.js +73 -0
- package/dist/cjs/cli/prisma-resolve.d.ts +106 -0
- package/dist/cjs/cli/prisma-resolve.js +1 -0
- package/dist/cjs/cli/prisma-schema.d.ts +176 -0
- package/dist/cjs/cli/prisma-schema.js +82 -4
- package/dist/cjs/cli/rate-limit.d.ts +32 -0
- package/dist/cjs/cli/rate-limit.js +45 -0
- package/dist/cjs/cli/studio-demo.d.ts +43 -0
- package/dist/cjs/cli/studio-ui.generated.d.ts +1 -0
- package/dist/cjs/cli/studio.d.ts +207 -0
- package/dist/cjs/cli/studio.js +136 -71
- package/dist/cjs/cli/ui.d.ts +73 -0
- package/dist/cjs/cli/ui.js +51 -9
- package/dist/cjs/client.d.ts +837 -0
- package/dist/cjs/client.js +3 -0
- package/dist/cjs/dialect.d.ts +516 -0
- package/dist/cjs/dialect.js +37 -12
- package/dist/cjs/errors.d.ts +370 -0
- package/dist/cjs/generate.d.ts +137 -0
- package/dist/cjs/generate.js +39 -6
- package/dist/cjs/index-advisor.d.ts +153 -0
- package/dist/cjs/index-stats.d.ts +384 -0
- package/dist/cjs/index.d.ts +55 -0
- package/dist/cjs/index.js +7 -2
- package/dist/cjs/introspect.d.ts +269 -0
- package/dist/cjs/mssql.d.ts +232 -0
- package/dist/cjs/mssql.js +6 -0
- package/dist/cjs/mysql.d.ts +173 -0
- package/dist/cjs/mysql.js +16 -0
- package/dist/cjs/nested-write.d.ts +96 -0
- package/dist/cjs/nested-write.js +414 -24
- package/dist/cjs/observe.d.ts +115 -0
- package/dist/cjs/optional-peer-import.d.cts +72 -0
- package/dist/cjs/pipeline-submittable.d.ts +93 -0
- package/dist/cjs/pipeline.d.ts +71 -0
- package/dist/cjs/powdb-introspect.d.ts +84 -0
- package/dist/cjs/powdb.d.ts +931 -0
- package/dist/cjs/powdb.js +106 -21
- package/dist/cjs/powql.d.ts +592 -0
- package/dist/cjs/powql.js +42 -6
- package/dist/cjs/prisma-compat.d.ts +283 -0
- package/dist/cjs/prisma-compat.js +167 -9
- package/dist/cjs/query/aggregates.d.ts +92 -0
- package/dist/cjs/query/aggregates.js +7 -3
- package/dist/cjs/query/batched-loader.d.ts +193 -0
- package/dist/cjs/query/builder.d.ts +849 -0
- package/dist/cjs/query/builder.js +571 -65
- package/dist/cjs/query/compound-unique.d.ts +51 -0
- package/dist/cjs/query/deferred.d.ts +223 -0
- package/dist/cjs/query/filters.d.ts +201 -0
- package/dist/cjs/query/index.d.ts +14 -0
- package/dist/cjs/query/index.js +6 -1
- package/dist/cjs/query/relations.d.ts +609 -0
- package/dist/cjs/query/relations.js +693 -46
- package/dist/cjs/query/types.d.ts +1300 -0
- package/dist/cjs/query/utils.d.ts +209 -0
- package/dist/cjs/query/utils.js +208 -1
- package/dist/cjs/query/warn-registry.d.ts +68 -0
- package/dist/cjs/query/warn-registry.js +9 -0
- package/dist/cjs/query/where-compile.d.ts +139 -0
- package/dist/cjs/query/where.d.ts +548 -0
- package/dist/cjs/query/where.js +58 -22
- package/dist/cjs/query/writes.d.ts +172 -0
- package/dist/cjs/query/writes.js +105 -12
- package/dist/cjs/realtime.d.ts +70 -0
- package/dist/cjs/schema-builder.d.ts +354 -0
- package/dist/cjs/schema-metadata.d.ts +83 -0
- package/dist/cjs/schema-sql.d.ts +217 -0
- package/dist/cjs/schema-sql.js +23 -5
- package/dist/cjs/schema.d.ts +356 -0
- package/dist/cjs/schema.js +125 -0
- package/dist/cjs/seed.d.ts +15 -0
- package/dist/cjs/serverless.d.ts +142 -0
- package/dist/cjs/sqlite.d.ts +143 -0
- package/dist/cjs/sqlite.js +4 -0
- package/dist/cjs/typed-sql.d.ts +102 -0
- package/dist/cli/config.d.ts +18 -4
- package/dist/cli/config.js +31 -6
- package/dist/cli/index.d.ts +123 -0
- package/dist/cli/index.js +223 -58
- package/dist/cli/migrate.d.ts +59 -10
- package/dist/cli/migrate.js +128 -41
- package/dist/cli/observe-ui.d.ts +1 -1
- package/dist/cli/observe-ui.js +14 -5
- package/dist/cli/observe.d.ts +7 -1
- package/dist/cli/observe.js +48 -12
- package/dist/cli/prisma-report.d.ts +14 -0
- package/dist/cli/prisma-report.js +72 -0
- package/dist/cli/prisma-resolve.d.ts +6 -0
- package/dist/cli/prisma-resolve.js +1 -0
- package/dist/cli/prisma-schema.d.ts +62 -2
- package/dist/cli/prisma-schema.js +81 -4
- package/dist/cli/rate-limit.d.ts +32 -0
- package/dist/cli/rate-limit.js +40 -0
- package/dist/cli/studio.d.ts +5 -5
- package/dist/cli/studio.js +135 -70
- package/dist/cli/ui.d.ts +1 -1
- package/dist/cli/ui.js +51 -9
- package/dist/client.d.ts +40 -0
- package/dist/client.js +3 -0
- package/dist/dialect.d.ts +17 -1
- package/dist/dialect.js +37 -12
- package/dist/generate.js +40 -7
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/mssql.js +6 -0
- package/dist/mysql.js +16 -0
- package/dist/nested-write.d.ts +2 -0
- package/dist/nested-write.js +415 -25
- package/dist/powdb.d.ts +4 -2
- package/dist/powdb.js +106 -21
- package/dist/powql.d.ts +5 -0
- package/dist/powql.js +42 -6
- package/dist/prisma-compat.d.ts +2 -0
- package/dist/prisma-compat.js +166 -8
- package/dist/query/aggregates.js +7 -3
- package/dist/query/builder.d.ts +292 -21
- package/dist/query/builder.js +570 -64
- package/dist/query/deferred.d.ts +39 -0
- package/dist/query/index.d.ts +1 -1
- package/dist/query/index.js +1 -1
- package/dist/query/relations.d.ts +173 -5
- package/dist/query/relations.js +688 -47
- package/dist/query/types.d.ts +123 -39
- package/dist/query/utils.d.ts +116 -0
- package/dist/query/utils.js +198 -0
- package/dist/query/warn-registry.d.ts +9 -0
- package/dist/query/warn-registry.js +9 -0
- package/dist/query/where.d.ts +38 -1
- package/dist/query/where.js +58 -23
- package/dist/query/writes.d.ts +42 -1
- package/dist/query/writes.js +104 -13
- package/dist/schema-sql.d.ts +14 -0
- package/dist/schema-sql.js +23 -5
- package/dist/schema.d.ts +38 -0
- package/dist/schema.js +123 -0
- package/dist/sqlite.js +4 -0
- package/package.json +77 -28
|
@@ -80,6 +80,12 @@ exports.makeNestedParser = makeNestedParser;
|
|
|
80
80
|
exports.decodePositionalRelations = decodePositionalRelations;
|
|
81
81
|
exports.decodePositionalValue = decodePositionalValue;
|
|
82
82
|
exports.decodePositionalObject = decodePositionalObject;
|
|
83
|
+
exports.provableUniqueTargetKey = provableUniqueTargetKey;
|
|
84
|
+
exports.planFlattenWith = planFlattenWith;
|
|
85
|
+
exports.emitFlattenNode = emitFlattenNode;
|
|
86
|
+
exports.collectFlattenNodeParams = collectFlattenNodeParams;
|
|
87
|
+
exports.assembleFlattenNode = assembleFlattenNode;
|
|
88
|
+
exports.makeFlattenParser = makeFlattenParser;
|
|
83
89
|
exports.buildSelectWithRelations = buildSelectWithRelations;
|
|
84
90
|
exports.buildRelationSubquery = buildRelationSubquery;
|
|
85
91
|
exports.buildManyToManySubquery = buildManyToManySubquery;
|
|
@@ -163,7 +169,7 @@ function withFingerprint(qi, withClause, table, depth = 0) {
|
|
|
163
169
|
.join(',')})`);
|
|
164
170
|
continue;
|
|
165
171
|
}
|
|
166
|
-
const relDef = meta.relations
|
|
172
|
+
const relDef = (0, utils_js_1.ownLookup)(meta.relations, relName);
|
|
167
173
|
if (!relDef) {
|
|
168
174
|
parts.push(`unknown:${relName}`);
|
|
169
175
|
continue;
|
|
@@ -193,7 +199,7 @@ function withFingerprint(qi, withClause, table, depth = 0) {
|
|
|
193
199
|
// `{title: {contains: 'x'}}` emit different SQL so they must not share
|
|
194
200
|
// a fingerprint)
|
|
195
201
|
if (opts.where) {
|
|
196
|
-
subParts.push(`w=${whereMod.fingerprintAliasWhere(qi, opts.where,
|
|
202
|
+
subParts.push(`w=${whereMod.fingerprintAliasWhere(qi, opts.where, relDef.to)}`);
|
|
197
203
|
}
|
|
198
204
|
// orderBy shape (OrderBySpec nulls placement changes the SQL, so fingerprint it)
|
|
199
205
|
if (opts.orderBy) {
|
|
@@ -221,14 +227,19 @@ function withFingerprint(qi, withClause, table, depth = 0) {
|
|
|
221
227
|
* Collect params from a `with` clause tree. Mirrors buildSelectWithRelations +
|
|
222
228
|
* buildRelationSubquery param-push order.
|
|
223
229
|
*/
|
|
224
|
-
function collectWithParams(qi, withClause, params, table) {
|
|
230
|
+
function collectWithParams(qi, withClause, params, table, flattenPlan) {
|
|
225
231
|
const meta = qi.schema.tables[table ?? qi.table];
|
|
226
232
|
if (!meta)
|
|
227
233
|
return;
|
|
228
234
|
for (const [relName, relSpec] of (0, filters_js_1.sortedEntries)(withClause)) {
|
|
229
|
-
const relDef = meta.relations
|
|
235
|
+
const relDef = (0, utils_js_1.ownLookup)(meta.relations, relName);
|
|
230
236
|
if (!relDef)
|
|
231
237
|
continue;
|
|
238
|
+
const flatNode = flattenPlan?.nodes[relName];
|
|
239
|
+
if (flatNode) {
|
|
240
|
+
collectFlattenNodeParams(qi, flatNode, params);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
232
243
|
collectRelationSubqueryParams(qi, relDef, relSpec, params, table ?? qi.table);
|
|
233
244
|
}
|
|
234
245
|
// `_count` global-filter params — mirror buildSelectWithRelations, which
|
|
@@ -270,11 +281,11 @@ function collectRelationSubqueryParams(qi, relDef, spec, params, _parentRef, dep
|
|
|
270
281
|
}
|
|
271
282
|
whereMod.collectTargetGlobalFilterAlias(qi, targetTable, params);
|
|
272
283
|
if (spec.limit !== undefined && !qi.dialect.inlineLimitOffset) {
|
|
273
|
-
params.push(
|
|
284
|
+
params.push(qi.paginationValue(spec.limit, 'relation limit'));
|
|
274
285
|
}
|
|
275
286
|
if (spec.with) {
|
|
276
287
|
for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
|
|
277
|
-
const nestedRelDef = targetMeta.relations
|
|
288
|
+
const nestedRelDef = (0, utils_js_1.ownLookup)(targetMeta.relations, nestedRelName);
|
|
278
289
|
if (!nestedRelDef)
|
|
279
290
|
continue;
|
|
280
291
|
collectRelationSubqueryParams(qi, nestedRelDef, nestedSpec, params, 'alias', depth + 1);
|
|
@@ -289,7 +300,7 @@ function collectRelationSubqueryParams(qi, relDef, spec, params, _parentRef, dep
|
|
|
289
300
|
// Non-wrapped path: nested relations BEFORE where/limit
|
|
290
301
|
if (!willWrap && spec.with) {
|
|
291
302
|
for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
|
|
292
|
-
const nestedRelDef = targetMeta.relations
|
|
303
|
+
const nestedRelDef = (0, utils_js_1.ownLookup)(targetMeta.relations, nestedRelName);
|
|
293
304
|
if (!nestedRelDef)
|
|
294
305
|
continue;
|
|
295
306
|
collectRelationSubqueryParams(qi, nestedRelDef, nestedSpec, params, 'alias', depth + 1);
|
|
@@ -313,12 +324,12 @@ function collectRelationSubqueryParams(qi, relDef, spec, params, _parentRef, dep
|
|
|
313
324
|
// pushing one here would orphan a param and desync the collect path.
|
|
314
325
|
// `limit: 0` pushes (LIMIT 0 is honored), so check !== undefined.
|
|
315
326
|
if (relDef.type === 'hasMany' && spec.limit !== undefined && !qi.dialect.inlineLimitOffset) {
|
|
316
|
-
params.push(
|
|
327
|
+
params.push(qi.paginationValue(spec.limit, 'relation limit'));
|
|
317
328
|
}
|
|
318
329
|
// Wrapped path: nested relations AFTER where/limit (inside inner subquery)
|
|
319
330
|
if (willWrap && spec.with) {
|
|
320
331
|
for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
|
|
321
|
-
const nestedRelDef = targetMeta.relations
|
|
332
|
+
const nestedRelDef = (0, utils_js_1.ownLookup)(targetMeta.relations, nestedRelName);
|
|
322
333
|
if (!nestedRelDef)
|
|
323
334
|
continue;
|
|
324
335
|
collectRelationSubqueryParams(qi, nestedRelDef, nestedSpec, params, 'innerAlias', depth + 1);
|
|
@@ -549,7 +560,7 @@ function buildRelationOrderBy(qi, relName, value, alias, params, ctx, lateralSin
|
|
|
549
560
|
const ownerMeta = ctx?.meta ?? qi.tableMeta;
|
|
550
561
|
const ownerTable = ctx?.table ?? qi.table;
|
|
551
562
|
const parentRef = ctx?.parentRef ?? qi.table;
|
|
552
|
-
const relDef = ownerMeta.relations
|
|
563
|
+
const relDef = (0, utils_js_1.ownLookup)(ownerMeta.relations, relName);
|
|
553
564
|
if (!relDef) {
|
|
554
565
|
// A table with no relations at all would otherwise render a dangling
|
|
555
566
|
// "Available: " and read as a broken message; and the most likely cause of
|
|
@@ -869,7 +880,7 @@ function collectRelationOrderParams(qi, targetTable, targetMeta, orderEntries, p
|
|
|
869
880
|
if ((0, filters_js_1.isRelationPickOrderBy)(dirValue)) {
|
|
870
881
|
throw pickOrderNestedError(qi, key);
|
|
871
882
|
}
|
|
872
|
-
const relDef = targetMeta.relations
|
|
883
|
+
const relDef = (0, utils_js_1.ownLookup)(targetMeta.relations, key);
|
|
873
884
|
if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
|
|
874
885
|
collectRelationCountParams(qi, relDef, params);
|
|
875
886
|
}
|
|
@@ -984,12 +995,23 @@ function getCamelDateFields(qi, table, meta) {
|
|
|
984
995
|
}
|
|
985
996
|
return camel;
|
|
986
997
|
}
|
|
987
|
-
/**
|
|
988
|
-
|
|
989
|
-
|
|
998
|
+
/**
|
|
999
|
+
* Parse a row that may contain JSON nested relation columns.
|
|
1000
|
+
*
|
|
1001
|
+
* `fromJson` says where THIS row's own scalar columns came from. A root row
|
|
1002
|
+
* (join, batched, flatten) is read straight off the driver, so `false`; a row
|
|
1003
|
+
* decoded out of a `json_agg`/`json_build_object` column is `true` and gets
|
|
1004
|
+
* its divergent scalars decoded back to the driver's representation first (see
|
|
1005
|
+
* the JSON-wire section above). Recursion into a relation column is always
|
|
1006
|
+
* `true`, which is exactly right on every strategy: `batched` and `flatten`
|
|
1007
|
+
* hand this function driver rows, but any relation still nested INSIDE one of
|
|
1008
|
+
* those rows arrived as a correlated JSON subquery.
|
|
1009
|
+
*/
|
|
1010
|
+
function parseNestedRow(qi, row, table, fromJson = false) {
|
|
990
1011
|
const meta = qi.schema.tables[table];
|
|
991
1012
|
if (!meta)
|
|
992
|
-
return
|
|
1013
|
+
return qi.parseRow(row, table);
|
|
1014
|
+
const parsed = qi.parseRow(fromJson ? decodeJsonWireRow(qi, row, table, meta) : row, table);
|
|
993
1015
|
// Assemble reserved `_count__<rel>` scalar columns into a `_count` object.
|
|
994
1016
|
// parseRow copies these unknown columns through under their raw key.
|
|
995
1017
|
let countObj;
|
|
@@ -1029,11 +1051,11 @@ function parseNestedRow(qi, row, table) {
|
|
|
1029
1051
|
// coercion AND its own sub-relations parsed at arbitrary depth.
|
|
1030
1052
|
if (Array.isArray(jsonVal)) {
|
|
1031
1053
|
parsed[relName] = jsonVal.map((item) => typeof item === 'object' && item !== null
|
|
1032
|
-
? parseNestedRow(qi, item, relDef.to)
|
|
1054
|
+
? parseNestedRow(qi, item, relDef.to, true)
|
|
1033
1055
|
: item);
|
|
1034
1056
|
}
|
|
1035
1057
|
else if (typeof jsonVal === 'object' && jsonVal !== null) {
|
|
1036
|
-
parsed[relName] = parseNestedRow(qi, jsonVal, relDef.to);
|
|
1058
|
+
parsed[relName] = parseNestedRow(qi, jsonVal, relDef.to, true);
|
|
1037
1059
|
}
|
|
1038
1060
|
else {
|
|
1039
1061
|
parsed[relName] = jsonVal;
|
|
@@ -1046,11 +1068,11 @@ function parseNestedRow(qi, row, table) {
|
|
|
1046
1068
|
}
|
|
1047
1069
|
else if (Array.isArray(rawValue)) {
|
|
1048
1070
|
parsed[relName] = rawValue.map((item) => typeof item === 'object' && item !== null
|
|
1049
|
-
? parseNestedRow(qi, item, relDef.to)
|
|
1071
|
+
? parseNestedRow(qi, item, relDef.to, true)
|
|
1050
1072
|
: item);
|
|
1051
1073
|
}
|
|
1052
1074
|
else if (typeof rawValue === 'object' && rawValue !== null) {
|
|
1053
|
-
parsed[relName] = parseNestedRow(qi, rawValue, relDef.to);
|
|
1075
|
+
parsed[relName] = parseNestedRow(qi, rawValue, relDef.to, true);
|
|
1054
1076
|
}
|
|
1055
1077
|
else {
|
|
1056
1078
|
parsed[relName] = rawValue;
|
|
@@ -1100,6 +1122,112 @@ function buildJsonRow(qi, jsonPairs) {
|
|
|
1100
1122
|
}
|
|
1101
1123
|
return qi.dialect.buildJsonObject(jsonPairs);
|
|
1102
1124
|
}
|
|
1125
|
+
// ---------------------------------------------------------------------------
|
|
1126
|
+
// JSON-wire value fidelity (see JSON_WIRE_COERCION_OIDS in utils.ts)
|
|
1127
|
+
//
|
|
1128
|
+
// `json_build_object` renders a handful of Postgres types as something other
|
|
1129
|
+
// than the value the driver hands back for the same column, so the 'join'
|
|
1130
|
+
// strategy used to disagree with a top-level read, with 'batched', and with
|
|
1131
|
+
// 'flatten' — losing precision outright on numeric and int8. The two halves of
|
|
1132
|
+
// the fix live here and must stay in lockstep:
|
|
1133
|
+
//
|
|
1134
|
+
// SQL side jsonScalarPairs() emits `alias."col"::text` for a divergent
|
|
1135
|
+
// column, so the JSON carries the driver's wire text verbatim.
|
|
1136
|
+
// parse side parseNestedRow(..., fromJson = true) runs that text back
|
|
1137
|
+
// through the driver's own parser for the column's OID.
|
|
1138
|
+
//
|
|
1139
|
+
// Both derive the column set from the SAME jsonWireCoercionOid() call over the
|
|
1140
|
+
// target table's `pgTypes`, so a cast can never be emitted without a matching
|
|
1141
|
+
// decode (or vice versa). Postgres-only: other engines neither use
|
|
1142
|
+
// json_build_object nor share this divergence set.
|
|
1143
|
+
// ---------------------------------------------------------------------------
|
|
1144
|
+
/** True when this query's engine gets the JSON-wire cast/decode treatment. */
|
|
1145
|
+
function usesJsonWireCoercion(qi) {
|
|
1146
|
+
return qi.dialect.name === 'postgresql';
|
|
1147
|
+
}
|
|
1148
|
+
/**
|
|
1149
|
+
* The JSON value expression for one scalar relation column: the plain column
|
|
1150
|
+
* reference, or a `::text` cast when the type's JSON rendering would not match
|
|
1151
|
+
* what the driver produces.
|
|
1152
|
+
*/
|
|
1153
|
+
function jsonScalarExpr(qi, targetMeta, col, ref) {
|
|
1154
|
+
const expr = `${ref}.${qi.q(col)}`;
|
|
1155
|
+
if (!usesJsonWireCoercion(qi))
|
|
1156
|
+
return expr;
|
|
1157
|
+
return (0, utils_js_1.jsonWireCoercionOid)(targetMeta.pgTypes?.[col]) === undefined ? expr : `${expr}::text`;
|
|
1158
|
+
}
|
|
1159
|
+
/**
|
|
1160
|
+
* The `[camelKey, valueExpr]` pairs for a relation's scalar columns. Single
|
|
1161
|
+
* source for all four `json_build_object` emission sites (to-one, hasMany
|
|
1162
|
+
* simple + wrapped, manyToMany) so the `::text` casts can never be applied on
|
|
1163
|
+
* one path and forgotten on another.
|
|
1164
|
+
*/
|
|
1165
|
+
function jsonScalarPairs(qi, targetMeta, targetColumns, ref) {
|
|
1166
|
+
return targetColumns.map((col) => [
|
|
1167
|
+
targetMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col),
|
|
1168
|
+
jsonScalarExpr(qi, targetMeta, col, ref),
|
|
1169
|
+
]);
|
|
1170
|
+
}
|
|
1171
|
+
/**
|
|
1172
|
+
* Per-`BuilderCtx`, per-table memo of the camelCase field names that need
|
|
1173
|
+
* JSON-wire decoding, mapped to the OID whose driver parser performs it.
|
|
1174
|
+
* `null` records "this table has none", the overwhelmingly common case, so a
|
|
1175
|
+
* relation of plain text/int columns costs one Map hit per row and no scan.
|
|
1176
|
+
*
|
|
1177
|
+
* Held in a WeakMap rather than on the ctx so the cache dies with the
|
|
1178
|
+
* QueryInterface and no shared interface has to grow a field for it.
|
|
1179
|
+
*/
|
|
1180
|
+
const jsonWireFieldCache = new WeakMap();
|
|
1181
|
+
function jsonWireFields(qi, table, meta) {
|
|
1182
|
+
let byTable = jsonWireFieldCache.get(qi);
|
|
1183
|
+
if (!byTable) {
|
|
1184
|
+
byTable = new Map();
|
|
1185
|
+
jsonWireFieldCache.set(qi, byTable);
|
|
1186
|
+
}
|
|
1187
|
+
const cached = byTable.get(table);
|
|
1188
|
+
if (cached !== undefined)
|
|
1189
|
+
return cached;
|
|
1190
|
+
let fields = null;
|
|
1191
|
+
const pgTypes = meta.pgTypes;
|
|
1192
|
+
if (pgTypes) {
|
|
1193
|
+
for (const col of meta.allColumns) {
|
|
1194
|
+
const oid = (0, utils_js_1.jsonWireCoercionOid)(pgTypes[col]);
|
|
1195
|
+
if (oid === undefined)
|
|
1196
|
+
continue;
|
|
1197
|
+
if (fields === null)
|
|
1198
|
+
fields = new Map();
|
|
1199
|
+
fields.set(meta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col), oid);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
byTable.set(table, fields);
|
|
1203
|
+
return fields;
|
|
1204
|
+
}
|
|
1205
|
+
/**
|
|
1206
|
+
* Rewrite a JSON-sourced relation row's divergent scalars into the driver's
|
|
1207
|
+
* representation. Returns `row` untouched when the table has no such column,
|
|
1208
|
+
* so the ordinary relation pays nothing beyond the memo lookup.
|
|
1209
|
+
*/
|
|
1210
|
+
function decodeJsonWireRow(qi, row, table, meta) {
|
|
1211
|
+
if (!usesJsonWireCoercion(qi))
|
|
1212
|
+
return row;
|
|
1213
|
+
const fields = jsonWireFields(qi, table, meta);
|
|
1214
|
+
if (fields === null)
|
|
1215
|
+
return row;
|
|
1216
|
+
// Copy-on-first-write rather than mutating in place: the row can be a
|
|
1217
|
+
// sub-object of the driver's parsed `json` column, which middleware may still
|
|
1218
|
+
// be holding. Measured against an in-place variant on a 20K-child-row join the
|
|
1219
|
+
// two were indistinguishable, so the copy is free insurance.
|
|
1220
|
+
let decoded;
|
|
1221
|
+
for (const [field, oid] of fields) {
|
|
1222
|
+
const raw = row[field];
|
|
1223
|
+
if (typeof raw !== 'string')
|
|
1224
|
+
continue;
|
|
1225
|
+
if (decoded === undefined)
|
|
1226
|
+
decoded = { ...row };
|
|
1227
|
+
decoded[field] = (0, utils_js_1.coerceJsonWireValue)(oid, raw);
|
|
1228
|
+
}
|
|
1229
|
+
return decoded ?? row;
|
|
1230
|
+
}
|
|
1103
1231
|
/**
|
|
1104
1232
|
* Build the top-level relation shapes for a `with` clause, mirroring
|
|
1105
1233
|
* {@link buildSelectWithRelations}: same relation iteration order, same
|
|
@@ -1111,7 +1239,7 @@ function buildRelationShapes(qi, table, withClause, includePii) {
|
|
|
1111
1239
|
return {};
|
|
1112
1240
|
const shapes = {};
|
|
1113
1241
|
for (const [relName, relSpec] of (0, filters_js_1.sortedEntries)(withClause)) {
|
|
1114
|
-
const relDef = meta.relations
|
|
1242
|
+
const relDef = (0, utils_js_1.ownLookup)(meta.relations, relName);
|
|
1115
1243
|
if (!relDef)
|
|
1116
1244
|
continue; // buildSelectWithRelations already threw for this
|
|
1117
1245
|
shapes[relName] = buildRelationShape(qi, relDef, relSpec, meta, includePii);
|
|
@@ -1134,7 +1262,7 @@ function buildRelationShape(qi, relDef, spec, parentMeta, includePii) {
|
|
|
1134
1262
|
const nested = {};
|
|
1135
1263
|
if (spec !== true && spec.with) {
|
|
1136
1264
|
for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
|
|
1137
|
-
const nestedRelDef = targetMeta.relations
|
|
1265
|
+
const nestedRelDef = (0, utils_js_1.ownLookup)(targetMeta.relations, nestedRelName);
|
|
1138
1266
|
if (!nestedRelDef)
|
|
1139
1267
|
continue;
|
|
1140
1268
|
keys.push(nestedRelName);
|
|
@@ -1150,7 +1278,12 @@ function buildRelationShape(qi, relDef, spec, parentMeta, includePii) {
|
|
|
1150
1278
|
* positional arrays into the object form first (shapes built once, not per
|
|
1151
1279
|
* row), then delegates to parseNestedRow for date/snake-camel coercion.
|
|
1152
1280
|
*/
|
|
1153
|
-
function makeNestedParser(qi, withClause, includePii) {
|
|
1281
|
+
function makeNestedParser(qi, withClause, includePii, flattenPlan) {
|
|
1282
|
+
// A flatten plan and the positional encoding are mutually exclusive (the
|
|
1283
|
+
// planner is only consulted for the object encoding), so this branch is safe
|
|
1284
|
+
// ahead of the positional one.
|
|
1285
|
+
if (flattenPlan)
|
|
1286
|
+
return makeFlattenParser(qi, flattenPlan);
|
|
1154
1287
|
if (qi.jsonEncoding !== 'positional') {
|
|
1155
1288
|
return (row) => parseNestedRow(qi, row, qi.table);
|
|
1156
1289
|
}
|
|
@@ -1209,6 +1342,524 @@ function decodePositionalObject(qi, arr, shape) {
|
|
|
1209
1342
|
}
|
|
1210
1343
|
return obj;
|
|
1211
1344
|
}
|
|
1345
|
+
// ---------------------------------------------------------------------------
|
|
1346
|
+
// relationLoadStrategy: 'flatten' — to-one relations compiled as LEFT JOINs
|
|
1347
|
+
// ---------------------------------------------------------------------------
|
|
1348
|
+
/**
|
|
1349
|
+
* Alias prefix for a flattened relation's join. Deliberately distinct from the
|
|
1350
|
+
* `t<n>` family {@link buildRelationSubquery} (and its `i`/`j`/`t`/`ord`
|
|
1351
|
+
* suffixes) allocates, so the two compilation paths can share one SELECT list
|
|
1352
|
+
* without ever colliding.
|
|
1353
|
+
*/
|
|
1354
|
+
const FLATTEN_ALIAS_PREFIX = 'f';
|
|
1355
|
+
/**
|
|
1356
|
+
* WHY EACH FLATTENED SUBTREE IS WRAPPED IN A DERIVED TABLE
|
|
1357
|
+
*
|
|
1358
|
+
* The top-level `WHERE` and `ORDER BY` reference the parent's columns
|
|
1359
|
+
* UNQUALIFIED (`WHERE "name" = $1`). A bare `LEFT JOIN "orgs" f0` puts a second
|
|
1360
|
+
* table in scope, so any column name the two tables share ("id", "name",
|
|
1361
|
+
* "created_at" — i.e. most of them) turns those references ambiguous and
|
|
1362
|
+
* Postgres rejects the statement. Qualifying the parent's references is not an
|
|
1363
|
+
* option here: they are compiled by the shared WHERE walk, which is scope-blind
|
|
1364
|
+
* by design.
|
|
1365
|
+
*
|
|
1366
|
+
* So a flattened subtree joins as a derived table that exposes ONLY prefixed
|
|
1367
|
+
* names (`f0__id`, `f0__name`, ...). Nothing it contributes can collide with an
|
|
1368
|
+
* unqualified parent reference, by construction rather than by analysis. This is
|
|
1369
|
+
* the same trick the `plan: 'lateral'` pick join uses when it exposes a single
|
|
1370
|
+
* reserved `__turbine_pick` column.
|
|
1371
|
+
*
|
|
1372
|
+
* Inside the derived table every reference is alias-qualified, so the whole
|
|
1373
|
+
* to-one subtree (chained joins, per-relation filters, and any to-many
|
|
1374
|
+
* correlated subqueries hanging off it) lives in one flat, unambiguous scope.
|
|
1375
|
+
* The subquery is a plain SELECT with no LIMIT / DISTINCT / aggregate, so
|
|
1376
|
+
* Postgres pulls it up into the outer join rather than materializing it.
|
|
1377
|
+
*/
|
|
1378
|
+
/** Suffix for the inner (real-table) alias inside a flattened derived table. */
|
|
1379
|
+
const FLATTEN_SRC_SUFFIX = 's';
|
|
1380
|
+
/** Alias prefix for a top-level node's exposed correlation columns. */
|
|
1381
|
+
const FLATTEN_CORR = '$c';
|
|
1382
|
+
/**
|
|
1383
|
+
* Maximum SELECT-list alias length. Postgres truncates identifiers at 63 bytes
|
|
1384
|
+
* (MySQL/SQLite are more generous), and a truncated alias could collide with
|
|
1385
|
+
* another projected column, so a relation whose prefixed projection would
|
|
1386
|
+
* exceed it is ineligible and falls back to the correlated subquery.
|
|
1387
|
+
*/
|
|
1388
|
+
const FLATTEN_MAX_ALIAS_LEN = 63;
|
|
1389
|
+
/** Nesting depth past which a flattened subtree declines (mirrors the join cap). */
|
|
1390
|
+
const FLATTEN_MAX_DEPTH = 10;
|
|
1391
|
+
/** Suffix of the non-null discriminator column projected for every flattened node. */
|
|
1392
|
+
const FLATTEN_DISCRIMINATOR = '$k';
|
|
1393
|
+
/** Set equality over two column lists (order-insensitive, duplicate-intolerant). */
|
|
1394
|
+
function sameColumnSet(a, b) {
|
|
1395
|
+
if (a.length === 0 || a.length !== b.length)
|
|
1396
|
+
return false;
|
|
1397
|
+
const set = new Set(a);
|
|
1398
|
+
if (set.size !== a.length)
|
|
1399
|
+
return false;
|
|
1400
|
+
for (const col of b)
|
|
1401
|
+
if (!set.has(col))
|
|
1402
|
+
return false;
|
|
1403
|
+
return true;
|
|
1404
|
+
}
|
|
1405
|
+
/**
|
|
1406
|
+
* The target-side correlation columns of a to-one relation, but ONLY when the
|
|
1407
|
+
* schema PROVES they are unique on the target table. This is the row-
|
|
1408
|
+
* multiplication guard: a LEFT JOIN over a non-unique key silently duplicates
|
|
1409
|
+
* parent rows, which would change results rather than just the plan.
|
|
1410
|
+
*
|
|
1411
|
+
* Proof sources, all exact set matches (a unique index on `(a, b)` does NOT
|
|
1412
|
+
* make `a` unique):
|
|
1413
|
+
* - the target's primary key,
|
|
1414
|
+
* - a declared unique constraint (`uniqueColumns`),
|
|
1415
|
+
* - a full, non-expression UNIQUE index. Partial unique indexes are refused
|
|
1416
|
+
* (they only constrain the rows matching their predicate) and PowDB
|
|
1417
|
+
* doc-field expression indexes are refused (they index a JSON path, not the
|
|
1418
|
+
* raw column).
|
|
1419
|
+
*
|
|
1420
|
+
* Returns `null` for to-many / manyToMany relations and for anything it cannot
|
|
1421
|
+
* prove, which routes the relation back to the correlated subquery.
|
|
1422
|
+
*
|
|
1423
|
+
* NULLs need no special handling: a unique constraint permits repeated NULLs,
|
|
1424
|
+
* but `target.key = parent.fk` never matches a NULL key, so a null-keyed target
|
|
1425
|
+
* row can never join.
|
|
1426
|
+
*/
|
|
1427
|
+
function provableUniqueTargetKey(relDef, targetMeta) {
|
|
1428
|
+
let cols;
|
|
1429
|
+
if (relDef.type === 'belongsTo') {
|
|
1430
|
+
// belongsTo: SOURCE holds the FK, so the join is target.referenceKey = parent.foreignKey.
|
|
1431
|
+
cols = (0, schema_js_1.normalizeKeyColumns)(relDef.referenceKey);
|
|
1432
|
+
}
|
|
1433
|
+
else if (relDef.type === 'hasOne') {
|
|
1434
|
+
// hasOne: TARGET holds the FK, so the join is target.foreignKey = parent.referenceKey.
|
|
1435
|
+
cols = (0, schema_js_1.normalizeKeyColumns)(relDef.foreignKey);
|
|
1436
|
+
}
|
|
1437
|
+
else {
|
|
1438
|
+
return null;
|
|
1439
|
+
}
|
|
1440
|
+
if (cols.length === 0)
|
|
1441
|
+
return null;
|
|
1442
|
+
if (!cols.every((col) => targetMeta.allColumns.includes(col)))
|
|
1443
|
+
return null;
|
|
1444
|
+
if (sameColumnSet(cols, targetMeta.primaryKey))
|
|
1445
|
+
return cols;
|
|
1446
|
+
for (const unique of targetMeta.uniqueColumns) {
|
|
1447
|
+
if (sameColumnSet(cols, unique))
|
|
1448
|
+
return cols;
|
|
1449
|
+
}
|
|
1450
|
+
for (const idx of targetMeta.indexes) {
|
|
1451
|
+
if (!idx.unique || idx.partial || idx.docPath)
|
|
1452
|
+
continue;
|
|
1453
|
+
if (sameColumnSet(cols, idx.columns))
|
|
1454
|
+
return cols;
|
|
1455
|
+
}
|
|
1456
|
+
return null;
|
|
1457
|
+
}
|
|
1458
|
+
/**
|
|
1459
|
+
* Plan one relation as a flattened LEFT JOIN, or return `null` to leave it on
|
|
1460
|
+
* the correlated-subquery path.
|
|
1461
|
+
*
|
|
1462
|
+
* A relation is ELIGIBLE when all of the following hold:
|
|
1463
|
+
* 1. it is `belongsTo` or `hasOne` AND its target-side correlation columns are
|
|
1464
|
+
* provably unique ({@link provableUniqueTargetKey}) — the row-multiplication
|
|
1465
|
+
* guard;
|
|
1466
|
+
* 2. its spec declares no `limit` and no `orderBy` (both are no-ops over a
|
|
1467
|
+
* single matching row, but refusing them keeps the emitted SQL and the
|
|
1468
|
+
* param stream trivially equivalent);
|
|
1469
|
+
* 3. its nested `with` names only real relations and no reserved `_count`
|
|
1470
|
+
* (nested `_count` is unsupported on every strategy — falling back lets the
|
|
1471
|
+
* subquery path raise the same error);
|
|
1472
|
+
* 4. the depth cap is not reached, so a too-deep chain still raises
|
|
1473
|
+
* {@link CircularRelationError} from the subquery path instead of silently
|
|
1474
|
+
* succeeding;
|
|
1475
|
+
* 5. its projected aliases are unique within the node and within the identifier
|
|
1476
|
+
* length limit.
|
|
1477
|
+
*
|
|
1478
|
+
* Relation `where` IS supported: it moves into the join's `ON` clause, where a
|
|
1479
|
+
* non-matching row null-extends exactly as the subquery's `LIMIT 1` returned
|
|
1480
|
+
* NULL. The target's global filter is applied the same way, so a flattened
|
|
1481
|
+
* relation can never surface soft-deleted / out-of-tenant rows.
|
|
1482
|
+
*/
|
|
1483
|
+
function planFlattenNode(qi, counter, relName, relDef, spec, depth, path, includePii, aliasSink, rejects) {
|
|
1484
|
+
/** Record why this relation stays on the subquery path, then decline it. */
|
|
1485
|
+
const decline = (reason) => {
|
|
1486
|
+
rejects.push({ relation: [...path.slice(1), relName].join('.'), reason });
|
|
1487
|
+
return null;
|
|
1488
|
+
};
|
|
1489
|
+
if (depth >= FLATTEN_MAX_DEPTH)
|
|
1490
|
+
return decline(`it nests more than ${FLATTEN_MAX_DEPTH} levels deep`);
|
|
1491
|
+
const targetMeta = qi.schema.tables[relDef.to];
|
|
1492
|
+
if (!targetMeta)
|
|
1493
|
+
return decline(`its target table "${relDef.to}" is not in the schema metadata`);
|
|
1494
|
+
const key = provableUniqueTargetKey(relDef, targetMeta);
|
|
1495
|
+
if (!key) {
|
|
1496
|
+
return decline(relDef.type !== 'belongsTo' && relDef.type !== 'hasOne'
|
|
1497
|
+
? `it is ${relDef.type}, and only a to-one relation can be flattened into a join`
|
|
1498
|
+
: `its correlation column(s) on "${relDef.to}" are not provably unique (no primary key, ` +
|
|
1499
|
+
'unique constraint or non-partial unique index covers them), so a join could multiply parent rows');
|
|
1500
|
+
}
|
|
1501
|
+
const opts = spec === true ? undefined : spec;
|
|
1502
|
+
if (opts) {
|
|
1503
|
+
if (opts.limit !== undefined)
|
|
1504
|
+
return decline('it declares a `limit`');
|
|
1505
|
+
if (opts.orderBy && (0, filters_js_1.orderByEntries)(opts.orderBy).some(([, dir]) => dir !== undefined)) {
|
|
1506
|
+
return decline('it declares an `orderBy`');
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
const nestedEntries = opts?.with ? (0, filters_js_1.sortedEntries)(opts.with) : [];
|
|
1510
|
+
for (const [nestedRelName] of nestedEntries) {
|
|
1511
|
+
if (nestedRelName === '_count')
|
|
1512
|
+
return decline('its nested `with` uses `_count`');
|
|
1513
|
+
if (!(0, utils_js_1.ownLookup)(targetMeta.relations, nestedRelName)) {
|
|
1514
|
+
return decline(`its nested \`with\` names "${nestedRelName}", which is not a relation of "${relDef.to}"`);
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
const alias = `${FLATTEN_ALIAS_PREFIX}${counter.n++}`;
|
|
1518
|
+
const cols = resolveTargetColumns(qi, spec, targetMeta, includePii);
|
|
1519
|
+
const discAlias = `${alias}__${FLATTEN_DISCRIMINATOR}`;
|
|
1520
|
+
const node = {
|
|
1521
|
+
relName,
|
|
1522
|
+
relDef,
|
|
1523
|
+
spec,
|
|
1524
|
+
targetTable: relDef.to,
|
|
1525
|
+
targetMeta,
|
|
1526
|
+
alias,
|
|
1527
|
+
srcAlias: `${alias}${FLATTEN_SRC_SUFFIX}`,
|
|
1528
|
+
keyColumns: key,
|
|
1529
|
+
discAlias,
|
|
1530
|
+
cols: cols.map((col) => [col, `${alias}__${col}`]),
|
|
1531
|
+
slots: [],
|
|
1532
|
+
children: {},
|
|
1533
|
+
jsonAliases: {},
|
|
1534
|
+
corrAliases: depth === 0 ? key.map((_, i) => `${alias}__${FLATTEN_CORR}${i}`) : [],
|
|
1535
|
+
depth,
|
|
1536
|
+
path,
|
|
1537
|
+
};
|
|
1538
|
+
// Local alias uniqueness: a target whose relation name equals one of its own
|
|
1539
|
+
// column names ("owner" the column and "owner" the relation) would otherwise
|
|
1540
|
+
// project two `f0__owner` columns and silently clobber one.
|
|
1541
|
+
const local = [discAlias, ...node.corrAliases, ...node.cols.map(([, a]) => a)];
|
|
1542
|
+
for (const [nestedRelName] of nestedEntries)
|
|
1543
|
+
local.push(`${alias}__${nestedRelName}`);
|
|
1544
|
+
if (new Set(local).size !== local.length) {
|
|
1545
|
+
return decline('a projected column alias would collide (a relation name matches one of its own column names)');
|
|
1546
|
+
}
|
|
1547
|
+
if (local.some((a) => a.length > FLATTEN_MAX_ALIAS_LEN)) {
|
|
1548
|
+
return decline(`a projected column alias would exceed ${FLATTEN_MAX_ALIAS_LEN} characters`);
|
|
1549
|
+
}
|
|
1550
|
+
for (const [nestedRelName, nestedSpec] of nestedEntries) {
|
|
1551
|
+
const nestedRelDef = (0, utils_js_1.ownLookup)(targetMeta.relations, nestedRelName);
|
|
1552
|
+
const child = planFlattenNode(qi, counter, nestedRelName, nestedRelDef, nestedSpec, depth + 1, [...path, relName], includePii, aliasSink, rejects);
|
|
1553
|
+
if (child) {
|
|
1554
|
+
node.slots.push({ relName: nestedRelName, kind: 'flat' });
|
|
1555
|
+
node.children[nestedRelName] = child;
|
|
1556
|
+
}
|
|
1557
|
+
else {
|
|
1558
|
+
node.slots.push({ relName: nestedRelName, kind: 'json' });
|
|
1559
|
+
node.jsonAliases[nestedRelName] = `${alias}__${nestedRelName}`;
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
aliasSink.push(discAlias, ...node.cols.map(([, a]) => a), ...Object.values(node.jsonAliases));
|
|
1563
|
+
return node;
|
|
1564
|
+
}
|
|
1565
|
+
/** Structural signature of one node, recursively. Feeds the SQL cache key. */
|
|
1566
|
+
function flattenNodeSignature(node) {
|
|
1567
|
+
const slots = node.slots
|
|
1568
|
+
.map((slot) => slot.kind === 'flat' ? `+${flattenNodeSignature(node.children[slot.relName])}` : `~${slot.relName}`)
|
|
1569
|
+
.join('');
|
|
1570
|
+
return `${node.relName}@${node.alias}(${node.cols.map(([col]) => col).join(',')})${slots}`;
|
|
1571
|
+
}
|
|
1572
|
+
/**
|
|
1573
|
+
* Dev-only, once-per-(table, relation, reason) note that an explicitly
|
|
1574
|
+
* requested `relationLoadStrategy: 'flatten'` did NOT engage for a relation.
|
|
1575
|
+
*
|
|
1576
|
+
* Worth its own warning because the fallback is otherwise INVISIBLE: the query
|
|
1577
|
+
* succeeds, the rows are correct (the strategies are value-identical), and the
|
|
1578
|
+
* only symptom is that the plan the caller asked for is not the plan that ran.
|
|
1579
|
+
* Silence there is indistinguishable from the strategy being a no-op, which is
|
|
1580
|
+
* exactly the shape of thing that gets filed as a bug.
|
|
1581
|
+
*
|
|
1582
|
+
* Suppressed in production like the other planner notes, and deduped through
|
|
1583
|
+
* the shared registry so a hot query logs once rather than per call. Note that
|
|
1584
|
+
* a flatten plan is recomputed on the build, param-collect and row-assembly
|
|
1585
|
+
* paths for the same query; the registry is what keeps that to one line.
|
|
1586
|
+
*/
|
|
1587
|
+
function warnFlattenFallback(table, rejects) {
|
|
1588
|
+
if (rejects.length === 0)
|
|
1589
|
+
return;
|
|
1590
|
+
if (process.env.NODE_ENV === 'production')
|
|
1591
|
+
return;
|
|
1592
|
+
for (const { relation, reason } of rejects) {
|
|
1593
|
+
if (!(0, warn_registry_js_1.shouldWarnOnce)(warn_registry_js_1.WARN_NS.flattenFallback, `${table}.${relation}|${reason}`))
|
|
1594
|
+
continue;
|
|
1595
|
+
console.warn(`[turbine] relationLoadStrategy: 'flatten' did not engage for relation "${relation}" on "${table}": ` +
|
|
1596
|
+
`${reason}. It loads via the correlated subquery instead (same rows, same values, different plan).`);
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
/**
|
|
1600
|
+
* Compile the `'flatten'` plan for a top-level `with` clause, or return `null`
|
|
1601
|
+
* when nothing in it is eligible (in which case the caller emits exactly the SQL
|
|
1602
|
+
* it emits today, down to the cache key).
|
|
1603
|
+
*
|
|
1604
|
+
* The plan is a pure function of the schema, the `with` clause shape and
|
|
1605
|
+
* `includePii` — never of any bound value — so the build path, the cache-hit
|
|
1606
|
+
* param-collect path and the row assembler can each recompute it and agree.
|
|
1607
|
+
*/
|
|
1608
|
+
function planFlattenWith(qi, table, withClause, includePii) {
|
|
1609
|
+
const meta = qi.schema.tables[table];
|
|
1610
|
+
if (!meta)
|
|
1611
|
+
return null;
|
|
1612
|
+
const counter = { n: 0 };
|
|
1613
|
+
const aliases = [];
|
|
1614
|
+
const nodes = {};
|
|
1615
|
+
const rejects = [];
|
|
1616
|
+
for (const [relName, relSpec] of (0, filters_js_1.sortedEntries)(withClause)) {
|
|
1617
|
+
if (relName === '_count')
|
|
1618
|
+
continue;
|
|
1619
|
+
const relDef = (0, utils_js_1.ownLookup)(meta.relations, relName);
|
|
1620
|
+
// An unknown relation stays on the subquery path so it raises E005 there.
|
|
1621
|
+
if (!relDef)
|
|
1622
|
+
continue;
|
|
1623
|
+
const node = planFlattenNode(qi, counter, relName, relDef, relSpec, 0, [table], includePii, aliases, rejects);
|
|
1624
|
+
if (node)
|
|
1625
|
+
nodes[relName] = node;
|
|
1626
|
+
}
|
|
1627
|
+
warnFlattenFallback(table, rejects);
|
|
1628
|
+
if (Object.keys(nodes).length === 0)
|
|
1629
|
+
return null;
|
|
1630
|
+
// Flat rows carry the root table's own columns, one column per non-flattened
|
|
1631
|
+
// relation (named for the relation) and `_count__<rel>` scalars alongside the
|
|
1632
|
+
// prefixed aliases. A collision would make the assembler read the wrong cell,
|
|
1633
|
+
// so refuse the whole plan rather than plan around it.
|
|
1634
|
+
const reserved = new Set([...meta.allColumns, ...Object.keys(withClause)]);
|
|
1635
|
+
for (const alias of aliases) {
|
|
1636
|
+
if (reserved.has(alias)) {
|
|
1637
|
+
warnFlattenFallback(table, [
|
|
1638
|
+
{
|
|
1639
|
+
relation: Object.keys(nodes).join(', '),
|
|
1640
|
+
reason: `the projected alias "${alias}" collides with a column or relation name on "${table}"`,
|
|
1641
|
+
},
|
|
1642
|
+
]);
|
|
1643
|
+
return null;
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
const signature = Object.keys(nodes)
|
|
1647
|
+
.sort()
|
|
1648
|
+
.map((relName) => flattenNodeSignature(nodes[relName]))
|
|
1649
|
+
.join('|');
|
|
1650
|
+
return { nodes, aliases, signature };
|
|
1651
|
+
}
|
|
1652
|
+
/**
|
|
1653
|
+
* Compile the correlation of a to-one relation as a join condition:
|
|
1654
|
+
* - `belongsTo` → `target.referenceKey = parent.foreignKey` (SOURCE holds the FK)
|
|
1655
|
+
* - `hasOne` → `target.foreignKey = parent.referenceKey` (TARGET holds the FK)
|
|
1656
|
+
*
|
|
1657
|
+
* Same column pairing {@link buildRelationSubquery} correlates on; getting it
|
|
1658
|
+
* backwards silently compares the wrong columns.
|
|
1659
|
+
*/
|
|
1660
|
+
function flattenCorrelation(qi, relDef, targetRef, targetColumns, parentRef) {
|
|
1661
|
+
const parentColumns = relDef.type === 'belongsTo' ? relDef.foreignKey : relDef.referenceKey;
|
|
1662
|
+
return qi.dialect.buildCorrelation(targetRef, targetColumns, parentRef, parentColumns);
|
|
1663
|
+
}
|
|
1664
|
+
/**
|
|
1665
|
+
* Emit one TOP-LEVEL flattened relation: a derived table holding its whole
|
|
1666
|
+
* to-one subtree, joined to the parent on the re-exposed correlation columns,
|
|
1667
|
+
* plus the pass-through projection of every name that subtree contributes.
|
|
1668
|
+
*
|
|
1669
|
+
* ```sql
|
|
1670
|
+
* LEFT JOIN (
|
|
1671
|
+
* SELECT 1 AS "f0__$k", f0s."id" AS "f0__$c0",
|
|
1672
|
+
* f0s."id" AS "f0__id", f0s."name" AS "f0__name",
|
|
1673
|
+
* (f1s."id" IS NOT NULL) AS "f1__$k", f1s."code" AS "f1__code"
|
|
1674
|
+
* FROM "orgs" f0s
|
|
1675
|
+
* LEFT JOIN "regions" f1s ON f1s."id" = f0s."region_id"
|
|
1676
|
+
* WHERE f0s."deleted" = $1
|
|
1677
|
+
* ) f0 ON f0."f0__$c0" = "users"."org_id"
|
|
1678
|
+
* ```
|
|
1679
|
+
*
|
|
1680
|
+
* See the derived-table note above for why the join cannot expose the target's
|
|
1681
|
+
* real column names.
|
|
1682
|
+
*/
|
|
1683
|
+
function emitFlattenNode(qi, node, parentRef, params, joinSink, selectSink, aliasCounter, includePii) {
|
|
1684
|
+
const innerSelects = [];
|
|
1685
|
+
const innerJoins = [];
|
|
1686
|
+
const innerWhere = [];
|
|
1687
|
+
emitFlattenInner(qi, node, params, innerSelects, innerJoins, innerWhere, aliasCounter, includePii);
|
|
1688
|
+
const whereSql = innerWhere.length > 0 ? ` WHERE ${innerWhere.join(' AND ')}` : '';
|
|
1689
|
+
const derived = `SELECT ${innerSelects.join(', ')} FROM ${qi.q(node.targetTable)} ${node.srcAlias}${innerJoins.join('')}${whereSql}`;
|
|
1690
|
+
const on = flattenCorrelation(qi, node.relDef, node.alias, node.corrAliases, parentRef);
|
|
1691
|
+
joinSink.push(` LEFT JOIN (${derived}) ${node.alias} ON ${on}`);
|
|
1692
|
+
projectFlattenNode(qi, node, node.alias, selectSink);
|
|
1693
|
+
}
|
|
1694
|
+
/**
|
|
1695
|
+
* Build one node's contribution INSIDE its top-level derived table: its filters,
|
|
1696
|
+
* its prefixed column projection, and its nested slots (another inner LEFT JOIN
|
|
1697
|
+
* for a flattened child, a correlated subquery column for anything else).
|
|
1698
|
+
*
|
|
1699
|
+
* Param push order, mirrored exactly by {@link collectFlattenNodeParams}:
|
|
1700
|
+
* relation `where` → target global filter → each nested slot in `slots` order.
|
|
1701
|
+
*/
|
|
1702
|
+
function emitFlattenInner(qi, node, params, innerSelects, innerJoins, innerWhere, aliasCounter, includePii) {
|
|
1703
|
+
const { srcAlias, targetTable, targetMeta } = node;
|
|
1704
|
+
const isRoot = node.depth === 0;
|
|
1705
|
+
// A root node's filters go in the derived table's WHERE; a nested node's go in
|
|
1706
|
+
// its own inner LEFT JOIN's ON. Both null-extend the relation on a miss rather
|
|
1707
|
+
// than dropping the parent row, matching what the correlated subquery's
|
|
1708
|
+
// `LIMIT 1` did when it returned NULL.
|
|
1709
|
+
const filters = innerWhere;
|
|
1710
|
+
if (node.spec !== true && node.spec.where) {
|
|
1711
|
+
const extra = whereMod.buildAliasWhere(qi, targetTable, targetMeta, srcAlias, node.spec.where, params);
|
|
1712
|
+
if (extra)
|
|
1713
|
+
filters.push(extra);
|
|
1714
|
+
}
|
|
1715
|
+
// Global filter on the target (soft-delete / tenancy): a flattened relation
|
|
1716
|
+
// must never surface rows the join strategy would have filtered out.
|
|
1717
|
+
const gf = whereMod.targetGlobalFilterAlias(qi, targetTable, srcAlias, params);
|
|
1718
|
+
if (gf)
|
|
1719
|
+
filters.push(gf);
|
|
1720
|
+
if (isRoot) {
|
|
1721
|
+
// The derived table's rows exist only where the target matched, so a
|
|
1722
|
+
// constant marks the match; the outer LEFT JOIN nulls it on a miss.
|
|
1723
|
+
innerSelects.push(`1 AS ${qi.q(node.discAlias)}`);
|
|
1724
|
+
node.keyColumns.forEach((col, i) => {
|
|
1725
|
+
innerSelects.push(`${srcAlias}.${qi.q(col)} AS ${qi.q(node.corrAliases[i])}`);
|
|
1726
|
+
});
|
|
1727
|
+
}
|
|
1728
|
+
else {
|
|
1729
|
+
innerSelects.push(`(${srcAlias}.${qi.q(node.keyColumns[0])} IS NOT NULL) AS ${qi.q(node.discAlias)}`);
|
|
1730
|
+
}
|
|
1731
|
+
for (const [col, sqlAlias] of node.cols) {
|
|
1732
|
+
innerSelects.push(`${srcAlias}.${qi.q(col)} AS ${qi.q(sqlAlias)}`);
|
|
1733
|
+
}
|
|
1734
|
+
for (const slot of node.slots) {
|
|
1735
|
+
if (slot.kind === 'flat') {
|
|
1736
|
+
const child = node.children[slot.relName];
|
|
1737
|
+
const on = flattenCorrelation(qi, child.relDef, child.srcAlias, child.keyColumns, srcAlias);
|
|
1738
|
+
// Placeholder: the child's own filters are appended to this ON below, so
|
|
1739
|
+
// reserve the slot now to keep join order matching slot order.
|
|
1740
|
+
const at = innerJoins.length;
|
|
1741
|
+
innerJoins.push('');
|
|
1742
|
+
const childFilters = [];
|
|
1743
|
+
emitFlattenInner(qi, child, params, innerSelects, innerJoins, childFilters, aliasCounter, includePii);
|
|
1744
|
+
const conds = [on, ...childFilters].join(' AND ');
|
|
1745
|
+
innerJoins[at] = ` LEFT JOIN ${qi.q(child.targetTable)} ${child.srcAlias} ON ${conds}`;
|
|
1746
|
+
continue;
|
|
1747
|
+
}
|
|
1748
|
+
const nestedRelDef = (0, utils_js_1.ownLookup)(targetMeta.relations, slot.relName);
|
|
1749
|
+
const nestedSpec = node.spec.with[slot.relName];
|
|
1750
|
+
const sub = buildRelationSubquery(qi, nestedRelDef, nestedSpec, params, srcAlias, aliasCounter, node.depth + 1, [...node.path, node.relName], includePii);
|
|
1751
|
+
// Same fallback the subquery path picks for a nested relation slot.
|
|
1752
|
+
const fallback = nestedRelDef.type === 'hasMany' ? qi.dialect.emptyJsonArrayLiteral : qi.dialect.nullJsonLiteral;
|
|
1753
|
+
innerSelects.push(`${qi.dialect.wrapJsonSubresult(sub, fallback)} AS ${qi.q(node.jsonAliases[slot.relName])}`);
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
/**
|
|
1757
|
+
* Pass every name a flattened subtree contributes through the outer SELECT, in
|
|
1758
|
+
* the same order {@link emitFlattenInner} produced it (which is the order the
|
|
1759
|
+
* join strategy's `json_build_object` uses, so assembled key order matches).
|
|
1760
|
+
* The internal `$c` correlation columns are deliberately NOT projected: they
|
|
1761
|
+
* exist only for the outer join condition.
|
|
1762
|
+
*/
|
|
1763
|
+
function projectFlattenNode(qi, node, outerAlias, selectSink) {
|
|
1764
|
+
const ref = (name) => `${qi.q(outerAlias)}.${qi.q(name)}`;
|
|
1765
|
+
selectSink.push(ref(node.discAlias));
|
|
1766
|
+
for (const [, sqlAlias] of node.cols)
|
|
1767
|
+
selectSink.push(ref(sqlAlias));
|
|
1768
|
+
for (const slot of node.slots) {
|
|
1769
|
+
if (slot.kind === 'flat')
|
|
1770
|
+
projectFlattenNode(qi, node.children[slot.relName], outerAlias, selectSink);
|
|
1771
|
+
else
|
|
1772
|
+
selectSink.push(ref(node.jsonAliases[slot.relName]));
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
/** Param-collect mirror of {@link emitFlattenNode}. */
|
|
1776
|
+
function collectFlattenNodeParams(qi, node, params) {
|
|
1777
|
+
if (node.spec !== true && node.spec.where) {
|
|
1778
|
+
whereMod.collectAliasWhereParams(qi, node.targetTable, node.targetMeta, node.spec.where, params);
|
|
1779
|
+
}
|
|
1780
|
+
whereMod.collectTargetGlobalFilterAlias(qi, node.targetTable, params);
|
|
1781
|
+
for (const slot of node.slots) {
|
|
1782
|
+
if (slot.kind === 'flat') {
|
|
1783
|
+
collectFlattenNodeParams(qi, node.children[slot.relName], params);
|
|
1784
|
+
continue;
|
|
1785
|
+
}
|
|
1786
|
+
const nestedRelDef = (0, utils_js_1.ownLookup)(node.targetMeta.relations, slot.relName);
|
|
1787
|
+
if (!nestedRelDef)
|
|
1788
|
+
continue;
|
|
1789
|
+
const nestedSpec = node.spec.with[slot.relName];
|
|
1790
|
+
collectRelationSubqueryParams(qi, nestedRelDef, nestedSpec, params, node.alias, node.depth + 1);
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
/**
|
|
1794
|
+
* Rebuild one flattened relation's object from a flat row.
|
|
1795
|
+
*
|
|
1796
|
+
* Returns `null` when the discriminator is NULL (the LEFT JOIN matched
|
|
1797
|
+
* nothing), which is the ONLY signal that distinguishes "no related row" from
|
|
1798
|
+
* "a related row whose every projected column is NULL".
|
|
1799
|
+
*
|
|
1800
|
+
* The sub-row is keyed by the target's raw snake_case column names and handed
|
|
1801
|
+
* to {@link parseNestedRow}, so the flattened object goes through the very same
|
|
1802
|
+
* camelCase mapping, Date coercion and nested-JSON parsing the join strategy's
|
|
1803
|
+
* `json_build_object` output does. Nested flattened relations are assigned
|
|
1804
|
+
* after the parse, over placeholder keys inserted in slot order, so the
|
|
1805
|
+
* assembled object's key order matches the join strategy's exactly.
|
|
1806
|
+
*/
|
|
1807
|
+
function assembleFlattenNode(qi, row, node) {
|
|
1808
|
+
// The discriminator is the projected predicate `key IS NOT NULL`, so its
|
|
1809
|
+
// truthy encodings are engine-specific (Postgres booleans, MySQL/SQLite 0/1,
|
|
1810
|
+
// and a text-mode driver's 't'/'1'). Whitelist the matched forms: an
|
|
1811
|
+
// unrecognized encoding reads as "no related row", which the cross-engine
|
|
1812
|
+
// parity suites surface loudly rather than as scattered wrong objects.
|
|
1813
|
+
const disc = row[node.discAlias];
|
|
1814
|
+
const matched = disc === true || disc === 1 || disc === '1' || disc === 't' || disc === 'true';
|
|
1815
|
+
if (!matched)
|
|
1816
|
+
return null;
|
|
1817
|
+
const sub = {};
|
|
1818
|
+
for (const [col, sqlAlias] of node.cols)
|
|
1819
|
+
sub[col] = row[sqlAlias];
|
|
1820
|
+
for (const slot of node.slots) {
|
|
1821
|
+
// A 'flat' slot is a placeholder here: parseNestedRow skips `undefined`
|
|
1822
|
+
// relation values, so the key keeps its position and is filled in below.
|
|
1823
|
+
sub[slot.relName] = slot.kind === 'json' ? row[node.jsonAliases[slot.relName]] : undefined;
|
|
1824
|
+
}
|
|
1825
|
+
const parsed = parseNestedRow(qi, sub, node.targetTable);
|
|
1826
|
+
for (const slot of node.slots) {
|
|
1827
|
+
if (slot.kind === 'flat')
|
|
1828
|
+
parsed[slot.relName] = assembleFlattenNode(qi, row, node.children[slot.relName]);
|
|
1829
|
+
}
|
|
1830
|
+
return parsed;
|
|
1831
|
+
}
|
|
1832
|
+
/**
|
|
1833
|
+
* Row parser for a `'flatten'` plan: strip the prefixed join columns out of the
|
|
1834
|
+
* flat row (inserting a placeholder at the position each relation's block
|
|
1835
|
+
* started, so key order is preserved), parse the remainder exactly as the join
|
|
1836
|
+
* strategy does, then assemble each flattened relation.
|
|
1837
|
+
*/
|
|
1838
|
+
function makeFlattenParser(qi, plan) {
|
|
1839
|
+
const flatAliases = new Set(plan.aliases);
|
|
1840
|
+
// The discriminator is emitted FIRST for each node, so it marks where a
|
|
1841
|
+
// relation's block begins in the flat row's key order.
|
|
1842
|
+
const relationByLeadAlias = new Map();
|
|
1843
|
+
for (const [relName, node] of Object.entries(plan.nodes))
|
|
1844
|
+
relationByLeadAlias.set(node.discAlias, relName);
|
|
1845
|
+
return (row) => {
|
|
1846
|
+
const stripped = {};
|
|
1847
|
+
for (const key of Object.keys(row)) {
|
|
1848
|
+
if (!flatAliases.has(key)) {
|
|
1849
|
+
stripped[key] = row[key];
|
|
1850
|
+
continue;
|
|
1851
|
+
}
|
|
1852
|
+
const relName = relationByLeadAlias.get(key);
|
|
1853
|
+
if (relName !== undefined)
|
|
1854
|
+
stripped[relName] = undefined;
|
|
1855
|
+
}
|
|
1856
|
+
const parsed = parseNestedRow(qi, stripped, qi.table);
|
|
1857
|
+
for (const [relName, node] of Object.entries(plan.nodes)) {
|
|
1858
|
+
parsed[relName] = assembleFlattenNode(qi, row, node);
|
|
1859
|
+
}
|
|
1860
|
+
return parsed;
|
|
1861
|
+
};
|
|
1862
|
+
}
|
|
1212
1863
|
/**
|
|
1213
1864
|
* Build a SELECT clause that includes both base columns and nested relation subqueries.
|
|
1214
1865
|
*
|
|
@@ -1250,7 +1901,7 @@ function decodePositionalObject(qi, arr, shape) {
|
|
|
1250
1901
|
* @returns A complete SELECT clause string (without the `SELECT` keyword) containing
|
|
1251
1902
|
* base columns and relation subqueries.
|
|
1252
1903
|
*/
|
|
1253
|
-
function buildSelectWithRelations(qi, table, withClause, params, columnsList, depth, path, includePii) {
|
|
1904
|
+
function buildSelectWithRelations(qi, table, withClause, params, columnsList, depth, path, includePii, flatten) {
|
|
1254
1905
|
const meta = qi.schema.tables[table];
|
|
1255
1906
|
if (!meta)
|
|
1256
1907
|
throw new errors_js_1.ValidationError(`[turbine] Unknown table "${table}"`);
|
|
@@ -1270,11 +1921,19 @@ function buildSelectWithRelations(qi, table, withClause, params, columnsList, de
|
|
|
1270
1921
|
// `_count` is a reserved key handled after the relation subqueries.
|
|
1271
1922
|
if (relName === '_count')
|
|
1272
1923
|
continue;
|
|
1273
|
-
const relDef = meta.relations
|
|
1924
|
+
const relDef = (0, utils_js_1.ownLookup)(meta.relations, relName);
|
|
1274
1925
|
if (!relDef) {
|
|
1275
1926
|
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
|
|
1276
1927
|
`Available: ${Object.keys(meta.relations).join(', ')}`);
|
|
1277
1928
|
}
|
|
1929
|
+
// `relationLoadStrategy: 'flatten'`: an eligible to-one relation becomes a
|
|
1930
|
+
// LEFT JOIN + prefixed scalar projection instead of a per-parent-row
|
|
1931
|
+
// correlated subquery. Every other relation falls through unchanged.
|
|
1932
|
+
const flatNode = flatten?.plan.nodes[relName];
|
|
1933
|
+
if (flatNode) {
|
|
1934
|
+
emitFlattenNode(qi, flatNode, qi.q(table), params, flatten.joinSink, relationSelects, aliasCounter, includePii);
|
|
1935
|
+
continue;
|
|
1936
|
+
}
|
|
1278
1937
|
// The main table is not aliased, so pass table name as parentRef
|
|
1279
1938
|
const subquery = buildRelationSubquery(qi, relDef, relSpec, params, table, aliasCounter, depth, path, includePii);
|
|
1280
1939
|
relationSelects.push(`(${subquery}) AS ${qi.q(relName)}`);
|
|
@@ -1454,10 +2113,7 @@ function buildRelationSubquery(qi, relDef, spec, params, parentRef, aliasCounter
|
|
|
1454
2113
|
});
|
|
1455
2114
|
}
|
|
1456
2115
|
// Build JSON object pairs for resolved columns
|
|
1457
|
-
const jsonPairs =
|
|
1458
|
-
targetMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col),
|
|
1459
|
-
`${alias}.${qi.q(col)}`,
|
|
1460
|
-
]);
|
|
2116
|
+
const jsonPairs = jsonScalarPairs(qi, targetMeta, targetColumns, alias);
|
|
1461
2117
|
// Determine if this hasMany will take the wrapped subquery path (LIMIT or ORDER BY).
|
|
1462
2118
|
// When wrapping, nested relations are built in the wrapped path referencing innerAlias,
|
|
1463
2119
|
// so we must NOT build them here (they would push orphaned params).
|
|
@@ -1475,7 +2131,7 @@ function buildRelationSubquery(qi, relDef, spec, params, parentRef, aliasCounter
|
|
|
1475
2131
|
// Nested relations — only in the non-wrapped path (wrapped path builds them separately)
|
|
1476
2132
|
if (!willWrap && spec !== true && spec.with) {
|
|
1477
2133
|
for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
|
|
1478
|
-
const nestedRelDef = targetMeta.relations
|
|
2134
|
+
const nestedRelDef = (0, utils_js_1.ownLookup)(targetMeta.relations, nestedRelName);
|
|
1479
2135
|
if (!nestedRelDef) {
|
|
1480
2136
|
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
1481
2137
|
`Available: ${Object.keys(targetMeta.relations).join(', ')}`);
|
|
@@ -1534,7 +2190,7 @@ function buildRelationSubquery(qi, relDef, spec, params, parentRef, aliasCounter
|
|
|
1534
2190
|
// `limit: 0` is honored (LIMIT 0 → empty array), so check !== undefined.
|
|
1535
2191
|
let limitClause = '';
|
|
1536
2192
|
if (relDef.type === 'hasMany' && spec !== true && spec.limit !== undefined) {
|
|
1537
|
-
limitClause = ` LIMIT ${qi.paginationRef(spec.limit, params)}`;
|
|
2193
|
+
limitClause = ` LIMIT ${qi.paginationRef(spec.limit, params, 'relation limit')}`;
|
|
1538
2194
|
}
|
|
1539
2195
|
if (relDef.type === 'hasMany') {
|
|
1540
2196
|
// When LIMIT or ORDER BY is used, wrap in a subquery so LIMIT applies to rows
|
|
@@ -1545,14 +2201,11 @@ function buildRelationSubquery(qi, relDef, spec, params, parentRef, aliasCounter
|
|
|
1545
2201
|
// Inner SELECT always needs all columns for WHERE/ORDER to work; json_build_object filters later
|
|
1546
2202
|
const innerSql = `SELECT ${targetMeta.allColumns.map((c) => `${alias}.${qi.q(c)}`).join(', ')} FROM ${qTarget} ${alias} WHERE ${whereClause}${orderClause}${limitClause}`;
|
|
1547
2203
|
// For the json_build_object, reference the inner alias — only include resolved columns
|
|
1548
|
-
const innerJsonPairs =
|
|
1549
|
-
targetMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col),
|
|
1550
|
-
`${innerAlias}.${qi.q(col)}`,
|
|
1551
|
-
]);
|
|
2204
|
+
const innerJsonPairs = jsonScalarPairs(qi, targetMeta, targetColumns, innerAlias);
|
|
1552
2205
|
// Build nested relation subqueries referencing innerAlias
|
|
1553
2206
|
if (spec !== true && spec.with) {
|
|
1554
2207
|
for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
|
|
1555
|
-
const nestedRelDef = targetMeta.relations
|
|
2208
|
+
const nestedRelDef = (0, utils_js_1.ownLookup)(targetMeta.relations, nestedRelName);
|
|
1556
2209
|
if (!nestedRelDef) {
|
|
1557
2210
|
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
1558
2211
|
`Available: ${Object.keys(targetMeta.relations).join(', ')}`);
|
|
@@ -1655,7 +2308,7 @@ function buildManyToManySubquery(qi, relDef, spec, params, parentRef, aliasCount
|
|
|
1655
2308
|
// LIMIT — `limit: 0` is honored (LIMIT 0 → empty array)
|
|
1656
2309
|
let limitClause = '';
|
|
1657
2310
|
if (spec !== true && spec.limit !== undefined) {
|
|
1658
|
-
limitClause = ` LIMIT ${qi.paginationRef(spec.limit, params)}`;
|
|
2311
|
+
limitClause = ` LIMIT ${qi.paginationRef(spec.limit, params, 'relation limit')}`;
|
|
1659
2312
|
}
|
|
1660
2313
|
const fromJoin = `FROM ${qTarget} ${talias} JOIN ${qJunction} ${jalias} ON ${joinOn}`;
|
|
1661
2314
|
// When LIMIT or ORDER BY is present, wrap the joined rows in an inner subquery
|
|
@@ -1664,14 +2317,11 @@ function buildManyToManySubquery(qi, relDef, spec, params, parentRef, aliasCount
|
|
|
1664
2317
|
const innerAlias = `${talias}i`;
|
|
1665
2318
|
const innerSql = `SELECT ${targetMeta.allColumns.map((c) => `${talias}.${qi.q(c)}`).join(', ')} ` +
|
|
1666
2319
|
`${fromJoin} WHERE ${whereClause}${orderClause}${limitClause}`;
|
|
1667
|
-
const innerJsonPairs =
|
|
1668
|
-
targetMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col),
|
|
1669
|
-
`${innerAlias}.${qi.q(col)}`,
|
|
1670
|
-
]);
|
|
2320
|
+
const innerJsonPairs = jsonScalarPairs(qi, targetMeta, targetColumns, innerAlias);
|
|
1671
2321
|
// Nested relations reference the inner alias.
|
|
1672
2322
|
if (spec !== true && spec.with) {
|
|
1673
2323
|
for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
|
|
1674
|
-
const nestedRelDef = targetMeta.relations
|
|
2324
|
+
const nestedRelDef = (0, utils_js_1.ownLookup)(targetMeta.relations, nestedRelName);
|
|
1675
2325
|
if (!nestedRelDef) {
|
|
1676
2326
|
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
1677
2327
|
`Available: ${Object.keys(targetMeta.relations).join(', ')}`);
|
|
@@ -1688,13 +2338,10 @@ function buildManyToManySubquery(qi, relDef, spec, params, parentRef, aliasCount
|
|
|
1688
2338
|
}
|
|
1689
2339
|
// Simple path: build the json object pairs directly off the target alias,
|
|
1690
2340
|
// including any nested relations (correlated to the target alias).
|
|
1691
|
-
const jsonPairs =
|
|
1692
|
-
targetMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col),
|
|
1693
|
-
`${talias}.${qi.q(col)}`,
|
|
1694
|
-
]);
|
|
2341
|
+
const jsonPairs = jsonScalarPairs(qi, targetMeta, targetColumns, talias);
|
|
1695
2342
|
if (spec !== true && spec.with) {
|
|
1696
2343
|
for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
|
|
1697
|
-
const nestedRelDef = targetMeta.relations
|
|
2344
|
+
const nestedRelDef = (0, utils_js_1.ownLookup)(targetMeta.relations, nestedRelName);
|
|
1698
2345
|
if (!nestedRelDef) {
|
|
1699
2346
|
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
1700
2347
|
`Available: ${Object.keys(targetMeta.relations).join(', ')}`);
|