Skip to content

Commit c4cf006

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 b01ad8d commit c4cf006

3 files changed

Lines changed: 812 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
@@ -57,7 +57,7 @@ import {
5757
type RelationNode,
5858
type UniqueConstraintNode,
5959
} from '@prisma-next/sql-contract-ts/contract-builder';
60-
import { invariant } from '@prisma-next/utils/assertions';
60+
import { assertDefined, invariant } from '@prisma-next/utils/assertions';
6161
import { blindCast } from '@prisma-next/utils/casts';
6262
import { ifDefined } from '@prisma-next/utils/defined';
6363
import { notOk, ok, type Result } from '@prisma-next/utils/result';
@@ -99,6 +99,9 @@ import {
9999
type ParsedThrough,
100100
parseRelationAttribute,
101101
resolveTargetIdFieldNames,
102+
SYNTHESIZED_JUNCTION_COLUMN_A,
103+
SYNTHESIZED_JUNCTION_COLUMN_B,
104+
type SynthesizedJunction,
102105
validateNavigationListFieldAttributes,
103106
} from './psl-relation-resolution';
104107

@@ -1194,6 +1197,104 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
11941197
};
11951198
}
11961199

1200+
type JunctionTerminal = {
1201+
readonly tableName: string;
1202+
readonly idColumn: string;
1203+
readonly idDescriptor: FieldNode['descriptor'];
1204+
readonly namespaceId: string | undefined;
1205+
};
1206+
1207+
/**
1208+
* Resolves the table name, single `@id` column, and that column's type
1209+
* descriptor for one terminal of a synthesised junction, so the junction can
1210+
* copy the descriptor onto its referencing foreign-key column. The resolver
1211+
* guarantees a single-column `@id` before requesting synthesis, so the terminal
1212+
* model and its id column must both resolve.
1213+
*/
1214+
function junctionTerminal(
1215+
modelNodes: readonly ModelNode[],
1216+
modelName: string,
1217+
modelNamespaceIds: ReadonlyMap<string, string>,
1218+
): JunctionTerminal {
1219+
const modelNode = modelNodes.find((node) => node.modelName === modelName);
1220+
assertDefined(modelNode, `synthesised junction terminal model "${modelName}"`);
1221+
const idColumns = modelNode.id?.columns;
1222+
invariant(
1223+
idColumns?.length === 1,
1224+
`synthesised junction terminal "${modelName}" must have a single-column @id`,
1225+
);
1226+
const idColumn = idColumns[0];
1227+
const idField = modelNode.fields.find(
1228+
(field): field is FieldNode => 'descriptor' in field && field.columnName === idColumn,
1229+
);
1230+
assertDefined(idField, `synthesised junction terminal "${modelName}" @id column field`);
1231+
return {
1232+
tableName: modelNode.tableName,
1233+
idColumn: idField.columnName,
1234+
idDescriptor: idField.descriptor,
1235+
namespaceId: modelNamespaceIds.get(modelName),
1236+
};
1237+
}
1238+
1239+
/**
1240+
* Builds the model-less junction `ModelNode` for a synthesised implicit
1241+
* many-to-many: two foreign-key columns `A`/`B` whose types match the two
1242+
* terminal models' ids, a composite primary key over them, and the two foreign
1243+
* keys back to the terminals. The contract assembler turns this into a storage
1244+
* table and a (non-root) domain model; the through descriptors already emitted
1245+
* on the navigable ends reference it by name.
1246+
*/
1247+
function buildSynthesizedJunctionModelNode(
1248+
junction: SynthesizedJunction,
1249+
modelNodes: readonly ModelNode[],
1250+
modelNamespaceIds: ReadonlyMap<string, string>,
1251+
): ModelNode {
1252+
const terminalA = junctionTerminal(modelNodes, junction.modelA, modelNamespaceIds);
1253+
const terminalB = junctionTerminal(modelNodes, junction.modelB, modelNamespaceIds);
1254+
const fields: FieldNode[] = [
1255+
{
1256+
fieldName: SYNTHESIZED_JUNCTION_COLUMN_A,
1257+
columnName: SYNTHESIZED_JUNCTION_COLUMN_A,
1258+
descriptor: terminalA.idDescriptor,
1259+
nullable: false,
1260+
},
1261+
{
1262+
fieldName: SYNTHESIZED_JUNCTION_COLUMN_B,
1263+
columnName: SYNTHESIZED_JUNCTION_COLUMN_B,
1264+
descriptor: terminalB.idDescriptor,
1265+
nullable: false,
1266+
},
1267+
];
1268+
const foreignKeys: ForeignKeyNode[] = [
1269+
{
1270+
columns: [SYNTHESIZED_JUNCTION_COLUMN_A],
1271+
references: {
1272+
model: junction.modelA,
1273+
table: terminalA.tableName,
1274+
columns: [terminalA.idColumn],
1275+
...ifDefined('namespaceId', terminalA.namespaceId),
1276+
},
1277+
},
1278+
{
1279+
columns: [SYNTHESIZED_JUNCTION_COLUMN_B],
1280+
references: {
1281+
model: junction.modelB,
1282+
table: terminalB.tableName,
1283+
columns: [terminalB.idColumn],
1284+
...ifDefined('namespaceId', terminalB.namespaceId),
1285+
},
1286+
},
1287+
];
1288+
return {
1289+
modelName: junction.junctionModelName,
1290+
tableName: junction.junctionModelName,
1291+
...ifDefined('namespaceId', terminalA.namespaceId),
1292+
fields,
1293+
id: { columns: [SYNTHESIZED_JUNCTION_COLUMN_A, SYNTHESIZED_JUNCTION_COLUMN_B] },
1294+
foreignKeys,
1295+
};
1296+
}
1297+
11971298
interface BuildValueObjectsInput {
11981299
readonly compositeTypes: readonly CompositeTypeSymbol[];
11991300
readonly enumTypeDescriptors: ReadonlyMap<string, ColumnDescriptor>;
@@ -1959,21 +2060,37 @@ export function interpretPslDocumentToSqlContract(
19592060
fkRelationMetadata,
19602061
});
19612062
const modelIdColumns = new Map<string, readonly string[]>();
2063+
const modelTableNames = new Map<string, string>();
2064+
const declaredTableNames = new Set<string>();
19622065
for (const modelNode of modelNodes) {
19632066
if (modelNode.id) {
19642067
modelIdColumns.set(modelNode.modelName, modelNode.id.columns);
19652068
}
2069+
modelTableNames.set(modelNode.modelName, modelNode.tableName);
2070+
declaredTableNames.add(modelNode.modelName);
2071+
declaredTableNames.add(modelNode.tableName);
19662072
}
1967-
applyBackrelationCandidates({
2073+
const { synthesizedJunctions } = applyBackrelationCandidates({
19682074
backrelationCandidates,
19692075
fkRelationsByPair,
19702076
fkRelationsByDeclaringModel,
19712077
modelIdColumns,
2078+
modelTableNames,
2079+
modelNamespaceIds,
2080+
declaredTableNames,
19722081
modelRelations,
19732082
diagnostics,
19742083
sourceId,
19752084
});
19762085

2086+
// Inject a model-less junction table for each implicit many-to-many: a
2087+
// physical table the user never authored (Prisma's `_<A>To<B>` convention).
2088+
// The contract assembler turns it into a storage table and a domain model,
2089+
// and fills the through descriptors' `targetColumns` from the terminal ids.
2090+
for (const junction of synthesizedJunctions) {
2091+
modelNodes.push(buildSynthesizedJunctionModelNode(junction, modelNodes, modelNamespaceIds));
2092+
}
2093+
19772094
// Merge cross-space relations into modelRelations after local back-relation matching.
19782095
// Cross-space targets have no local back-relation candidates, so they bypass that step.
19792096
for (const [modelName, relations] of crossSpaceRelationsByModel) {
@@ -2160,10 +2277,15 @@ export function interpretPslDocumentToSqlContract(
21602277
});
21612278
}
21622279

2163-
const variantModelNames = new Set(baseDeclarations.keys());
2280+
// STI variants share the base table and synthesised junctions are physical
2281+
// tables only — neither is a queryable root.
2282+
const nonRootModelNames = new Set(baseDeclarations.keys());
2283+
for (const junction of synthesizedJunctions) {
2284+
nonRootModelNames.add(junction.junctionModelName);
2285+
}
21642286
const filteredRoots = Object.fromEntries(
21652287
Object.entries(contract.roots).filter(
2166-
([, crossReference]) => !variantModelNames.has(crossReference.model),
2288+
([, crossReference]) => !nonRootModelNames.has(crossReference.model),
21672289
),
21682290
);
21692291

0 commit comments

Comments
 (0)