Skip to content

Commit d2244a8

Browse files
committed
fix(sql-orm-client): replace bare as casts with castAs in buildManyToManyJunctionArtifacts; add M:N+distinct+non-leaf test
F1: the two bare `as AnyExpression` casts in `buildManyToManyJunctionArtifacts` are replaced with `castAs<AnyExpression>(…!)` — BinaryExpr is a union member of AnyExpression, so the assertion is type-checked; the non-null assertion is safe because the branch is only taken when `length === 1`. Adds the `castAs` import from `@prisma-next/utils/casts`. `lint:casts` delta: -1. F2: new unit test `attaches junction join to baseInner in M:N + distinct + nested non-leaf path` in the `M:N include correlated subquery` describe block. Constructs a contract with parents→children (M:N via parent_child junction) + children→grandchildren (FK), sets `distinct: ['name']` and a nested grandchild include on the M:N IncludeExpr, calls `compileSelectWithIncludes`, and asserts: - junction join (`INNER JOIN parent_child`, `lateral: false`) attaches to `baseInner` (the innermost scalar SELECT inside the ROW_NUMBER wrap), not to the dedup wrapper or outer distinct SELECT - correlated WHERE (`parent_child.parent_id = parents.id`) is present at `baseInner` - no junction join leaks to `innerSelect` or the outer `childRows` All 493 tests pass; typecheck clean. Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
1 parent 017a327 commit d2244a8

2 files changed

Lines changed: 205 additions & 2 deletions

File tree

packages/3-extensions/sql-orm-client/src/query-plan-select.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
} from '@prisma-next/sql-relational-core/ast';
2626
import { codecRefForStorageColumn } from '@prisma-next/sql-relational-core/codec-descriptor-registry';
2727
import type { SqlQueryPlan } from '@prisma-next/sql-relational-core/plan';
28+
import { castAs } from '@prisma-next/utils/casts';
2829
import { ifDefined } from '@prisma-next/utils/defined';
2930
import {
3031
type PolymorphismInfo,
@@ -317,7 +318,7 @@ function buildManyToManyJunctionArtifacts(
317318
),
318319
);
319320
const joinOn: AnyExpression =
320-
joinOnPairs.length === 1 ? (joinOnPairs[0] as AnyExpression) : AndExpr.of(joinOnPairs);
321+
joinOnPairs.length === 1 ? castAs<AnyExpression>(joinOnPairs[0]!) : AndExpr.of(joinOnPairs);
321322

