Skip to content

Commit 348775c

Browse files
committed
feat(sql-contract): add N:M cardinality and through descriptor to contract shape
Extend ContractReferenceRelation to accept cardinality 'N:M' and an optional through: { table, parentColumns, childColumns, targetColumns }. Three places updated in lockstep: - domain-types.ts: add 'N:M' to cardinality union, add ContractRelationThrough type and optional through field - validators.ts: add 'N:M' to ContractReferenceRelationSchema enum, add ContractRelationThroughSchema with its own '+': 'reject' guard - data-contract-sql-v1.json: add through object to ModelRelation def - build-contract.ts: remove the 'as ContractRelation['cardinality']' cast (now unnecessary), rename emitted parentCols/childCols to parentColumns/ childColumns, populate targetColumns from the target model's PK - mongo-orm collection-state.ts: align local cardinality type from 'M:N' to 'N:M' (trivial consumer fix required by the type widening) New round-trip test: author User-UserTag-Tag M:N via rel.manyToMany, emit, validateSqlContractFully passes, and through descriptor is asserted. Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
1 parent b9afebc commit 348775c

7 files changed

Lines changed: 133 additions & 10 deletions

File tree

packages/1-framework/0-foundation/contract/src/domain-types.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,18 @@ export type ContractRelationOn = {
3030
readonly targetFields: readonly string[];
3131
};
3232

33+
export type ContractRelationThrough = {
34+
readonly table: string;
35+
readonly parentColumns: readonly string[];
36+
readonly childColumns: readonly string[];
37+
readonly targetColumns: readonly string[];
38+
};
39+
3340
export type ContractReferenceRelation = {
3441
readonly to: CrossReference;
35-
readonly cardinality: '1:1' | '1:N' | 'N:1';
42+
readonly cardinality: '1:1' | '1:N' | 'N:1' | 'N:M';
3643
readonly on: ContractRelationOn;
44+
readonly through?: ContractRelationThrough;
3745
};
3846

3947
export type ContractEmbedRelation = {

packages/2-mongo-family/5-query-builders/orm/src/collection-state.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export interface MongoIncludeExpr {
55
readonly from: string;
66
readonly localField: string;
77
readonly foreignField: string;
8-
readonly cardinality: '1:1' | 'N:1' | '1:N' | 'M:N';
8+
readonly cardinality: '1:1' | 'N:1' | '1:N' | 'N:M';
99
}
1010

1111
export interface MongoCollectionState {

packages/2-sql/1-core/contract/src/validators.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -370,15 +370,24 @@ const ModelStorageSchema = type({
370370
fields: type({ '[string]': ModelStorageFieldSchema }),
371371
});
372372

373+
const ContractRelationThroughSchema = type({
374+
'+': 'reject',
375+
table: 'string',
376+
parentColumns: type.string.array().readonly(),
377+
childColumns: type.string.array().readonly(),
378+
targetColumns: type.string.array().readonly(),
379+
});
380+
373381
const ContractReferenceRelationSchema = type({
374382
'+': 'reject',
375383
to: CrossReferenceSchema,
376-
cardinality: "'1:1' | '1:N' | 'N:1'",
384+
cardinality: "'1:1' | '1:N' | 'N:1' | 'N:M'",
377385
on: type({
378386
'+': 'reject',
379387
localFields: type.string.array().readonly(),
380388
targetFields: type.string.array().readonly(),
381389
}),
390+
'through?': ContractRelationThroughSchema,
382391
});
383392

384393
const ContractEmbedRelationSchema = type({

packages/2-sql/2-authoring/contract-ts/schemas/data-contract-sql-v1.json

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,33 @@
586586
"items": { "type": "string" }
587587
}
588588
}
589+
},
590+
"through": {
591+
"type": "object",
592+
"description": "Junction table descriptor for N:M relations",
593+
"additionalProperties": false,
594+
"properties": {
595+
"table": {
596+
"type": "string",
597+
"description": "Junction table name"
598+
},
599+
"parentColumns": {
600+
"type": "array",
601+
"description": "Junction columns referencing the parent FK",
602+
"items": { "type": "string" }
603+
},
604+
"childColumns": {
605+
"type": "array",
606+
"description": "Junction columns referencing the target FK",
607+
"items": { "type": "string" }
608+
},
609+
"targetColumns": {
610+
"type": "array",
611+
"description": "Target anchor columns (PK) the childColumns reference",
612+
"items": { "type": "string" }
613+
}
614+
},
615+
"required": ["table", "parentColumns", "childColumns", "targetColumns"]
589616
}
590617
},
591618
"required": ["to", "cardinality"]

