diff --git a/packages/3-extensions/sql-orm-client/src/collection-contract.ts b/packages/3-extensions/sql-orm-client/src/collection-contract.ts index ac9ba37f9f..4b73a45d37 100644 --- a/packages/3-extensions/sql-orm-client/src/collection-contract.ts +++ b/packages/3-extensions/sql-orm-client/src/collection-contract.ts @@ -6,7 +6,7 @@ import { } from '@prisma-next/contract/types'; import type { SqlStorage, StorageTable } from '@prisma-next/sql-contract/types'; import { castAs } from '@prisma-next/utils/casts'; -import type { RelationCardinalityTag } from './types'; +import type { IncludeThroughDescriptor, RelationCardinalityTag } from './types'; type ModelStorageFields = Record; type ModelEntry = { @@ -221,6 +221,7 @@ export interface ResolvedIncludeRelation { readonly targetColumn: string; readonly localColumn: string; readonly cardinality: RelationCardinalityTag | undefined; + readonly through?: IncludeThroughDescriptor; } export function resolveIncludeRelation( @@ -245,12 +246,27 @@ export function resolveIncludeRelation( const localColumn = resolveFieldToColumn(contract, modelName, localField); const targetColumn = resolveFieldToColumn(contract, relation.to, targetField); + let through: IncludeThroughDescriptor | undefined; + if (relation.through !== undefined) { + const parentLocalColumns = relation.on.localFields.map((field) => + resolveFieldToColumn(contract, modelName, field), + ); + through = { + table: relation.through.table, + parentColumns: relation.through.parentColumns, + childColumns: relation.through.childColumns, + targetColumns: relation.through.targetColumns, + parentLocalColumns, + }; + } + return { relatedModelName: relation.to, relatedTableName, targetColumn, localColumn, cardinality: relation.cardinality, + ...(through !== undefined ? { through } : {}), }; } diff --git a/packages/3-extensions/sql-orm-client/src/collection-dispatch.ts b/packages/3-extensions/sql-orm-client/src/collection-dispatch.ts index a28bf58f97..f0b53a39cc 100644 --- a/packages/3-extensions/sql-orm-client/src/collection-dispatch.ts +++ b/packages/3-extensions/sql-orm-client/src/collection-dispatch.ts @@ -91,7 +91,9 @@ function dispatchWithIncludes(options: { const generator = async function* (): AsyncGenerator { const { scope, release } = await acquireRuntimeScope(runtime); try { - const parentJoinColumns = state.includes.map((include) => include.localColumn); + const parentJoinColumns = state.includes.flatMap((include) => + include.through !== undefined ? include.through.parentLocalColumns : [include.localColumn], + ); const { selectedForQuery: parentSelectedForQuery, hiddenColumns: hiddenParentColumns } = augmentSelectionForJoinColumns(state.selectedFields, parentJoinColumns); const compiled = compileSelectWithIncludes( diff --git a/packages/3-extensions/sql-orm-client/src/collection.ts b/packages/3-extensions/sql-orm-client/src/collection.ts index ece7a32f10..5c33cd137f 100644 --- a/packages/3-extensions/sql-orm-client/src/collection.ts +++ b/packages/3-extensions/sql-orm-client/src/collection.ts @@ -481,6 +481,7 @@ export class Collection< targetColumn: relation.targetColumn, localColumn: relation.localColumn, cardinality: relation.cardinality, + ...(relation.through !== undefined ? { through: relation.through } : {}), nested: nestedState, scalar: scalarSelector, combine: combineBranches, diff --git a/packages/3-extensions/sql-orm-client/src/query-plan-select.ts b/packages/3-extensions/sql-orm-client/src/query-plan-select.ts index 7d04f8bdce..bf1ec1599b 100644 --- a/packages/3-extensions/sql-orm-client/src/query-plan-select.ts +++ b/packages/3-extensions/sql-orm-client/src/query-plan-select.ts @@ -25,6 +25,7 @@ import { } from '@prisma-next/sql-relational-core/ast'; import { codecRefForStorageColumn } from '@prisma-next/sql-relational-core/codec-descriptor-registry'; import type { SqlQueryPlan } from '@prisma-next/sql-relational-core/plan'; +import { castAs } from '@prisma-next/utils/casts'; import { ifDefined } from '@prisma-next/utils/defined'; import { type PolymorphismInfo, @@ -292,6 +293,53 @@ function buildNestedIncludeArtifacts( return { projections }; } +/** + * Build the correlated WHERE and junction JOIN artifacts for a many-to-many + * include. The resulting WHERE correlates the junction to the parent rows + * (AND-ed across all column pairs for composite keys). The junction JOIN + * connects child rows to the junction via the child columns. + */ +function buildManyToManyJunctionArtifacts( + parentTableName: string, + childTableRef: string, + through: NonNullable, +): { + readonly whereExpr: AnyExpression; + readonly junctionJoin: JoinAst; +} { + const { + table: junctionTable, + parentColumns, + childColumns, + targetColumns, + parentLocalColumns, + } = through; + + const joinOnPairs = childColumns.map((junctionCol, i) => + BinaryExpr.eq( + ColumnRef.of(junctionTable, junctionCol), + ColumnRef.of(childTableRef, targetColumns[i] ?? junctionCol), + ), + ); + const joinOn: AnyExpression = + joinOnPairs.length === 1 ? castAs(joinOnPairs[0]!) : AndExpr.of(joinOnPairs); + + const correlationPairs = parentColumns.map((junctionCol, i) => + BinaryExpr.eq( + ColumnRef.of(junctionTable, junctionCol), + ColumnRef.of(parentTableName, parentLocalColumns[i] ?? junctionCol), + ), + ); + const whereExpr: AnyExpression = + correlationPairs.length === 1 + ? castAs(correlationPairs[0]!) + : AndExpr.of(correlationPairs); + + const junctionJoin = JoinAst.inner(TableSource.named(junctionTable), joinOn, false); + + return { whereExpr, junctionJoin }; +} + function buildIncludeChildRowsSelect( contract: Contract, parentTableName: string, @@ -327,11 +375,25 @@ function buildIncludeChildRowsSelect( const childWhere = buildStateWhere(contract, childTableRef, childState, { filterTableName: include.relatedTableName, }); - const joinExpr = BinaryExpr.eq( - ColumnRef.of(childTableRef, include.targetColumn), - ColumnRef.of(parentTableName, include.localColumn), - ); - const whereExpr = childWhere ? AndExpr.of([joinExpr, childWhere]) : joinExpr; + + let whereExpr: AnyExpression; + let junctionJoins: JoinAst[] = []; + + if (include.through !== undefined) { + const artifacts = buildManyToManyJunctionArtifacts( + parentTableName, + childTableRef, + include.through, + ); + whereExpr = childWhere ? AndExpr.of([artifacts.whereExpr, childWhere]) : artifacts.whereExpr; + junctionJoins = [artifacts.junctionJoin]; + } else { + const joinExpr = BinaryExpr.eq( + ColumnRef.of(childTableRef, include.targetColumn), + ColumnRef.of(parentTableName, include.localColumn), + ); + whereExpr = childWhere ? AndExpr.of([joinExpr, childWhere]) : joinExpr; + } // `distinct()` on a non-leaf include cannot be lowered as // `SELECT DISTINCT , json_agg() FROM ...`: @@ -359,6 +421,7 @@ function buildIncludeChildRowsSelect( hiddenOrderProjection, aggregateOrderBy, whereExpr, + junctionJoins, }); } @@ -392,6 +455,10 @@ function buildIncludeChildRowsSelect( .withProjection([...childProjection, ...hiddenOrderProjection]) .withWhere(whereExpr); + if (junctionJoins.length > 0) { + childRows = childRows.withJoins(junctionJoins); + } + if (childState.distinctOn && childState.distinctOn.length > 0) { childRows = childRows.withDistinctOn( childState.distinctOn.map((column) => ColumnRef.of(childTableRef, column)), @@ -454,6 +521,7 @@ function buildDistinctNonLeafChildRowsSelect(options: { readonly hiddenOrderProjection: ReadonlyArray; readonly aggregateOrderBy: ReadonlyArray | undefined; readonly whereExpr: AnyExpression; + readonly junctionJoins: ReadonlyArray; }): { readonly childRows: SelectAst; readonly childProjection: ReadonlyArray; @@ -470,6 +538,7 @@ function buildDistinctNonLeafChildRowsSelect(options: { hiddenOrderProjection, aggregateOrderBy, whereExpr, + junctionJoins, } = options; const childState = include.nested; @@ -511,9 +580,12 @@ function buildDistinctNonLeafChildRowsSelect(options: { selectedForQuery, childTableRef, ); - const baseInner = SelectAst.from(TableSource.named(include.relatedTableName, childTableAlias)) + let baseInner = SelectAst.from(TableSource.named(include.relatedTableName, childTableAlias)) .withProjection([...innerScalarProjection, ...hiddenOrderProjection]) .withWhere(whereExpr); + if (junctionJoins.length > 0) { + baseInner = baseInner.withJoins(junctionJoins); + } // `childState.distinct` is non-empty by the `isDistinctNonLeaf` guard // at the only caller (`buildIncludeChildRowsSelect`); assert here so diff --git a/packages/3-extensions/sql-orm-client/src/types.ts b/packages/3-extensions/sql-orm-client/src/types.ts index 458a5b0d38..4550b168f3 100644 --- a/packages/3-extensions/sql-orm-client/src/types.ts +++ b/packages/3-extensions/sql-orm-client/src/types.ts @@ -50,6 +50,18 @@ export interface IncludeCombine> readonly branches: Readonly>; } +export interface IncludeThroughDescriptor { + readonly table: string; + /** FK columns in the junction table that point to the parent. */ + readonly parentColumns: readonly string[]; + /** FK columns in the junction table that point to the target (child). */ + readonly childColumns: readonly string[]; + /** PK columns in the target table that the junction's childColumns reference. */ + readonly targetColumns: readonly string[]; + /** Resolved column names in the parent table that junction.parentColumns reference. */ + readonly parentLocalColumns: readonly string[]; +} + export interface IncludeExpr { readonly relationName: string; readonly relatedModelName: string; @@ -57,6 +69,7 @@ export interface IncludeExpr { readonly targetColumn: string; readonly localColumn: string; readonly cardinality: RelationCardinalityTag | undefined; + readonly through?: IncludeThroughDescriptor; readonly nested: CollectionState; readonly scalar: IncludeScalar | undefined; readonly combine: Readonly> | undefined; diff --git a/packages/3-extensions/sql-orm-client/test/fixtures/generated/contract.d.ts b/packages/3-extensions/sql-orm-client/test/fixtures/generated/contract.d.ts index cee860f56b..d14d318a38 100644 --- a/packages/3-extensions/sql-orm-client/test/fixtures/generated/contract.d.ts +++ b/packages/3-extensions/sql-orm-client/test/fixtures/generated/contract.d.ts @@ -40,7 +40,7 @@ import type { } from '@prisma-next/contract/types'; export type StorageHash = - StorageHashBase<'sha256:0c33777620981b67b3bf40871bae60c1a07e006e24f36a8f7b133263d9d5c541'>; + StorageHashBase<'sha256:e05ccf77cd36bcf34f470cae1638a9bda152e054c982497ee262f1f2d3d021d6'>; export type ExecutionHash = ExecutionHashBase<'sha256:a108e5f9b4a5af51635ffde3849836fee73cd71cd568e1f2daf236c5768bcb07'>; export type ProfileHash = @@ -94,6 +94,10 @@ export type FieldOutputTypes = { readonly invitedById: CodecTypes['pg/int4@1']['output'] | null; readonly address: AddressOutput | null; }; + readonly UserTag: { + readonly userId: CodecTypes['pg/int4@1']['output']; + readonly tagId: CodecTypes['pg/text@1']['output']; + }; }; export type FieldInputTypes = { readonly Article: { @@ -129,6 +133,10 @@ export type FieldInputTypes = { readonly invitedById: CodecTypes['pg/int4@1']['input'] | null; readonly address: AddressInput | null; }; + readonly UserTag: { + readonly userId: CodecTypes['pg/int4@1']['input']; + readonly tagId: CodecTypes['pg/text@1']['input']; + }; }; export type TypeMaps = TypeMapsType< CodecTypes, @@ -312,6 +320,24 @@ type ContractBase = Omit< indexes: readonly []; foreignKeys: readonly []; }; + readonly user_tags: { + columns: { + readonly user_id: { + readonly nativeType: 'int4'; + readonly codecId: 'pg/int4@1'; + readonly nullable: false; + }; + readonly tag_id: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + }; + primaryKey: { readonly columns: readonly ['user_id', 'tag_id'] }; + uniques: readonly []; + indexes: readonly []; + foreignKeys: readonly []; + }; readonly users: { columns: { readonly id: { @@ -595,6 +621,14 @@ type ContractBase = Omit< readonly targetFields: readonly ['userId']; }; }; + readonly tags: { + readonly to: { readonly namespace: 'public' & NamespaceId; readonly model: 'Tag' }; + readonly cardinality: 'N:M'; + readonly on: { + readonly localFields: readonly ['id']; + readonly targetFields: readonly ['user_id']; + }; + }; }; readonly storage: { readonly table: 'users'; @@ -607,6 +641,26 @@ type ContractBase = Omit< }; }; }; + readonly UserTag: { + readonly fields: { + readonly userId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; + readonly tagId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'user_tags'; + readonly fields: { + readonly userId: { readonly column: 'user_id' }; + readonly tagId: { readonly column: 'tag_id' }; + }; + }; + }; } >, 'roots' | 'domain' @@ -620,6 +674,7 @@ type ContractBase = Omit< readonly profiles: { readonly namespace: 'public' & NamespaceId; readonly model: 'Profile' }; readonly articles: { readonly namespace: 'public' & NamespaceId; readonly model: 'Article' }; readonly tags: { readonly namespace: 'public' & NamespaceId; readonly model: 'Tag' }; + readonly user_tags: { readonly namespace: 'public' & NamespaceId; readonly model: 'UserTag' }; }; readonly domain: { readonly namespaces: { @@ -860,6 +915,14 @@ type ContractBase = Omit< readonly targetFields: readonly ['userId']; }; }; + readonly tags: { + readonly to: { readonly namespace: 'public' & NamespaceId; readonly model: 'Tag' }; + readonly cardinality: 'N:M'; + readonly on: { + readonly localFields: readonly ['id']; + readonly targetFields: readonly ['user_id']; + }; + }; }; readonly storage: { readonly table: 'users'; @@ -872,6 +935,26 @@ type ContractBase = Omit< }; }; }; + readonly UserTag: { + readonly fields: { + readonly userId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; + readonly tagId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'user_tags'; + readonly fields: { + readonly userId: { readonly column: 'user_id' }; + readonly tagId: { readonly column: 'tag_id' }; + }; + }; + }; }; readonly valueObjects: { readonly Address: { diff --git a/packages/3-extensions/sql-orm-client/test/fixtures/generated/contract.json b/packages/3-extensions/sql-orm-client/test/fixtures/generated/contract.json index 25403b2b3a..a3f175cf41 100644 --- a/packages/3-extensions/sql-orm-client/test/fixtures/generated/contract.json +++ b/packages/3-extensions/sql-orm-client/test/fixtures/generated/contract.json @@ -24,6 +24,10 @@ "model": "Tag", "namespace": "public" }, + "user_tags": { + "model": "UserTag", + "namespace": "public" + }, "users": { "model": "User", "namespace": "public" @@ -410,6 +414,33 @@ "model": "Profile", "namespace": "public" } + }, + "tags": { + "cardinality": "N:M", + "on": { + "localFields": [ + "id" + ], + "targetFields": [ + "user_id" + ] + }, + "through": { + "childColumns": [ + "tag_id" + ], + "parentColumns": [ + "user_id" + ], + "table": "user_tags", + "targetColumns": [ + "id" + ] + }, + "to": { + "model": "Tag", + "namespace": "public" + } } }, "storage": { @@ -432,6 +463,36 @@ }, "table": "users" } + }, + "UserTag": { + "fields": { + "tagId": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "userId": { + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "fields": { + "tagId": { + "column": "tag_id" + }, + "userId": { + "column": "user_id" + } + }, + "table": "user_tags" + } } }, "valueObjects": { @@ -685,6 +746,29 @@ } ] }, + "user_tags": { + "columns": { + "tag_id": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "user_id": { + "codecId": "pg/int4@1", + "nativeType": "int4", + "nullable": false + } + }, + "foreignKeys": [], + "indexes": [], + "primaryKey": { + "columns": [ + "user_id", + "tag_id" + ] + }, + "uniques": [] + }, "users": { "columns": { "address": { @@ -750,7 +834,7 @@ } } }, - "storageHash": "sha256:0c33777620981b67b3bf40871bae60c1a07e006e24f36a8f7b133263d9d5c541" + "storageHash": "sha256:e05ccf77cd36bcf34f470cae1638a9bda152e054c982497ee262f1f2d3d021d6" }, "execution": { "executionHash": "sha256:a108e5f9b4a5af51635ffde3849836fee73cd71cd568e1f2daf236c5768bcb07", diff --git a/packages/3-extensions/sql-orm-client/test/helpers.ts b/packages/3-extensions/sql-orm-client/test/helpers.ts index 26fee9d55a..35fefb2cf8 100644 --- a/packages/3-extensions/sql-orm-client/test/helpers.ts +++ b/packages/3-extensions/sql-orm-client/test/helpers.ts @@ -288,6 +288,146 @@ export function buildStiPolyContract(): TestContract { return raw as TestContract; } +type RawColumn = { nativeType: string; codecId: string; nullable: boolean; default?: unknown }; + +/** + * Builds a minimal M:N contract with Parent ↔ Child via a junction table. + * Used by unit tests that assert the correlated subquery shape for M:N includes. + * + * `localFields` defaults to `['id']` (single-column parent PK). For composite-key parents, + * pass `localFields: ['tenant_id', 'id']` — each entry resolves positionally to the + * corresponding junction `parentColumns` entry. + */ +export function buildManyToManyContract(opts: { + junctionTable: string; + parentColumns: string[]; + childColumns: string[]; + targetColumns: string[]; + localFields?: string[]; + extraColumns?: Record; +}): FrameworkContract { + const { + junctionTable, + parentColumns, + childColumns, + targetColumns, + localFields = ['id'], + extraColumns = {}, + } = opts; + + const junctionStorageColumns: Record = {}; + for (const col of parentColumns) { + junctionStorageColumns[col] = { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }; + } + for (const col of childColumns) { + junctionStorageColumns[col] = { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }; + } + for (const [name, col] of Object.entries(extraColumns)) { + junctionStorageColumns[name] = col; + } + + const parentStorageColumns: Record = {}; + for (const col of localFields) { + parentStorageColumns[col] = { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }; + } + + const parentStorageFields: Record = {}; + for (const col of localFields) { + parentStorageFields[col] = { column: col }; + } + + const parentFields: Record< + string, + { nullable: boolean; type: { kind: string; codecId: string } } + > = {}; + for (const col of localFields) { + parentFields[col] = { nullable: false, type: { kind: 'scalar', codecId: 'pg/int4@1' } }; + } + + return { + domain: { + namespaces: { + public: { + id: 'public', + models: { + Parent: { + fields: parentFields, + relations: { + children: { + to: { model: 'Child', namespace: 'public' }, + cardinality: 'N:M', + on: { localFields, targetFields: targetColumns }, + through: { + table: junctionTable, + parentColumns, + childColumns, + targetColumns, + }, + }, + }, + storage: { table: 'parents', fields: parentStorageFields }, + }, + Child: { + fields: Object.fromEntries( + targetColumns.map((col) => [ + col, + { nullable: false, type: { kind: 'scalar', codecId: 'pg/int4@1' } }, + ]), + ), + relations: {}, + storage: { + table: 'children', + fields: Object.fromEntries(targetColumns.map((col) => [col, { column: col }])), + }, + }, + Junction: { + fields: {}, + relations: {}, + storage: { table: junctionTable, fields: {} }, + }, + }, + }, + }, + }, + storage: { + namespaces: { + public: { + id: 'public', + tables: { + parents: { + columns: parentStorageColumns, + primaryKey: { columns: localFields }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + children: { + columns: Object.fromEntries( + targetColumns.map((col) => [ + col, + { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, + ]), + ), + primaryKey: { columns: targetColumns }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + [junctionTable]: { + columns: junctionStorageColumns, + primaryKey: { columns: [...parentColumns, ...childColumns] }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + }, + }, + }, + }, + capabilities: {}, + } as unknown as FrameworkContract; +} + export function createMockRuntime(): MockRuntime { const executions: MockExecution[] = []; let nextResult: Record[][] = []; diff --git a/packages/3-extensions/sql-orm-client/test/query-plan-select.test.ts b/packages/3-extensions/sql-orm-client/test/query-plan-select.test.ts index dea944e88d..2604c0daa8 100644 --- a/packages/3-extensions/sql-orm-client/test/query-plan-select.test.ts +++ b/packages/3-extensions/sql-orm-client/test/query-plan-select.test.ts @@ -21,10 +21,10 @@ import { } from '@prisma-next/sql-relational-core/ast'; import { describe, expect, it } from 'vitest'; import { compileSelect, compileSelectWithIncludes } from '../src/query-plan-select'; -import { emptyState } from '../src/types'; +import { emptyState, type IncludeExpr } from '../src/types'; import { bindWhereExpr } from '../src/where-binding'; import { baseContract, createCollection, createCollectionFor } from './collection-fixtures'; -import { buildMixedPolyContract, isSelectAst } from './helpers'; +import { buildManyToManyContract, buildMixedPolyContract, isSelectAst } from './helpers'; import { unboundTables } from './unbound-tables'; function codecForColumn(table: string, column: string): string { @@ -620,6 +620,365 @@ describe('compileSelectWithIncludes', () => { }); }); +describe('M:N include correlated subquery', () => { + function buildManyToManyIncludeExpr(opts: { + junctionTable: string; + parentColumns: string[]; + childColumns: string[]; + targetColumns: string[]; + parentLocalColumns?: string[]; + }): IncludeExpr { + const parentLocalColumns = opts.parentLocalColumns ?? ['id']; + return { + relationName: 'children', + relatedModelName: 'Child', + relatedTableName: 'children', + targetColumn: opts.targetColumns[0] ?? 'id', + localColumn: parentLocalColumns[0] ?? 'id', + cardinality: 'N:M', + through: { + table: opts.junctionTable, + parentColumns: opts.parentColumns, + childColumns: opts.childColumns, + targetColumns: opts.targetColumns, + parentLocalColumns, + }, + nested: emptyState(), + scalar: undefined, + combine: undefined, + }; + } + + it('compiles a single-column M:N include to one correlated subquery through the junction', () => { + const contract = buildManyToManyContract({ + junctionTable: 'parent_child', + parentColumns: ['parent_id'], + childColumns: ['child_id'], + targetColumns: ['id'], + }); + + const include = buildManyToManyIncludeExpr({ + junctionTable: 'parent_child', + parentColumns: ['parent_id'], + childColumns: ['child_id'], + targetColumns: ['id'], + parentLocalColumns: ['id'], + }); + + const state = { ...emptyState(), includes: [include] }; + const plan = compileSelectWithIncludes(contract, 'parents', state); + + expectSelectAst(plan.ast); + // Single top-level subquery projection — no multiple executions + const childrenProjection = plan.ast.projection.find((item) => item.alias === 'children'); + expectSubqueryExpr(childrenProjection?.expr); + + // Outer aggregate: FROM (child rows derived table) + const aggregateQuery = childrenProjection.expr.query; + expectDerivedTableSource(aggregateQuery.from); + expect(aggregateQuery.from.alias).toBe('children__rows'); + + // Inner child rows SELECT + const childRowsSelect = aggregateQuery.from.query; + expect(childRowsSelect.from).toBeInstanceOf(TableSource); + + // The child SELECT has exactly one inner join to the junction — no LATERAL + expect(childRowsSelect.joins).toHaveLength(1); + const junctionJoin = childRowsSelect.joins![0]!; + expect(junctionJoin.joinType).toBe('inner'); + expect(junctionJoin.lateral).toBe(false); + expect(junctionJoin.source).toBeInstanceOf(TableSource); + expect((junctionJoin.source as TableSource).name).toBe('parent_child'); + + // JOIN ON: junction.child_id = children.id + expect(junctionJoin.on).toEqual( + BinaryExpr.eq(ColumnRef.of('parent_child', 'child_id'), ColumnRef.of('children', 'id')), + ); + + // WHERE correlates junction to parent: parent_child.parent_id = parents.id + expect(childRowsSelect.where).toEqual( + BinaryExpr.eq(ColumnRef.of('parent_child', 'parent_id'), ColumnRef.of('parents', 'id')), + ); + }); + + it('AND-s across all column pairs for a composite-key M:N junction', () => { + const contract = buildManyToManyContract({ + junctionTable: 'parent_child', + parentColumns: ['tenant_id', 'parent_id'], + childColumns: ['tenant_id', 'child_id'], + targetColumns: ['tenant_id', 'id'], + localFields: ['tenant_id', 'id'], + }); + + const include = buildManyToManyIncludeExpr({ + junctionTable: 'parent_child', + parentColumns: ['tenant_id', 'parent_id'], + childColumns: ['tenant_id', 'child_id'], + targetColumns: ['tenant_id', 'id'], + parentLocalColumns: ['tenant_id', 'id'], + }); + + const state = { ...emptyState(), includes: [include] }; + const plan = compileSelectWithIncludes(contract, 'parents', state); + + expectSelectAst(plan.ast); + const childrenProjection = plan.ast.projection.find((item) => item.alias === 'children'); + expectSubqueryExpr(childrenProjection?.expr); + + const childRowsSelect = childrenProjection.expr.query.from; + expectDerivedTableSource(childRowsSelect); + const childSelect = childRowsSelect.query; + + // JOIN ON: AND(junction.tenant_id = children.tenant_id, junction.child_id = children.id) + expect(childSelect.joins).toHaveLength(1); + const junctionJoin = childSelect.joins![0]!; + expect(junctionJoin.lateral).toBe(false); + expect(junctionJoin.on).toEqual( + AndExpr.of([ + BinaryExpr.eq( + ColumnRef.of('parent_child', 'tenant_id'), + ColumnRef.of('children', 'tenant_id'), + ), + BinaryExpr.eq(ColumnRef.of('parent_child', 'child_id'), ColumnRef.of('children', 'id')), + ]), + ); + + // WHERE: AND(parent_child.tenant_id = parents.tenant_id, parent_child.parent_id = parents.id) + expect(childSelect.where).toEqual( + AndExpr.of([ + BinaryExpr.eq( + ColumnRef.of('parent_child', 'tenant_id'), + ColumnRef.of('parents', 'tenant_id'), + ), + BinaryExpr.eq(ColumnRef.of('parent_child', 'parent_id'), ColumnRef.of('parents', 'id')), + ]), + ); + }); + + it('FK include path is unchanged (no regression)', () => { + const { collection } = createCollection(); + const state = collection.include('posts').state; + + const plan = compileSelectWithIncludes(baseContract, 'users', state); + expectSelectAst(plan.ast); + + const postsProjection = plan.ast.projection.find((item) => item.alias === 'posts'); + expectSubqueryExpr(postsProjection?.expr); + + const childRowsSelect = postsProjection.expr.query.from; + expectDerivedTableSource(childRowsSelect); + const childSelect = childRowsSelect.query; + + // FK path: no JOIN to junction, just WHERE on child FK + expect(childSelect.joins ?? []).toHaveLength(0); + expect(childSelect.where).toEqual( + BinaryExpr.eq(ColumnRef.of('posts', 'user_id'), ColumnRef.of('users', 'id')), + ); + }); + + // M:N + distinct(cols) + nested non-leaf include exercises + // `buildDistinctNonLeafChildRowsSelect` which applies `junctionJoins` to + // `baseInner` — the innermost scalar SELECT inside the ROW_NUMBER wrap. + // This test verifies the junction join attaches to `baseInner` (not to the + // dedup wrapper or the outer distinct SELECT) and that the correlated WHERE + // is present at that same level. + it('attaches junction join to baseInner in M:N + distinct + nested non-leaf path', () => { + // Contract: parents -[M:N via parent_child]-> children (has `name` column), + // children -[1:N FK]-> grandchildren (child_id → id). + // We inline the contract to give `children` a `name` column for distinct. + const contract = { + domain: { + namespaces: { + public: { + id: 'public', + models: { + Parent: { + fields: { id: { nullable: false, type: { kind: 'scalar', codecId: 'pg/int4@1' } } }, + relations: { + children: { + to: { model: 'Child', namespace: 'public' }, + cardinality: 'N:M', + on: { localFields: ['id'], targetFields: ['id'] }, + through: { + table: 'parent_child', + parentColumns: ['parent_id'], + childColumns: ['child_id'], + targetColumns: ['id'], + }, + }, + }, + storage: { table: 'parents', fields: { id: { column: 'id' } } }, + }, + Child: { + fields: { + id: { nullable: false, type: { kind: 'scalar', codecId: 'pg/int4@1' } }, + name: { nullable: false, type: { kind: 'scalar', codecId: 'pg/text@1' } }, + }, + relations: {}, + storage: { + table: 'children', + fields: { id: { column: 'id' }, name: { column: 'name' } }, + }, + }, + Grandchild: { + fields: { + id: { nullable: false, type: { kind: 'scalar', codecId: 'pg/int4@1' } }, + child_id: { nullable: false, type: { kind: 'scalar', codecId: 'pg/int4@1' } }, + }, + relations: {}, + storage: { + table: 'grandchildren', + fields: { + id: { column: 'id' }, + child_id: { column: 'child_id' }, + }, + }, + }, + }, + }, + }, + }, + storage: { + namespaces: { + public: { + id: 'public', + tables: { + parents: { + columns: { id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false } }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + children: { + columns: { + id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, + name: { nativeType: 'text', codecId: 'pg/text@1', nullable: false }, + }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + grandchildren: { + columns: { + id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, + child_id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, + }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + parent_child: { + columns: { + parent_id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, + child_id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, + }, + primaryKey: { columns: ['parent_id', 'child_id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + }, + }, + }, + }, + capabilities: {}, + }; + + // Grandchild FK include: children.id → grandchildren.child_id + const grandchildInclude: IncludeExpr = { + relationName: 'grandchildren', + relatedModelName: 'Grandchild', + relatedTableName: 'grandchildren', + targetColumn: 'child_id', + localColumn: 'id', + cardinality: '1:N', + nested: emptyState(), + scalar: undefined, + combine: undefined, + }; + + // M:N include: parents → children via parent_child, with distinct('name') + // and a nested non-leaf grandchild include — exercises + // buildDistinctNonLeafChildRowsSelect. + const include: IncludeExpr = { + relationName: 'children', + relatedModelName: 'Child', + relatedTableName: 'children', + targetColumn: 'id', + localColumn: 'id', + cardinality: 'N:M', + through: { + table: 'parent_child', + parentColumns: ['parent_id'], + childColumns: ['child_id'], + targetColumns: ['id'], + parentLocalColumns: ['id'], + }, + nested: { + ...emptyState(), + distinct: ['name'], + includes: [grandchildInclude], + }, + scalar: undefined, + combine: undefined, + }; + + const state = { ...emptyState(), includes: [include] }; + // Cast: inline contract literal is structurally compatible but lacks + // generated nominal types; the cast is local to this test. + const plan = compileSelectWithIncludes( + contract as unknown as Parameters[0], + 'parents', + state, + ); + + expectSelectAst(plan.ast); + const childrenProjection = plan.ast.projection.find((item) => item.alias === 'children'); + expectSubqueryExpr(childrenProjection?.expr); + + // Aggregate wrapper: FROM (children__rows) + const aggregateQuery = childrenProjection.expr.query; + expectDerivedTableSource(aggregateQuery.from); + expect(aggregateQuery.from.alias).toBe('children__rows'); + + // Outer distinct SELECT: FROM (children__distinct) + const childRows = aggregateQuery.from.query; + expectDerivedTableSource(childRows.from); + expect(childRows.from.alias).toBe('children__distinct'); + + // ROW_NUMBER dedup wrapper: FROM (children__ranked) + const innerSelect = childRows.from.query; + expectDerivedTableSource(innerSelect.from); + expect(innerSelect.from.alias).toBe('children__ranked'); + + // baseInner: innermost scalar SELECT — junction join must be here + const baseInner = innerSelect.from.query; + + // Junction join attaches to baseInner, not the dedup wrapper or outer SELECT + expect(baseInner.joins).toHaveLength(1); + const junctionJoin = baseInner.joins![0]!; + expect(junctionJoin.joinType).toBe('inner'); + expect(junctionJoin.lateral).toBe(false); + expect(junctionJoin.source).toBeInstanceOf(TableSource); + expect((junctionJoin.source as TableSource).name).toBe('parent_child'); + expect(junctionJoin.on).toEqual( + BinaryExpr.eq(ColumnRef.of('parent_child', 'child_id'), ColumnRef.of('children', 'id')), + ); + + // Correlated WHERE is present at baseInner level + expect(baseInner.where).toEqual( + BinaryExpr.eq(ColumnRef.of('parent_child', 'parent_id'), ColumnRef.of('parents', 'id')), + ); + + // No junction join leaked to the dedup wrapper or outer distinct SELECT + expect(innerSelect.joins ?? []).toHaveLength(0); + expect(childRows.joins ?? []).toHaveLength(0); + }); +}); + describe('compileSelect MTI JOINs', () => { type AnyContract = { storage: { diff --git a/projects/sql-orm-many-to-many/learnings.md b/projects/sql-orm-many-to-many/learnings.md index 2144bc8b11..3d45eeaf62 100644 --- a/projects/sql-orm-many-to-many/learnings.md +++ b/projects/sql-orm-many-to-many/learnings.md @@ -9,3 +9,11 @@ This harness exposes no `SendMessage`/resume for spawned subagents — the `Agen ## Pre-existing `fixtures:check` env failure `pnpm fixtures:check` fails at `fixtures:emit` in this sandbox (CLI not on PATH / "Failed to load config" for sql-builder + sql-orm-client emit scripts) — pre-existing, not introduced (matches the TML-2729 gotcha). Additivity is verified instead via a direct golden git-diff (`git diff -- ':(glob)**/contract.json' …`); CI runs the real gate. Don't treat the local `fixtures:check` red as a dispatch failure. + +## PGlite/WASM JIT flakiness on broad integration runs + +Running the whole sql-orm-client integration suite at once (`cd test/integration && pnpm test test/sql-orm-client/`) can crash with V8 `jit_page.has_value()` (WASM JIT) failures — **pre-existing PGlite/Node env flakiness**, reproduces on the parent branch, not introduced by M:N work. Targeted reruns (per-file, or the same suite again) pass cleanly. Verify integration blast radius with targeted per-file runs; don't trust a single broad-run red. + +## Dispatch truncation recovery (no subagent resume) + +A substantial dispatch can exhaust the implementer's budget mid-work and return a truncated report with **uncommitted WIP** (happened on the slice-1 read path). Recovery: inspect `git status`/`git diff`, then dispatch a fresh continuation implementer pointed at the WIP with a focused completion brief (it commits the WIP + completion as one commit). Keep dispatches tight and tell implementers to implement-then-test-then-gate rather than over-explore (over-exploration is what burned the budget). diff --git a/projects/sql-orm-many-to-many/slices/01-correlated-read-through-junction/dispatches/01-fixture-m2n.md b/projects/sql-orm-many-to-many/slices/01-correlated-read-through-junction/dispatches/01-fixture-m2n.md new file mode 100644 index 0000000000..c4c8fa6260 --- /dev/null +++ b/projects/sql-orm-many-to-many/slices/01-correlated-read-through-junction/dispatches/01-fixture-m2n.md @@ -0,0 +1,35 @@ +# Brief: S1-D1 — integration fixture gains an M:N relation + +## Task + +The sql-orm-client integration fixture has no many-to-many relation, so M:N read tests have nothing to run against. Add a **User ↔ Tag** M:N relation through a **`UserTag`** junction model (`userId`, `tagId`, composite primary key, **no payload columns** — the canonical pure-junction case) to the integration fixture's **source schema**, then **re-emit** the generated `contract.json` + `contract.d.ts`. The `Tag` model already exists in the fixture (`id`, `name`); add the junction + the `rel.manyToMany` relation on `User` (and the reverse on `Tag` if the fixture convention declares both sides). + +Find the fixture source: the generated artifacts live under `test/integration/test/sql-orm-client/fixtures/generated/` (and/or `packages/3-extensions/sql-orm-client/test/fixtures/generated/`); the emit is wired via a `package.json` script (grep for `emit` / `contract emit`). Modify the **source**, not the generated files by hand; re-run the emit. + +## Scope + +**In:** the fixture source schema (add `UserTag` junction + User↔Tag M:N); the re-emitted `contract.json` + `contract.d.ts`. + +**Out:** any read/projection code (S1-D2); any test (S1-D3); other fixture relations. Do not hand-edit generated files except as the emitter produces them. + +## Completed when + +- [ ] The fixture source declares an M:N User↔Tag relation via a `UserTag` junction (composite PK `userId`,`tagId`); the relation emits with `cardinality: 'N:M'` and a populated `through { table, parentColumns, childColumns, targetColumns }`. +- [ ] `contract.json` + `contract.d.ts` are re-emitted from source and committed; the emitted M:N relation **round-trips `validateContract`**. +- [ ] Change is additive — existing fixture models/relations emit unchanged (verify by diffing the generated files: only the new junction + relation appear). + +## Standing instruction + +Stay focused: add exactly the pure-junction M:N relation and re-emit. No extra models, no payload columns (a payload-junction fixture is a later concern if a test needs it). + +## References + +- Slice spec: `projects/sql-orm-many-to-many/slices/01-correlated-read-through-junction/spec.md` (§ Open Questions fixes the User↔Tag shape). +- Slice 0 added `rel.manyToMany` validation; commit `f962fd47d` shows the emitted `through` shape. + +## Operational metadata + +- **Model tier:** mid (sonnet) — schema authoring + mechanical re-emit. +- **Branch:** `tml-2785-slice-1-correlated-read`. Explicit staging + `-s` sign-off. **Do not push.** +- **Time-box:** ~45 min. +- **Halt conditions:** the local `fixtures:emit` / `pnpm fixtures:check` fails on the pre-existing CLI-on-PATH / config env issue (known) — if so, emit via the most direct working path you can (e.g. the package's own emit script) and verify the generated `contract.json` by inspection + `validateContract`; note it. If re-emit is genuinely impossible in-sandbox, surface to the orchestrator (do not hand-fabricate `contract.json`). Halt if adding the relation forces touching read/test code. diff --git a/projects/sql-orm-many-to-many/slices/01-correlated-read-through-junction/dispatches/02-read-path.md b/projects/sql-orm-many-to-many/slices/01-correlated-read-through-junction/dispatches/02-read-path.md new file mode 100644 index 0000000000..54c0c987f9 --- /dev/null +++ b/projects/sql-orm-many-to-many/slices/01-correlated-read-through-junction/dispatches/02-read-path.md @@ -0,0 +1,42 @@ +# Brief: S1-D2 — read path correlates through the junction + +## Task + +Teach the correlated include read path to walk an M:N relation through its junction. When the resolved include relation carries `through` (surfaced by slice 0 on `ResolvedRelation`), `db.orm.User.include('tags')` must resolve to `tags: Tag[]` via a **single correlated subquery**: the child subquery selects from the **target** (`tags`) joined to the **junction** (`user_tags`) on `junction.childColumns = target.targetColumns`, correlated to the parent on `junction.parentColumns = parent`'s anchor key — i.e. the target rows whose PK appears in junction rows pointing at the current parent row. No `LATERAL`, no second query. + +Concretely: +1. `resolveIncludeRelation` (`collection-contract.ts`) surfaces the `through` descriptor onto its `ResolvedIncludeRelation` result. +2. `IncludeExpr` (`types.ts`) gains an optional `through?` mirroring the descriptor. +3. `buildCorrelatedIncludeProjection` (`query-plan-select.ts`) — today it correlates `child.targetColumn = parent.localColumn` for FK relations. Add the M:N branch: when `include.through` is present, build the child subquery against the target joined to the junction, with the parent correlation on the junction side. AND across all column pairs for composite keys. +4. Whatever include-child **decode / graft** is needed (`collection-dispatch.ts` / the include decode path) to assemble the aggregated `tags: Tag[]` under the relation key — mirror the existing FK include child handling. + +**Write unit tests first** (`query-plan-select.test.ts` or the nearest existing suite): assert the compiled AST for an M:N include is a single correlated subquery joining through the junction, with the composite-key correlation AND-ed, and **no `LATERAL`** node. Use a hand-built M:N contract (mirror slice 0's `buildManyToManyContract` resolver test, or the fixture). + +## Scope + +**In:** `resolveIncludeRelation` + `IncludeExpr.through` (`collection-contract.ts`, `types.ts`); the M:N branch in `buildCorrelatedIncludeProjection` + include-child decode (`query-plan-select.ts`, `collection-dispatch.ts`); unit tests for the compiled AST. + +**Out:** integration tests (S1-D3 — those run against the fixture/DB); filter EXISTS (slice 2); nested write (slice 3); any `IncludeExpr` change beyond carrying `through`; the FK include path (don't regress it). + +## Completed when + +- [ ] An M:N `include` compiles to a **single correlated subquery** walking parent → junction → target (composite-key correlation AND-ed); unit test asserts the AST shape and the **absence of any `LATERAL`** node. +- [ ] The FK (non-M:N) include path is unchanged (its existing unit tests still pass). +- [ ] Gate: `pnpm --filter @prisma-next/sql-orm-client typecheck` + `pnpm --filter @prisma-next/sql-orm-client test` green. + +## Standing instruction + +Stay focused on the M:N read correlation. The judgment site is the junction-join in the correlated builder — get it right; mirror the FK path's decode/aggregation rather than inventing a parallel one. + +## References + +- Slice spec: `projects/sql-orm-many-to-many/slices/01-correlated-read-through-junction/spec.md` (the parent→junction→target correlation shape). +- Slice 0: `ResolvedRelation.through` in `collection-contract.ts` (`{ table, parentColumns[], childColumns[], targetColumns[], requiredPayloadColumns[] }`). +- The FK correlated path in `buildCorrelatedIncludeProjection` (`query-plan-select.ts`) is the pattern to extend — find the `child.targetColumn = parent.localColumn` correlation site. + +## Operational metadata + +- **Model tier:** mid→orchestrator (sonnet) — this is the slice's design-judgment dispatch (correlated junction subquery). Take care; the AST mechanics are the hard part. +- **Branch:** `tml-2785-slice-1-correlated-read`. Explicit staging + `-s` sign-off. **Do not push.** +- **Time-box:** ~90 min. +- **Halt conditions (surface, don't work around):** the correlated builder cannot express the junction join without a **new AST primitive / a LATERAL** (would falsify the slice's "correlated-only through the junction" premise — surface to me); composite-key correlation needs data not present on `through`; surfacing `through` onto `IncludeExpr` forces touching an out-of-scope consumer. diff --git a/projects/sql-orm-many-to-many/slices/01-correlated-read-through-junction/dispatches/02-read-path.r2.md b/projects/sql-orm-many-to-many/slices/01-correlated-read-through-junction/dispatches/02-read-path.r2.md new file mode 100644 index 0000000000..094b0aaeb3 --- /dev/null +++ b/projects/sql-orm-many-to-many/slices/01-correlated-read-through-junction/dispatches/02-read-path.r2.md @@ -0,0 +1,46 @@ +# Brief: S1-D2 R2 — finish the read-path correlation (continue from WIP) + +## Situation + +The first R1 implementer ran out of budget mid-dispatch and **did not commit**. There is **uncommitted WIP** in the working tree (run `git status` + `git diff` to see it): + +- `src/types.ts` — `IncludeExpr` gained `through?` (likely done). +- `src/collection-contract.ts` — `resolveIncludeRelation` surfacing `through` (likely done). +- `src/collection.ts` — small change. +- `test/helpers.ts` — a `buildManyToManyContract` helper (was mid-edit when cut off — verify it's coherent). +- `test/query-plan-select.test.ts` — new M:N unit tests (written; the impl they assert is **missing**, so they currently fail). + +**What's missing (the core task):** `query-plan-select.ts` (`buildCorrelatedIncludeProjection`) was **not touched** — the M:N junction-correlation branch is not implemented. The include-child decode (`collection-dispatch.ts`) may also be needed. + +## Task + +**First, read the uncommitted diff** to understand what R1 left. Then finish the dispatch: + +1. Implement the M:N branch in `buildCorrelatedIncludeProjection` (`query-plan-select.ts`): when `include.through` is present, compile a **single correlated subquery** — target JOIN junction ON `junction.childColumns = target.targetColumns`, correlated to the parent on `junction.parentColumns = parent` anchor; AND across all column pairs for composite keys; **no `LATERAL`**. +2. Wire whatever include-child decode/graft is needed (`collection-dispatch.ts`) to aggregate `tags: Tag[]` — mirror the FK include path, don't fork it. +3. Reconcile the WIP test helper + unit tests so they pass and assert the AST shape (single correlated subquery through the junction, composite-key AND-ed, no `LATERAL`). If R1's test helper or tests are incoherent/incomplete, fix them. +4. Don't regress the FK include path. + +## Completed when + +- [ ] An M:N `include` compiles to a single correlated subquery through the junction (composite-key AND-ed); unit test asserts the AST + absence of `LATERAL`, and **passes**. +- [ ] FK include path unchanged (its tests pass). +- [ ] `pnpm --filter @prisma-next/sql-orm-client typecheck` + `test` green. +- [ ] Committed as **one coherent commit** (the WIP + your completion together), explicit staging + `-s` sign-off, **no push**. + +## Standing instruction + +Finish the goal: the M:N correlated read. Keep R1's coherent WIP; complete the missing builder/decode; make the suite green. No bare `as` casts (use `castAs`/`blindCast` if unavoidable). + +## References + +- R1 brief: `./02-read-path.md` (full task spec — the correlation shape). +- Slice spec: `projects/sql-orm-many-to-many/slices/01-correlated-read-through-junction/spec.md`. +- Slice 0 `ResolvedRelation.through` in `collection-contract.ts`. + +## Operational metadata + +- **Model tier:** sonnet. +- **Branch:** `tml-2785-slice-1-correlated-read`. The WIP is already on it (uncommitted). Explicit staging + `-s` sign-off; **do not push**. +- **Time-box:** ~75 min. To reduce truncation risk: implement the builder branch first, get the targeted unit test green, then run the package gate — don't over-explore. +- **Halt + surface to me:** if the correlated builder cannot express the junction join without a new AST primitive / `LATERAL` (falsifies the correlated-only premise); if R1's WIP is in a state you can't reconcile coherently (describe it, don't force it). diff --git a/projects/sql-orm-many-to-many/slices/01-correlated-read-through-junction/dispatches/02-read-path.r3.md b/projects/sql-orm-many-to-many/slices/01-correlated-read-through-junction/dispatches/02-read-path.r3.md new file mode 100644 index 0000000000..34790d0473 --- /dev/null +++ b/projects/sql-orm-many-to-many/slices/01-correlated-read-through-junction/dispatches/02-read-path.r3.md @@ -0,0 +1,34 @@ +# Brief: S1-D2 R3 — clear F1 + F2 + +The read-path commit `e587b433c` is structurally accepted (correct LATERAL-free M:N correlation; FK path intact). Two `should-fix` findings remain; both block dispatch close. + +## F1 (should-fix) — bare casts + +`packages/3-extensions/sql-orm-client/src/query-plan-select.ts`, in `buildManyToManyJunctionArtifacts`: two bare `as AnyExpression` casts — `(joinOnPairs[0] as AnyExpression)` and `(correlationPairs[0] as AnyExpression)`. Both are pure widenings (`BinaryExpr` is a member of `AnyExpression`). Replace with `castAs(…)` (import `castAs` from `@prisma-next/utils/casts`). + +## F2 (should-fix) — missing test for M:N + distinct + non-leaf + +`buildDistinctNonLeafChildRowsSelect` applies `junctionJoins` (lines ~582–587) but no test exercises **M:N + `.distinct(…)` + nested include** together. Add a unit test in `test/query-plan-select.test.ts`: build an M:N include whose `nested` state carries a `distinct` + a further (non-leaf) include, call `compileSelectWithIncludes`, and assert (a) the junction join attaches to the innermost `baseInner` SELECT and (b) the correlated WHERE is present at that level. Reuse/extend the existing `buildManyToManyContract` / `buildManyToManyIncludeExpr` helpers. The test must genuinely exercise the distinct-non-leaf branch (not the plain branch). + +## Completed when + +- [ ] No bare `as` casts in `buildManyToManyJunctionArtifacts` (only `castAs<…>`); `pnpm lint:casts` passes (no count increase from this branch). +- [ ] New M:N + distinct + non-leaf unit test added and **passing**, asserting the junction join + correlation at the inner select. +- [ ] `pnpm --filter @prisma-next/sql-orm-client typecheck` + `test` green. + +## Standing instruction + +Surgical: the two cast sites + one new test. Don't reshape the accepted builder logic. + +## References + +- Findings F1, F2 in `projects/sql-orm-many-to-many/reviews/code-review.md § Findings log` (exact locations + recommended actions). +- `.agents/rules/no-bare-casts.mdc`. +- R2 commit: `e587b433c`. + +## Operational metadata + +- **Model tier:** sonnet — surgical fix + one test. +- **Branch:** `tml-2785-slice-1-correlated-read`. New commit (do not amend `e587b433c`). Explicit staging + `-s` sign-off. **Do not push.** +- **Time-box:** ~40 min. +- **Halt:** if the M:N + distinct + non-leaf path turns out to be **incorrect** when you write the test (the test fails because the junction join is mis-placed), that's a real bug in `e587b433c` — surface it to me with the evidence rather than papering over it (it would mean F2 is a `must-fix`, not just missing coverage). diff --git a/projects/sql-orm-many-to-many/slices/01-correlated-read-through-junction/dispatches/03-integration-tests.md b/projects/sql-orm-many-to-many/slices/01-correlated-read-through-junction/dispatches/03-integration-tests.md new file mode 100644 index 0000000000..3d5cd37e41 --- /dev/null +++ b/projects/sql-orm-many-to-many/slices/01-correlated-read-through-junction/dispatches/03-integration-tests.md @@ -0,0 +1,44 @@ +# Brief: S1-D3 — M:N include integration tests (operator standard) + +## Task + +Prove the M:N include works end-to-end against the database, following the project's **integration-test standard**. The fixture (S1-D1) defines `User.tags` (→ `Tag` via `UserTag`); the read path (S1-D2) compiles the correlated junction subquery. Add integration tests under `test/integration/test/sql-orm-client/` (alongside `include.test.ts` / `nested-includes.test.ts`), using the existing harness (`withCollectionRuntime`, PGlite). Seed users, tags, and `user_tags` junction rows, then assert `db.orm.User.include('tags')` returns the expected shape. + +**The standard (all three apply):** +1. **Whole-row assertions** — `toEqual` (or snapshot) on the complete returned rows; never cherry-pick individual fields. +2. **Explicit `.select(...)` in most tests** — project the fields each test asserts (user-level and nested `tags`-level), so adding a field to `User`/`Tag` later doesn't churn these assertions. Assert the whole *selected* shape. +3. **≥1 implicit/default-selection test** — at least one test does `include('tags')` with **no** `.select`, asserting the full default row shape (all `User` fields + `tags: Tag[]` with all `Tag` fields) comes back. + +Plus: +- **Single execution** — assert the M:N include runs in **one** SQL execution (the harness's query-count/exec hook if available; otherwise assert no `LATERAL` in the emitted SQL). Mirror how `include.test.ts` / `nested-includes-strategy.test.ts` verify execution count if they do. +- **Depth-2** — a test that nests another include under the M:N read (or M:N nested under a 1:N), to prove the junction walk composes with deeper includes. +- Edge: a user with **no** tags returns `tags: []`; a tag connected to multiple users still resolves correctly. + +## Scope + +**In:** new integration test file(s) under `test/integration/test/sql-orm-client/`; any seed/helper needed there. + +**Out:** filter (slice 2); write (slice 3); production code changes (D2 owns the read path — if a test reveals a read **bug**, surface it, don't fix production here without flagging). Do not modify the fixture (D1 owns it). + +## Completed when + +- [ ] M:N `include('tags')` integration tests pass on PGlite: whole-row `toEqual`; **most** use explicit `.select`; **≥1** uses implicit/default selection; depth-2 covered; empty-tags and shared-tag cases covered. +- [ ] A single-execution assertion (no `LATERAL`, one query) for the M:N include. +- [ ] Gate: the new tests run green — `cd test/integration && pnpm test test/sql-orm-client/` (this is how the sql-orm-client integration suite runs in-sandbox; the CLI-journey e2e tests are the ones with the known env limitation, not these). + +## Standing instruction + +Match the existing integration corpus's style (whole-row `toEqual`); add the explicit-select-dominant + implicit-select cases the standard requires. If a test surfaces a real read-path bug, **surface it to me** with the failing assertion — that would be a `must-fix` against D2, not something to patch here. + +## References + +- Existing corpus: `test/integration/test/sql-orm-client/include.test.ts`, `nested-includes.test.ts`, `nested-includes-strategy.test.ts` (assertion + execution-count patterns to mirror). +- Slice spec: `projects/sql-orm-many-to-many/slices/01-correlated-read-through-junction/spec.md` (§ done conditions — the standard). +- Fixture: `User.tags` M:N via `UserTag` (commit `fcecac5b3`). + +## Operational metadata + +- **Model tier:** sonnet. +- **Branch:** `tml-2785-slice-1-correlated-read`. Explicit staging + `-s` sign-off. **Do not push.** +- **Time-box:** ~75 min — write the core whole-row + implicit-select tests first, get them green, then add depth-2/edge cases; don't over-explore. +- **Halt + surface to me:** if the sql-orm-client integration harness genuinely cannot run in-sandbox (PGlite spin-up failure unrelated to your tests), describe the failure — don't claim green without running. If `include('tags')` returns a wrong shape (read-path bug in D2), surface it. diff --git a/projects/sql-orm-many-to-many/slices/01-correlated-read-through-junction/plan.md b/projects/sql-orm-many-to-many/slices/01-correlated-read-through-junction/plan.md new file mode 100644 index 0000000000..74b8a88d73 --- /dev/null +++ b/projects/sql-orm-many-to-many/slices/01-correlated-read-through-junction/plan.md @@ -0,0 +1,31 @@ +# Slice 1: correlated read through the junction — Dispatch plan + +**Spec:** `projects/sql-orm-many-to-many/slices/01-correlated-read-through-junction/spec.md` +**Linear:** [TML-2785](https://linear.app/prisma-company/issue/TML-2785) + +Three dispatches. Fixture (data) and read-code (logic) are split from the integration tests (verification) so the projection-builder judgment isn't buried in the goldens fan-out (per `sizing.md`). Test-first throughout. + +### Dispatch 1: integration fixture gains an M:N relation + +- **Outcome:** the sql-orm-client integration fixture defines a **User ↔ Tag** M:N relation via a `UserTag` junction (`userId`, `tagId`, composite PK, no payload columns); `contract.json` + `contract.d.ts` are re-emitted and committed; the M:N relation round-trips `validateContract` (slice 0 made that possible). +- **Builds on:** slice 0's validatable M:N contract shape. +- **Hands to:** a committed integration fixture carrying a pure-junction M:N relation — the data foundation D3's integration tests and D2's reasoning use. +- **Focus:** add the relation + junction model to the fixture **source** schema; re-emit the generated `contract.json`/`contract.d.ts`. Additive to the fixture (existing models unchanged). Note the pre-existing `fixtures:emit` CLI-on-PATH env limitation — verify the re-emitted contract by inspecting its diff; CI runs the real gate. + +### Dispatch 2: read path correlates through the junction + +- **Outcome:** `buildCorrelatedIncludeProjection` (`query-plan-select.ts`) emits a **single correlated subquery** walking parent → junction → target when the resolved include relation carries `through` (`target JOIN junction ON junction.childColumns = target.targetColumns WHERE junction.parentColumns = parent`); `IncludeExpr` carries `through` (surfaced by `resolveIncludeRelation`); the include-child decode/graft assembles `tags: Tag[]`. Unit tests assert the correlated junction AST (composite-key AND-ed; **no `LATERAL`**). +- **Builds on:** slice 0's `ResolvedRelation.through` (not D1 — unit-tested against hand-built contracts). +- **Hands to:** an M:N include that resolves to `tags: Tag[]` in one execution, unit-proven at the AST level. +- **Focus:** `resolveIncludeRelation` + `IncludeExpr.through` (`collection-contract.ts`, `types.ts`); the M:N branch in `buildCorrelatedIncludeProjection` + child decode; `query-plan-select` unit tests. No integration tests here (D3). No filter/write surfaces. + +### Dispatch 3: M:N include integration tests (operator standard) + +- **Outcome:** integration tests prove `db.orm.User.include('tags')` returns `{ …user, tags: Tag[] }` on PGlite, following the standard — whole-row `toEqual`, explicit `.select(...)` in most cases, **≥1 implicit/default-selection** case for the nested M:N read, a **single-execution / no-`LATERAL`** assertion, and depth-2 nesting through the junction. +- **Builds on:** D1's fixture **and** D2's read path (non-linear — needs both hand-offs). +- **Hands to:** the slice-DoD-satisfying M:N read coverage; the fixture + patterns the filter/write slices reuse. +- **Focus:** new integration test file(s) alongside `include.test.ts` / `nested-includes.test.ts`, PGlite via `withCollectionRuntime` (SQLite too only if the harness already supports it). Match the existing whole-row assertion corpus; add the explicit-select-dominant + implicit-select cases the standard requires. + +## Handoff completeness + +Slice-DoD reachable: single-execution M:N include (D2 unit + D3 integration) · whole-row/explicit-select/implicit-select standard (D3) · fixture M:N committed (D1). D3's hand-off (working M:N read + fixture) is what slices 2/3 build on. diff --git a/projects/sql-orm-many-to-many/slices/01-correlated-read-through-junction/spec.md b/projects/sql-orm-many-to-many/slices/01-correlated-read-through-junction/spec.md new file mode 100644 index 0000000000..5c8e8fecfc --- /dev/null +++ b/projects/sql-orm-many-to-many/slices/01-correlated-read-through-junction/spec.md @@ -0,0 +1,56 @@ +# Slice 1: correlated include read through the junction + +_Parent project: `projects/sql-orm-many-to-many/`. Outcome this slice contributes: `include('tags')` returns `tags: Tag[]` for an M:N relation in one correlated query._ + +## At a glance + +Slice 0 made `ResolvedRelation.through` available. This slice teaches the read path to walk it: `db.orm.User.include('tags')` resolves an M:N relation to `{ …user, tags: Tag[] }` in a **single** SQL execution — one correlated subquery that hops parent → junction → target, no LATERAL, no multi-query. It also lands the first M:N **integration** coverage (and the M:N fixture the later slices reuse). + +## Chosen design + +**Carry `through` onto `IncludeExpr`, branch in the correlated projection builder.** + +- `resolveIncludeRelation` (`collection-contract.ts`) surfaces the slice-0 `through` descriptor onto the resolved include relation; `IncludeExpr` (`types.ts`) gains an optional `through?` mirroring it. +- `buildCorrelatedIncludeProjection` (`query-plan-select.ts`): today it correlates `child.targetColumn = parent.localColumn` directly. When `include.through` is present, the correlated subquery instead selects from the **target** joined to the **junction** (`junction.childColumns = target.targetColumns`), correlated to the parent on `junction.parentColumns = parent`'s anchor — i.e. the target rows are those whose PK appears in the junction rows pointing at this parent. Child rows aggregate under the relation key exactly as the FK case does; the outer query stays one execution. + +```ts +// FK case (today): target WHERE target.fk = parent.pk +// M:N case (this slice): target JOIN junction ON junction.child = target.pk +// WHERE junction.parent = parent.pk +``` + +**Integration tests + fixture.** The integration fixture (`test/integration/test/sql-orm-client/…`, PGlite via `withCollectionRuntime`) has no M:N relation today. This slice adds one to the fixture source — **User ↔ Tag** via a `UserTag` junction (`userId`, `tagId`) — and re-emits `contract.json` + `contract.d.ts`. Tests follow the project's **integration-test standard** (below). + +## Coherence rationale + +One reviewable story: "the include path reads an M:N relation through its junction, proven end-to-end on the database." The `IncludeExpr.through` plumbing, the projection-builder branch, the fixture M:N relation, and the integration tests are inseparable — the tests can't run without the fixture, and the plumbing is only meaningful once a query exercises it. + +## Scope + +**In:** `resolveIncludeRelation` + `IncludeExpr.through` (`collection-contract.ts`, `types.ts`); the M:N branch in `buildCorrelatedIncludeProjection` (`query-plan-select.ts`) and whatever decode/grafting the include child path needs to assemble `tags: Tag[]`; the integration fixture M:N relation + re-emit; M:N include integration tests. + +**Out:** filter EXISTS (slice 2); nested write (slice 3); any `IncludeExpr` change beyond carrying `through`; non-correlated strategies. + +## Pre-investigated edge cases + +| Edge case | Disposition | Notes | +|---|---|---| +| Composite-key junctions | `through.parentColumns`/`childColumns`/`targetColumns` are arrays — the correlation must AND across all column pairs, never assume a single column | Slice 0 surfaces arrays | +| Single SQL execution | A test must assert the M:N include resolves in **one** execution with **no `LATERAL`** keyword — pins the correlated-only intent (per TML-2729) | The repo dropped LATERAL/ multi-query; don't reintroduce | +| `fixtures:check` emit env limitation | Local `fixtures:emit` fails on a pre-existing CLI-on-PATH issue in this sandbox; verify the re-emitted fixture by inspecting the generated `contract.json` diff + rely on CI for the gate | Known from slice 0 / TML-2729 | + +## Slice-specific done conditions + +- [ ] `db.orm.User.include('tags')` returns `{ …user, tags: Tag[] }` for the M:N relation, asserted as a **whole row** (`toEqual`), in a **single** SQL execution (no `LATERAL`). +- [ ] Integration tests follow the standard: most use explicit `.select(...)` (whole-selected-row `toEqual`); **at least one** exercises implicit/default selection for the nested M:N read (full default `Tag` shape returns without an explicit select). Depth-2 nesting through the junction covered. +- [ ] The integration fixture defines an M:N relation (User↔Tag via `UserTag`) and the re-emitted `contract.json`/`contract.d.ts` are committed; `fixtures:check` reconciles (or the emit-env limitation is noted and additivity shown via diff). + +## Open Questions + +1. **Fixture M:N shape.** Working position: **User ↔ Tag** via an explicit `UserTag` junction model (`userId`, `tagId`, composite PK), no payload columns — the canonical pure-junction case. A second fixture relation with a composite or payload junction can be added if a test needs it, but the simple case is the baseline. + +## References + +- Parent project: `projects/sql-orm-many-to-many/spec.md` (§ Cross-cutting requirements — the integration-test standard). +- Slice 0: `../00-contract-resolver-foundation/spec.md` — the `ResolvedRelation.through` this builds on. +- Linear issue: [TML-2785](https://linear.app/prisma-company/issue/TML-2785) diff --git a/projects/sql-orm-many-to-many/spec.md b/projects/sql-orm-many-to-many/spec.md index a9887a273d..a0a921a3a3 100644 --- a/projects/sql-orm-many-to-many/spec.md +++ b/projects/sql-orm-many-to-many/spec.md @@ -51,7 +51,7 @@ The whole project hangs off one new primitive: a uniform **`through` descriptor* - **An M:N contract must emit and round-trip through `validateContract`.** This is the foundation every slice's integration fixture depends on — no validatable M:N contract exists today. (System-level because it's a prerequisite shared by all three consumer slices, not owned by any one of them.) - **The junction-walk is a single shared primitive.** The `through` descriptor is surfaced once, through the one `resolveModelRelations` → `ResolvedRelation` resolver that already feeds includes, filters, and mutations. Slices consume it differently but must not fork the resolution. - **Cardinality tag canonicalised on `'N:M'` repo-wide.** The contract, schema, PSL, and lowering already use `'N:M'`; the orm-client's lone `'M:N'` spelling is reconciled to it (not translated at a boundary). No `'M:N'`/`'N:M'` split survives. -- **PG + SQLite integration coverage** for every user-observable M:N path (read, filter, write). +- **Integration-test standard for every user-observable M:N path (read, filter, write).** Tests run against the existing sql-orm-client integration harness (PGlite via `withCollectionRuntime`; cover SQLite too only if the harness already supports it — do not build new SQLite infra). They (a) assert on the **whole returned row** via `.toEqual()` / snapshot — never cherry-pick individual fields; (b) use **explicit `.select(...)` projections in most tests** so adding a model field doesn't churn assertions; and (c) include **some tests exercising implicit (default) selection** for nested M:N reads, verifying the full default shape returns without an explicit select. The integration fixture has no M:N relation today — the read slice adds one (e.g. User↔Tag via a junction) to the fixture source + re-emits; later slices reuse it. - **Every merged slice leaves non-M:N paths green and the system deployable.** M:N support arrives incrementally; partial support must never regress existing cardinalities. ## Transitional-shape constraints diff --git a/projects/sql-orm-many-to-many/trace.jsonl b/projects/sql-orm-many-to-many/trace.jsonl index 6ad39e8ead..aa9bca7a50 100644 --- a/projects/sql-orm-many-to-many/trace.jsonl +++ b/projects/sql-orm-many-to-many/trace.jsonl @@ -22,3 +22,27 @@ {"event_id":"095349d4-6fad-464e-8c53-ef8814d481f0","schema_version":"1","ts":"2026-06-01T17:14:46.188Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"9c040cab-ed73-4389-8936-ed814aba6f41","round_id":"54cf9ccf-90c6-4a7a-b851-99e0ae941beb","brief_byte_length":2573,"brief_content_hash":"b8a8b0c9797b3391aac5fd30ad20e2fcddfc0fe2cd00474b0a91ed0121748a54","brief_disposition":"amended"} {"event_id":"3fbb1dda-cfa9-455a-add4-a9f2d2038601","schema_version":"1","ts":"2026-06-01T17:19:03.479Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"9c040cab-ed73-4389-8936-ed814aba6f41","round_id":"54cf9ccf-90c6-4a7a-b851-99e0ae941beb","verdict":"satisfied","findings_filed":0,"wall_clock_ms":257242} {"event_id":"8e000aa1-74f3-4cc3-9ce7-30a5b8b0e5c8","schema_version":"1","ts":"2026-06-01T17:19:03.882Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"dispatch-end","dispatch_id":"9c040cab-ed73-4389-8936-ed814aba6f41","result":"completed","wall_clock_ms":930188} +{"event_id":"50628ecd-e7cb-4e07-9bf5-df93530b0fc7","schema_version":"1","ts":"2026-06-01T17:48:50.869Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"spec-amended","spec_path":"projects/sql-orm-many-to-many/spec.md","spec_kind":"project","byte_length":11800,"bytes_delta":900,"edge_cases_count":null,"open_questions_count":1,"dod_items_count":7,"reason":"operator-correction","sections_changed":["Cross-cutting requirements"]} +{"event_id":"b71f9bf4-89b9-49e9-95d7-935838950489","schema_version":"1","ts":"2026-06-01T17:49:56.116Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"spec-authored","spec_path":"projects/sql-orm-many-to-many/slices/01-correlated-read-through-junction/spec.md","spec_kind":"slice","byte_length":5250,"edge_cases_count":3,"open_questions_count":1,"dod_items_count":3} +{"event_id":"e37836e1-adf8-4741-96d1-a9242461412a","schema_version":"1","ts":"2026-06-01T17:51:12.185Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"plan-authored","plan_path":"projects/sql-orm-many-to-many/slices/01-correlated-read-through-junction/plan.md","plan_kind":"slice","byte_length":3669,"dispatch_count":3,"slice_count":null,"dispatch_size_distribution":{"S":0,"M":2,"L":1,"XL":0},"open_items_count":0} +{"event_id":"8f8e693e-e9d4-4eba-bf0c-ee288a2333b2","schema_version":"1","ts":"2026-06-01T17:52:46.684Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"dispatch-start","dispatch_id":"7acc602c-afce-4e1c-be10-93d43dd4248f","dispatch_name":"S1-D1 integration fixture M:N","subagent_type":"general-purpose","model":"sonnet","parent_dispatch_id":null} +{"event_id":"12c25a6d-8df7-43d5-88d5-29f214686986","schema_version":"1","ts":"2026-06-01T17:52:47.556Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"7acc602c-afce-4e1c-be10-93d43dd4248f","round_id":"2c0d9ce0-a686-4c60-b20b-9cdbc96d233f","round_number":1} +{"event_id":"d3f747ba-c069-4550-bb84-33fa5c1dfe45","schema_version":"1","ts":"2026-06-01T17:52:48.621Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"7acc602c-afce-4e1c-be10-93d43dd4248f","round_id":"2c0d9ce0-a686-4c60-b20b-9cdbc96d233f","brief_byte_length":3068,"brief_content_hash":"0c48164d58bb4e58f337cb66afbedc6643b2edf77f5a267bbb663065c6f1387d","brief_disposition":"initial"} +{"event_id":"3dba0ca8-0708-474b-815a-f32c51d24167","schema_version":"1","ts":"2026-06-01T18:21:52.995Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"7acc602c-afce-4e1c-be10-93d43dd4248f","round_id":"2c0d9ce0-a686-4c60-b20b-9cdbc96d233f","verdict":"satisfied","findings_filed":0,"wall_clock_ms":1744908} +{"event_id":"16b89c62-c18c-4d10-89aa-6e070216ee39","schema_version":"1","ts":"2026-06-01T18:21:53.406Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"dispatch-end","dispatch_id":"7acc602c-afce-4e1c-be10-93d43dd4248f","result":"completed","wall_clock_ms":1745780} +{"event_id":"e17e60f7-ea8e-473d-8775-17be478bccbb","schema_version":"1","ts":"2026-06-01T18:22:35.627Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"dispatch-start","dispatch_id":"7cc850c1-836a-45c6-be43-02e95b4c09ad","dispatch_name":"S1-D2 read path correlates through junction","subagent_type":"general-purpose","model":"sonnet","parent_dispatch_id":null} +{"event_id":"8712f691-f8b9-494b-b545-7bcae6f93f5b","schema_version":"1","ts":"2026-06-01T18:22:36.037Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"7cc850c1-836a-45c6-be43-02e95b4c09ad","round_id":"d8dcf1a3-5e87-4ce5-a631-46b283207cf8","round_number":1} +{"event_id":"700e86ff-c2f2-4281-ad1e-c96c37271816","schema_version":"1","ts":"2026-06-01T18:22:36.446Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"7cc850c1-836a-45c6-be43-02e95b4c09ad","round_id":"d8dcf1a3-5e87-4ce5-a631-46b283207cf8","brief_byte_length":4279,"brief_content_hash":"af4cb1dac86ea7e3e013ba3fd2015bbfa3731cc26fe8b873086769f322400054","brief_disposition":"initial"} +{"event_id":"c486bd8c-eee6-4dfe-a710-d3aee88dc159","schema_version":"1","ts":"2026-06-01T18:32:47.761Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"7cc850c1-836a-45c6-be43-02e95b4c09ad","round_id":"d8dcf1a3-5e87-4ce5-a631-46b283207cf8","verdict":"another-round-needed","findings_filed":0,"wall_clock_ms":611230} +{"event_id":"e68dd00d-356c-4e12-ae35-22f3356ce47c","schema_version":"1","ts":"2026-06-01T18:32:48.252Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"7cc850c1-836a-45c6-be43-02e95b4c09ad","round_id":"72462a6b-3616-4847-8412-c6ce5658adbc","round_number":2} +{"event_id":"3f0c180b-902b-494a-92af-f2e05b2a9dba","schema_version":"1","ts":"2026-06-01T18:32:48.657Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"7cc850c1-836a-45c6-be43-02e95b4c09ad","round_id":"72462a6b-3616-4847-8412-c6ce5658adbc","brief_byte_length":3409,"brief_content_hash":"e124bbc1428ed4217ac82ccc109ac150b6f1b4ad727383048177d2da005587fc","brief_disposition":"amended"} +{"event_id":"9b7e7802-3e5d-4ca8-9d72-ddb39974b5b0","schema_version":"1","ts":"2026-06-01T18:44:29.832Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"7cc850c1-836a-45c6-be43-02e95b4c09ad","round_id":"72462a6b-3616-4847-8412-c6ce5658adbc","verdict":"another-round-needed","findings_filed":2,"wall_clock_ms":701084} +{"event_id":"e28329ab-91c8-48a1-9037-5c0bdf33f8ed","schema_version":"1","ts":"2026-06-01T18:44:30.324Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"7cc850c1-836a-45c6-be43-02e95b4c09ad","round_id":"f6bf92f9-c5c1-4af8-b747-469749fc827d","round_number":3} +{"event_id":"ea47bb4b-8b94-4a60-950f-6562b3c00d41","schema_version":"1","ts":"2026-06-01T18:44:30.706Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"7cc850c1-836a-45c6-be43-02e95b4c09ad","round_id":"f6bf92f9-c5c1-4af8-b747-469749fc827d","brief_byte_length":2626,"brief_content_hash":"093a2e3b5d66b3046fdcd249642d75582e3b902988248a2c2289b19fcab8f8cf","brief_disposition":"amended"} +{"event_id":"7ed64aff-8e88-4c94-8b5e-12261970e1f1","schema_version":"1","ts":"2026-06-01T18:52:26.344Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"7cc850c1-836a-45c6-be43-02e95b4c09ad","round_id":"f6bf92f9-c5c1-4af8-b747-469749fc827d","verdict":"satisfied","findings_filed":0,"wall_clock_ms":475566} +{"event_id":"1e0172e9-c865-4736-affc-737c7f55e4f4","schema_version":"1","ts":"2026-06-01T18:52:26.736Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"dispatch-end","dispatch_id":"7cc850c1-836a-45c6-be43-02e95b4c09ad","result":"completed","wall_clock_ms":1790263} +{"event_id":"e04f1fc8-f52b-4da7-b209-31d762d4668e","schema_version":"1","ts":"2026-06-01T18:53:03.473Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"dispatch-start","dispatch_id":"88c9c264-8edc-4584-b440-7d51eeae37e4","dispatch_name":"S1-D3 M:N include integration tests","subagent_type":"general-purpose","model":"sonnet","parent_dispatch_id":null} +{"event_id":"8c3be603-cab6-4dda-a6ad-9074b13ac0cd","schema_version":"1","ts":"2026-06-01T18:53:03.859Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"88c9c264-8edc-4584-b440-7d51eeae37e4","round_id":"a405f9a4-2407-4328-93f9-476c31d88c0e","round_number":1} +{"event_id":"b5530bd5-2ada-44bc-b65e-880aaff74187","schema_version":"1","ts":"2026-06-01T18:53:04.232Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"88c9c264-8edc-4584-b440-7d51eeae37e4","round_id":"a405f9a4-2407-4328-93f9-476c31d88c0e","brief_byte_length":4062,"brief_content_hash":"c0f7aa67226f403d7c10b578156ae5b5dd8141de924ed80e3b0e1aeec917ae3e","brief_disposition":"initial"} +{"event_id":"7f4f9ac3-eaf8-41df-b5df-cc72314030de","schema_version":"1","ts":"2026-06-01T19:14:43.687Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"88c9c264-8edc-4584-b440-7d51eeae37e4","round_id":"a405f9a4-2407-4328-93f9-476c31d88c0e","verdict":"satisfied","findings_filed":0,"wall_clock_ms":1299311} +{"event_id":"10edc1f4-e070-4dc6-b0ac-bf8eaf2565a7","schema_version":"1","ts":"2026-06-01T19:14:44.090Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"dispatch-end","dispatch_id":"88c9c264-8edc-4584-b440-7d51eeae37e4","result":"completed","wall_clock_ms":1299697} diff --git a/test/integration/test/sql-orm-client/fixtures/contract.ts b/test/integration/test/sql-orm-client/fixtures/contract.ts index c33ca089fd..eaefdf87b2 100644 --- a/test/integration/test/sql-orm-client/fixtures/contract.ts +++ b/test/integration/test/sql-orm-client/fixtures/contract.ts @@ -64,6 +64,17 @@ const Tag = model('Tag', { }, }).sql({ table: 'tags' }); +const UserTag = model('UserTag', { + fields: { + userId: field.column(int4Column).column('user_id'), + tagId: field.column(textColumn).column('tag_id'), + }, +}) + .attributes(({ fields, constraints }) => ({ + id: constraints.id([fields.userId, fields.tagId]), + })) + .sql({ table: 'user_tags' }); + const Post = PostBase.relations({ comments: rel.hasMany(() => Comment, { by: 'postId' }), author: rel.belongsTo(UserBase, { from: 'userId', to: 'id' }).sql({ fk: {} }), @@ -74,6 +85,11 @@ const User = UserBase.relations({ invitedBy: rel.belongsTo(UserBase, { from: 'invitedById', to: 'id' }).sql({ fk: {} }), posts: rel.hasMany(() => Post, { by: 'userId' }), profile: rel.hasOne(() => Profile, { by: 'userId' }), + tags: rel.manyToMany(() => Tag, { + through: () => UserTag, + from: 'userId', + to: 'tagId', + }), }).sql({ table: 'users' }); const baseContract = defineContract({ @@ -85,6 +101,7 @@ const baseContract = defineContract({ Profile, Article, Tag, + UserTag, }, }); diff --git a/test/integration/test/sql-orm-client/fixtures/generated/contract.d.ts b/test/integration/test/sql-orm-client/fixtures/generated/contract.d.ts index 13362b8c9c..14357a590d 100644 --- a/test/integration/test/sql-orm-client/fixtures/generated/contract.d.ts +++ b/test/integration/test/sql-orm-client/fixtures/generated/contract.d.ts @@ -36,7 +36,7 @@ import type { } from '@prisma-next/contract/types'; export type StorageHash = - StorageHashBase<'sha256:0c33777620981b67b3bf40871bae60c1a07e006e24f36a8f7b133263d9d5c541'>; + StorageHashBase<'sha256:e05ccf77cd36bcf34f470cae1638a9bda152e054c982497ee262f1f2d3d021d6'>; export type ExecutionHash = ExecutionHashBase<'sha256:a108e5f9b4a5af51635ffde3849836fee73cd71cd568e1f2daf236c5768bcb07'>; export type ProfileHash = @@ -90,6 +90,10 @@ export type FieldOutputTypes = { readonly invitedById: CodecTypes['pg/int4@1']['output'] | null; readonly address: AddressOutput | null; }; + readonly UserTag: { + readonly userId: CodecTypes['pg/int4@1']['output']; + readonly tagId: CodecTypes['pg/text@1']['output']; + }; }; export type FieldInputTypes = { readonly Article: { @@ -125,6 +129,10 @@ export type FieldInputTypes = { readonly invitedById: CodecTypes['pg/int4@1']['input'] | null; readonly address: AddressInput | null; }; + readonly UserTag: { + readonly userId: CodecTypes['pg/int4@1']['input']; + readonly tagId: CodecTypes['pg/text@1']['input']; + }; }; export type TypeMaps = TypeMapsType< CodecTypes, @@ -308,6 +316,24 @@ type ContractBase = Omit< indexes: readonly []; foreignKeys: readonly []; }; + readonly user_tags: { + columns: { + readonly user_id: { + readonly nativeType: 'int4'; + readonly codecId: 'pg/int4@1'; + readonly nullable: false; + }; + readonly tag_id: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + }; + primaryKey: { readonly columns: readonly ['user_id', 'tag_id'] }; + uniques: readonly []; + indexes: readonly []; + foreignKeys: readonly []; + }; readonly users: { columns: { readonly id: { @@ -591,6 +617,14 @@ type ContractBase = Omit< readonly targetFields: readonly ['userId']; }; }; + readonly tags: { + readonly to: { readonly namespace: 'public' & NamespaceId; readonly model: 'Tag' }; + readonly cardinality: 'N:M'; + readonly on: { + readonly localFields: readonly ['id']; + readonly targetFields: readonly ['user_id']; + }; + }; }; readonly storage: { readonly table: 'users'; @@ -603,6 +637,26 @@ type ContractBase = Omit< }; }; }; + readonly UserTag: { + readonly fields: { + readonly userId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; + readonly tagId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'user_tags'; + readonly fields: { + readonly userId: { readonly column: 'user_id' }; + readonly tagId: { readonly column: 'tag_id' }; + }; + }; + }; } >, 'roots' | 'domain' @@ -616,6 +670,7 @@ type ContractBase = Omit< readonly profiles: { readonly namespace: 'public' & NamespaceId; readonly model: 'Profile' }; readonly articles: { readonly namespace: 'public' & NamespaceId; readonly model: 'Article' }; readonly tags: { readonly namespace: 'public' & NamespaceId; readonly model: 'Tag' }; + readonly user_tags: { readonly namespace: 'public' & NamespaceId; readonly model: 'UserTag' }; }; readonly domain: { readonly namespaces: { @@ -856,6 +911,14 @@ type ContractBase = Omit< readonly targetFields: readonly ['userId']; }; }; + readonly tags: { + readonly to: { readonly namespace: 'public' & NamespaceId; readonly model: 'Tag' }; + readonly cardinality: 'N:M'; + readonly on: { + readonly localFields: readonly ['id']; + readonly targetFields: readonly ['user_id']; + }; + }; }; readonly storage: { readonly table: 'users'; @@ -868,6 +931,26 @@ type ContractBase = Omit< }; }; }; + readonly UserTag: { + readonly fields: { + readonly userId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; + readonly tagId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'user_tags'; + readonly fields: { + readonly userId: { readonly column: 'user_id' }; + readonly tagId: { readonly column: 'tag_id' }; + }; + }; + }; }; readonly valueObjects: { readonly Address: { diff --git a/test/integration/test/sql-orm-client/fixtures/generated/contract.json b/test/integration/test/sql-orm-client/fixtures/generated/contract.json index 25403b2b3a..a3f175cf41 100644 --- a/test/integration/test/sql-orm-client/fixtures/generated/contract.json +++ b/test/integration/test/sql-orm-client/fixtures/generated/contract.json @@ -24,6 +24,10 @@ "model": "Tag", "namespace": "public" }, + "user_tags": { + "model": "UserTag", + "namespace": "public" + }, "users": { "model": "User", "namespace": "public" @@ -410,6 +414,33 @@ "model": "Profile", "namespace": "public" } + }, + "tags": { + "cardinality": "N:M", + "on": { + "localFields": [ + "id" + ], + "targetFields": [ + "user_id" + ] + }, + "through": { + "childColumns": [ + "tag_id" + ], + "parentColumns": [ + "user_id" + ], + "table": "user_tags", + "targetColumns": [ + "id" + ] + }, + "to": { + "model": "Tag", + "namespace": "public" + } } }, "storage": { @@ -432,6 +463,36 @@ }, "table": "users" } + }, + "UserTag": { + "fields": { + "tagId": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "userId": { + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "fields": { + "tagId": { + "column": "tag_id" + }, + "userId": { + "column": "user_id" + } + }, + "table": "user_tags" + } } }, "valueObjects": { @@ -685,6 +746,29 @@ } ] }, + "user_tags": { + "columns": { + "tag_id": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "user_id": { + "codecId": "pg/int4@1", + "nativeType": "int4", + "nullable": false + } + }, + "foreignKeys": [], + "indexes": [], + "primaryKey": { + "columns": [ + "user_id", + "tag_id" + ] + }, + "uniques": [] + }, "users": { "columns": { "address": { @@ -750,7 +834,7 @@ } } }, - "storageHash": "sha256:0c33777620981b67b3bf40871bae60c1a07e006e24f36a8f7b133263d9d5c541" + "storageHash": "sha256:e05ccf77cd36bcf34f470cae1638a9bda152e054c982497ee262f1f2d3d021d6" }, "execution": { "executionHash": "sha256:a108e5f9b4a5af51635ffde3849836fee73cd71cd568e1f2daf236c5768bcb07", diff --git a/test/integration/test/sql-orm-client/mn-include.test.ts b/test/integration/test/sql-orm-client/mn-include.test.ts new file mode 100644 index 0000000000..bad43ef088 --- /dev/null +++ b/test/integration/test/sql-orm-client/mn-include.test.ts @@ -0,0 +1,312 @@ +// Integration coverage for M:N include (User -> tags via user_tags junction). +// +// `User.tags` is a many-to-many relation to `Tag` through the `user_tags` +// junction table. The read path compiles a correlated junction subquery that +// resolves each user's tags in a single SQL execution. These tests prove the +// end-to-end behaviour against a real database. +// +// Test data shape: +// +// User(id, name, email, invitedById?) +// tags: N:M Tag through user_tags (via user_id / tag_id) +// +// Tag(id: text, name: text) +// +// UserTag(userId, tagId) — junction +// +// Standard (from project integration-test standard): +// 1. Whole-row assertions via toEqual on every test. +// 2. Explicit .select() used in most tests. +// 3. At least one implicit/default-selection test (no .select()). + +import { describe, expect, it } from 'vitest'; +import { createUsersCollection, timeouts, withCollectionRuntime } from './integration-helpers'; +import { seedTags, seedUsers, seedUserTags } from './runtime-helpers'; + +// Tag IDs are text at the DB level (sql/char@1 at contract level). +const TAG_RUST = 'tag-rust'; +const TAG_TS = 'tag-typescript'; +const TAG_DB = 'tag-database'; + +describe('integration/mn-include', () => { + // =========================================================================== + // Core M:N include via junction: whole-row correctness. + // =========================================================================== + + it( + 'include("tags") with explicit select returns selected fields on user and tags (whole-row toEqual)', + async () => { + await withCollectionRuntime(async (runtime) => { + const users = createUsersCollection(runtime); + + await seedUsers(runtime, [ + { id: 1, name: 'Alice', email: 'alice@example.com' }, + { id: 2, name: 'Bob', email: 'bob@example.com' }, + ]); + await seedTags(runtime, [ + { id: TAG_RUST, name: 'Rust' }, + { id: TAG_TS, name: 'TypeScript' }, + ]); + await seedUserTags(runtime, [ + { userId: 1, tagId: TAG_RUST }, + { userId: 1, tagId: TAG_TS }, + { userId: 2, tagId: TAG_TS }, + ]); + + const rows = await users + .select('id', 'name') + .orderBy((u) => u.id.asc()) + .include('tags', (tags) => tags.select('id', 'name').orderBy((t) => t.name.asc())) + .all(); + + expect(rows).toEqual([ + { + id: 1, + name: 'Alice', + tags: [ + { id: TAG_RUST, name: 'Rust' }, + { id: TAG_TS, name: 'TypeScript' }, + ], + }, + { + id: 2, + name: 'Bob', + tags: [{ id: TAG_TS, name: 'TypeScript' }], + }, + ]); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'include("tags") resolves in a single SQL execution with no LATERAL keyword', + async () => { + // The M:N correlated subquery through the junction must lower to a + // single SQL execution. The junction join inside the subquery is an + // inner join — never a LATERAL join — so LATERAL must be absent from + // the emitted SQL. + await withCollectionRuntime(async (runtime) => { + const users = createUsersCollection(runtime); + + await seedUsers(runtime, [{ id: 1, name: 'Alice', email: 'alice@example.com' }]); + await seedTags(runtime, [{ id: TAG_TS, name: 'TypeScript' }]); + await seedUserTags(runtime, [{ userId: 1, tagId: TAG_TS }]); + + runtime.resetExecutions(); + const rows = await users + .select('id', 'name') + .include('tags', (tags) => tags.select('id', 'name')) + .all(); + + expect(rows).toEqual([ + { id: 1, name: 'Alice', tags: [{ id: TAG_TS, name: 'TypeScript' }] }, + ]); + expect(runtime.executions).toHaveLength(1); + expect(runtime.executions[0]?.sql).not.toContain('LATERAL'); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'user with no tags returns tags: []', + async () => { + // Edge case: a user with no junction rows must yield an empty array, + // not null or undefined. + await withCollectionRuntime(async (runtime) => { + const users = createUsersCollection(runtime); + + await seedUsers(runtime, [ + { id: 1, name: 'Alice', email: 'alice@example.com' }, + { id: 2, name: 'Bob', email: 'bob@example.com' }, + ]); + await seedTags(runtime, [{ id: TAG_RUST, name: 'Rust' }]); + // Only Alice has a tag; Bob has none. + await seedUserTags(runtime, [{ userId: 1, tagId: TAG_RUST }]); + + const rows = await users + .select('id', 'name') + .orderBy((u) => u.id.asc()) + .include('tags', (tags) => tags.select('id', 'name')) + .all(); + + expect(rows).toEqual([ + { id: 1, name: 'Alice', tags: [{ id: TAG_RUST, name: 'Rust' }] }, + { id: 2, name: 'Bob', tags: [] }, + ]); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'a tag connected to multiple users resolves correctly for each user', + async () => { + // A shared tag must appear in every user's tags array independently. + // A bug that deduplicated tags globally (e.g. keyed by tag ID across + // all users) would drop the tag from some users' result. + await withCollectionRuntime(async (runtime) => { + const users = createUsersCollection(runtime); + + await seedUsers(runtime, [ + { id: 1, name: 'Alice', email: 'alice@example.com' }, + { id: 2, name: 'Bob', email: 'bob@example.com' }, + { id: 3, name: 'Cara', email: 'cara@example.com' }, + ]); + await seedTags(runtime, [ + { id: TAG_TS, name: 'TypeScript' }, + { id: TAG_DB, name: 'Database' }, + ]); + // TypeScript is shared by Alice and Cara; Bob has only Database. + await seedUserTags(runtime, [ + { userId: 1, tagId: TAG_TS }, + { userId: 2, tagId: TAG_DB }, + { userId: 3, tagId: TAG_TS }, + ]); + + const rows = await users + .select('id', 'name') + .orderBy((u) => u.id.asc()) + .include('tags', (tags) => tags.select('id', 'name').orderBy((t) => t.name.asc())) + .all(); + + expect(rows).toEqual([ + { id: 1, name: 'Alice', tags: [{ id: TAG_TS, name: 'TypeScript' }] }, + { id: 2, name: 'Bob', tags: [{ id: TAG_DB, name: 'Database' }] }, + { id: 3, name: 'Cara', tags: [{ id: TAG_TS, name: 'TypeScript' }] }, + ]); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'include("tags") with no .select returns the full default row shape (implicit selection)', + async () => { + // Standard requirement: at least one test with no .select so the + // full default shape for User + tags: Tag[] is asserted. + await withCollectionRuntime(async (runtime) => { + const users = createUsersCollection(runtime); + + await seedUsers(runtime, [{ id: 1, name: 'Alice', email: 'alice@example.com' }]); + await seedTags(runtime, [ + { id: TAG_RUST, name: 'Rust' }, + { id: TAG_TS, name: 'TypeScript' }, + ]); + await seedUserTags(runtime, [ + { userId: 1, tagId: TAG_RUST }, + { userId: 1, tagId: TAG_TS }, + ]); + + const rows = await users + .orderBy((u) => u.id.asc()) + .include('tags', (tags) => tags.orderBy((t) => t.name.asc())) + .all(); + + // Full User shape + tags: Tag[] (all Tag fields). + expect(rows).toEqual([ + { + id: 1, + name: 'Alice', + email: 'alice@example.com', + invitedById: null, + address: null, + tags: [ + { id: TAG_RUST, name: 'Rust' }, + { id: TAG_TS, name: 'TypeScript' }, + ], + }, + ]); + }); + }, + timeouts.spinUpPpgDev, + ); + + // =========================================================================== + // Depth-2: M:N include nested under a 1:N (invitedUsers -> tags). + // Proves the junction walk composes when the parent row comes from a + // depth-1 include rather than the root collection. + // =========================================================================== + + it( + 'depth-2: M:N tags nested under 1:N invitedUsers resolves in a single execution', + async () => { + // users -> invitedUsers (1:N self-relation) -> tags (N:M via junction). + // The M:N subquery at depth 2 must still correlate correctly to the + // depth-1 invitedUsers alias and resolve in a single SQL execution. + await withCollectionRuntime(async (runtime) => { + const users = createUsersCollection(runtime); + + await seedUsers(runtime, [ + { id: 1, name: 'Alice', email: 'alice@example.com' }, + { id: 2, name: 'Bob', email: 'bob@example.com', invitedById: 1 }, + { id: 3, name: 'Cara', email: 'cara@example.com', invitedById: 1 }, + ]); + await seedTags(runtime, [ + { id: TAG_RUST, name: 'Rust' }, + { id: TAG_TS, name: 'TypeScript' }, + ]); + // Bob has Rust; Cara has TypeScript; Alice has no tags. + await seedUserTags(runtime, [ + { userId: 2, tagId: TAG_RUST }, + { userId: 3, tagId: TAG_TS }, + ]); + + runtime.resetExecutions(); + const rows = await users + .select('id', 'name') + .where((u) => u.id.eq(1)) + .include('invitedUsers', (inv) => + inv + .select('id', 'name') + .orderBy((u) => u.id.asc()) + .include('tags', (tags) => tags.select('id', 'name').orderBy((t) => t.name.asc())), + ) + .all(); + + expect(rows).toEqual([ + { + id: 1, + name: 'Alice', + invitedUsers: [ + { id: 2, name: 'Bob', tags: [{ id: TAG_RUST, name: 'Rust' }] }, + { id: 3, name: 'Cara', tags: [{ id: TAG_TS, name: 'TypeScript' }] }, + ], + }, + ]); + expect(runtime.executions).toHaveLength(1); + expect(runtime.executions[0]?.sql).not.toContain('LATERAL'); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'depth-2: sibling include("tags") and include("posts") on the same user resolves in one execution', + async () => { + // Two sibling top-level includes: tags (N:M) and posts (1:N). Both must + // pack into a single SQL execution and resolve to independent correct shapes. + await withCollectionRuntime(async (runtime) => { + const users = createUsersCollection(runtime); + + await seedUsers(runtime, [{ id: 1, name: 'Alice', email: 'alice@example.com' }]); + await seedTags(runtime, [{ id: TAG_RUST, name: 'Rust' }]); + await seedUserTags(runtime, [{ userId: 1, tagId: TAG_RUST }]); + + runtime.resetExecutions(); + const rows = await users + .select('id', 'name') + .include('tags', (tags) => tags.select('id', 'name')) + .include('posts', (posts) => posts.select('id', 'title').orderBy((p) => p.id.asc())) + .all(); + + expect(rows).toEqual([ + { id: 1, name: 'Alice', tags: [{ id: TAG_RUST, name: 'Rust' }], posts: [] }, + ]); + expect(runtime.executions).toHaveLength(1); + }); + }, + timeouts.spinUpPpgDev, + ); +}); diff --git a/test/integration/test/sql-orm-client/runtime-helpers.ts b/test/integration/test/sql-orm-client/runtime-helpers.ts index 8c7b16332e..b6fe2210f0 100644 --- a/test/integration/test/sql-orm-client/runtime-helpers.ts +++ b/test/integration/test/sql-orm-client/runtime-helpers.ts @@ -41,6 +41,16 @@ interface SeedComment { postId: number; } +interface SeedTag { + id: string; + name: string; +} + +interface SeedUserTag { + userId: number; + tagId: string; +} + export interface PgIntegrationRuntime extends RuntimeQueryable { readonly executions: readonly SqlExecutionPlan[]; query = Record>( @@ -166,6 +176,7 @@ export async function setupTestSchema(runtime: PgIntegrationRuntime): Promise { + for (const tag of tags) { + await runtime.query('insert into tags (id, name) values ($1, $2)', [tag.id, tag.name]); + } +} + +export async function seedUserTags( + runtime: PgIntegrationRuntime, + userTags: readonly SeedUserTag[], +): Promise { + for (const ut of userTags) { + await runtime.query('insert into user_tags (user_id, tag_id) values ($1, $2)', [ + ut.userId, + ut.tagId, + ]); + } +}