Skip to content

Commit f9226cc

Browse files
committed
feat(sql-orm-client): M:N relation filters via junction EXISTS
Add some/every/none filter support for many-to-many relations, generating correlated EXISTS/NOT EXISTS subqueries that join through the junction table. Both correlation sides are correctly threaded: - junction→target: JOIN junction ON junction.childColumns = target.targetColumns - junction→parent: WHERE junction.parentColumns = parent.{localFields→columns} (parent anchor columns resolved via resolveFieldToColumn, mirroring slice-1 read path in buildManyToManyJunctionArtifacts) Shapes: - some → EXISTS(SELECT 1 FROM target JOIN junction ON <j→t> WHERE <j→parent> [AND pred]) - none → NOT EXISTS(… [AND pred]) - every → NOT EXISTS(… AND NOT(pred)); vacuously true (AndExpr.true()) when no pred Composite keys are AND-ed across all column pairs via buildPairedColumnExprs. A hasThrough type guard narrows ResolvedModelRelation at the dispatch site, keeping the narrowed type in buildManyToManyExistsExpr explicit without bare casts. Unit tests cover single-key and composite-key junction shapes for all three modes, plus AND-ed predicate variants and the vacuous-true edge case. FK relation filter tests are unaffected. Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
1 parent 9c59b57 commit f9226cc

2 files changed

Lines changed: 283 additions & 1 deletion

File tree

packages/3-extensions/sql-orm-client/src/model-accessor.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
type CodecRef,
99
ColumnRef,
1010
ExistsExpr,
11+
JoinAst,
1112
ProjectionItem,
1213
SelectAst,
1314
TableSource,
@@ -30,6 +31,13 @@ import {
3031
} from './types';
3132

3233
type ResolvedModelRelation = ReturnType<typeof resolveModelRelations>[string];
34+
type ResolvedModelRelationWithThrough = ResolvedModelRelation & {
35+
through: NonNullable<ResolvedModelRelation['through']>;
36+
};
37+
38+
function hasThrough(relation: ResolvedModelRelation): relation is ResolvedModelRelationWithThrough {
39+
return relation.through !== undefined;
40+
}
3341