packages/2-sql/2-authoring/contract-ts/src/build-contract.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -489,10 +489,7 @@ export function buildSqlContractFromDefinition(
489489
relation.toModel,
490490
resolveModelNamespaceId(targetModel, modelNameToNamespaceId, target),
491491
),
492-
// RelationDefinition.cardinality includes 'N:M' which isn't in
493-
// ContractReferenceRelation yet — cast is needed until the contract
494-
// type is extended to cover many-to-many.
495-
cardinality: relation.cardinality as ContractRelation['cardinality'],
492+
cardinality: relation.cardinality,
496493
on: {
497494
localFields: relation.on.parentColumns.map((col) => columnToField.get(col) ?? col),
498495
targetFields: relation.on.childColumns.map((col) => targetColumnToField.get(col) ?? col),
@@ -501,8 +498,9 @@ export function buildSqlContractFromDefinition(
501498
? {
502499
through: {
503500
table: relation.through.table,
504-
parentCols: relation.through.parentColumns,
505-
childCols: relation.through.childColumns,
501+
parentColumns: relation.through.parentColumns,
502+
childColumns: relation.through.childColumns,
503+
targetColumns: targetModel.id?.columns ?? ([] as const),
506504
},
507505
}
508506
: undefined),

packages/2-sql/2-authoring/contract-ts/test/contract-builder.dsl.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import type { Contract } from '@prisma-next/contract/types';
12
import type { FamilyPackRef, TargetPackRef } from '@prisma-next/framework-components/components';
3+
import type { SqlStorage } from '@prisma-next/sql-contract/types';
4+
import { validateSqlContractFully } from '@prisma-next/sql-contract/validators';
25
import { describe, expect, expectTypeOf, it } from 'vitest';
36
import { type ContractInput, defineContract, field, model, rel } from '../src/contract-builder';
47
import { modelsMapForAssertions, modelsOf } from './contract-test-helpers';
@@ -958,4 +961,82 @@ describe('self-referential and circular relations', () => {
958961
},
959962
});
960963
});
964+
965+
it('M:N relation round-trips through validateContract with through descriptor intact', () => {
966+
const UserTag = model('UserTag', {
967+
fields: {
968+
userId: field.column(textColumn).column('user_id'),
969+
tagId: field.column(textColumn).column('tag_id'),
970+
},
971+
})
972+
.attributes(({ fields, constraints }) => ({
973+
id: constraints.id([fields.userId, fields.tagId]),
974+
}))
975+
.sql({ table: 'user_tag' });
976+
977+
const TagBase = model('Tag', {
978+
fields: {
979+
id: field.column(textColumn).id(),
980+
name: field.column(textColumn),
981+
},
982+
}).sql({ table: 'tag' });
983+
984+
const UserBase = model('User', {
985+
fields: {
986+
id: field.column(textColumn).id(),
987+
email: field.column(textColumn),
988+
},
989+
});
990+
991+
const Tag = TagBase.relations({
992+
users: rel.manyToMany(() => UserBase, {
993+
through: () => UserTag,
994+
from: 'tagId',
995+
to: 'userId',
996+
}),
997+
});
998+
999+
const User = UserBase.relations({
1000+
tags: rel.manyToMany(() => Tag, {
1001+
through: () => UserTag,
1002+
from: 'userId',
1003+
to: 'tagId',
1004+
}),
1005+
}).sql({ table: 'app_user' });
1006+
1007+
const contract = defineTestContract({
1008+
models: { User, Tag, UserTag },
1009+
});
1010+
1011+
expect(() => validateSqlContractFully<Contract<SqlStorage>>(contract)).not.toThrow();
1012+
1013+
const contractModels = modelsOf(contract) as Record<
1014+
string,
1015+
{ relations: Record<string, unknown> }
1016+
>;
1017+
expect(contractModels['User']?.relations).toMatchObject({
1018+
tags: {
1019+
to: crossRef('Tag', 'public'),
1020+
cardinality: 'N:M',
1021+
through: {
1022+
table: 'user_tag',
1023+
parentColumns: ['user_id'],
1024+
childColumns: ['tag_id'],
1025+
targetColumns: ['id'],
1026+
},
1027+
},
1028+
});
1029+
expect(contractModels['Tag']?.relations).toMatchObject({
1030+
users: {
1031+
to: crossRef('User', 'public'),
1032+
cardinality: 'N:M',
1033+
through: {
1034+
table: 'user_tag',
1035+
parentColumns: ['tag_id'],
1036+
childColumns: ['user_id'],
1037+
targetColumns: ['id'],
1038+
},
1039+
},
1040+
});
1041+
});
9611042
});

packages/2-sql/2-authoring/contract-ts/test/contract.edge-cases.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ describe('SqlContractSerializer edge cases', () => {
130130
},
131131
relations: {
132132
posts: {
133-
on: { parentCols: ['id'], childCols: ['userId'] },
133+
on: { localFields: ['id'], targetFields: ['userId'] },
134134
cardinality: '1:N',
135135
},
136136
},

0 commit comments

Comments
 (0)