Skip to content

Commit a5d4d01

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 5a90380 commit a5d4d01

3 files changed

Lines changed: 815 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
@@ -66,7 +66,7 @@ import {
6666
type RelationNode,
6767
type UniqueConstraintNode,
6868
} from '@prisma-next/sql-contract-ts/contract-builder';
69-
import { invariant } from '@prisma-next/utils/assertions';
69+
import { assertDefined, invariant } from '@prisma-next/utils/assertions';
7070
import { blindCast } from '@prisma-next/utils/casts';
7171
import { ifDefined } from '@prisma-next/utils/defined';
7272
import { notOk, ok, type Result } from '@prisma-next/utils/result';
@@ -108,6 +108,9 @@ import {
108108
normalizeReferentialAction,
109109
type ParsedThrough,
110110
resolveTargetIdFieldNames,
111+
SYNTHESIZED_JUNCTION_COLUMN_A,
112+
SYNTHESIZED_JUNCTION_COLUMN_B,
113+
type SynthesizedJunction,
111114
validateNavigationListFieldAttributes,
112115
} from './psl-relation-resolution';
113116

@@ -1210,6 +1213,104 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
12101213
};
12111214
}
12121215

1216+
type JunctionTerminal = {
1217+
readonly tableName: string;
1218+
readonly idColumn: string;
1219+
readonly idDescriptor: FieldNode['descriptor'];
1220+
readonly namespaceId: string | undefined;
1221+
};
1222+
1223+
/**
1224+
* Resolves the table name, single `@id` column, and that column's type
1225+
* descriptor for one terminal of a synthesised junction, so the junction can
1226+
* copy the descriptor onto its referencing foreign-key column. The resolver
1227+
* guarantees a single-column `@id` before requesting synthesis, so the terminal
1228+
* model and its id column must both resolve.
1229+
*/
1230+
function junctionTerminal(
1231+
modelNodes: readonly ModelNode[],
1232+
modelName: string,
1233+
modelNamespaceIds: ReadonlyMap<string, string>,
1234+
): JunctionTerminal {
1235+
const modelNode = modelNodes.find((node) => node.modelName === modelName);
1236+
assertDefined(modelNode, `synthesised junction terminal model "${modelName}"`);
1237+
const idColumns = modelNode.id?.columns;
1238+
invariant(
1239+
idColumns?.length === 1,
1240+
`synthesised junction terminal "${modelName}" must have a single-column @id`,
1241+
);
1242+
const idColumn = idColumns[0];
1243+
const idField = modelNode.fields.find(
1244+
(field): field is FieldNode => 'descriptor' in field && field.columnName === idColumn,
1245+
);
1246+
assertDefined(idField, `synthesised junction terminal "${modelName}" @id column field`);
1247+
return {
1248+
tableName: modelNode.tableName,
1249+
idColumn: idField.columnName,
1250+
idDescriptor: idField.descriptor,
1251+
namespaceId: modelNamespaceIds.get(modelName),
1252+
};
1253+
}
1254+
1255+
/**
1256+
* Builds the model-less junction `ModelNode` for a synthesised implicit
1257+
* many-to-many: two foreign-key columns `A`/`B` whose types match the two
1258+
* terminal models' ids, a composite primary key over them, and the two foreign
1259+
* keys back to the terminals. The contract assembler turns this into a storage
1260+
* table and a (non-root) domain model; the through descriptors already emitted
1261+
* on the navigable ends reference it by name.
1262+
*/
1263+
function buildSynthesizedJunctionModelNode(
1264+
junction: SynthesizedJunction,
1265+
modelNodes: readonly ModelNode[],
1266+
modelNamespaceIds: ReadonlyMap<string, string>,
1267+
): ModelNode {
1268+
const terminalA = junctionTerminal(modelNodes, junction.modelA, modelNamespaceIds);
1269+
const terminalB = junctionTerminal(modelNodes, junction.modelB, modelNamespaceIds);
1270+
const fields: FieldNode[] = [
1271+
{
1272+
fieldName: SYNTHESIZED_JUNCTION_COLUMN_A,
1273+
columnName: SYNTHESIZED_JUNCTION_COLUMN_A,
1274+
descriptor: terminalA.idDescriptor,
1275+
nullable: false,
1276+
},
1277+
{
1278+
fieldName: SYNTHESIZED_JUNCTION_COLUMN_B,
1279+
columnName: SYNTHESIZED_JUNCTION_COLUMN_B,
1280+
descriptor: terminalB.idDescriptor,
1281+
nullable: false,
1282+
},
1283+
];
1284+
const foreignKeys: ForeignKeyNode[] = [
1285+
{
1286+
columns: [SYNTHESIZED_JUNCTION_COLUMN_A],
1287+
references: {
1288+
model: junction.modelA,
1289+
table: terminalA.tableName,
1290+
columns: [terminalA.idColumn],
1291+
...ifDefined('namespaceId', terminalA.namespaceId),
1292+
},
1293+
},
1294+
{
1295+
columns: [SYNTHESIZED_JUNCTION_COLUMN_B],
1296+
references: {
1297+
model: junction.modelB,
1298+
table: terminalB.tableName,
1299+
columns: [terminalB.idColumn],
1300+
...ifDefined('namespaceId', terminalB.namespaceId),
1301+
},
1302+
},
1303+
];
1304+
return {
1305+
modelName: junction.junctionModelName,
1306+
tableName: junction.junctionModelName,
1307+
...ifDefined('namespaceId', terminalA.namespaceId),
1308+
fields,
1309+
id: { columns: [SYNTHESIZED_JUNCTION_COLUMN_A, SYNTHESIZED_JUNCTION_COLUMN_B] },
1310+
foreignKeys,
1311+
};
1312+
}
1313+
12131314
interface BuildValueObjectsInput {
12141315
readonly compositeTypes: readonly CompositeTypeSymbol[];
12151316
readonly enumTypeDescriptors: ReadonlyMap<string, ColumnDescriptor>;
@@ -2055,21 +2156,37 @@ export function interpretPslDocumentToSqlContract(
20552156
fkRelationMetadata,
20562157
});
20572158
const modelIdColumns = new Map<string, readonly string[]>();
2159+
const modelTableNames = new Map<string, string>();
2160+
const declaredTableNames = new Set<string>();
20582161
for (const modelNode of modelNodes) {
20592162
if (modelNode.id) {
20602163
modelIdColumns.set(modelNode.modelName, modelNode.id.columns);
20612164
}
2165+
modelTableNames.set(modelNode.modelName, modelNode.tableName);
2166+
declaredTableNames.add(modelNode.modelName);
2167+
declaredTableNames.add(modelNode.tableName);
20622168
}
2063-
applyBackrelationCandidates({
2169+
const { synthesizedJunctions } = applyBackrelationCandidates({
20642170
backrelationCandidates,
20652171
fkRelationsByPair,
20662172
fkRelationsByDeclaringModel,
20672173
modelIdColumns,
2174+
modelTableNames,
2175+
modelNamespaceIds,
2176+
declaredTableNames,
20682177
modelRelations,
20692178
diagnostics,
20702179
sourceId,
20712180
});
20722181

2182+
// Inject a model-less junction table for each implicit many-to-many: a
2183+
// physical table the user never authored (Prisma's `_<A>To<B>` convention).
2184+
// The contract assembler turns it into a storage table and a domain model,
2185+
// and fills the through descriptors' `targetColumns` from the terminal ids.
2186+
for (const junction of synthesizedJunctions) {
2187+
modelNodes.push(buildSynthesizedJunctionModelNode(junction, modelNodes, modelNamespaceIds));
2188+
}
2189+
20732190
// Merge cross-space relations into modelRelations after local back-relation matching.
20742191
// Cross-space targets have no local back-relation candidates, so they bypass that step.
20752192
for (const [modelName, relations] of crossSpaceRelationsByModel) {
@@ -2215,10 +2332,15 @@ export function interpretPslDocumentToSqlContract(
22152332
});
22162333
}
22172334

2218-
const variantModelNames = new Set(baseDeclarations.keys());
2335+
// STI variants share the base table and synthesised junctions are physical
2336+
// tables only — neither is a queryable root.
2337+
const nonRootModelNames = new Set(baseDeclarations.keys());
2338+
for (const junction of synthesizedJunctions) {
2339+
nonRootModelNames.add(junction.junctionModelName);
2340+
}
22192341
const filteredRoots = Object.fromEntries(
22202342
Object.entries(contract.roots).filter(
2221-
([, crossReference]) => !variantModelNames.has(crossReference.model),
2343+
([, crossReference]) => !nonRootModelNames.has(crossReference.model),
22222344
),
22232345
);
22242346

0 commit comments

Comments
 (0)