322323
const correlationPairs = parentColumns.map((junctionCol, i) =>
323324
BinaryExpr.eq(
@@ -327,7 +328,7 @@ function buildManyToManyJunctionArtifacts(
327328
);
328329
const whereExpr: AnyExpression =
329330
correlationPairs.length === 1
330-
? (correlationPairs[0] as AnyExpression)
331+
? castAs<AnyExpression>(correlationPairs[0]!)
331332
: AndExpr.of(correlationPairs);
332333

333334
const junctionJoin = JoinAst.inner(TableSource.named(junctionTable), joinOn, false);

packages/3-extensions/sql-orm-client/test/query-plan-select.test.ts

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -775,6 +775,208 @@ describe('M:N include correlated subquery', () => {
775775
BinaryExpr.eq(ColumnRef.of('posts', 'user_id'), ColumnRef.of('users', 'id')),
776776
);
777777
});
778+
779+
// M:N + distinct(cols) + nested non-leaf include exercises
780+
// `buildDistinctNonLeafChildRowsSelect` which applies `junctionJoins` to
781+
// `baseInner` — the innermost scalar SELECT inside the ROW_NUMBER wrap.
782+
// This test verifies the junction join attaches to `baseInner` (not to the
783+
// dedup wrapper or the outer distinct SELECT) and that the correlated WHERE
784+
// is present at that same level.
785+
it('attaches junction join to baseInner in M:N + distinct + nested non-leaf path', () => {
786+
// Contract: parents -[M:N via parent_child]-> children (has `name` column),
787+
// children -[1:N FK]-> grandchildren (child_id → id).
788+
// We inline the contract to give `children` a `name` column for distinct.
789+
const contract = {
790+
domain: {
791+
namespaces: {
792+
public: {
793+
id: 'public',
794+
models: {
795+
Parent: {
796+
fields: { id: { nullable: false, type: { kind: 'scalar', codecId: 'pg/int4@1' } } },
797+
relations: {
798+
children: {
799+
to: { model: 'Child', namespace: 'public' },
800+
cardinality: 'N:M',
801+
on: { localFields: ['id'], targetFields: ['id'] },
802+
through: {
803+
table: 'parent_child',
804+
parentColumns: ['parent_id'],
805+
childColumns: ['child_id'],
806+
targetColumns: ['id'],
807+
},
808+
},
809+
},
810+
storage: { table: 'parents', fields: { id: { column: 'id' } } },
811+
},
812+
Child: {
813+
fields: {
814+
id: { nullable: false, type: { kind: 'scalar', codecId: 'pg/int4@1' } },
815+
name: { nullable: false, type: { kind: 'scalar', codecId: 'pg/text@1' } },
816+
},
817+
relations: {},
818+
storage: {
819+
table: 'children',
820+
fields: { id: { column: 'id' }, name: { column: 'name' } },
821+
},
822+
},
823+
Grandchild: {
824+
fields: {
825+
id: { nullable: false, type: { kind: 'scalar', codecId: 'pg/int4@1' } },
826+
child_id: { nullable: false, type: { kind: 'scalar', codecId: 'pg/int4@1' } },
827+
},
828+
relations: {},
829+
storage: {
830+
table: 'grandchildren',
831+
fields: {
832+
id: { column: 'id' },
833+
child_id: { column: 'child_id' },
834+
},
835+
},
836+
},
837+
},
838+
},
839+
},
840+
},
841+
storage: {
842+
namespaces: {
843+
public: {
844+
id: 'public',
845+
tables: {
846+
parents: {
847+
columns: { id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false } },
848+
primaryKey: { columns: ['id'] },
849+
uniques: [],
850+
indexes: [],
851+
foreignKeys: [],
852+
},
853+
children: {
854+
columns: {
855+
id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false },
856+
name: { nativeType: 'text', codecId: 'pg/text@1', nullable: false },
857+
},
858+
primaryKey: { columns: ['id'] },
859+
uniques: [],
860+
indexes: [],
861+
foreignKeys: [],
862+
},
863+
grandchildren: {
864+
columns: {
865+
id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false },
866+
child_id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false },
867+
},
868+
primaryKey: { columns: ['id'] },
869+
uniques: [],
870+
indexes: [],
871+
foreignKeys: [],
872+
},
873+
parent_child: {
874+
columns: {
875+
parent_id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false },
876+
child_id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false },
877+
},
878+
primaryKey: { columns: ['parent_id', 'child_id'] },
879+
uniques: [],
880+
indexes: [],
881+
foreignKeys: [],
882+
},
883+
},
884+
},
885+
},
886+
},
887+
capabilities: {},
888+
};
889+
890+
// Grandchild FK include: children.id → grandchildren.child_id
891+
const grandchildInclude: IncludeExpr = {
892+
relationName: 'grandchildren',
893+
relatedModelName: 'Grandchild',
894+
relatedTableName: 'grandchildren',
895+
targetColumn: 'child_id',
896+
localColumn: 'id',
897+
cardinality: '1:N',
898+
nested: emptyState(),
899+
scalar: undefined,
900+
combine: undefined,
901+
};
902+
903+
// M:N include: parents → children via parent_child, with distinct('name')
904+
// and a nested non-leaf grandchild include — exercises
905+
// buildDistinctNonLeafChildRowsSelect.
906+
const include: IncludeExpr = {
907+
relationName: 'children',
908+
relatedModelName: 'Child',
909+
relatedTableName: 'children',
910+
targetColumn: 'id',
911+
localColumn: 'id',
912+
cardinality: 'N:M',
913+
through: {
914+
table: 'parent_child',
915+
parentColumns: ['parent_id'],
916+
childColumns: ['child_id'],
917+
targetColumns: ['id'],
918+
parentLocalColumns: ['id'],
919+
},
920+
nested: {
921+
...emptyState(),
922+
distinct: ['name'],
923+
includes: [grandchildInclude],
924+
},
925+
scalar: undefined,
926+
combine: undefined,
927+
};
928+
929+
const state = { ...emptyState(), includes: [include] };
930+
// Cast: inline contract literal is structurally compatible but lacks
931+
// generated nominal types; the cast is local to this test.
932+
const plan = compileSelectWithIncludes(
933+
contract as unknown as Parameters<typeof compileSelectWithIncludes>[0],
934+
'parents',
935+
state,
936+
);
937+
938+
expectSelectAst(plan.ast);
939+
const childrenProjection = plan.ast.projection.find((item) => item.alias === 'children');
940+
expectSubqueryExpr(childrenProjection?.expr);
941+
942+
// Aggregate wrapper: FROM (children__rows)
943+
const aggregateQuery = childrenProjection.expr.query;
944+
expectDerivedTableSource(aggregateQuery.from);
945+
expect(aggregateQuery.from.alias).toBe('children__rows');
946+
947+
// Outer distinct SELECT: FROM (children__distinct)
948+
const childRows = aggregateQuery.from.query;
949+
expectDerivedTableSource(childRows.from);
950+
expect(childRows.from.alias).toBe('children__distinct');
951+
952+
// ROW_NUMBER dedup wrapper: FROM (children__ranked)
953+
const innerSelect = childRows.from.query;
954+
expectDerivedTableSource(innerSelect.from);
955+
expect(innerSelect.from.alias).toBe('children__ranked');
956+
957+
// baseInner: innermost scalar SELECT — junction join must be here
958+
const baseInner = innerSelect.from.query;
959+
960+
// Junction join attaches to baseInner, not the dedup wrapper or outer SELECT
961+
expect(baseInner.joins).toHaveLength(1);
962+
const junctionJoin = baseInner.joins![0]!;
963+
expect(junctionJoin.joinType).toBe('inner');
964+
expect(junctionJoin.lateral).toBe(false);
965+
expect(junctionJoin.source).toBeInstanceOf(TableSource);
966+
expect((junctionJoin.source as TableSource).name).toBe('parent_child');
967+
expect(junctionJoin.on).toEqual(
968+
BinaryExpr.eq(ColumnRef.of('parent_child', 'child_id'), ColumnRef.of('children', 'id')),
969+
);
970+
971+
// Correlated WHERE is present at baseInner level
972+
expect(baseInner.where).toEqual(
973+
BinaryExpr.eq(ColumnRef.of('parent_child', 'parent_id'), ColumnRef.of('parents', 'id')),
974+
);
975+
976+
// No junction join leaked to the dedup wrapper or outer distinct SELECT
977+
expect(innerSelect.joins ?? []).toHaveLength(0);
978+
expect(childRows.joins ?? []).toHaveLength(0);
979+
});
778980
});
779981

780982
describe('compileSelect MTI JOINs', () => {

0 commit comments

Comments
 (0)