Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
102 changes: 102 additions & 0 deletions packages/3-extensions/sql-orm-client/src/model-accessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type CodecRef,
ColumnRef,
ExistsExpr,
JoinAst,
ProjectionItem,
SelectAst,
TableSource,
Expand All @@ -30,6 +31,13 @@ import {
} from './types';

type ResolvedModelRelation = ReturnType<typeof resolveModelRelations>[string];
type ResolvedModelRelationWithThrough = ResolvedModelRelation & {
through: NonNullable<ResolvedModelRelation['through']>;
};

function hasThrough(relation: ResolvedModelRelation): relation is ResolvedModelRelationWithThrough {
return relation.through !== undefined;
}

type RelationPredicateInput<TContract extends Contract<SqlStorage>, ModelName extends string> =
| ((model: ModelAccessor<TContract, ModelName>) => AnyExpression)
Expand Down Expand Up @@ -230,6 +238,17 @@ function buildExistsExpr<TContract extends Contract<SqlStorage>>(
readonly predicate: RelationPredicateInput<TContract, string> | undefined;
},
): AnyExpression {
if (hasThrough(relation)) {
return buildManyToManyExistsExpr(
context,
parentModelName,
parentTableName,
relatedTableName,
relation,
options,
);
}

const joinWhere = buildJoinWhere(
context.contract,
parentModelName,
Expand Down Expand Up @@ -267,6 +286,89 @@ function buildExistsExpr<TContract extends Contract<SqlStorage>>(
return existsNot ? ExistsExpr.notExists(subquery) : ExistsExpr.exists(subquery);
}

function buildManyToManyExistsExpr<TContract extends Contract<SqlStorage>>(
context: ExecutionContext<TContract>,
parentModelName: string,
parentTableName: string,
relatedTableName: string,
relation: ResolvedModelRelationWithThrough,
options: {
readonly mode: 'some' | 'every' | 'none';
readonly predicate: RelationPredicateInput<TContract, string> | undefined;
},
): AnyExpression {
const { through } = relation;
const junctionTable = through.table;

const junctionJoinOn = buildPairedColumnExprs(
junctionTable,
through.childColumns,
relatedTableName,
through.targetColumns,
);

const parentLocalColumns = relation.on.localFields.map((field) =>
resolveFieldToColumn(context.contract, parentModelName, field),
);
const junctionCorrelation = buildPairedColumnExprs(
junctionTable,
through.parentColumns,
parentTableName,
parentLocalColumns,
);

const childWhere = toRelationWhereExpr(context, relation.to, options.predicate);

let subqueryWhere: AnyExpression = junctionCorrelation;
let existsNot = false;

if (options.mode === 'every') {
if (!childWhere) {
return AndExpr.true();
}
existsNot = true;
subqueryWhere = and(junctionCorrelation, not(childWhere));
} else if (options.mode === 'none') {
existsNot = true;
if (childWhere) {
subqueryWhere = and(junctionCorrelation, childWhere);
}
} else if (childWhere) {
subqueryWhere = and(junctionCorrelation, childWhere);
}

const firstTargetCol = through.targetColumns[0] ?? 'id';
const subquery = SelectAst.from(TableSource.named(relatedTableName))
.withJoins([JoinAst.inner(TableSource.named(junctionTable), junctionJoinOn)])
.withProjection([ProjectionItem.of('_exists', ColumnRef.of(relatedTableName, firstTargetCol))])
.withWhere(subqueryWhere);

return existsNot ? ExistsExpr.notExists(subquery) : ExistsExpr.exists(subquery);
}

function buildPairedColumnExprs(
leftTable: string,
leftColumns: readonly string[],
rightTable: string,
rightColumns: readonly string[],
): AnyExpression {
const count = Math.min(leftColumns.length, rightColumns.length);
if (count === 0) {
throw new Error('Relation metadata is missing join columns');
}
const exprs: AnyExpression[] = [];
for (let i = 0; i < count; i++) {
const left = leftColumns[i];
const right = rightColumns[i];
if (!left || !right) continue;
exprs.push(BinaryExpr.eq(ColumnRef.of(leftTable, left), ColumnRef.of(rightTable, right)));
}
if (exprs.length === 1 && exprs[0]) {
return exprs[0];
}
return and(...exprs);
}

function toRelationWhereExpr<TContract extends Contract<SqlStorage>>(
context: ExecutionContext<TContract>,
relatedModelName: string,
Expand Down
182 changes: 181 additions & 1 deletion packages/3-extensions/sql-orm-client/test/model-accessor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
BinaryExpr,
ColumnRef,
ExistsExpr,
JoinAst,
ListExpression,
NotExpr,
NullCheckExpr,
Expand All @@ -17,7 +18,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 {
buildManyToManyContract,
getTestContext,
getTestContract,
withPatchedDomainModels,
} from './helpers';
import { unboundTables } from './unbound-tables';

describe('createModelAccessor', () => {
Expand Down Expand Up @@ -479,6 +485,180 @@ describe('createModelAccessor', () => {
});
});

describe('M:N relation filters via junction', () => {
it('some() emits EXISTS through junction (single-key)', () => {
const contract = buildManyToManyContract({
junctionTable: 'parent_children',
parentColumns: ['parent_id'],
childColumns: ['child_id'],
targetColumns: ['id'],
});
const accessor = createModelAccessor(
{ ...getTestContext(), contract } as never,
'Parent',
) as unknown as Record<string, { some: (pred?: unknown) => unknown }>;

const expr = accessor['children']!.some() as ExistsExpr;

expect(expr.notExists).toBe(false);
expect(expr.subquery.from).toEqual(TableSource.named('children'));
expect(expr.subquery.joins).toEqual([
JoinAst.inner(
TableSource.named('parent_children'),
BinaryExpr.eq(
ColumnRef.of('parent_children', 'child_id'),
ColumnRef.of('children', 'id'),
),
),
]);
expect(expr.subquery.where).toEqual(
BinaryExpr.eq(ColumnRef.of('parent_children', 'parent_id'), ColumnRef.of('parents', 'id')),
);
});

it('some(pred) AND-s junction correlation with predicate', () => {
const contract = buildManyToManyContract({
junctionTable: 'parent_children',
parentColumns: ['parent_id'],
childColumns: ['child_id'],
targetColumns: ['id'],
});
const accessor = createModelAccessor(
{ ...getTestContext(), contract } as never,
'Parent',
) as unknown as Record<string, { some: (pred: (c: unknown) => unknown) => unknown }>;

const expr = accessor['children']!.some((c: unknown) =>
(c as Record<string, { eq: (v: unknown) => unknown }>)['id']!.eq(42),
) as ExistsExpr;

expect(expr.notExists).toBe(false);
expect(expr.subquery.where).toEqual(
AndExpr.of([
BinaryExpr.eq(
ColumnRef.of('parent_children', 'parent_id'),
ColumnRef.of('parents', 'id'),
),
BinaryExpr.eq(
ColumnRef.of('children', 'id'),
ParamRef.of(42, { codec: { codecId: 'pg/int4@1' } }),
),
]),
);
});

it('none() emits NOT EXISTS through junction', () => {
const contract = buildManyToManyContract({
junctionTable: 'parent_children',
parentColumns: ['parent_id'],
childColumns: ['child_id'],
targetColumns: ['id'],
});
const accessor = createModelAccessor(
{ ...getTestContext(), contract } as never,
'Parent',
) as unknown as Record<string, { none: (pred?: unknown) => unknown }>;

const expr = accessor['children']!.none() as ExistsExpr;
expect(expr.notExists).toBe(true);
expect(expr.subquery.where).toEqual(
BinaryExpr.eq(ColumnRef.of('parent_children', 'parent_id'), ColumnRef.of('parents', 'id')),
);
});

it('every(pred) emits NOT EXISTS(… AND NOT(pred)) through junction', () => {
const contract = buildManyToManyContract({
junctionTable: 'parent_children',
parentColumns: ['parent_id'],
childColumns: ['child_id'],
targetColumns: ['id'],
});
const accessor = createModelAccessor(
{ ...getTestContext(), contract } as never,
'Parent',
) as unknown as Record<string, { every: (pred: (c: unknown) => unknown) => unknown }>;

const expr = accessor['children']!.every((c: unknown) =>
(c as Record<string, { eq: (v: unknown) => unknown }>)['id']!.eq(99),
) as ExistsExpr;

expect(expr.notExists).toBe(true);
expect(expr.subquery.where).toEqual(
AndExpr.of([
BinaryExpr.eq(
ColumnRef.of('parent_children', 'parent_id'),
ColumnRef.of('parents', 'id'),
),
new NotExpr(
BinaryExpr.eq(
ColumnRef.of('children', 'id'),
ParamRef.of(99, { codec: { codecId: 'pg/int4@1' } }),
),
),
]),
);
});

it('every({}) is vacuously true for M:N relations', () => {
const contract = buildManyToManyContract({
junctionTable: 'parent_children',
parentColumns: ['parent_id'],
childColumns: ['child_id'],
targetColumns: ['id'],
});
const accessor = createModelAccessor(
{ ...getTestContext(), contract } as never,
'Parent',
) as unknown as Record<string, { every: (pred: unknown) => unknown }>;

expect(accessor['children']!.every({})).toEqual(AndExpr.true());
});

it('some() emits EXISTS with composite-key AND-ed junction join', () => {
const contract = buildManyToManyContract({
junctionTable: 'parent_children',
parentColumns: ['tenant_id', 'parent_id'],
childColumns: ['tenant_id', 'child_id'],
targetColumns: ['tenant_id', 'id'],
localFields: ['tenant_id', 'id'],
});
const accessor = createModelAccessor(
{ ...getTestContext(), contract } as never,
'Parent',
) as unknown as Record<string, { some: () => unknown }>;

const expr = accessor['children']!.some() as ExistsExpr;

expect(expr.subquery.joins).toEqual([
JoinAst.inner(
TableSource.named('parent_children'),
AndExpr.of([
BinaryExpr.eq(
ColumnRef.of('parent_children', 'tenant_id'),
ColumnRef.of('children', 'tenant_id'),
),
BinaryExpr.eq(
ColumnRef.of('parent_children', 'child_id'),
ColumnRef.of('children', 'id'),
),
]),
),
]);
expect(expr.subquery.where).toEqual(
AndExpr.of([
BinaryExpr.eq(
ColumnRef.of('parent_children', 'tenant_id'),
ColumnRef.of('parents', 'tenant_id'),
),
BinaryExpr.eq(
ColumnRef.of('parent_children', 'parent_id'),
ColumnRef.of('parents', 'id'),
),
]),
);
});
});

describe('extension operations', () => {
it('attaches trait-targeted op only when codec traits are a superset of required traits', () => {
const queryOperations = createSqlOperationRegistry();
Expand Down
2 changes: 2 additions & 0 deletions projects/sql-orm-many-to-many/learnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,5 @@ Running the whole sql-orm-client integration suite at once (`cd test/integration
## Dispatch truncation recovery (no subagent resume)

A substantial dispatch can exhaust the implementer's budget mid-work and return a truncated report with **uncommitted WIP** (happened on the slice-1 read path). Recovery: inspect `git status`/`git diff`, then dispatch a fresh continuation implementer pointed at the WIP with a focused completion brief (it commits the WIP + completion as one commit). Keep dispatches tight and tell implementers to implement-then-test-then-gate rather than over-explore (over-exploration is what burned the budget).

**Recurrence (2×):** both the slice-1 read-path dispatch and the slice-2 filter dispatch (the "junction-correlation code + unit tests" judgment dispatches) truncated around 70–135k implementer tokens. The combination of (read corpus) + (design the SQL shape) + (write + iterate tests) reliably exceeds one sonnet budget here. The continue-from-WIP recovery works every time, but for future projects of this shape, consider splitting "implement the builder/accessor branch" and "write its unit tests" into two dispatches, or routing these to a higher-budget tier.
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Brief: S2-D1 — filter EXISTS walks the junction

## Task

Teach the relation-filter accessor to walk the junction for M:N relations. `db.orm.User.filter((u) => u.tags.some/every/none((t) => …))` must emit an EXISTS / NOT EXISTS subquery that goes through the `UserTag` junction. Today `buildJoinWhere` (`packages/3-extensions/sql-orm-client/src/model-accessor.ts`) reads only `relation.on.localFields/targetFields` — for an M:N relation that emits a wrong-shape EXISTS that skips the junction.

When the resolved relation carries `through` (slice 0 added it to `resolveModelRelations`'s output), build the M:N shape in `buildExistsExpr`/`buildJoinWhere`:
- **`some(pred)`** → `EXISTS (SELECT 1 FROM target JOIN junction ON junction.childColumns = target.targetColumns WHERE junction.parentColumns = parent.anchor AND <pred>)`.
- **`none(pred)`** → `NOT EXISTS (… AND <pred>)`.
- **`every(pred)`** → `NOT EXISTS (… AND NOT(<pred>))` — mirror the existing FK `every` shape, just through the junction.

The parent correlation is on the **junction** side; the target is reached via the junction join; composite keys AND-ed across all pairs. The child predicate is unchanged.

**First confirm** the relation reaching `buildJoinWhere` carries `through` — it should, via `resolveModelRelations` (slice 0). If the filter path uses a relation shape that drops `through`, surface it onto that path (one field; mirror how slice 1 surfaced `through` onto `IncludeExpr`).

**Write unit tests first** asserting the compiled EXISTS AST for `some`/`every`/`none` on an M:N relation joins through the junction (composite-key AND-ed), and that FK relation filters are unchanged.

## Scope

**In:** the M:N branch in `buildExistsExpr`/`buildJoinWhere` (`model-accessor.ts`) for some/every/none; surfacing `through` onto the filter relation if dropped; unit tests for the EXISTS AST.

**Out:** integration tests (S2-D2); include reads (slice 1); nested writes (slice 3); the `through` shape (slice 0). Don't regress FK filters.

## Completed when

- [ ] `some`/`every`/`none` on an M:N relation compile to a correctly-shaped EXISTS/NOT EXISTS through the junction (composite-key AND-ed); unit test asserts the AST.
- [ ] FK relation filters unchanged (existing model-accessor unit tests pass).
- [ ] Gate: `pnpm --filter @prisma-next/sql-orm-client typecheck` + `test` green.

## Standing instruction

Stay focused on the junction EXISTS. The judgment site is the junction hop in `buildJoinWhere` and the `every` = `NOT EXISTS(… NOT(pred))` shape; mirror the FK path. No bare `as` casts (use `castAs`/`blindCast` if unavoidable — a sibling slice was bounced for bare casts twice; don't add new ones).

## References

- Slice spec: `projects/sql-orm-many-to-many/slices/02-filter-exists-through-junction/spec.md`.
- `model-accessor.ts`: `createRelationFilterAccessor` (~190), `buildExistsExpr` (~222), `buildJoinWhere` (~331) — the FK path to extend.
- Slice 0 `ResolvedRelation.through` in `collection-contract.ts`.

## Operational metadata

- **Model tier:** sonnet — bounded judgment (the junction EXISTS + every/none shapes).
- **Branch:** `tml-2786-slice-2-filter`. Explicit staging + `-s` sign-off. **Do not push.**
- **Time-box:** ~75 min — implement `some` first + its test, then `none`/`every`, then gate; don't over-explore.
- **Halt + surface to me:** if `buildJoinWhere`'s EXISTS construction can't host the junction join without a structural change beyond the FK path's shape (surface the obstacle); if `through` is genuinely unavailable on the filter relation and surfacing it touches an out-of-scope consumer.
Loading
Loading