From ea025e88b1da747bfb49ec3db36bf91967eb3e7c Mon Sep 17 00:00:00 2001 From: Alexey Orlenko's AI Agent Date: Mon, 1 Jun 2026 15:34:43 +0200 Subject: [PATCH 1/2] refactor(sql-orm-client): drop LATERAL include codegen for correlated-only read path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The read-path include dispatch carried two single-query builders — buildLateralIncludeArtifacts (LEFT JOIN LATERAL + json_agg) for Postgres and buildCorrelatedIncludeProjection (correlated subquery) for SQLite — selected on the `lateral` capability flag. Benchmarking on PG 17.5/17.10 proved the two forms compile to structurally identical plans for the top-N-per-parent shape (the per-parent LIMIT forbids de-correlation of either form), so LATERAL offers no planner advantage for includes. Route every include through the correlated path and remove the strategy axis entirely (TML-2657 already removed the multi-query fallback, so the dispatch was binary): delete buildLateralIncludeArtifacts, drop the `strategy` parameter from every include builder, simplify buildNestedIncludeArtifacts to projections-only (correlated emits no joins), rename compileSelectWithIncludeStrategy to compileSelectWithIncludes, and delete include-strategy.ts (selectIncludeStrategy / IncludeStrategy). The `lateral` capability flag, JoinAst.lateral, the renderer LATERAL emission, and the public lateralJoin() DSL are independent consumers and are untouched. A new regression guard pins that a lateral-capable contract now resolves includes in a single execution with no LATERAL join and no LATERAL keyword in the lowered SQL. Refs: TML-2729 Signed-off-by: Alexey Orlenko's AI Agent --- .../sql-orm-client/src/collection-dispatch.ts | 32 +- .../sql-orm-client/src/include-strategy.ts | 45 -- .../sql-orm-client/src/query-plan-select.ts | 187 ++---- .../sql-orm-client/src/query-plan.ts | 2 +- .../test/collection-dispatch.test.ts | 38 +- .../test/include-strategy.test.ts | 84 --- .../test/query-plan-select.test.ts | 565 ++---------------- .../test/rich-query-plans.test.ts | 4 +- .../follow-ups.md | 2 +- .../test/sql-orm-client/include.test.ts | 90 ++- .../sql-orm-client/nested-includes-helpers.ts | 19 +- .../nested-includes-strategy.test.ts | 226 +++---- 12 files changed, 250 insertions(+), 1044 deletions(-) delete mode 100644 packages/3-extensions/sql-orm-client/src/include-strategy.ts delete mode 100644 packages/3-extensions/sql-orm-client/test/include-strategy.test.ts 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 5827dc7d3d..a28bf58f97 100644 --- a/packages/3-extensions/sql-orm-client/src/collection-dispatch.ts +++ b/packages/3-extensions/sql-orm-client/src/collection-dispatch.ts @@ -40,8 +40,7 @@ import { stripHiddenMappedFields, } from './collection-runtime'; import { executeQueryPlan } from './execute-query-plan'; -import { selectIncludeStrategy } from './include-strategy'; -import { compileSelect, compileSelectWithIncludeStrategy } from './query-plan'; +import { compileSelect, compileSelectWithIncludes } from './query-plan'; import { augmentSelectionForJoinColumns } from './selection-shaping'; import { type CollectionContext, @@ -78,11 +77,9 @@ export function dispatchCollectionRows(options: { return dispatchWithIncludes(options); } -// Both include builders — lateral and correlated — lower every include +// The correlated-subquery include builder lowers every include // descriptor shape (row, scalar reducers, and combine()) at any depth -// into a single query. Dispatch picks one purely on the `lateral` -// capability flag via `selectIncludeStrategy`; the read path has no -// multi-query fallback. +// into a single query; the read path has no multi-query fallback. function dispatchWithIncludes(options: { contract: Contract; runtime: CollectionContext>['runtime']; @@ -91,21 +88,19 @@ function dispatchWithIncludes(options: { modelName: string; }): AsyncIterableResult { const { contract, runtime, state, tableName, modelName } = options; - const strategy = selectIncludeStrategy(contract); const generator = async function* (): AsyncGenerator { const { scope, release } = await acquireRuntimeScope(runtime); try { const parentJoinColumns = state.includes.map((include) => include.localColumn); const { selectedForQuery: parentSelectedForQuery, hiddenColumns: hiddenParentColumns } = augmentSelectionForJoinColumns(state.selectedFields, parentJoinColumns); - const compiled = compileSelectWithIncludeStrategy( + const compiled = compileSelectWithIncludes( contract, tableName, { ...state, selectedFields: parentSelectedForQuery, }, - strategy, modelName, ); @@ -155,7 +150,7 @@ function dispatchWithIncludes(options: { /** * Reload the rows a mutation just wrote (create / createAll / update / * updateAll / upsert) through the read-path dispatch, so `.include()` - * relations resolve via the exact same lateral / correlated builders, + * relations resolve via the exact same correlated-subquery builder, * decode, hidden-column stripping, and polymorphism mapping a read * query uses — there is no parallel mutation read-back implementation. * @@ -255,7 +250,7 @@ function buildIdentityInFilter( * Decode a single-query include payload from a parent row's raw cell * into the model-shaped value that downstream consumers see. Recurses * through `include.nested.includes` so depth-2+ trees — emitted by the - * recursive lateral / correlated builders — are decoded symmetrically. + * recursive correlated-subquery builder — are decoded symmetrically. * * The shape produced by the SQL side is one JSON column per top-level * include; values nested inside that JSON are already-parsed JS values @@ -312,9 +307,10 @@ function decodeIncludePayload( * - scalar branch -> unwrap the `{value: ...}` envelope via the * standalone scalar decoder. * - * On a parent with zero matching child rows the LATERAL still produces - * one row (aggregates collapse the empty input to a single row), so - * the combine envelope here is always present in the read path. The + * On a parent with zero matching child rows the correlated subquery + * still produces one row (aggregates collapse the empty input to a + * single row), so the combine envelope here is always present in the + * read path. The * mutation read-back's `assignEmptyMutationIncludes` writes the empty * per-branch shape directly to `parent.mapped[relationName]` for any * parent absent from the read-back result and never enters the decoder, @@ -350,7 +346,7 @@ function decodeCombineIncludePayload( function parseCombineEnvelope(include: IncludeExpr, raw: unknown): Record { if (raw === null || raw === undefined) { throw new Error( - `combine() envelope for include "${include.relationName}" is missing (got ${raw === null ? 'null' : 'undefined'}); the LATERAL / correlated subquery should always produce a JSON object — this indicates a planner or decoder bug.`, + `combine() envelope for include "${include.relationName}" is missing (got ${raw === null ? 'null' : 'undefined'}); the correlated subquery should always produce a JSON object — this indicates a planner or decoder bug.`, ); } const parsed = parseIncludePayload(raw); @@ -374,7 +370,7 @@ function describeEnvelopeShape(value: unknown): string { /** * Pull the primitive scalar value out of the JSON envelope emitted by - * the lateral / correlated scalar builder. + * the correlated scalar builder. * * Contract: the envelope is always either * - a `{ value: }` JSON object (the SQL path), or @@ -393,8 +389,8 @@ function describeEnvelopeShape(value: unknown): string { * `SUM` / `AVG` / `MIN` / `MAX` over an empty input set return SQL * `NULL`, which surfaces as `null` here. The outer `raw === null` * fallback is defensive cover for an empty parent set; in single-query - * dispatch the LATERAL / correlated subquery always produces a row, - * so the inner envelope's `value` is always set by SQL. + * dispatch the correlated subquery always produces a row, so the inner + * envelope's `value` is always set by SQL. */ function decodeScalarIncludePayload( include: IncludeExpr, diff --git a/packages/3-extensions/sql-orm-client/src/include-strategy.ts b/packages/3-extensions/sql-orm-client/src/include-strategy.ts deleted file mode 100644 index 3ce21951da..0000000000 --- a/packages/3-extensions/sql-orm-client/src/include-strategy.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { Contract } from '@prisma-next/contract/types'; -import type { SqlStorage } from '@prisma-next/sql-contract/types'; - -export type IncludeStrategy = 'lateral' | 'correlated'; - -/** - * Choose the single-query SQL emission strategy for nested includes - * based on the contract's declared capabilities. - * - * - `'lateral'`: outer SELECT with one LATERAL JOIN per relation, - * aggregating to JSON. Requires both `lateral` and `jsonAgg`. - * Postgres has both. - * - `'correlated'`: outer SELECT with one correlated subquery per - * relation, aggregating to JSON. Requires `jsonAgg` only. - * SQLite has `jsonAgg` (via `json_group_array`) but no LATERAL. - * - * Every supported SQL target provides `jsonAgg`; the only axis the - * selector reads is `lateral`. Targets that declare neither flag are - * not supported on the read path — there is no multi-query fallback. - * - * The `lateral` flag is looked up under the contract's `targetFamily` - * and `target` namespaces — the two layers the contract emitter - * actually populates. Cross-namespace ("`postgres.lateral` found while - * running SQLite") false positives are impossible because we only - * inspect the running target's namespaces. - */ -export function selectIncludeStrategy(contract: Contract): IncludeStrategy { - return capabilityFlag(contract, 'lateral') ? 'lateral' : 'correlated'; -} - -/** - * Read a capability flag from the contract's target/family namespaces. - * - * The contract emitter populates `capabilities[targetFamily]` (universal - * SQL flags like `jsonAgg`, `returning`) and `capabilities[target]` - * (target-specific flags like `lateral` on Postgres). Either may - * declare a given flag; the family namespace declares the floor and the - * target namespace can extend on top. - */ -function capabilityFlag(contract: Contract, flag: string): boolean { - return ( - contract.capabilities[contract.targetFamily]?.[flag] === true || - contract.capabilities[contract.target]?.[flag] === true - ); -} 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 77df0c0a0e..7d04f8bdce 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 @@ -230,7 +230,7 @@ function buildIncludeOrderArtifacts( * The wrapper forwards every column of `base.projection` through the * derived alias, so the wrapper's projection is byte-identical in alias * names — making this transparent to any outer query (`json_agg`, - * lateral correlation, top-level SELECT) that consumes the SELECT. + * correlated subquery, top-level SELECT) that consumes the SELECT. */ function wrapWithRowNumberDedup(options: { readonly base: SelectAst; @@ -271,53 +271,31 @@ function wrapWithRowNumberDedup(options: { } /** - * Recursively build the join + projection artifacts for the nested - * includes attached to a child SELECT. Used by - * `buildIncludeChildRowsSelect` to wire depth-2+ aggregates into the - * inner SELECT at each level. + * Recursively build the projection artifacts for the nested includes + * attached to a child SELECT. Used by `buildIncludeChildRowsSelect` to + * wire depth-2+ aggregates into the inner SELECT at each level. * - * Under the `lateral` strategy each nested include contributes one - * LEFT JOIN LATERAL plus one projection item that references the - * lateral alias. Under the `correlated` strategy each nested include - * contributes a single projection item whose expression is a - * correlated subquery; no joins are produced. The two cases are - * symmetric, which is why both paths share `buildIncludeChildRowsSelect`. + * Each nested include contributes a single projection item whose + * expression is a correlated subquery; no joins are produced. */ function buildNestedIncludeArtifacts( contract: Contract, parentTableRef: string, includes: readonly IncludeExpr[], - strategy: 'lateral' | 'correlated', ): { - readonly joins: ReadonlyArray; readonly projections: ReadonlyArray; } { - if (includes.length === 0) { - return { joins: [], projections: [] }; - } - - const joins: JoinAst[] = []; - const projections: ProjectionItem[] = []; - - for (const nested of includes) { - if (strategy === 'lateral') { - const artifact = buildLateralIncludeArtifacts(contract, parentTableRef, nested); - joins.push(artifact.join); - projections.push(artifact.projection); - continue; - } - const artifact = buildCorrelatedIncludeProjection(contract, parentTableRef, nested); - projections.push(artifact.projection); - } + const projections = includes.map( + (nested) => buildCorrelatedIncludeProjection(contract, parentTableRef, nested).projection, + ); - return { joins, projections }; + return { projections }; } function buildIncludeChildRowsSelect( contract: Contract, parentTableName: string, include: IncludeExpr, - strategy: 'lateral' | 'correlated', ): { readonly childRows: SelectAst; readonly childProjection: ReadonlyArray; @@ -381,7 +359,6 @@ function buildIncludeChildRowsSelect( hiddenOrderProjection, aggregateOrderBy, whereExpr, - strategy, }); } @@ -392,16 +369,14 @@ function buildIncludeChildRowsSelect( childTableRef, ); - // Recurse: each nested include produces either a LATERAL JOIN (under - // `lateral`) or a correlated subquery projection (under `correlated`). - // The nested aggregates are attached to *this* child SELECT, so they - // correlate against `childTableRef` — which may itself be an alias if - // the relation is self-referential. - const { joins: nestedJoins, projections: nestedProjections } = buildNestedIncludeArtifacts( + // Recurse: each nested include produces a correlated subquery + // projection. The nested aggregates are attached to *this* child + // SELECT, so they correlate against `childTableRef` — which may itself + // be an alias if the relation is self-referential. + const { projections: nestedProjections } = buildNestedIncludeArtifacts( contract, childTableRef, childState.includes, - strategy, ); // `childProjection` is the set of items that survive into the parent's @@ -415,7 +390,6 @@ function buildIncludeChildRowsSelect( let childRows = SelectAst.from(TableSource.named(include.relatedTableName, childTableAlias)) .withProjection([...childProjection, ...hiddenOrderProjection]) - .withJoins(nestedJoins) .withWhere(whereExpr); if (childState.distinctOn && childState.distinctOn.length > 0) { @@ -480,7 +454,6 @@ function buildDistinctNonLeafChildRowsSelect(options: { readonly hiddenOrderProjection: ReadonlyArray; readonly aggregateOrderBy: ReadonlyArray | undefined; readonly whereExpr: AnyExpression; - readonly strategy: 'lateral' | 'correlated'; }): { readonly childRows: SelectAst; readonly childProjection: ReadonlyArray; @@ -497,7 +470,6 @@ function buildDistinctNonLeafChildRowsSelect(options: { hiddenOrderProjection, aggregateOrderBy, whereExpr, - strategy, } = options; const childState = include.nested; @@ -527,7 +499,7 @@ function buildDistinctNonLeafChildRowsSelect(options: { // We use `ROW_NUMBER() OVER (PARTITION BY ORDER BY …) // = 1` rather than SQL `DISTINCT` because the latter dedupes by the // full projected row — and we force-include grandchild join keys - // (e.g. `post.id` so the `comments` lateral can correlate). With those + // (e.g. `post.id` so the `comments` correlated subquery can correlate). With those // join keys in the projection, plain `DISTINCT` would never collapse // rows whose ids differ, making `.distinct('title')` a no-op. The // window-function form partitions strictly on the user's chosen @@ -592,8 +564,11 @@ function buildDistinctNonLeafChildRowsSelect(options: { childState.selectedFields, distinctAlias, ); - const { joins: outerNestedJoins, projections: outerNestedProjections } = - buildNestedIncludeArtifacts(contract, distinctAlias, childState.includes, strategy); + const { projections: outerNestedProjections } = buildNestedIncludeArtifacts( + contract, + distinctAlias, + childState.includes, + ); // Forward hidden order columns from the inner distinct subquery to the // outer SELECT so `aggregateOrderBy` (which still references `rowsAlias`) @@ -607,9 +582,9 @@ function buildDistinctNonLeafChildRowsSelect(options: { ...outerNestedProjections, ]; - const childRows = SelectAst.from(DerivedTableSource.as(distinctAlias, innerSelect)) - .withProjection([...childProjection, ...outerHiddenOrderProjection]) - .withJoins(outerNestedJoins); + const childRows = SelectAst.from( + DerivedTableSource.as(distinctAlias, innerSelect), + ).withProjection([...childProjection, ...outerHiddenOrderProjection]); return { childRows, @@ -815,9 +790,8 @@ function buildIncludeAggregateExpr( * INNER JOIN ON TRUE ...), and the outer projection packs * them into a single `json_build_object` keyed by branch name. The * resulting subquery emits exactly one row per parent row containing - * the combined JSON — wrapped as a LATERAL join under the lateral - * strategy or embedded as a correlated subquery under the correlated - * strategy. + * the combined JSON — embedded as a correlated subquery in the outer + * projection. * * Row branches reuse the standalone row-include builder; scalar * branches reuse `buildIncludeChildScalarSelect` — the `{value: ...}` @@ -831,7 +805,6 @@ function buildIncludeChildCombineSelect( parentTableName: string, include: IncludeExpr, branches: Readonly>, - strategy: 'lateral' | 'correlated', ): SelectAst { const branchEntries = Object.entries(branches); if (branchEntries.length === 0) { @@ -841,13 +814,7 @@ function buildIncludeChildCombineSelect( const compiledBranches = branchEntries.map(([name, branch]) => ({ name, alias: `${include.relationName}__combine__${name}`, - select: buildIncludeChildCombineBranchSelect( - contract, - parentTableName, - include, - branch, - strategy, - ), + select: buildIncludeChildCombineBranchSelect(contract, parentTableName, include, branch), })); const jsonObjectExpr = JsonObjectExpr.fromEntries( @@ -876,16 +843,13 @@ function buildIncludeChildCombineSelect( * Compile one branch of a `combine({ ... })` into a SelectAst that * projects exactly one row with one column aliased to the parent * relation name. Dispatches to the standalone scalar / row builders - * with the branch's state spliced into a synthetic IncludeExpr. The - * `strategy` is forwarded so that nested includes inside a row branch - * stay on the same emission strategy as the outer query. + * with the branch's state spliced into a synthetic IncludeExpr. */ function buildIncludeChildCombineBranchSelect( contract: Contract, parentTableName: string, include: IncludeExpr, branch: IncludeCombineBranch, - strategy: 'lateral' | 'correlated', ): SelectAst { if (branch.kind === 'scalar') { return buildIncludeChildScalarSelect(contract, parentTableName, include, branch.selector); @@ -898,33 +862,24 @@ function buildIncludeChildCombineBranchSelect( scalar: undefined, combine: undefined, }; - return buildIncludeChildRowsAggregateSelect( - contract, - parentTableName, - syntheticInclude, - strategy, - ); + return buildIncludeChildRowsAggregateSelect(contract, parentTableName, syntheticInclude); } /** * Internal helper: build the inner aggregate SELECT that `json_agg`s * child rows into a single JSON-array column aliased to the relation - * name. Used by both the standalone row LATERAL / correlated path and - * by combine's row branches. The `strategy` parameter propagates into - * `buildIncludeChildRowsSelect` so that nested includes inside the - * aggregated child rows use the same emission shape as the outer query. + * name. Used by both the standalone row correlated-subquery path and + * by combine's row branches. */ function buildIncludeChildRowsAggregateSelect( contract: Contract, parentTableName: string, include: IncludeExpr, - strategy: 'lateral' | 'correlated', ): SelectAst { const { childRows, childProjection, rowsAlias, aggregateOrderBy } = buildIncludeChildRowsSelect( contract, parentTableName, include, - strategy, ); const jsonObjectExpr = JsonObjectExpr.fromEntries( childProjection.map((item) => @@ -939,65 +894,6 @@ function buildIncludeChildRowsAggregateSelect( ]); } -function buildLateralIncludeArtifacts( - contract: Contract, - parentTableName: string, - include: IncludeExpr, -): { - readonly join: JoinAst; - readonly projection: ProjectionItem; -} { - const lateralAlias = `${include.relationName}_lateral`; - - if (include.scalar) { - const scalarSelect = buildIncludeChildScalarSelect( - contract, - parentTableName, - include, - include.scalar, - ); - return { - join: JoinAst.left(DerivedTableSource.as(lateralAlias, scalarSelect), AndExpr.true(), true), - projection: ProjectionItem.of( - include.relationName, - ColumnRef.of(lateralAlias, include.relationName), - ), - }; - } - - if (include.combine) { - const combineSelect = buildIncludeChildCombineSelect( - contract, - parentTableName, - include, - include.combine, - 'lateral', - ); - return { - join: JoinAst.left(DerivedTableSource.as(lateralAlias, combineSelect), AndExpr.true(), true), - projection: ProjectionItem.of( - include.relationName, - ColumnRef.of(lateralAlias, include.relationName), - ), - }; - } - - const aggregateQuery = buildIncludeChildRowsAggregateSelect( - contract, - parentTableName, - include, - 'lateral', - ); - - return { - join: JoinAst.left(DerivedTableSource.as(lateralAlias, aggregateQuery), AndExpr.true(), true), - projection: ProjectionItem.of( - include.relationName, - ColumnRef.of(lateralAlias, include.relationName), - ), - }; -} - function buildCorrelatedIncludeProjection( contract: Contract, parentTableName: string, @@ -1023,19 +919,13 @@ function buildCorrelatedIncludeProjection( parentTableName, include, include.combine, - 'correlated', ); return { projection: ProjectionItem.of(include.relationName, SubqueryExpr.of(combineSelect)), }; } - const aggregateQuery = buildIncludeChildRowsAggregateSelect( - contract, - parentTableName, - include, - 'correlated', - ); + const aggregateQuery = buildIncludeChildRowsAggregateSelect(contract, parentTableName, include); return { projection: ProjectionItem.of(include.relationName, SubqueryExpr.of(aggregateQuery)), }; @@ -1059,7 +949,7 @@ function buildSelectAst( // ROW_NUMBER-based dedup subquery aliased to the original `tableName`. // That aliasing keeps every outer reference — the projection's // scalar columns, the MTI variant joins, the include subqueries' - // lateral parent correlations, the orderBy — resolving transparently, + // parent correlations, the orderBy — resolving transparently, // without needing to rewrite column refs across the AST. // // We project every column of the underlying table so anything the @@ -1118,7 +1008,7 @@ function buildTopLevelDistinctRankedInner( throw new Error('buildTopLevelDistinctRankedInner called without `state.distinct`'); } // Project every column of the underlying table so outer references - // (projection, joins, includes' lateral correlations, orderBy) resolve + // (projection, joins, includes' correlations, orderBy) resolve // through the derived-subquery alias. const allCols = resolveTableColumns(contract, tableName); const allColsProjection = allCols.map((column) => @@ -1216,11 +1106,10 @@ export function compileSelect( return buildOrmQueryPlan(contract, ast, params, state.annotations); } -export function compileSelectWithIncludeStrategy( +export function compileSelectWithIncludes( contract: Contract, tableName: string, state: CollectionState, - strategy: 'lateral' | 'correlated', modelName?: string, ): SqlQueryPlan> { const includeJoins: JoinAst[] = []; @@ -1235,12 +1124,6 @@ export function compileSelectWithIncludeStrategy( } for (const include of state.includes) { - if (strategy === 'lateral') { - const artifact = buildLateralIncludeArtifacts(contract, tableName, include); - includeJoins.push(artifact.join); - includeProjection.push(artifact.projection); - continue; - } const artifact = buildCorrelatedIncludeProjection(contract, tableName, include); includeProjection.push(artifact.projection); } diff --git a/packages/3-extensions/sql-orm-client/src/query-plan.ts b/packages/3-extensions/sql-orm-client/src/query-plan.ts index 7269f38029..35149d8e76 100644 --- a/packages/3-extensions/sql-orm-client/src/query-plan.ts +++ b/packages/3-extensions/sql-orm-client/src/query-plan.ts @@ -14,4 +14,4 @@ export { compileUpdateReturning, compileUpsertReturning, } from './query-plan-mutations'; -export { compileSelect, compileSelectWithIncludeStrategy } from './query-plan-select'; +export { compileSelect, compileSelectWithIncludes } from './query-plan-select'; diff --git a/packages/3-extensions/sql-orm-client/test/collection-dispatch.test.ts b/packages/3-extensions/sql-orm-client/test/collection-dispatch.test.ts index 28fc9a743f..e829131694 100644 --- a/packages/3-extensions/sql-orm-client/test/collection-dispatch.test.ts +++ b/packages/3-extensions/sql-orm-client/test/collection-dispatch.test.ts @@ -71,13 +71,10 @@ describe('collection-dispatch', () => { }); it('dispatchCollectionRows() depth-1 include with emitted-shape capabilities fires a single SQL execution (regression guard for namespaced capability lookup)', async () => { - // Guards against regressing the fix that taught `selectIncludeStrategy` - // to read capability flags from the contract's `targetFamily` and - // `target` namespaces. Prior to that fix, every emitted contract fell - // back to multi-query for nested includes — silently, because - // functional correctness was unaffected. This test fails fast if the - // regression returns: an emitted-shape contract should resolve a - // depth-1 include in one SQL execution, not two. + // Guards against regressing single-query include dispatch. This test + // fails fast if a multi-query fallback returns: an emitted-shape + // contract should resolve a depth-1 include in one SQL execution, + // not two. const contract = withEmittedSqlCapabilities(getTestContract()); const { collection, runtime } = createCollectionFor('User', contract); const scoped = collection.select('name').include('posts'); @@ -102,11 +99,10 @@ describe('collection-dispatch', () => { it('dispatchCollectionRows() depth-2 nested include with emitted-shape capabilities fires a single SQL execution', async () => { // Regression guard for the TML-2594 fix: depth-2 includes used to - // unconditionally fall back to the multi-query strategy via the - // `hasNestedIncludes` arm of `dispatchWithIncludeStrategy`, regardless - // of the contract's declared capabilities. On an emitted-shape - // contract that advertises `postgres.lateral` + `postgres.jsonAgg`, - // a `users -> posts -> comments` tree should resolve in one SQL + // unconditionally fall back to a multi-query path, regardless of the + // contract's declared capabilities. On an emitted-shape contract + // that advertises `postgres.lateral` + `postgres.jsonAgg`, a + // `users -> posts -> comments` tree should resolve in one SQL // execution, not three (parent + posts + comments). const contract = withEmittedSqlCapabilities(getTestContract()); const { collection, runtime } = createCollectionFor('User', contract); @@ -114,16 +110,16 @@ describe('collection-dispatch', () => { .select('name') .include('posts', (posts) => posts.select('title').include('comments')); - // The lateral builder produces one JSON column per top-level include; - // nested includes appear as nested JSON values (already parsed by - // JSON.parse inside the include payload — they are not stringified - // a second time). This shape mirrors what `json_array_agg` over a - // LATERAL JOIN with a nested LATERAL JOIN actually emits. + // The correlated builder produces one JSON column per top-level + // include; nested includes appear as nested JSON values (already + // parsed by JSON.parse inside the include payload — they are not + // stringified a second time). This shape mirrors what `json_array_agg` + // over a correlated subquery with a nested correlated subquery emits. // // The posts payload only carries `title` and `comments` because the // SQL projection is restricted by `.select('title')` plus the nested // aggregate column. Join keys (`posts.user_id`, `comments.post_id`) - // are referenced by WHERE clauses inside the lateral and never + // are referenced by WHERE clauses inside the subquery and never // projected to the parent's result row. runtime.setNextResults([ [ @@ -164,8 +160,8 @@ describe('collection-dispatch', () => { it('dispatchCollectionRows() depth-2 mixed cardinality (to-many -> to-one) fires a single SQL execution', async () => { // Same regression guard, but covers the to-one leg of the depth-2 - // tree: `users -> posts -> author`. The lateral builder must - // recursively wire a nested LATERAL JOIN even when the inner edge + // tree: `users -> posts -> author`. The correlated builder must + // recursively wire a nested subquery even when the inner edge // collapses to a single object via `coerceSingleQueryIncludeResult`. const contract = withEmittedSqlCapabilities(getTestContract()); const { collection, runtime } = createCollectionFor('User', contract); @@ -343,7 +339,7 @@ describe('collection-dispatch', () => { // Single-query include child-row codec decoding — DEFERRED follow-up. // // The three `it.skip` blocks below are placeholders for the case where the - // single-query include strategy (lateral / correlated jsonb_agg payload) + // single-query include builder (correlated jsonb_agg payload) // routes embedded child rows through the codec registry and surfaces // decoded values (or wrapped failures) on each child cell. The titles // describe what each case would assert under the single-path always-await diff --git a/packages/3-extensions/sql-orm-client/test/include-strategy.test.ts b/packages/3-extensions/sql-orm-client/test/include-strategy.test.ts deleted file mode 100644 index 082674a367..0000000000 --- a/packages/3-extensions/sql-orm-client/test/include-strategy.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { selectIncludeStrategy } from '../src/include-strategy'; -import { getTestContract, withCapabilities } from './helpers'; - -// The default test contract has `target: 'postgres'`, `targetFamily: 'sql'`, -// and capabilities populated under those two namespaces. The strategy -// selector reads only those namespaces, so each test uses -// `withCapabilities(...)` to swap in the override the scenario needs. - -describe('selectIncludeStrategy', () => { - it('returns correlated when the lateral capability is absent', () => { - // The read path is capability-gated to two single-query builders: - // lateral (Postgres) and correlated subqueries (any other jsonAgg - // target). Absent the `lateral` flag, the selector falls to - // correlated. Targets that declare neither `lateral` nor `jsonAgg` - // are unsupported on the read path (see selectIncludeStrategy docs). - const contract = withCapabilities(getTestContract(), {}); - - expect(selectIncludeStrategy(contract)).toBe('correlated'); - }); - - it('returns correlated when jsonAgg is enabled in the family namespace without lateral', () => { - const contract = withCapabilities(getTestContract(), { - sql: { jsonAgg: true }, - }); - - expect(selectIncludeStrategy(contract)).toBe('correlated'); - }); - - it('returns lateral when both flags are enabled in the same namespace', () => { - const contract = withCapabilities(getTestContract(), { - postgres: { jsonAgg: true, lateral: true }, - }); - - expect(selectIncludeStrategy(contract)).toBe('lateral'); - }); - - it('returns lateral when flags are split across family and target namespaces', () => { - // Real-world shape: SQL family declares `jsonAgg`; the postgres - // target adds `lateral` on top. - const contract = withCapabilities(getTestContract(), { - sql: { jsonAgg: true }, - postgres: { lateral: true }, - }); - - expect(selectIncludeStrategy(contract)).toBe('lateral'); - }); - - it('ignores capability flags in unrelated namespaces', () => { - // The default test contract's target/family are 'postgres' / 'sql'. - // A `mongo: { lateral: true }` namespace must not enable lateral - // on a postgres runtime — namespaces are scoped to the running - // target/family. Without lateral in scope, the selector falls to - // correlated. - const contract = withCapabilities(getTestContract(), { - mongo: { jsonAgg: true, lateral: true }, - nonsense: { lateral: true }, - }); - - expect(selectIncludeStrategy(contract)).toBe('correlated'); - }); - - it('treats a non-boolean lateral value as missing', () => { - // The Contract type declares capability values as `boolean`. Anything - // else (string, object, undefined) is treated as not present, so a - // bogus `lateral` value falls through to correlated. The cast on - // `'yes'` is deliberate — we're feeding an invalid value through a - // valid-typed contract to exercise the runtime check. - const contract = withCapabilities(getTestContract(), { - sql: { jsonAgg: true }, - postgres: { lateral: 'yes' as unknown as boolean }, - }); - - expect(selectIncludeStrategy(contract)).toBe('correlated'); - }); - - it('treats explicit `false` as not enabled', () => { - const contract = withCapabilities(getTestContract(), { - sql: { jsonAgg: true, lateral: false }, - }); - - expect(selectIncludeStrategy(contract)).toBe('correlated'); - }); -}); 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 9d50eb911e..dea944e88d 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 @@ -20,7 +20,7 @@ import { TableSource, } from '@prisma-next/sql-relational-core/ast'; import { describe, expect, it } from 'vitest'; -import { compileSelect, compileSelectWithIncludeStrategy } from '../src/query-plan-select'; +import { compileSelect, compileSelectWithIncludes } from '../src/query-plan-select'; import { emptyState } from '../src/types'; import { bindWhereExpr } from '../src/where-binding'; import { baseContract, createCollection, createCollectionFor } from './collection-fixtures'; @@ -57,14 +57,14 @@ function expectDerivedTableSource(source: unknown): asserts source is DerivedTab expect(source).toBeInstanceOf(DerivedTableSource); } -describe('compileSelectWithIncludeStrategy', () => { +describe('compileSelectWithIncludes', () => { it('collects params in AST traversal order (includes before top-level)', () => { const { collection } = createCollection(); const state = collection .where((user) => user.name.eq('Alice')) .include('posts', (posts) => posts.where((post) => post.views.gte(100))).state; - const plan = compileSelectWithIncludeStrategy(baseContract, 'users', state, 'correlated'); + const plan = compileSelectWithIncludes(baseContract, 'users', state); expect(plan.params).toEqual([100, 'Alice']); expect(paramCodecs(plan)).toEqual([ codecForColumn('posts', 'views'), @@ -285,7 +285,7 @@ describe('compileSelectWithIncludeStrategy', () => { ]); }); - it('builds lateral include joins with child distinctOn and offset', () => { + it('builds include subqueries with child distinctOn and offset', () => { const { collection } = createCollection(); const state = collection.include('posts', (posts) => posts @@ -295,15 +295,14 @@ describe('compileSelectWithIncludeStrategy', () => { .take(2), ).state; - const plan = compileSelectWithIncludeStrategy(baseContract, 'users', state, 'lateral'); + const plan = compileSelectWithIncludes(baseContract, 'users', state); expectSelectAst(plan.ast); + expect(plan.ast.joins ?? []).toHaveLength(0); - const join = plan.ast.joins?.[0]; - expect(join?.kind).toBe('join'); - expect(join?.lateral).toBe(true); - expectDerivedTableSource(join?.source); + const postsProjection = plan.ast.projection.find((item) => item.alias === 'posts'); + expectSubqueryExpr(postsProjection?.expr); - const aggregateQuery = join.source.query; + const aggregateQuery = postsProjection.expr.query; expectDerivedTableSource(aggregateQuery.from); const childRows = aggregateQuery.from.query; @@ -312,33 +311,28 @@ describe('compileSelectWithIncludeStrategy', () => { expect(childRows.limit).toBe(2); }); - // Lateral lowers scalar reducers as `LEFT JOIN LATERAL (SELECT - // json_build_object('value', AGG(...)) AS FROM WHERE - // = . [AND ]) AS ON TRUE`. + // Each scalar reducer lowers to a correlated subquery whose + // projection is the `json_build_object('value', AGG(...))` envelope. // The JSON wrapper lets the value travel through the existing // include-payload decoder (which JSON.parse'es the column and pulls - // `.value` out) — no codec wiring needed on the outer projection, - // and JSON-level numeric encoding matches the multi-query path's - // observable shape (count: number, sum/avg/min/max: number | null). - describe('lateral scalar reducers', () => { - function extractScalarLateralSelect(plan: { ast: unknown }, alias: string): SelectAst { + // `.value` out) — no codec wiring needed on the outer projection. + describe('correlated scalar reducers', () => { + function extractScalarCorrelatedSubquery( + plan: { ast: unknown }, + relationName: string, + ): SelectAst { expectSelectAst(plan.ast); - const join = plan.ast.joins?.find( - (candidate) => - candidate.source.kind === 'derived-table-source' && candidate.source.alias === alias, - ); - expect(join?.kind).toBe('join'); - expect(join?.lateral).toBe(true); - expectDerivedTableSource(join?.source); - return join.source.query; + const projection = plan.ast.projection.find((item) => item.alias === relationName); + expectSubqueryExpr(projection?.expr); + return projection.expr.query; } function expectAggregateProjection( - lateralSelect: SelectAst, + subquerySelect: SelectAst, relationName: string, expectedAggregate: AnyExpression, ): void { - expect(lateralSelect.projection).toEqual([ + expect(subquerySelect.projection).toEqual([ ProjectionItem.of( relationName, JsonObjectExpr.fromEntries([JsonObjectExpr.entry('value', expectedAggregate)]), @@ -346,84 +340,34 @@ describe('compileSelectWithIncludeStrategy', () => { ]); } - it('emits LATERAL COUNT(*) for a bare count() include with no refinements', () => { + it('emits correlated COUNT(*) for a bare count() include', () => { const { collection } = createCollection(); const state = collection.include('posts', (posts) => posts.count()).state; - const plan = compileSelectWithIncludeStrategy(baseContract, 'users', state, 'lateral'); - const lateralSelect = extractScalarLateralSelect(plan, 'posts_lateral'); + const plan = compileSelectWithIncludes(baseContract, 'users', state); + const subquery = extractScalarCorrelatedSubquery(plan, 'posts'); - expectAggregateProjection(lateralSelect, 'posts', AggregateExpr.count()); - expect(lateralSelect.where).toEqual( + expectAggregateProjection(subquery, 'posts', AggregateExpr.count()); + expect(subquery.where).toEqual( BinaryExpr.eq(ColumnRef.of('posts', 'user_id'), ColumnRef.of('users', 'id')), ); - // Aggregate scope must not carry pagination clauses. - expect(lateralSelect.limit).toBeUndefined(); - expect(lateralSelect.offset).toBeUndefined(); - expect(lateralSelect.orderBy).toBeUndefined(); - - // Outer projection references the lateral alias's relation column. - expectSelectAst(plan.ast); - const outerPostsProjection = plan.ast.projection.find((item) => item.alias === 'posts'); - expect(outerPostsProjection?.expr).toEqual(ColumnRef.of('posts_lateral', 'posts')); + // Aggregate scope omits pagination / orderBy. + expect(subquery.limit).toBeUndefined(); + expect(subquery.offset).toBeUndefined(); + expect(subquery.orderBy).toBeUndefined(); }); - it('emits LATERAL COUNT(*) over the where-filtered relation', () => { + it('emits correlated COUNT(*) over the where-filtered relation', () => { const { collection } = createCollection(); const state = collection.include('posts', (posts) => posts.where((post) => post.views.gte(100)).count(), ).state; - const plan = compileSelectWithIncludeStrategy(baseContract, 'users', state, 'lateral'); - const lateralSelect = extractScalarLateralSelect(plan, 'posts_lateral'); - - expectAggregateProjection(lateralSelect, 'posts', AggregateExpr.count()); - expect(lateralSelect.where).toEqual( - AndExpr.of([ - BinaryExpr.eq(ColumnRef.of('posts', 'user_id'), ColumnRef.of('users', 'id')), - bindWhereExpr( - baseContract, - BinaryExpr.gte(ColumnRef.of('posts', 'views'), LiteralExpr.of(100)), - ), - ]), - ); - }); - - // Pagination on a scalar refine composes through to the aggregate - // scope: `take(N)` / `skip(M)` shape the row set the aggregate - // sees, matching the natural compositional semantic of the chain. - // The lateral SELECT wraps the source in a derived inner SELECT - // that materialises the where-filtered, paginated rows; the outer - // aggregate then runs over that. - it('pagination composes through to the LATERAL COUNT scope', () => { - const { collection } = createCollection(); - const state = collection.include('posts', (posts) => - posts - .where((post) => post.views.gte(100)) - .skip(5) - .take(10) - .count(), - ).state; + const plan = compileSelectWithIncludes(baseContract, 'users', state); + const subquery = extractScalarCorrelatedSubquery(plan, 'posts'); - const plan = compileSelectWithIncludeStrategy(baseContract, 'users', state, 'lateral'); - const lateralSelect = extractScalarLateralSelect(plan, 'posts_lateral'); - - // Outer aggregating SELECT: COUNT(*) over the derived inner table, - // no top-level LIMIT/OFFSET (those would only trim the one-row - // aggregate output, not the rows being aggregated). - expectAggregateProjection(lateralSelect, 'posts', AggregateExpr.count()); - expect(lateralSelect.limit).toBeUndefined(); - expect(lateralSelect.offset).toBeUndefined(); - expect(lateralSelect.where).toBeUndefined(); - expectDerivedTableSource(lateralSelect.from); - expect(lateralSelect.from.alias).toBe('posts__scalar'); - - // Inner SELECT carries the where + pagination. The join condition - // and the user's filter both live in the inner where. - const innerSelect = lateralSelect.from.query; - expect(innerSelect.limit).toBe(10); - expect(innerSelect.offset).toBe(5); - expect(innerSelect.where).toEqual( + expectAggregateProjection(subquery, 'posts', AggregateExpr.count()); + expect(subquery.where).toEqual( AndExpr.of([ BinaryExpr.eq(ColumnRef.of('posts', 'user_id'), ColumnRef.of('users', 'id')), bindWhereExpr( @@ -434,62 +378,6 @@ describe('compileSelectWithIncludeStrategy', () => { ); }); - it('emits LATERAL SUM(col) for sum()', () => { - const { collection } = createCollection(); - const state = collection.include('posts', (posts) => posts.sum('views')).state; - - const plan = compileSelectWithIncludeStrategy(baseContract, 'users', state, 'lateral'); - const lateralSelect = extractScalarLateralSelect(plan, 'posts_lateral'); - - expectAggregateProjection( - lateralSelect, - 'posts', - AggregateExpr.sum(ColumnRef.of('posts', 'views')), - ); - }); - - it('emits LATERAL AVG(col) for avg()', () => { - const { collection } = createCollection(); - const state = collection.include('posts', (posts) => posts.avg('views')).state; - - const plan = compileSelectWithIncludeStrategy(baseContract, 'users', state, 'lateral'); - const lateralSelect = extractScalarLateralSelect(plan, 'posts_lateral'); - - expectAggregateProjection( - lateralSelect, - 'posts', - AggregateExpr.avg(ColumnRef.of('posts', 'views')), - ); - }); - - it('emits LATERAL MIN(col) for min()', () => { - const { collection } = createCollection(); - const state = collection.include('posts', (posts) => posts.min('views')).state; - - const plan = compileSelectWithIncludeStrategy(baseContract, 'users', state, 'lateral'); - const lateralSelect = extractScalarLateralSelect(plan, 'posts_lateral'); - - expectAggregateProjection( - lateralSelect, - 'posts', - AggregateExpr.min(ColumnRef.of('posts', 'views')), - ); - }); - - it('emits LATERAL MAX(col) for max()', () => { - const { collection } = createCollection(); - const state = collection.include('posts', (posts) => posts.max('views')).state; - - const plan = compileSelectWithIncludeStrategy(baseContract, 'users', state, 'lateral'); - const lateralSelect = extractScalarLateralSelect(plan, 'posts_lateral'); - - expectAggregateProjection( - lateralSelect, - 'posts', - AggregateExpr.max(ColumnRef.of('posts', 'views')), - ); - }); - // `orderBy` on a scalar refine is meaningless for an aggregate. // Silently drop it at SQL level — matches existing behaviour for // other irrelevant clauses (e.g. ignoring select() in scalar context). @@ -499,358 +387,13 @@ describe('compileSelectWithIncludeStrategy', () => { posts.orderBy((post) => post.id.asc()).count(), ).state; - const plan = compileSelectWithIncludeStrategy(baseContract, 'users', state, 'lateral'); - const lateralSelect = extractScalarLateralSelect(plan, 'posts_lateral'); - expect(lateralSelect.orderBy).toBeUndefined(); - }); - - // `distinct(cols).orderBy(c).take(N).sum(...)` must aggregate the - // ordered top-N deduped rows, not an arbitrary N. The ROW_NUMBER - // dedup wrap strips ordering from its output, so without an explicit - // reapplication of orderBy on the wrapped alias, LIMIT picks an - // implementation-defined subset — wrong for SUM/AVG/MIN/MAX over - // a top-N slice. Mirrors the row-include path's distinct lowering. - it('reapplies orderBy after the ROW_NUMBER dedup wrap so LIMIT slices the ordered top N', () => { - const { collection } = createCollection(); - const state = collection.include('posts', (posts) => - posts - .distinct('title') - .orderBy((post) => post.views.desc()) - .take(2) - .sum('views'), - ).state; - - const plan = compileSelectWithIncludeStrategy(baseContract, 'users', state, 'lateral'); - const lateralSelect = extractScalarLateralSelect(plan, 'posts_lateral'); - - // Outer aggregating SELECT: SUM(.views) over the - // derived inner table. No top-level LIMIT — that lives on the inner. - expectAggregateProjection( - lateralSelect, - 'posts', - AggregateExpr.sum(ColumnRef.of('posts__scalar', 'views')), - ); - expect(lateralSelect.limit).toBeUndefined(); - expect(lateralSelect.offset).toBeUndefined(); - expectDerivedTableSource(lateralSelect.from); - expect(lateralSelect.from.alias).toBe('posts__scalar'); - - // Inner SELECT: post-ROW_NUMBER-dedup wrap. The wrap is itself a - // derived `posts__scalar_distinct` source; subsequent LIMIT and - // the reapplied ORDER BY live on the outer of that wrap. - const innerSelect = lateralSelect.from.query; - expect(innerSelect.limit).toBe(2); - expect(innerSelect.offset).toBeUndefined(); - expectDerivedTableSource(innerSelect.from); - expect(innerSelect.from.alias).toBe('posts__scalar_distinct'); - - // The reapplied orderBy references the hidden order column on the - // ranked alias, NOT the bare `posts.views` (which is out of scope - // post-wrap). The hidden column is named `${relName}__order_${idx}`. - expect(innerSelect.orderBy).toEqual([ - new OrderByItem(ColumnRef.of('posts__scalar_distinct', 'posts__order_0'), 'desc'), - ]); - }); - - // Recursive carve-out: a `count()` nested inside a row include must - // produce its own LATERAL inside the parent row's SELECT, and the - // parent's json_object payload should reference that nested lateral's - // column verbatim — JSON-on-JSON nesting Just Works because PG's - // json_build_object embeds json values directly. - it('emits a nested LATERAL for a count() inside a row include', () => { - const { collection } = createCollection(); - const state = collection.include('posts', (posts) => - posts.include('comments', (comments) => comments.count()), - ).state; - - const plan = compileSelectWithIncludeStrategy(baseContract, 'users', state, 'lateral'); - expectSelectAst(plan.ast); - - // Top-level posts lateral. - const postsLateralSelect = extractScalarLateralSelect(plan, 'posts_lateral'); - // Posts' inner SELECT (rows feeding the json_agg) carries the - // nested comments lateral. - expectDerivedTableSource(postsLateralSelect.from); - const postsRows = postsLateralSelect.from.query; - const commentsJoin = postsRows.joins?.find( - (candidate) => - candidate.source.kind === 'derived-table-source' && - candidate.source.alias === 'comments_lateral', - ); - expect(commentsJoin?.lateral).toBe(true); - expectDerivedTableSource(commentsJoin?.source); - expect(commentsJoin.source.query.projection).toEqual([ - ProjectionItem.of( - 'comments', - JsonObjectExpr.fromEntries([JsonObjectExpr.entry('value', AggregateExpr.count())]), - ), - ]); - }); - }); - - // Lateral lowers `combine({ a, b, ... })` as a single LATERAL JOIN - // whose inner SELECT cross-joins each branch as a derived table and - // projects `json_build_object('a', a_alias., 'b', b_alias., ...)`. - // Row branches reuse the standalone row builder; scalar branches - // reuse the standalone scalar builder (preserving the `{value: - // }` envelope inside the combined JSON — the decoder - // unwraps per-branch). - describe('lateral combine() packing', () => { - function extractCombineLateralSelect(plan: { ast: unknown }, alias: string): SelectAst { - expectSelectAst(plan.ast); - const join = plan.ast.joins?.find( - (candidate) => - candidate.source.kind === 'derived-table-source' && candidate.source.alias === alias, - ); - expect(join?.kind).toBe('join'); - expect(join?.lateral).toBe(true); - expectDerivedTableSource(join?.source); - return join.source.query; - } - - function expectCombineJsonProjection( - lateralSelect: SelectAst, - relationName: string, - expectedEntries: ReadonlyArray, - ): void { - expect(lateralSelect.projection).toEqual([ - ProjectionItem.of( - relationName, - JsonObjectExpr.fromEntries( - expectedEntries.map(([branchName, branchAlias]) => - JsonObjectExpr.entry(branchName, ColumnRef.of(branchAlias, relationName)), - ), - ), - ), - ]); - } - - it('packs a row + scalar combine into one LATERAL with json_build_object', () => { - const { collection } = createCollection(); - const state = collection.include('posts', (posts) => - posts.combine({ - recent: posts.orderBy((p) => p.id.desc()).take(3), - total: posts.count(), - }), - ).state; - - const plan = compileSelectWithIncludeStrategy(baseContract, 'users', state, 'lateral'); - const lateralSelect = extractCombineLateralSelect(plan, 'posts_lateral'); - - expectCombineJsonProjection(lateralSelect, 'posts', [ - ['recent', 'posts__combine__recent'], - ['total', 'posts__combine__total'], - ]); - - // FROM , INNER JOIN ON TRUE. - expectDerivedTableSource(lateralSelect.from); - expect(lateralSelect.from.alias).toBe('posts__combine__recent'); - expect(lateralSelect.joins).toHaveLength(1); - const totalJoin = lateralSelect.joins?.[0]; - expect(totalJoin?.joinType).toBe('inner'); - expect(totalJoin?.lateral).toBe(false); - expect(totalJoin?.on).toEqual(AndExpr.true()); - expectDerivedTableSource(totalJoin?.source); - expect(totalJoin.source.alias).toBe('posts__combine__total'); - - // Each branch keeps its own FK correlation in WHERE. - // The scalar branch (total): json_build_object('value', count(*)) AS posts - const totalBranchSelect = totalJoin.source.query; - expect(totalBranchSelect.projection).toEqual([ - ProjectionItem.of( - 'posts', - JsonObjectExpr.fromEntries([JsonObjectExpr.entry('value', AggregateExpr.count())]), - ), - ]); - expect(totalBranchSelect.where).toEqual( - BinaryExpr.eq(ColumnRef.of('posts', 'user_id'), ColumnRef.of('users', 'id')), - ); - // Pagination NEVER enters the scalar branch's scope. - expect(totalBranchSelect.limit).toBeUndefined(); - expect(totalBranchSelect.offset).toBeUndefined(); - - // The row branch (recent): paginated rows, json_agg'd. - expectDerivedTableSource(lateralSelect.from); - const recentBranchSelect = lateralSelect.from.query; - expectDerivedTableSource(recentBranchSelect.from); - const recentRows = recentBranchSelect.from.query; - expect(recentRows.limit).toBe(3); - }); - - it('packs two scalar branches (count + sum) into one LATERAL', () => { - const { collection } = createCollection(); - const state = collection.include('posts', (posts) => - posts.combine({ - a: posts.count(), - b: posts.sum('views'), - }), - ).state; - - const plan = compileSelectWithIncludeStrategy(baseContract, 'users', state, 'lateral'); - const lateralSelect = extractCombineLateralSelect(plan, 'posts_lateral'); - - expectCombineJsonProjection(lateralSelect, 'posts', [ - ['a', 'posts__combine__a'], - ['b', 'posts__combine__b'], - ]); - - // Branch a: SELECT json_build_object('value', count(*)) AS posts FROM posts WHERE FK - expectDerivedTableSource(lateralSelect.from); - const aSelect = lateralSelect.from.query; - expect(aSelect.projection).toEqual([ - ProjectionItem.of( - 'posts', - JsonObjectExpr.fromEntries([JsonObjectExpr.entry('value', AggregateExpr.count())]), - ), - ]); - - // Branch b: SELECT json_build_object('value', sum(views)) AS posts FROM posts WHERE FK - const bJoin = lateralSelect.joins?.[0]; - expectDerivedTableSource(bJoin?.source); - const bSelect = bJoin.source.query; - expect(bSelect.projection).toEqual([ - ProjectionItem.of( - 'posts', - JsonObjectExpr.fromEntries([ - JsonObjectExpr.entry('value', AggregateExpr.sum(ColumnRef.of('posts', 'views'))), - ]), - ), - ]); - }); - - it('keeps each branch independently scoped under divergent where filters', () => { - const { collection } = createCollection(); - const state = collection.include('posts', (posts) => - posts.combine({ - popular: posts.where((p) => p.views.gte(200)).count(), - mediocre: posts.where((p) => p.views.lt(200)).count(), - }), - ).state; - - const plan = compileSelectWithIncludeStrategy(baseContract, 'users', state, 'lateral'); - const lateralSelect = extractCombineLateralSelect(plan, 'posts_lateral'); - - const fkExpr = BinaryExpr.eq(ColumnRef.of('posts', 'user_id'), ColumnRef.of('users', 'id')); - const popularWhere = bindWhereExpr( - baseContract, - BinaryExpr.gte(ColumnRef.of('posts', 'views'), LiteralExpr.of(200)), - ); - const mediocreWhere = bindWhereExpr( - baseContract, - BinaryExpr.lt(ColumnRef.of('posts', 'views'), LiteralExpr.of(200)), - ); - - expectDerivedTableSource(lateralSelect.from); - const popularSelect = lateralSelect.from.query; - expect(popularSelect.where).toEqual(AndExpr.of([fkExpr, popularWhere])); - - const mediocreJoin = lateralSelect.joins?.[0]; - expectDerivedTableSource(mediocreJoin?.source); - const mediocreSelect = mediocreJoin.source.query; - expect(mediocreSelect.where).toEqual(AndExpr.of([fkExpr, mediocreWhere])); - }); - - // Distinct interplay: the spec promises the row branch's existing - // distinct(cols) lowering (ROW_NUMBER wrap from TML-2656) is reused - // verbatim; scalar branches see the where-only relation. This pins - // the row branch's ROW_NUMBER lowering survives into the combine - // packing without combine-specific distinct handling. - it('row branch with distinct() keeps its ROW_NUMBER lowering', () => { - const { collection } = createCollection(); - const state = collection.include('posts', (posts) => - posts.combine({ - unique: posts.distinct('title'), - total: posts.count(), - }), - ).state; - - const plan = compileSelectWithIncludeStrategy(baseContract, 'users', state, 'lateral'); - const lateralSelect = extractCombineLateralSelect(plan, 'posts_lateral'); - - // The row branch's inner FROM source is the rows-derived-table - // wrap; its query carries the `__prisma_distinct_rn` projection - // (the ROW_NUMBER lowering signature). - expectDerivedTableSource(lateralSelect.from); - const uniqueBranchSelect = lateralSelect.from.query; - expectDerivedTableSource(uniqueBranchSelect.from); - const rowsWrap = uniqueBranchSelect.from.query; - // The ROW_NUMBER wrap aliases to `${include.relationName}__distinct`. - expect(rowsWrap.from.kind === 'derived-table-source').toBe(true); - }); - - // The dispatch path admits combine under lateral. Top-level row + - // combine sibling: each becomes its own outer LATERAL; the planner - // wires both projections into the parent SELECT. - it('admits combine alongside a plain row include at the same level', () => { - const { collection } = createCollection(); - const state = collection - .include('posts', (posts) => - posts.combine({ - a: posts.count(), - b: posts.sum('views'), - }), - ) - .include('profile').state; - - const plan = compileSelectWithIncludeStrategy(baseContract, 'users', state, 'lateral'); - expectSelectAst(plan.ast); - // Both includes contribute joins. - const aliases = plan.ast.joins?.map((j) => - j.source.kind === 'derived-table-source' ? j.source.alias : undefined, - ); - expect(aliases).toEqual(expect.arrayContaining(['posts_lateral', 'profile_lateral'])); - }); - }); - - // Correlated mirror of the lateral scalar tests: each scalar reducer - // lowers to a correlated subquery whose projection is the same - // `json_build_object('value', AGG(...))` envelope used by the lateral - // builder. The structural difference is the SQL primitive — a - // SubqueryExpr in the outer projection vs. a LEFT JOIN LATERAL - // contributing a joined derived table. - describe('correlated scalar reducers', () => { - function extractScalarCorrelatedSubquery( - plan: { ast: unknown }, - relationName: string, - ): SelectAst { - expectSelectAst(plan.ast); - const projection = plan.ast.projection.find((item) => item.alias === relationName); - expectSubqueryExpr(projection?.expr); - return projection.expr.query; - } - - function expectAggregateProjection( - subquerySelect: SelectAst, - relationName: string, - expectedAggregate: AnyExpression, - ): void { - expect(subquerySelect.projection).toEqual([ - ProjectionItem.of( - relationName, - JsonObjectExpr.fromEntries([JsonObjectExpr.entry('value', expectedAggregate)]), - ), - ]); - } - - it('emits correlated COUNT(*) for a bare count() include', () => { - const { collection } = createCollection(); - const state = collection.include('posts', (posts) => posts.count()).state; - - const plan = compileSelectWithIncludeStrategy(baseContract, 'users', state, 'correlated'); + const plan = compileSelectWithIncludes(baseContract, 'users', state); const subquery = extractScalarCorrelatedSubquery(plan, 'posts'); - - expectAggregateProjection(subquery, 'posts', AggregateExpr.count()); - expect(subquery.where).toEqual( - BinaryExpr.eq(ColumnRef.of('posts', 'user_id'), ColumnRef.of('users', 'id')), - ); - // Aggregate scope omits pagination / orderBy. - expect(subquery.limit).toBeUndefined(); - expect(subquery.offset).toBeUndefined(); expect(subquery.orderBy).toBeUndefined(); }); - // Correlated mirror: pagination on a scalar refine composes - // through to the aggregate scope, same as the lateral path. + // Pagination on a scalar refine composes through to the aggregate + // scope: `take(N)` / `skip(M)` shape the row set the aggregate sees. it('pagination composes through to the correlated COUNT scope', () => { const { collection } = createCollection(); const state = collection.include('posts', (posts) => @@ -861,7 +404,7 @@ describe('compileSelectWithIncludeStrategy', () => { .count(), ).state; - const plan = compileSelectWithIncludeStrategy(baseContract, 'users', state, 'correlated'); + const plan = compileSelectWithIncludes(baseContract, 'users', state); const subquery = extractScalarCorrelatedSubquery(plan, 'posts'); expectAggregateProjection(subquery, 'posts', AggregateExpr.count()); @@ -885,11 +428,11 @@ describe('compileSelectWithIncludeStrategy', () => { ); }); - // Correlated mirror of the lateral `reapplies orderBy after the - // ROW_NUMBER dedup wrap` test. `buildIncludeChildScalarSelect` is - // shared by both strategies so the fix lands once; this pins that - // the correlated subquery shape is symmetric. - it('reapplies orderBy after the ROW_NUMBER dedup wrap under correlated', () => { + // `distinct(cols).orderBy(c).take(N).sum(...)` must aggregate the + // ordered top-N deduped rows. The ROW_NUMBER dedup wrap strips + // ordering from its output, so the orderBy is reapplied on the + // wrapped alias before LIMIT slices the deduped rows. + it('reapplies orderBy after the ROW_NUMBER dedup wrap', () => { const { collection } = createCollection(); const state = collection.include('posts', (posts) => posts @@ -899,7 +442,7 @@ describe('compileSelectWithIncludeStrategy', () => { .sum('views'), ).state; - const plan = compileSelectWithIncludeStrategy(baseContract, 'users', state, 'correlated'); + const plan = compileSelectWithIncludes(baseContract, 'users', state); const subquery = extractScalarCorrelatedSubquery(plan, 'posts'); expectAggregateProjection( @@ -929,7 +472,7 @@ describe('compileSelectWithIncludeStrategy', () => { for (const [fn, expected] of reducers) { const { collection } = createCollection(); const state = collection.include('posts', (posts) => posts[fn]('views')).state; - const plan = compileSelectWithIncludeStrategy(baseContract, 'users', state, 'correlated'); + const plan = compileSelectWithIncludes(baseContract, 'users', state); const subquery = extractScalarCorrelatedSubquery(plan, 'posts'); expectAggregateProjection(subquery, 'posts', expected); } @@ -943,7 +486,7 @@ describe('compileSelectWithIncludeStrategy', () => { posts.include('comments', (comments) => comments.count()), ).state; - const plan = compileSelectWithIncludeStrategy(baseContract, 'users', state, 'correlated'); + const plan = compileSelectWithIncludes(baseContract, 'users', state); const postsSubquery = extractScalarCorrelatedSubquery(plan, 'posts'); // The posts subquery's FROM is the child-rows derived table; its // inner SELECT carries the nested comments correlated subquery as @@ -961,9 +504,9 @@ describe('compileSelectWithIncludeStrategy', () => { }); }); - // Correlated mirror of the lateral combine tests: combine packs into - // a single correlated subquery whose FROM cross-joins per-branch - // derived tables and whose projection is the `json_build_object` + // combine() packs into a single correlated subquery whose FROM + // cross-joins per-branch derived tables and whose projection is the + // `json_build_object` // over those branches. describe('correlated combine() packing', () => { function extractCombineCorrelatedSubquery( @@ -985,7 +528,7 @@ describe('compileSelectWithIncludeStrategy', () => { }), ).state; - const plan = compileSelectWithIncludeStrategy(baseContract, 'users', state, 'correlated'); + const plan = compileSelectWithIncludes(baseContract, 'users', state); const subquery = extractCombineCorrelatedSubquery(plan, 'posts'); // Outer projection is json_build_object referencing per-branch @@ -1020,7 +563,7 @@ describe('compileSelectWithIncludeStrategy', () => { }), ).state; - const plan = compileSelectWithIncludeStrategy(baseContract, 'users', state, 'correlated'); + const plan = compileSelectWithIncludes(baseContract, 'users', state); const subquery = extractCombineCorrelatedSubquery(plan, 'posts'); expectDerivedTableSource(subquery.from); @@ -1053,7 +596,7 @@ describe('compileSelectWithIncludeStrategy', () => { }), ).state; - const plan = compileSelectWithIncludeStrategy(baseContract, 'users', state, 'correlated'); + const plan = compileSelectWithIncludes(baseContract, 'users', state); const subquery = extractCombineCorrelatedSubquery(plan, 'posts'); const fkExpr = BinaryExpr.eq(ColumnRef.of('posts', 'user_id'), ColumnRef.of('users', 'id')); diff --git a/packages/3-extensions/sql-orm-client/test/rich-query-plans.test.ts b/packages/3-extensions/sql-orm-client/test/rich-query-plans.test.ts index 6d7f12a436..db09f802a7 100644 --- a/packages/3-extensions/sql-orm-client/test/rich-query-plans.test.ts +++ b/packages/3-extensions/sql-orm-client/test/rich-query-plans.test.ts @@ -16,7 +16,7 @@ import { compileDeleteReturning, compileGroupedAggregate, compileInsertReturning, - compileSelectWithIncludeStrategy, + compileSelectWithIncludes, compileUpdateReturning, compileUpsertReturning, } from '../src/query-plan'; @@ -42,7 +42,7 @@ describe('SQL ORM rich AST query plans', () => { ) .take(5).state; - const plan = compileSelectWithIncludeStrategy(baseContract, 'users', state, 'correlated'); + const plan = compileSelectWithIncludes(baseContract, 'users', state); expect(plan.ast.kind).toBe('select'); expect(plan.params).toEqual([100, 'Alice']); diff --git a/projects/middleware-intercept-and-cache/follow-ups.md b/projects/middleware-intercept-and-cache/follow-ups.md index bc83e5f5fc..9d84b091a3 100644 --- a/projects/middleware-intercept-and-cache/follow-ups.md +++ b/projects/middleware-intercept-and-cache/follow-ups.md @@ -94,7 +94,7 @@ Land after TML-2143 M1 merges. Doesn't block M2 or M3. The variadic annotation argument is now available on every user-facing query-issuing terminal of `Collection` and `GroupedCollection`: -- **Read terminals (state-driven):** `all`, `first`. Annotations flow via `state.userAnnotations`, which `compileSelect` and `compileSelectWithIncludeStrategy` thread to `buildOrmQueryPlan`. +- **Read terminals (state-driven):** `all`, `first`. Annotations flow via `state.userAnnotations`, which `compileSelect` and `compileSelectWithIncludes` thread to `buildOrmQueryPlan`. - **Read terminals (post-wrap):** `Collection.aggregate`, `GroupedCollection.aggregate`. Annotations are merged into the compiled plan via `mergeUserAnnotations` after `compileAggregate` / `compileGroupedAggregate` runs. - **Write terminals (post-wrap):** `create`, `createAll`, `createCount`, `update`, `updateAll`, `updateCount`, `delete`, `deleteAll`, `deleteCount`, `upsert`. Each post-wraps the compiled mutation plan(s) before dispatch. diff --git a/test/integration/test/sql-orm-client/include.test.ts b/test/integration/test/sql-orm-client/include.test.ts index 5fab6d8fcb..35da7e34b5 100644 --- a/test/integration/test/sql-orm-client/include.test.ts +++ b/test/integration/test/sql-orm-client/include.test.ts @@ -50,8 +50,8 @@ function createUsersCollectionWithCapabilities( // Replace capabilities entirely (rather than merging with base) so the // test's intent is unambiguous. Merging with the base contract's // postgres namespace would leak `postgres.lateral` and `postgres.jsonAgg` - // into the strategy detection — making it impossible to test the - // correlated-only path by passing only `jsonAgg`. + // into the advertised capabilities — making it impossible to assert + // behaviour against a contract that advertises only `jsonAgg`. const contract = { ...base, capabilities, @@ -70,12 +70,11 @@ describe('integration/include', () => { it( 'depth-1 include against an emitted contract fires a single SQL execution (regression guard for namespaced capability lookup)', async () => { - // Guards against regressing the fix that taught `selectIncludeStrategy` - // to read capability flags from the contract's `targetFamily` and - // `target` namespaces. The default `getTestContract()` carries - // `postgres: { lateral: true, jsonAgg: true, ... }` — the emitter's - // actual output shape. Prior to the fix, this exact test would fire - // 2 SQL queries instead of 1, against a real driver. + // Guards against regressing single-query include dispatch. The + // default `getTestContract()` carries `postgres: { lateral: true, + // jsonAgg: true, ... }` — the emitter's actual output shape. A + // depth-1 include must resolve in one SQL execution, not two, + // against a real driver. await withCollectionRuntime(async (runtime) => { const users = createUsersCollection(runtime); @@ -181,7 +180,7 @@ describe('integration/include', () => { { id: 11, title: 'Post B', userId: 1, views: 200, embedding: null, comments: 0 }, { id: 12, title: 'Post C', userId: 2, views: 300, embedding: null, comments: 1 }, ]); - // Scalar `count()` lowers to a `LEFT JOIN LATERAL (SELECT + // Scalar `count()` lowers to a correlated subquery `(SELECT // json_build_object('value', count(*)) ...)` — the whole // parent + counts roll up into one SQL execution. expect(runtime.executions).toHaveLength(1); @@ -193,11 +192,11 @@ describe('integration/include', () => { // Pagination composes through to the scalar aggregate scope: a // `take(N)` / `skip(M)` on a count() refine shapes the row set the // aggregate sees, so `where(W).take(N).count()` returns at most N. - // The lateral builder wraps the source in a derived SELECT that + // The correlated builder wraps the source in a derived SELECT that // materialises the paginated rows and aggregates over that, in a // single SQL execution. it( - 'pagination composes through to scalar aggregate scope under lateral', + 'pagination composes through to scalar aggregate scope', async () => { await withCollectionRuntime(async (runtime) => { const users = createUsersCollection(runtime); @@ -412,9 +411,9 @@ describe('integration/include', () => { posts: { avgViews: null, minViews: null, maxViews: null }, }, ]); - // All three scalar branches now pack into a single LATERAL - // (json_build_object packing three sub-envelopes); the parent - // SELECT rolls up everything into one round-trip. + // All three scalar branches now pack into a single correlated + // subquery (json_build_object packing three sub-envelopes); the + // parent SELECT rolls up everything into one round-trip. expect(runtime.executions).toHaveLength(1); }); }, @@ -423,9 +422,9 @@ describe('integration/include', () => { // TML-2595 worked example: the Pothos `totalCount` shape — a paginated // row branch alongside a count() scalar branch. This is the headline - // case that motivated single-query combine emission: one LATERAL - // packs both branches; the parent + count + page roll up to one SQL - // execution per query. + // case that motivated single-query combine emission: one correlated + // subquery packs both branches; the parent + count + page roll up to + // one SQL execution per query. it( 'TML-2595: include().combine({ recent: take(N), count: count() }) resolves in a single execution', async () => { @@ -546,7 +545,7 @@ describe('integration/include', () => { }, }, ]); - // Three branches (two row + one scalar) pack into one LATERAL. + // Three branches (two row + one scalar) pack into one correlated subquery. expect(runtime.executions).toHaveLength(1); }); }, @@ -554,9 +553,11 @@ describe('integration/include', () => { ); it( - 'single-query include uses lateral strategy when lateral and jsonAgg are enabled', + 'a lateral-capable contract still lowers an include to a correlated subquery', async () => { await withCollectionRuntime(async (runtime) => { + // The `lateral` capability flag is inert for include codegen: + // every include lowers to a correlated subquery regardless. const users = createUsersCollectionWithCapabilities(runtime, { postgres: { jsonAgg: true, lateral: true }, }); @@ -602,18 +603,16 @@ describe('integration/include', () => { ]); expect(runtime.executions).toHaveLength(1); - const ast = runtime.executions[0]?.ast; + const execution = runtime.executions[0]; + const ast = execution?.ast; expectSelectAst(ast); - const includeJoin = ast.joins?.find( - (join) => - join.lateral && - join.source.kind === 'derived-table-source' && - join.source.alias === 'posts_lateral', - ); - expect(includeJoin).toBeDefined(); + // No lateral join is emitted for the include. + expect((ast.joins ?? []).some((join) => join.lateral)).toBe(false); + expect(execution?.sql).not.toContain('LATERAL'); - expectDerivedTableSource(includeJoin?.source); - const includeAggregateProjection = includeJoin.source.query.projection[0]; + const postsProjection = ast.projection.find((item) => item.alias === 'posts'); + expectSubqueryExpr(postsProjection?.expr); + const includeAggregateProjection = postsProjection.expr.query.projection[0]; expectJsonArrayAggExpr(includeAggregateProjection?.expr); expect(includeAggregateProjection.expr.onEmpty).toBe('emptyArray'); expect(includeAggregateProjection.expr.expr.kind).toBe('json-object'); @@ -621,7 +620,7 @@ describe('integration/include', () => { OrderByItem.asc(ColumnRef.of('posts__rows', 'posts__order_0')), ]); - const rowsSource = includeJoin.source.query.from; + const rowsSource = postsProjection.expr.query.from; expectDerivedTableSource(rowsSource); expect(rowsSource.query.limit).toBe(1); expect(rowsSource.query.offset).toBe(1); @@ -632,7 +631,7 @@ describe('integration/include', () => { ); it( - 'single-query lateral include correlates self-relations with child alias', + 'a lateral-capable contract correlates self-relations with child alias', async () => { await withCollectionRuntime(async (runtime) => { const users = createUsersCollectionWithCapabilities(runtime, { @@ -696,28 +695,25 @@ describe('integration/include', () => { expect(runtime.executions).toHaveLength(1); const ast = runtime.executions[0]?.ast; expectSelectAst(ast); - const includeJoin = ast.joins?.find( - (join) => - join.lateral && - join.source.kind === 'derived-table-source' && - join.source.alias === 'invitedUsers_lateral', - ); - expect(includeJoin).toBeDefined(); + // No lateral join is emitted for the include. + expect((ast.joins ?? []).some((join) => join.lateral)).toBe(false); - expectDerivedTableSource(includeJoin?.source); - const includeAggregateProjection = includeJoin.source.query.projection[0]; + const invitedUsersProjection = ast.projection.find((item) => item.alias === 'invitedUsers'); + expectSubqueryExpr(invitedUsersProjection?.expr); + const includeAggregateProjection = invitedUsersProjection.expr.query.projection[0]; expectJsonArrayAggExpr(includeAggregateProjection?.expr); expect(includeAggregateProjection.expr.orderBy).toEqual([ OrderByItem.asc(ColumnRef.of('invitedUsers__rows', 'invitedUsers__order_0')), ]); - const rowsSource = includeJoin.source.query.from; + const rowsSource = invitedUsersProjection.expr.query.from; expectDerivedTableSource(rowsSource); expect(rowsSource.query.projection.map((item) => item.alias)).toContain( 'invitedUsers__order_0', ); const sql = runtime.executions[0]?.sql; + expect(sql).not.toContain('LATERAL'); expect(sql).toContain('"invitedUsers__child"."invited_by_id" = "users"."id"'); }); }, @@ -932,9 +928,9 @@ describe('integration/include', () => { ], }, ]); - // TML-2594 acceptance: depth-2 on a lateral+jsonAgg-capable - // contract (the default postgres test contract) must collapse - // to a single SQL execution via nested LATERAL JOINs. + // TML-2594 acceptance: depth-2 on the default postgres test + // contract must collapse to a single SQL execution via nested + // correlated subqueries. expect(runtime.executions).toHaveLength(1); }); }, @@ -944,9 +940,9 @@ describe('integration/include', () => { it( 'depth-2 include uses correlated subqueries when only jsonAgg is enabled', async () => { - // jsonAgg without lateral → correlated strategy. Same acceptance - // criterion as the lateral case above: one round-trip, regardless - // of depth or row count, when the target advertises the capability. + // jsonAgg without lateral. Same acceptance criterion as the case + // above: one round-trip, regardless of depth or row count, when + // the target advertises the capability. await withCollectionRuntime(async (runtime) => { const users = createUsersCollectionWithCapabilities(runtime, { sql: { jsonAgg: true }, diff --git a/test/integration/test/sql-orm-client/nested-includes-helpers.ts b/test/integration/test/sql-orm-client/nested-includes-helpers.ts index 274f09b115..f20ef6d281 100644 --- a/test/integration/test/sql-orm-client/nested-includes-helpers.ts +++ b/test/integration/test/sql-orm-client/nested-includes-helpers.ts @@ -19,9 +19,10 @@ import type { PgIntegrationRuntime } from './runtime-helpers'; /** * Build a `Collection` whose contract carries the given capability * overrides. The runtime itself still uses the default postgres test - * contract; only `dispatchWithIncludeStrategy` reads from the override, - * so this is the right knob for exercising both single-query dispatch - * strategies (lateral / correlated) against the same real database. + * contract; the override only changes which capability flags the + * contract advertises. Includes always lower to correlated subqueries + * regardless of the `lateral` flag, so this knob exists to prove the + * flag is inert for include codegen against the same real database. */ export function collectionWithCapabilities< ModelName extends keyof ContractModelsMap & string, @@ -36,12 +37,12 @@ export function collectionWithCapabilities< return new Collection({ runtime, context }, modelName as ModelName & string); } -// Capability constants used across the strategy-variant suites. -// Strategy selection reads from `contract.capabilities[targetFamily]` -// ('sql') and `contract.capabilities[target]` ('postgres'). The shapes -// below match what `selectIncludeStrategy` looks up — no more, no less, -// so test intent is unambiguous and a missing capability cannot leak -// through. +// Capability fixtures for the include suites. Include codegen always +// emits correlated subqueries; the `lateral` flag is inert. These two +// shapes — one advertising `lateral` + `jsonAgg`, one advertising only +// `jsonAgg` — let the suites assert that both resolve includes in a +// single correlated SQL execution, and that the lateral flag never +// produces a lateral join for an include. export const LATERAL_CAPABILITIES = { postgres: { lateral: true, jsonAgg: true }, } as const; diff --git a/test/integration/test/sql-orm-client/nested-includes-strategy.test.ts b/test/integration/test/sql-orm-client/nested-includes-strategy.test.ts index 2a287e293b..082aa3f695 100644 --- a/test/integration/test/sql-orm-client/nested-includes-strategy.test.ts +++ b/test/integration/test/sql-orm-client/nested-includes-strategy.test.ts @@ -1,18 +1,19 @@ -// Integration coverage for nested includes (depth >= 2) across both -// single-query dispatch strategies: lateral and correlated. +// Integration coverage for nested includes (depth >= 2) lowered to the +// single correlated-subquery builder. // // Split from `nested-includes.test.ts` for the reason documented in // `./nested-includes-helpers.ts` (per-file test-count threshold of the // prisma/dev PGlite infrastructure). // -// These tests pin the SQL-execution count per strategy, so a future -// regression flipping the dispatch gate is caught at the contract -// level, not by downstream benchmark drift. The cross-strategy -// equivalence tests then assert that both single-query strategies — -// lateral and correlated — produce byte-identical result trees over -// the same data. +// These tests pin the SQL-execution count, so a future regression that +// reintroduces a multi-query fallback is caught at the contract level, +// not by downstream benchmark drift. The `lateral`-flag-is-inert guard +// then proves that advertising the lateral capability no longer emits a +// lateral join for an include — every include routes through the +// correlated path regardless of the flag. import { describe, expect, it } from 'vitest'; +import { isSelectAst } from './helpers'; import { timeouts, withCollectionRuntime } from './integration-helpers'; import { CORRELATED_CAPABILITIES, @@ -23,12 +24,11 @@ import { type PgIntegrationRuntime, seedComments, seedPosts, seedUsers } from '. describe('integration/nested-includes/strategy', () => { // =========================================================================== - // Cross-strategy correctness: the same query must yield byte-identical - // result trees regardless of which dispatch strategy actually runs. This - // is the strongest guarantee we offer downstream consumers. + // Correctness: the correlated builder must yield the canonical result + // tree. This is the strongest guarantee we offer downstream consumers. // =========================================================================== - describe('cross-strategy result equivalence', () => { + describe('correlated result tree', () => { async function seedBlog(runtime: PgIntegrationRuntime) { await seedUsers(runtime, [ { id: 1, name: 'Alice', email: 'alice@example.com' }, @@ -95,27 +95,7 @@ describe('integration/nested-includes/strategy', () => { ]; it( - 'depth-2 lateral path produces the canonical result tree', - async () => { - await withCollectionRuntime(async (runtime) => { - await seedBlog(runtime); - const users = collectionWithCapabilities(runtime, 'User', LATERAL_CAPABILITIES); - const rows = await users - .orderBy((u) => u.id.asc()) - .include('posts', (posts) => - posts - .orderBy((p) => p.id.asc()) - .include('comments', (c) => c.orderBy((cc) => cc.id.asc())), - ) - .all(); - expect(rows).toEqual(expectedRows); - }); - }, - timeouts.spinUpPpgDev, - ); - - it( - 'depth-2 correlated path produces the canonical result tree', + 'depth-2 produces the canonical result tree', async () => { await withCollectionRuntime(async (runtime) => { await seedBlog(runtime); @@ -136,38 +116,51 @@ describe('integration/nested-includes/strategy', () => { }); // =========================================================================== - // SQL execution counts per strategy. These are the TML-2594 acceptance - // criteria. They fail under the pre-fix dispatch gate (which always - // routes depth-2+ through multi-query) and pass once strategy selection - // is honoured. + // The `lateral` capability flag is inert for include codegen. A + // contract advertising `lateral: true` still resolves includes in a + // single SQL execution, and its compiled plan contains NO lateral join + // for the include — every include lowers to a correlated subquery. // =========================================================================== - describe('SQL execution count per strategy', () => { + describe('lateral capability flag is inert for include codegen', () => { it( - 'depth-2 under lateral capabilities runs a single SQL execution', + 'a lateral-capable contract resolves a depth-2 include in one execution with no lateral join', async () => { await withCollectionRuntime(async (runtime) => { - await seedUsers(runtime, [ - { id: 1, name: 'Alice', email: 'alice@example.com' }, - { id: 2, name: 'Bob', email: 'bob@example.com' }, - ]); - await seedPosts(runtime, [ - { id: 10, title: 'A1', userId: 1, views: 1 }, - { id: 11, title: 'B1', userId: 2, views: 2 }, - ]); + await seedUsers(runtime, [{ id: 1, name: 'Alice', email: 'alice@example.com' }]); + await seedPosts(runtime, [{ id: 10, title: 'A1', userId: 1, views: 1 }]); await seedComments(runtime, [{ id: 100, body: 'c', postId: 10 }]); const users = collectionWithCapabilities(runtime, 'User', LATERAL_CAPABILITIES); runtime.resetExecutions(); await users.include('posts', (posts) => posts.include('comments')).all(); + expect(runtime.executions).toHaveLength(1); + + const execution = runtime.executions[0]; + const ast = execution?.ast; + expect(isSelectAst(ast)).toBe(true); + if (!isSelectAst(ast)) return; + // No include join is emitted; the include rides on a + // SubqueryExpr projection instead. + expect((ast.joins ?? []).some((join) => join.lateral)).toBe(false); + expect(ast.projection.some((item) => item.alias === 'posts')).toBe(true); + // The lowered SQL carries no LATERAL keyword either. + expect(execution?.sql).not.toContain('LATERAL'); }); }, timeouts.spinUpPpgDev, ); + }); + + // =========================================================================== + // SQL execution counts. Each include tree resolves in a single + // correlated execution at depth 2, depth 3, and across a self-relation. + // =========================================================================== + describe('single SQL execution per include tree', () => { it( - 'depth-2 under correlated capabilities runs a single SQL execution', + 'depth-2 runs a single SQL execution', async () => { await withCollectionRuntime(async (runtime) => { await seedUsers(runtime, [ @@ -190,31 +183,7 @@ describe('integration/nested-includes/strategy', () => { ); it( - 'depth-3 under lateral capabilities runs a single SQL execution', - async () => { - await withCollectionRuntime(async (runtime) => { - await seedUsers(runtime, [ - { id: 1, name: 'Root', email: 'root@example.com' }, - { id: 2, name: 'Child', email: 'child@example.com', invitedById: 1 }, - ]); - await seedPosts(runtime, [{ id: 10, title: 'P', userId: 2, views: 1 }]); - await seedComments(runtime, [{ id: 100, body: 'c', postId: 10 }]); - - const users = collectionWithCapabilities(runtime, 'User', LATERAL_CAPABILITIES); - runtime.resetExecutions(); - await users - .include('invitedUsers', (inv) => - inv.include('posts', (posts) => posts.include('comments')), - ) - .all(); - expect(runtime.executions).toHaveLength(1); - }); - }, - timeouts.spinUpPpgDev, - ); - - it( - 'depth-3 under correlated capabilities runs a single SQL execution', + 'depth-3 runs a single SQL execution', async () => { await withCollectionRuntime(async (runtime) => { await seedUsers(runtime, [ @@ -238,31 +207,12 @@ describe('integration/nested-includes/strategy', () => { ); it( - 'depth-2 self-relation under lateral capabilities runs a single SQL execution', + 'depth-2 self-relation runs a single SQL execution', async () => { // Self-relation aliasing must propagate through the recursion or - // the lateral join will fail to compile against the same physical - // table at two depths. Asserting one execution here pins both - // the alias propagation and the strategy selection. - await withCollectionRuntime(async (runtime) => { - await seedUsers(runtime, [ - { id: 1, name: 'Root', email: 'root@example.com' }, - { id: 2, name: 'Child', email: 'child@example.com', invitedById: 1 }, - { id: 3, name: 'Grandchild', email: 'gc@example.com', invitedById: 2 }, - ]); - - const users = collectionWithCapabilities(runtime, 'User', LATERAL_CAPABILITIES); - runtime.resetExecutions(); - await users.include('invitedUsers', (inv) => inv.include('invitedUsers')).all(); - expect(runtime.executions).toHaveLength(1); - }); - }, - timeouts.spinUpPpgDev, - ); - - it( - 'depth-2 self-relation under correlated capabilities runs a single SQL execution', - async () => { + // the correlated subquery will fail to compile against the same + // physical table at two depths. Asserting one execution here pins + // both the alias propagation and the single-query lowering. await withCollectionRuntime(async (runtime) => { await seedUsers(runtime, [ { id: 1, name: 'Root', email: 'root@example.com' }, @@ -281,16 +231,15 @@ describe('integration/nested-includes/strategy', () => { }); // =========================================================================== - // Scalar / combine include descriptors resolve in a single SQL - // execution under both lateral and correlated capabilities. The - // single-query builders lower scalar and combine at any depth; the - // dispatch path no longer has a descriptor-aware fallback to + // Scalar / combine include descriptors resolve in a single correlated + // SQL execution. The correlated builder lowers scalar and combine at + // any depth; the dispatch path has no descriptor-aware fallback to // multi-query. // =========================================================================== describe('scalar / combine include descriptors resolve in a single execution', () => { it( - 'top-level combine() resolves in a single execution under lateral capabilities', + 'top-level combine() resolves in a single execution', async () => { await withCollectionRuntime(async (runtime) => { await seedUsers(runtime, [{ id: 1, name: 'Alice', email: 'alice@example.com' }]); @@ -299,11 +248,11 @@ describe('integration/nested-includes/strategy', () => { { id: 11, title: 'B', userId: 1, views: 2 }, ]); - // The lateral builder packs combine() into one LATERAL JOIN - // whose inner SELECT cross-joins per-branch derived tables - // and projects json_build_object over them. The whole tree - // rolls up into a single SQL execution. - const users = collectionWithCapabilities(runtime, 'User', LATERAL_CAPABILITIES); + // The correlated builder packs combine() into one subquery + // whose FROM cross-joins per-branch derived tables and projects + // json_build_object over them. The whole tree rolls up into a + // single SQL execution. + const users = collectionWithCapabilities(runtime, 'User', CORRELATED_CAPABILITIES); runtime.resetExecutions(); const rows = await users .include('posts', (p) => @@ -337,13 +286,13 @@ describe('integration/nested-includes/strategy', () => { ); it( - 'nested scalar at depth 2 resolves in a single execution under lateral capabilities', + 'nested scalar at depth 2 resolves in a single execution', async () => { - // The lateral builder emits a nested LATERAL inside the parent - // row's SELECT so the whole tree resolves in one round-trip. - // This test pins that recursion: a `count()` at depth 2 must - // roll up into the same single-query plan as the outer row - // include. + // The correlated builder emits a nested subquery inside the + // parent row's SELECT so the whole tree resolves in one + // round-trip. This test pins that recursion: a `count()` at + // depth 2 must roll up into the same single-query plan as the + // outer row include. await withCollectionRuntime(async (runtime) => { await seedUsers(runtime, [{ id: 1, name: 'Alice', email: 'alice@example.com' }]); await seedPosts(runtime, [{ id: 10, title: 'A', userId: 1, views: 1 }]); @@ -352,7 +301,7 @@ describe('integration/nested-includes/strategy', () => { { id: 101, body: 'c2', postId: 10 }, ]); - const users = collectionWithCapabilities(runtime, 'User', LATERAL_CAPABILITIES); + const users = collectionWithCapabilities(runtime, 'User', CORRELATED_CAPABILITIES); runtime.resetExecutions(); const rows = await users .include('posts', (posts) => posts.include('comments', (c) => c.count())) @@ -374,11 +323,8 @@ describe('integration/nested-includes/strategy', () => { timeouts.spinUpPpgDev, ); - // Correlated mirror of the lateral scalar count test. Same shape, - // same result; the SQL primitive is a correlated subquery instead - // of a LATERAL JOIN. Both paths produce one execution. it( - 'scalar count() resolves in a single execution under correlated capabilities', + 'scalar count() resolves in a single execution', async () => { await withCollectionRuntime(async (runtime) => { await seedUsers(runtime, [ @@ -422,10 +368,8 @@ describe('integration/nested-includes/strategy', () => { timeouts.spinUpPpgDev, ); - // Correlated mirror: `take(N)` on a count() refine composes - // through to the aggregate scope, same as the lateral path. it( - 'pagination composes through to scalar aggregate scope under correlated', + 'pagination composes through to scalar aggregate scope', async () => { await withCollectionRuntime(async (runtime) => { await seedUsers(runtime, [{ id: 1, name: 'Alice', email: 'alice@example.com' }]); @@ -465,13 +409,8 @@ describe('integration/nested-includes/strategy', () => { timeouts.spinUpPpgDev, ); - // Correlated mirror of the lateral `distinct(cols).orderBy().take().sum()` - // integration test. The fix in `buildIncludeChildScalarSelect` - // reapplies orderBy after the ROW_NUMBER dedup wrap for both - // strategies; this pins the correlated path produces the same - // ordered-top-N sum (700) rather than an insertion-order slice. it( - 'distinct(cols).orderBy().take().sum() aggregates the ordered top-N deduped rows under correlated', + 'distinct(cols).orderBy().take().sum() aggregates the ordered top-N deduped rows', async () => { await withCollectionRuntime(async (runtime) => { await seedUsers(runtime, [{ id: 1, name: 'Alice', email: 'alice@example.com' }]); @@ -512,12 +451,8 @@ describe('integration/nested-includes/strategy', () => { timeouts.spinUpPpgDev, ); - // Combine under correlated: same Pothos `totalCount` worked - // example as the lateral version, validated against the correlated - // emission shape (one correlated subquery whose FROM cross-joins - // per-branch derived tables). it( - 'combine({ rows, count }) resolves in a single execution under correlated capabilities', + 'combine({ rows, count }) resolves in a single execution', async () => { await withCollectionRuntime(async (runtime) => { await seedUsers(runtime, [{ id: 1, name: 'Alice', email: 'alice@example.com' }]); @@ -564,10 +499,10 @@ describe('integration/nested-includes/strategy', () => { // =========================================================================== // Sentinel coverage for the dispatch boundary: include trees with - // `distinct()` on a non-leaf level must resolve via the single-query - // strategies (lateral / correlated). A regression that flips dispatch - // back to multi-query is caught here at the dispatch boundary, not - // only downstream in the dedicated distinct suites. + // `distinct()` on a non-leaf level must resolve via the single + // correlated query. A regression that flips dispatch back to + // multi-query is caught here at the dispatch boundary, not only + // downstream in the dedicated distinct suites. // // Result-shape coverage — hasMany/belongsTo grandchild variants, force- // included join keys, depth-3 trees, self-relations, refinements, @@ -578,10 +513,8 @@ describe('integration/nested-includes/strategy', () => { describe('non-leaf includes with distinct() resolve in a single SQL execution', () => { it( - 'distinct() on a non-leaf include resolves in 1 execution under lateral and correlated capabilities', + 'distinct() on a non-leaf include resolves in 1 execution', async () => { - // Both strategy variants share one `withCollectionRuntime` spinup - // for the reason documented in `nested-includes-helpers.ts`. await withCollectionRuntime(async (runtime) => { await seedUsers(runtime, [{ id: 1, name: 'Alice', email: 'alice@example.com' }]); await seedPosts(runtime, [ @@ -590,9 +523,9 @@ describe('integration/nested-includes/strategy', () => { ]); await seedComments(runtime, [{ id: 100, body: 'c', postId: 10 }]); - const lateralUsers = collectionWithCapabilities(runtime, 'User', LATERAL_CAPABILITIES); + const users = collectionWithCapabilities(runtime, 'User', CORRELATED_CAPABILITIES); runtime.resetExecutions(); - await lateralUsers + await users .include('posts', (posts) => posts .select('title') @@ -603,19 +536,6 @@ describe('integration/nested-includes/strategy', () => { .orderBy((u) => u.id.asc()) .all(); expect(runtime.executions).toHaveLength(1); - - const correlatedUsers = collectionWithCapabilities( - runtime, - 'User', - CORRELATED_CAPABILITIES, - ); - runtime.resetExecutions(); - await correlatedUsers - .include('posts', (posts) => - posts.select('title').distinct('title').include('comments'), - ) - .all(); - expect(runtime.executions).toHaveLength(1); }); }, timeouts.spinUpPpgDev, From aa68bf51a93fdea0b833a9051587cc324582d1ce Mon Sep 17 00:00:00 2001 From: Alexey Orlenko's AI Agent Date: Mon, 1 Jun 2026 16:05:39 +0200 Subject: [PATCH 2/2] docs(sql-orm): retire stale includeMany references; align include docs with correlated lowering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `includeMany` is no longer a method on any surface — the ORM exposes `.include(...)` and the SQL builder has no include method. Clean up the references this leaves behind, and align the adapter docs with the correlated-only include lowering this branch introduces: - Delete the obsolete `include-many-patterns.mdc` rulecard (it documented a removed `SelectBuilderImpl.includeMany()` API) and its index entry. - Remove the dead `HasIncludeManyCapabilities` type (zero usages; it encoded the now-defunct lateral+jsonAgg include gate). - Postgres README: includes now lower to a correlated subquery, not `LEFT JOIN LATERAL`; note LATERAL emission is retained only for the public `lateralJoin()` DSL. - SQLite README: rename includeMany -> include; correlated is the strategy, not a fallback. - AGENTS.md (capability-gating example), the queries skill gotcha, and a stale e2e describe label: includeMany -> include / a real capability. - Remove the broken `### Queries with includeMany` SQL-builder example from query-patterns.md (it called a nonexistent `.includeMany()`). ADRs and the Query Lanes subsystem doc are left as-is (historical / architecture docs handled separately). Refs: TML-2729 Signed-off-by: Alexey Orlenko's AI Agent --- .agents/rules/README.md | 1 - .agents/rules/include-many-patterns.mdc | 95 ------------------- AGENTS.md | 2 +- docs/reference/query-patterns.md | 33 ------- .../4-lanes/relational-core/src/types.ts | 20 ---- .../3-targets/6-adapters/postgres/README.md | 29 +++--- .../3-targets/6-adapters/sqlite/README.md | 14 +-- skills/prisma-next-queries/postgres.md | 2 +- test/e2e/framework/test/sqlite/orm.test.ts | 2 +- 9 files changed, 24 insertions(+), 174 deletions(-) delete mode 100644 .agents/rules/include-many-patterns.mdc diff --git a/.agents/rules/README.md b/.agents/rules/README.md index 1f6de56a4f..213e12187c 100644 --- a/.agents/rules/README.md +++ b/.agents/rules/README.md @@ -90,7 +90,6 @@ Rules below are listed by bare filename; the canonical file is `.agents/rules/, - Row = unknown, - CodecTypes extends Record = Record, - Includes extends Record = Record, // Accumulates includes -> { - includeMany<..., AliasName extends string = string>( - ... - ): SelectBuilderImpl<..., Includes & { [K in AliasName]: ChildRow }> { - // Updates Includes map with new include - } -} -``` - -**Why**: This allows TypeScript to track include definitions across multiple `includeMany()` calls and infer correct array types in the final result. - -## ExtractIncludeType Helper - -When looking up types from an accumulated map: - -**Pattern**: Use a helper type that safely extracts types: - -```typescript -type ExtractIncludeType< - K extends string, - Includes extends Record, -> = K extends keyof Includes ? Includes[K] : unknown; -``` - -**Why**: This provides type-safe lookup with a fallback to `unknown` if the key doesn't exist, preventing type errors while maintaining type safety. - -## Boolean True for Include References - -When using boolean `true` to mark include references in projections: - -**Pattern**: Handle `true` in type inference: - -```typescript -: P[K] extends true - ? Array> - : ... -``` - -**Why**: This allows `select({ posts: true })` to infer `Array` from the `Includes` map, providing type-safe include references. - -## Capability Gating - -When gating features by capabilities (used by `includeMany` and DML `returning()`): - -**Pattern**: Check capabilities at runtime in builder methods: - -```typescript -// Runtime check in builder method -returning( - ...columns: Columns -): InsertBuilder> { - // Runtime capability check - const target = this.contract.target; - const capabilities = this.contract.capabilities; - if (!capabilities || !capabilities[target]) { - throw planInvalid('returning() requires returning capability'); - } - const targetCapabilities = capabilities[target]; - if (targetCapabilities['returning'] !== true) { - throw planInvalid('returning() requires returning capability to be true'); - } - - // Continue with implementation... -} -``` - -**For multiple capabilities** (like `includeMany`): -```typescript -// Runtime check -if (targetCapabilities['lateral'] !== true || targetCapabilities['jsonAgg'] !== true) { - throw planInvalid('includeMany requires lateral and jsonAgg capabilities'); -} -``` - -**Why**: Runtime checks prevent execution errors when capabilities are missing or false. This pattern ensures consistent behavior across all capability-gated features (`includeMany`, DML `returning()`, etc.). diff --git a/AGENTS.md b/AGENTS.md index ddb185a727..cf1abaca75 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,7 @@ pnpm fixtures:check # Use this rather than ad-hoc emit-and-diff - **ExecutionContext**: Encapsulates contract, codecs, operations, and types. Pass to `schema()`, `sql()`, `orm()`. - **Interface + factory pattern for stateful services**: Stateful services (registries, runtimes, adapters, drivers) are exposed through an interface plus a `createX()` factory; the implementing class stays package-private. Consumers depend on the interface, never the implementation. Pattern reference: [`docs/architecture docs/patterns/interface-plus-factory.md`](docs/architecture%20docs/patterns/interface-plus-factory.md). - **Three-layer polymorphic IR for AST/IR class hierarchies**: AST/IR nodes (Contract IR, Schema IR, migration ops) are organised as framework interface → family abstract base → target concrete classes. Concrete classes are publicly exported as the target's IR alphabet; `freezeNode(this)` is called in the constructor. Target packs contribute new entity kinds via `AuthoringContributions.entities` (see [`docs/reference/typescript-patterns.md`](docs/reference/typescript-patterns.md) § "AST/IR class hierarchies"). Pattern references: [`three-layer-polymorphic-ir.md`](docs/architecture%20docs/patterns/three-layer-polymorphic-ir.md), [`frozen-class-ast.md`](docs/architecture%20docs/patterns/frozen-class-ast.md), [`json-canonical-class-in-memory.md`](docs/architecture%20docs/patterns/json-canonical-class-in-memory.md). -- **Capability Gating**: Features like `includeMany` and `returning()` require capabilities in the contract; gating is enforced at authoring time. +- **Capability Gating**: Features like `returning()` require capabilities in the contract; gating is enforced at authoring time. - **Builder chaining**: Methods return new instances — always chain calls. - **Column access**: Use `table.columns.fieldName` to avoid conflicts with table properties. diff --git a/docs/reference/query-patterns.md b/docs/reference/query-patterns.md index 181817513c..f0b8978a38 100644 --- a/docs/reference/query-patterns.md +++ b/docs/reference/query-patterns.md @@ -152,39 +152,6 @@ const plan = db.sql type JoinedRow = ResultType; ``` -### Queries with includeMany - -```typescript -import { db } from '../prisma/db'; -import type { ResultType } from '@prisma-next/sql-query/types'; - -const userTable = db.schema.tables.user; -const postTable = db.schema.tables.post; - -const plan = db.sql - .from(userTable) - .includeMany( - postTable, - (on) => on.eqCol(userTable.columns.id, postTable.columns.userId), - (child) => - child - .select({ - id: postTable.columns.id, - title: postTable.columns.title, - }) - .orderBy(postTable.columns.createdAt.desc()), - { alias: 'posts' }, - ) - .select({ - id: userTable.columns.id, - email: userTable.columns.email, - posts: true, - }) - .build(); - -type UserWithPosts = ResultType; -``` - ## Anti-Patterns **❌ WRONG: Don't create extra aliases for one-off usage** diff --git a/packages/2-sql/4-lanes/relational-core/src/types.ts b/packages/2-sql/4-lanes/relational-core/src/types.ts index 1402fd3a0c..a487b2faa8 100644 --- a/packages/2-sql/4-lanes/relational-core/src/types.ts +++ b/packages/2-sql/4-lanes/relational-core/src/types.ts @@ -151,26 +151,6 @@ export type ComputeColumnJsType< : never : never; -/** - * Utility type to check if a contract has the required capabilities for includeMany. - * Requires both `lateral` and `jsonAgg` to be `true` in the contract's capabilities for the target. - * Capabilities are nested by target: `{ [target]: { lateral: true, jsonAgg: true } }` - */ -export type HasIncludeManyCapabilities> = TContract extends { - capabilities: infer C; - target: infer T; -} - ? T extends string - ? C extends Record> - ? C extends { [K in T]: infer TargetCaps } - ? TargetCaps extends { lateral: true; jsonAgg: true } - ? true - : false - : false - : false - : false - : false; - /** * Alias for the SQL-domain executable plan, exposed under the legacy * `SqlPlan` name for compatibility with SQL builder/utility call sites. diff --git a/packages/3-targets/6-adapters/postgres/README.md b/packages/3-targets/6-adapters/postgres/README.md index a10f81691d..a86858aeca 100644 --- a/packages/3-targets/6-adapters/postgres/README.md +++ b/packages/3-targets/6-adapters/postgres/README.md @@ -20,7 +20,7 @@ Provide PostgreSQL-specific adapter implementation, codecs, and capabilities. En - **Adapter Implementation**: Implement `Adapter` SPI for PostgreSQL - Lower SQL ASTs to PostgreSQL dialect SQL - - Render `includeMany` as `LEFT JOIN LATERAL` with `json_agg` for nested array includes + - Render `.include(...)` as a correlated subquery with `json_agg` for nested array includes - Advertise PostgreSQL capabilities (`lateral`, `jsonAgg`) - Normalize PostgreSQL EXPLAIN output - Map PostgreSQL errors to `RuntimeError` envelope @@ -90,7 +90,7 @@ flowchart TD - Main adapter implementation - Lowers SQL ASTs to PostgreSQL SQL - Renders joins (INNER, LEFT, RIGHT, FULL) with ON conditions -- Renders `includeMany` as `LEFT JOIN LATERAL` with `json_agg` for nested array includes +- Renders `.include(...)` as a correlated subquery with `json_agg` for nested array includes - Renders DML operations (INSERT, UPDATE, DELETE) with RETURNING clauses - Advertises PostgreSQL capabilities (`lateral`, `jsonAgg`, `returning`) - Maps PostgreSQL errors to `RuntimeError` @@ -191,8 +191,8 @@ The adapter declares the following PostgreSQL capabilities: - **`orderBy: true`** - Supports ORDER BY clauses - **`limit: true`** - Supports LIMIT clauses -- **`lateral: true`** - Supports LATERAL joins for `includeMany` nested array includes -- **`jsonAgg: true`** - Supports JSON aggregation functions (`json_agg`) for `includeMany` +- **`lateral: true`** - Supports LATERAL joins (used by the SQL-builder `lateralJoin()` DSL) +- **`jsonAgg: true`** - Supports JSON aggregation functions (`json_agg`) used to lower `.include(...)` to correlated subqueries - **`returning: true`** - Supports RETURNING clauses for DML operations (INSERT, UPDATE, DELETE) - **`sql.enums: true`** - Supports contract-defined enum storage types @@ -205,31 +205,30 @@ The capabilities on the descriptor must match the capabilities in code. If they See `docs/reference/capabilities.md` and `docs/architecture docs/subsystems/5. Adapters & Targets.md` for details. -## includeMany Support +## Include Support -The adapter supports `includeMany` for nested array includes using PostgreSQL's `LATERAL` joins and `json_agg`: +The adapter lowers `.include(...)` for nested array includes to a correlated subquery in the SELECT list, using `json_agg`: **Lowering Strategy:** -- Renders `includeMany` as `LEFT JOIN LATERAL` with a subquery that uses `json_agg(json_build_object(...))` to aggregate child rows into a JSON array -- The ON condition from the include is moved into the WHERE clause of the lateral subquery -- When both `ORDER BY` and `LIMIT` are present, wraps the query in an inner SELECT that projects individual columns with aliases, then uses `json_agg(row_to_json(sub.*))` on the result -- Uses different aliases for the table (`{alias}_lateral`) and column (`{alias}`) to avoid ambiguity +- Renders each `.include(...)` as a correlated subquery that uses `json_agg(json_build_object(...))` to aggregate child rows into a JSON array +- The ON condition from the include becomes the WHERE clause of the correlated subquery, referencing the outer row +- When both `ORDER BY` and `LIMIT` are present, wraps the child query in an inner SELECT that projects individual columns with aliases, then uses `json_agg(row_to_json(sub.*))` on the result **Capabilities Required:** -- `lateral: true` - Enables LATERAL join support - `jsonAgg: true` - Enables `json_agg` function support **Example SQL Output:** ```sql -SELECT "user"."id" AS "id", "posts_lateral"."posts" AS "posts" -FROM "user" -LEFT JOIN LATERAL ( +SELECT "user"."id" AS "id", ( SELECT json_agg(json_build_object('id', "post"."id", 'title', "post"."title")) AS "posts" FROM "post" WHERE "user"."id" = "post"."userId" -) AS "posts_lateral" ON true +) AS "posts" +FROM "user" ``` +> LATERAL emission is retained in the renderer (and the `lateral` capability stays advertised) for the public SQL-builder `lateralJoin()` DSL; the ORM include path no longer uses it. + ## DML Operations with RETURNING The adapter supports RETURNING clauses for DML operations (INSERT, UPDATE, DELETE), allowing you to return affected rows: diff --git a/packages/3-targets/6-adapters/sqlite/README.md b/packages/3-targets/6-adapters/sqlite/README.md index c045965a32..62b4551b2c 100644 --- a/packages/3-targets/6-adapters/sqlite/README.md +++ b/packages/3-targets/6-adapters/sqlite/README.md @@ -20,7 +20,7 @@ Provide SQLite-specific adapter implementation, codecs, and capabilities. Enable - **Adapter Implementation**: Implement `Adapter` SPI for SQLite - Lower SQL ASTs to SQLite dialect SQL - - Render `includeMany` as correlated subquery with `json_group_array(json_object(...))` for nested array includes + - Render `.include(...)` as correlated subquery with `json_group_array(json_object(...))` for nested array includes - Advertise SQLite capabilities (`jsonAgg`, `returning`; no `lateral`, no `enums`) - Provide target-specific marker SQL via `readMarkerStatement()` on `AdapterProfile` - Map SQLite errors to `RuntimeError` envelope @@ -91,7 +91,7 @@ flowchart TD - Main adapter implementation - Lowers SQL ASTs to SQLite SQL with `?` positional parameters - Renders joins (INNER, LEFT, RIGHT, FULL) with ON conditions -- Renders `includeMany` as correlated subquery with `json_group_array(json_object(...))` for nested array includes +- Renders `.include(...)` as correlated subquery with `json_group_array(json_object(...))` for nested array includes - Renders DML operations (INSERT, UPDATE, DELETE) with RETURNING clauses - Renders ON CONFLICT (DO NOTHING / DO UPDATE SET) for upserts - Uses `CAST(expr AS type)` instead of Postgres `::type` syntax @@ -148,17 +148,17 @@ The adapter declares the following SQLite capabilities: - **`sql.orderBy: true`** -- Supports ORDER BY clauses - **`sql.limit: true`** -- Supports LIMIT clauses -- **`sql.lateral: false`** -- No LATERAL join support; `includeMany` uses correlated subquery fallback -- **`sql.jsonAgg: true`** -- Supports JSON aggregation via `json_group_array()` for `includeMany` +- **`sql.lateral: false`** -- No LATERAL join support; `.include(...)` uses correlated subqueries +- **`sql.jsonAgg: true`** -- Supports JSON aggregation via `json_group_array()` for `.include(...)` - **`sql.returning: true`** -- Supports RETURNING clauses for DML operations (SQLite 3.35+) - **`sql.enums: false`** -- No native enum support -## includeMany Support +## Include Support -The adapter supports `includeMany` for nested array includes using SQLite's `json_group_array()` and `json_object()`: +The adapter lowers `.include(...)` for nested array includes using SQLite's `json_group_array()` and `json_object()`: **Lowering Strategy:** -- Renders `includeMany` as a correlated subquery with `json_group_array(json_object(...))` to aggregate child rows into a JSON array +- Renders `.include(...)` as a correlated subquery with `json_group_array(json_object(...))` to aggregate child rows into a JSON array - Uses `COALESCE(..., '[]')` to handle empty results **Example SQL Output:** diff --git a/skills/prisma-next-queries/postgres.md b/skills/prisma-next-queries/postgres.md index 6bc7ba0914..95a2c0ad26 100644 --- a/skills/prisma-next-queries/postgres.md +++ b/skills/prisma-next-queries/postgres.md @@ -314,7 +314,7 @@ The callback's return value passes through `db.transaction(...)`. Capture insert 5. **Importing `and` / `or` / `not` from a Postgres façade subpath.** The combinators currently live in `@prisma-next/sql-orm-client` — an internal package. See *What Prisma Next doesn't do yet* in [`SKILL.md`](./SKILL.md). 6. **Trying to `db.sql.from(tables.user)`.** That surface does not exist. The builder is table-shaped: `db.sql..select(...)`. There is no `db.schema.tables` either. 7. **Trying to `db.execute(plan)` directly.** Plans execute through the runtime: `db.runtime().execute(plan)`. Inside a transaction, use `tx.execute(plan)`. -8. **Setting `capabilities: { includeMany: true }` in `prisma-next.config.ts`.** `defineConfig` does not take `capabilities`. Capabilities are declared by the active adapter and become part of the emitted contract; the Postgres adapter advertises `lateral`, `jsonAgg`, and `returning` out of the box. Enable extension capabilities through `extensions: [...]` in the config (see `prisma-next-contract`). +8. **Setting `capabilities: { lateral: true }` in `prisma-next.config.ts`.** `defineConfig` does not take `capabilities`. Capabilities are declared by the active adapter and become part of the emitted contract; the Postgres adapter advertises `lateral`, `jsonAgg`, and `returning` out of the box. Enable extension capabilities through `extensions: [...]` in the config (see `prisma-next-contract`). 9. **Confabulating a `db.sql.raw(...)`, TypedSQL, or `.stream()` surface.** None of those exist today. See *What Prisma Next doesn't do yet* in [`SKILL.md`](./SKILL.md). 10. **Mixing the ORM mutation return with `runtime.execute(plan)`.** ORM terminals issue the query themselves and return rows. `runtime.execute` is for SQL-builder plans. 11. **Top-N grouped queries written as `groupBy(...).aggregate(...).sort().slice()` in JS.** That's a fallback because the grouped collection doesn't expose `.orderBy(...)` / `.take(...)`. Fine at small cardinalities; for large grouped result sets, drop to `db.sql.`. diff --git a/test/e2e/framework/test/sqlite/orm.test.ts b/test/e2e/framework/test/sqlite/orm.test.ts index 9f0846f9f5..759e53b7d5 100644 --- a/test/e2e/framework/test/sqlite/orm.test.ts +++ b/test/e2e/framework/test/sqlite/orm.test.ts @@ -171,7 +171,7 @@ describe('e2e: ORM on SQLite', { timeout: timeouts.databaseOperation }, () => { }); }); - describe('includeMany', () => { + describe('include', () => { it('loads 1:N relation via json_group_array', async () => { await withSqliteTestRuntime(contractJsonPath, async ({ ormClient }) => { const users = await ormClient.User.where((u) => u.id.eq(1))