Skip to content

Commit ea025e8

Browse files
committed
refactor(sql-orm-client): drop LATERAL include codegen for correlated-only read path
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 <robot@aqrln.net>
1 parent 82216b8 commit ea025e8

12 files changed

Lines changed: 250 additions & 1044 deletions

File tree

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

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,7 @@ import {
4040
stripHiddenMappedFields,
4141
} from './collection-runtime';
4242
import { executeQueryPlan } from './execute-query-plan';
43-
import { selectIncludeStrategy } from './include-strategy';
44-
import { compileSelect, compileSelectWithIncludeStrategy } from './query-plan';
43+
import { compileSelect, compileSelectWithIncludes } from './query-plan';
4544
import { augmentSelectionForJoinColumns } from './selection-shaping';
4645
import {
4746
type CollectionContext,
@@ -78,11 +77,9 @@ export function dispatchCollectionRows<Row>(options: {
7877
return dispatchWithIncludes<Row>(options);
7978
}
8079

81-
// Both include builders — lateral and correlated — lower every include
80+
// The correlated-subquery include builder lowers every include
8281
// descriptor shape (row, scalar reducers, and combine()) at any depth
83-
// into a single query. Dispatch picks one purely on the `lateral`
84-
// capability flag via `selectIncludeStrategy`; the read path has no
85-
// multi-query fallback.
82+
// into a single query; the read path has no multi-query fallback.
8683
function dispatchWithIncludes<Row>(options: {
8784
contract: Contract<SqlStorage>;
8885
runtime: CollectionContext<Contract<SqlStorage>>['runtime'];
@@ -91,21 +88,19 @@ function dispatchWithIncludes<Row>(options: {
9188
modelName: string;
9289
}): AsyncIterableResult<Row> {
9390
const { contract, runtime, state, tableName, modelName } = options;
94-
const strategy = selectIncludeStrategy(contract);
9591
const generator = async function* (): AsyncGenerator<Row, void, unknown> {
9692
const { scope, release } = await acquireRuntimeScope(runtime);
9793
try {
9894
const parentJoinColumns = state.includes.map((include) => include.localColumn);
9995
const { selectedForQuery: parentSelectedForQuery, hiddenColumns: hiddenParentColumns } =
10096
augmentSelectionForJoinColumns(state.selectedFields, parentJoinColumns);
101-
const compiled = compileSelectWithIncludeStrategy(
97+
const compiled = compileSelectWithIncludes(
10298
contract,
10399
tableName,
104100
{
105101
...state,
106102
selectedFields: parentSelectedForQuery,
107103
},
108-
strategy,
109104
modelName,
110105
);
111106

@@ -155,7 +150,7 @@ function dispatchWithIncludes<Row>(options: {
155150
/**
156151
* Reload the rows a mutation just wrote (create / createAll / update /
157152
* updateAll / upsert) through the read-path dispatch, so `.include()`
158-
* relations resolve via the exact same lateral / correlated builders,
153+
* relations resolve via the exact same correlated-subquery builder,
159154
* decode, hidden-column stripping, and polymorphism mapping a read
160155
* query uses — there is no parallel mutation read-back implementation.
161156
*
@@ -255,7 +250,7 @@ function buildIdentityInFilter(
255250
* Decode a single-query include payload from a parent row's raw cell
256251
* into the model-shaped value that downstream consumers see. Recurses
257252
* through `include.nested.includes` so depth-2+ trees — emitted by the
258-
* recursive lateral / correlated builders — are decoded symmetrically.
253+
* recursive correlated-subquery builder — are decoded symmetrically.
259254
*
260255
* The shape produced by the SQL side is one JSON column per top-level
261256
* include; values nested inside that JSON are already-parsed JS values
@@ -312,9 +307,10 @@ function decodeIncludePayload(
312307
* - scalar branch -> unwrap the `{value: ...}` envelope via the
313308
* standalone scalar decoder.
314309
*
315-
* On a parent with zero matching child rows the LATERAL still produces
316-
* one row (aggregates collapse the empty input to a single row), so
317-
* the combine envelope here is always present in the read path. The
310+
* On a parent with zero matching child rows the correlated subquery
311+
* still produces one row (aggregates collapse the empty input to a
312+
* single row), so the combine envelope here is always present in the
313+
* read path. The
318314
* mutation read-back's `assignEmptyMutationIncludes` writes the empty
319315
* per-branch shape directly to `parent.mapped[relationName]` for any
320316
* parent absent from the read-back result and never enters the decoder,
@@ -350,7 +346,7 @@ function decodeCombineIncludePayload(
350346
function parseCombineEnvelope(include: IncludeExpr, raw: unknown): Record<string, unknown> {
351347
if (raw === null || raw === undefined) {
352348
throw new Error(
353-
`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.`,
349+
`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.`,
354350
);
355351
}
356352
const parsed = parseIncludePayload(raw);
@@ -374,7 +370,7 @@ function describeEnvelopeShape(value: unknown): string {
374370

375371
/**
376372
* Pull the primitive scalar value out of the JSON envelope emitted by
377-
* the lateral / correlated scalar builder.
373+
* the correlated scalar builder.
378374
*
379375
* Contract: the envelope is always either
380376
* - a `{ value: <primitive> }` JSON object (the SQL path), or
@@ -393,8 +389,8 @@ function describeEnvelopeShape(value: unknown): string {
393389
* `SUM` / `AVG` / `MIN` / `MAX` over an empty input set return SQL
394390
* `NULL`, which surfaces as `null` here. The outer `raw === null`
395391
* fallback is defensive cover for an empty parent set; in single-query
396-
* dispatch the LATERAL / correlated subquery always produces a row,
397-
* so the inner envelope's `value` is always set by SQL.
392+
* dispatch the correlated subquery always produces a row, so the inner
393+
* envelope's `value` is always set by SQL.
398394
*/
399395
function decodeScalarIncludePayload(
400396
include: IncludeExpr,

packages/3-extensions/sql-orm-client/src/include-strategy.ts

Lines changed: 0 additions & 45 deletions
This file was deleted.

0 commit comments

Comments
 (0)