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
package/dist/query/relations.js
CHANGED
|
@@ -17,7 +17,7 @@ import { missingIndexForRelation } from '../index-advisor.js';
|
|
|
17
17
|
import { camelToSnake, normalizeKeyColumns, snakeToCamel } from '../schema.js';
|
|
18
18
|
import { resolveCountRelations } from './batched-loader.js';
|
|
19
19
|
import { isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, isVectorOrderBy, normalizeOrderBy, orderByEntries, sortedEntries, } from './filters.js';
|
|
20
|
-
import { ownLookup } from './utils.js';
|
|
20
|
+
import { coerceJsonWireValue, jsonWireCoercionOid, ownLookup } from './utils.js';
|
|
21
21
|
import { hasWarnedOnce, shouldWarnOnce, WARN_NS } from './warn-registry.js';
|
|
22
22
|
import * as whereMod from './where.js';
|
|
23
23
|
import * as writesMod from './writes.js';
|
|
@@ -92,7 +92,7 @@ export function withFingerprint(qi, withClause, table, depth = 0) {
|
|
|
92
92
|
.join(',')})`);
|
|
93
93
|
continue;
|
|
94
94
|
}
|
|
95
|
-
const relDef = meta.relations
|
|
95
|
+
const relDef = ownLookup(meta.relations, relName);
|
|
96
96
|
if (!relDef) {
|
|
97
97
|
parts.push(`unknown:${relName}`);
|
|
98
98
|
continue;
|
|
@@ -122,7 +122,7 @@ export function withFingerprint(qi, withClause, table, depth = 0) {
|
|
|
122
122
|
// `{title: {contains: 'x'}}` emit different SQL so they must not share
|
|
123
123
|
// a fingerprint)
|
|
124
124
|
if (opts.where) {
|
|
125
|
-
subParts.push(`w=${whereMod.fingerprintAliasWhere(qi, opts.where,
|
|
125
|
+
subParts.push(`w=${whereMod.fingerprintAliasWhere(qi, opts.where, relDef.to)}`);
|
|
126
126
|
}
|
|
127
127
|
// orderBy shape (OrderBySpec nulls placement changes the SQL, so fingerprint it)
|
|
128
128
|
if (opts.orderBy) {
|
|
@@ -150,14 +150,19 @@ export function withFingerprint(qi, withClause, table, depth = 0) {
|
|
|
150
150
|
* Collect params from a `with` clause tree. Mirrors buildSelectWithRelations +
|
|
151
151
|
* buildRelationSubquery param-push order.
|
|
152
152
|
*/
|
|
153
|
-
export function collectWithParams(qi, withClause, params, table) {
|
|
153
|
+
export function collectWithParams(qi, withClause, params, table, flattenPlan) {
|
|
154
154
|
const meta = qi.schema.tables[table ?? qi.table];
|
|
155
155
|
if (!meta)
|
|
156
156
|
return;
|
|
157
157
|
for (const [relName, relSpec] of sortedEntries(withClause)) {
|
|
158
|
-
const relDef = meta.relations
|
|
158
|
+
const relDef = ownLookup(meta.relations, relName);
|
|
159
159
|
if (!relDef)
|
|
160
160
|
continue;
|
|
161
|
+
const flatNode = flattenPlan?.nodes[relName];
|
|
162
|
+
if (flatNode) {
|
|
163
|
+
collectFlattenNodeParams(qi, flatNode, params);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
161
166
|
collectRelationSubqueryParams(qi, relDef, relSpec, params, table ?? qi.table);
|
|
162
167
|
}
|
|
163
168
|
// `_count` global-filter params — mirror buildSelectWithRelations, which
|
|
@@ -199,11 +204,11 @@ export function collectRelationSubqueryParams(qi, relDef, spec, params, _parentR
|
|
|
199
204
|
}
|
|
200
205
|
whereMod.collectTargetGlobalFilterAlias(qi, targetTable, params);
|
|
201
206
|
if (spec.limit !== undefined && !qi.dialect.inlineLimitOffset) {
|
|
202
|
-
params.push(
|
|
207
|
+
params.push(qi.paginationValue(spec.limit, 'relation limit'));
|
|
203
208
|
}
|
|
204
209
|
if (spec.with) {
|
|
205
210
|
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
206
|
-
const nestedRelDef = targetMeta.relations
|
|
211
|
+
const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
|
|
207
212
|
if (!nestedRelDef)
|
|
208
213
|
continue;
|
|
209
214
|
collectRelationSubqueryParams(qi, nestedRelDef, nestedSpec, params, 'alias', depth + 1);
|
|
@@ -218,7 +223,7 @@ export function collectRelationSubqueryParams(qi, relDef, spec, params, _parentR
|
|
|
218
223
|
// Non-wrapped path: nested relations BEFORE where/limit
|
|
219
224
|
if (!willWrap && spec.with) {
|
|
220
225
|
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
221
|
-
const nestedRelDef = targetMeta.relations
|
|
226
|
+
const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
|
|
222
227
|
if (!nestedRelDef)
|
|
223
228
|
continue;
|
|
224
229
|
collectRelationSubqueryParams(qi, nestedRelDef, nestedSpec, params, 'alias', depth + 1);
|
|
@@ -242,12 +247,12 @@ export function collectRelationSubqueryParams(qi, relDef, spec, params, _parentR
|
|
|
242
247
|
// pushing one here would orphan a param and desync the collect path.
|
|
243
248
|
// `limit: 0` pushes (LIMIT 0 is honored), so check !== undefined.
|
|
244
249
|
if (relDef.type === 'hasMany' && spec.limit !== undefined && !qi.dialect.inlineLimitOffset) {
|
|
245
|
-
params.push(
|
|
250
|
+
params.push(qi.paginationValue(spec.limit, 'relation limit'));
|
|
246
251
|
}
|
|
247
252
|
// Wrapped path: nested relations AFTER where/limit (inside inner subquery)
|
|
248
253
|
if (willWrap && spec.with) {
|
|
249
254
|
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
250
|
-
const nestedRelDef = targetMeta.relations
|
|
255
|
+
const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
|
|
251
256
|
if (!nestedRelDef)
|
|
252
257
|
continue;
|
|
253
258
|
collectRelationSubqueryParams(qi, nestedRelDef, nestedSpec, params, 'innerAlias', depth + 1);
|
|
@@ -478,7 +483,7 @@ export function buildRelationOrderBy(qi, relName, value, alias, params, ctx, lat
|
|
|
478
483
|
const ownerMeta = ctx?.meta ?? qi.tableMeta;
|
|
479
484
|
const ownerTable = ctx?.table ?? qi.table;
|
|
480
485
|
const parentRef = ctx?.parentRef ?? qi.table;
|
|
481
|
-
const relDef = ownerMeta.relations
|
|
486
|
+
const relDef = ownLookup(ownerMeta.relations, relName);
|
|
482
487
|
if (!relDef) {
|
|
483
488
|
// A table with no relations at all would otherwise render a dangling
|
|
484
489
|
// "Available: " and read as a broken message; and the most likely cause of
|
|
@@ -798,7 +803,7 @@ export function collectRelationOrderParams(qi, targetTable, targetMeta, orderEnt
|
|
|
798
803
|
if (isRelationPickOrderBy(dirValue)) {
|
|
799
804
|
throw pickOrderNestedError(qi, key);
|
|
800
805
|
}
|
|
801
|
-
const relDef = targetMeta.relations
|
|
806
|
+
const relDef = ownLookup(targetMeta.relations, key);
|
|
802
807
|
if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
|
|
803
808
|
collectRelationCountParams(qi, relDef, params);
|
|
804
809
|
}
|
|
@@ -913,12 +918,23 @@ export function getCamelDateFields(qi, table, meta) {
|
|
|
913
918
|
}
|
|
914
919
|
return camel;
|
|
915
920
|
}
|
|
916
|
-
/**
|
|
917
|
-
|
|
918
|
-
|
|
921
|
+
/**
|
|
922
|
+
* Parse a row that may contain JSON nested relation columns.
|
|
923
|
+
*
|
|
924
|
+
* `fromJson` says where THIS row's own scalar columns came from. A root row
|
|
925
|
+
* (join, batched, flatten) is read straight off the driver, so `false`; a row
|
|
926
|
+
* decoded out of a `json_agg`/`json_build_object` column is `true` and gets
|
|
927
|
+
* its divergent scalars decoded back to the driver's representation first (see
|
|
928
|
+
* the JSON-wire section above). Recursion into a relation column is always
|
|
929
|
+
* `true`, which is exactly right on every strategy: `batched` and `flatten`
|
|
930
|
+
* hand this function driver rows, but any relation still nested INSIDE one of
|
|
931
|
+
* those rows arrived as a correlated JSON subquery.
|
|
932
|
+
*/
|
|
933
|
+
export function parseNestedRow(qi, row, table, fromJson = false) {
|
|
919
934
|
const meta = qi.schema.tables[table];
|
|
920
935
|
if (!meta)
|
|
921
|
-
return
|
|
936
|
+
return qi.parseRow(row, table);
|
|
937
|
+
const parsed = qi.parseRow(fromJson ? decodeJsonWireRow(qi, row, table, meta) : row, table);
|
|
922
938
|
// Assemble reserved `_count__<rel>` scalar columns into a `_count` object.
|
|
923
939
|
// parseRow copies these unknown columns through under their raw key.
|
|
924
940
|
let countObj;
|
|
@@ -958,11 +974,11 @@ export function parseNestedRow(qi, row, table) {
|
|
|
958
974
|
// coercion AND its own sub-relations parsed at arbitrary depth.
|
|
959
975
|
if (Array.isArray(jsonVal)) {
|
|
960
976
|
parsed[relName] = jsonVal.map((item) => typeof item === 'object' && item !== null
|
|
961
|
-
? parseNestedRow(qi, item, relDef.to)
|
|
977
|
+
? parseNestedRow(qi, item, relDef.to, true)
|
|
962
978
|
: item);
|
|
963
979
|
}
|
|
964
980
|
else if (typeof jsonVal === 'object' && jsonVal !== null) {
|
|
965
|
-
parsed[relName] = parseNestedRow(qi, jsonVal, relDef.to);
|
|
981
|
+
parsed[relName] = parseNestedRow(qi, jsonVal, relDef.to, true);
|
|
966
982
|
}
|
|
967
983
|
else {
|
|
968
984
|
parsed[relName] = jsonVal;
|
|
@@ -975,11 +991,11 @@ export function parseNestedRow(qi, row, table) {
|
|
|
975
991
|
}
|
|
976
992
|
else if (Array.isArray(rawValue)) {
|
|
977
993
|
parsed[relName] = rawValue.map((item) => typeof item === 'object' && item !== null
|
|
978
|
-
? parseNestedRow(qi, item, relDef.to)
|
|
994
|
+
? parseNestedRow(qi, item, relDef.to, true)
|
|
979
995
|
: item);
|
|
980
996
|
}
|
|
981
997
|
else if (typeof rawValue === 'object' && rawValue !== null) {
|
|
982
|
-
parsed[relName] = parseNestedRow(qi, rawValue, relDef.to);
|
|
998
|
+
parsed[relName] = parseNestedRow(qi, rawValue, relDef.to, true);
|
|
983
999
|
}
|
|
984
1000
|
else {
|
|
985
1001
|
parsed[relName] = rawValue;
|
|
@@ -1029,6 +1045,112 @@ export function buildJsonRow(qi, jsonPairs) {
|
|
|
1029
1045
|
}
|
|
1030
1046
|
return qi.dialect.buildJsonObject(jsonPairs);
|
|
1031
1047
|
}
|
|
1048
|
+
// ---------------------------------------------------------------------------
|
|
1049
|
+
// JSON-wire value fidelity (see JSON_WIRE_COERCION_OIDS in utils.ts)
|
|
1050
|
+
//
|
|
1051
|
+
// `json_build_object` renders a handful of Postgres types as something other
|
|
1052
|
+
// than the value the driver hands back for the same column, so the 'join'
|
|
1053
|
+
// strategy used to disagree with a top-level read, with 'batched', and with
|
|
1054
|
+
// 'flatten' — losing precision outright on numeric and int8. The two halves of
|
|
1055
|
+
// the fix live here and must stay in lockstep:
|
|
1056
|
+
//
|
|
1057
|
+
// SQL side jsonScalarPairs() emits `alias."col"::text` for a divergent
|
|
1058
|
+
// column, so the JSON carries the driver's wire text verbatim.
|
|
1059
|
+
// parse side parseNestedRow(..., fromJson = true) runs that text back
|
|
1060
|
+
// through the driver's own parser for the column's OID.
|
|
1061
|
+
//
|
|
1062
|
+
// Both derive the column set from the SAME jsonWireCoercionOid() call over the
|
|
1063
|
+
// target table's `pgTypes`, so a cast can never be emitted without a matching
|
|
1064
|
+
// decode (or vice versa). Postgres-only: other engines neither use
|
|
1065
|
+
// json_build_object nor share this divergence set.
|
|
1066
|
+
// ---------------------------------------------------------------------------
|
|
1067
|
+
/** True when this query's engine gets the JSON-wire cast/decode treatment. */
|
|
1068
|
+
function usesJsonWireCoercion(qi) {
|
|
1069
|
+
return qi.dialect.name === 'postgresql';
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* The JSON value expression for one scalar relation column: the plain column
|
|
1073
|
+
* reference, or a `::text` cast when the type's JSON rendering would not match
|
|
1074
|
+
* what the driver produces.
|
|
1075
|
+
*/
|
|
1076
|
+
function jsonScalarExpr(qi, targetMeta, col, ref) {
|
|
1077
|
+
const expr = `${ref}.${qi.q(col)}`;
|
|
1078
|
+
if (!usesJsonWireCoercion(qi))
|
|
1079
|
+
return expr;
|
|
1080
|
+
return jsonWireCoercionOid(targetMeta.pgTypes?.[col]) === undefined ? expr : `${expr}::text`;
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* The `[camelKey, valueExpr]` pairs for a relation's scalar columns. Single
|
|
1084
|
+
* source for all four `json_build_object` emission sites (to-one, hasMany
|
|
1085
|
+
* simple + wrapped, manyToMany) so the `::text` casts can never be applied on
|
|
1086
|
+
* one path and forgotten on another.
|
|
1087
|
+
*/
|
|
1088
|
+
function jsonScalarPairs(qi, targetMeta, targetColumns, ref) {
|
|
1089
|
+
return targetColumns.map((col) => [
|
|
1090
|
+
targetMeta.reverseColumnMap[col] ?? snakeToCamel(col),
|
|
1091
|
+
jsonScalarExpr(qi, targetMeta, col, ref),
|
|
1092
|
+
]);
|
|
1093
|
+
}
|
|
1094
|
+
/**
|
|
1095
|
+
* Per-`BuilderCtx`, per-table memo of the camelCase field names that need
|
|
1096
|
+
* JSON-wire decoding, mapped to the OID whose driver parser performs it.
|
|
1097
|
+
* `null` records "this table has none", the overwhelmingly common case, so a
|
|
1098
|
+
* relation of plain text/int columns costs one Map hit per row and no scan.
|
|
1099
|
+
*
|
|
1100
|
+
* Held in a WeakMap rather than on the ctx so the cache dies with the
|
|
1101
|
+
* QueryInterface and no shared interface has to grow a field for it.
|
|
1102
|
+
*/
|
|
1103
|
+
const jsonWireFieldCache = new WeakMap();
|
|
1104
|
+
function jsonWireFields(qi, table, meta) {
|
|
1105
|
+
let byTable = jsonWireFieldCache.get(qi);
|
|
1106
|
+
if (!byTable) {
|
|
1107
|
+
byTable = new Map();
|
|
1108
|
+
jsonWireFieldCache.set(qi, byTable);
|
|
1109
|
+
}
|
|
1110
|
+
const cached = byTable.get(table);
|
|
1111
|
+
if (cached !== undefined)
|
|
1112
|
+
return cached;
|
|
1113
|
+
let fields = null;
|
|
1114
|
+
const pgTypes = meta.pgTypes;
|
|
1115
|
+
if (pgTypes) {
|
|
1116
|
+
for (const col of meta.allColumns) {
|
|
1117
|
+
const oid = jsonWireCoercionOid(pgTypes[col]);
|
|
1118
|
+
if (oid === undefined)
|
|
1119
|
+
continue;
|
|
1120
|
+
if (fields === null)
|
|
1121
|
+
fields = new Map();
|
|
1122
|
+
fields.set(meta.reverseColumnMap[col] ?? snakeToCamel(col), oid);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
byTable.set(table, fields);
|
|
1126
|
+
return fields;
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* Rewrite a JSON-sourced relation row's divergent scalars into the driver's
|
|
1130
|
+
* representation. Returns `row` untouched when the table has no such column,
|
|
1131
|
+
* so the ordinary relation pays nothing beyond the memo lookup.
|
|
1132
|
+
*/
|
|
1133
|
+
function decodeJsonWireRow(qi, row, table, meta) {
|
|
1134
|
+
if (!usesJsonWireCoercion(qi))
|
|
1135
|
+
return row;
|
|
1136
|
+
const fields = jsonWireFields(qi, table, meta);
|
|
1137
|
+
if (fields === null)
|
|
1138
|
+
return row;
|
|
1139
|
+
// Copy-on-first-write rather than mutating in place: the row can be a
|
|
1140
|
+
// sub-object of the driver's parsed `json` column, which middleware may still
|
|
1141
|
+
// be holding. Measured against an in-place variant on a 20K-child-row join the
|
|
1142
|
+
// two were indistinguishable, so the copy is free insurance.
|
|
1143
|
+
let decoded;
|
|
1144
|
+
for (const [field, oid] of fields) {
|
|
1145
|
+
const raw = row[field];
|
|
1146
|
+
if (typeof raw !== 'string')
|
|
1147
|
+
continue;
|
|
1148
|
+
if (decoded === undefined)
|
|
1149
|
+
decoded = { ...row };
|
|
1150
|
+
decoded[field] = coerceJsonWireValue(oid, raw);
|
|
1151
|
+
}
|
|
1152
|
+
return decoded ?? row;
|
|
1153
|
+
}
|
|
1032
1154
|
/**
|
|
1033
1155
|
* Build the top-level relation shapes for a `with` clause, mirroring
|
|
1034
1156
|
* {@link buildSelectWithRelations}: same relation iteration order, same
|
|
@@ -1040,7 +1162,7 @@ export function buildRelationShapes(qi, table, withClause, includePii) {
|
|
|
1040
1162
|
return {};
|
|
1041
1163
|
const shapes = {};
|
|
1042
1164
|
for (const [relName, relSpec] of sortedEntries(withClause)) {
|
|
1043
|
-
const relDef = meta.relations
|
|
1165
|
+
const relDef = ownLookup(meta.relations, relName);
|
|
1044
1166
|
if (!relDef)
|
|
1045
1167
|
continue; // buildSelectWithRelations already threw for this
|
|
1046
1168
|
shapes[relName] = buildRelationShape(qi, relDef, relSpec, meta, includePii);
|
|
@@ -1063,7 +1185,7 @@ export function buildRelationShape(qi, relDef, spec, parentMeta, includePii) {
|
|
|
1063
1185
|
const nested = {};
|
|
1064
1186
|
if (spec !== true && spec.with) {
|
|
1065
1187
|
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
1066
|
-
const nestedRelDef = targetMeta.relations
|
|
1188
|
+
const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
|
|
1067
1189
|
if (!nestedRelDef)
|
|
1068
1190
|
continue;
|
|
1069
1191
|
keys.push(nestedRelName);
|
|
@@ -1079,7 +1201,12 @@ export function buildRelationShape(qi, relDef, spec, parentMeta, includePii) {
|
|
|
1079
1201
|
* positional arrays into the object form first (shapes built once, not per
|
|
1080
1202
|
* row), then delegates to parseNestedRow for date/snake-camel coercion.
|
|
1081
1203
|
*/
|
|
1082
|
-
export function makeNestedParser(qi, withClause, includePii) {
|
|
1204
|
+
export function makeNestedParser(qi, withClause, includePii, flattenPlan) {
|
|
1205
|
+
// A flatten plan and the positional encoding are mutually exclusive (the
|
|
1206
|
+
// planner is only consulted for the object encoding), so this branch is safe
|
|
1207
|
+
// ahead of the positional one.
|
|
1208
|
+
if (flattenPlan)
|
|
1209
|
+
return makeFlattenParser(qi, flattenPlan);
|
|
1083
1210
|
if (qi.jsonEncoding !== 'positional') {
|
|
1084
1211
|
return (row) => parseNestedRow(qi, row, qi.table);
|
|
1085
1212
|
}
|
|
@@ -1138,6 +1265,524 @@ export function decodePositionalObject(qi, arr, shape) {
|
|
|
1138
1265
|
}
|
|
1139
1266
|
return obj;
|
|
1140
1267
|
}
|
|
1268
|
+
// ---------------------------------------------------------------------------
|
|
1269
|
+
// relationLoadStrategy: 'flatten' — to-one relations compiled as LEFT JOINs
|
|
1270
|
+
// ---------------------------------------------------------------------------
|
|
1271
|
+
/**
|
|
1272
|
+
* Alias prefix for a flattened relation's join. Deliberately distinct from the
|
|
1273
|
+
* `t<n>` family {@link buildRelationSubquery} (and its `i`/`j`/`t`/`ord`
|
|
1274
|
+
* suffixes) allocates, so the two compilation paths can share one SELECT list
|
|
1275
|
+
* without ever colliding.
|
|
1276
|
+
*/
|
|
1277
|
+
const FLATTEN_ALIAS_PREFIX = 'f';
|
|
1278
|
+
/**
|
|
1279
|
+
* WHY EACH FLATTENED SUBTREE IS WRAPPED IN A DERIVED TABLE
|
|
1280
|
+
*
|
|
1281
|
+
* The top-level `WHERE` and `ORDER BY` reference the parent's columns
|
|
1282
|
+
* UNQUALIFIED (`WHERE "name" = $1`). A bare `LEFT JOIN "orgs" f0` puts a second
|
|
1283
|
+
* table in scope, so any column name the two tables share ("id", "name",
|
|
1284
|
+
* "created_at" — i.e. most of them) turns those references ambiguous and
|
|
1285
|
+
* Postgres rejects the statement. Qualifying the parent's references is not an
|
|
1286
|
+
* option here: they are compiled by the shared WHERE walk, which is scope-blind
|
|
1287
|
+
* by design.
|
|
1288
|
+
*
|
|
1289
|
+
* So a flattened subtree joins as a derived table that exposes ONLY prefixed
|
|
1290
|
+
* names (`f0__id`, `f0__name`, ...). Nothing it contributes can collide with an
|
|
1291
|
+
* unqualified parent reference, by construction rather than by analysis. This is
|
|
1292
|
+
* the same trick the `plan: 'lateral'` pick join uses when it exposes a single
|
|
1293
|
+
* reserved `__turbine_pick` column.
|
|
1294
|
+
*
|
|
1295
|
+
* Inside the derived table every reference is alias-qualified, so the whole
|
|
1296
|
+
* to-one subtree (chained joins, per-relation filters, and any to-many
|
|
1297
|
+
* correlated subqueries hanging off it) lives in one flat, unambiguous scope.
|
|
1298
|
+
* The subquery is a plain SELECT with no LIMIT / DISTINCT / aggregate, so
|
|
1299
|
+
* Postgres pulls it up into the outer join rather than materializing it.
|
|
1300
|
+
*/
|
|
1301
|
+
/** Suffix for the inner (real-table) alias inside a flattened derived table. */
|
|
1302
|
+
const FLATTEN_SRC_SUFFIX = 's';
|
|
1303
|
+
/** Alias prefix for a top-level node's exposed correlation columns. */
|
|
1304
|
+
const FLATTEN_CORR = '$c';
|
|
1305
|
+
/**
|
|
1306
|
+
* Maximum SELECT-list alias length. Postgres truncates identifiers at 63 bytes
|
|
1307
|
+
* (MySQL/SQLite are more generous), and a truncated alias could collide with
|
|
1308
|
+
* another projected column, so a relation whose prefixed projection would
|
|
1309
|
+
* exceed it is ineligible and falls back to the correlated subquery.
|
|
1310
|
+
*/
|
|
1311
|
+
const FLATTEN_MAX_ALIAS_LEN = 63;
|
|
1312
|
+
/** Nesting depth past which a flattened subtree declines (mirrors the join cap). */
|
|
1313
|
+
const FLATTEN_MAX_DEPTH = 10;
|
|
1314
|
+
/** Suffix of the non-null discriminator column projected for every flattened node. */
|
|
1315
|
+
const FLATTEN_DISCRIMINATOR = '$k';
|
|
1316
|
+
/** Set equality over two column lists (order-insensitive, duplicate-intolerant). */
|
|
1317
|
+
function sameColumnSet(a, b) {
|
|
1318
|
+
if (a.length === 0 || a.length !== b.length)
|
|
1319
|
+
return false;
|
|
1320
|
+
const set = new Set(a);
|
|
1321
|
+
if (set.size !== a.length)
|
|
1322
|
+
return false;
|
|
1323
|
+
for (const col of b)
|
|
1324
|
+
if (!set.has(col))
|
|
1325
|
+
return false;
|
|
1326
|
+
return true;
|
|
1327
|
+
}
|
|
1328
|
+
/**
|
|
1329
|
+
* The target-side correlation columns of a to-one relation, but ONLY when the
|
|
1330
|
+
* schema PROVES they are unique on the target table. This is the row-
|
|
1331
|
+
* multiplication guard: a LEFT JOIN over a non-unique key silently duplicates
|
|
1332
|
+
* parent rows, which would change results rather than just the plan.
|
|
1333
|
+
*
|
|
1334
|
+
* Proof sources, all exact set matches (a unique index on `(a, b)` does NOT
|
|
1335
|
+
* make `a` unique):
|
|
1336
|
+
* - the target's primary key,
|
|
1337
|
+
* - a declared unique constraint (`uniqueColumns`),
|
|
1338
|
+
* - a full, non-expression UNIQUE index. Partial unique indexes are refused
|
|
1339
|
+
* (they only constrain the rows matching their predicate) and PowDB
|
|
1340
|
+
* doc-field expression indexes are refused (they index a JSON path, not the
|
|
1341
|
+
* raw column).
|
|
1342
|
+
*
|
|
1343
|
+
* Returns `null` for to-many / manyToMany relations and for anything it cannot
|
|
1344
|
+
* prove, which routes the relation back to the correlated subquery.
|
|
1345
|
+
*
|
|
1346
|
+
* NULLs need no special handling: a unique constraint permits repeated NULLs,
|
|
1347
|
+
* but `target.key = parent.fk` never matches a NULL key, so a null-keyed target
|
|
1348
|
+
* row can never join.
|
|
1349
|
+
*/
|
|
1350
|
+
export function provableUniqueTargetKey(relDef, targetMeta) {
|
|
1351
|
+
let cols;
|
|
1352
|
+
if (relDef.type === 'belongsTo') {
|
|
1353
|
+
// belongsTo: SOURCE holds the FK, so the join is target.referenceKey = parent.foreignKey.
|
|
1354
|
+
cols = normalizeKeyColumns(relDef.referenceKey);
|
|
1355
|
+
}
|
|
1356
|
+
else if (relDef.type === 'hasOne') {
|
|
1357
|
+
// hasOne: TARGET holds the FK, so the join is target.foreignKey = parent.referenceKey.
|
|
1358
|
+
cols = normalizeKeyColumns(relDef.foreignKey);
|
|
1359
|
+
}
|
|
1360
|
+
else {
|
|
1361
|
+
return null;
|
|
1362
|
+
}
|
|
1363
|
+
if (cols.length === 0)
|
|
1364
|
+
return null;
|
|
1365
|
+
if (!cols.every((col) => targetMeta.allColumns.includes(col)))
|
|
1366
|
+
return null;
|
|
1367
|
+
if (sameColumnSet(cols, targetMeta.primaryKey))
|
|
1368
|
+
return cols;
|
|
1369
|
+
for (const unique of targetMeta.uniqueColumns) {
|
|
1370
|
+
if (sameColumnSet(cols, unique))
|
|
1371
|
+
return cols;
|
|
1372
|
+
}
|
|
1373
|
+
for (const idx of targetMeta.indexes) {
|
|
1374
|
+
if (!idx.unique || idx.partial || idx.docPath)
|
|
1375
|
+
continue;
|
|
1376
|
+
if (sameColumnSet(cols, idx.columns))
|
|
1377
|
+
return cols;
|
|
1378
|
+
}
|
|
1379
|
+
return null;
|
|
1380
|
+
}
|
|
1381
|
+
/**
|
|
1382
|
+
* Plan one relation as a flattened LEFT JOIN, or return `null` to leave it on
|
|
1383
|
+
* the correlated-subquery path.
|
|
1384
|
+
*
|
|
1385
|
+
* A relation is ELIGIBLE when all of the following hold:
|
|
1386
|
+
* 1. it is `belongsTo` or `hasOne` AND its target-side correlation columns are
|
|
1387
|
+
* provably unique ({@link provableUniqueTargetKey}) — the row-multiplication
|
|
1388
|
+
* guard;
|
|
1389
|
+
* 2. its spec declares no `limit` and no `orderBy` (both are no-ops over a
|
|
1390
|
+
* single matching row, but refusing them keeps the emitted SQL and the
|
|
1391
|
+
* param stream trivially equivalent);
|
|
1392
|
+
* 3. its nested `with` names only real relations and no reserved `_count`
|
|
1393
|
+
* (nested `_count` is unsupported on every strategy — falling back lets the
|
|
1394
|
+
* subquery path raise the same error);
|
|
1395
|
+
* 4. the depth cap is not reached, so a too-deep chain still raises
|
|
1396
|
+
* {@link CircularRelationError} from the subquery path instead of silently
|
|
1397
|
+
* succeeding;
|
|
1398
|
+
* 5. its projected aliases are unique within the node and within the identifier
|
|
1399
|
+
* length limit.
|
|
1400
|
+
*
|
|
1401
|
+
* Relation `where` IS supported: it moves into the join's `ON` clause, where a
|
|
1402
|
+
* non-matching row null-extends exactly as the subquery's `LIMIT 1` returned
|
|
1403
|
+
* NULL. The target's global filter is applied the same way, so a flattened
|
|
1404
|
+
* relation can never surface soft-deleted / out-of-tenant rows.
|
|
1405
|
+
*/
|
|
1406
|
+
function planFlattenNode(qi, counter, relName, relDef, spec, depth, path, includePii, aliasSink, rejects) {
|
|
1407
|
+
/** Record why this relation stays on the subquery path, then decline it. */
|
|
1408
|
+
const decline = (reason) => {
|
|
1409
|
+
rejects.push({ relation: [...path.slice(1), relName].join('.'), reason });
|
|
1410
|
+
return null;
|
|
1411
|
+
};
|
|
1412
|
+
if (depth >= FLATTEN_MAX_DEPTH)
|
|
1413
|
+
return decline(`it nests more than ${FLATTEN_MAX_DEPTH} levels deep`);
|
|
1414
|
+
const targetMeta = qi.schema.tables[relDef.to];
|
|
1415
|
+
if (!targetMeta)
|
|
1416
|
+
return decline(`its target table "${relDef.to}" is not in the schema metadata`);
|
|
1417
|
+
const key = provableUniqueTargetKey(relDef, targetMeta);
|
|
1418
|
+
if (!key) {
|
|
1419
|
+
return decline(relDef.type !== 'belongsTo' && relDef.type !== 'hasOne'
|
|
1420
|
+
? `it is ${relDef.type}, and only a to-one relation can be flattened into a join`
|
|
1421
|
+
: `its correlation column(s) on "${relDef.to}" are not provably unique (no primary key, ` +
|
|
1422
|
+
'unique constraint or non-partial unique index covers them), so a join could multiply parent rows');
|
|
1423
|
+
}
|
|
1424
|
+
const opts = spec === true ? undefined : spec;
|
|
1425
|
+
if (opts) {
|
|
1426
|
+
if (opts.limit !== undefined)
|
|
1427
|
+
return decline('it declares a `limit`');
|
|
1428
|
+
if (opts.orderBy && orderByEntries(opts.orderBy).some(([, dir]) => dir !== undefined)) {
|
|
1429
|
+
return decline('it declares an `orderBy`');
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
const nestedEntries = opts?.with ? sortedEntries(opts.with) : [];
|
|
1433
|
+
for (const [nestedRelName] of nestedEntries) {
|
|
1434
|
+
if (nestedRelName === '_count')
|
|
1435
|
+
return decline('its nested `with` uses `_count`');
|
|
1436
|
+
if (!ownLookup(targetMeta.relations, nestedRelName)) {
|
|
1437
|
+
return decline(`its nested \`with\` names "${nestedRelName}", which is not a relation of "${relDef.to}"`);
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
const alias = `${FLATTEN_ALIAS_PREFIX}${counter.n++}`;
|
|
1441
|
+
const cols = resolveTargetColumns(qi, spec, targetMeta, includePii);
|
|
1442
|
+
const discAlias = `${alias}__${FLATTEN_DISCRIMINATOR}`;
|
|
1443
|
+
const node = {
|
|
1444
|
+
relName,
|
|
1445
|
+
relDef,
|
|
1446
|
+
spec,
|
|
1447
|
+
targetTable: relDef.to,
|
|
1448
|
+
targetMeta,
|
|
1449
|
+
alias,
|
|
1450
|
+
srcAlias: `${alias}${FLATTEN_SRC_SUFFIX}`,
|
|
1451
|
+
keyColumns: key,
|
|
1452
|
+
discAlias,
|
|
1453
|
+
cols: cols.map((col) => [col, `${alias}__${col}`]),
|
|
1454
|
+
slots: [],
|
|
1455
|
+
children: {},
|
|
1456
|
+
jsonAliases: {},
|
|
1457
|
+
corrAliases: depth === 0 ? key.map((_, i) => `${alias}__${FLATTEN_CORR}${i}`) : [],
|
|
1458
|
+
depth,
|
|
1459
|
+
path,
|
|
1460
|
+
};
|
|
1461
|
+
// Local alias uniqueness: a target whose relation name equals one of its own
|
|
1462
|
+
// column names ("owner" the column and "owner" the relation) would otherwise
|
|
1463
|
+
// project two `f0__owner` columns and silently clobber one.
|
|
1464
|
+
const local = [discAlias, ...node.corrAliases, ...node.cols.map(([, a]) => a)];
|
|
1465
|
+
for (const [nestedRelName] of nestedEntries)
|
|
1466
|
+
local.push(`${alias}__${nestedRelName}`);
|
|
1467
|
+
if (new Set(local).size !== local.length) {
|
|
1468
|
+
return decline('a projected column alias would collide (a relation name matches one of its own column names)');
|
|
1469
|
+
}
|
|
1470
|
+
if (local.some((a) => a.length > FLATTEN_MAX_ALIAS_LEN)) {
|
|
1471
|
+
return decline(`a projected column alias would exceed ${FLATTEN_MAX_ALIAS_LEN} characters`);
|
|
1472
|
+
}
|
|
1473
|
+
for (const [nestedRelName, nestedSpec] of nestedEntries) {
|
|
1474
|
+
const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
|
|
1475
|
+
const child = planFlattenNode(qi, counter, nestedRelName, nestedRelDef, nestedSpec, depth + 1, [...path, relName], includePii, aliasSink, rejects);
|
|
1476
|
+
if (child) {
|
|
1477
|
+
node.slots.push({ relName: nestedRelName, kind: 'flat' });
|
|
1478
|
+
node.children[nestedRelName] = child;
|
|
1479
|
+
}
|
|
1480
|
+
else {
|
|
1481
|
+
node.slots.push({ relName: nestedRelName, kind: 'json' });
|
|
1482
|
+
node.jsonAliases[nestedRelName] = `${alias}__${nestedRelName}`;
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
aliasSink.push(discAlias, ...node.cols.map(([, a]) => a), ...Object.values(node.jsonAliases));
|
|
1486
|
+
return node;
|
|
1487
|
+
}
|
|
1488
|
+
/** Structural signature of one node, recursively. Feeds the SQL cache key. */
|
|
1489
|
+
function flattenNodeSignature(node) {
|
|
1490
|
+
const slots = node.slots
|
|
1491
|
+
.map((slot) => slot.kind === 'flat' ? `+${flattenNodeSignature(node.children[slot.relName])}` : `~${slot.relName}`)
|
|
1492
|
+
.join('');
|
|
1493
|
+
return `${node.relName}@${node.alias}(${node.cols.map(([col]) => col).join(',')})${slots}`;
|
|
1494
|
+
}
|
|
1495
|
+
/**
|
|
1496
|
+
* Dev-only, once-per-(table, relation, reason) note that an explicitly
|
|
1497
|
+
* requested `relationLoadStrategy: 'flatten'` did NOT engage for a relation.
|
|
1498
|
+
*
|
|
1499
|
+
* Worth its own warning because the fallback is otherwise INVISIBLE: the query
|
|
1500
|
+
* succeeds, the rows are correct (the strategies are value-identical), and the
|
|
1501
|
+
* only symptom is that the plan the caller asked for is not the plan that ran.
|
|
1502
|
+
* Silence there is indistinguishable from the strategy being a no-op, which is
|
|
1503
|
+
* exactly the shape of thing that gets filed as a bug.
|
|
1504
|
+
*
|
|
1505
|
+
* Suppressed in production like the other planner notes, and deduped through
|
|
1506
|
+
* the shared registry so a hot query logs once rather than per call. Note that
|
|
1507
|
+
* a flatten plan is recomputed on the build, param-collect and row-assembly
|
|
1508
|
+
* paths for the same query; the registry is what keeps that to one line.
|
|
1509
|
+
*/
|
|
1510
|
+
function warnFlattenFallback(table, rejects) {
|
|
1511
|
+
if (rejects.length === 0)
|
|
1512
|
+
return;
|
|
1513
|
+
if (process.env.NODE_ENV === 'production')
|
|
1514
|
+
return;
|
|
1515
|
+
for (const { relation, reason } of rejects) {
|
|
1516
|
+
if (!shouldWarnOnce(WARN_NS.flattenFallback, `${table}.${relation}|${reason}`))
|
|
1517
|
+
continue;
|
|
1518
|
+
console.warn(`[turbine] relationLoadStrategy: 'flatten' did not engage for relation "${relation}" on "${table}": ` +
|
|
1519
|
+
`${reason}. It loads via the correlated subquery instead (same rows, same values, different plan).`);
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
/**
|
|
1523
|
+
* Compile the `'flatten'` plan for a top-level `with` clause, or return `null`
|
|
1524
|
+
* when nothing in it is eligible (in which case the caller emits exactly the SQL
|
|
1525
|
+
* it emits today, down to the cache key).
|
|
1526
|
+
*
|
|
1527
|
+
* The plan is a pure function of the schema, the `with` clause shape and
|
|
1528
|
+
* `includePii` — never of any bound value — so the build path, the cache-hit
|
|
1529
|
+
* param-collect path and the row assembler can each recompute it and agree.
|
|
1530
|
+
*/
|
|
1531
|
+
export function planFlattenWith(qi, table, withClause, includePii) {
|
|
1532
|
+
const meta = qi.schema.tables[table];
|
|
1533
|
+
if (!meta)
|
|
1534
|
+
return null;
|
|
1535
|
+
const counter = { n: 0 };
|
|
1536
|
+
const aliases = [];
|
|
1537
|
+
const nodes = {};
|
|
1538
|
+
const rejects = [];
|
|
1539
|
+
for (const [relName, relSpec] of sortedEntries(withClause)) {
|
|
1540
|
+
if (relName === '_count')
|
|
1541
|
+
continue;
|
|
1542
|
+
const relDef = ownLookup(meta.relations, relName);
|
|
1543
|
+
// An unknown relation stays on the subquery path so it raises E005 there.
|
|
1544
|
+
if (!relDef)
|
|
1545
|
+
continue;
|
|
1546
|
+
const node = planFlattenNode(qi, counter, relName, relDef, relSpec, 0, [table], includePii, aliases, rejects);
|
|
1547
|
+
if (node)
|
|
1548
|
+
nodes[relName] = node;
|
|
1549
|
+
}
|
|
1550
|
+
warnFlattenFallback(table, rejects);
|
|
1551
|
+
if (Object.keys(nodes).length === 0)
|
|
1552
|
+
return null;
|
|
1553
|
+
// Flat rows carry the root table's own columns, one column per non-flattened
|
|
1554
|
+
// relation (named for the relation) and `_count__<rel>` scalars alongside the
|
|
1555
|
+
// prefixed aliases. A collision would make the assembler read the wrong cell,
|
|
1556
|
+
// so refuse the whole plan rather than plan around it.
|
|
1557
|
+
const reserved = new Set([...meta.allColumns, ...Object.keys(withClause)]);
|
|
1558
|
+
for (const alias of aliases) {
|
|
1559
|
+
if (reserved.has(alias)) {
|
|
1560
|
+
warnFlattenFallback(table, [
|
|
1561
|
+
{
|
|
1562
|
+
relation: Object.keys(nodes).join(', '),
|
|
1563
|
+
reason: `the projected alias "${alias}" collides with a column or relation name on "${table}"`,
|
|
1564
|
+
},
|
|
1565
|
+
]);
|
|
1566
|
+
return null;
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
const signature = Object.keys(nodes)
|
|
1570
|
+
.sort()
|
|
1571
|
+
.map((relName) => flattenNodeSignature(nodes[relName]))
|
|
1572
|
+
.join('|');
|
|
1573
|
+
return { nodes, aliases, signature };
|
|
1574
|
+
}
|
|
1575
|
+
/**
|
|
1576
|
+
* Compile the correlation of a to-one relation as a join condition:
|
|
1577
|
+
* - `belongsTo` → `target.referenceKey = parent.foreignKey` (SOURCE holds the FK)
|
|
1578
|
+
* - `hasOne` → `target.foreignKey = parent.referenceKey` (TARGET holds the FK)
|
|
1579
|
+
*
|
|
1580
|
+
* Same column pairing {@link buildRelationSubquery} correlates on; getting it
|
|
1581
|
+
* backwards silently compares the wrong columns.
|
|
1582
|
+
*/
|
|
1583
|
+
function flattenCorrelation(qi, relDef, targetRef, targetColumns, parentRef) {
|
|
1584
|
+
const parentColumns = relDef.type === 'belongsTo' ? relDef.foreignKey : relDef.referenceKey;
|
|
1585
|
+
return qi.dialect.buildCorrelation(targetRef, targetColumns, parentRef, parentColumns);
|
|
1586
|
+
}
|
|
1587
|
+
/**
|
|
1588
|
+
* Emit one TOP-LEVEL flattened relation: a derived table holding its whole
|
|
1589
|
+
* to-one subtree, joined to the parent on the re-exposed correlation columns,
|
|
1590
|
+
* plus the pass-through projection of every name that subtree contributes.
|
|
1591
|
+
*
|
|
1592
|
+
* ```sql
|
|
1593
|
+
* LEFT JOIN (
|
|
1594
|
+
* SELECT 1 AS "f0__$k", f0s."id" AS "f0__$c0",
|
|
1595
|
+
* f0s."id" AS "f0__id", f0s."name" AS "f0__name",
|
|
1596
|
+
* (f1s."id" IS NOT NULL) AS "f1__$k", f1s."code" AS "f1__code"
|
|
1597
|
+
* FROM "orgs" f0s
|
|
1598
|
+
* LEFT JOIN "regions" f1s ON f1s."id" = f0s."region_id"
|
|
1599
|
+
* WHERE f0s."deleted" = $1
|
|
1600
|
+
* ) f0 ON f0."f0__$c0" = "users"."org_id"
|
|
1601
|
+
* ```
|
|
1602
|
+
*
|
|
1603
|
+
* See the derived-table note above for why the join cannot expose the target's
|
|
1604
|
+
* real column names.
|
|
1605
|
+
*/
|
|
1606
|
+
export function emitFlattenNode(qi, node, parentRef, params, joinSink, selectSink, aliasCounter, includePii) {
|
|
1607
|
+
const innerSelects = [];
|
|
1608
|
+
const innerJoins = [];
|
|
1609
|
+
const innerWhere = [];
|
|
1610
|
+
emitFlattenInner(qi, node, params, innerSelects, innerJoins, innerWhere, aliasCounter, includePii);
|
|
1611
|
+
const whereSql = innerWhere.length > 0 ? ` WHERE ${innerWhere.join(' AND ')}` : '';
|
|
1612
|
+
const derived = `SELECT ${innerSelects.join(', ')} FROM ${qi.q(node.targetTable)} ${node.srcAlias}${innerJoins.join('')}${whereSql}`;
|
|
1613
|
+
const on = flattenCorrelation(qi, node.relDef, node.alias, node.corrAliases, parentRef);
|
|
1614
|
+
joinSink.push(` LEFT JOIN (${derived}) ${node.alias} ON ${on}`);
|
|
1615
|
+
projectFlattenNode(qi, node, node.alias, selectSink);
|
|
1616
|
+
}
|
|
1617
|
+
/**
|
|
1618
|
+
* Build one node's contribution INSIDE its top-level derived table: its filters,
|
|
1619
|
+
* its prefixed column projection, and its nested slots (another inner LEFT JOIN
|
|
1620
|
+
* for a flattened child, a correlated subquery column for anything else).
|
|
1621
|
+
*
|
|
1622
|
+
* Param push order, mirrored exactly by {@link collectFlattenNodeParams}:
|
|
1623
|
+
* relation `where` → target global filter → each nested slot in `slots` order.
|
|
1624
|
+
*/
|
|
1625
|
+
function emitFlattenInner(qi, node, params, innerSelects, innerJoins, innerWhere, aliasCounter, includePii) {
|
|
1626
|
+
const { srcAlias, targetTable, targetMeta } = node;
|
|
1627
|
+
const isRoot = node.depth === 0;
|
|
1628
|
+
// A root node's filters go in the derived table's WHERE; a nested node's go in
|
|
1629
|
+
// its own inner LEFT JOIN's ON. Both null-extend the relation on a miss rather
|
|
1630
|
+
// than dropping the parent row, matching what the correlated subquery's
|
|
1631
|
+
// `LIMIT 1` did when it returned NULL.
|
|
1632
|
+
const filters = innerWhere;
|
|
1633
|
+
if (node.spec !== true && node.spec.where) {
|
|
1634
|
+
const extra = whereMod.buildAliasWhere(qi, targetTable, targetMeta, srcAlias, node.spec.where, params);
|
|
1635
|
+
if (extra)
|
|
1636
|
+
filters.push(extra);
|
|
1637
|
+
}
|
|
1638
|
+
// Global filter on the target (soft-delete / tenancy): a flattened relation
|
|
1639
|
+
// must never surface rows the join strategy would have filtered out.
|
|
1640
|
+
const gf = whereMod.targetGlobalFilterAlias(qi, targetTable, srcAlias, params);
|
|
1641
|
+
if (gf)
|
|
1642
|
+
filters.push(gf);
|
|
1643
|
+
if (isRoot) {
|
|
1644
|
+
// The derived table's rows exist only where the target matched, so a
|
|
1645
|
+
// constant marks the match; the outer LEFT JOIN nulls it on a miss.
|
|
1646
|
+
innerSelects.push(`1 AS ${qi.q(node.discAlias)}`);
|
|
1647
|
+
node.keyColumns.forEach((col, i) => {
|
|
1648
|
+
innerSelects.push(`${srcAlias}.${qi.q(col)} AS ${qi.q(node.corrAliases[i])}`);
|
|
1649
|
+
});
|
|
1650
|
+
}
|
|
1651
|
+
else {
|
|
1652
|
+
innerSelects.push(`(${srcAlias}.${qi.q(node.keyColumns[0])} IS NOT NULL) AS ${qi.q(node.discAlias)}`);
|
|
1653
|
+
}
|
|
1654
|
+
for (const [col, sqlAlias] of node.cols) {
|
|
1655
|
+
innerSelects.push(`${srcAlias}.${qi.q(col)} AS ${qi.q(sqlAlias)}`);
|
|
1656
|
+
}
|
|
1657
|
+
for (const slot of node.slots) {
|
|
1658
|
+
if (slot.kind === 'flat') {
|
|
1659
|
+
const child = node.children[slot.relName];
|
|
1660
|
+
const on = flattenCorrelation(qi, child.relDef, child.srcAlias, child.keyColumns, srcAlias);
|
|
1661
|
+
// Placeholder: the child's own filters are appended to this ON below, so
|
|
1662
|
+
// reserve the slot now to keep join order matching slot order.
|
|
1663
|
+
const at = innerJoins.length;
|
|
1664
|
+
innerJoins.push('');
|
|
1665
|
+
const childFilters = [];
|
|
1666
|
+
emitFlattenInner(qi, child, params, innerSelects, innerJoins, childFilters, aliasCounter, includePii);
|
|
1667
|
+
const conds = [on, ...childFilters].join(' AND ');
|
|
1668
|
+
innerJoins[at] = ` LEFT JOIN ${qi.q(child.targetTable)} ${child.srcAlias} ON ${conds}`;
|
|
1669
|
+
continue;
|
|
1670
|
+
}
|
|
1671
|
+
const nestedRelDef = ownLookup(targetMeta.relations, slot.relName);
|
|
1672
|
+
const nestedSpec = node.spec.with[slot.relName];
|
|
1673
|
+
const sub = buildRelationSubquery(qi, nestedRelDef, nestedSpec, params, srcAlias, aliasCounter, node.depth + 1, [...node.path, node.relName], includePii);
|
|
1674
|
+
// Same fallback the subquery path picks for a nested relation slot.
|
|
1675
|
+
const fallback = nestedRelDef.type === 'hasMany' ? qi.dialect.emptyJsonArrayLiteral : qi.dialect.nullJsonLiteral;
|
|
1676
|
+
innerSelects.push(`${qi.dialect.wrapJsonSubresult(sub, fallback)} AS ${qi.q(node.jsonAliases[slot.relName])}`);
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
/**
|
|
1680
|
+
* Pass every name a flattened subtree contributes through the outer SELECT, in
|
|
1681
|
+
* the same order {@link emitFlattenInner} produced it (which is the order the
|
|
1682
|
+
* join strategy's `json_build_object` uses, so assembled key order matches).
|
|
1683
|
+
* The internal `$c` correlation columns are deliberately NOT projected: they
|
|
1684
|
+
* exist only for the outer join condition.
|
|
1685
|
+
*/
|
|
1686
|
+
function projectFlattenNode(qi, node, outerAlias, selectSink) {
|
|
1687
|
+
const ref = (name) => `${qi.q(outerAlias)}.${qi.q(name)}`;
|
|
1688
|
+
selectSink.push(ref(node.discAlias));
|
|
1689
|
+
for (const [, sqlAlias] of node.cols)
|
|
1690
|
+
selectSink.push(ref(sqlAlias));
|
|
1691
|
+
for (const slot of node.slots) {
|
|
1692
|
+
if (slot.kind === 'flat')
|
|
1693
|
+
projectFlattenNode(qi, node.children[slot.relName], outerAlias, selectSink);
|
|
1694
|
+
else
|
|
1695
|
+
selectSink.push(ref(node.jsonAliases[slot.relName]));
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
/** Param-collect mirror of {@link emitFlattenNode}. */
|
|
1699
|
+
export function collectFlattenNodeParams(qi, node, params) {
|
|
1700
|
+
if (node.spec !== true && node.spec.where) {
|
|
1701
|
+
whereMod.collectAliasWhereParams(qi, node.targetTable, node.targetMeta, node.spec.where, params);
|
|
1702
|
+
}
|
|
1703
|
+
whereMod.collectTargetGlobalFilterAlias(qi, node.targetTable, params);
|
|
1704
|
+
for (const slot of node.slots) {
|
|
1705
|
+
if (slot.kind === 'flat') {
|
|
1706
|
+
collectFlattenNodeParams(qi, node.children[slot.relName], params);
|
|
1707
|
+
continue;
|
|
1708
|
+
}
|
|
1709
|
+
const nestedRelDef = ownLookup(node.targetMeta.relations, slot.relName);
|
|
1710
|
+
if (!nestedRelDef)
|
|
1711
|
+
continue;
|
|
1712
|
+
const nestedSpec = node.spec.with[slot.relName];
|
|
1713
|
+
collectRelationSubqueryParams(qi, nestedRelDef, nestedSpec, params, node.alias, node.depth + 1);
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
/**
|
|
1717
|
+
* Rebuild one flattened relation's object from a flat row.
|
|
1718
|
+
*
|
|
1719
|
+
* Returns `null` when the discriminator is NULL (the LEFT JOIN matched
|
|
1720
|
+
* nothing), which is the ONLY signal that distinguishes "no related row" from
|
|
1721
|
+
* "a related row whose every projected column is NULL".
|
|
1722
|
+
*
|
|
1723
|
+
* The sub-row is keyed by the target's raw snake_case column names and handed
|
|
1724
|
+
* to {@link parseNestedRow}, so the flattened object goes through the very same
|
|
1725
|
+
* camelCase mapping, Date coercion and nested-JSON parsing the join strategy's
|
|
1726
|
+
* `json_build_object` output does. Nested flattened relations are assigned
|
|
1727
|
+
* after the parse, over placeholder keys inserted in slot order, so the
|
|
1728
|
+
* assembled object's key order matches the join strategy's exactly.
|
|
1729
|
+
*/
|
|
1730
|
+
export function assembleFlattenNode(qi, row, node) {
|
|
1731
|
+
// The discriminator is the projected predicate `key IS NOT NULL`, so its
|
|
1732
|
+
// truthy encodings are engine-specific (Postgres booleans, MySQL/SQLite 0/1,
|
|
1733
|
+
// and a text-mode driver's 't'/'1'). Whitelist the matched forms: an
|
|
1734
|
+
// unrecognized encoding reads as "no related row", which the cross-engine
|
|
1735
|
+
// parity suites surface loudly rather than as scattered wrong objects.
|
|
1736
|
+
const disc = row[node.discAlias];
|
|
1737
|
+
const matched = disc === true || disc === 1 || disc === '1' || disc === 't' || disc === 'true';
|
|
1738
|
+
if (!matched)
|
|
1739
|
+
return null;
|
|
1740
|
+
const sub = {};
|
|
1741
|
+
for (const [col, sqlAlias] of node.cols)
|
|
1742
|
+
sub[col] = row[sqlAlias];
|
|
1743
|
+
for (const slot of node.slots) {
|
|
1744
|
+
// A 'flat' slot is a placeholder here: parseNestedRow skips `undefined`
|
|
1745
|
+
// relation values, so the key keeps its position and is filled in below.
|
|
1746
|
+
sub[slot.relName] = slot.kind === 'json' ? row[node.jsonAliases[slot.relName]] : undefined;
|
|
1747
|
+
}
|
|
1748
|
+
const parsed = parseNestedRow(qi, sub, node.targetTable);
|
|
1749
|
+
for (const slot of node.slots) {
|
|
1750
|
+
if (slot.kind === 'flat')
|
|
1751
|
+
parsed[slot.relName] = assembleFlattenNode(qi, row, node.children[slot.relName]);
|
|
1752
|
+
}
|
|
1753
|
+
return parsed;
|
|
1754
|
+
}
|
|
1755
|
+
/**
|
|
1756
|
+
* Row parser for a `'flatten'` plan: strip the prefixed join columns out of the
|
|
1757
|
+
* flat row (inserting a placeholder at the position each relation's block
|
|
1758
|
+
* started, so key order is preserved), parse the remainder exactly as the join
|
|
1759
|
+
* strategy does, then assemble each flattened relation.
|
|
1760
|
+
*/
|
|
1761
|
+
export function makeFlattenParser(qi, plan) {
|
|
1762
|
+
const flatAliases = new Set(plan.aliases);
|
|
1763
|
+
// The discriminator is emitted FIRST for each node, so it marks where a
|
|
1764
|
+
// relation's block begins in the flat row's key order.
|
|
1765
|
+
const relationByLeadAlias = new Map();
|
|
1766
|
+
for (const [relName, node] of Object.entries(plan.nodes))
|
|
1767
|
+
relationByLeadAlias.set(node.discAlias, relName);
|
|
1768
|
+
return (row) => {
|
|
1769
|
+
const stripped = {};
|
|
1770
|
+
for (const key of Object.keys(row)) {
|
|
1771
|
+
if (!flatAliases.has(key)) {
|
|
1772
|
+
stripped[key] = row[key];
|
|
1773
|
+
continue;
|
|
1774
|
+
}
|
|
1775
|
+
const relName = relationByLeadAlias.get(key);
|
|
1776
|
+
if (relName !== undefined)
|
|
1777
|
+
stripped[relName] = undefined;
|
|
1778
|
+
}
|
|
1779
|
+
const parsed = parseNestedRow(qi, stripped, qi.table);
|
|
1780
|
+
for (const [relName, node] of Object.entries(plan.nodes)) {
|
|
1781
|
+
parsed[relName] = assembleFlattenNode(qi, row, node);
|
|
1782
|
+
}
|
|
1783
|
+
return parsed;
|
|
1784
|
+
};
|
|
1785
|
+
}
|
|
1141
1786
|
/**
|
|
1142
1787
|
* Build a SELECT clause that includes both base columns and nested relation subqueries.
|
|
1143
1788
|
*
|
|
@@ -1179,7 +1824,7 @@ export function decodePositionalObject(qi, arr, shape) {
|
|
|
1179
1824
|
* @returns A complete SELECT clause string (without the `SELECT` keyword) containing
|
|
1180
1825
|
* base columns and relation subqueries.
|
|
1181
1826
|
*/
|
|
1182
|
-
export function buildSelectWithRelations(qi, table, withClause, params, columnsList, depth, path, includePii) {
|
|
1827
|
+
export function buildSelectWithRelations(qi, table, withClause, params, columnsList, depth, path, includePii, flatten) {
|
|
1183
1828
|
const meta = qi.schema.tables[table];
|
|
1184
1829
|
if (!meta)
|
|
1185
1830
|
throw new ValidationError(`[turbine] Unknown table "${table}"`);
|
|
@@ -1199,11 +1844,19 @@ export function buildSelectWithRelations(qi, table, withClause, params, columnsL
|
|
|
1199
1844
|
// `_count` is a reserved key handled after the relation subqueries.
|
|
1200
1845
|
if (relName === '_count')
|
|
1201
1846
|
continue;
|
|
1202
|
-
const relDef = meta.relations
|
|
1847
|
+
const relDef = ownLookup(meta.relations, relName);
|
|
1203
1848
|
if (!relDef) {
|
|
1204
1849
|
throw new RelationError(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
|
|
1205
1850
|
`Available: ${Object.keys(meta.relations).join(', ')}`);
|
|
1206
1851
|
}
|
|
1852
|
+
// `relationLoadStrategy: 'flatten'`: an eligible to-one relation becomes a
|
|
1853
|
+
// LEFT JOIN + prefixed scalar projection instead of a per-parent-row
|
|
1854
|
+
// correlated subquery. Every other relation falls through unchanged.
|
|
1855
|
+
const flatNode = flatten?.plan.nodes[relName];
|
|
1856
|
+
if (flatNode) {
|
|
1857
|
+
emitFlattenNode(qi, flatNode, qi.q(table), params, flatten.joinSink, relationSelects, aliasCounter, includePii);
|
|
1858
|
+
continue;
|
|
1859
|
+
}
|
|
1207
1860
|
// The main table is not aliased, so pass table name as parentRef
|
|
1208
1861
|
const subquery = buildRelationSubquery(qi, relDef, relSpec, params, table, aliasCounter, depth, path, includePii);
|
|
1209
1862
|
relationSelects.push(`(${subquery}) AS ${qi.q(relName)}`);
|
|
@@ -1383,10 +2036,7 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
|
|
|
1383
2036
|
});
|
|
1384
2037
|
}
|
|
1385
2038
|
// Build JSON object pairs for resolved columns
|
|
1386
|
-
const jsonPairs =
|
|
1387
|
-
targetMeta.reverseColumnMap[col] ?? snakeToCamel(col),
|
|
1388
|
-
`${alias}.${qi.q(col)}`,
|
|
1389
|
-
]);
|
|
2039
|
+
const jsonPairs = jsonScalarPairs(qi, targetMeta, targetColumns, alias);
|
|
1390
2040
|
// Determine if this hasMany will take the wrapped subquery path (LIMIT or ORDER BY).
|
|
1391
2041
|
// When wrapping, nested relations are built in the wrapped path referencing innerAlias,
|
|
1392
2042
|
// so we must NOT build them here (they would push orphaned params).
|
|
@@ -1404,7 +2054,7 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
|
|
|
1404
2054
|
// Nested relations — only in the non-wrapped path (wrapped path builds them separately)
|
|
1405
2055
|
if (!willWrap && spec !== true && spec.with) {
|
|
1406
2056
|
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
1407
|
-
const nestedRelDef = targetMeta.relations
|
|
2057
|
+
const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
|
|
1408
2058
|
if (!nestedRelDef) {
|
|
1409
2059
|
throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
1410
2060
|
`Available: ${Object.keys(targetMeta.relations).join(', ')}`);
|
|
@@ -1463,7 +2113,7 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
|
|
|
1463
2113
|
// `limit: 0` is honored (LIMIT 0 → empty array), so check !== undefined.
|
|
1464
2114
|
let limitClause = '';
|
|
1465
2115
|
if (relDef.type === 'hasMany' && spec !== true && spec.limit !== undefined) {
|
|
1466
|
-
limitClause = ` LIMIT ${qi.paginationRef(spec.limit, params)}`;
|
|
2116
|
+
limitClause = ` LIMIT ${qi.paginationRef(spec.limit, params, 'relation limit')}`;
|
|
1467
2117
|
}
|
|
1468
2118
|
if (relDef.type === 'hasMany') {
|
|
1469
2119
|
// When LIMIT or ORDER BY is used, wrap in a subquery so LIMIT applies to rows
|
|
@@ -1474,14 +2124,11 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
|
|
|
1474
2124
|
// Inner SELECT always needs all columns for WHERE/ORDER to work; json_build_object filters later
|
|
1475
2125
|
const innerSql = `SELECT ${targetMeta.allColumns.map((c) => `${alias}.${qi.q(c)}`).join(', ')} FROM ${qTarget} ${alias} WHERE ${whereClause}${orderClause}${limitClause}`;
|
|
1476
2126
|
// For the json_build_object, reference the inner alias — only include resolved columns
|
|
1477
|
-
const innerJsonPairs =
|
|
1478
|
-
targetMeta.reverseColumnMap[col] ?? snakeToCamel(col),
|
|
1479
|
-
`${innerAlias}.${qi.q(col)}`,
|
|
1480
|
-
]);
|
|
2127
|
+
const innerJsonPairs = jsonScalarPairs(qi, targetMeta, targetColumns, innerAlias);
|
|
1481
2128
|
// Build nested relation subqueries referencing innerAlias
|
|
1482
2129
|
if (spec !== true && spec.with) {
|
|
1483
2130
|
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
1484
|
-
const nestedRelDef = targetMeta.relations
|
|
2131
|
+
const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
|
|
1485
2132
|
if (!nestedRelDef) {
|
|
1486
2133
|
throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
1487
2134
|
`Available: ${Object.keys(targetMeta.relations).join(', ')}`);
|
|
@@ -1584,7 +2231,7 @@ export function buildManyToManySubquery(qi, relDef, spec, params, parentRef, ali
|
|
|
1584
2231
|
// LIMIT — `limit: 0` is honored (LIMIT 0 → empty array)
|
|
1585
2232
|
let limitClause = '';
|
|
1586
2233
|
if (spec !== true && spec.limit !== undefined) {
|
|
1587
|
-
limitClause = ` LIMIT ${qi.paginationRef(spec.limit, params)}`;
|
|
2234
|
+
limitClause = ` LIMIT ${qi.paginationRef(spec.limit, params, 'relation limit')}`;
|
|
1588
2235
|
}
|
|
1589
2236
|
const fromJoin = `FROM ${qTarget} ${talias} JOIN ${qJunction} ${jalias} ON ${joinOn}`;
|
|
1590
2237
|
// When LIMIT or ORDER BY is present, wrap the joined rows in an inner subquery
|
|
@@ -1593,14 +2240,11 @@ export function buildManyToManySubquery(qi, relDef, spec, params, parentRef, ali
|
|
|
1593
2240
|
const innerAlias = `${talias}i`;
|
|
1594
2241
|
const innerSql = `SELECT ${targetMeta.allColumns.map((c) => `${talias}.${qi.q(c)}`).join(', ')} ` +
|
|
1595
2242
|
`${fromJoin} WHERE ${whereClause}${orderClause}${limitClause}`;
|
|
1596
|
-
const innerJsonPairs =
|
|
1597
|
-
targetMeta.reverseColumnMap[col] ?? snakeToCamel(col),
|
|
1598
|
-
`${innerAlias}.${qi.q(col)}`,
|
|
1599
|
-
]);
|
|
2243
|
+
const innerJsonPairs = jsonScalarPairs(qi, targetMeta, targetColumns, innerAlias);
|
|
1600
2244
|
// Nested relations reference the inner alias.
|
|
1601
2245
|
if (spec !== true && spec.with) {
|
|
1602
2246
|
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
1603
|
-
const nestedRelDef = targetMeta.relations
|
|
2247
|
+
const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
|
|
1604
2248
|
if (!nestedRelDef) {
|
|
1605
2249
|
throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
1606
2250
|
`Available: ${Object.keys(targetMeta.relations).join(', ')}`);
|
|
@@ -1617,13 +2261,10 @@ export function buildManyToManySubquery(qi, relDef, spec, params, parentRef, ali
|
|
|
1617
2261
|
}
|
|
1618
2262
|
// Simple path: build the json object pairs directly off the target alias,
|
|
1619
2263
|
// including any nested relations (correlated to the target alias).
|
|
1620
|
-
const jsonPairs =
|
|
1621
|
-
targetMeta.reverseColumnMap[col] ?? snakeToCamel(col),
|
|
1622
|
-
`${talias}.${qi.q(col)}`,
|
|
1623
|
-
]);
|
|
2264
|
+
const jsonPairs = jsonScalarPairs(qi, targetMeta, targetColumns, talias);
|
|
1624
2265
|
if (spec !== true && spec.with) {
|
|
1625
2266
|
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
1626
|
-
const nestedRelDef = targetMeta.relations
|
|
2267
|
+
const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
|
|
1627
2268
|
if (!nestedRelDef) {
|
|
1628
2269
|
throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
1629
2270
|
`Available: ${Object.keys(targetMeta.relations).join(', ')}`);
|