diff --git a/.agents/rules/README.md b/.agents/rules/README.md index 1f6de56a4f..75ff4167b0 100644 --- a/.agents/rules/README.md +++ b/.agents/rules/README.md @@ -57,6 +57,7 @@ Rules below are listed by bare filename; the canonical file is `.agents/rules/ c..asc())` so `toEqual` on arrays is reliable. + +Avoid partial matchers (`toMatchObject`, `toHaveProperty` / `not.toHaveProperty`, lone +single-field `toBe`/`toEqual`) as the primary assertion for a query result, and avoid `toEqual` +on a full model row with no `select`. + +## Why + +Two failure modes this prevents: + +1. **Partial matchers pass silently on wrong shapes.** `toMatchObject({ id: 1, role: 'admin' })` + succeeds even if the row carries an extra field it shouldn't, a sibling-variant field that + should have been dropped, or a misspelled key elsewhere. The result's *shape* is the contract; + assert all of it. +2. **`toEqual` without `select` couples every test to the full model field set.** Adding one field + to a model then breaks every test that asserted a full row of it — tests far from the change. + An explicit `.select(...)` makes the asserted columns intentional, so unrelated field + additions don't ripple into unrelated tests, while `toEqual` still catches any wrong/missing + value *within* the selected shape. + +Together: `select` + `toEqual` is both **complete** (catches extra/missing/wrong fields in the +projected shape) and **stable** (immune to unrelated model growth). + +## Good + +```ts +const rows = await db.orm.Account + .select('id', 'name') + .orderBy((a) => a.id.asc()) + .include('members', (m) => m.select('id', 'kind', 'role', 'plan').orderBy((u) => u.id.asc())) + .all(); + +expect(rows).toEqual([ + { id: 1, name: 'Acme', members: [ + { id: 1, kind: 'admin', role: 'superadmin' }, // variant fields surface per the row's variant; + { id: 2, kind: 'regular', plan: 'free' }, // pinning them locks the variant shape + ] }, + { id: 2, name: 'Empty', members: [] }, +]); +``` + +## Avoid + +```ts +const member = members.find((m) => m.id === 1)!; +expect(member).toMatchObject({ id: 1, role: 'admin' }); // passes even if `member` has stray fields +expect(member).not.toHaveProperty('plan'); // enumerating absences ≠ asserting the shape +``` + +## Notes + +- **Polymorphic includes:** variant-specific fields surface according to each row's variant. Pin + them with `select` + `toEqual` so the per-variant shape (e.g. admin rows carry `role`, regular + rows carry `plan`) is asserted, not assumed. +- **Determinism:** order by a **base-table** column (typically `id`). Don't order by a variant + table's column on a variant-narrowed collection unless that path is the thing under test. +- **Snapshots** (`toMatchInlineSnapshot`) are an acceptable alternative to a hand-written `toEqual` + for large shapes, but still pair them with explicit `select` so the snapshot is stable. +- This is about *result-shape* assertions. Asserting a single scalar (a count, a thrown error + code, a boolean) with `toBe`/`expect().rejects` is fine and expected. +- **Implicit-default-projection tests are the deliberate exception:** a test whose explicit purpose + is to verify the default projection (no `.select(...)`) *should* assert the full default row shape + with `toEqual`, by design — that's the property under test. Name it as such. Everywhere else, a + `toEqual` on a full row with no `select` is the brittleness this rule warns against. +- **Relationship to `prefer-object-matcher.mdc`:** that rule consolidates scattered + `expect().toBe()` calls into one matcher repo-wide. This rule is the stricter, sql-orm-client + specialization for *query results*: the result row is the contract, so assert it **completely** + with `toEqual` + `select` rather than partially with `toMatchObject`. `toMatchObject` is still + fine for the non-result, constructed-object cases `prefer-object-matcher` targets. diff --git a/AGENTS.md b/AGENTS.md index ddb185a727..4d0db4b8f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,7 @@ The repo keeps a single canonical home for each kind of agent surface, with pres - Don't reexport from one file in another, except in `exports/` folders. - Don't branch on target; use adapters: `.agents/rules/no-target-branches.mdc`. - Keep tests concise; omit "should": `.agents/rules/omit-should-in-tests.mdc`. +- In sql-orm-client tests, assert the whole result shape (`toEqual`/snapshot) with explicit `select`: `.agents/rules/sql-orm-client-whole-shape-assertions.mdc`. - Keep docs current (READMEs, rules, links): `.agents/rules/doc-maintenance.mdc`. - Prefer links to canonical docs over long comments. diff --git a/packages/3-extensions/sql-orm-client/src/collection-contract.ts b/packages/3-extensions/sql-orm-client/src/collection-contract.ts index b773cb4f0f..19bdf7a863 100644 --- a/packages/3-extensions/sql-orm-client/src/collection-contract.ts +++ b/packages/3-extensions/sql-orm-client/src/collection-contract.ts @@ -127,6 +127,53 @@ export function resolveFieldToColumn( return getFieldToColumnMap(contract, modelName)[fieldName] ?? fieldName; } +export interface VariantColumnRef { + readonly table: string; + readonly column: string; +} + +const variantFieldColumnCache = new WeakMap< + object, + Map> +>(); + +/** + * Map the fields that an MTI variant contributes to `{ table, column }` refs + * qualified against the variant's own table — the table the read path joins + * into the correlated child SELECT. STI variants contribute nothing here: + * their columns live on the base table and resolve through the ordinary + * base-table field map. Base fields are intentionally absent so callers can + * gate variant qualification strictly to variant-owned fields. + */ +export function resolveVariantFieldColumns( + contract: Contract, + baseModelName: string, + variantName: string, +): Record { + const cacheKey = `${baseModelName}:${variantName}`; + let perContract = variantFieldColumnCache.get(contract); + if (!perContract) { + perContract = new Map(); + variantFieldColumnCache.set(contract, perContract); + } + const cached = perContract.get(cacheKey); + if (cached) return cached; + + const polyInfo = resolvePolymorphismInfo(contract, baseModelName); + const variant = polyInfo?.variants.get(variantName); + const result: Record = {}; + + if (variant && variant.strategy === 'mti') { + const variantFieldToColumn = getFieldToColumnMap(contract, variant.modelName); + for (const [field, column] of Object.entries(variantFieldToColumn)) { + result[field] = { table: variant.table, column }; + } + } + + perContract.set(cacheKey, result); + return result; +} + export function getFieldToColumnMap( contract: Contract, modelName: string, diff --git a/packages/3-extensions/sql-orm-client/src/collection-dispatch.ts b/packages/3-extensions/sql-orm-client/src/collection-dispatch.ts index a28bf58f97..1135dbde02 100644 --- a/packages/3-extensions/sql-orm-client/src/collection-dispatch.ts +++ b/packages/3-extensions/sql-orm-client/src/collection-dispatch.ts @@ -281,13 +281,29 @@ function decodeIncludePayload( return decodeCombineIncludePayload(contract, include, include.combine, raw); } const rawChildren = parseIncludedRows(raw); + const polyInfo = resolvePolymorphismInfo(contract, include.relatedModelName); + const mapChildRow = polyInfo + ? (childRow: Record) => + mapPolymorphicRow( + contract, + include.relatedModelName, + polyInfo, + childRow, + include.nested.variantName, + ) + : (childRow: Record) => + mapStorageRowToModelFields(contract, include.relatedModelName, childRow); const mappedChildren = rawChildren.map((childRow) => { - const mapped = mapStorageRowToModelFields(contract, include.relatedModelName, childRow); + const mapped = mapChildRow(childRow); + // Source each nested-include payload from the RAW child row: it always + // carries the payload under its relation alias. `mapChildRow` may be the + // polymorphic mapper, which keeps only variant model-field columns and so + // drops the relation alias — reading from `mapped` would lose it. for (const nestedInclude of include.nested.includes) { mapped[nestedInclude.relationName] = decodeIncludePayload( contract, nestedInclude, - mapped[nestedInclude.relationName], + childRow[nestedInclude.relationName], ); } return mapped; diff --git a/packages/3-extensions/sql-orm-client/src/collection.ts b/packages/3-extensions/sql-orm-client/src/collection.ts index ece7a32f10..303f2de6ac 100644 --- a/packages/3-extensions/sql-orm-client/src/collection.ts +++ b/packages/3-extensions/sql-orm-client/src/collection.ts @@ -16,6 +16,7 @@ import { type ToWhereExpr, type WhereArg, } from '@prisma-next/sql-relational-core/ast'; +import { blindCast } from '@prisma-next/utils/casts'; import type { SimplifyDeep } from '@prisma-next/utils/simplify-deep'; import { createAggregateBuilder, isAggregateSelector } from './aggregate-builder'; import { normalizeAggregateResult } from './collection-aggregate-result'; @@ -115,6 +116,7 @@ import { type RuntimeQueryable, type ShorthandWhereFilter, type UniqueConstraintCriterion, + type VariantAwareModelAccessor, type VariantModelRow, type VariantNames, } from './types'; @@ -241,11 +243,13 @@ export class Collection< * ``` */ where( - fn: (model: ModelAccessor) => WhereDirectInput, + fn: ( + model: VariantAwareModelAccessor, + ) => WhereDirectInput, ): Collection>; where(input: WhereDirectInput): Collection>; where( - fn: (model: ModelAccessor) => WhereArg, + fn: (model: VariantAwareModelAccessor) => WhereArg, ): Collection>; where( filters: ShorthandWhereFilter, @@ -253,13 +257,25 @@ export class Collection< where( input: | WhereDirectInput - | ((model: ModelAccessor) => WhereDirectInput) - | ((model: ModelAccessor) => WhereArg) + | (( + model: VariantAwareModelAccessor, + ) => WhereDirectInput) + | ((model: VariantAwareModelAccessor) => WhereArg) | ShorthandWhereFilter, ): Collection> { const whereArg = typeof input === 'function' - ? input(createModelAccessor(this.ctx.context, this.modelName)) + ? input( + // The runtime accessor exposes the selected variant's fields via + // the proxy when `state.variantName` is threaded in, but + // `createModelAccessor` is declared to return the base + // `ModelAccessor`; the variant widening lives only in the callback + // param type, so the value cannot be statically proven to satisfy it. + blindCast< + VariantAwareModelAccessor, + 'runtime accessor carries the selected variant fields; the variant widening is callback-param-only' + >(createModelAccessor(this.ctx.context, this.modelName, this.state.variantName)), + ) : isWhereDirectInput(input) ? input : shorthandToWhereExpr(this.ctx.context, this.modelName, input); @@ -393,7 +409,7 @@ export class Collection< > = IncludeRefinementCollection< TContract, RelatedName, - DefaultModelRow, + SimplifyDeep>, CollectionTypeState, IsToMany >, @@ -403,7 +419,7 @@ export class Collection< collection: IncludeRefinementCollection< TContract, RelatedName, - DefaultModelRow, + SimplifyDeep>, DefaultCollectionTypeState, IsToMany >, @@ -417,7 +433,7 @@ export class Collection< TContract, ModelName, K, - DefaultModelRow, + SimplifyDeep>, RefinedResult >; } @@ -433,7 +449,7 @@ export class Collection< if (refineFn) { const nestedCollection = this.#createCollection< RelatedName, - DefaultModelRow, + SimplifyDeep>, DefaultCollectionTypeState >(relation.relatedModelName as RelatedName, { tableName: relation.relatedTableName, @@ -444,7 +460,7 @@ export class Collection< nestedCollection as unknown as IncludeRefinementCollection< TContract, RelatedName, - DefaultModelRow, + SimplifyDeep>, DefaultCollectionTypeState, IsToMany >, @@ -493,7 +509,7 @@ export class Collection< TContract, ModelName, K, - DefaultModelRow, + SimplifyDeep>, RefinedResult >; } diff --git a/packages/3-extensions/sql-orm-client/src/model-accessor.ts b/packages/3-extensions/sql-orm-client/src/model-accessor.ts index 2b3ec2c8eb..71bc93f918 100644 --- a/packages/3-extensions/sql-orm-client/src/model-accessor.ts +++ b/packages/3-extensions/sql-orm-client/src/model-accessor.ts @@ -20,6 +20,8 @@ import { resolveFieldToColumn, resolveModelRelations, resolveModelTableName, + resolveVariantFieldColumns, + type VariantColumnRef, } from './collection-contract'; import { and, not } from './filters'; import { @@ -40,11 +42,24 @@ type NamedOp = readonly [name: string, entry: SqlOperationEntry]; export function createModelAccessor< TContract extends Contract, ModelName extends string, ->(context: ExecutionContext, modelName: ModelName): ModelAccessor { +>( + context: ExecutionContext, + modelName: ModelName, + variantName?: string, +): ModelAccessor { const contract = context.contract; const fieldToColumn = getFieldToColumnMap(contract, modelName); const tableName = resolveModelTableName(contract, modelName); const modelRelations = resolveModelRelations(contract, modelName); + // When a variant is selected, MTI variant-owned fields resolve to a + // `ColumnRef` qualified against the variant table the read path joins into + // the correlated child SELECT. STI variant columns live on the base table + // and never appear here, so base resolution is untouched. Gating strictly + // on `variantName` keeps the common base-predicate path byte-for-byte + // unchanged. + const variantFieldColumns: Record = variantName + ? resolveVariantFieldColumns(contract, modelName, variantName) + : {}; const opsByCodecId = new Map(); @@ -84,8 +99,10 @@ export function createModelAccessor< return createRelationFilterAccessor(context, modelName, tableName, relation); } - const columnName = fieldToColumn[prop] ?? prop; - const column = resolveColumn(contract, tableName, columnName); + const variantField = variantFieldColumns[prop]; + const resolvedTable = variantField?.table ?? tableName; + const columnName = variantField?.column ?? fieldToColumn[prop] ?? prop; + const column = resolveColumn(contract, resolvedTable, columnName); // Unknown fields return `undefined`, matching plain JS object semantics. // The `ModelAccessor` type already rejects typos // at compile time for TS consumers, and contexts that iterate accessor @@ -96,9 +113,9 @@ export function createModelAccessor< } const traits = context.codecDescriptors.descriptorFor(column.codecId)?.traits ?? []; const operations = opsByCodecId.get(column.codecId) ?? []; - const codec = codecRefForStorageColumn(contract.storage, tableName, columnName); + const codec = codecRefForStorageColumn(contract.storage, resolvedTable, columnName); return createScalarFieldAccessor( - tableName, + resolvedTable, columnName, column.codecId, column.nullable, diff --git a/packages/3-extensions/sql-orm-client/src/query-plan-select.ts b/packages/3-extensions/sql-orm-client/src/query-plan-select.ts index 7d04f8bdce..a9d4596c57 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 @@ -292,6 +292,42 @@ function buildNestedIncludeArtifacts( return { projections }; } +/** + * Resolve the MTI variant joins + `variant_table__column` projection for an + * include whose target model is polymorphic, mirroring the parent path in + * `compileSelectWithIncludes`. The discriminator column and any STI + * variant-specific columns live on the base table and reach the row through + * the ordinary base-column projection (`buildProjection`); only the MTI + * variant tables need a join. + * + * When the child base table is aliased (self-relations), `buildMtiJoins` + * emits a join `ON` against the unaliased base table name, which would fall + * out of scope. Remap it to the child alias — the same remap the row builder + * already applies to `orderBy`/`where`. + */ +function buildChildPolymorphismArtifacts( + contract: Contract, + include: IncludeExpr, + childTableAlias: string | undefined, + childTableRef: string, +): { joins: ReadonlyArray; projection: ReadonlyArray } { + const polyInfo = resolvePolymorphismInfo(contract, include.relatedModelName); + if (!polyInfo || polyInfo.mtiVariants.length === 0) { + return { joins: [], projection: [] }; + } + + const { joins, projection } = buildMtiJoins(contract, polyInfo, include.nested.variantName); + if (!childTableAlias) { + return { joins, projection }; + } + + const remapper = createTableRefRemapper(polyInfo.baseTable, childTableRef); + return { + joins: joins.map((join) => join.rewrite(remapper)), + projection, + }; +} + function buildIncludeChildRowsSelect( contract: Contract, parentTableName: string, @@ -369,6 +405,18 @@ function buildIncludeChildRowsSelect( childTableRef, ); + // When the include target is polymorphic, mirror the parent path: join + // the MTI variant tables into the correlated subquery's FROM and project + // their `variant_table__column` cells so the decoder can resolve each + // row's variant. The discriminator + STI variant columns ride the base + // projection above. + const polyArtifacts = buildChildPolymorphismArtifacts( + contract, + include, + childTableAlias, + childTableRef, + ); + // 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 @@ -380,17 +428,22 @@ function buildIncludeChildRowsSelect( ); // `childProjection` is the set of items that survive into the parent's - // JSON object — the scalar columns plus any nested-include aggregate - // columns. The hidden order-by projection is separate and is dropped - // before assembling the parent's json_object_expr. + // JSON object — the scalar columns, the MTI variant columns, plus any + // nested-include aggregate columns. The hidden order-by projection is + // separate and is dropped before assembling the parent's + // json_object_expr. const childProjection: ReadonlyArray = [ ...scalarProjection, + ...polyArtifacts.projection, ...nestedProjections, ]; let childRows = SelectAst.from(TableSource.named(include.relatedTableName, childTableAlias)) .withProjection([...childProjection, ...hiddenOrderProjection]) .withWhere(whereExpr); + if (polyArtifacts.joins.length > 0) { + childRows = childRows.withJoins([...polyArtifacts.joins]); + } if (childState.distinctOn && childState.distinctOn.length > 0) { childRows = childRows.withDistinctOn( @@ -511,9 +564,28 @@ function buildDistinctNonLeafChildRowsSelect(options: { selectedForQuery, childTableRef, ); - const baseInner = SelectAst.from(TableSource.named(include.relatedTableName, childTableAlias)) - .withProjection([...innerScalarProjection, ...hiddenOrderProjection]) + + // Polymorphic target: join the MTI variant tables into the pre-dedup + // inner SELECT and carry their `variant_table__column` cells through the + // ROW_NUMBER wrap so they reach the outer projection (forwarded by alias + // from `distinctAlias` below). The discriminator + STI variant columns + // ride the base projection. + const polyArtifacts = buildChildPolymorphismArtifacts( + contract, + include, + childTableAlias, + childTableRef, + ); + let baseInner = SelectAst.from(TableSource.named(include.relatedTableName, childTableAlias)) + .withProjection([ + ...innerScalarProjection, + ...polyArtifacts.projection, + ...hiddenOrderProjection, + ]) .withWhere(whereExpr); + if (polyArtifacts.joins.length > 0) { + baseInner = baseInner.withJoins([...polyArtifacts.joins]); + } // `childState.distinct` is non-empty by the `isDistinctNonLeaf` guard // at the only caller (`buildIncludeChildRowsSelect`); assert here so @@ -570,6 +642,13 @@ function buildDistinctNonLeafChildRowsSelect(options: { childState.includes, ); + // Forward the MTI variant columns the inner wrap carried under their + // `variant_table__column` aliases onto the outer SELECT, now sourced + // from the deduped distinct alias (their join is gone at this level). + const outerPolyProjection = polyArtifacts.projection.map((proj) => + ProjectionItem.of(proj.alias, ColumnRef.of(distinctAlias, proj.alias), proj.codec), + ); + // Forward hidden order columns from the inner distinct subquery to the // outer SELECT so `aggregateOrderBy` (which still references `rowsAlias`) // can resolve them when the outer wrap materialises `(childRows) AS rowsAlias`. @@ -579,6 +658,7 @@ function buildDistinctNonLeafChildRowsSelect(options: { const childProjection: ReadonlyArray = [ ...outerScalarProjection, + ...outerPolyProjection, ...outerNestedProjections, ]; diff --git a/packages/3-extensions/sql-orm-client/src/types.ts b/packages/3-extensions/sql-orm-client/src/types.ts index 7726116073..4afe28f894 100644 --- a/packages/3-extensions/sql-orm-client/src/types.ts +++ b/packages/3-extensions/sql-orm-client/src/types.ts @@ -402,6 +402,24 @@ export type ModelAccessor< ModelName extends string, > = ScalarModelAccessor & RelationModelAccessor; +/** + * The predicate accessor for a collection narrowed to a variant. When a real + * variant is selected its (possibly MTI) fields are merged onto the base + * accessor so `t.variant('Feature').where(x => x.priority…)` type-checks; with + * no variant the accessor is the plain base `ModelAccessor` and is unchanged. + */ +export type VariantAwareModelAccessor< + TContract extends Contract, + ModelName extends string, + VariantName extends string | undefined, +> = [VariantName] extends [string] + ? VariantName extends VariantNames + ? ScalarModelAccessor & + ScalarModelAccessor & + RelationModelAccessor + : ModelAccessor + : ModelAccessor; + export type DefaultModelRow, ModelName extends string> = { [K in keyof FieldsOf & string]: FieldJsType; }; 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 e829131694..59a7430f6d 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 @@ -1,8 +1,42 @@ +import type { Contract } from '@prisma-next/contract/types'; +import type { SqlStorage } from '@prisma-next/sql-contract/types'; import { describe, expect, it } from 'vitest'; +import { resolveIncludeRelation } from '../src/collection-contract'; import { dispatchCollectionRows } from '../src/collection-dispatch'; +import { type CollectionState, emptyState, type IncludeExpr } from '../src/types'; import { createCollectionFor } from './collection-fixtures'; import type { MockRuntime, TestContract } from './helpers'; -import { getTestContract, withCapabilities } from './helpers'; +import { + buildMixedPolyContract, + buildStiPolyContract, + createMockRuntime, + getTestContract, + withCapabilities, +} from './helpers'; + +function includeFor( + contract: Contract, + parentModel: string, + relationName: string, + nested: CollectionState = emptyState(), +): IncludeExpr { + const relation = resolveIncludeRelation(contract, parentModel, relationName); + return { + relationName, + relatedModelName: relation.relatedModelName, + relatedTableName: relation.relatedTableName, + targetColumn: relation.targetColumn, + localColumn: relation.localColumn, + cardinality: relation.cardinality, + nested, + scalar: undefined, + combine: undefined, + }; +} + +function stateWithInclude(include: IncludeExpr): CollectionState { + return { ...emptyState(), includes: [include] }; +} function withSingleQueryCapabilities(contract: TestContract) { return withCapabilities(contract, { @@ -335,6 +369,194 @@ describe('collection-dispatch', () => { ]); }); + it('dispatchCollectionRows() decodes STI-target include child rows to their discriminator variant', async () => { + const contract = withSingleQueryCapabilities(buildStiPolyContract()); + const runtime = createMockRuntime(); + const state = stateWithInclude(includeFor(contract, 'Account', 'members')); + // Both STI variant columns live in the base table, so the child SELECT + // projects both for every row; the non-matching variant's column is NULL. + // Decoding by discriminator must keep the matching variant's field and + // strip the other variant's NULL column entirely. + runtime.setNextResults([ + [ + { + id: 1, + name: 'Acme', + members: + '[{"id":10,"name":"Ada","email":"ada@example.com","kind":"admin","role":"owner","plan":null},{"id":11,"name":"Bo","email":"bo@example.com","kind":"regular","role":null,"plan":"free"}]', + }, + ], + ]); + + const rows = await dispatchCollectionRows>({ + contract, + runtime, + state, + tableName: 'accounts', + modelName: 'Account', + }).toArray(); + + const members = (rows[0] as { members: Record[] }).members; + expect(members[0]).toEqual({ + id: 10, + name: 'Ada', + email: 'ada@example.com', + kind: 'admin', + role: 'owner', + }); + expect(members[0]).not.toHaveProperty('plan'); + expect(members[1]).toEqual({ + id: 11, + name: 'Bo', + email: 'bo@example.com', + kind: 'regular', + plan: 'free', + }); + expect(members[1]).not.toHaveProperty('role'); + }); + + it('dispatchCollectionRows() decodes MTI-target include child rows, surfacing variant columns under their field names', async () => { + const contract = withSingleQueryCapabilities(buildMixedPolyContract()); + const runtime = createMockRuntime(); + const state = stateWithInclude(includeFor(contract, 'Project', 'tasks')); + runtime.setNextResults([ + [ + { + id: 1, + name: 'Roadmap', + tasks: + '[{"id":10,"title":"Crash","type":"bug","severity":"critical","project_id":1,"features__priority":null},{"id":11,"title":"Dark mode","type":"feature","severity":null,"project_id":1,"features__priority":7}]', + }, + ], + ]); + + const rows = await dispatchCollectionRows>({ + contract, + runtime, + state, + tableName: 'projects_tbl', + modelName: 'Project', + }).toArray(); + + const tasks = (rows[0] as { tasks: Record[] }).tasks; + expect(tasks[0]).toMatchObject({ id: 10, title: 'Crash', type: 'bug', severity: 'critical' }); + expect(tasks[0]).not.toHaveProperty('priority'); + expect(tasks[1]).toMatchObject({ id: 11, title: 'Dark mode', type: 'feature', priority: 7 }); + expect(tasks[1]).not.toHaveProperty('severity'); + }); + + it('dispatchCollectionRows() decodes a nested include hanging off a poly-target child from the raw child row, for both an MTI and a non-MTI variant', async () => { + // Regression guard: a nested include through a polymorphic include target + // used to decode the grandchild to an empty value for every row. The poly + // branch maps the child row via `mapPolymorphicRow` (which drops every + // column not in the variant model-field map — including the nested + // payload's relation alias), then read the nested payload from the MAPPED + // row, so it was always gone. The fix reads each nested payload from the + // RAW child row. This must hold for a variant WITH an MTI table (Feature -> + // features) and one WITHOUT (Bug, STI on the base table) — the bug hit both. + const contract = withSingleQueryCapabilities(buildMixedPolyContract()); + const runtime = createMockRuntime(); + const state = stateWithInclude( + includeFor(contract, 'Project', 'tasks', { + ...emptyState(), + includes: [includeFor(contract, 'Task', 'subtasks')], + }), + ); + + // Each task child row carries its `subtasks` nested payload under the + // relation alias (already parsed by the outer JSON.parse, so an array). + // The poly columns (`severity` for the STI Bug, `features__priority` for + // the MTI Feature) plus the sibling-variant NULL columns are present, as + // the per-variant SELECT emits them. + runtime.setNextResults([ + [ + { + id: 1, + name: 'Roadmap', + tasks: JSON.stringify([ + { + id: 10, + title: 'Crash', + type: 'bug', + severity: 'critical', + project_id: 1, + features__priority: null, + subtasks: [{ id: 100, title: 'Repro', type: 'bug', parent_id: 10 }], + }, + { + id: 11, + title: 'Dark mode', + type: 'feature', + severity: null, + project_id: 1, + features__priority: 7, + subtasks: [{ id: 101, title: 'Toggle', type: 'feature', parent_id: 11 }], + }, + ]), + }, + ], + ]); + + const rows = await dispatchCollectionRows>({ + contract, + runtime, + state, + tableName: 'projects_tbl', + modelName: 'Project', + }).toArray(); + + const tasks = (rows[0] as { tasks: Record[] }).tasks; + + // Non-MTI (STI) variant: grandchild decodes to its real value, parent poly + // row is still variant-shaped (the sibling-variant `priority` column is + // dropped). + expect(tasks[0]).toMatchObject({ id: 10, title: 'Crash', type: 'bug', severity: 'critical' }); + expect(tasks[0]).not.toHaveProperty('priority'); + expect(tasks[0]!['subtasks']).toEqual([{ id: 100, title: 'Repro', type: 'bug', parentId: 10 }]); + + // MTI variant: grandchild decodes to its real value, parent poly row is + // still variant-shaped (the sibling-variant `severity` column is dropped). + expect(tasks[1]).toMatchObject({ id: 11, title: 'Dark mode', type: 'feature', priority: 7 }); + expect(tasks[1]).not.toHaveProperty('severity'); + expect(tasks[1]!['subtasks']).toEqual([ + { id: 101, title: 'Toggle', type: 'feature', parentId: 11 }, + ]); + }); + + it('dispatchCollectionRows() maps a variant-narrowed include via its named variant', async () => { + const contract = withSingleQueryCapabilities(buildMixedPolyContract()); + const runtime = createMockRuntime(); + // A variant-narrowed include carries `variantName` on the include's nested + // state. The decode side reads that to map every child row to the named + // variant rather than resolving per-row by discriminator. + const state = stateWithInclude( + includeFor(contract, 'Project', 'tasks', { ...emptyState(), variantName: 'Feature' }), + ); + + runtime.setNextResults([ + [ + { + id: 1, + name: 'Roadmap', + tasks: + '[{"id":11,"title":"Dark mode","type":"feature","project_id":1,"features__priority":7}]', + }, + ], + ]); + + const rows = await dispatchCollectionRows>({ + contract, + runtime, + state, + tableName: 'projects_tbl', + modelName: 'Project', + }).toArray(); + + const tasks = (rows[0] as { tasks: Record[] }).tasks; + expect(tasks[0]).toMatchObject({ id: 11, title: 'Dark mode', type: 'feature', priority: 7 }); + expect(tasks[0]).not.toHaveProperty('severity'); + }); + // --------------------------------------------------------------------------- // Single-query include child-row codec decoding — DEFERRED follow-up. // diff --git a/packages/3-extensions/sql-orm-client/test/collection-variant.test.ts b/packages/3-extensions/sql-orm-client/test/collection-variant.test.ts index be3af43645..819d510054 100644 --- a/packages/3-extensions/sql-orm-client/test/collection-variant.test.ts +++ b/packages/3-extensions/sql-orm-client/test/collection-variant.test.ts @@ -14,6 +14,20 @@ import { getTestContext, } from './helpers'; +// The mixed poly contract is patched in at runtime, so Project/tasks are +// absent from the static Models type. This minimal surface lets the runtime +// test drive include('tasks', t => t.variant('Bug')) and read the resulting +// nested state without a static contract for the patched models. +interface PolyVariantRefinement { + variant(name: string): PolyVariantRefinement; +} +interface PolyParent { + include( + relation: 'tasks', + refine?: (collection: PolyVariantRefinement) => PolyVariantRefinement, + ): { state: { includes: { nested: { variantName?: string } }[] } }; +} + function createPolyCollection() { const contract = buildStiPolyContract(); const baseContext = getTestContext(); @@ -199,6 +213,28 @@ describe('Mixed STI+MTI polymorphic query pipeline', () => { expect(rows).toHaveLength(1); expect(rows[0]).toEqual({ id: 2, title: 'Dark mode', type: 'feature', priority: 1 }); }); + + it('variant() on a polymorphic-target include refinement sets nested variantName', () => { + const contract = buildMixedPolyContract(); + const context = { ...getTestContext(), contract }; + const runtime = createMockRuntime(); + const projects = new Collection({ runtime, context }, 'Project') as unknown as PolyParent; + + const refined = projects.include('tasks', (tasks) => tasks.variant('Bug')); + + expect(refined.state.includes[0]?.nested.variantName).toBe('Bug'); + }); + + it('include of a polymorphic-target relation without refinement leaves variantName unset', () => { + const contract = buildMixedPolyContract(); + const context = { ...getTestContext(), contract }; + const runtime = createMockRuntime(); + const projects = new Collection({ runtime, context }, 'Project') as unknown as PolyParent; + + const included = projects.include('tasks'); + + expect(included.state.includes[0]?.nested.variantName).toBeUndefined(); + }); }); function createReturningMixedPolyCollection() { diff --git a/packages/3-extensions/sql-orm-client/test/helpers.ts b/packages/3-extensions/sql-orm-client/test/helpers.ts index 26fee9d55a..7ef8c3c3f0 100644 --- a/packages/3-extensions/sql-orm-client/test/helpers.ts +++ b/packages/3-extensions/sql-orm-client/test/helpers.ts @@ -163,6 +163,12 @@ export interface MockRuntime extends RuntimeQueryable { * - Task (base, table: tasks, discriminator: type) * - Bug (STI, table: tasks, value: bug) with `severity` field * - Feature (MTI, table: features, value: feature) with `priority` field + * + * A non-polymorphic `Project` parent (table: projects_tbl) owns a `tasks` + * relation targeting the polymorphic `Task`, so an include can be planned + * against a polymorphic target. `Task` also carries a self-relation + * `subtasks` (parent_id → id) so the self-relation alias path can be + * exercised on a polymorphic target. */ export function buildMixedPolyContract(): TestContract { const raw = JSON.parse(JSON.stringify(getTestContract())); @@ -173,16 +179,48 @@ export function buildMixedPolyContract(): TestContract { id: { nullable: false, type: { kind: 'scalar', codecId: 'pg/int4@1' } }, title: { nullable: false, type: { kind: 'scalar', codecId: 'pg/text@1' } }, type: { nullable: false, type: { kind: 'scalar', codecId: 'pg/text@1' } }, + projectId: { nullable: true, type: { kind: 'scalar', codecId: 'pg/int4@1' } }, + parentId: { nullable: true, type: { kind: 'scalar', codecId: 'pg/int4@1' } }, + }, + relations: { + subtasks: { + to: { model: 'Task' }, + cardinality: '1:N', + on: { localFields: ['id'], targetFields: ['parentId'] }, + }, }, - relations: {}, storage: { table: 'tasks', - fields: { id: { column: 'id' }, title: { column: 'title' }, type: { column: 'type' } }, + fields: { + id: { column: 'id' }, + title: { column: 'title' }, + type: { column: 'type' }, + projectId: { column: 'project_id' }, + parentId: { column: 'parent_id' }, + }, }, discriminator: { field: 'type' }, variants: { Bug: { value: 'bug' }, Feature: { value: 'feature' } }, }; + domainModels['Project'] = { + fields: { + id: { nullable: false, type: { kind: 'scalar', codecId: 'pg/int4@1' } }, + name: { nullable: false, type: { kind: 'scalar', codecId: 'pg/text@1' } }, + }, + relations: { + tasks: { + to: { model: 'Task' }, + cardinality: '1:N', + on: { localFields: ['id'], targetFields: ['projectId'] }, + }, + }, + storage: { + table: 'projects_tbl', + fields: { id: { column: 'id' }, name: { column: 'name' } }, + }, + }; + domainModels['Bug'] = { fields: { severity: { nullable: true, type: { kind: 'scalar', codecId: 'pg/text@1' } } }, relations: {}, @@ -203,6 +241,19 @@ export function buildMixedPolyContract(): TestContract { title: { nativeType: 'text', codecId: 'pg/text@1', nullable: false }, type: { nativeType: 'text', codecId: 'pg/text@1', nullable: false }, severity: { nativeType: 'text', codecId: 'pg/text@1', nullable: true }, + project_id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: true }, + parent_id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: true }, + }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }; + + raw.storage.namespaces[UNBOUND_NAMESPACE_ID].tables.projects_tbl = { + columns: { + id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, + name: { nativeType: 'text', codecId: 'pg/text@1', nullable: false }, }, primaryKey: { columns: ['id'] }, uniques: [], @@ -229,6 +280,11 @@ export function buildMixedPolyContract(): TestContract { * - User (base, table: users, discriminator: kind) * - Admin (STI, table: users, value: admin) with `role` field * - Regular (STI, table: users, value: regular) with `plan` field + * + * A non-polymorphic `Account` parent (table: accounts) owns a `members` + * relation targeting the STI-polymorphic `User`, so an include can be + * planned against an STI-only polymorphic target (no MTI variant tables, + * so no joins — only discriminator + variant base-table column projection). */ export function buildStiPolyContract(): TestContract { const raw = JSON.parse(JSON.stringify(getTestContract())); @@ -242,12 +298,37 @@ export function buildStiPolyContract(): TestContract { (userModel.storage as { fields: Record }).fields['kind'] = { column: 'kind', }; + userModel.fields['accountId'] = { + nullable: true, + type: { kind: 'scalar', codecId: 'pg/int4@1' }, + }; + (userModel.storage as { fields: Record }).fields['accountId'] = { + column: 'account_id', + }; userModel.discriminator = { field: 'kind' }; userModel.variants = { Admin: { value: 'admin' }, Regular: { value: 'regular' }, }; + domainModels['Account'] = { + fields: { + id: { nullable: false, type: { kind: 'scalar', codecId: 'pg/int4@1' } }, + name: { nullable: false, type: { kind: 'scalar', codecId: 'pg/text@1' } }, + }, + relations: { + members: { + to: { model: 'User' }, + cardinality: '1:N', + on: { localFields: ['id'], targetFields: ['accountId'] }, + }, + }, + storage: { + table: 'accounts', + fields: { id: { column: 'id' }, name: { column: 'name' } }, + }, + }; + domainModels['Admin'] = { fields: { role: { nullable: false, type: { kind: 'scalar', codecId: 'pg/text@1' } } }, relations: {}, @@ -284,6 +365,22 @@ export function buildStiPolyContract(): TestContract { nativeType: 'text', nullable: true, }; + usersStorageTable.columns['account_id'] = { + codecId: 'pg/int4@1', + nativeType: 'int4', + nullable: true, + }; + + raw.storage.namespaces[UNBOUND_NAMESPACE_ID].tables.accounts = { + columns: { + id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, + name: { nativeType: 'text', codecId: 'pg/text@1', nullable: false }, + }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }; return raw as TestContract; } diff --git a/packages/3-extensions/sql-orm-client/test/model-accessor.test.ts b/packages/3-extensions/sql-orm-client/test/model-accessor.test.ts index 871d0028bb..c1dbd1e63a 100644 --- a/packages/3-extensions/sql-orm-client/test/model-accessor.test.ts +++ b/packages/3-extensions/sql-orm-client/test/model-accessor.test.ts @@ -17,7 +17,12 @@ import { } from '@prisma-next/sql-relational-core/ast'; import { describe, expect, it } from 'vitest'; import { createModelAccessor } from '../src/model-accessor'; -import { getTestContext, getTestContract, withPatchedDomainModels } from './helpers'; +import { + buildMixedPolyContract, + getTestContext, + getTestContract, + withPatchedDomainModels, +} from './helpers'; import { unboundTables } from './unbound-tables'; describe('createModelAccessor', () => { @@ -479,6 +484,79 @@ describe('createModelAccessor', () => { }); }); + describe('variant-aware field resolution', () => { + const polyContext = { ...context, contract: buildMixedPolyContract() }; + + // Codecs come from the patched poly contract's storage, not the base + // test contract that the outer `paramRef` helper reads. + function polyParam(table: string, column: string, value: unknown): ParamRef { + const tables = unboundTables(polyContext.contract.storage) as Record< + string, + { columns: Record } | undefined + >; + const codecId = tables[table]?.columns[column]?.codecId; + return codecId ? ParamRef.of(value, { codec: { codecId } }) : ParamRef.of(value); + } + + // The base `Task` accessor type carries only base fields; the patched poly + // contract adds variant columns at runtime. View the accessor as a bag of + // comparison methods so the runtime resolution can be asserted regardless + // of the static base type. + interface FieldOps { + eq(value: unknown): unknown; + gte(value: unknown): unknown; + } + type FieldBag = Record; + + it('resolves an MTI variant column against the joined variant table', () => { + const feature = createModelAccessor(polyContext, 'Task', 'Feature') as unknown as FieldBag; + // `priority` lives on the joined `features` table, not the base `tasks`. + expect(feature['priority']!.gte(3)).toEqual( + new BinaryExpr( + 'gte', + ColumnRef.of('features', 'priority'), + polyParam('features', 'priority', 3), + ), + ); + }); + + it('keeps base columns qualified against the base table when a variant is selected', () => { + const feature = createModelAccessor(polyContext, 'Task', 'Feature') as unknown as FieldBag; + expect(feature['title']!.eq('Dark mode')).toEqual( + new BinaryExpr( + 'eq', + ColumnRef.of('tasks', 'title'), + polyParam('tasks', 'title', 'Dark mode'), + ), + ); + }); + + it('does not expose another variant column for the selected variant', () => { + const feature = createModelAccessor(polyContext, 'Task', 'Feature') as unknown as FieldBag; + expect(feature['priority']).toBeDefined(); + const bug = createModelAccessor(polyContext, 'Task', 'Bug') as unknown as FieldBag; + // Bug is STI — its `severity` rides the base table, never the features join. + expect(bug['severity']!.eq('critical')).toEqual( + new BinaryExpr( + 'eq', + ColumnRef.of('tasks', 'severity'), + polyParam('tasks', 'severity', 'critical'), + ), + ); + // Selecting an STI variant must not surface the MTI variant column. + expect(bug['priority']).toBeUndefined(); + }); + + it('leaves base resolution untouched when no variant is selected', () => { + const task = createModelAccessor(polyContext, 'Task') as unknown as FieldBag; + expect(task['title']!.eq('x')).toEqual( + new BinaryExpr('eq', ColumnRef.of('tasks', 'title'), polyParam('tasks', 'title', 'x')), + ); + // Without a selected variant the MTI variant column is not resolvable. + expect(task['priority']).toBeUndefined(); + }); + }); + describe('extension operations', () => { it('attaches trait-targeted op only when codec traits are a superset of required traits', () => { const queryOperations = createSqlOperationRegistry(); diff --git a/packages/3-extensions/sql-orm-client/test/polymorphism.test-d.ts b/packages/3-extensions/sql-orm-client/test/polymorphism.test-d.ts index 8226623abf..4b6fed9d97 100644 --- a/packages/3-extensions/sql-orm-client/test/polymorphism.test-d.ts +++ b/packages/3-extensions/sql-orm-client/test/polymorphism.test-d.ts @@ -34,6 +34,11 @@ interface PolyStorage { readonly codecId: 'pg/text@1'; readonly nullable: true; }; + readonly project_id: { + readonly nativeType: 'int4'; + readonly codecId: 'pg/int4@1'; + readonly nullable: true; + }; }; primaryKey: { columns: readonly ['id'] }; uniques: readonly []; @@ -76,6 +81,24 @@ interface PolyStorage { indexes: readonly []; foreignKeys: readonly []; }; + readonly projects: { + columns: { + readonly id: { + readonly nativeType: 'int4'; + readonly codecId: 'pg/int4@1'; + readonly nullable: false; + }; + readonly name: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + }; + primaryKey: { columns: readonly ['id'] }; + uniques: readonly []; + indexes: readonly []; + foreignKeys: readonly []; + }; }; readonly storageHash: string; } @@ -99,6 +122,10 @@ type PolyContract = Contract< readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; readonly nullable: false; }; + readonly projectId: { + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + readonly nullable: true; + }; }; readonly relations: R; readonly storage: { @@ -107,6 +134,7 @@ type PolyContract = Contract< readonly id: { readonly column: 'id' }; readonly title: { readonly column: 'title' }; readonly type: { readonly column: 'type' }; + readonly projectId: { readonly column: 'project_id' }; }; }; readonly discriminator: { readonly field: 'type' }; @@ -167,6 +195,35 @@ type PolyContract = Contract< }; }; }; + readonly Project: { + readonly fields: { + readonly id: { + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + readonly nullable: false; + }; + readonly name: { + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + readonly nullable: false; + }; + }; + readonly relations: { + readonly tasks: { + readonly to: { readonly namespace: '__unbound__' & NamespaceId; readonly model: 'Task' }; + readonly cardinality: '1:N'; + readonly on: { + readonly localFields: readonly ['id']; + readonly targetFields: readonly ['projectId']; + }; + }; + }; + readonly storage: { + readonly table: 'projects'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly name: { readonly column: 'name' }; + }; + }; + }; } >; @@ -273,3 +330,93 @@ test('ResolvedCreateInput with variant name equals VariantCreateInput', () => { type Direct = VariantCreateInput; expectTypeOf().toEqualTypeOf(); }); + +// --------------------------------------------------------------------------- +// Include narrowing: a polymorphic-target relation surfaces the variant union +// by default, and `r.variant('X')` narrows the included value to variant X. +// --------------------------------------------------------------------------- + +type RowOfCollection = TCollection extends { all(): infer R } + ? R extends AsyncIterable + ? T + : never + : never; + +declare const projects: Collection; + +test('include of a polymorphic-target relation types the value as the variant union', () => { + type Included = RowOfCollection>>['tasks']; + expectTypeOf().toExtend(); + type Element = Included[number]; + expectTypeOf().toEqualTypeOf<'bug' | 'feature'>(); +}); + +test('include without refinement narrows each variant exclusively by discriminator', () => { + type Included = RowOfCollection>>['tasks']; + const element = {} as unknown as Included[number]; + if (element.type === 'bug') { + expectTypeOf().toHaveProperty('severity'); + // @ts-expect-error priority only exists on the Feature variant + element.priority; + } + if (element.type === 'feature') { + expectTypeOf().toHaveProperty('priority'); + // @ts-expect-error severity only exists on the Bug variant + element.severity; + } +}); + +test('r.variant("Bug") on an include refinement narrows the value to the Bug variant', () => { + const refined = projects.include('tasks', (tasks) => tasks.variant('Bug')); + type Included = RowOfCollection['tasks']; + type Element = Included[number]; + expectTypeOf().toEqualTypeOf<'bug'>(); + expectTypeOf().toHaveProperty('severity'); + expectTypeOf().not.toHaveProperty('priority'); +}); + +test('r.variant("Feature") on an include refinement narrows the value to the Feature variant', () => { + const refined = projects.include('tasks', (tasks) => tasks.variant('Feature')); + type Included = RowOfCollection['tasks']; + type Element = Included[number]; + expectTypeOf().toEqualTypeOf<'feature'>(); + expectTypeOf().toHaveProperty('priority'); + expectTypeOf().not.toHaveProperty('severity'); +}); + +// --------------------------------------------------------------------------- +// Variant-aware predicate accessor: inside `t.variant('X').where(...)` the +// predicate model exposes variant X's fields (MTI variant columns included). +// --------------------------------------------------------------------------- + +test('where after variant("Feature") exposes the MTI variant field on the predicate model', () => { + projects.include('tasks', (tasks) => + tasks.variant('Feature').where((task) => { + expectTypeOf(task).toHaveProperty('priority'); + expectTypeOf(task).toHaveProperty('title'); + return task.priority.gte(3); + }), + ); +}); + +test('where after variant("Bug") exposes the Bug variant field and rejects the other variant field', () => { + projects.include('tasks', (tasks) => + tasks.variant('Bug').where((task) => { + expectTypeOf(task).toHaveProperty('severity'); + // @ts-expect-error priority belongs to the Feature variant, not Bug + task.priority; + return task.severity.isNull(); + }), + ); +}); + +test('where without a variant exposes only base fields on the predicate model', () => { + projects.include('tasks', (tasks) => + tasks.where((task) => { + expectTypeOf(task).toHaveProperty('title'); + // @ts-expect-error priority is an MTI variant field, absent on the base predicate model + task.priority; + return task.title.isNotNull(); + }), + ); +}); diff --git a/packages/3-extensions/sql-orm-client/test/query-plan-select.test.ts b/packages/3-extensions/sql-orm-client/test/query-plan-select.test.ts index dea944e88d..5fcdf6191c 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 @@ -1,3 +1,5 @@ +import type { Contract } from '@prisma-next/contract/types'; +import type { SqlStorage } from '@prisma-next/sql-contract/types'; import { AggregateExpr, AndExpr, @@ -20,11 +22,12 @@ import { TableSource, } from '@prisma-next/sql-relational-core/ast'; import { describe, expect, it } from 'vitest'; +import { resolveIncludeRelation } from '../src/collection-contract'; import { compileSelect, compileSelectWithIncludes } from '../src/query-plan-select'; -import { emptyState } from '../src/types'; +import { type CollectionState, emptyState, type IncludeExpr } from '../src/types'; import { bindWhereExpr } from '../src/where-binding'; import { baseContract, createCollection, createCollectionFor } from './collection-fixtures'; -import { buildMixedPolyContract, isSelectAst } from './helpers'; +import { buildMixedPolyContract, buildStiPolyContract, isSelectAst } from './helpers'; import { unboundTables } from './unbound-tables'; function codecForColumn(table: string, column: string): string { @@ -666,6 +669,8 @@ describe('compileSelect MTI JOINs', () => { 'title', 'type', 'severity', + 'project_id', + 'parent_id', ]); const featuresMtiProjection = [ ProjectionItem.of( @@ -693,6 +698,8 @@ describe('compileSelect MTI JOINs', () => { 'title', 'type', 'severity', + 'project_id', + 'parent_id', ]); const featuresMtiProjection = [ ProjectionItem.of( @@ -720,6 +727,8 @@ describe('compileSelect MTI JOINs', () => { 'title', 'type', 'severity', + 'project_id', + 'parent_id', ]); const plan = compileSelect(contract, 'tasks', state, 'Task'); @@ -743,3 +752,114 @@ describe('compileSelect MTI JOINs', () => { ); }); }); + +describe('compileSelectWithIncludes polymorphic targets', () => { + function includeFor( + contract: Contract, + parentModel: string, + relationName: string, + nested: CollectionState = emptyState(), + ): IncludeExpr { + const relation = resolveIncludeRelation(contract, parentModel, relationName); + return { + relationName, + relatedModelName: relation.relatedModelName, + relatedTableName: relation.relatedTableName, + targetColumn: relation.targetColumn, + localColumn: relation.localColumn, + cardinality: relation.cardinality, + nested, + scalar: undefined, + combine: undefined, + }; + } + + function stateWithInclude(include: IncludeExpr): CollectionState { + return { ...emptyState(), includes: [include] }; + } + + function childRowsSelectFor(plan: { ast: unknown }, relationName: string): SelectAst { + expectSelectAst(plan.ast); + const projection = plan.ast.projection.find((item) => item.alias === relationName); + expectSubqueryExpr(projection?.expr); + const aggregateQuery = projection.expr.query; + expectDerivedTableSource(aggregateQuery.from); + return aggregateQuery.from.query; + } + + function projectionAliases(select: SelectAst): string[] { + return select.projection.map((item) => item.alias); + } + + it('STI-target include projects discriminator and variant base-table columns, no joins', () => { + const contract = buildStiPolyContract(); + const state = stateWithInclude(includeFor(contract, 'Account', 'members')); + + const plan = compileSelectWithIncludes(contract, 'accounts', state, 'Account'); + const childRows = childRowsSelectFor(plan, 'members'); + + expect(childRows.joins ?? []).toHaveLength(0); + const aliases = projectionAliases(childRows); + expect(aliases).toContain('kind'); + expect(aliases).toContain('role'); + expect(aliases).toContain('plan'); + }); + + it('MTI-target include left-joins variant tables and projects variant_table__column', () => { + const contract = buildMixedPolyContract(); + const state = stateWithInclude(includeFor(contract, 'Project', 'tasks')); + + const plan = compileSelectWithIncludes(contract, 'projects_tbl', state, 'Project'); + const childRows = childRowsSelectFor(plan, 'tasks'); + + expect(childRows.joins).toEqual([ + JoinAst.left( + TableSource.named('features'), + EqColJoinOn.of(ColumnRef.of('tasks', 'id'), ColumnRef.of('features', 'id')), + ), + ]); + + const aliases = projectionAliases(childRows); + expect(aliases).toContain('type'); + expect(aliases).toContain('severity'); + expect(aliases).toContain('features__priority'); + }); + + it('variant-narrowed MTI-target include inner-joins only the named variant', () => { + const contract = buildMixedPolyContract(); + const include = includeFor(contract, 'Project', 'tasks', { + ...emptyState(), + variantName: 'Feature', + }); + const state = stateWithInclude(include); + + const plan = compileSelectWithIncludes(contract, 'projects_tbl', state, 'Project'); + const childRows = childRowsSelectFor(plan, 'tasks'); + + expect(childRows.joins).toEqual([ + JoinAst.inner( + TableSource.named('features'), + EqColJoinOn.of(ColumnRef.of('tasks', 'id'), ColumnRef.of('features', 'id')), + ), + ]); + expect(projectionAliases(childRows)).toContain('features__priority'); + }); + + it('self-relation poly include remaps the variant join ON to the child alias', () => { + const contract = buildMixedPolyContract(); + // `subtasks` is a Task→Task self relation; the child base table is + // aliased, so the variant join ON must reference the alias rather + // than the unaliased base table name. + const state = stateWithInclude(includeFor(contract, 'Task', 'subtasks')); + + const plan = compileSelectWithIncludes(contract, 'tasks', state, 'Task'); + const childRows = childRowsSelectFor(plan, 'subtasks'); + + expect(childRows.joins).toEqual([ + JoinAst.left( + TableSource.named('features'), + EqColJoinOn.of(ColumnRef.of('subtasks__child', 'id'), ColumnRef.of('features', 'id')), + ), + ]); + }); +}); diff --git a/projects/tml-2683/design-notes.md b/projects/tml-2683/design-notes.md new file mode 100644 index 0000000000..343e3cf3f9 --- /dev/null +++ b/projects/tml-2683/design-notes.md @@ -0,0 +1,125 @@ +# Design notes — TML-2683 + +Decisions settled before spec authoring (this slice is post-discussion). Alternatives +live here; the spec carries only the decided shape. + +## D1 — `r => r.variant('X')` narrowing on a polymorphic include: **implement it** + +**Decision:** mirror parent `Collection.variant()` at the include-refinement level. A +`.variant('X')` on a polymorphic include applies the discriminator filter (STI) **and** +inner-joins only the named variant table (MTI) inside the LATERAL / correlated child +subquery, and narrows the included relation's TypeScript value to that variant's row type. + +**Why:** the runtime slot already exists — `IncludeExpr.nested` is a `CollectionState`, +which already carries `variantName` (`types.ts:75`). The include refinement callback +already receives a real `Collection` running in `includeRefinementMode` +(`collection.ts:434`), and `Collection.variant()` (`collection.ts:297`) already sets +`state.variantName` + the discriminator filter. The SQL parent path already reads +`state.variantName` to choose inner-vs-left join in `buildMtiJoins` (`query-plan-select.ts:1161-1166`), +and the parent decode path already passes it to `mapPolymorphicRow`. So narrowing at the +child level is mostly *wiring an existing mechanism through one more level* plus the +result-type narrowing — not a new subsystem. Rejecting it at the type level would be +roughly the same effort (a deliberate type-level block) for less capability. + +**Rejected:** defer `.variant()` narrowing to a follow-up (fix only silent degradation + +variant-specific `where`). Cheaper, but leaves an obvious asymmetry with parent +`.variant()` and a half-wired `nested.variantName` slot that the SQL/decode paths would +read but no public API could set. + +## D2 — Baseline: **plan against the post-2657 + post-2729 correlated-only tree** + +**Decision:** the plan assumes both predecessors have landed: +[TML-2657](https://linear.app/prisma-company/issue/TML-2657) (multi-query include strategy +removed from the read path) and [TML-2729](https://linear.app/prisma-company/issue/TML-2729) +(LATERAL dropped from includes; all SQL targets emit correlated subqueries). The read path is +therefore a **single correlated include builder** — `buildCorrelatedIncludeProjection` sharing +a strategy-less `buildIncludeChildRowsSelect`. There is no multi-query stitcher and no lateral +builder to extend. We add the MTI variant joins/projection + the decode mapping to that single +path only. + +**Why:** matches TML-2683's stated rationale ("don't extend two builder families when one is +being deleted"). 2657 has landed; 2729 is assumed landed per operator direction. The original +#619 blocker is resolved. + +**Consequence to watch (from 2729's end-state):** 2729 removed `.withJoins(nestedJoins)` from +`buildIncludeChildRowsSelect` — nested includes are now projection-only correlated subqueries, +so the child SELECT has *no* join source. This slice re-introduces `.withJoins(...)` for the +MTI variant tables. That join (base⋈variant on PK, inside the correlated subquery's FROM) is +ordinary SQL, orthogonal to the LATERAL machinery 2729 removed. Must NOT touch the surfaces +2729 deliberately kept: `JoinAst.lateral`, the postgres renderer's LATERAL emission, the public +`lateralJoin()` DSL, and the `lateral` capability flag. + +**Rejected:** keep a lateral path / strategy axis for this fix. Contradicts 2729. + +## D3 (execution-time, post-D4 discovery) — MTI variant-field `where`: **fix in this slice** + +**Discovery:** D4 integration testing found that a variant-specific `where` on an **MTI** poly +include throws at runtime — `db.orm.Project.include('tasks', t => t.variant('Feature').where(x => x.priority.gte(3)))` +→ `TypeError: Cannot read properties of undefined (reading 'gte')`. Root cause: `.variant()` +(`collection.ts:296`) keeps the base `modelName` and only sets `state.variantName`; the predicate +accessor `createModelAccessor(context, modelName)` (`model-accessor.ts:40`) resolves columns via the +**base** table only (`resolveColumn(contract, baseTable, …)`), so an MTI variant column (which lives +on a joined variant table) is `undefined`. STI variant columns live on the base table → they resolve +→ the STI variant-`where` case works and is tested. So the spec's original AC-3 framing ("falls out +of the joins") holds for STI but **not** MTI. + +**Decision (operator):** fix it in this slice — adds dispatch **D5**. Make the predicate accessor +variant-aware: when a variant is selected, merge that variant's columns and qualify their `ColumnRef` +against the variant table D1 already joins into the child SELECT. The merged field→column map already +exists for the mutation path (`collection.ts` ~`:1274`); reuse the pattern for `where`. + +**Rejected:** defer MTI variant-field filtering to a follow-up ticket (scope AC-3 to STI). Smaller, +but leaves an asymmetry where STI variant-`where` works and MTI silently throws. + +## D4 (execution-time, post-D4 discovery) — integration target: **PGlite-only; amend spec** + +**Discovery:** the `sql-orm-client` integration suite is **Postgres/PGlite-only** (`runtime-helpers.ts` +imports only `postgres*`; `withDevDatabase` = PGlite) — not "both PGlite + SQLite" as the spec/plan +stated. SQLite ORM coverage lives in a separate e2e package (`test/e2e/framework/test/sqlite/`) whose +contract-builder has **no polymorphism support** (no `discriminator`/`variant`). + +**Decision (operator):** accept **PGlite-only** integration coverage for this slice and amend the +"both targets" condition. Rationale: the emitted variant-join/projection lowering is target-agnostic, +so PGlite already exercises the D1–D3 logic; a SQLite leg would only re-confirm the sqlite renderer +handles an ordinary join. SQLite poly-include coverage is a **deferred follow-up**, itself gated on +teaching the sqlite contract-builder about polymorphism. + +**Rejected:** build the SQLite harness now (largest scope; blocked on contract-builder polymorphism). + +## D5b (execution-time, post-D7 discovery) — nested include through a poly target: **fix in this PR (D8)** + +**Discovery:** D7's "nested include through a poly target" scenario (`Parent → tasks(poly) → reporter`) +silently decodes the grandchild to `null` for every row. Root cause: `decodeIncludePayload` +(`collection-dispatch.ts`) poly-maps the child row via `mapPolymorphicRow` **first**, then reads the +nested-include payload from the *mapped* row — but `mapPolymorphicRow` (`collection-runtime.ts:122-126`) +keeps only columns present in the variant model-field map, so the nested payload column (a relation +alias, not a model field) is dropped → `null`. The non-poly mapper (`mapStorageRowToModelFields:58`) +keeps unknown columns via fallback, which is why the non-poly nested path works. Same silent-degradation +class as TML-2683, one level deeper. + +**Decision (operator):** fix in this PR — dispatch **D8**. Preferred fix: read each nested-include +payload from the **raw** child row (`childRow[nestedInclude.relationName]`) before poly-mapping, then +assign the decoded value onto the mapped row — leaving `mapPolymorphicRow`'s variant-shaping (which +must keep dropping sibling-variant columns) untouched. Unskip the D7 scenario-4 test. Regression guard: +sibling-variant columns must still be dropped per row. + +**Rejected:** make `mapPolymorphicRow` keep all unknown columns — would re-break the per-variant shaping +D2 established (sibling-variant NULL columns would resurface). + +## Non-blocking process note — stale `dist` masks the fix in integration + +The integration package imports the **built `dist`** of `@prisma-next/sql-orm-client`, not `src`. A +stale `dist` makes the integration tests silently exercise pre-fix behavior. `pnpm test:integration`'s +`pretest` runs `pnpm -w build`, so the full gate is safe; a bare `vitest` filter run is **not** — +rebuild `@prisma-next/sql-orm-client` first. + + +## Non-blocking note — scalar reducers on polymorphic-target relations + +TML-2683 observes that include scalar reducers (`count()` / `sum()` / …) on a poly-target +relation inherit the SQL-side gap (a `where` on a variant-specific column needs the variant +joins) but not the row-decode gap (they return a primitive). The scalar-reducer SQL is +emitted through the same `buildIncludeChildRowsSelect` family, so the variant joins added in +this slice's SQL-side dispatch cover the scalar `where` case automatically once the +include-aggregates slice (TML-2588 / TML-2595) lands. No extra scalar-specific dispatch is +needed here; called out in the spec's **Out** scope. diff --git a/projects/tml-2683/dispatches/01-sql-side.md b/projects/tml-2683/dispatches/01-sql-side.md new file mode 100644 index 0000000000..c015dca8f8 --- /dev/null +++ b/projects/tml-2683/dispatches/01-sql-side.md @@ -0,0 +1,87 @@ +# Brief: D1 — SQL side: emit polymorphism joins + projection in the child SELECT + +## Task + +In `packages/3-extensions/sql-orm-client/src/query-plan-select.ts`, teach the correlated +include child-SELECT builder about polymorphism. For a `.include()` whose **target (related) +model** is polymorphic, the child correlated subquery must resolve +`resolvePolymorphismInfo(contract, include.relatedModelName)` and, when MTI variants exist, +emit the `buildMtiJoins(...)` joins + `variant_table__column` projection **inside** the child +SELECT — choosing inner-join (named variant) vs left-join (all variants) from +`include.nested.variantName`, exactly as the parent path does in `compileSelectWithIncludes`. +The discriminator column and any STI variant-specific (base-table) columns must be projected so +the decoder (a later dispatch) can resolve each row's variant. This is the parent↔child +symmetry described in the slice spec's "Chosen design". + +Baseline note: the branch is stacked on TML-2729 — the read path is **correlated-only** +(`buildCorrelatedIncludeProjection` is the sole include builder; there is no +`buildLateralIncludeArtifacts`, no `include-strategy.ts`, no `strategy` param). TML-2729 also +removed the child SELECT's `.withJoins(nestedJoins)` (nested includes are projection-only +correlated subqueries), so the child SELECT currently has **no join source** — this dispatch +re-introduces `.withJoins(...)` as the sole source, for the MTI variant tables. That variant +join (base ⋈ variant on PK, in the correlated subquery's FROM) is ordinary SQL, unrelated to +the LATERAL machinery 2729 removed. + +## Scope + +**In:** +- `query-plan-select.ts` — `buildIncludeChildRowsSelect` and `buildDistinctNonLeafChildRowsSelect` + (both child-SELECT builders), reached via `buildCorrelatedIncludeProjection`. Add the + polymorphism resolution + MTI joins/projection + discriminator/STI-variant-column projection. +- Self-relation correctness: when the base table is aliased (`childTableAlias` set), the + `buildMtiJoins` join-`ON` references the unaliased base table name and will fall out of scope — + apply the same alias remap the builder already applies to `orderBy`/`where`. +- `test/query-plan-select.test.ts` — **write the tests first**. Assert the correlated child + SELECT's joins + projection for an STI-target include and an MTI-target include, the + variant-narrowed inner join (`nested.variantName` set), and the self-relation alias remap. + Use/extend `buildStiPolyContract` / `buildMixedPolyContract` from `test/helpers.ts`; if those + contracts lack a parent→poly relation, add one. + +**Out:** +- `decodeIncludePayload` / any decode-side change (that is D2 — rows may still be base-mapped + at runtime after this dispatch; you are only making the data *available* in the SQL). +- The `.variant()` public refinement API surface / result-type narrowing (that is D3). You may + read `include.nested.variantName` here, but do not add the `.variant()` operator to the + refinement collection type. +- Integration tests / new DB fixtures (that is D4). Unit-level query-plan assertions only. +- Anything 2729 deliberately keeps: `JoinAst.lateral`, the postgres LATERAL renderer, the + public `lateralJoin()` DSL, the `lateral` capability flag. + +## Completed when + +- [ ] For an MTI-target `.include()`, the emitted child correlated SELECT joins the variant + table(s) and projects their columns under `variant_table__column` aliases. +- [ ] For an STI-target `.include()`, the child SELECT projects the discriminator column and the + variant-specific base-table columns. +- [ ] `include.nested.variantName`, when set, produces an inner join to only that variant (STI: + discriminator filter already present via the refinement's `where`); when unset, left-joins + all MTI variants. +- [ ] New `test/query-plan-select.test.ts` cases (written before the implementation) cover STI + target, MTI target, variant-narrowed, and self-relation alias remap — and pass. +- [ ] Validation gate green (below). + +## Standing instruction + +Stay focused on the goal; control scope. Trivial-and-related fixes that obviously serve the goal +go in the same dispatch with a one-line note in your wrap-up. Anything that pulls you off the +goal — especially touching the decode path, the `.variant()` type surface, or any 2729-kept +lateral surface — halts and surfaces to the orchestrator. + +## References + +- Slice spec: `projects/tml-2683/spec.md` — chosen design (parent↔child symmetry), scope, edge cases. +- Slice plan: `projects/tml-2683/plan.md` § Dispatch 1. +- Design notes: `projects/tml-2683/design-notes.md` — the 2729 `.withJoins` consequence + must-not-touch list. +- Parent-side template to mirror: `compileSelectWithIncludes` and `buildMtiJoins` in `query-plan-select.ts`. +- Implementer persona: `skills-contrib/drive-dispatch/agents/implementer.md` (commit hygiene, tests-first, heartbeats, no transient IDs in code). + +## Operational metadata + +- **Model tier:** orchestrator-grade (opus) — subtle typed query-AST work with a self-relation alias trap. +- **Validation gate (run once, at end):** + - `pnpm --filter @prisma-next/sql-orm-client typecheck` + - `pnpm --filter @prisma-next/sql-orm-client test` + - `pnpm lint:deps` +- **Halt conditions:** the decode path must be touched to make a unit test pass (means the test is mis-scoped — surface); a 2729-kept lateral surface needs changing; an assumption in the spec is observed false (e.g. `buildMtiJoins` can't be reused inside a correlated subquery); diff exceeds ~12 files. +- **Heartbeats:** write `wip/heartbeats/implementer.txt` per the persona cadence. +- **Commit hygiene:** explicit staging; tests-first commit then implementation commit is ideal; never push. diff --git a/projects/tml-2683/dispatches/02-decode-side.md b/projects/tml-2683/dispatches/02-decode-side.md new file mode 100644 index 0000000000..08ff67dadd --- /dev/null +++ b/projects/tml-2683/dispatches/02-decode-side.md @@ -0,0 +1,76 @@ +# Brief: D2 — Decode side: map poly child rows to their variant + +## Task + +In `packages/3-extensions/sql-orm-client/src/collection-dispatch.ts`, make `decodeIncludePayload` +map polymorphic-target included child rows to their correct variant shape. Today (row branch, +after the scalar/combine short-circuits) it calls +`mapStorageRowToModelFields(contract, include.relatedModelName, childRow)` — and +`include.relatedModelName` is the **base** model (it resolves from `relation.to`), so STI child +rows come back base-shaped (variant fields dropped/mis-mapped) and MTI child rows lack their +variant columns. Change it to: resolve `resolvePolymorphismInfo(contract, include.relatedModelName)` +**once per include** (not per row), and when the related model is polymorphic, map each child row +via `mapPolymorphicRow(contract, include.relatedModelName, polyInfo, childRow, include.nested.variantName)`; +otherwise keep `mapStorageRowToModelFields`. This is the decode half of the parent↔child symmetry — +the parent dispatchers already call `mapPolymorphicRow` for parent rows. + +D1 (already merged on this branch) made the SQL side emit the discriminator column, STI variant +columns, and MTI `variant_table__column`-aliased columns into the child rows — so the data +`mapPolymorphicRow` needs is present in `childRow`. `mapPolymorphicRow` reads the discriminator from +`row[polyInfo.discriminatorColumn]` and merges base + `variant_table__column` cells via +`getMergedColumnToFieldMap` (see `collection-runtime.ts`). + +## Scope + +**In:** +- `collection-dispatch.ts` — `decodeIncludePayload` row branch. Resolve poly info once per include; + branch the per-row mapper. Preserve the existing nested-include recursion and the scalar/combine + branches unchanged. Keep the empty-relation / `coerceSingleQueryIncludeResult` handling intact. +- `test/collection-dispatch.test.ts` — **write tests first**. Construct raw include payloads (the + parsed child-row JSON the SQL side produces: base cols + discriminator + `variant_table__column` + cells) and assert the decoded rows are shaped per their variant — STI rows decode by discriminator + to the right variant fields; MTI rows surface variant columns under their model field names; a + variant-narrowed include (`nested.variantName` set) maps to the named variant. Reuse the poly + contract builders in `test/helpers.ts` (extended by D1). + +**Out:** +- The SQL builder (`query-plan-select.ts`) — D1 owns it; do not touch it. +- The public `.variant()` refinement type surface / result-type narrowing — D3. You may read + `include.nested.variantName`; do not add the `.variant()` operator to the refinement type. +- Integration tests / DB fixtures — D4. + +## Completed when + +- [ ] `decodeIncludePayload` maps poly-target child rows via `mapPolymorphicRow`, resolving poly info + once per include; non-poly includes are unchanged (`mapStorageRowToModelFields`). +- [ ] Nested-include recursion, scalar, and combine branches behave exactly as before for non-poly + relations (no regression). +- [ ] New `test/collection-dispatch.test.ts` cases (written before the implementation) cover STI-target + decode, MTI-target decode, and variant-narrowed decode — and pass. +- [ ] Validation gate green (below). + +## Standing instruction + +Stay focused on the goal; control scope. Tests-first, in their own commit. Anything that pulls you +off-goal — touching the SQL builder, the `.variant()` type surface, or integration fixtures — halts +and surfaces to the orchestrator. + +## References + +- Slice spec: `projects/tml-2683/spec.md` — chosen design (decode half). +- Slice plan: `projects/tml-2683/plan.md` § Dispatch 2. +- D1 commits (already on this branch): `git log --oneline 21551b8f3..HEAD` — the SQL side you build on. Read them to see exactly what cells the child rows now carry. +- `collection-runtime.ts` — `mapPolymorphicRow`, `mapStorageRowToModelFields`, `getMergedColumnToFieldMap`. +- `collection-contract.ts` — `resolvePolymorphismInfo`. +- Parent-side precedent: the `mapPolymorphicRow` call sites in `collection-dispatch.ts` (parent rows). +- Implementer persona: `skills-contrib/drive-dispatch/agents/implementer.md`. + +## Operational metadata + +- **Model tier:** orchestrator-grade (opus) — correctness-sensitive decode wiring; per-row vs per-include resolution matters. +- **Validation gate (run once, at end):** + - `pnpm --filter @prisma-next/sql-orm-client typecheck` + - `pnpm --filter @prisma-next/sql-orm-client test` + - `pnpm lint:deps` +- **Halt conditions:** the SQL builder must change to make a decode test pass (means D1 left a gap — surface it); an assumption is observed false (e.g. the discriminator column isn't actually present in child rows after D1); diff exceeds ~8 files. +- **Heartbeats:** `wip/heartbeats/implementer.txt` per persona cadence. **Commit hygiene:** explicit staging; tests-first; never push. diff --git a/projects/tml-2683/dispatches/03-variant-narrowing.md b/projects/tml-2683/dispatches/03-variant-narrowing.md new file mode 100644 index 0000000000..c20514805a --- /dev/null +++ b/projects/tml-2683/dispatches/03-variant-narrowing.md @@ -0,0 +1,81 @@ +# Brief: D3 — `.variant()` narrowing surface on include refinements (type + wiring) + +## Task + +Make `db.orm..include('', r => r.variant('X'))` a supported, correctly-typed +operation. After this dispatch: (a) `.variant('X')` is callable on the include-refinement +collection the `include(rel, r => …)` callback receives, for a polymorphic-target relation; +(b) it sets `nested.variantName = 'X'` on the include's nested state (so the already-wired SQL +side from D1 inner-joins only that variant and the decode side from D2 maps to it); and (c) the +**included relation's value type narrows** to variant `X`'s row type — mirroring how the parent +`Collection.variant('X')` narrows a root query's row type. + +Runtime reality to confirm first: the refinement callback already receives a real `Collection` +constructed in `includeRefinementMode` (`collection.ts` ~`:434`), and `Collection.variant()` +(`collection.ts:297`) already sets `state.variantName` + the discriminator filter; that nested +state becomes `include.nested`. So the runtime path may already work — your job is principally the +**type surface**: confirm `.variant()` is actually exposed (and correctly typed) on the refinement +collection type, and that the result row type narrows. The open question the spec flags: is +`.variant()` present on `IncludeRefinementCollection` today, or stripped in refinement mode? Resolve +it by reading the types; then make the type surface + result narrowing correct. + +## Scope + +**In:** +- The include type machinery in `collection.ts` / `collection-internal-types.ts` / `types.ts`: + `IncludeRefinementCollection`, `IncludeRefinementResult`, `IncludeRefinementValue` (and whatever + maps a refinement's returned collection to the included relation's value type). Expose `.variant()` + on the refinement collection for polymorphic targets and narrow the resulting relation value type + to the chosen variant's row union member. +- Any minimal runtime wiring needed so `r.variant('X')` on the refinement actually lands + `nested.variantName` on `include.nested` (verify D1/D2 consume it; if the refinement path already + threads it, no runtime change is needed — say so). +- Type-level tests: extend `test/polymorphism.test-d.ts` (or add a `*.test-d.ts`) to assert + `.include('')` without refinement yields the **variant union** row type, and + `.include('', r => r.variant('X'))` narrows the relation value to variant `X`. Add a + small runtime unit test that the refinement `.variant()` sets `nested.variantName` on the include. + +**Out:** +- The SQL builder (`query-plan-select.ts`, D1) and the decoder (`collection-dispatch.ts`, D2) — + both already read `nested.variantName`. Do not change their logic. If you find they *don't* + actually consume it correctly for the refinement path, that's a halt-and-surface (D1/D2 gap), not + a silent fix here. +- Integration / real-DB tests — D4. +- Mutation-path include typing. + +## Completed when + +- [ ] `.include('', r => r.variant('X'))` type-checks and the included relation value type + is variant `X`'s row type (not the base/union). +- [ ] `.include('')` (no refinement) types the relation value as the variant union. +- [ ] `r.variant('X')` on the refinement sets `nested.variantName` (runtime unit test). +- [ ] New type-level tests (`*.test-d.ts`) assert both the union and the narrowed shapes and pass + the package's type-test runner. +- [ ] Validation gate green (below) — including the type-test command. + +## Standing instruction + +Stay focused on the goal; control scope. Tests-first where it applies (type tests express the +contract — write them first, watch them fail/pass). The hardest part is type-level; if you find the +narrowing requires reshaping a widely-used type alias, surface the blast radius before committing. +Anything that pulls you into changing D1's SQL logic or D2's decode logic HALTS and surfaces. + +## References + +- Slice spec: `projects/tml-2683/spec.md` — chosen design (`.variant()` row of the design block) + Open Question #1 (is `.variant()` exposed on the refinement type today?). +- Slice plan: `projects/tml-2683/plan.md` § Dispatch 3. +- D1+D2 commits on this branch (`git log --oneline df99e8c7a..HEAD`) — the runtime that already reads `nested.variantName`. +- Parent-side precedent: `Collection.variant()` (`collection.ts:297`) — how it narrows the root row type; mirror its result-type mechanism at the include level. +- `test/polymorphism.test-d.ts` — existing type-test patterns for the poly mutation path. +- Implementer persona: `skills-contrib/drive-dispatch/agents/implementer.md`. + +## Operational metadata + +- **Model tier:** orchestrator-grade (opus) — type-level narrowing across the include result-type machinery; highest-judgment dispatch in the slice. +- **Validation gate (run once, at end):** + - `pnpm --filter @prisma-next/sql-orm-client typecheck` + - the package's type-test command for `*.test-d.ts` (discover from `package.json` / how `polymorphism.test-d.ts` is run — likely `vitest --typecheck` or a `test:types` script; confirm and report which) + - `pnpm --filter @prisma-next/sql-orm-client test` + - `pnpm lint:deps` +- **Halt conditions:** narrowing the include value type requires changing D1/D2 runtime logic; the refinement-path `nested.variantName` is NOT consumed by D1/D2 as assumed; the narrowing needs a breaking reshape of a type alias used outside the include surface; diff exceeds ~8 files. +- **Heartbeats:** `wip/heartbeats/implementer.txt`. **Commit hygiene:** explicit staging; never push; never amend without authorization. diff --git a/projects/tml-2683/dispatches/04-integration.md b/projects/tml-2683/dispatches/04-integration.md new file mode 100644 index 0000000000..6d7c0f88ad --- /dev/null +++ b/projects/tml-2683/dispatches/04-integration.md @@ -0,0 +1,78 @@ +# Brief: D4 — Integration coverage: STI + MTI target includes on a real DB + +## Task + +Add real-DB integration coverage in `test/integration/test/sql-orm-client/` for +`.include('')` where the related model is polymorphic — both an STI-target relation and +an MTI-target relation — exercised against **both** PGlite (Postgres) and SQLite, the two targets +the existing integration suite already runs. The tests must assert: (1) STI-target include returns +each child row shaped per its discriminator variant (variant-specific fields present + correct); +(2) MTI-target include returns rows with the variant tables' columns present; (3) a variant-specific +`where` on the include refinement filters correctly (this is the runtime confirmation of AC-3 — only +works because D1 joined the variant tables into the child SELECT); (4) a `.variant('X')`-narrowed +include returns only that variant's rows with the narrowed shape. This is the slice's acceptance +surface: D1–D3 are unit/type-verified; D4 confirms the whole read path end-to-end on real engines. + +There is **zero** polymorphism coverage in the integration suite today, so you will need to author a +polymorphic-relation fixture: a parent model with a 1:N relation to an STI poly model and a 1:N +relation to an MTI poly model, the real tables (base + MTI variant tables) created in the test DB, +and seed helpers. The slice spec's working position (Open Question #2) is a **standalone poly +contract + seed helpers** rather than bending the shared `getTestContract()` — confirm what fits the +suite's harness and say which you chose. + +## Scope + +**In:** +- A polymorphic fixture for the integration suite: contract + DDL (base table, STI discriminator + column, MTI variant tables joined on PK) + seed helpers, following the patterns the existing + `test/integration/test/sql-orm-client/` tests use to stand up schema and seed rows (see + `integration-helpers.ts`, `runtime-helpers.ts`, `helpers.ts`, and how `include.test.ts` / + `nested-includes.test.ts` create + seed + run across targets). +- New test file(s) under `test/integration/test/sql-orm-client/` covering the four assertions above, + parameterized over both targets exactly as the suite's existing cross-target tests are. +- Mirror the poly contract shape from the unit fixtures (`packages/3-extensions/sql-orm-client/test/helpers.ts` + `buildStiPolyContract` / `buildMixedPolyContract`) so the integration models match what the unit + layer already exercises. + +**Out:** +- Any production-code change in `packages/3-extensions/sql-orm-client/src/**`. D1–D3 delivered the + behavior; D4 is **tests + fixtures only**. If an integration test fails because of a real + production bug (not a fixture/seed mistake), that is a halt-and-surface — do NOT patch + `src/**` from this dispatch; report it so the orchestrator can reopen the relevant dispatch. +- Unit / type-level tests (D1–D3 own those). +- The `.variant()` type surface (D3). + +## Completed when + +- [ ] STI-target `.include()` integration test passes on PGlite + SQLite, asserting per-variant row shape. +- [ ] MTI-target `.include()` integration test passes on PGlite + SQLite, asserting variant columns present. +- [ ] Variant-specific `where` on a poly include refinement is exercised and filters correctly (AC-3). +- [ ] `.variant('X')`-narrowed poly include is exercised and returns only that variant (AC-4 at integration). +- [ ] The new fixture (contract + DDL + seeds) is committed and self-contained (no external DB). +- [ ] Validation gate green (below). + +## Standing instruction + +Stay focused on the goal; control scope. Tests are the deliverable here. If you hit a real +production defect, HALT and surface it with the failing assertion + evidence — do not fix `src/**`. +Trivial-and-related fixture/helper additions that serve the goal are fine in the same dispatch. + +## References + +- Slice spec: `projects/tml-2683/spec.md` — slice-DoD (the integration condition), Open Question #2 (standalone contract vs extend shared). +- Slice plan: `projects/tml-2683/plan.md` § Dispatch 4 (note the sanctioned resize: if the shared contract can't be cleanly extended, authoring the poly fixture can be its own sub-step). +- D1–D3 commits on this branch (`git log --oneline df99e8c7a..HEAD`) — the behavior you're confirming. +- Existing integration patterns: `test/integration/test/sql-orm-client/include.test.ts`, `nested-includes.test.ts`, `integration-helpers.ts`, `runtime-helpers.ts`, `helpers.ts`. +- Unit poly fixtures to mirror: `packages/3-extensions/sql-orm-client/test/helpers.ts` (`buildStiPolyContract`, `buildMixedPolyContract`); unit poly include behavior: `query-plan-select.test.ts`, `collection-dispatch.test.ts`, `collection-variant.test.ts`. +- Implementer persona: `skills-contrib/drive-dispatch/agents/implementer.md`. + +## Operational metadata + +- **Model tier:** orchestrator-grade (opus) — real-DB fixture authoring + cross-target wiring; the suite's harness is non-obvious. +- **Validation gate (run once, at end):** + - the new integration test file(s) on **both** targets (discover the suite's target-parameterization + the right `pnpm --filter test ` or `pnpm test:integration` invocation; report the exact commands) + - `pnpm --filter typecheck` (discover the package name) + - a focused run confirming you did not break sibling integration tests in `sql-orm-client/` + - `pnpm lint:deps` +- **Halt conditions:** an integration test fails due to a real `src/**` defect (surface, do not patch src); the suite can't run both targets without infra you don't have; a new external dependency would be required; diff strays into `packages/**/src`. +- **Heartbeats:** `wip/heartbeats/implementer.txt` per persona cadence (integration runs are long — foreground them and ping before/after). **Commit hygiene:** explicit staging; never push. diff --git a/projects/tml-2683/dispatches/05-mti-variant-where.md b/projects/tml-2683/dispatches/05-mti-variant-where.md new file mode 100644 index 0000000000..f42f6a8043 --- /dev/null +++ b/projects/tml-2683/dispatches/05-mti-variant-where.md @@ -0,0 +1,81 @@ +# Brief: D5 — MTI variant-field `where`: variant-aware predicate accessor + +## Task + +Make a variant-specific `where` referencing an **MTI** variant column work on a polymorphic include +refinement — both at the type level and at runtime. Today +`db.orm.Project.include('tasks', t => t.variant('Feature').where(x => x.priority.gte(3)))` throws +`TypeError: Cannot read properties of undefined (reading 'gte')` because the predicate accessor +(`createModelAccessor(context, modelName)`, `model-accessor.ts:40`) resolves fields against the +**base** table only (`resolveColumn(contract, baseTable, …)`), and an MTI variant column (`priority`) +lives on the joined variant table (`features`), not the base table. STI variant columns live on the +base table, so the STI case already works (and is integration-tested in D4). D1 already inner-joins +the selected variant's table into the child SELECT, so the variant table IS in scope in the emitted +SQL — the gap is purely that the predicate builder can't *name* the variant column. + +Make the accessor variant-aware: when the collection state has a selected `variantName` and a field +belongs to that variant (not the base), resolve it to a `ColumnRef` qualified against the **variant +table** (e.g. `features.priority`), so the emitted `where` references the joined variant table. Reuse +the merged field→column knowledge that already exists — `getMergedColumnToFieldMap` in +`collection-runtime.ts` (and the mutation-path merged map around `collection.ts:1274`) — adapting it +to yield `{ table, column }` for the predicate builder rather than a flat field→column map. + +## Scope + +**In:** +- `model-accessor.ts` (and the minimal `where`-binding plumbing it feeds) — make field resolution + variant-aware when `state.variantName` is set: variant-owned fields resolve to a `ColumnRef` on the + variant table; base fields keep resolving to the base table. Thread the selected variant + its + `PolymorphismInfo` to the accessor (it currently gets only `modelName`). +- The **type** side: inside `t.variant('X').where(predicate)`, the predicate accessor must expose + variant `X`'s fields (so `x.priority` type-checks for a Feature variant). Mirror how `.variant()` + narrows elsewhere; if the predicate-accessor row type is independent of D3's result-type narrowing, + narrow it here too. +- Tests-first: unit coverage that the predicate for a selected MTI variant resolves the variant + column to a variant-table `ColumnRef` (and base columns stay base-qualified); a type-level test that + `t.variant('Feature').where(x => x.priority…)` type-checks and `x` carries the variant's fields. +- Extend the D4 integration test (`test/integration/test/sql-orm-client/polymorphism-include.test.ts`) + with the **MTI** variant-`where` case (`.variant('Feature').where(x => x.priority.gte(N))`) on + PGlite, asserting it filters correctly — the runtime confirmation that closes AC-3. + +**Out:** +- The SQL **join** emission (D1) — the variant table is already joined; do not change `query-plan-select.ts`'s join logic. You may need to confirm the `where` clause lands inside the child correlated SELECT where the join is in scope; if it doesn't, that's a halt-and-surface (a D1 gap), not a join-logic change here. +- The decode path (D2), the `.variant()` result-type narrowing (D3 — already done), the integration fixture/seed shape (D4 — reuse it). +- Non-variant `where` behavior must be byte-for-byte unchanged (no regression to the overwhelmingly common base-table predicate path). This is the top risk — guard it. +- SQLite (deferred — design-notes D4). + +## Completed when + +- [ ] `t.variant('X').where(x => …)` type-checks (predicate accessor exposes variant `X`'s fields) and resolves the variant column to a variant-table `ColumnRef` at runtime. +- [ ] Base-table predicates and non-variant queries are unchanged (regression guard — unit test or explicit reasoning). +- [ ] New unit + type tests (written first) pass; the D4 integration test gains a passing MTI variant-`where` case on PGlite. +- [ ] Validation gate green (below). + +## Standing instruction + +Stay focused on the goal; control scope. Tests-first. The top risk is regressing the base predicate +path — keep variant-awareness strictly gated on `state.variantName` being set. If the fix requires +reshaping `where`-binding internals used broadly across the ORM (beyond the variant gate), surface the +blast radius before committing. If the `where` clause turns out NOT to be emitted inside the child +correlated SELECT (so the variant table isn't in scope for it), HALT and surface — that's a D1 gap. + +## References + +- Slice spec: `projects/tml-2683/spec.md` — the amended variant-`where` scope bullet + slice-DoD. +- Design notes: `projects/tml-2683/design-notes.md` § D3 (the discovery + mechanism + the mutation-path merged-map precedent). +- Slice plan: `projects/tml-2683/plan.md` § Dispatch 5. +- The throwing path: `collection.ts` `.variant()` (`:296`), `model-accessor.ts` (`createModelAccessor`, `resolveColumn` `:88`, `:114`), `collection-runtime.ts` (`getMergedColumnToFieldMap`), `collection-contract.ts` (`resolvePolymorphismInfo`). +- D4 integration test to extend: `test/integration/test/sql-orm-client/polymorphism-include.test.ts` (the STI variant-`where` case is the pattern to mirror for MTI). +- D1 join emission (read-only context): `query-plan-select.ts` `buildMtiJoins` / `buildChildPolymorphismArtifacts` + `buildStateWhere`. +- Implementer persona: `skills-contrib/drive-dispatch/agents/implementer.md`. + +## Operational metadata + +- **Model tier:** orchestrator-grade (opus) — src change across accessor + where-binding + types, with a sharp regression-risk on the base predicate path. +- **Validation gate (run once, at end):** + - `pnpm --filter @prisma-next/sql-orm-client typecheck` + - `pnpm --filter @prisma-next/sql-orm-client test` (incl. the type tests) + - the extended integration test on PGlite (rebuild `@prisma-next/sql-orm-client` first — the integration package imports `dist`; or run via the suite's build-aware path); report the exact command + - `pnpm lint:deps` +- **Halt conditions:** the `where` isn't emitted inside the child SELECT (D1 gap); the variant-aware change can't be gated cleanly and risks the base predicate path; the predicate-accessor type narrowing requires a broad reshape; diff exceeds ~8 files. +- **Heartbeats:** `wip/heartbeats/implementer.txt`. **Commit hygiene:** explicit staging; never push; never amend without authorization. diff --git a/projects/tml-2683/dispatches/06-polymorphism-test-hardening.md b/projects/tml-2683/dispatches/06-polymorphism-test-hardening.md new file mode 100644 index 0000000000..e2552526ed --- /dev/null +++ b/projects/tml-2683/dispatches/06-polymorphism-test-hardening.md @@ -0,0 +1,69 @@ +# Brief: D6 — harden polymorphism.test.ts (whole-shape, implicit-default, de-raw) + +## Task + +Bring `test/integration/test/sql-orm-client/polymorphism.test.ts` (base / variant queries + variant +create) up to the `.agents/rules/sql-orm-client-whole-shape-assertions.mdc` standard, add explicit +top-level **implicit-default-selection** coverage for STI and MTI, and replace gratuitous raw SQL +reads with the ORM API. + +Three threads: + +1. **Whole-shape assertions.** Replace every `toMatchObject` / `toHaveProperty` / `not.toHaveProperty` + / lone-`toBe` result assertion with a single whole-result `toEqual`. Order deterministically by a + **base** column (`id`). +2. **Implicit-default-selection tests (STI + MTI), top level.** Add/repurpose tests that issue a + query with **no `.select(...)`** and assert the *full default projected shape* with `toEqual` — + for an STI variant row, for an MTI variant row, and for a base/all query returning the variant + union. This is the deliberate "no-select → full default shape" exception the rule documents; name + the tests for that property. (The existing "base query returns all variants" / "variant(Bug)" / + "variant(Feature)" tests are the natural homes — make them assert the whole default shape.) +3. **De-raw.** Replace raw `runtime.query('select …')` **read-backs** that merely re-read data the + ORM can return (e.g. `select type from tasks where id = …` after a create) with an ORM read + + whole-shape `toEqual`. **KEEP** raw SQL that asserts a *storage-level invariant the ORM + intentionally hides* — specifically the MTI-create test's check that **both** the base `tasks` + row and the `features` variant row were written (the two-table transactional write). That storage + assertion is legitimate; leave it raw and add a one-line comment saying why. Keep raw DDL + (`create table`) and raw seed `insert`s (the patched-contract cast pattern makes ORM seeding + awkward) — those are not "for no reason". + +## Scope + +**In:** `test/integration/test/sql-orm-client/polymorphism.test.ts` only (plus its local helpers if +needed). Test-only. + +**Out:** `packages/**/src` (no production change). `polymorphism-include.test.ts` (that's D7). Do NOT +attempt to make explicit `.select(...)` restrict variant columns — that's the **TML-2783** bug; +don't write a test asserting the post-fix select behavior. If you use `.select(...)` anywhere on a +poly query and the variant column leaks in, that's TML-2783 — either assert the actual (leaky) shape +with a `// TML-2783` comment, or prefer the implicit-default shape instead. + +## Completed when + +- [ ] No `toMatchObject` / `toHaveProperty` / `not.toHaveProperty` remain as primary result assertions; results asserted with whole-shape `toEqual`, deterministically ordered by `id`. +- [ ] STI and MTI implicit-default-selection tests exist (no `.select`), asserting the full default shape. +- [ ] Gratuitous raw read-backs replaced with ORM reads; the MTI two-table storage assertion kept (with a why-comment); DDL + seeds left raw. +- [ ] Validation gate green. + +## Standing instruction + +Stay focused; test-only. Reference `TML-2783` where the variant-column-select-leak is relevant rather +than working around it silently. If a raw query turns out to assert something the ORM genuinely can't +express, keep it and say so. + +## References + +- Rule: `.agents/rules/sql-orm-client-whole-shape-assertions.mdc` (incl. the implicit-default exception). +- TML-2783 (explicit select doesn't restrict poly variant columns) — don't assert its post-fix behavior. +- Sibling pattern already refactored: `polymorphism-include.test.ts` (whole-shape + select + base-`id` orderBy). +- Implementer persona: `skills-contrib/drive-dispatch/agents/implementer.md`. + +## Operational metadata + +- **Model tier:** opus. +- **Validation gate (run once):** + - `pnpm --filter @prisma-next/integration-tests exec vitest run test/sql-orm-client/polymorphism.test.ts` + - `pnpm --filter @prisma-next/integration-tests typecheck` + - `pnpm --filter @prisma-next/integration-tests exec biome check test/sql-orm-client/polymorphism.test.ts` +- **Halt conditions:** a test can only pass by asserting TML-2783's buggy select behavior as if correct (surface instead); production change needed; diff strays into `src`. +- **Commit hygiene:** explicit staging; never push. diff --git a/projects/tml-2683/dispatches/07-mti-relationship-coverage.md b/projects/tml-2683/dispatches/07-mti-relationship-coverage.md new file mode 100644 index 0000000000..de7fa9ff94 --- /dev/null +++ b/projects/tml-2683/dispatches/07-mti-relationship-coverage.md @@ -0,0 +1,76 @@ +# Brief: D7 — MTI+relationship coverage gaps + relationship implicit-default + +## Task + +Close the MTI+relationship integration-coverage gaps identified during D4/D5, and add relationship-level +**implicit-default-selection** coverage. All in the integration suite +(`test/integration/test/sql-orm-client/`), whole-shape `toEqual`, base-column ordering, per the +`.agents/rules/sql-orm-client-whole-shape-assertions.mdc` standard. + +New scenarios to cover (add the minimal local fixtures each needs — keep fixtures **local** to the +test file as `build*IncludeContract` helpers, per the standalone-fixture pattern; do NOT widen the +shared `helpers.ts` builders): + +1. **MTI model as the include PARENT** — a polymorphic (MTI) model that is the *root* of an include + (it has a relation to some child). Confirms parent-side correlation works when the parent itself + spans base + variant tables. +2. **To-one / N:1 include whose TARGET is a poly model** — current coverage is all 1:N. Confirms + per-row variant mapping on a single included object (not an array). +3. **A base with 2+ MTI variant tables** — current `Task` has one MTI variant (`Feature`). Add a + second MTI variant and confirm only the matching variant table's columns appear per row (no + cross-variant column contamination). +4. **Nested include through a poly target** — `Parent → tasks (poly) → grandchild`. Confirms a + relation hanging off the poly child stitches correctly when the child row is variant-mapped. + +Plus: + +5. **Relationship-level implicit-default tests (STI + MTI).** `.include('')` with **no + `.select(...)`** asserting the *full default variant shape* per child row (admin rows carry `role`, + regular carry `plan`; bug rows carry `severity`, feature rows carry `priority`). The deliberate + no-select → full-default-shape exception in the rule. + +6. **TML-2783 cross-reference.** Add a `// TML-2783` comment to the existing select-based MTI + assertion in `polymorphism-include.test.ts` (where `priority` surfaces despite a base-only + `.select(...)`), so the known leak is traceable. + +## Scope + +**In:** `test/integration/test/sql-orm-client/polymorphism-include.test.ts` (extend) and/or a new +sibling test file + local fixtures. Test-only. + +**Out:** `packages/**/src` (no production change). Do not assert TML-2783's post-fix select behavior; +prefer implicit-default shapes for poly result assertions, or assert the actual current shape with a +`// TML-2783` note. Don't widen `helpers.ts` shared builders (breaks sibling DDL). + +## Completed when + +- [ ] Tests for scenarios 1–4 exist and pass on PGlite, each asserting the whole result with `toEqual`, ordered by base `id`. +- [ ] Relationship-level STI + MTI implicit-default tests exist (no `.select`), asserting full default per-variant shape. +- [ ] The existing poly-include select-leak assertion carries a `// TML-2783` reference. +- [ ] Validation gate green. + +## Standing instruction + +Stay focused; test-only. If any new scenario surfaces a real production defect (not a fixture/seed +mistake), HALT and surface it with evidence + the likely owning area — do not patch `src`. If a +scenario needs a fixture shape the cast-interface pattern can't express cleanly, surface the friction +rather than contorting the test. + +## References + +- D4/D5 gap list: `projects/tml-2683/reviews/code-review.md` round notes + the D4 implementer's coverage assessment. +- Existing patterns: `polymorphism-include.test.ts` (local `build*IncludeContract` fixtures, DDL, seeds, cast interfaces, base-`id` orderBy, whole-shape `toEqual`). +- Rule: `.agents/rules/sql-orm-client-whole-shape-assertions.mdc`. +- TML-2783 (select-vs-variant-columns) and TML-2782 (orderBy-vs-MTI-variant) — don't trip these; order by base columns only. +- Implementer persona: `skills-contrib/drive-dispatch/agents/implementer.md`. + +## Operational metadata + +- **Model tier:** opus. +- **Validation gate (run once — report exact commands):** + - the new/extended test file(s) on PGlite via `pnpm --filter @prisma-next/integration-tests exec vitest run ` + - `pnpm --filter @prisma-next/integration-tests typecheck` + - `pnpm --filter @prisma-next/integration-tests exec biome check ` + - confirm sibling `polymorphism*.test.ts` still pass +- **Halt conditions:** a scenario reveals a real `src` defect (surface, don't patch); a fixture can't be expressed via the cast pattern; diff strays into `src`. +- **Commit hygiene:** explicit staging; never push. diff --git a/projects/tml-2683/dispatches/08-nested-poly-include-decode.md b/projects/tml-2683/dispatches/08-nested-poly-include-decode.md new file mode 100644 index 0000000000..7804d5b632 --- /dev/null +++ b/projects/tml-2683/dispatches/08-nested-poly-include-decode.md @@ -0,0 +1,69 @@ +# Brief: D8 — fix nested include through a polymorphic target (depth-2 decode) + +## Task + +Fix the depth-2 silent-degradation bug D7 surfaced: a nested `.include(...)` hanging off a +**polymorphic** include target decodes to `null` for every row. In +`decodeIncludePayload` (`packages/3-extensions/sql-orm-client/src/collection-dispatch.ts`), the poly +branch maps the child row via `mapPolymorphicRow` first, then reads each nested-include payload from +the **mapped** row (`mapped[nestedInclude.relationName]`). But `mapPolymorphicRow` +(`collection-runtime.ts:96-128`) keeps only columns present in the variant model-field map, so the +nested payload column (a relation alias, not a model field) is dropped → the grandchild decodes to +`null`. The non-poly mapper preserves unknown columns via fallback, so the non-poly nested path works. + +**Preferred fix:** read each nested-include payload from the **raw** child row +(`childRow[nestedInclude.relationName]`), decode it, and assign the decoded value onto the mapped row +(`mapped[nestedInclude.relationName]`). This works for both poly and non-poly (the raw row always +carries the payload under its relation alias) and leaves `mapPolymorphicRow`'s variant-shaping +untouched. Do NOT make `mapPolymorphicRow` keep all unknown columns — that would resurrect the +sibling-variant NULL columns it is supposed to drop (re-breaking D2's per-variant shaping). + +## Scope + +**In:** +- `collection-dispatch.ts` `decodeIncludePayload` — source the nested payload from the raw child row. +- Tests-first: a unit test in `test/collection-dispatch.test.ts` for a poly child with a nested + include — assert the grandchild decodes to its real value (not `null`) AND the parent poly row is + still variant-shaped (sibling-variant columns still dropped). Cover both a variant with an MTI table + and a variant without (the bug hit both). +- Unskip the D7 scenario-4 integration test (`it.skip` → `it`) in + `test/integration/test/sql-orm-client/polymorphism-include-relationships.test.ts` and confirm it + passes on PGlite with the correct stitched shape. + +**Out:** +- `mapPolymorphicRow`'s variant-shaping logic (don't change how it drops sibling-variant columns). +- The SQL builder (`query-plan-select.ts`) — the nested correlated subquery is already emitted; this + is decode-only. +- Anything beyond nested-include payload preservation. + +## Completed when + +- [ ] A nested include through a poly target decodes the grandchild to its real value (unit + the unskipped D7 integration test). +- [ ] Per-variant shaping is unchanged: sibling-variant columns are still dropped (regression guard, asserted). +- [ ] The previously-skipped D7 scenario-4 test is unskipped and passes on PGlite. +- [ ] Validation gate green. + +## Standing instruction + +Stay focused; minimal decode-only fix. Tests-first. The top risk is regressing per-variant shaping — +assert it explicitly. If the raw-row payload isn't actually available where expected, surface it +(would indicate a SQL-side gap) rather than reaching into `mapPolymorphicRow`. + +## References + +- Design notes: `projects/tml-2683/design-notes.md` § D5b (root cause + the chosen fix + the rejected alternative). +- D7 report + the `it.skip` scenario: `test/integration/test/sql-orm-client/polymorphism-include-relationships.test.ts`. +- D2 decode (what you're extending): `decodeIncludePayload` in `collection-dispatch.ts`; `mapPolymorphicRow` / `mapStorageRowToModelFields` in `collection-runtime.ts`. +- Rule for the integration test style: `.agents/rules/sql-orm-client-whole-shape-assertions.mdc`. +- Implementer persona: `skills-contrib/drive-dispatch/agents/implementer.md`. + +## Operational metadata + +- **Model tier:** opus. +- **Validation gate (run once — rebuild sql-orm-client before the integration run; the integration package imports `dist`):** + - `pnpm --filter @prisma-next/sql-orm-client typecheck` + - `pnpm --filter @prisma-next/sql-orm-client test` + - `pnpm --filter @prisma-next/sql-orm-client build` then `pnpm --filter @prisma-next/integration-tests exec vitest run test/sql-orm-client/polymorphism-include-relationships.test.ts test/sql-orm-client/polymorphism-include.test.ts` + - `pnpm lint:deps` +- **Halt conditions:** the fix requires changing `mapPolymorphicRow`'s shaping (means the raw-row approach didn't work — surface); per-variant shaping can't be preserved; the raw nested payload isn't present (SQL-side gap); diff exceeds ~6 files. +- **Commit hygiene:** explicit staging; tests-first; never push; no bare casts (use `blindCast`/`castAs` if needed). diff --git a/projects/tml-2683/learnings.md b/projects/tml-2683/learnings.md new file mode 100644 index 0000000000..eb441c0efb --- /dev/null +++ b/projects/tml-2683/learnings.md @@ -0,0 +1,19 @@ +# Learnings — tml-2683 + +> Orchestrator-maintained working ledger of patterns surfaced during this run. Cross-cutting +> lessons migrate to durable docs at close-out; project-local lessons drop with the folder. + +- **Integration package imports built `dist`, not `src`.** A code fix in `@prisma-next/sql-orm-client/src` + is invisible to `test/integration/**` until the package is rebuilt. `pnpm test:integration`'s + `pretest` runs `pnpm -w build` (safe), but a bare `vitest` filter run exercises stale behavior. + Surfaced in D4 — the variant join was silently absent until `pnpm --filter @prisma-next/sql-orm-client build`. +- **"Runs on both targets" is suite-specific, not repo-wide.** `test/integration/test/sql-orm-client/` + is Postgres/PGlite-only; SQLite ORM coverage lives in a separate e2e package whose contract-builder + lacks polymorphism support. The spec/plan assumed both — grounded only at the D4 integration dispatch. + Lesson for spec-time: verify the *specific* suite's target matrix, don't infer from CLAUDE.md's + general "PGlite + mongodb-memory-server" framing. +- **A SQL join being emitted does not make its columns referenceable in the predicate builder.** D1 + joined the MTI variant table into the child SELECT, but the `where` accessor (`model-accessor.ts`) + still resolved fields against the base table only — so MTI variant-field `where` threw. The + predicate-accessor layer is independent of the join-emission layer; a "falls out of the joins" + assumption (true for STI base-table columns) silently failed for MTI. Routed to D5. diff --git a/projects/tml-2683/plan.md b/projects/tml-2683/plan.md new file mode 100644 index 0000000000..bd4036a76c --- /dev/null +++ b/projects/tml-2683/plan.md @@ -0,0 +1,147 @@ +# Dispatch plan — TML-2683 + +Four dispatches, strictly sequential. Each is tests-first per the repo rule ("always write +tests before creating or modifying implementation"). The SQL→decode order is mandated by the +ticket (data must be present before it can be mapped); the `.variant()` surface rides on top; +integration coverage confirms the whole. + +> **Baseline:** both predecessors are assumed landed — TML-2657 (multi-query include path +> removed) and TML-2729 (LATERAL dropped; correlated-only read path). The plan targets that +> tree: the **single correlated include builder** (`buildCorrelatedIncludeProjection`, sharing +> `buildIncludeChildRowsSelect` with no `strategy` param); no multi-query stitcher and no +> lateral builder to extend. The original #619 blocker is resolved. + +### Dispatch 1: SQL side — emit polymorphism joins + projection in the child SELECT + +- **Outcome:** for a `.include()` whose target model is polymorphic, the correlated child + SELECT (via `buildIncludeChildRowsSelect` **and** the `buildDistinctNonLeafChildRowsSelect` + branch) resolves `resolvePolymorphismInfo(include.relatedModelName)` and, when MTI variants + exist, emits `buildMtiJoins(...)` joins + `variant_table__column` projection inside the + correlated subquery, choosing inner-vs-left join from `include.nested.variantName`. Because + 2729 dropped the child SELECT's `.withJoins(nestedJoins)`, this dispatch re-introduces + `.withJoins(...)` as the (now sole) join source. The discriminator column and STI + variant-specific (base-table) columns are projected. A variant-specific `where` on the + refinement evaluates (the variant tables are now in scope). +- **Builds on:** the spec's chosen design; the post-2657/post-2729 correlated-only tree. +- **Hands to:** child storage rows for poly-target includes carry the discriminator + all + variant columns; `include.nested.variantName` is honored in emitted SQL. (Rows are + correctly *fetched* but still base-*mapped* until D2 — observable via query-plan unit tests, + not yet via decoded row shape.) +- **Focus:** `query-plan-select.ts` — `buildIncludeChildRowsSelect`, + `buildDistinctNonLeafChildRowsSelect`, reached via `buildCorrelatedIncludeProjection`. + Tests-first: `test/query-plan-select.test.ts` — assert the correlated child SELECT's + joins/projection for STI-target and MTI-target includes, variant-narrowed inner join, + self-relation alias remap. Use `buildStiPolyContract` / `buildMixedPolyContract` + (`test/helpers.ts`), extending with a parent→poly relation if absent. **Not** the decoder, + **not** the `.variant()` type surface. (Note: 2729 collapsed `nested-includes-strategy.test.ts` + to a single correlated path — write new poly assertions against that single path, not a + lateral/correlated matrix.) + +### Dispatch 2: Decode side — map poly child rows to their variant + +- **Outcome:** `decodeIncludePayload` resolves `PolymorphismInfo` once per include for + `include.relatedModelName` and, when polymorphic, maps each child row via + `mapPolymorphicRow(contract, relatedModelName, polyInfo, childRow, include.nested.variantName)` + instead of `mapStorageRowToModelFields`. Nested-include recursion, scalar, and combine + branches are unchanged. Included poly rows come back shaped as the variant each row is. +- **Builds on:** Dispatch 1's hand-off — child rows carry the discriminator + variant columns. +- **Hands to:** runtime-correct variant row shapes for poly-target includes (unit-level); + STI variant fields present and per-row-correct, MTI variant columns present. +- **Focus:** `collection-dispatch.ts` (`decodeIncludePayload:504`). Tests-first: + `test/collection-dispatch.test.ts` — STI child rows decode to the right variant by + discriminator; MTI child rows surface variant columns; variant-narrowed include maps to the + named variant. **Not** the SQL builder, **not** the public `.variant()` API surface (unit + tests set `nested.variantName` on state directly). + +### Dispatch 3: `.variant()` narrowing surface on include refinements (type + wiring) + +- **Outcome:** `db.orm..include('', r => r.variant('X'))` type-checks, sets + `nested.variantName = 'X'`, and the included relation's value type narrows to variant `X`'s + row type (mirroring parent `Collection.variant()`). At runtime the discriminator filter + + inner-join-named-variant already fire (D1/D2 read the slot); this dispatch makes the + operator reachable and correctly typed on the refinement collection. +- **Builds on:** D1 + D2 (SQL + decode already honor `nested.variantName`). +- **Hands to:** typed, runtime-correct variant narrowing on includes — the full + acceptance-criteria API surface exists. +- **Focus:** `collection.ts` include-refinement collection type (`IncludeRefinementCollection`, + refinement creation `:434`) + the result-type mapper (`IncludeRefinementValue`). Tests-first: + type-level `test-d` (result row type = variant union; `.variant('X')` narrows) + + a unit test that the refinement `.variant()` sets `nested.variantName`. **Not** new SQL or + decode logic — those already consume the slot. + +### Dispatch 4: Integration coverage — STI + MTI target includes on a real DB + +- **Outcome:** integration tests in `test/integration/test/sql-orm-client/` exercise + `.include('')` for an STI-target and an MTI-target relation against **PGlite + (Postgres)** — the only target this suite runs (see design-notes D4) — asserting + variant-correct row shapes, an **STI** variant-specific `where` on the refinement, and a + `.variant()`-narrowed include. A polymorphic-relation fixture (parent model + STI poly target + + MTI poly target) + seed helpers are added (none exists today). +- **Builds on:** D1 + D2 + D3 (the full read path + narrowing API). +- **Hands to:** PGlite integration coverage; the silent-degradation regression is locked by + acceptance-level tests. Surfaced the MTI variant-`where` gap → D5. +- **Focus:** integration fixture/contract + seeds + tests. **Not** production-code changes — a + surfaced gap halts and re-opens the relevant dispatch (which is exactly what happened: the MTI + variant-`where` gap routed to D5). Standalone poly contract per the sanctioned resize. +- **Status:** delivered 5 PGlite tests (commit `34becbd8a`); MTI variant-`where` case to be + added in D5 once the accessor fix lands. + +### Dispatch 5: MTI variant-field `where` — variant-aware predicate accessor + +- **Outcome:** a variant-specific `where` referencing an **MTI** variant column on a poly include + refinement (e.g. `.include('tasks', t => t.variant('Feature').where(x => x.priority.gte(3)))`) + type-checks and evaluates correctly at runtime — the predicate accessor resolves the variant's + columns (merged from the selected variant) and qualifies their `ColumnRef` against the variant + table D1 already joins into the child SELECT. The D4 integration test is extended with the MTI + variant-`where` case (PGlite), and unit coverage pins the accessor/where-binding behavior. +- **Builds on:** D1 (variant tables joined into the child SELECT), D3 (`.variant()` surface), D4 + (the integration fixture + the STI `where` case to mirror). +- **Hands to:** AC-3 fully discharged for both STI and MTI; slice-DoD met. +- **Focus:** `model-accessor.ts` (make `createModelAccessor` variant-aware — merge variant + columns, qualify `ColumnRef` to the variant table) + the `where`-binding path; reuse the merged + field→column map pattern from the mutation path (`collection.ts` ~`:1274`). Tests-first: unit + (predicate resolves an MTI variant column to the variant-table `ColumnRef`) + extend the D4 + integration test. **Not** the SQL join emission (D1 already joins the table) or the decode path. + +## Handoff linearity + +D1→D2→D3→D4→D5 is linear. Completeness: D1+D2 discharge "rows match their variant"; D3 discharges +the `.variant()` narrowing API + type-shape; D4 discharges PGlite integration coverage + STI +variant-`where`; D5 discharges MTI variant-`where` (the AC-3 remainder). D4 surfaced the D5 gap — +a non-linear discovery, recorded in design-notes D3. + +## Manual QA + +**N/A** — the change is library-internal read-path behavior with no CLI/UI surface; the +acceptance surface is the D4/D5 integration tests (real DB, PGlite). Recorded here as an +explicit N/A per the slice-DoD requirement. + +## Whole-slice DoD + +- `pnpm test:packages typecheck lint:deps` green at the slice tip. +- `pnpm test:integration` green on Postgres (PGlite). *(SQLite poly-include coverage deferred — + design-notes D4.)* + +## Post-close test-coverage dispatches (added after slice DoD, same PR) + +- **D6 — harden `polymorphism.test.ts`:** whole-shape `toEqual`, top-level STI+MTI implicit-default + tests, de-raw gratuitous read-backs (keep the MTI two-table storage assertion). Committed. +- **D7 — MTI+relationship coverage gaps:** MTI-as-parent, to-one/N:1 poly target, 2+ MTI variant + tables, nested-through-poly, relationship implicit-default. Committed. **Surfaced a depth-2 defect → + D8** (scenario-4 landed as `it.skip` asserting the correct shape). +- **D8 — fix nested include through a poly target (depth-2 decode):** the poly row-mapper dropped the + nested-include payload column → grandchild decoded to `null`. Read the nested payload from the raw + child row before poly-mapping; unskip the D7 scenario-4 test. See design-notes § D5b. + +## Open items (follow-ups, not in this slice) + +- **`orderBy` on a variant-narrowed collection has the same base-table-only gap for MTI variant + columns** as the `where` path D5 fixed (`collection.ts` orderBy passes no variant to + `createModelAccessor`). Surfaced + scope-controlled out of D5. The threading mechanism (optional + `variantName` on `createModelAccessor`) is now in place, so the fix is small. → filed as **TML-2782**. +- **SQLite poly-include integration coverage** — deferred (design-notes D4), gated on the sqlite + contract-builder gaining polymorphism support. Subsumed by the separately-tracked multi-target + test-runtime project (a larger effort); not filed here. Some SQLite breakage is acceptable through + mid-July while Postgres GA is the focus. +- Type-level tests assert the variant-union result shape and `.variant()` narrowing. +- PR description is the slice spec (`projects/tml-2683/spec.md`) + Linear back-link. diff --git a/projects/tml-2683/spec.md b/projects/tml-2683/spec.md new file mode 100644 index 0000000000..40d68d854a --- /dev/null +++ b/projects/tml-2683/spec.md @@ -0,0 +1,197 @@ +# Slice: wire polymorphism into the `.include()` child path + +_Standalone ticket-scoped slice (no parent Drive project). Linear project: Pothos +Integration. One PR, one reviewer sitting._ + +## At a glance + +`db.orm..include('')` where ``'s target model is polymorphic (STI or +MTI) currently returns wrong-shaped rows (STI: variant fields dropped/confused) or +missing-field rows (MTI: variant columns never fetched), silently. This slice wires the +parent-side polymorphism machinery — already correct — into the **child** include path: +the single-query SQL builders emit the variant joins/projection, the decoder maps each +child row to its real variant, and `r => r.variant('X')` narrows a polymorphic include the +same way `Collection.variant()` narrows a root query. + +## Chosen design + +The parent path is the template; the child path copies it. Two reads/writes of the +**existing** `IncludeExpr.nested.variantName` slot (`types.ts:60`, `:75`) tie it together. + +> **Baseline: post-2729, correlated-only.** This slice plans against the tree after +> [TML-2729](https://linear.app/prisma-company/issue/TML-2729) — LATERAL is removed from the +> include read path and all SQL targets emit correlated subqueries. `buildLateralIncludeArtifacts`, +> `include-strategy.ts` / `selectIncludeStrategy`, and the `strategy: 'lateral' | 'correlated'` +> param are gone. `compileSelectWithIncludeStrategy` is renamed `compileSelectWithIncludes`. +> The single remaining include builder is the correlated projection builder, sharing +> `buildIncludeChildRowsSelect`. **Function names below are post-2729; line numbers are +> omitted where 2729 shifts them.** + +### Baseline (parent correct, child absent) + +``` +compileSelectWithIncludes (query-plan-select.ts) // renamed by 2729, no strategy param +├─ PARENT: resolvePolymorphismInfo(modelName) ─┐ ✅ wired (outer SELECT joins; untouched by 2729) +│ buildMtiJoins(...) → joins+projection │ +└─ for each include: + buildCorrelatedIncludeProjection // the only include builder post-2729 + └─ buildIncludeChildRowsSelect // no strategy param; nested joins removed by 2729 + SELECT FROM ❌ no resolvePolymorphismInfo + WHERE [AND ] ❌ no buildMtiJoins + ❌ no variant/discriminator projection + // 2729 also dropped `.withJoins(nestedJoins)` here — the child SELECT now has + // NO join source at all (nested includes are projection-only correlated subqueries) + +decodeIncludePayload (collection-dispatch.ts:504) +└─ mapStorageRowToModelFields(contract, include.relatedModelName, childRow) ❌ base mapper + (relatedModelName = relation.to = BASE model) +``` + +### After + +``` +buildIncludeChildRowsSelect (and buildDistinctNonLeafChildRowsSelect): + polyInfo = resolvePolymorphismInfo(contract, include.relatedModelName) + if polyInfo?.mtiVariants.length: + { joins, projection } = buildMtiJoins(contract, polyInfo, include.nested.variantName) + → re-introduce `.withJoins(joins)` on the child correlated SELECT (it joins the + variant tables to the base on PK — valid inside a correlated subquery's FROM; this + is the child SELECT's only join source post-2729); + add projection (variant_table__column) to childProjection + ensure the discriminator column + STI variant columns are projected from the base table + (so the decoder can resolve variant per row even with no MTI join) + +decodeIncludePayload: + polyInfo = resolvePolymorphismInfo(contract, include.relatedModelName) // once per include + row = polyInfo + ? mapPolymorphicRow(contract, include.relatedModelName, polyInfo, childRow, include.nested.variantName) + : mapStorageRowToModelFields(contract, include.relatedModelName, childRow) + +include refinement: + r.variant('X') → sets nested.variantName = 'X' (Collection.variant already does this; + expose it on the IncludeRefinementCollection type + narrow the + included relation's result type to the 'X' variant row union member) +``` + +`buildMtiJoins` already emits `variant_table__column`-aliased projections and chooses `inner` +vs `left` join by `variantName`; `mapPolymorphicRow` (`collection-runtime.ts:96`) already +understands that exact alias (via `getMergedColumnToFieldMap`) and reads the per-row +discriminator. Both are reused verbatim — the child path only needs to *call* them. The MTI +variant join is an ordinary base⋈variant-on-PK join inside the correlated subquery's FROM; it +is unrelated to the LATERAL machinery 2729 removed (which joined *nested includes*, not +variant tables). + +### Worked example + +`Role` is STI (`AdminRole` / `GuestRole`, discriminator `kind`, `AdminRole.permissions`, +`GuestRole.invitedBy`). `db.orm.user.include('roles')`: + +- **Before:** each role row shaped as base `Role`; `permissions` / `invitedBy` dropped or + mis-mapped. No error. +- **After:** each role row is the variant its `kind` says — `{ ..., permissions }` for + admin rows, `{ ..., invitedBy }` for guest rows. + +MTI `Role` (variant tables `admin_roles` / `guest_roles`): before, variant columns are +absent entirely (no join); after, they are joined into the child SELECT and surface on the +row. `db.orm.user.include('roles', r => r.variant('Admin'))` inner-joins only `admin_roles` +and the result type narrows to the admin variant. + +## Coherence rationale + +One reviewable change: "teach the single-query include builder family + its decoder about +the polymorphism metadata they currently ignore, and expose the narrowing operator that +rides the same slot." All three pieces (SQL emit, decode map, `.variant()` surface) read or +write one shared concept — `include.nested.variantName` + the related model's +`PolymorphismInfo` — and are individually meaningless: SQL without decode returns +correctly-fetched-but-base-mapped rows; decode without SQL has no variant columns to map; +`.variant()` without both is an inert flag. They ship as one PR so the reviewer holds the +parent↔child symmetry in one sitting. + +## Scope + +**In:** + +- `query-plan-select.ts` — `buildIncludeChildRowsSelect` and + `buildDistinctNonLeafChildRowsSelect` resolve polymorphism for `include.relatedModelName` + and emit MTI variant joins (re-introducing `.withJoins(...)` on the child correlated SELECT) + + variant/discriminator projection, honoring `include.nested.variantName`. Reached via the + sole post-2729 include builder `buildCorrelatedIncludeProjection`. +- `collection-dispatch.ts` — `decodeIncludePayload` (`:504`) maps poly-target child rows via + `mapPolymorphicRow` instead of `mapStorageRowToModelFields`. +- `collection.ts` / type surface — expose `.variant()` on the include-refinement collection + type and narrow the included relation's result type to the variant union member. +- Variant-specific `where` on a poly include refinement evaluates correctly. For **STI** this + falls out of the SQL-side joins (the variant columns live on the base table the predicate + accessor already resolves). For **MTI** it does *not* — the predicate accessor + (`model-accessor.ts`) resolves fields against the base table only, so a variant column on a + joined variant table is unreferenceable; the accessor must become variant-aware (merge the + selected variant's columns, qualifying their `ColumnRef` against the variant table D1 already + joins into the child SELECT). Delivered by **D5**. *(Discovered during D4 — see design-notes D4.)* +- Tests: unit (`test/query-plan-select.test.ts`, `test/collection-dispatch.test.ts`), + types (`test/polymorphism.test-d.ts` or a new `test-d`), integration + (`test/integration/test/sql-orm-client/`), plus a polymorphic-relation fixture for the + integration suite (none exists today). + +**Out:** + +- The multi-query stitcher path — already removed by TML-2657 (landed); nothing to extend. +- LATERAL include emission — already removed by TML-2729 (assumed landed); the child SELECT + is correlated-only. This slice does not touch `JoinAst.lateral`, the postgres renderer's + LATERAL emission, the public `lateralJoin()` DSL, or the `lateral` capability flag — all of + which 2729 deliberately keeps. +- A scalar-reducer-specific dispatch. Scalar reducers on poly-target relations inherit the + SQL-side variant-join fix automatically (same builder family) once the include-aggregates + slice (TML-2588 / TML-2595) lands; no row-decode work applies to a primitive. See + `design-notes.md`. +- Mutation-path includes (read path only). +- Any change to the contract emitter or polymorphism metadata shape — all consumed + metadata (`discriminator`, `variants`, variant `storage.table`) already exists. + +## Pre-investigated edge cases + +| Edge case | Disposition | Notes | +|---|---|---| +| STI variant columns live on the **base** table | In — handled by projection | No MTI join exists for STI; the child SELECT must still project the discriminator + the variant-specific base-table columns, else the decoder has nothing to map. Confirm `buildProjection` over the base model's `selectedFields` does **not** already drop variant columns. | +| Self-relation poly include (`childTableAlias` set) | In — must remap | When the base table is aliased, `buildMtiJoins`' join-`ON` references the unaliased base table name and falls out of scope. The existing alias-remap that `buildIncludeChildRowsSelect` applies to `orderBy`/`where` must also cover the variant joins. | +| `distinct()` on a non-leaf poly include | In — second emit site | `buildDistinctNonLeafChildRowsSelect` is a separate child-SELECT builder; the variant joins/projection must be added there too, not only the plain branch. | +| Empty relation (no child rows) | Inherited — no new work | Existing empty-relation handling (`coerceSingleQueryIncludeResult`, no-LATERAL-row short-circuit) is variant-agnostic. | + +## Slice-specific done conditions + +- [ ] A new polymorphic-relation fixture (STI-target **and** MTI-target relation off a + parent model) exists in the integration suite and is committed; integration tests pass + on **PGlite (Postgres)**. *(Amended from "PGlite + SQLite" — see design-notes D4: the + `sql-orm-client` integration suite is Postgres-only; the emitted variant-join lowering is + target-agnostic, so PGlite exercises the D1–D3 logic. SQLite poly-include coverage is a + deferred follow-up, gated on the sqlite contract-builder gaining polymorphism support.)* +- [ ] Variant-specific `where` works end-to-end on a real DB for **both** STI (base-table + column) and MTI (variant-table column, via the D5 variant-aware predicate accessor). +- [ ] Type-level test asserts `.include('')` result row type = the variant union, + and `.include('', r => r.variant('X'))` narrows to variant `X`. +- [ ] `pnpm fixtures:check` clean (if any emitted fixture/contract changes). + +## Open Questions + +1. Does `IncludeRefinementCollection` already expose `.variant()` at the type level, or is + it stripped in `includeRefinementMode`? Working position: it is likely **not** exposed + (the refinement surface is curated); the `.variant()` dispatch adds it explicitly and + narrows the result type. Resolve by grep at dispatch time. +2. Can the integration suite's shared `getTestContract()` be extended with poly models, or + does the poly fixture need a standalone contract + seed helpers? Working position: + standalone poly contract + seed helpers (keeps the shared contract stable); confirm at + the integration dispatch. + +## References + +- Linear issue: [TML-2683](https://linear.app/prisma-company/issue/TML-2683) +- Predecessors (both assumed landed): [TML-2657](https://linear.app/prisma-company/issue/TML-2657) + (remove multi-query include path) and [TML-2729](https://linear.app/prisma-company/issue/TML-2729) + (drop LATERAL; correlated-only read path) +- Decisions: `projects/tml-2683/design-notes.md` +- Parent-side template: `compileSelectWithIncludes` (renamed by 2729; parent MTI joins via + `buildMtiJoins`), `mapPolymorphicRow` (`collection-runtime.ts:96`), `Collection.variant()` + (`collection.ts:297`) +- Existing poly unit fixtures: `buildStiPolyContract` / `buildMixedPolyContract` + (`packages/3-extensions/sql-orm-client/test/helpers.ts:190`, `:125`) +- ADRs: none — wires existing parent-side polymorphism machinery into the child path; no + architectural shift. diff --git a/test/integration/test/sql-orm-client/polymorphism-include-relationships.test.ts b/test/integration/test/sql-orm-client/polymorphism-include-relationships.test.ts new file mode 100644 index 0000000000..b29391f88c --- /dev/null +++ b/test/integration/test/sql-orm-client/polymorphism-include-relationships.test.ts @@ -0,0 +1,848 @@ +import { Collection } from '@prisma-next/sql-orm-client'; +import type { ExecutionContext } from '@prisma-next/sql-relational-core/query-lane-context'; +import { describe, expect, it } from 'vitest'; +import { + buildMixedPolyContract, + buildStiPolyContract, + getTestContext, + type TestContract, +} from './helpers'; +import { timeouts, withCollectionRuntime } from './integration-helpers'; +import type { PgIntegrationRuntime } from './runtime-helpers'; + +// These tests extend the poly-include coverage to relationship shapes the +// sibling `polymorphism-include.test.ts` doesn't reach: +// 1. a poly (MTI) model as the include PARENT (poly model is the root); +// 2. a to-one / N:1 include whose TARGET is a poly model; +// 3. a base with two MTI variant tables (no cross-variant contamination); +// 4. a nested include through a poly target (Parent -> tasks(poly) -> child); +// 5. relationship-level implicit-default selection (no `.select(...)`). +// +// As in the sibling file, the poly models are patched in at runtime and are +// absent from the static `TestContract` Models type, so minimal cast +// interfaces drive `.include('')` / `.select` / `.orderBy` and read +// the rows back. Fixtures stay LOCAL here (the `build*Contract` helpers below +// deep-clone the shared poly contracts before mutating) so the shared +// `helpers.ts` builders — and the sibling tests' hand-rolled DDL — stay stable. +// +// Ordering is ALWAYS by a base-table column (`id`): TML-2782 makes orderBy on +// an MTI variant column throw. Poly result columns are asserted at their +// CURRENT behavior — explicit `.select(...)` does not restrict MTI variant +// columns (TML-2783) — so the no-select implicit-default shape is the primary +// vehicle for poly result assertions here. + +interface OrderBy { + asc(): unknown; +} +interface BaseRow { + id: OrderBy; +} +interface IncludeRefinement { + variant(name: string): IncludeRefinement; + select(...fields: string[]): IncludeRefinement; + orderBy(selector: (row: BaseRow) => unknown): IncludeRefinement; + include( + relation: string, + refine?: (collection: IncludeRefinement) => IncludeRefinement, + ): IncludeRefinement; +} +interface IncludeRoot { + select(...fields: string[]): IncludeRoot; + orderBy(selector: (row: BaseRow) => unknown): IncludeRoot; + include( + relation: string, + refine?: (collection: IncludeRefinement) => IncludeRefinement, + ): IncludeRoot & { + include( + relation: string, + refine?: (collection: IncludeRefinement) => IncludeRefinement, + ): IncludeRoot; + all(): { toArray(): Promise[]> }; + }; +} + +type RawContract = { + domain: { namespaces: Record }> }; + storage: { namespaces: Record }> }; +}; +type MutableModel = { + fields: Record; + relations: Record; + storage: { table: string; fields: Record }; + discriminator?: { field: string }; + variants?: Record; + base?: string; +}; +type RawTable = { + columns: Record; + primaryKey: { columns: string[] }; + uniques: never[]; + indexes: never[]; + foreignKeys: never[]; +}; + +function rawOf(contract: TestContract): RawContract { + return JSON.parse(JSON.stringify(contract)) as RawContract; +} +function modelsOf(raw: RawContract): Record { + return Object.values(raw.domain.namespaces)[0]!.models; +} +function tablesOf(raw: RawContract): Record { + return Object.values(raw.storage.namespaces)[0]!.tables; +} +function int(nullable: boolean) { + return { nullable, type: { kind: 'scalar', codecId: 'pg/int4@1' } }; +} +function text(nullable: boolean) { + return { nullable, type: { kind: 'scalar', codecId: 'pg/text@1' } }; +} +function intCol(nullable: boolean) { + return { nativeType: 'int4', codecId: 'pg/int4@1', nullable }; +} +function textCol(nullable: boolean) { + return { nativeType: 'text', codecId: 'pg/text@1', nullable }; +} + +// Task (Bug STI / Feature MTI poly) as the include PARENT --comments(1:N)--> +// Comment (plain). The poly model is the include ROOT here: its base+variant +// span must correlate to the child rows. +function buildPolyParentContract(): TestContract { + const raw = rawOf(buildMixedPolyContract()); + const models = modelsOf(raw); + const tables = tablesOf(raw); + + const task = models['Task']!; + task.relations['comments'] = { + to: { model: 'TaskComment' }, + cardinality: '1:N', + on: { localFields: ['id'], targetFields: ['taskId'] }, + }; + + models['TaskComment'] = { + fields: { id: int(false), body: text(false), taskId: int(true) }, + relations: {}, + storage: { + table: 'task_comments', + fields: { id: { column: 'id' }, body: { column: 'body' }, taskId: { column: 'task_id' } }, + }, + }; + tables['task_comments'] = { + columns: { id: intCol(false), body: textCol(false), task_id: intCol(true) }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }; + + return raw as unknown as TestContract; +} + +// Ticket (plain) --owner(N:1)--> User (Admin/Regular STI poly). A to-one +// include whose TARGET is a poly model: per-row variant mapping on a single +// included object, not an array. +function buildToOnePolyTargetContract(): TestContract { + const raw = rawOf(buildStiPolyContract()); + const models = modelsOf(raw); + const tables = tablesOf(raw); + + models['Ticket'] = { + fields: { id: int(false), subject: text(false), ownerId: int(true) }, + relations: { + owner: { + to: { model: 'User' }, + cardinality: 'N:1', + on: { localFields: ['ownerId'], targetFields: ['id'] }, + }, + }, + storage: { + table: 'tickets', + fields: { + id: { column: 'id' }, + subject: { column: 'subject' }, + ownerId: { column: 'owner_id' }, + }, + }, + }; + tables['tickets'] = { + columns: { id: intCol(false), subject: textCol(false), owner_id: intCol(true) }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }; + + return raw as unknown as TestContract; +} + +// Project (plain) --tasks(1:N)--> Task with TWO MTI variants: +// Feature (MTI -> features.priority) and Epic (MTI -> epics.scope). +// Confirms each row carries ONLY its own variant table's column. +function buildTwoMtiVariantContract(): TestContract { + const raw = rawOf(buildMixedPolyContract()); + const models = modelsOf(raw); + const tables = tablesOf(raw); + + const task = models['Task']!; + task.fields['projectId'] = int(true); + task.storage.fields['projectId'] = { column: 'project_id' }; + task.variants = { Bug: { value: 'bug' }, Feature: { value: 'feature' }, Epic: { value: 'epic' } }; + tables['tasks']!.columns['project_id'] = intCol(true); + + models['Epic'] = { + fields: { scope: text(false) }, + relations: {}, + storage: { table: 'epics', fields: { scope: { column: 'scope' } } }, + base: 'Task', + }; + tables['epics'] = { + columns: { id: intCol(false), scope: textCol(false) }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }; + + models['Project'] = { + fields: { id: int(false), name: text(false) }, + relations: { + tasks: { + to: { model: 'Task' }, + cardinality: '1:N', + on: { localFields: ['id'], targetFields: ['projectId'] }, + }, + }, + storage: { table: 'projects_tbl', fields: { id: { column: 'id' }, name: { column: 'name' } } }, + }; + tables['projects_tbl'] = { + columns: { id: intCol(false), name: textCol(false) }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }; + + return raw as unknown as TestContract; +} + +// Project --tasks(1:N)--> Task (poly) --reporter(N:1)--> Person. +// Nested include through a poly target: a relation hanging off the poly child +// must stitch when the child row is variant-mapped. +function buildNestedThroughPolyContract(): TestContract { + const raw = rawOf(buildMixedPolyContract()); + const models = modelsOf(raw); + const tables = tablesOf(raw); + + const task = models['Task']!; + task.fields['projectId'] = int(true); + task.storage.fields['projectId'] = { column: 'project_id' }; + task.fields['reporterId'] = int(true); + task.storage.fields['reporterId'] = { column: 'reporter_id' }; + task.relations['reporter'] = { + to: { model: 'Person' }, + cardinality: 'N:1', + on: { localFields: ['reporterId'], targetFields: ['id'] }, + }; + tables['tasks']!.columns['project_id'] = intCol(true); + tables['tasks']!.columns['reporter_id'] = intCol(true); + + models['Person'] = { + fields: { id: int(false), name: text(false) }, + relations: {}, + storage: { table: 'people', fields: { id: { column: 'id' }, name: { column: 'name' } } }, + }; + tables['people'] = { + columns: { id: intCol(false), name: textCol(false) }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }; + + models['Project'] = { + fields: { id: int(false), name: text(false) }, + relations: { + tasks: { + to: { model: 'Task' }, + cardinality: '1:N', + on: { localFields: ['id'], targetFields: ['projectId'] }, + }, + }, + storage: { table: 'projects_tbl', fields: { id: { column: 'id' }, name: { column: 'name' } } }, + }; + tables['projects_tbl'] = { + columns: { id: intCol(false), name: textCol(false) }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }; + + return raw as unknown as TestContract; +} + +// Account --members(1:N)--> User (STI poly), for relationship-level +// implicit-default coverage. Mirrors `buildStiIncludeContract` in the sibling +// file but stays local here. +function buildStiMembersContract(): TestContract { + const raw = rawOf(buildStiPolyContract()); + const models = modelsOf(raw); + const tables = tablesOf(raw); + + const user = models['User']!; + user.fields['accountId'] = int(true); + user.storage.fields['accountId'] = { column: 'account_id' }; + tables['users']!.columns['account_id'] = intCol(true); + + models['Account'] = { + fields: { id: int(false), name: text(false) }, + relations: { + members: { + to: { model: 'User' }, + cardinality: '1:N', + on: { localFields: ['id'], targetFields: ['accountId'] }, + }, + }, + storage: { table: 'accounts', fields: { id: { column: 'id' }, name: { column: 'name' } } }, + }; + tables['accounts'] = { + columns: { id: intCol(false), name: textCol(false) }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }; + + return raw as unknown as TestContract; +} + +// Project --tasks(1:N)--> Task (Bug STI / Feature MTI), for the MTI half of +// relationship-level implicit-default coverage. +function buildMtiTasksContract(): TestContract { + const raw = rawOf(buildMixedPolyContract()); + const models = modelsOf(raw); + const tables = tablesOf(raw); + + const task = models['Task']!; + task.fields['projectId'] = int(true); + task.storage.fields['projectId'] = { column: 'project_id' }; + tables['tasks']!.columns['project_id'] = intCol(true); + + models['Project'] = { + fields: { id: int(false), name: text(false) }, + relations: { + tasks: { + to: { model: 'Task' }, + cardinality: '1:N', + on: { localFields: ['id'], targetFields: ['projectId'] }, + }, + }, + storage: { table: 'projects_tbl', fields: { id: { column: 'id' }, name: { column: 'name' } } }, + }; + tables['projects_tbl'] = { + columns: { id: intCol(false), name: textCol(false) }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }; + + return raw as unknown as TestContract; +} + +function collectionOf( + runtime: PgIntegrationRuntime, + contract: TestContract, + model: string, +): IncludeRoot { + const context = { ...getTestContext(), contract } as ExecutionContext; + return new Collection({ runtime, context }, model as never) as unknown as IncludeRoot; +} + +describe('integration/polymorphism-include-relationships', () => { + it( + 'a poly (MTI) parent correlates its child relation across base + variant tables', + async () => { + await withCollectionRuntime(async (runtime) => { + await runtime.query('drop table if exists task_comments'); + await runtime.query('drop table if exists features'); + await runtime.query('drop table if exists tasks'); + await runtime.query(` + create table tasks ( + id integer primary key, + title text not null, + type text not null, + severity text + ) + `); + await runtime.query(` + create table features ( + id integer primary key references tasks(id), + priority integer not null + ) + `); + await runtime.query(` + create table task_comments ( + id integer primary key, + body text not null, + task_id integer + ) + `); + await runtime.query( + "insert into tasks (id, title, type, severity) values (1, 'Crash', 'bug', 'critical')", + ); + await runtime.query( + "insert into tasks (id, title, type) values (2, 'Dark mode', 'feature')", + ); + await runtime.query('insert into features (id, priority) values (2, 5)'); + await runtime.query( + "insert into task_comments (id, body, task_id) values (10, 'repro attached', 1)", + ); + await runtime.query( + "insert into task_comments (id, body, task_id) values (11, 'ship it', 2)", + ); + await runtime.query( + "insert into task_comments (id, body, task_id) values (12, 'me too', 1)", + ); + + const tasks = collectionOf(runtime, buildPolyParentContract(), 'Task'); + const rows = await tasks + .orderBy((task) => task.id.asc()) + .include('comments', (comments) => + comments.select('id', 'body', 'taskId').orderBy((comment) => comment.id.asc()), + ) + .all() + .toArray(); + + // The bug row (base-only) and the feature row (base + features variant + // table) each correlate their `comments` by the base `id`. No-select on + // the poly ROOT yields the full default per-variant shape: the bug row + // carries `severity`, the feature row carries `priority` (TML-2783). + expect(rows).toEqual([ + { + id: 1, + title: 'Crash', + type: 'bug', + severity: 'critical', + comments: [ + { id: 10, body: 'repro attached', taskId: 1 }, + { id: 12, body: 'me too', taskId: 1 }, + ], + }, + { + id: 2, + title: 'Dark mode', + type: 'feature', + priority: 5, + comments: [{ id: 11, body: 'ship it', taskId: 2 }], + }, + ]); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'a to-one (N:1) include whose target is a poly model variant-maps the single object', + async () => { + await withCollectionRuntime(async (runtime) => { + await runtime.query('drop table if exists tickets'); + await runtime.query('drop table if exists users'); + await runtime.query(` + create table users ( + id integer primary key, + name text not null, + email text not null, + invited_by_id integer, + address jsonb, + kind text not null, + role text, + plan text + ) + `); + await runtime.query(` + create table tickets ( + id integer primary key, + subject text not null, + owner_id integer + ) + `); + await runtime.query( + "insert into users (id, name, email, kind, role) values (1, 'Ada', 'ada@x', 'admin', 'superadmin')", + ); + await runtime.query( + "insert into users (id, name, email, kind, plan) values (2, 'Bob', 'bob@x', 'regular', 'free')", + ); + await runtime.query( + "insert into tickets (id, subject, owner_id) values (100, 'Login broken', 1)", + ); + await runtime.query( + "insert into tickets (id, subject, owner_id) values (101, 'Add export', 2)", + ); + await runtime.query( + "insert into tickets (id, subject, owner_id) values (102, 'Orphan', null)", + ); + + const tickets = collectionOf(runtime, buildToOnePolyTargetContract(), 'Ticket'); + const rows = await tickets + .select('id', 'subject') + .orderBy((ticket) => ticket.id.asc()) + .include('owner') + .all() + .toArray(); + + // `owner` is a single object (or null), not an array. Each owner is + // variant-mapped: the admin carries `role`, the regular carries `plan`. + expect(rows).toEqual([ + { + id: 100, + subject: 'Login broken', + owner: { + id: 1, + name: 'Ada', + email: 'ada@x', + invitedById: null, + address: null, + kind: 'admin', + role: 'superadmin', + }, + }, + { + id: 101, + subject: 'Add export', + owner: { + id: 2, + name: 'Bob', + email: 'bob@x', + invitedById: null, + address: null, + kind: 'regular', + plan: 'free', + }, + }, + { id: 102, subject: 'Orphan', owner: null }, + ]); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'a base with two MTI variant tables surfaces only the matching variant column per row', + async () => { + await withCollectionRuntime(async (runtime) => { + await runtime.query('drop table if exists epics'); + await runtime.query('drop table if exists features'); + await runtime.query('drop table if exists tasks'); + await runtime.query('drop table if exists projects_tbl'); + await runtime.query(` + create table projects_tbl ( + id integer primary key, + name text not null + ) + `); + await runtime.query(` + create table tasks ( + id integer primary key, + title text not null, + type text not null, + severity text, + project_id integer + ) + `); + await runtime.query(` + create table features ( + id integer primary key references tasks(id), + priority integer not null + ) + `); + await runtime.query(` + create table epics ( + id integer primary key references tasks(id), + scope text not null + ) + `); + await runtime.query("insert into projects_tbl (id, name) values (1, 'Roadmap')"); + await runtime.query( + "insert into tasks (id, title, type, severity, project_id) values (1, 'Crash', 'bug', 'critical', 1)", + ); + await runtime.query( + "insert into tasks (id, title, type, project_id) values (2, 'Dark mode', 'feature', 1)", + ); + await runtime.query('insert into features (id, priority) values (2, 3)'); + await runtime.query( + "insert into tasks (id, title, type, project_id) values (3, 'Billing', 'epic', 1)", + ); + await runtime.query("insert into epics (id, scope) values (3, 'Q3')"); + + const projects = collectionOf(runtime, buildTwoMtiVariantContract(), 'Project'); + const rows = await projects + .select('id', 'name') + .orderBy((project) => project.id.asc()) + .include('tasks', (tasks) => tasks.orderBy((task) => task.id.asc())) + .all() + .toArray(); + + // No-select on the poly include → full default per-variant shape. + // The bug row carries `severity`, the feature row carries `priority` + // (from `features`), the epic row carries `scope` (from `epics`). No + // row carries a sibling variant's column — no cross-variant + // contamination across the two MTI variant tables. + expect(rows).toEqual([ + { + id: 1, + name: 'Roadmap', + tasks: [ + { id: 1, title: 'Crash', type: 'bug', severity: 'critical', projectId: 1 }, + { id: 2, title: 'Dark mode', type: 'feature', projectId: 1, priority: 3 }, + { id: 3, title: 'Billing', type: 'epic', projectId: 1, scope: 'Q3' }, + ], + }, + ]); + }); + }, + timeouts.spinUpPpgDev, + ); + + // A nested `.include('reporter')` hanging off a polymorphic include TARGET + // used to decode to `null` for every row, regardless of data: `mapPolymorphicRow` + // (`collection-runtime.ts`) keeps only base/variant MODEL-field columns, so the + // nested-include payload column (`reporter`) was dropped before + // `decodeIncludePayload` (`collection-dispatch.ts`) read it back at the mapped + // row. The fix sources each nested-include payload from the RAW child row, which + // always carries the relation alias. This test asserts the CORRECT (stitched) + // shape; do not weaken it to match the old bug. + it( + 'a nested include through a poly target stitches the grandchild on variant-mapped rows', + async () => { + await withCollectionRuntime(async (runtime) => { + await runtime.query('drop table if exists features'); + await runtime.query('drop table if exists tasks'); + await runtime.query('drop table if exists people'); + await runtime.query('drop table if exists projects_tbl'); + await runtime.query(` + create table projects_tbl ( + id integer primary key, + name text not null + ) + `); + await runtime.query(` + create table people ( + id integer primary key, + name text not null + ) + `); + await runtime.query(` + create table tasks ( + id integer primary key, + title text not null, + type text not null, + severity text, + project_id integer, + reporter_id integer + ) + `); + await runtime.query(` + create table features ( + id integer primary key references tasks(id), + priority integer not null + ) + `); + await runtime.query("insert into projects_tbl (id, name) values (1, 'Roadmap')"); + await runtime.query("insert into people (id, name) values (50, 'Ada')"); + await runtime.query("insert into people (id, name) values (51, 'Bob')"); + await runtime.query( + "insert into tasks (id, title, type, severity, project_id, reporter_id) values (1, 'Crash', 'bug', 'critical', 1, 50)", + ); + await runtime.query( + "insert into tasks (id, title, type, project_id, reporter_id) values (2, 'Dark mode', 'feature', 1, 51)", + ); + await runtime.query('insert into features (id, priority) values (2, 7)'); + + const projects = collectionOf(runtime, buildNestedThroughPolyContract(), 'Project'); + const rows = await projects + .select('id', 'name') + .orderBy((project) => project.id.asc()) + .include('tasks', (tasks) => + tasks + .orderBy((task) => task.id.asc()) + .include('reporter', (reporter) => reporter.select('id', 'name')), + ) + .all() + .toArray(); + + // The poly child rows are variant-mapped (bug carries `severity`, + // feature carries `priority`) AND each carries the nested `reporter` + // grandchild stitched by `reporter_id`. + expect(rows).toEqual([ + { + id: 1, + name: 'Roadmap', + tasks: [ + { + id: 1, + title: 'Crash', + type: 'bug', + severity: 'critical', + projectId: 1, + reporterId: 50, + reporter: { id: 50, name: 'Ada' }, + }, + { + id: 2, + title: 'Dark mode', + type: 'feature', + projectId: 1, + reporterId: 51, + priority: 7, + reporter: { id: 51, name: 'Bob' }, + }, + ], + }, + ]); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'an STI-target include with no select returns the full default per-variant shape', + async () => { + await withCollectionRuntime(async (runtime) => { + await runtime.query('drop table if exists users'); + await runtime.query('drop table if exists accounts'); + await runtime.query(` + create table accounts ( + id integer primary key, + name text not null + ) + `); + await runtime.query(` + create table users ( + id integer primary key, + name text not null, + email text not null, + invited_by_id integer, + address jsonb, + kind text not null, + role text, + plan text, + account_id integer + ) + `); + await runtime.query("insert into accounts (id, name) values (1, 'Acme')"); + await runtime.query( + "insert into users (id, name, email, kind, role, account_id) values (1, 'Ada', 'ada@x', 'admin', 'superadmin', 1)", + ); + await runtime.query( + "insert into users (id, name, email, kind, plan, account_id) values (2, 'Bob', 'bob@x', 'regular', 'free', 1)", + ); + + const accounts = collectionOf(runtime, buildStiMembersContract(), 'Account'); + const rows = await accounts + .select('id', 'name') + .orderBy((account) => account.id.asc()) + .include('members', (members) => members.orderBy((member) => member.id.asc())) + .all() + .toArray(); + + // No `.select(...)` on the poly include — the deliberate + // implicit-default exception in the whole-shape rule. The admin row + // carries `role` (no `plan`), the regular row carries `plan` (no + // `role`); both carry the full base shape. + expect(rows).toEqual([ + { + id: 1, + name: 'Acme', + members: [ + { + id: 1, + name: 'Ada', + email: 'ada@x', + invitedById: null, + address: null, + kind: 'admin', + role: 'superadmin', + accountId: 1, + }, + { + id: 2, + name: 'Bob', + email: 'bob@x', + invitedById: null, + address: null, + kind: 'regular', + plan: 'free', + accountId: 1, + }, + ], + }, + ]); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'an MTI-target include with no select returns the full default per-variant shape', + async () => { + await withCollectionRuntime(async (runtime) => { + await runtime.query('drop table if exists features'); + await runtime.query('drop table if exists tasks'); + await runtime.query('drop table if exists projects_tbl'); + await runtime.query(` + create table projects_tbl ( + id integer primary key, + name text not null + ) + `); + await runtime.query(` + create table tasks ( + id integer primary key, + title text not null, + type text not null, + severity text, + project_id integer + ) + `); + await runtime.query(` + create table features ( + id integer primary key references tasks(id), + priority integer not null + ) + `); + await runtime.query("insert into projects_tbl (id, name) values (1, 'Roadmap')"); + await runtime.query( + "insert into tasks (id, title, type, severity, project_id) values (1, 'Crash', 'bug', 'critical', 1)", + ); + await runtime.query( + "insert into tasks (id, title, type, project_id) values (2, 'Dark mode', 'feature', 1)", + ); + await runtime.query('insert into features (id, priority) values (2, 9)'); + + const projects = collectionOf(runtime, buildMtiTasksContract(), 'Project'); + const rows = await projects + .select('id', 'name') + .orderBy((project) => project.id.asc()) + .include('tasks', (tasks) => tasks.orderBy((task) => task.id.asc())) + .all() + .toArray(); + + // No `.select(...)` on the poly include — implicit-default exception. + // The bug row carries `severity`, the feature row carries `priority` + // (joined from the `features` MTI variant table); neither carries the + // sibling variant's column. + expect(rows).toEqual([ + { + id: 1, + name: 'Roadmap', + tasks: [ + { id: 1, title: 'Crash', type: 'bug', severity: 'critical', projectId: 1 }, + { id: 2, title: 'Dark mode', type: 'feature', projectId: 1, priority: 9 }, + ], + }, + ]); + }); + }, + timeouts.spinUpPpgDev, + ); +}); diff --git a/test/integration/test/sql-orm-client/polymorphism-include.test.ts b/test/integration/test/sql-orm-client/polymorphism-include.test.ts new file mode 100644 index 0000000000..abb57fa1fb --- /dev/null +++ b/test/integration/test/sql-orm-client/polymorphism-include.test.ts @@ -0,0 +1,506 @@ +import { Collection } from '@prisma-next/sql-orm-client'; +import type { ExecutionContext } from '@prisma-next/sql-relational-core/query-lane-context'; +import { describe, expect, it } from 'vitest'; +import { + buildMixedPolyContract, + buildStiPolyContract, + getTestContext, + type TestContract, +} from './helpers'; +import { timeouts, withCollectionRuntime } from './integration-helpers'; +import type { PgIntegrationRuntime } from './runtime-helpers'; + +// The poly contracts are patched in at runtime, so the parent models +// (`Account` / `Project`) and their polymorphic-target relations are +// absent from the static `TestContract` Models type. These minimal +// surfaces let the tests drive `.include('')` and read the +// included rows without a static contract for the patched models. This +// mirrors the cast pattern the unit `collection-variant.test.ts` uses. +// +// `select` / `orderBy` mirror the real `Collection` API idiom +// (`.select('id', 'name').orderBy((row) => row.id.asc())`) so the asserted +// shape is intentional and stable when new model fields are added. +interface ScalarFilter { + eq(value: unknown): unknown; + gte(value: unknown): unknown; +} +interface OrderBy { + asc(): unknown; +} +interface RefinementRow { + id: OrderBy; +} +interface TaskRefinementRow extends RefinementRow { + severity: ScalarFilter; + priority: ScalarFilter; +} +interface PolyIncludeRefinement { + variant(name: string): PolyIncludeRefinement; + where(predicate: (row: TaskRefinementRow) => unknown): PolyIncludeRefinement; + select(...fields: string[]): PolyIncludeRefinement; + orderBy(selector: (row: TaskRefinementRow) => unknown): PolyIncludeRefinement; +} +interface ParentRow { + id: OrderBy; +} +interface PolyIncludeParent { + select(...fields: string[]): PolyIncludeParent; + orderBy(selector: (row: ParentRow) => unknown): PolyIncludeParent; + include( + relation: string, + refine?: (collection: PolyIncludeRefinement) => PolyIncludeRefinement, + ): { + all(): { toArray(): Promise[]> }; + }; +} + +// Build the parent-bearing poly contracts locally rather than widening the +// shared `buildStiPolyContract` / `buildMixedPolyContract` helpers: a parent +// relation + FK column on the poly child is only needed by these +// include-against-a-poly-target tests, and adding it to the shared helpers +// breaks sibling tests whose hand-rolled DDL omits the FK column. This is the +// "standalone poly fixture, shared contract stays stable" position. +type RawContract = { + domain: { namespaces: Record }> }; + storage: { namespaces: Record }> }; +}; +type MutableModel = { + fields: Record; + relations: Record; + storage: { table: string; fields: Record }; +}; +type RawTable = { + columns: Record; + primaryKey: { columns: string[] }; + uniques: never[]; + indexes: never[]; + foreignKeys: never[]; +}; + +function rawOf(contract: TestContract): RawContract { + return JSON.parse(JSON.stringify(contract)) as RawContract; +} + +function modelsOf(raw: RawContract): Record { + return Object.values(raw.domain.namespaces)[0]!.models; +} + +function tablesOf(raw: RawContract): Record { + return Object.values(raw.storage.namespaces)[0]!.tables; +} + +// Account (parent) --members(1:N)--> User (STI poly target). +function buildStiIncludeContract(): TestContract { + const raw = rawOf(buildStiPolyContract()); + const models = modelsOf(raw); + const tables = tablesOf(raw); + + const user = models['User']!; + user.fields['accountId'] = { nullable: true, type: { kind: 'scalar', codecId: 'pg/int4@1' } }; + user.storage.fields['accountId'] = { column: 'account_id' }; + tables['users']!.columns['account_id'] = { + nativeType: 'int4', + codecId: 'pg/int4@1', + nullable: true, + }; + + models['Account'] = { + fields: { + id: { nullable: false, type: { kind: 'scalar', codecId: 'pg/int4@1' } }, + name: { nullable: false, type: { kind: 'scalar', codecId: 'pg/text@1' } }, + }, + relations: { + members: { + to: { model: 'User' }, + cardinality: '1:N', + on: { localFields: ['id'], targetFields: ['accountId'] }, + }, + }, + storage: { table: 'accounts', fields: { id: { column: 'id' }, name: { column: 'name' } } }, + }; + tables['accounts'] = { + columns: { + id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, + name: { nativeType: 'text', codecId: 'pg/text@1', nullable: false }, + }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }; + + return raw as unknown as TestContract; +} + +// Project (parent) --tasks(1:N)--> Task (Bug STI / Feature MTI poly target). +function buildMtiIncludeContract(): TestContract { + const raw = rawOf(buildMixedPolyContract()); + const models = modelsOf(raw); + const tables = tablesOf(raw); + + const task = models['Task']!; + task.fields['projectId'] = { nullable: true, type: { kind: 'scalar', codecId: 'pg/int4@1' } }; + task.storage.fields['projectId'] = { column: 'project_id' }; + tables['tasks']!.columns['project_id'] = { + nativeType: 'int4', + codecId: 'pg/int4@1', + nullable: true, + }; + + models['Project'] = { + fields: { + id: { nullable: false, type: { kind: 'scalar', codecId: 'pg/int4@1' } }, + name: { nullable: false, type: { kind: 'scalar', codecId: 'pg/text@1' } }, + }, + relations: { + tasks: { + to: { model: 'Task' }, + cardinality: '1:N', + on: { localFields: ['id'], targetFields: ['projectId'] }, + }, + }, + storage: { table: 'projects_tbl', fields: { id: { column: 'id' }, name: { column: 'name' } } }, + }; + tables['projects_tbl'] = { + columns: { + id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, + name: { nativeType: 'text', codecId: 'pg/text@1', nullable: false }, + }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }; + + return raw as unknown as TestContract; +} + +function createAccountCollection(runtime: PgIntegrationRuntime): PolyIncludeParent { + const contract = buildStiIncludeContract(); + const context = { ...getTestContext(), contract } as ExecutionContext; + return new Collection({ runtime, context }, 'Account' as never) as unknown as PolyIncludeParent; +} + +function createProjectCollection(runtime: PgIntegrationRuntime): PolyIncludeParent { + const contract = buildMtiIncludeContract(); + const context = { ...getTestContext(), contract } as ExecutionContext; + return new Collection({ runtime, context }, 'Project' as never) as unknown as PolyIncludeParent; +} + +async function setupStiIncludeSchema(runtime: PgIntegrationRuntime): Promise { + await runtime.query('drop table if exists users'); + await runtime.query('drop table if exists accounts'); + + await runtime.query(` + create table accounts ( + id integer primary key, + name text not null + ) + `); + await runtime.query(` + create table users ( + id integer primary key, + name text not null, + email text not null, + invited_by_id integer, + address jsonb, + kind text not null, + role text, + plan text, + account_id integer + ) + `); +} + +async function seedStiIncludeData(runtime: PgIntegrationRuntime): Promise { + await runtime.query("insert into accounts (id, name) values (1, 'Acme')"); + await runtime.query("insert into accounts (id, name) values (2, 'Empty')"); + await runtime.query( + "insert into users (id, name, email, kind, role, account_id) values (1, 'Ada', 'ada@x', 'admin', 'superadmin', 1)", + ); + await runtime.query( + "insert into users (id, name, email, kind, plan, account_id) values (2, 'Bob', 'bob@x', 'regular', 'free', 1)", + ); + await runtime.query( + "insert into users (id, name, email, kind, role, account_id) values (3, 'Cal', 'cal@x', 'admin', 'auditor', 1)", + ); +} + +async function setupMtiIncludeSchema(runtime: PgIntegrationRuntime): Promise { + await runtime.query('drop table if exists features'); + await runtime.query('drop table if exists tasks'); + await runtime.query('drop table if exists projects_tbl'); + + await runtime.query(` + create table projects_tbl ( + id integer primary key, + name text not null + ) + `); + await runtime.query(` + create table tasks ( + id integer primary key, + title text not null, + type text not null, + severity text, + project_id integer + ) + `); + await runtime.query(` + create table features ( + id integer primary key references tasks(id), + priority integer not null + ) + `); +} + +async function seedMtiIncludeData(runtime: PgIntegrationRuntime): Promise { + await runtime.query("insert into projects_tbl (id, name) values (1, 'Roadmap')"); + await runtime.query("insert into projects_tbl (id, name) values (2, 'Empty')"); + await runtime.query( + "insert into tasks (id, title, type, severity, project_id) values (1, 'Crash on login', 'bug', 'critical', 1)", + ); + await runtime.query( + "insert into tasks (id, title, type, severity, project_id) values (2, 'Null ref', 'bug', 'low', 1)", + ); + await runtime.query( + "insert into tasks (id, title, type, project_id) values (3, 'Dark mode', 'feature', 1)", + ); + await runtime.query('insert into features (id, priority) values (3, 1)'); + await runtime.query( + "insert into tasks (id, title, type, project_id) values (4, 'Export PDF', 'feature', 1)", + ); + await runtime.query('insert into features (id, priority) values (4, 3)'); +} + +describe('integration/polymorphism-include', () => { + it( + 'STI-target include returns each child row shaped per its discriminator variant', + async () => { + await withCollectionRuntime(async (runtime) => { + await setupStiIncludeSchema(runtime); + await seedStiIncludeData(runtime); + + const accounts = createAccountCollection(runtime); + // `select(['id','kind','role','plan'])` projects all four base columns + // (STI variant fields are base-table columns); `mapPolymorphicRow` + // then drops the sibling-variant field per row by the discriminator — + // admin rows carry `role` (no `plan`), regular rows carry `plan` + // (no `role`). + const rows = await accounts + .select('id', 'name') + .orderBy((account) => account.id.asc()) + .include('members', (members) => + members.select('id', 'kind', 'role', 'plan').orderBy((member) => member.id.asc()), + ) + .all() + .toArray(); + + expect(rows).toEqual([ + { + id: 1, + name: 'Acme', + members: [ + { id: 1, kind: 'admin', role: 'superadmin' }, + { id: 2, kind: 'regular', plan: 'free' }, + { id: 3, kind: 'admin', role: 'auditor' }, + ], + }, + { id: 2, name: 'Empty', members: [] }, + ]); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'MTI-target include returns rows with the variant table column present', + async () => { + await withCollectionRuntime(async (runtime) => { + await setupMtiIncludeSchema(runtime); + await seedMtiIncludeData(runtime); + + const projects = createProjectCollection(runtime); + // The MTI variant column (`features.priority`) is joined+projected by + // the poly machinery regardless of `select`; the base columns are + // controlled by `select`. So a bug row carries only the selected base + // fields, a feature row additionally carries `priority`. + // TML-2783: explicit `.select('id', 'title', 'type')` does NOT restrict + // the poly variant columns — `priority` leaks in despite not being + // selected. This asserts the current (buggy) shape, not the post-fix one. + const rows = await projects + .select('id', 'name') + .orderBy((project) => project.id.asc()) + .include('tasks', (tasks) => + tasks.select('id', 'title', 'type').orderBy((task) => task.id.asc()), + ) + .all() + .toArray(); + + expect(rows).toEqual([ + { + id: 1, + name: 'Roadmap', + tasks: [ + { id: 1, title: 'Crash on login', type: 'bug' }, + { id: 2, title: 'Null ref', type: 'bug' }, + { id: 3, title: 'Dark mode', type: 'feature', priority: 1 }, + { id: 4, title: 'Export PDF', type: 'feature', priority: 3 }, + ], + }, + { id: 2, name: 'Empty', tasks: [] }, + ]); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'an STI variant-specific where on a poly include refinement filters by the variant field', + async () => { + await withCollectionRuntime(async (runtime) => { + await setupMtiIncludeSchema(runtime); + await seedMtiIncludeData(runtime); + + const projects = createProjectCollection(runtime); + // `severity` is the Bug variant's discriminating field. Filtering an + // STI-variant-narrowed include on it confirms the refinement's where + // is scoped to the joined child rows and filters per the variant field. + const rows = await projects + .select('id', 'name') + .orderBy((project) => project.id.asc()) + .include('tasks', (tasks) => + tasks + .variant('Bug') + .where((task) => task.severity.eq('critical')) + .select('id', 'title', 'type', 'severity') + .orderBy((task) => task.id.asc()), + ) + .all() + .toArray(); + + expect(rows).toEqual([ + { + id: 1, + name: 'Roadmap', + tasks: [{ id: 1, title: 'Crash on login', type: 'bug', severity: 'critical' }], + }, + { id: 2, name: 'Empty', tasks: [] }, + ]); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'an MTI variant-specific where on a poly include refinement filters by the variant table column', + async () => { + await withCollectionRuntime(async (runtime) => { + await setupMtiIncludeSchema(runtime); + await seedMtiIncludeData(runtime); + + const projects = createProjectCollection(runtime); + // `priority` is the Feature (MTI) variant column — it lives on the + // joined `features` table, not the base `tasks` table. Filtering on it + // confirms the predicate accessor names the variant column against the + // joined variant table inside the correlated child SELECT. The MTI + // variant column projects regardless of select; seed has Feature id=3 + // (priority 1) and id=4 (priority 3), only id=4 passes priority >= 3. + const rows = await projects + .select('id', 'name') + .orderBy((project) => project.id.asc()) + .include('tasks', (tasks) => + tasks + .variant('Feature') + .where((task) => task.priority.gte(3)) + .select('id', 'title', 'type') + .orderBy((task) => task.id.asc()), + ) + .all() + .toArray(); + + expect(rows).toEqual([ + { + id: 1, + name: 'Roadmap', + tasks: [{ id: 4, title: 'Export PDF', type: 'feature', priority: 3 }], + }, + { id: 2, name: 'Empty', tasks: [] }, + ]); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'an MTI variant-narrowed include returns only that variant', + async () => { + await withCollectionRuntime(async (runtime) => { + await setupMtiIncludeSchema(runtime); + await seedMtiIncludeData(runtime); + + const projects = createProjectCollection(runtime); + const rows = await projects + .select('id', 'name') + .orderBy((project) => project.id.asc()) + .include('tasks', (tasks) => + tasks + .variant('Feature') + .select('id', 'title', 'type') + .orderBy((task) => task.id.asc()), + ) + .all() + .toArray(); + + expect(rows).toEqual([ + { + id: 1, + name: 'Roadmap', + tasks: [ + { id: 3, title: 'Dark mode', type: 'feature', priority: 1 }, + { id: 4, title: 'Export PDF', type: 'feature', priority: 3 }, + ], + }, + { id: 2, name: 'Empty', tasks: [] }, + ]); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'an STI-target variant-narrowed include returns only that variant shape', + async () => { + await withCollectionRuntime(async (runtime) => { + await setupStiIncludeSchema(runtime); + await seedStiIncludeData(runtime); + + const accounts = createAccountCollection(runtime); + const rows = await accounts + .select('id', 'name') + .orderBy((account) => account.id.asc()) + .include('members', (members) => + members + .variant('Admin') + .select('id', 'kind', 'role') + .orderBy((member) => member.id.asc()), + ) + .all() + .toArray(); + + expect(rows).toEqual([ + { + id: 1, + name: 'Acme', + members: [ + { id: 1, kind: 'admin', role: 'superadmin' }, + { id: 3, kind: 'admin', role: 'auditor' }, + ], + }, + { id: 2, name: 'Empty', members: [] }, + ]); + }); + }, + timeouts.spinUpPpgDev, + ); +}); diff --git a/test/integration/test/sql-orm-client/polymorphism.test.ts b/test/integration/test/sql-orm-client/polymorphism.test.ts index 2cf297a8ad..b3b5da9efc 100644 --- a/test/integration/test/sql-orm-client/polymorphism.test.ts +++ b/test/integration/test/sql-orm-client/polymorphism.test.ts @@ -10,10 +10,26 @@ function polyContract() { return withReturningCapability(buildMixedPolyContract()) as TestContract; } -function createTaskCollection(runtime: PgIntegrationRuntime) { +// The poly `Task` hierarchy is patched into the contract at runtime, so the +// static `TestContract` Models type doesn't carry its order-by accessor. This +// minimal view exposes the `Collection` API idiom these tests need — +// `.variant(...).orderBy((row) => row.id.asc()).all().toArray()` — so the +// asserted shape is stable and DB-side ordered by the base `id` column. Mirrors +// the cast pattern in the sibling `polymorphism-include.test.ts`. +interface OrderByRow { + id: { asc(): unknown }; +} +interface PolyTaskCollection { + variant(name: string): PolyTaskCollection; + orderBy(selector: (row: OrderByRow) => unknown): PolyTaskCollection; + all(): { toArray(): Promise[]> }; + create(values: Record): Promise>; +} + +function createTaskCollection(runtime: PgIntegrationRuntime): PolyTaskCollection { const contract = polyContract(); const context = { ...getTestContext(), contract } as ExecutionContext; - return new Collection({ runtime, context }, 'Task'); + return new Collection({ runtime, context }, 'Task') as unknown as PolyTaskCollection; } async function setupPolySchema(runtime: PgIntegrationRuntime): Promise { @@ -52,69 +68,81 @@ async function seedPolyData(runtime: PgIntegrationRuntime): Promise { describe('integration/polymorphism', () => { it( - 'base query returns all variants with discriminator-aware mapping', + 'base query with no select returns the full default shape per variant of the union', async () => { await withCollectionRuntime(async (runtime) => { await setupPolySchema(runtime); await seedPolyData(runtime); const tasks = createTaskCollection(runtime); - const rows = await tasks.all().toArray(); - - expect(rows).toHaveLength(4); - - const bug = rows.find((r) => r['title'] === 'Crash on login'); - expect(bug).toMatchObject({ - id: 1, - title: 'Crash on login', - type: 'bug', - severity: 'critical', - }); - expect(bug).not.toHaveProperty('priority'); - - const feature = rows.find((r) => r['title'] === 'Dark mode'); - expect(feature).toMatchObject({ id: 3, title: 'Dark mode', type: 'feature', priority: 1 }); - expect(feature).not.toHaveProperty('severity'); + // No `.select(...)` on purpose: this pins the default projection of a + // base poly query — each row carries the base fields plus only its own + // variant's field (Bug rows carry `severity`, Feature rows carry + // `priority`; no sibling-variant field leaks). + const rows = await tasks + .orderBy((task) => task.id.asc()) + .all() + .toArray(); + + expect(rows).toEqual([ + { id: 1, title: 'Crash on login', type: 'bug', severity: 'critical' }, + { id: 2, title: 'Null ref in parser', type: 'bug', severity: 'low' }, + { id: 3, title: 'Dark mode', type: 'feature', priority: 1 }, + { id: 4, title: 'Export to PDF', type: 'feature', priority: 3 }, + ]); }); }, timeouts.spinUpPpgDev, ); it( - 'variant(Bug) query returns only STI Bug rows', + 'variant(Bug) query with no select returns the full default STI variant shape', async () => { await withCollectionRuntime(async (runtime) => { await setupPolySchema(runtime); await seedPolyData(runtime); const tasks = createTaskCollection(runtime); - const bugs = await (tasks.variant('Bug' as never) as typeof tasks).all().toArray(); - - expect(bugs).toHaveLength(2); - for (const bug of bugs) { - expect(bug['type']).toBe('bug'); - expect(bug).toHaveProperty('severity'); - } + // STI variant (`severity` is a base-table column). No `.select(...)`: + // pins the default projection of an STI-variant-narrowed query — base + // fields plus the Bug variant's `severity`, and only the Bug rows. + const bugs = await tasks + .variant('Bug') + .orderBy((task) => task.id.asc()) + .all() + .toArray(); + + expect(bugs).toEqual([ + { id: 1, title: 'Crash on login', type: 'bug', severity: 'critical' }, + { id: 2, title: 'Null ref in parser', type: 'bug', severity: 'low' }, + ]); }); }, timeouts.spinUpPpgDev, ); it( - 'variant(Feature) query INNER JOINs and returns only MTI Feature rows', + 'variant(Feature) query with no select INNER JOINs and returns the full default MTI variant shape', async () => { await withCollectionRuntime(async (runtime) => { await setupPolySchema(runtime); await seedPolyData(runtime); const tasks = createTaskCollection(runtime); - const features = await (tasks.variant('Feature' as never) as typeof tasks).all().toArray(); - - expect(features).toHaveLength(2); - for (const feature of features) { - expect(feature['type']).toBe('feature'); - expect(feature).toHaveProperty('priority'); - } + // MTI variant (`priority` lives on the joined `features` table). No + // `.select(...)`: pins the default projection of an MTI-variant-narrowed + // query — base fields plus the joined `priority`, and only the Feature + // rows (the INNER JOIN drops non-Feature rows). + const features = await tasks + .variant('Feature') + .orderBy((task) => task.id.asc()) + .all() + .toArray(); + + expect(features).toEqual([ + { id: 3, title: 'Dark mode', type: 'feature', priority: 1 }, + { id: 4, title: 'Export to PDF', type: 'feature', priority: 3 }, + ]); }); }, timeouts.spinUpPpgDev, @@ -127,16 +155,21 @@ describe('integration/polymorphism', () => { await setupPolySchema(runtime); const tasks = createTaskCollection(runtime); - const bugs = tasks.variant('Bug' as never) as typeof tasks; - const created = await bugs.create({ title: 'New bug', severity: 'high' } as never); - - expect(created).toMatchObject({ title: 'New bug', type: 'bug', severity: 'high' }); - expect(created['id']).toBeDefined(); - - const rows = await runtime.query<{ type: string }>('select type from tasks where id = $1', [ - created['id'], - ]); - expect(rows[0]!.type).toBe('bug'); + const bugs = tasks.variant('Bug'); + const created = await bugs.create({ title: 'New bug', severity: 'high' }); + + const id = created['id']; + expect(created).toEqual({ id, title: 'New bug', type: 'bug', severity: 'high' }); + + // Read the row back through the ORM (no select → default variant shape) + // rather than re-reading the discriminator column raw: the discriminator + // round-trips through the mapped variant shape, which is what callers see. + const readBack = await tasks + .variant('Bug') + .orderBy((task) => task.id.asc()) + .all() + .toArray(); + expect(readBack).toEqual([{ id, title: 'New bug', type: 'bug', severity: 'high' }]); }); }, timeouts.spinUpPpgDev, @@ -149,28 +182,31 @@ describe('integration/polymorphism', () => { await setupPolySchema(runtime); const tasks = createTaskCollection(runtime); - const features = tasks.variant('Feature' as never) as typeof tasks; + const features = tasks.variant('Feature'); const created = await features.create({ title: 'New feature', priority: 5, - } as never); + }); - expect(created).toMatchObject({ title: 'New feature', type: 'feature', priority: 5 }); - expect(created['id']).toBeDefined(); + const id = created['id']; + expect(created).toEqual({ id, title: 'New feature', type: 'feature', priority: 5 }); + // Storage-level invariant the ORM intentionally hides: an MTI create is a + // two-table transactional write — the base row lands in `tasks` and the + // variant row in `features`. The mapped ORM result above presents a single + // merged row, so only a raw read can prove both physical tables were + // written. This is the deliberate exception to "read back through the ORM". const baseRows = await runtime.query<{ title: string; type: string }>( 'select title, type from tasks where id = $1', - [created['id']], + [id], ); - expect(baseRows).toHaveLength(1); - expect(baseRows[0]).toMatchObject({ title: 'New feature', type: 'feature' }); + expect(baseRows).toEqual([{ title: 'New feature', type: 'feature' }]); const variantRows = await runtime.query<{ priority: number }>( 'select priority from features where id = $1', - [created['id']], + [id], ); - expect(variantRows).toHaveLength(1); - expect(variantRows[0]!.priority).toBe(5); + expect(variantRows).toEqual([{ priority: 5 }]); }); }, timeouts.spinUpPpgDev,