Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
df99e8c
TML-2683: plan polymorphic .include() fix as a slice
tensordreams Jun 1, 2026
21551b8
TML-2683: scaffold orchestration artifacts (code-review, learnings, D…
tensordreams Jun 1, 2026
6ab5f04
test(sql-orm-client): poly-target include child SELECT joins + projec…
tensordreams Jun 1, 2026
bb42ab1
feat(sql-orm-client): emit polymorphism joins + projection in include…
tensordreams Jun 1, 2026
18f1b4d
test(sql-orm-client): widen parent-side MTI projection literals for n…
tensordreams Jun 1, 2026
a92b5c1
TML-2683: D2 brief (decode side)
tensordreams Jun 1, 2026
08171ac
test(sql-orm-client): decode poly-target include child rows to their …
tensordreams Jun 1, 2026
20b9637
feat(sql-orm-client): decode poly-target include child rows via mapPo…
tensordreams Jun 1, 2026
a1ccf16
test(sql-orm-client): build poly include via resolveIncludeRelation, …
tensordreams Jun 1, 2026
dd19454
TML-2683: D3 brief (.variant() narrowing surface)
tensordreams Jun 1, 2026
08b472a
test(sql-orm-client): poly-target include surfaces variant union, .va…
tensordreams Jun 1, 2026
4c8f2d4
feat(sql-orm-client): narrow poly-target includes to the variant unio…
tensordreams Jun 1, 2026
3c509c7
TML-2683: D4 brief (integration coverage)
tensordreams Jun 1, 2026
34becbd
test(integration): poly-target includes return variant-correct rows o…
tensordreams Jun 1, 2026
8e21579
TML-2683: amend spec/plan post-D4 — MTI variant-where (D5), PGlite-on…
tensordreams Jun 1, 2026
07c1b8d
TML-2683: D5 brief (MTI variant-field where)
tensordreams Jun 1, 2026
d5d9204
feat(sql-orm-client): variant-aware predicate accessor for MTI varian…
tensordreams Jun 1, 2026
a3c0fa4
TML-2683: record orderBy + SQLite follow-ups; slice close
tensordreams Jun 1, 2026
f49d61b
TML-2683: link orderBy follow-up (TML-2782); note SQLite under multi-…
tensordreams Jun 1, 2026
1506f21
tml-2683: whole-shape assertions for polymorphic include tests
tensordreams Jun 1, 2026
799d3dd
TML-2683: add sql-orm-client whole-shape-assertions rule + index + go…
tensordreams Jun 1, 2026
bd79c8b
TML-2683: D6/D7 briefs (poly test hardening + MTI relationship covera…
tensordreams Jun 1, 2026
abbaacd
test(sql-orm-client): harden polymorphism integration tests
tensordreams Jun 1, 2026
c41940f
test(sql-orm-client): cover MTI+relationship poly-include shapes
tensordreams Jun 1, 2026
e6c3158
TML-2683: D8 brief (fix nested include through poly target); record d…
tensordreams Jun 1, 2026
67d9910
fix(sql-orm-client): decode nested include through a poly target from…
tensordreams Jun 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/rules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ Rules below are listed by bare filename; the canonical file is `.agents/rules/<n
- `vitest-expect-typeof.mdc` — Type test patterns
- `test-mocking-patterns.mdc` — Test-only assertions and mocking patterns
- `prefer-object-matcher.mdc` — Prefer object matchers over multiple individual expect().toBe() calls
- `sql-orm-client-whole-shape-assertions.mdc` — In sql-orm-client tests, assert the whole result shape (`toEqual`/snapshot) with explicit `select`
- `prefer-to-throw.mdc` — Use `expect().toThrow()` instead of manual try/catch blocks
- `no-tautological-tests.mdc` — Avoid tests that only restate fixture input structure
- `use-ast-factories.mdc` — Use factory functions for creating AST nodes instead of manual object creation
Expand Down
81 changes: 81 additions & 0 deletions .agents/rules/sql-orm-client-whole-shape-assertions.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
---
description: Prefer whole-result-shape assertions with explicit select projections in sql-orm-client tests
globs: ["packages/3-extensions/sql-orm-client/**/*.test.ts", "test/integration/test/sql-orm-client/**/*.test.ts"]
alwaysApply: false
---

# Assert the whole result shape, with explicit projections

In `sql-orm-client` tests (unit and integration), assert the **entire** result with `toEqual`
(or an inline/file snapshot), and pin the projected columns with explicit `.select(...)` (varargs:
`.select('id', 'name')`) on the root collection **and** on every included relation. Order
deterministically with
`.orderBy((c) => c.<baseColumn>.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.
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
47 changes: 47 additions & 0 deletions packages/3-extensions/sql-orm-client/src/collection-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Record<string, VariantColumnRef>>
>();

/**
* 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<SqlStorage>,
baseModelName: string,
variantName: string,
): Record<string, VariantColumnRef> {
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<string, VariantColumnRef> = {};

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<SqlStorage>,
modelName: string,
Expand Down
20 changes: 18 additions & 2 deletions packages/3-extensions/sql-orm-client/src/collection-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) =>
mapPolymorphicRow(
contract,
include.relatedModelName,
polyInfo,
childRow,
include.nested.variantName,
)
: (childRow: Record<string, unknown>) =>
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;
Expand Down
38 changes: 27 additions & 11 deletions packages/3-extensions/sql-orm-client/src/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -115,6 +116,7 @@ import {
type RuntimeQueryable,
type ShorthandWhereFilter,
type UniqueConstraintCriterion,
type VariantAwareModelAccessor,
type VariantModelRow,
type VariantNames,
} from './types';
Expand Down Expand Up @@ -241,25 +243,39 @@ export class Collection<
* ```
*/
where(
fn: (model: ModelAccessor<TContract, ModelName>) => WhereDirectInput,
fn: (
model: VariantAwareModelAccessor<TContract, ModelName, State['variantName']>,
) => WhereDirectInput,
): Collection<TContract, ModelName, Row, WithWhereState<State>>;
where(input: WhereDirectInput): Collection<TContract, ModelName, Row, WithWhereState<State>>;
where(
fn: (model: ModelAccessor<TContract, ModelName>) => WhereArg,
fn: (model: VariantAwareModelAccessor<TContract, ModelName, State['variantName']>) => WhereArg,
): Collection<TContract, ModelName, Row, WithWhereState<State>>;
where(
filters: ShorthandWhereFilter<TContract, ModelName>,
): Collection<TContract, ModelName, Row, WithWhereState<State>>;
where(
input:
| WhereDirectInput
| ((model: ModelAccessor<TContract, ModelName>) => WhereDirectInput)
| ((model: ModelAccessor<TContract, ModelName>) => WhereArg)
| ((
model: VariantAwareModelAccessor<TContract, ModelName, State['variantName']>,
) => WhereDirectInput)
| ((model: VariantAwareModelAccessor<TContract, ModelName, State['variantName']>) => WhereArg)
| ShorthandWhereFilter<TContract, ModelName>,
): Collection<TContract, ModelName, Row, WithWhereState<State>> {
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<TContract, ModelName, State['variantName']>,
'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);
Expand Down Expand Up @@ -393,7 +409,7 @@ export class Collection<
> = IncludeRefinementCollection<
TContract,
RelatedName,
DefaultModelRow<TContract, RelatedName>,
SimplifyDeep<InferRootRow<TContract, RelatedName>>,
CollectionTypeState,
IsToMany
>,
Expand All @@ -403,7 +419,7 @@ export class Collection<
collection: IncludeRefinementCollection<
TContract,
RelatedName,
DefaultModelRow<TContract, RelatedName>,
SimplifyDeep<InferRootRow<TContract, RelatedName>>,
DefaultCollectionTypeState,
IsToMany
>,
Expand All @@ -417,7 +433,7 @@ export class Collection<
TContract,
ModelName,
K,
DefaultModelRow<TContract, RelatedName>,
SimplifyDeep<InferRootRow<TContract, RelatedName>>,
RefinedResult
>;
}
Expand All @@ -433,7 +449,7 @@ export class Collection<
if (refineFn) {
const nestedCollection = this.#createCollection<
RelatedName,
DefaultModelRow<TContract, RelatedName>,
SimplifyDeep<InferRootRow<TContract, RelatedName>>,
DefaultCollectionTypeState
>(relation.relatedModelName as RelatedName, {
tableName: relation.relatedTableName,
Expand All @@ -444,7 +460,7 @@ export class Collection<
nestedCollection as unknown as IncludeRefinementCollection<
TContract,
RelatedName,
DefaultModelRow<TContract, RelatedName>,
SimplifyDeep<InferRootRow<TContract, RelatedName>>,
DefaultCollectionTypeState,
IsToMany
>,
Expand Down Expand Up @@ -493,7 +509,7 @@ export class Collection<
TContract,
ModelName,
K,
DefaultModelRow<TContract, RelatedName>,
SimplifyDeep<InferRootRow<TContract, RelatedName>>,
RefinedResult
>;
}
Expand Down
27 changes: 22 additions & 5 deletions packages/3-extensions/sql-orm-client/src/model-accessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
resolveFieldToColumn,
resolveModelRelations,
resolveModelTableName,
resolveVariantFieldColumns,
type VariantColumnRef,
} from './collection-contract';
import { and, not } from './filters';
import {
Expand All @@ -40,11 +42,24 @@ type NamedOp = readonly [name: string, entry: SqlOperationEntry];
export function createModelAccessor<
TContract extends Contract<SqlStorage>,
ModelName extends string,
>(context: ExecutionContext<TContract>, modelName: ModelName): ModelAccessor<TContract, ModelName> {
>(
context: ExecutionContext<TContract>,
modelName: ModelName,
variantName?: string,
): ModelAccessor<TContract, ModelName> {
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<string, VariantColumnRef> = variantName
? resolveVariantFieldColumns(contract, modelName, variantName)
: {};

const opsByCodecId = new Map<string, NamedOp[]>();

Expand Down Expand Up @@ -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<TContract, ModelName>` type already rejects typos
// at compile time for TS consumers, and contexts that iterate accessor
Expand All @@ -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,
Expand Down
Loading
Loading