Skip to content

Commit e7ce035

Browse files
authored
TML-2785: M:N slice 1 — correlated include read through the junction (#679)
Slice 1 of the **SQL ORM: Many-to-Many End to End** project ([Linear project](https://linear.app/prisma-company/project/sql-orm-many-to-many-end-to-end-c178df40ca3a)). Reads an M:N relation through its junction. > **Stacked PR.** Base is `tml-2784` (#678, slice 0) → `tml-2597` (#673) → `tml-2729` (#667) → `main`. Review/merge bottom-up. ## Overview `db.orm.User.include(tags)` now resolves a many-to-many relation to `{ …user, tags: Tag[] }` in a **single correlated subquery** that walks parent → junction → target — no `LATERAL`, no second query. Built on slice 0s `ResolvedRelation.through`. ## Changes (4 commits) - **`fcecac5b3`** — integration fixture gains a `User ↔ Tag` M:N relation via a `UserTag` junction (composite PK `user_id`/`tag_id`); `contract.json`/`.d.ts` re-emitted. - **`e587b433c`** — read path: `IncludeExpr.through` (surfaced by `resolveIncludeRelation`), and `buildCorrelatedIncludeProjection` gains an M:N branch — `buildManyToManyJunctionArtifacts` builds a non-LATERAL inner join to the junction (`junction.childColumns = target.targetColumns`) correlated to the parent (`junction.parentColumns = parent` anchor), composite-key AND-ed; FK decode path reused. Unit-tested at the AST level. - **`b9c3e9f7b`** — replace 2 bare `as` casts with `castAs`; add the missing M:N + distinct + non-leaf unit test. - **`d3232cbad`** — 7 integration tests (PGlite). ## Integration tests (per the project standard) Whole-row `toEqual`; 6/7 use explicit `.select(...)` (so adding a model field wont churn assertions); **test 5 uses implicit/default selection** (full `User` + `tags: Tag[]` shape); a **single-execution / no-`LATERAL`** assertion; depth-2 nesting (`invitedUsers → tags`); edges (user with no tags → `tags: []`; a tag shared by multiple users). ## Why This is the first of the three relation-shaped M:N consumers (read / filter / write) over slice 0s shared `through` primitive. The correlated-only approach matches the post-TML-2729 read path (no LATERAL to reintroduce). ## Scope / notes Read only — filter (TML-2786) and write (TML-2787) are later slices. The fixture is **one-directional** (`User.tags`; reverse `Tag.users` deferred — adding it trips a latent create-overload type fragility in unrelated mutation-defaults tests; see the projects unattended-decisions log). Fixture re-emit used a `tsx` bypass because the CLI `contract emit` fails on a sandbox config-load env issue — **CI `fixtures:check` is the real golden-stability gate**; please confirm its green (or re-run the canonical emit). Broad integration runs show pre-existing PGlite/WASM JIT flakiness; the M:N tests pass on targeted runs. Refs: TML-2785. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * include(...) now supports many-to-many (M:N) relationships via junction tables, returning correct nested arrays and preserving single-query execution. * **Tests** * Added unit and integration tests covering M:N includes, composite keys, nested includes, distinct+nested scenarios, and end-to-end result shape correctness. * **Documentation** * Added upgrade notes for 0.14 describing the runtime support for M:N correlated include reads. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
1 parent dc72201 commit e7ce035

19 files changed

Lines changed: 1117 additions & 10 deletions

File tree

packages/3-extensions/sql-orm-client/src/collection-contract.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ import type {
66
} from '@prisma-next/contract/types';
77
import type { SqlStorage, StorageTable } from '@prisma-next/sql-contract/types';
88
import { blindCast } from '@prisma-next/utils/casts';
9+
import { ifDefined } from '@prisma-next/utils/defined';
910
import {
1011
domainModelTableInNamespace,
1112
resolveTableForContract,
1213
storageTableForContract,
1314
} from './storage-resolution';
14-
import type { RelationCardinalityTag } from './types';
15+
import type { IncludeThroughDescriptor, RelationCardinalityTag } from './types';
1516

1617
type ModelStorageFields = Record<string, { column?: string }>;
1718
type ModelEntry = {
@@ -297,6 +298,7 @@ export interface ResolvedIncludeRelation {
297298
readonly targetColumn: string;
298299
readonly localColumn: string;
299300
readonly cardinality: RelationCardinalityTag | undefined;
301+
readonly through?: IncludeThroughDescriptor;
300302
}
301303

302304
export function resolveIncludeRelation(
@@ -327,13 +329,29 @@ export function resolveIncludeRelation(
327329
targetField,
328330
);
329331

332+
let through: IncludeThroughDescriptor | undefined;
333+
if (relation.through !== undefined) {
334+
const parentLocalColumns = relation.on.localFields.map((field) =>
335+
resolveFieldToColumn(contract, namespaceId, modelName, field),
336+
);
337+
through = {
338+
table: relation.through.table,
339+
namespaceId: relation.through.namespaceId,
340+
parentColumns: relation.through.parentColumns,
341+
childColumns: relation.through.childColumns,
342+
targetColumns: relation.through.targetColumns,
343+
parentLocalColumns,
344+
};
345+
}
346+
330347
return {
331348
relatedModelName: relation.to,
332349
relatedNamespaceId: relation.toNamespace,
333350
relatedTableName,
334351
targetColumn,
335352
localColumn,
336353
cardinality: relation.cardinality,
354+
...ifDefined('through', through),
337355
};
338356
}
339357

packages/3-extensions/sql-orm-client/src/collection-dispatch.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,9 @@ function dispatchWithIncludes<Row>(options: {
100100
const generator = async function* (): AsyncGenerator<Row, void, unknown> {
101101
const { scope, release } = await acquireRuntimeScope(runtime);
102102
try {
103-
const parentJoinColumns = state.includes.map((include) => include.localColumn);
103+
const parentJoinColumns = state.includes.flatMap((include) =>
104+
include.through !== undefined ? include.through.parentLocalColumns : [include.localColumn],
105+
);
104106
const { selectedForQuery: parentSelectedForQuery, hiddenColumns: hiddenParentColumns } =
105107
augmentSelectionForJoinColumns(state.selectedFields, parentJoinColumns);
106108
const compiled = compileSelectWithIncludes(

packages/3-extensions/sql-orm-client/src/collection.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
type ToWhereExpr,
1717
type WhereArg,
1818
} from '@prisma-next/sql-relational-core/ast';
19+
import { ifDefined } from '@prisma-next/utils/defined';
1920
import type { SimplifyDeep } from '@prisma-next/utils/simplify-deep';
2021
import { createAggregateBuilder, isAggregateSelector } from './aggregate-builder';
2122
import { normalizeAggregateResult } from './collection-aggregate-result';
@@ -513,6 +514,7 @@ export class Collection<
513514
targetColumn: relation.targetColumn,
514515
localColumn: relation.localColumn,
515516
cardinality: relation.cardinality,
517+
...ifDefined('through', relation.through),
516518
nested: nestedState,
517519
scalar: scalarSelector,
518520
combine: combineBranches,

packages/3-extensions/sql-orm-client/src/query-plan-select.ts

Lines changed: 103 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
} from '@prisma-next/sql-relational-core/ast';
2626
import { codecRefForStorageColumn } from '@prisma-next/sql-relational-core/codec-descriptor-registry';
2727
import type { SqlQueryPlan } from '@prisma-next/sql-relational-core/plan';
28+
import { assertDefined, invariant } from '@prisma-next/utils/assertions';
2829
import { ifDefined } from '@prisma-next/utils/defined';
2930
import {
3031
type PolymorphismInfo,
@@ -341,6 +342,79 @@ function buildChildPolymorphismJoinsAndProjection(
341342
};
342343
}
343344

345+
/**
346+
* Build the correlated WHERE and junction JOIN artifacts for a many-to-many
347+
* include. The resulting WHERE correlates the junction to the parent rows
348+
* (AND-ed across all column pairs for composite keys). The junction JOIN
349+
* connects child rows to the junction via the child columns.
350+
*/
351+
function buildManyToManyJunctionArtifacts(
352+
parentTableName: string,
353+
childTableRef: string,
354+
through: NonNullable<IncludeExpr['through']>,
355+
): {
356+
readonly whereExpr: AnyExpression;
357+
readonly junctionJoin: JoinAst;
358+
} {
359+
const {
360+
table: junctionTable,
361+
parentColumns,
362+
childColumns,
363+
targetColumns,
364+
parentLocalColumns,
365+
namespaceId,
366+
} = through;
367+
368+
invariant(
369+
childColumns.length === targetColumns.length,
370+
`M:N junction '${junctionTable}': childColumns (${childColumns.length}) and targetColumns (${targetColumns.length}) must have equal length`,
371+
);
372+
invariant(
373+
parentColumns.length === parentLocalColumns.length,
374+
`M:N junction '${junctionTable}': parentColumns (${parentColumns.length}) and parentLocalColumns (${parentLocalColumns.length}) must have equal length`,
375+
);
376+
377+
const joinOnPairs = childColumns.map((junctionCol, i) => {
378+
const targetCol = targetColumns[i];
379+
assertDefined(
380+
targetCol,
381+
`M:N junction '${junctionTable}': missing target column at index ${i}`,
382+
);
383+
return BinaryExpr.eq(
384+
ColumnRef.of(junctionTable, junctionCol),
385+
ColumnRef.of(childTableRef, targetCol),
386+
);
387+
});
388+
const firstJoinPair = joinOnPairs[0];
389+
const joinOn: AnyExpression =
390+
joinOnPairs.length === 1 && firstJoinPair ? firstJoinPair : AndExpr.of(joinOnPairs);
391+
392+
const correlationPairs = parentColumns.map((junctionCol, i) => {
393+
const parentLocalCol = parentLocalColumns[i];
394+
assertDefined(
395+
parentLocalCol,
396+
`M:N junction '${junctionTable}': missing parent-local column at index ${i}`,
397+
);
398+
return BinaryExpr.eq(
399+
ColumnRef.of(junctionTable, junctionCol),
400+
ColumnRef.of(parentTableName, parentLocalCol),
401+
);
402+
});
403+
const firstCorrelationPair = correlationPairs[0];
404+
const whereExpr: AnyExpression =
405+
correlationPairs.length === 1 && firstCorrelationPair
406+
? firstCorrelationPair
407+
: AndExpr.of(correlationPairs);
408+
409+
const junctionJoin = JoinAst.inner(
410+
TableSource.named(junctionTable, undefined, namespaceId),
411+
joinOn,
412+
false,
413+
);
414+
415+
return { whereExpr, junctionJoin };
416+
}
417+
344418
function buildIncludeChildRowsSelect(
345419
contract: Contract<SqlStorage>,
346420
parentTableName: string,
@@ -377,11 +451,25 @@ function buildIncludeChildRowsSelect(
377451
filterTableName: include.relatedTableName,
378452
namespaceId: include.relatedNamespaceId,
379453
});
380-
const joinExpr = BinaryExpr.eq(
381-
ColumnRef.of(childTableRef, include.targetColumn),
382-
ColumnRef.of(parentTableName, include.localColumn),
383-
);
384-
const whereExpr = childWhere ? AndExpr.of([joinExpr, childWhere]) : joinExpr;
454+
455+
let whereExpr: AnyExpression;
456+
let junctionJoins: JoinAst[] = [];
457+
458+
if (include.through !== undefined) {
459+
const artifacts = buildManyToManyJunctionArtifacts(
460+
parentTableName,
461+
childTableRef,
462+
include.through,
463+
);
464+
whereExpr = childWhere ? AndExpr.of([artifacts.whereExpr, childWhere]) : artifacts.whereExpr;
465+
junctionJoins = [artifacts.junctionJoin];
466+
} else {
467+
const joinExpr = BinaryExpr.eq(
468+
ColumnRef.of(childTableRef, include.targetColumn),
469+
ColumnRef.of(parentTableName, include.localColumn),
470+
);
471+
whereExpr = childWhere ? AndExpr.of([joinExpr, childWhere]) : joinExpr;
472+
}
385473

386474
// `distinct()` on a non-leaf include cannot be lowered as
387475
// `SELECT DISTINCT <scalars>, json_agg(<grandchild>) FROM ...`:
@@ -409,6 +497,7 @@ function buildIncludeChildRowsSelect(
409497
hiddenOrderProjection,
410498
aggregateOrderBy,
411499
whereExpr,
500+
junctionJoins,
412501
});
413502
}
414503

@@ -467,6 +556,10 @@ function buildIncludeChildRowsSelect(
467556
childRows = childRows.withJoins([...polyJoinsAndProjection.joins]);
468557
}
469558

559+
if (junctionJoins.length > 0) {
560+
childRows = childRows.withJoins(junctionJoins);
561+
}
562+
470563
if (childState.distinctOn && childState.distinctOn.length > 0) {
471564
childRows = childRows.withDistinctOn(
472565
childState.distinctOn.map((column) => ColumnRef.of(childTableRef, column)),
@@ -529,6 +622,7 @@ function buildDistinctNonLeafChildRowsSelect(options: {
529622
readonly hiddenOrderProjection: ReadonlyArray<ProjectionItem>;
530623
readonly aggregateOrderBy: ReadonlyArray<OrderByItem> | undefined;
531624
readonly whereExpr: AnyExpression;
625+
readonly junctionJoins: ReadonlyArray<JoinAst>;
532626
}): {
533627
readonly childRows: SelectAst;
534628
readonly childProjection: ReadonlyArray<ProjectionItem>;
@@ -545,6 +639,7 @@ function buildDistinctNonLeafChildRowsSelect(options: {
545639
hiddenOrderProjection,
546640
aggregateOrderBy,
547641
whereExpr,
642+
junctionJoins,
548643
} = options;
549644
const childState = include.nested;
550645

@@ -614,8 +709,9 @@ function buildDistinctNonLeafChildRowsSelect(options: {
614709
...hiddenOrderProjection,
615710
])
616711
.withWhere(whereExpr);
617-
if (polyJoinsAndProjection.joins.length > 0) {
618-
baseInner = baseInner.withJoins([...polyJoinsAndProjection.joins]);
712+
const distinctExtraJoins = [...polyJoinsAndProjection.joins, ...junctionJoins];
713+
if (distinctExtraJoins.length > 0) {
714+
baseInner = baseInner.withJoins(distinctExtraJoins);
619715
}
620716

621717
// `childState.distinct` is non-empty by the `isDistinctNonLeaf` guard

packages/3-extensions/sql-orm-client/src/types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,20 @@ export interface IncludeCombine<ResultShape extends Record<string, unknown>>
5050
readonly branches: Readonly<Record<string, IncludeCombineBranch>>;
5151
}
5252

53+
export interface IncludeThroughDescriptor {
54+
readonly table: string;
55+
/** Namespace the junction table lives in, as declared in the contract. */
56+
readonly namespaceId: string;
57+
/** FK columns in the junction table that point to the parent. */
58+
readonly parentColumns: readonly string[];
59+
/** FK columns in the junction table that point to the target (child). */
60+
readonly childColumns: readonly string[];
61+
/** PK columns in the target table that the junction's childColumns reference. */
62+
readonly targetColumns: readonly string[];
63+
/** Resolved column names in the parent table that junction.parentColumns reference. */
64+
readonly parentLocalColumns: readonly string[];
65+
}
66+
5367
export interface IncludeExpr {
5468
readonly relationName: string;
5569
readonly relatedModelName: string;
@@ -58,6 +72,7 @@ export interface IncludeExpr {
5872
readonly targetColumn: string;
5973
readonly localColumn: string;
6074
readonly cardinality: RelationCardinalityTag | undefined;
75+
readonly through?: IncludeThroughDescriptor;
6176
readonly nested: CollectionState;
6277
readonly scalar: IncludeScalar<unknown> | undefined;
6378
readonly combine: Readonly<Record<string, IncludeCombineBranch>> | undefined;

0 commit comments

Comments
 (0)