Skip to content

Commit 95db505

Browse files
committed
feat(contract-psl): synthesise a model-less junction for implicit M:N
When both navigable list ends are bare (no `through:`) and no junction model links the pair, the PSL interpreter now synthesises a model-less junction table (Prisma's implicit many-to-many) instead of emitting the orphaned-backrelation diagnostic. Detection lives in `applyBackrelationCandidates`: a bare list with no FK-side match, no authored junction, and no junction near-miss is deferred to `resolveImplicitManyToMany`, which pairs it with its mirror end (or resolves a self-referential list on its own) and emits the `N:M`/`through` descriptor on both ends. D5 precedence is preserved — a both-bare pair with an authored junction model is still recognised, never synthesised. Synthesis injects a junction `ModelNode` named `_<A>To<B>` (terminal model names ordered alphabetically) with foreign-key columns `A` and `B` (A references the first model's id, B the second), a composite `(A, B)` identity, and the two foreign keys; the contract assembler turns it into a storage table and fills the through descriptors' `targetColumns` from the terminal ids. The junction is a physical table only — filtered out of `roots` like an STI variant. Diagnostics: a terminal without a single-column `@id` (`PSL_IMPLICIT_MN_TARGET_NO_ID`), more than one implicit many-to-many between the same pair (`PSL_IMPLICIT_MN_AMBIGUOUS`), and a real table already named like the synthesised junction (`PSL_IMPLICIT_MN_NAME_COLLISION`). Migration DDL and runtime `include` integration are S4·M2. Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
1 parent 512ab30 commit 95db505

3 files changed

Lines changed: 814 additions & 11 deletions

File tree

packages/2-sql/2-authoring/contract-psl/src/interpreter.ts

Lines changed: 126 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ import {
6060
type RelationNode,
6161
type UniqueConstraintNode,
6262
} from '@prisma-next/sql-contract-ts/contract-builder';
63-
import { invariant } from '@prisma-next/utils/assertions';
63+
import { assertDefined, invariant } from '@prisma-next/utils/assertions';
6464
import { blindCast } from '@prisma-next/utils/casts';
6565
import { ifDefined } from '@prisma-next/utils/defined';
6666
import { notOk, ok, type Result } from '@prisma-next/utils/result';
@@ -102,6 +102,9 @@ import {
102102
type ParsedThrough,
103103
parseRelationAttribute,
104104
resolveTargetIdFieldNames,
105+
SYNTHESIZED_JUNCTION_COLUMN_A,
106+
SYNTHESIZED_JUNCTION_COLUMN_B,
107+
type SynthesizedJunction,
105108
validateNavigationListFieldAttributes,
106109
} from './psl-relation-resolution';
107110

@@ -1188,6 +1191,104 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
11881191
};
11891192
}
11901193