3442
type RelationPredicateInput<TContract extends Contract<SqlStorage>, ModelName extends string> =
3543
| ((model: ModelAccessor<TContract, ModelName>) => AnyExpression)
@@ -230,6 +238,17 @@ function buildExistsExpr<TContract extends Contract<SqlStorage>>(
230238
readonly predicate: RelationPredicateInput<TContract, string> | undefined;
231239
},
232240
): AnyExpression {
241+
if (hasThrough(relation)) {
242+
return buildManyToManyExistsExpr(
243+
context,
244+
parentModelName,
245+
parentTableName,
246+
relatedTableName,
247+
relation,
248+
options,
249+
);
250+
}
251+
233252
const joinWhere = buildJoinWhere(
234253
context.contract,
235254
parentModelName,
@@ -267,6 +286,89 @@ function buildExistsExpr<TContract extends Contract<SqlStorage>>(
267286
return existsNot ? ExistsExpr.notExists(subquery) : ExistsExpr.exists(subquery);
268287
}
269288

289+
function buildManyToManyExistsExpr<TContract extends Contract<SqlStorage>>(
290+
context: ExecutionContext<TContract>,
291+
parentModelName: string,
292+
parentTableName: string,
293+
relatedTableName: string,
294+
relation: ResolvedModelRelationWithThrough,
295+
options: {
296+
readonly mode: 'some' | 'every' | 'none';
297+
readonly predicate: RelationPredicateInput<TContract, string> | undefined;
298+
},
299+
): AnyExpression {
300+
const { through } = relation;
301+
const junctionTable = through.table;
302+
303+
const junctionJoinOn = buildPairedColumnExprs(
304+
junctionTable,
305+
through.childColumns,
306+
relatedTableName,
307+
through.targetColumns,
308+
);
309+
310+
const parentLocalColumns = relation.on.localFields.map((field) =>
311+
resolveFieldToColumn(context.contract, parentModelName, field),
312+
);
313+
const junctionCorrelation = buildPairedColumnExprs(
314+
junctionTable,
315+
through.parentColumns,
316+
parentTableName,
317+
parentLocalColumns,
318+
);
319+
320+
const childWhere = toRelationWhereExpr(context, relation.to, options.predicate);
321+
322+
let subqueryWhere: AnyExpression = junctionCorrelation;
323+
let existsNot = false;
324+
325+
if (options.mode === 'every') {
326+
if (!childWhere) {
327+
return AndExpr.true();
328+
}
329+
existsNot = true;
330+
subqueryWhere = and(junctionCorrelation, not(childWhere));
331+
} else if (options.mode === 'none') {
332+
existsNot = true;
333+
if (childWhere) {
334+
subqueryWhere = and(junctionCorrelation, childWhere);
335+
}
336+
} else if (childWhere) {
337+
subqueryWhere = and(junctionCorrelation, childWhere);
338+
}
339+
340+
const firstTargetCol = through.targetColumns[0] ?? 'id';
341+
const subquery = SelectAst.from(TableSource.named(relatedTableName))
342+
.withJoins([JoinAst.inner(TableSource.named(junctionTable), junctionJoinOn)])
343+
.withProjection([ProjectionItem.of('_exists', ColumnRef.of(relatedTableName, firstTargetCol))])
344+
.withWhere(subqueryWhere);
345+
346+
return existsNot ? ExistsExpr.notExists(subquery) : ExistsExpr.exists(subquery);
347+
}
348+
349+
function buildPairedColumnExprs(
350+
leftTable: string,
351+
leftColumns: readonly string[],
352+
rightTable: string,
353+
rightColumns: readonly string[],
354+
): AnyExpression {
355+
const count = Math.min(leftColumns.length, rightColumns.length);
356+
if (count === 0) {
357+
throw new Error('Relation metadata is missing join columns');
358+
}
359+
const exprs: AnyExpression[] = [];
360+
for (let i = 0; i < count; i++) {
361+
const left = leftColumns[i];
362+
const right = rightColumns[i];
363+
if (!left || !right) continue;
364+
exprs.push(BinaryExpr.eq(ColumnRef.of(leftTable, left), ColumnRef.of(rightTable, right)));
365+
}
366+
if (exprs.length === 1 && exprs[0]) {
367+
return exprs[0];
368+
}
369+
return and(...exprs);
370+
}
371+
270372
function toRelationWhereExpr<TContract extends Contract<SqlStorage>>(
271373
context: ExecutionContext<TContract>,
272374
relatedModelName: string,

packages/3-extensions/sql-orm-client/test/model-accessor.test.ts

Lines changed: 181 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
BinaryExpr,
66
ColumnRef,
77
ExistsExpr,
8+
JoinAst,
89
ListExpression,
910
NotExpr,
1011
NullCheckExpr,
@@ -17,7 +18,12 @@ import {
1718
} from '@prisma-next/sql-relational-core/ast';
1819
import { describe, expect, it } from 'vitest';
1920
import { createModelAccessor } from '../src/model-accessor';
20-
import { getTestContext, getTestContract, withPatchedDomainModels } from './helpers';
21+
import {
22+
buildManyToManyContract,
23+
getTestContext,
24+
getTestContract,
25+
withPatchedDomainModels,
26+
} from './helpers';
2127
import { unboundTables } from './unbound-tables';
2228

2329
describe('createModelAccessor', () => {
@@ -479,6 +485,180 @@ describe('createModelAccessor', () => {
479485
});
480486
});
481487

488+
describe('M:N relation filters via junction', () => {
489+
it('some() emits EXISTS through junction (single-key)', () => {
490+
const contract = buildManyToManyContract({
491+
junctionTable: 'parent_children',
492+
parentColumns: ['parent_id'],
493+
childColumns: ['child_id'],
494+
targetColumns: ['id'],
495+
});
496+
const accessor = createModelAccessor(
497+
{ ...getTestContext(), contract } as never,
498+
'Parent',
499+
) as unknown as Record<string, { some: (pred?: unknown) => unknown }>;
500+
501+
const expr = accessor['children']!.some() as ExistsExpr;
502+
503+
expect(expr.notExists).toBe(false);
504+
expect(expr.subquery.from).toEqual(TableSource.named('children'));
505+
expect(expr.subquery.joins).toEqual([
506+
JoinAst.inner(
507+
TableSource.named('parent_children'),
508+
BinaryExpr.eq(
509+
ColumnRef.of('parent_children', 'child_id'),
510+
ColumnRef.of('children', 'id'),
511+
),
512+
),
513+
]);
514+
expect(expr.subquery.where).toEqual(
515+
BinaryExpr.eq(ColumnRef.of('parent_children', 'parent_id'), ColumnRef.of('parents', 'id')),
516+
);
517+
});
518+
519+
it('some(pred) AND-s junction correlation with predicate', () => {
520+
const contract = buildManyToManyContract({
521+
junctionTable: 'parent_children',
522+
parentColumns: ['parent_id'],
523+
childColumns: ['child_id'],
524+
targetColumns: ['id'],
525+
});
526+
const accessor = createModelAccessor(
527+
{ ...getTestContext(), contract } as never,
528+
'Parent',
529+
) as unknown as Record<string, { some: (pred: (c: unknown) => unknown) => unknown }>;
530+
531+
const expr = accessor['children']!.some((c: unknown) =>
532+
(c as Record<string, { eq: (v: unknown) => unknown }>)['id']!.eq(42),
533+
) as ExistsExpr;
534+
535+
expect(expr.notExists).toBe(false);
536+
expect(expr.subquery.where).toEqual(
537+
AndExpr.of([
538+
BinaryExpr.eq(
539+
ColumnRef.of('parent_children', 'parent_id'),
540+
ColumnRef.of('parents', 'id'),
541+
),
542+
BinaryExpr.eq(
543+
ColumnRef.of('children', 'id'),
544+
ParamRef.of(42, { codec: { codecId: 'pg/int4@1' } }),
545+
),
546+
]),
547+
);
548+
});
549+
550+
it('none() emits NOT EXISTS through junction', () => {
551+
const contract = buildManyToManyContract({
552+
junctionTable: 'parent_children',
553+
parentColumns: ['parent_id'],
554+
childColumns: ['child_id'],
555+
targetColumns: ['id'],
556+
});
557+
const accessor = createModelAccessor(
558+
{ ...getTestContext(), contract } as never,
559+
'Parent',
560+
) as unknown as Record<string, { none: (pred?: unknown) => unknown }>;
561+
562+
const expr = accessor['children']!.none() as ExistsExpr;
563+
expect(expr.notExists).toBe(true);
564+
expect(expr.subquery.where).toEqual(
565+
BinaryExpr.eq(ColumnRef.of('parent_children', 'parent_id'), ColumnRef.of('parents', 'id')),
566+
);
567+
});
568+
569+
it('every(pred) emits NOT EXISTS(… AND NOT(pred)) through junction', () => {
570+
const contract = buildManyToManyContract({
571+
junctionTable: 'parent_children',
572+
parentColumns: ['parent_id'],
573+
childColumns: ['child_id'],
574+
targetColumns: ['id'],
575+
});
576+
const accessor = createModelAccessor(
577+
{ ...getTestContext(), contract } as never,
578+
'Parent',
579+
) as unknown as Record<string, { every: (pred: (c: unknown) => unknown) => unknown }>;
580+
581+
const expr = accessor['children']!.every((c: unknown) =>
582+
(c as Record<string, { eq: (v: unknown) => unknown }>)['id']!.eq(99),
583+
) as ExistsExpr;
584+
585+
expect(expr.notExists).toBe(true);
586+
expect(expr.subquery.where).toEqual(
587+
AndExpr.of([
588+
BinaryExpr.eq(
589+
ColumnRef.of('parent_children', 'parent_id'),
590+
ColumnRef.of('parents', 'id'),
591+
),
592+
new NotExpr(
593+
BinaryExpr.eq(
594+
ColumnRef.of('children', 'id'),
595+
ParamRef.of(99, { codec: { codecId: 'pg/int4@1' } }),
596+
),
597+
),
598+
]),
599+
);
600+
});
601+
602+
it('every({}) is vacuously true for M:N relations', () => {
603+
const contract = buildManyToManyContract({
604+
junctionTable: 'parent_children',
605+
parentColumns: ['parent_id'],
606+
childColumns: ['child_id'],
607+
targetColumns: ['id'],
608+
});
609+
const accessor = createModelAccessor(
610+
{ ...getTestContext(), contract } as never,
611+
'Parent',
612+
) as unknown as Record<string, { every: (pred: unknown) => unknown }>;
613+
614+
expect(accessor['children']!.every({})).toEqual(AndExpr.true());
615+
});
616+
617+
it('some() emits EXISTS with composite-key AND-ed junction join', () => {
618+
const contract = buildManyToManyContract({
619+
junctionTable: 'parent_children',
620+
parentColumns: ['tenant_id', 'parent_id'],
621+
childColumns: ['tenant_id', 'child_id'],
622+
targetColumns: ['tenant_id', 'id'],
623+
localFields: ['tenant_id', 'id'],
624+
});
625+
const accessor = createModelAccessor(
626+
{ ...getTestContext(), contract } as never,
627+
'Parent',
628+
) as unknown as Record<string, { some: () => unknown }>;
629+
630+
const expr = accessor['children']!.some() as ExistsExpr;
631+
632+
expect(expr.subquery.joins).toEqual([
633+
JoinAst.inner(
634+
TableSource.named('parent_children'),
635+
AndExpr.of([
636+
BinaryExpr.eq(
637+
ColumnRef.of('parent_children', 'tenant_id'),
638+
ColumnRef.of('children', 'tenant_id'),
639+
),
640+
BinaryExpr.eq(
641+
ColumnRef.of('parent_children', 'child_id'),
642+
ColumnRef.of('children', 'id'),
643+
),
644+
]),
645+
),
646+
]);
647+
expect(expr.subquery.where).toEqual(
648+
AndExpr.of([
649+
BinaryExpr.eq(
650+
ColumnRef.of('parent_children', 'tenant_id'),
651+
ColumnRef.of('parents', 'tenant_id'),
652+
),
653+
BinaryExpr.eq(
654+
ColumnRef.of('parent_children', 'parent_id'),
655+
ColumnRef.of('parents', 'id'),
656+
),
657+
]),
658+
);
659+
});
660+
});
661+
482662
describe('extension operations', () => {
483663
it('attaches trait-targeted op only when codec traits are a superset of required traits', () => {
484664
const queryOperations = createSqlOperationRegistry();

0 commit comments

Comments
 (0)