Skip to content

Commit 846f95d

Browse files
committed
test(sql-contract-psl): cover two distinct M:N relations between the same model pair
The relation-name disambiguation that resolves self-referential many-to-many sides is general: it equally distinguishes two separate many-to-many relations between the same pair of distinct models, each through its own junction. That path was exercised only for the same-model (self-referential) permutation. Add two interpreter cases for the distinct-models shape (User <-> Tag through TagOwnership and TagWatch): one where both list fields and their junction parent-side FKs carry matching @relation names, asserting both relations lower to N:M with the correct through descriptors and the contract validates fully; and one without relation names, asserting the PSL_AMBIGUOUS_BACKRELATION_LIST diagnostic. No implementation change — the behaviour was already correct. Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
1 parent dbbd50f commit 846f95d

1 file changed

Lines changed: 138 additions & 0 deletions

File tree

packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.many-to-many.test.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,144 @@ model Follow {
432432
);
433433
});
434434

435+
it('lowers two distinct named many-to-many relations between the same pair through separate junctions', () => {
436+
const result = interpretSchema(`model User {
437+
id Int @id
438+
ownedTags Tag[] @relation("owned")
439+
watchedTags Tag[] @relation("watched")
440+
}
441+
442+
model Tag {
443+
id Int @id
444+
owners User[] @relation("owned")
445+
watchers User[] @relation("watched")
446+
}
447+
448+
model TagOwnership {
449+
userId Int
450+
tagId Int
451+
user User @relation("owned", fields: [userId], references: [id])
452+
tag Tag @relation("owned", fields: [tagId], references: [id])
453+
454+
@@id([userId, tagId])
455+
}
456+
457+
model TagWatch {
458+
userId Int
459+
tagId Int
460+
user User @relation("watched", fields: [userId], references: [id])
461+
tag Tag @relation("watched", fields: [tagId], references: [id])
462+
463+
@@id([userId, tagId])
464+
}
465+
`);
466+
467+
expect(result.ok).toBe(true);
468+
if (!result.ok) return;
469+
470+
const models = relationsOf(result.value);
471+
expect(models['User']?.relations).toEqual({
472+
ownedTags: {
473+
to: crossRef('Tag', 'public'),
474+
cardinality: 'N:M',
475+
on: { localFields: ['id'], targetFields: ['userId'] },
476+
through: {
477+
table: 'tagOwnership',
478+
namespaceId: 'public',
479+
parentColumns: ['userId'],
480+
childColumns: ['tagId'],
481+
targetColumns: ['id'],
482+
},
483+
},
484+
watchedTags: {
485+
to: crossRef('Tag', 'public'),
486+
cardinality: 'N:M',
487+
on: { localFields: ['id'], targetFields: ['userId'] },
488+
through: {
489+
table: 'tagWatch',
490+
namespaceId: 'public',
491+
parentColumns: ['userId'],
492+
childColumns: ['tagId'],
493+
targetColumns: ['id'],
494+
},
495+
},
496+
});
497+
expect(models['Tag']?.relations).toEqual({
498+
owners: {
499+
to: crossRef('User', 'public'),
500+
cardinality: 'N:M',
501+
on: { localFields: ['id'], targetFields: ['tagId'] },
502+
through: {
503+
table: 'tagOwnership',
504+
namespaceId: 'public',
505+
parentColumns: ['tagId'],
506+
childColumns: ['userId'],
507+
targetColumns: ['id'],
508+
},
509+
},
510+
watchers: {
511+
to: crossRef('User', 'public'),
512+
cardinality: 'N:M',
513+
on: { localFields: ['id'], targetFields: ['tagId'] },
514+
through: {
515+
table: 'tagWatch',
516+
namespaceId: 'public',
517+
parentColumns: ['tagId'],
518+
childColumns: ['userId'],
519+
targetColumns: ['id'],
520+
},
521+
},
522+
});
523+
524+
const envelope = JSON.parse(JSON.stringify(result.value)) as unknown;
525+
expect(() => validateSqlContractFully<Contract<SqlStorage>>(envelope)).not.toThrow();
526+
});
527+
528+
it('returns diagnostics for two unnamed many-to-many relations between the same pair', () => {
529+
const result = interpretSchema(`model User {
530+
id Int @id
531+
ownedTags Tag[]
532+
watchedTags Tag[]
533+
}
534+
535+
model Tag {
536+
id Int @id
537+
owners User[]
538+
watchers User[]
539+
}
540+
541+
model TagOwnership {
542+
userId Int
543+
tagId Int
544+
user User @relation(fields: [userId], references: [id])
545+
tag Tag @relation(fields: [tagId], references: [id])
546+
547+
@@id([userId, tagId])
548+
}
549+
550+
model TagWatch {
551+
userId Int
552+
tagId Int
553+
user User @relation(fields: [userId], references: [id])
554+
tag Tag @relation(fields: [tagId], references: [id])
555+
556+
@@id([userId, tagId])
557+
}
558+
`);
559+
560+
expect(result.ok).toBe(false);
561+
if (result.ok) return;
562+
563+
expect(result.failure.diagnostics).toEqual(
564+
expect.arrayContaining([
565+
expect.objectContaining({
566+
code: 'PSL_AMBIGUOUS_BACKRELATION_LIST',
567+
message: expect.stringContaining('User.ownedTags'),
568+
}),
569+
]),
570+
);
571+
});
572+
435573
it('keeps the orphaned diagnostic for bare lists without any junction model', () => {
436574
const result = interpretSchema(`model Post {
437575
id Int @id

0 commit comments

Comments
 (0)