Skip to content

Commit 6f1e1b6

Browse files
committed
feat(sql-orm-client): surface through descriptor on ResolvedRelation for M:N relations
Add ResolvedThrough interface and populate through on ResolvedRelation when the contract relation carries a through object (cardinality N:M). The resolver reads table, parentColumns, childColumns, and targetColumns directly from the contract, then derives requiredPayloadColumns by inspecting the junction table's storage columns: any column that is NOT NULL, has no default, and is not in parentColumns ∪ childColumns is required payload that the caller must supply when creating a junction row. Four resolver unit test cases: simple single-column FK, composite-key junction, junction with required non-FK payload columns, and junction with only nullable/defaulted extra columns. Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
1 parent f482cd8 commit 6f1e1b6

2 files changed

Lines changed: 230 additions & 0 deletions

File tree

packages/3-extensions/sql-orm-client/src/collection-contract.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,13 +196,22 @@ export function getCompleteColumnToFieldMap(
196196
return cached;
197197
}
198198

199+
interface ResolvedThrough {
200+
readonly table: string;
201+
readonly parentColumns: readonly string[];
202+
readonly childColumns: readonly string[];
203+
readonly targetColumns: readonly string[];
204+
readonly requiredPayloadColumns: readonly string[];
205+
}
206+
199207
interface ResolvedRelation {
200208
readonly to: string;
201209
readonly cardinality: RelationCardinalityTag | undefined;
202210
readonly on: {
203211
readonly localFields: readonly string[];
204212
readonly targetFields: readonly string[];
205213
};
214+
readonly through?: ResolvedThrough;
206215
}
207216

208217
export interface ResolvedIncludeRelation {
@@ -244,6 +253,46 @@ export function resolveIncludeRelation(
244253
};
245254
}
246255

256+
function resolveThrough(
257+
contract: Contract<SqlStorage>,
258+
raw:
259+
| { table?: unknown; parentColumns?: unknown; childColumns?: unknown; targetColumns?: unknown }
260+
| undefined,
261+
): ResolvedThrough | undefined {
262+
if (!raw) return undefined;
263+
const { table, parentColumns, childColumns, targetColumns } = raw;
264+
if (
265+
typeof table !== 'string' ||
266+
!Array.isArray(parentColumns) ||
267+
!Array.isArray(childColumns) ||
268+
!Array.isArray(targetColumns)
269+
) {
270+
return undefined;
271+
}
272+
273+
const fkColumnSet = new Set<string>([
274+
...(parentColumns as string[]),
275+
...(childColumns as string[]),
276+
]);
277+
const junctionTable = unboundTable(contract, table);
278+
const requiredPayloadColumns: string[] = [];
279+
if (junctionTable) {
280+
for (const [colName, col] of Object.entries(junctionTable.columns)) {
281+
if (!fkColumnSet.has(colName) && !col.nullable && col.default === undefined) {
282+
requiredPayloadColumns.push(colName);
283+
}
284+
}
285+
}
286+
287+
return {
288+
table,
289+
parentColumns: parentColumns as readonly string[],
290+
childColumns: childColumns as readonly string[],
291+
targetColumns: targetColumns as readonly string[],
292+
requiredPayloadColumns,
293+
};
294+
}
295+
247296
const modelRelationsCache = new WeakMap<object, Map<string, Record<string, ResolvedRelation>>>();
248297

249298
export function resolveModelRelations(
@@ -269,6 +318,12 @@ export function resolveModelRelations(
269318
to?: CrossReference;
270319
cardinality?: unknown;
271320
on?: { localFields?: unknown; targetFields?: unknown };
321+
through?: {
322+
table?: unknown;
323+
parentColumns?: unknown;
324+
childColumns?: unknown;
325+
targetColumns?: unknown;
326+
};
272327
};
273328
const localFields = rel.on?.localFields;
274329
const targetFields = rel.on?.targetFields;
@@ -283,13 +338,16 @@ export function resolveModelRelations(
283338
continue;
284339
}
285340

341+
const through = resolveThrough(contract, rel.through);
342+
286343
resolved[name] = {
287344
to: rel.to.model,
288345
cardinality: parseRelationCardinality(rel.cardinality),
289346
on: {
290347
localFields: localFields as readonly string[],
291348
targetFields: targetFields as readonly string[],
292349
},
350+
...(through !== undefined ? { through } : {}),
293351
};
294352
}
295353

