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
108 changes: 108 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,8 +8,10 @@ import {
type CodecRef,
ColumnRef,
ExistsExpr,
JoinAst,
ProjectionItem,
SelectAst,
TableSource,
} from '@prisma-next/sql-relational-core/ast';
import { codecRefForStorageColumn } from '@prisma-next/sql-relational-core/codec-descriptor-registry';
import type { Expression, ScopeField } from '@prisma-next/sql-relational-core/expression';
Expand All @@ -30,6 +32,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 @@ -233,6 +242,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 @@ -270,6 +290,94 @@ 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(tableSourceForContract(context.contract, relatedTableName))
.withJoins([
JoinAst.inner(
TableSource.named(junctionTable, undefined, through.namespaceId),
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
1 change: 1 addition & 0 deletions packages/3-extensions/sql-orm-client/test/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,7 @@ export function buildManyToManyContract(opts: {
on: { localFields, targetFields: targetColumns },
through: {
table: junctionTable,
namespaceId: 'public',
parentColumns,
childColumns,
targetColumns,
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', undefined, 'public'));
expect(expr.subquery.joins).toEqual([
JoinAst.inner(
TableSource.named('parent_children', undefined, 'public'),
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', undefined, 'public'),
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.
Loading
Loading