@prisma-next/sql-orm-client 0.10.0-dev.9 → 0.11.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/dist/index.d.mts +4 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +111 -21
- package/dist/index.mjs.map +1 -1
- package/package.json +20 -21
- package/src/collection-dispatch.ts +77 -21
- package/src/exports/index.ts +2 -0
- package/src/include-tree-predicates.ts +50 -0
- package/src/query-plan-mutations.ts +1 -1
- package/src/query-plan-select.ts +99 -5
package/package.json
CHANGED
|
@@ -1,32 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prisma-next/sql-orm-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
7
7
|
"description": "ORM client for Prisma Next — fluent, type-safe model collections",
|
|
8
8
|
"dependencies": {
|
|
9
|
-
"@prisma-next/contract": "0.
|
|
10
|
-
"@prisma-next/framework-components": "0.
|
|
11
|
-
"@prisma-next/operations": "0.
|
|
12
|
-
"@prisma-next/sql-contract": "0.
|
|
13
|
-
"@prisma-next/sql-operations": "0.
|
|
14
|
-
"@prisma-next/sql-relational-core": "0.
|
|
15
|
-
"@prisma-next/sql-runtime": "0.
|
|
16
|
-
"@prisma-next/utils": "0.
|
|
9
|
+
"@prisma-next/contract": "0.11.0",
|
|
10
|
+
"@prisma-next/framework-components": "0.11.0",
|
|
11
|
+
"@prisma-next/operations": "0.11.0",
|
|
12
|
+
"@prisma-next/sql-contract": "0.11.0",
|
|
13
|
+
"@prisma-next/sql-operations": "0.11.0",
|
|
14
|
+
"@prisma-next/sql-relational-core": "0.11.0",
|
|
15
|
+
"@prisma-next/sql-runtime": "0.11.0",
|
|
16
|
+
"@prisma-next/utils": "0.11.0"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
|
-
"@prisma-next/adapter-postgres": "0.
|
|
20
|
-
"@prisma-next/
|
|
21
|
-
"@prisma-next/
|
|
22
|
-
"@prisma-next/
|
|
23
|
-
"@prisma-next/
|
|
24
|
-
"@prisma-next/
|
|
25
|
-
"@prisma-next/
|
|
26
|
-
"@prisma-next/
|
|
27
|
-
"@prisma-next/
|
|
28
|
-
"@prisma-next/
|
|
29
|
-
"@prisma-next/tsdown": "0.10.0-dev.9",
|
|
19
|
+
"@prisma-next/adapter-postgres": "0.11.0",
|
|
20
|
+
"@prisma-next/cli": "0.11.0",
|
|
21
|
+
"@prisma-next/driver-postgres": "0.11.0",
|
|
22
|
+
"@prisma-next/family-sql": "0.11.0",
|
|
23
|
+
"@prisma-next/ids": "0.11.0",
|
|
24
|
+
"@prisma-next/sql-contract-ts": "0.11.0",
|
|
25
|
+
"@prisma-next/target-postgres": "0.11.0",
|
|
26
|
+
"@prisma-next/test-utils": "0.11.0",
|
|
27
|
+
"@prisma-next/tsconfig": "0.11.0",
|
|
28
|
+
"@prisma-next/tsdown": "0.11.0",
|
|
30
29
|
"@types/pg": "8.20.0",
|
|
31
30
|
"pg": "8.20.0",
|
|
32
31
|
"tsdown": "0.22.0",
|
|
@@ -52,7 +51,7 @@
|
|
|
52
51
|
},
|
|
53
52
|
"scripts": {
|
|
54
53
|
"build": "tsdown",
|
|
55
|
-
"emit": "node ../../1-framework/3-tooling/cli/dist/cli.js contract emit --config test/fixtures/prisma-next.config.ts",
|
|
54
|
+
"emit": "cd ../../../test/integration && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/prisma-next.config.ts && cp test/sql-orm-client/fixtures/generated/contract.json test/sql-orm-client/fixtures/generated/contract.d.ts ../../packages/3-extensions/sql-orm-client/test/fixtures/generated/ && cd ../../packages/3-extensions/sql-orm-client && node scripts/strip-pgvector-fixture.mjs test/fixtures/generated/contract.d.ts",
|
|
56
55
|
"emit:check": "pnpm emit && git diff --exit-code test/fixtures/generated/",
|
|
57
56
|
"test": "vitest run",
|
|
58
57
|
"test:coverage": "vitest run --coverage",
|
|
@@ -31,6 +31,10 @@ import {
|
|
|
31
31
|
} from './collection-runtime';
|
|
32
32
|
import { executeQueryPlan } from './execute-query-plan';
|
|
33
33
|
import { selectIncludeStrategy } from './include-strategy';
|
|
34
|
+
import {
|
|
35
|
+
hasNonLeafIncludeWithDistinct,
|
|
36
|
+
hasScalarOrCombineIncludeDescriptors,
|
|
37
|
+
} from './include-tree-predicates';
|
|
34
38
|
import {
|
|
35
39
|
compileRelationSelect,
|
|
36
40
|
compileSelect,
|
|
@@ -77,9 +81,23 @@ function dispatchWithIncludeStrategy<Row>(options: {
|
|
|
77
81
|
}): AsyncIterableResult<Row> {
|
|
78
82
|
const strategy = selectIncludeStrategy(options.contract);
|
|
79
83
|
|
|
84
|
+
// Nested row includes (depth >= 2) are emitted recursively by the
|
|
85
|
+
// lateral / correlated builders — they no longer force a fallback to
|
|
86
|
+
// multi-query (TML-2594). Scalar (`count`/`sum`/...) and `combine()`
|
|
87
|
+
// descriptors still do, until TML-2595 lands the matching lowering;
|
|
88
|
+
// the recursive scan below catches them at any depth so a nested
|
|
89
|
+
// `count()` inside a row include doesn't accidentally hit the
|
|
90
|
+
// throw in `compileSelectWithIncludeStrategy`.
|
|
91
|
+
//
|
|
92
|
+
// `distinct()` on a non-leaf include is also forced through multi-query:
|
|
93
|
+
// under the single-query strategies the child SELECT carries nested
|
|
94
|
+
// JSON aggregate columns, and `SELECT DISTINCT` over those fails on
|
|
95
|
+
// Postgres (`json` has no equality operator). The multi-query stitcher
|
|
96
|
+
// applies distinct to scalar-only child rows before grandchildren are
|
|
97
|
+
// joined, which is the semantically correct behavior we preserve.
|
|
80
98
|
if (
|
|
81
|
-
|
|
82
|
-
|
|
99
|
+
hasScalarOrCombineIncludeDescriptors(options.state.includes) ||
|
|
100
|
+
hasNonLeafIncludeWithDistinct(options.state.includes)
|
|
83
101
|
) {
|
|
84
102
|
return dispatchWithMultiQueryIncludes<Row>(options);
|
|
85
103
|
}
|
|
@@ -144,18 +162,10 @@ function dispatchWithSingleQueryIncludes<Row>(options: {
|
|
|
144
162
|
|
|
145
163
|
for (const parent of parentRows) {
|
|
146
164
|
for (const include of state.includes) {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
const rawChildren = parseIncludedRows(parent.raw[include.relationName]);
|
|
153
|
-
const mappedChildren = rawChildren.map((childRow) =>
|
|
154
|
-
mapStorageRowToModelFields(contract, include.relatedModelName, childRow),
|
|
155
|
-
);
|
|
156
|
-
parent.mapped[include.relationName] = coerceSingleQueryIncludeResult(
|
|
157
|
-
mappedChildren,
|
|
158
|
-
include.cardinality,
|
|
165
|
+
parent.mapped[include.relationName] = decodeIncludePayload(
|
|
166
|
+
contract,
|
|
167
|
+
include,
|
|
168
|
+
parent.raw[include.relationName],
|
|
159
169
|
);
|
|
160
170
|
}
|
|
161
171
|
|
|
@@ -372,8 +382,22 @@ async function resolveRowsByParent(
|
|
|
372
382
|
state: CollectionState,
|
|
373
383
|
parentJoinValues: readonly unknown[],
|
|
374
384
|
): Promise<Map<unknown, Record<string, unknown>[]>> {
|
|
385
|
+
// Multi-query stitching reads `child.raw[grandchildInclude.localColumn]`
|
|
386
|
+
// for every immediate include in `state.includes` to bucket children by
|
|
387
|
+
// join value. A user-supplied `.select(...)` on the child that omits any
|
|
388
|
+
// of those join columns would silently yield `undefined` for the join
|
|
389
|
+
// value, producing empty nested arrays at depth >= 2. Force every
|
|
390
|
+
// direct nested include's `localColumn` into the child's selected set
|
|
391
|
+
// alongside `include.targetColumn` so the stitcher always sees a
|
|
392
|
+
// defined join value. The next level of recursion (when grandchild
|
|
393
|
+
// stitching itself dispatches through `resolveRowsByParent`) repeats
|
|
394
|
+
// this same augmentation for its own children.
|
|
395
|
+
const nestedJoinColumns = state.includes.map((nested) => nested.localColumn);
|
|
396
|
+
const requiredChildColumns = Array.from(
|
|
397
|
+
new Set<string>([include.targetColumn, ...nestedJoinColumns]),
|
|
398
|
+
);
|
|
375
399
|
const { selectedForQuery: childSelectedForQuery, hiddenColumns: hiddenChildColumns } =
|
|
376
|
-
augmentSelectionForJoinColumns(state.selectedFields,
|
|
400
|
+
augmentSelectionForJoinColumns(state.selectedFields, requiredChildColumns);
|
|
377
401
|
|
|
378
402
|
const childCompiled = compileRelationSelect(
|
|
379
403
|
contract,
|
|
@@ -471,12 +495,44 @@ function uniqueValues(values: unknown[]): unknown[] {
|
|
|
471
495
|
return [...new Set(values)];
|
|
472
496
|
}
|
|
473
497
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
498
|
+
/**
|
|
499
|
+
* Decode a single-query include payload from a parent row's raw cell
|
|
500
|
+
* into the model-shaped value that downstream consumers see. Recurses
|
|
501
|
+
* through `include.nested.includes` so depth-2+ trees — emitted by the
|
|
502
|
+
* recursive lateral / correlated builders — are decoded symmetrically.
|
|
503
|
+
*
|
|
504
|
+
* The shape produced by the SQL side is one JSON column per top-level
|
|
505
|
+
* include; values nested inside that JSON are already-parsed JS values
|
|
506
|
+
* after the outer `JSON.parse`, so `parseIncludedRows` recognises both
|
|
507
|
+
* the string (top-level) and array (nested) forms.
|
|
508
|
+
*/
|
|
509
|
+
function decodeIncludePayload(
|
|
510
|
+
contract: Contract<SqlStorage>,
|
|
511
|
+
include: IncludeExpr,
|
|
512
|
+
raw: unknown,
|
|
513
|
+
): Record<string, unknown>[] | Record<string, unknown> | null {
|
|
514
|
+
const rawChildren = parseIncludedRows(raw);
|
|
515
|
+
const mappedChildren = rawChildren.map((childRow) => {
|
|
516
|
+
const mapped = mapStorageRowToModelFields(contract, include.relatedModelName, childRow);
|
|
517
|
+
for (const nestedInclude of include.nested.includes) {
|
|
518
|
+
// Defence in depth: the dispatch gate filters scalar/combine at
|
|
519
|
+
// any depth via `hasScalarOrCombineIncludeDescriptors`. This branch
|
|
520
|
+
// is unreachable in production but documents the contract the
|
|
521
|
+
// recursion relies on.
|
|
522
|
+
if (nestedInclude.scalar || nestedInclude.combine) {
|
|
523
|
+
throw new Error(
|
|
524
|
+
'single-query include strategy does not support nested scalar include selectors or combine()',
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
mapped[nestedInclude.relationName] = decodeIncludePayload(
|
|
528
|
+
contract,
|
|
529
|
+
nestedInclude,
|
|
530
|
+
mapped[nestedInclude.relationName],
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
return mapped;
|
|
534
|
+
});
|
|
535
|
+
return coerceSingleQueryIncludeResult(mappedChildren, include.cardinality);
|
|
480
536
|
}
|
|
481
537
|
|
|
482
538
|
function assignEmptyIncludeResult(parentRows: RowEnvelope[], include: IncludeExpr): void {
|
package/src/exports/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { Collection } from '../collection';
|
|
2
2
|
export { all, and, not, or } from '../filters';
|
|
3
3
|
export { GroupedCollection } from '../grouped-collection';
|
|
4
|
+
export { createModelAccessor } from '../model-accessor';
|
|
4
5
|
export type { OrmOptions } from '../orm';
|
|
5
6
|
export { orm } from '../orm';
|
|
6
7
|
export type {
|
|
@@ -17,6 +18,7 @@ export type {
|
|
|
17
18
|
DefaultModelRow,
|
|
18
19
|
IncludeExpr,
|
|
19
20
|
ModelAccessor,
|
|
21
|
+
NumericFieldNames,
|
|
20
22
|
RelatedModelName,
|
|
21
23
|
RelationFilterAccessor,
|
|
22
24
|
RelationMutator,
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { IncludeExpr } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Recursive predicate: does any include in the tree carry a
|
|
5
|
+
* non-leaf `distinct()` — i.e. `nested.distinct` set on an include
|
|
6
|
+
* whose `nested.includes` is non-empty?
|
|
7
|
+
*
|
|
8
|
+
* Such shapes cannot be lowered into the lateral / correlated
|
|
9
|
+
* single-query strategies: the child SELECT would emit
|
|
10
|
+
* `SELECT DISTINCT <scalars>, json_agg(<nested>) FROM ...`, and
|
|
11
|
+
* Postgres rejects equality on `json`. The dispatch path routes
|
|
12
|
+
* these to multi-query (which applies distinct to scalar-only rows
|
|
13
|
+
* before grandchildren stitch in JS); the planner rejects them at
|
|
14
|
+
* the boundary.
|
|
15
|
+
*
|
|
16
|
+
* `distinctOn` is intentionally not included: Postgres only
|
|
17
|
+
* compares the `ON (...)` expressions for equality, so a hashable
|
|
18
|
+
* key column plus json projections is well-defined.
|
|
19
|
+
*/
|
|
20
|
+
export function hasNonLeafIncludeWithDistinct(includes: readonly IncludeExpr[]): boolean {
|
|
21
|
+
return includes.some(
|
|
22
|
+
(include) =>
|
|
23
|
+
(include.nested.distinct !== undefined &&
|
|
24
|
+
include.nested.distinct.length > 0 &&
|
|
25
|
+
include.nested.includes.length > 0) ||
|
|
26
|
+
hasNonLeafIncludeWithDistinct(include.nested.includes),
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Recursive predicate: does any include in the tree carry a scalar
|
|
32
|
+
* selector (`count` / `sum` / ...) or a `combine()` descriptor?
|
|
33
|
+
*
|
|
34
|
+
* Such shapes cannot be lowered into the lateral / correlated
|
|
35
|
+
* single-query strategies (TML-2595). The dispatch path uses this
|
|
36
|
+
* to gate the whole tree to multi-query at any depth; the planner
|
|
37
|
+
* (`compileSelectWithIncludeStrategy`) uses the same predicate to
|
|
38
|
+
* fail fast at the boundary rather than build a malformed plan.
|
|
39
|
+
* Without the recursion, a depth-2+ row include containing a
|
|
40
|
+
* depth-3 `count()` would fall through to the planner and hit its
|
|
41
|
+
* explicit `throw` instead of routing to multi-query.
|
|
42
|
+
*/
|
|
43
|
+
export function hasScalarOrCombineIncludeDescriptors(includes: readonly IncludeExpr[]): boolean {
|
|
44
|
+
return includes.some(
|
|
45
|
+
(include) =>
|
|
46
|
+
include.scalar !== undefined ||
|
|
47
|
+
include.combine !== undefined ||
|
|
48
|
+
hasScalarOrCombineIncludeDescriptors(include.nested.includes),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -239,7 +239,7 @@ export function compileUpsertReturning(
|
|
|
239
239
|
).doNothing();
|
|
240
240
|
|
|
241
241
|
const ast = InsertAst.into(TableSource.named(tableName))
|
|
242
|
-
.
|
|
242
|
+
.withRows([createAssignments.assignments])
|
|
243
243
|
.withOnConflict(onConflict)
|
|
244
244
|
.withReturning(buildReturningColumns(contract, tableName, returningColumns));
|
|
245
245
|
|
package/src/query-plan-select.ts
CHANGED
|
@@ -29,6 +29,10 @@ import {
|
|
|
29
29
|
resolvePolymorphismInfo,
|
|
30
30
|
resolvePrimaryKeyColumn,
|
|
31
31
|
} from './collection-contract';
|
|
32
|
+
import {
|
|
33
|
+
hasNonLeafIncludeWithDistinct,
|
|
34
|
+
hasScalarOrCombineIncludeDescriptors,
|
|
35
|
+
} from './include-tree-predicates';
|
|
32
36
|
import { buildOrmQueryPlan, deriveParamsFromAst, resolveTableColumns } from './query-plan-meta';
|
|
33
37
|
import type { CollectionState, IncludeExpr } from './types';
|
|
34
38
|
import { bindWhereExpr } from './where-binding';
|
|
@@ -210,10 +214,54 @@ function buildIncludeOrderArtifacts(
|
|
|
210
214
|
};
|
|
211
215
|
}
|
|
212
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Recursively build the join + projection artifacts for the nested
|
|
219
|
+
* includes attached to a child SELECT. Used by
|
|
220
|
+
* `buildIncludeChildRowsSelect` to wire depth-2+ aggregates into the
|
|
221
|
+
* inner SELECT at each level.
|
|
222
|
+
*
|
|
223
|
+
* Under the `lateral` strategy each nested include contributes one
|
|
224
|
+
* LEFT JOIN LATERAL plus one projection item that references the
|
|
225
|
+
* lateral alias. Under the `correlated` strategy each nested include
|
|
226
|
+
* contributes a single projection item whose expression is a
|
|
227
|
+
* correlated subquery; no joins are produced. The two cases are
|
|
228
|
+
* symmetric, which is why both paths share `buildIncludeChildRowsSelect`.
|
|
229
|
+
*/
|
|
230
|
+
function buildNestedIncludeArtifacts(
|
|
231
|
+
contract: Contract<SqlStorage>,
|
|
232
|
+
parentTableRef: string,
|
|
233
|
+
includes: readonly IncludeExpr[],
|
|
234
|
+
strategy: 'lateral' | 'correlated',
|
|
235
|
+
): {
|
|
236
|
+
readonly joins: ReadonlyArray<JoinAst>;
|
|
237
|
+
readonly projections: ReadonlyArray<ProjectionItem>;
|
|
238
|
+
} {
|
|
239
|
+
if (includes.length === 0) {
|
|
240
|
+
return { joins: [], projections: [] };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const joins: JoinAst[] = [];
|
|
244
|
+
const projections: ProjectionItem[] = [];
|
|
245
|
+
|
|
246
|
+
for (const nested of includes) {
|
|
247
|
+
if (strategy === 'lateral') {
|
|
248
|
+
const artifact = buildLateralIncludeArtifacts(contract, parentTableRef, nested);
|
|
249
|
+
joins.push(artifact.join);
|
|
250
|
+
projections.push(artifact.projection);
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const artifact = buildCorrelatedIncludeProjection(contract, parentTableRef, nested);
|
|
254
|
+
projections.push(artifact.projection);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return { joins, projections };
|
|
258
|
+
}
|
|
259
|
+
|
|
213
260
|
function buildIncludeChildRowsSelect(
|
|
214
261
|
contract: Contract<SqlStorage>,
|
|
215
262
|
parentTableName: string,
|
|
216
263
|
include: IncludeExpr,
|
|
264
|
+
strategy: 'lateral' | 'correlated',
|
|
217
265
|
): {
|
|
218
266
|
readonly childRows: SelectAst;
|
|
219
267
|
readonly childProjection: ReadonlyArray<ProjectionItem>;
|
|
@@ -225,16 +273,28 @@ function buildIncludeChildRowsSelect(
|
|
|
225
273
|
include.relatedTableName === parentTableName ? `${include.relationName}__child` : undefined;
|
|
226
274
|
const childTableRef = childTableAlias ?? include.relatedTableName;
|
|
227
275
|
const rowsAlias = `${include.relationName}__rows`;
|
|
228
|
-
const
|
|
276
|
+
const scalarProjection = buildProjection(
|
|
229
277
|
contract,
|
|
230
278
|
include.relatedTableName,
|
|
231
279
|
childState.selectedFields,
|
|
232
280
|
childTableRef,
|
|
233
281
|
);
|
|
282
|
+
// Self-relations rename the inner table source via `childTableAlias`,
|
|
283
|
+
// so any ColumnRef the user-supplied `orderBy` carries against the
|
|
284
|
+
// original `include.relatedTableName` is no longer in scope inside the
|
|
285
|
+
// child SELECT. Remap before lowering to the hidden order projection
|
|
286
|
+
// — mirrors the `filterTableName` remap `buildStateWhere` applies to
|
|
287
|
+
// the where clauses just below.
|
|
288
|
+
const remappedChildOrderBy =
|
|
289
|
+
childTableAlias && childState.orderBy
|
|
290
|
+
? childState.orderBy.map((item) =>
|
|
291
|
+
item.rewrite(createTableRefRemapper(include.relatedTableName, childTableRef)),
|
|
292
|
+
)
|
|
293
|
+
: childState.orderBy;
|
|
234
294
|
const { childOrderBy, hiddenOrderProjection, aggregateOrderBy } = buildIncludeOrderArtifacts(
|
|
235
295
|
include.relationName,
|
|
236
296
|
rowsAlias,
|
|
237
|
-
|
|
297
|
+
remappedChildOrderBy,
|
|
238
298
|
);
|
|
239
299
|
const childWhere = buildStateWhere(contract, childTableRef, childState, {
|
|
240
300
|
filterTableName: include.relatedTableName,
|
|
@@ -245,8 +305,30 @@ function buildIncludeChildRowsSelect(
|
|
|
245
305
|
);
|
|
246
306
|
const whereExpr = childWhere ? AndExpr.of([joinExpr, childWhere]) : joinExpr;
|
|
247
307
|
|
|
308
|
+
// Recurse: each nested include produces either a LATERAL JOIN (under
|
|
309
|
+
// `lateral`) or a correlated subquery projection (under `correlated`).
|
|
310
|
+
// The nested aggregates are attached to *this* child SELECT, so they
|
|
311
|
+
// correlate against `childTableRef` — which may itself be an alias if
|
|
312
|
+
// the relation is self-referential.
|
|
313
|
+
const { joins: nestedJoins, projections: nestedProjections } = buildNestedIncludeArtifacts(
|
|
314
|
+
contract,
|
|
315
|
+
childTableRef,
|
|
316
|
+
childState.includes,
|
|
317
|
+
strategy,
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
// `childProjection` is the set of items that survive into the parent's
|
|
321
|
+
// JSON object — the scalar columns plus any nested-include aggregate
|
|
322
|
+
// columns. The hidden order-by projection is separate and is dropped
|
|
323
|
+
// before assembling the parent's json_object_expr.
|
|
324
|
+
const childProjection: ReadonlyArray<ProjectionItem> = [
|
|
325
|
+
...scalarProjection,
|
|
326
|
+
...nestedProjections,
|
|
327
|
+
];
|
|
328
|
+
|
|
248
329
|
let childRows = SelectAst.from(TableSource.named(include.relatedTableName, childTableAlias))
|
|
249
330
|
.withProjection([...childProjection, ...hiddenOrderProjection])
|
|
331
|
+
.withJoins(nestedJoins)
|
|
250
332
|
.withWhere(whereExpr);
|
|
251
333
|
|
|
252
334
|
if (childOrderBy) {
|
|
@@ -286,6 +368,7 @@ function buildLateralIncludeArtifacts(
|
|
|
286
368
|
contract,
|
|
287
369
|
parentTableName,
|
|
288
370
|
include,
|
|
371
|
+
'lateral',
|
|
289
372
|
);
|
|
290
373
|
const lateralAlias = `${include.relationName}_lateral`;
|
|
291
374
|
const jsonObjectExpr = JsonObjectExpr.fromEntries(
|
|
@@ -323,6 +406,7 @@ function buildCorrelatedIncludeProjection(
|
|
|
323
406
|
contract,
|
|
324
407
|
parentTableName,
|
|
325
408
|
include,
|
|
409
|
+
'correlated',
|
|
326
410
|
);
|
|
327
411
|
const jsonObjectExpr = JsonObjectExpr.fromEntries(
|
|
328
412
|
childProjection.map((item) =>
|
|
@@ -483,14 +567,24 @@ export function compileSelectWithIncludeStrategy(
|
|
|
483
567
|
strategy: 'lateral' | 'correlated',
|
|
484
568
|
modelName?: string,
|
|
485
569
|
): SqlQueryPlan<Record<string, unknown>> {
|
|
486
|
-
if (
|
|
487
|
-
state.includes.some((include) => include.scalar !== undefined || include.combine !== undefined)
|
|
488
|
-
) {
|
|
570
|
+
if (hasScalarOrCombineIncludeDescriptors(state.includes)) {
|
|
489
571
|
throw new Error(
|
|
490
572
|
'single-query include strategy does not support scalar include selectors or combine()',
|
|
491
573
|
);
|
|
492
574
|
}
|
|
493
575
|
|
|
576
|
+
// Same recursive shape as the scalar/combine gate above. A non-leaf
|
|
577
|
+
// `distinct()` cannot be lowered into the single-query strategies:
|
|
578
|
+
// the child SELECT would emit `SELECT DISTINCT <scalars>, json_agg(...)`,
|
|
579
|
+
// and Postgres rejects equality on `json`. The dispatch path routes
|
|
580
|
+
// these to multi-query; direct planner callers (tests, rich-plan
|
|
581
|
+
// consumers) fail fast here instead of building an invalid plan.
|
|
582
|
+
if (hasNonLeafIncludeWithDistinct(state.includes)) {
|
|
583
|
+
throw new Error(
|
|
584
|
+
'single-query include strategy does not support distinct() on a non-leaf include',
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
|
|
494
588
|
const includeJoins: JoinAst[] = [];
|
|
495
589
|
const includeProjection: ProjectionItem[] = [];
|
|
496
590
|
const topLevelWhere = buildStateWhere(contract, tableName, state);
|