packages/3-extensions/sql-orm-client/test/collection-contract.test.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
import type { Contract } from '@prisma-next/contract/types';
2+
import type { SqlStorage } from '@prisma-next/sql-contract/types';
13
import { describe, expect, it } from 'vitest';
24
import {
35
assertReturningCapability,
46
hasContractCapability,
57
isToOneCardinality,
68
resolveIncludeRelation,
9+
resolveModelRelations,
710
resolveModelTableName,
811
resolvePolymorphismInfo,
912
resolvePrimaryKeyColumn,
@@ -363,3 +366,172 @@ describe('resolvePolymorphismInfo()', () => {
363366
);
364367
});
365368
});
369+
370+
describe('resolveModelRelations() through descriptor', () => {
371+
type RawColumn = { nativeType: string; codecId: string; nullable: boolean; default?: unknown };
372+
373+
function buildManyToManyContract(opts: {
374+
junctionTable: string;
375+
parentColumns: string[];
376+
childColumns: string[];
377+
targetColumns: string[];
378+
extraColumns?: Record<string, RawColumn>;
379+
}): Contract<SqlStorage> {
380+
const { junctionTable, parentColumns, childColumns, targetColumns, extraColumns = {} } = opts;
381+
382+
const junctionStorageColumns: Record<string, RawColumn> = {};
383+
for (const col of parentColumns) {
384+
junctionStorageColumns[col] = { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false };
385+
}
386+
for (const col of childColumns) {
387+
junctionStorageColumns[col] = { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false };
388+
}
389+
for (const [name, col] of Object.entries(extraColumns)) {
390+
junctionStorageColumns[name] = col;
391+
}
392+
393+
return {
394+
domain: {
395+
namespaces: {
396+
public: {
397+
id: 'public',
398+
models: {
399+
Parent: {
400+
fields: { id: { nullable: false, type: { kind: 'scalar', codecId: 'pg/int4@1' } } },
401+
relations: {
402+
children: {
403+
to: { model: 'Child', namespace: 'public' },
404+
cardinality: 'N:M',
405+
on: { localFields: ['id'], targetFields: targetColumns },
406+
through: {
407+
table: junctionTable,
408+
parentColumns,
409+
childColumns,
410+
targetColumns,
411+
},
412+
},
413+
},
414+
storage: { table: 'parents', fields: { id: { column: 'id' } } },
415+
},
416+
Child: {
417+
fields: { id: { nullable: false, type: { kind: 'scalar', codecId: 'pg/int4@1' } } },
418+
relations: {},
419+
storage: { table: 'children', fields: { id: { column: 'id' } } },
420+
},
421+
Junction: {
422+
fields: {},
423+
relations: {},
424+
storage: { table: junctionTable, fields: {} },
425+
},
426+
},
427+
},
428+
},
429+
},
430+
storage: {
431+
namespaces: {
432+
public: {
433+
id: 'public',
434+
tables: {
435+
parents: {
436+
columns: { id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false } },
437+
primaryKey: { columns: ['id'] },
438+
uniques: [],
439+
indexes: [],
440+
foreignKeys: [],
441+
},
442+
children: {
443+
columns: { id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false } },
444+
primaryKey: { columns: ['id'] },
445+
uniques: [],
446+
indexes: [],
447+
foreignKeys: [],
448+
},
449+
[junctionTable]: {
450+
columns: junctionStorageColumns,
451+
primaryKey: { columns: [...parentColumns, ...childColumns] },
452+
uniques: [],
453+
indexes: [],
454+
foreignKeys: [],
455+
},
456+
},
457+
},
458+
},
459+
},
460+
capabilities: {},
461+
} as unknown as Contract<SqlStorage>;
462+
}
463+
464+
it('populates through descriptor for a simple single-column M:N relation', () => {
465+
const contract = buildManyToManyContract({
466+
junctionTable: 'parent_child',
467+
parentColumns: ['parent_id'],
468+
childColumns: ['child_id'],
469+
targetColumns: ['id'],
470+
});
471+
472+
const relations = resolveModelRelations(contract, 'Parent');
473+
expect(relations['children']?.through).toEqual({
474+
table: 'parent_child',
475+
parentColumns: ['parent_id'],
476+
childColumns: ['child_id'],
477+
targetColumns: ['id'],
478+
requiredPayloadColumns: [],
479+
});
480+
});
481+
482+
it('populates through descriptor for a composite-key M:N junction', () => {
483+
const contract = buildManyToManyContract({
484+
junctionTable: 'parent_child',
485+
parentColumns: ['tenant_id', 'parent_id'],
486+
childColumns: ['tenant_id', 'child_id'],
487+
targetColumns: ['tenant_id', 'id'],
488+
});
489+
490+
const relations = resolveModelRelations(contract, 'Parent');
491+
const through = relations['children']?.through;
492+
expect(through?.parentColumns).toEqual(['tenant_id', 'parent_id']);
493+
expect(through?.childColumns).toEqual(['tenant_id', 'child_id']);
494+
expect(through?.targetColumns).toEqual(['tenant_id', 'id']);
495+
expect(through?.requiredPayloadColumns).toEqual([]);
496+
});
497+
498+
it('includes NOT-NULL no-default non-FK columns in requiredPayloadColumns', () => {
499+
const contract = buildManyToManyContract({
500+
junctionTable: 'parent_child',
501+
parentColumns: ['parent_id'],
502+
childColumns: ['child_id'],
503+
targetColumns: ['id'],
504+
extraColumns: {
505+
assigned_at: { nativeType: 'timestamptz', codecId: 'pg/timestamptz@1', nullable: false },
506+
role: { nativeType: 'text', codecId: 'pg/text@1', nullable: false },
507+
},
508+
});
509+
510+
const relations = resolveModelRelations(contract, 'Parent');
511+
expect(relations['children']?.through?.requiredPayloadColumns).toEqual(
512+
expect.arrayContaining(['assigned_at', 'role']),
513+
);
514+
expect(relations['children']?.through?.requiredPayloadColumns).toHaveLength(2);
515+
});
516+
517+
it('excludes nullable and defaulted non-FK columns from requiredPayloadColumns', () => {
518+
const contract = buildManyToManyContract({
519+
junctionTable: 'parent_child',
520+
parentColumns: ['parent_id'],
521+
childColumns: ['child_id'],
522+
targetColumns: ['id'],
523+
extraColumns: {
524+
note: { nativeType: 'text', codecId: 'pg/text@1', nullable: true },
525+
created_at: {
526+
nativeType: 'timestamptz',
527+
codecId: 'pg/timestamptz@1',
528+
nullable: false,
529+
default: { kind: 'expression', sql: 'now()' },
530+
},
531+
},
532+
});
533+
534+
const relations = resolveModelRelations(contract, 'Parent');
535+
expect(relations['children']?.through?.requiredPayloadColumns).toEqual([]);
536+
});
537+
});

0 commit comments

Comments
 (0)