1194+
type JunctionTerminal = {
1195+
readonly tableName: string;
1196+
readonly idColumn: string;
1197+
readonly idDescriptor: FieldNode['descriptor'];
1198+
readonly namespaceId: string | undefined;
1199+
};
1200+
1201+
/**
1202+
* Resolves the table name, single `@id` column, and that column's type
1203+
* descriptor for one terminal of a synthesised junction, so the junction can
1204+
* copy the descriptor onto its referencing foreign-key column. The resolver
1205+
* guarantees a single-column `@id` before requesting synthesis, so the terminal
1206+
* model and its id column must both resolve.
1207+
*/
1208+
function junctionTerminal(
1209+
modelNodes: readonly ModelNode[],
1210+
modelName: string,
1211+
modelNamespaceIds: ReadonlyMap<string, string>,
1212+
): JunctionTerminal {
1213+
const modelNode = modelNodes.find((node) => node.modelName === modelName);
1214+
assertDefined(modelNode, `synthesised junction terminal model "${modelName}"`);
1215+
const idColumns = modelNode.id?.columns;
1216+
invariant(
1217+
idColumns?.length === 1,
1218+
`synthesised junction terminal "${modelName}" must have a single-column @id`,
1219+
);
1220+
const idColumn = idColumns[0];
1221+
const idField = modelNode.fields.find(
1222+
(field): field is FieldNode => 'descriptor' in field && field.columnName === idColumn,
1223+
);
1224+
assertDefined(idField, `synthesised junction terminal "${modelName}" @id column field`);
1225+
return {
1226+
tableName: modelNode.tableName,
1227+
idColumn: idField.columnName,
1228+
idDescriptor: idField.descriptor,
1229+
namespaceId: modelNamespaceIds.get(modelName),
1230+
};
1231+
}
1232+
1233+
/**
1234+
* Builds the model-less junction `ModelNode` for a synthesised implicit
1235+
* many-to-many: two foreign-key columns `A`/`B` whose types match the two
1236+
* terminal models' ids, a composite primary key over them, and the two foreign
1237+
* keys back to the terminals. The contract assembler turns this into a storage
1238+
* table and a (non-root) domain model; the through descriptors already emitted
1239+
* on the navigable ends reference it by name.
1240+
*/
1241+
function buildSynthesizedJunctionModelNode(
1242+
junction: SynthesizedJunction,
1243+
modelNodes: readonly ModelNode[],
1244+
modelNamespaceIds: ReadonlyMap<string, string>,
1245+
): ModelNode {
1246+
const terminalA = junctionTerminal(modelNodes, junction.modelA, modelNamespaceIds);
1247+
const terminalB = junctionTerminal(modelNodes, junction.modelB, modelNamespaceIds);
1248+
const fields: FieldNode[] = [
1249+
{
1250+
fieldName: SYNTHESIZED_JUNCTION_COLUMN_A,
1251+
columnName: SYNTHESIZED_JUNCTION_COLUMN_A,
1252+
descriptor: terminalA.idDescriptor,
1253+
nullable: false,
1254+
},
1255+
{
1256+
fieldName: SYNTHESIZED_JUNCTION_COLUMN_B,
1257+
columnName: SYNTHESIZED_JUNCTION_COLUMN_B,
1258+
descriptor: terminalB.idDescriptor,
1259+
nullable: false,
1260+
},
1261+
];
1262+
const foreignKeys: ForeignKeyNode[] = [
1263+
{
1264+
columns: [SYNTHESIZED_JUNCTION_COLUMN_A],
1265+
references: {
1266+
model: junction.modelA,
1267+
table: terminalA.tableName,
1268+
columns: [terminalA.idColumn],
1269+
...ifDefined('namespaceId', terminalA.namespaceId),
1270+
},
1271+
},
1272+
{
1273+
columns: [SYNTHESIZED_JUNCTION_COLUMN_B],
1274+
references: {
1275+
model: junction.modelB,
1276+
table: terminalB.tableName,
1277+
columns: [terminalB.idColumn],
1278+
...ifDefined('namespaceId', terminalB.namespaceId),
1279+
},
1280+
},
1281+
];
1282+
return {
1283+
modelName: junction.junctionModelName,
1284+
tableName: junction.junctionModelName,
1285+
...ifDefined('namespaceId', terminalA.namespaceId),
1286+
fields,
1287+
id: { columns: [SYNTHESIZED_JUNCTION_COLUMN_A, SYNTHESIZED_JUNCTION_COLUMN_B] },
1288+
foreignKeys,
1289+
};
1290+
}
1291+
11911292
interface BuildValueObjectsInput {
11921293
readonly compositeTypes: readonly CompositeTypeSymbol[];
11931294
readonly enumTypeDescriptors: ReadonlyMap<string, ColumnDescriptor>;
@@ -1953,21 +2054,37 @@ export function interpretPslDocumentToSqlContract(
19532054
fkRelationMetadata,
19542055
});
19552056
const modelIdColumns = new Map<string, readonly string[]>();
2057+
const modelTableNames = new Map<string, string>();
2058+
const declaredTableNames = new Set<string>();
19562059
for (const modelNode of modelNodes) {
19572060
if (modelNode.id) {
19582061
modelIdColumns.set(modelNode.modelName, modelNode.id.columns);
19592062
}
2063+
modelTableNames.set(modelNode.modelName, modelNode.tableName);
2064+
declaredTableNames.add(modelNode.modelName);
2065+
declaredTableNames.add(modelNode.tableName);
19602066
}
1961-
applyBackrelationCandidates({
2067+
const { synthesizedJunctions } = applyBackrelationCandidates({
19622068
backrelationCandidates,
19632069
fkRelationsByPair,
19642070
fkRelationsByDeclaringModel,
19652071
modelIdColumns,
2072+
modelTableNames,
2073+
modelNamespaceIds,
2074+
declaredTableNames,
19662075
modelRelations,
19672076
diagnostics,
19682077
sourceId,
19692078
});
19702079

2080+
// Inject a model-less junction table for each implicit many-to-many: a
2081+
// physical table the user never authored (Prisma's `_<A>To<B>` convention).
2082+
// The contract assembler turns it into a storage table and a domain model,
2083+
// and fills the through descriptors' `targetColumns` from the terminal ids.
2084+
for (const junction of synthesizedJunctions) {
2085+
modelNodes.push(buildSynthesizedJunctionModelNode(junction, modelNodes, modelNamespaceIds));
2086+
}
2087+
19712088
// Merge cross-space relations into modelRelations after local back-relation matching.
19722089
// Cross-space targets have no local back-relation candidates, so they bypass that step.
19732090
for (const [modelName, relations] of crossSpaceRelationsByModel) {
@@ -2137,10 +2254,15 @@ export function interpretPslDocumentToSqlContract(
21372254
});
21382255
}
21392256

2140-
const variantModelNames = new Set(baseDeclarations.keys());
2257+
// STI variants share the base table and synthesised junctions are physical
2258+
// tables only — neither is a queryable root.
2259+
const nonRootModelNames = new Set(baseDeclarations.keys());
2260+
for (const junction of synthesizedJunctions) {
2261+
nonRootModelNames.add(junction.junctionModelName);
2262+
}
21412263
const filteredRoots = Object.fromEntries(
21422264
Object.entries(contract.roots).filter(
2143-
([, crossReference]) => !variantModelNames.has(crossReference.model),
2265+
([, crossReference]) => !nonRootModelNames.has(crossReference.model),
21442266
),
21452267
);
21462268

0 commit comments

Comments
 (0)