Skip to content

Commit eeaea36

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 2e00f43 commit eeaea36

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

@@ -1198,6 +1201,104 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
11981201
};
11991202
}
12001203

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

2090+
// Inject a model-less junction table for each implicit many-to-many: a
2091+
// physical table the user never authored (Prisma's `_<A>To<B>` convention).
2092+
// The contract assembler turns it into a storage table and a domain model,
2093+
// and fills the through descriptors' `targetColumns` from the terminal ids.
2094+
for (const junction of synthesizedJunctions) {
2095+
modelNodes.push(buildSynthesizedJunctionModelNode(junction, modelNodes, modelNamespaceIds));
2096+
}
2097+
19812098
// Merge cross-space relations into modelRelations after local back-relation matching.
19822099
// Cross-space targets have no local back-relation candidates, so they bypass that step.
19832100
for (const [modelName, relations] of crossSpaceRelationsByModel) {
@@ -2164,10 +2281,15 @@ export function interpretPslDocumentToSqlContract(
21642281
});
21652282
}
21662283

2167-
const variantModelNames = new Set(baseDeclarations.keys());
2284+
// STI variants share the base table and synthesised junctions are physical
2285+
// tables only — neither is a queryable root.
2286+
const nonRootModelNames = new Set(baseDeclarations.keys());
2287+
for (const junction of synthesizedJunctions) {
2288+
nonRootModelNames.add(junction.junctionModelName);
2289+
}
21682290
const filteredRoots = Object.fromEntries(
21692291
Object.entries(contract.roots).filter(
2170-
([, crossReference]) => !variantModelNames.has(crossReference.model),
2292+
([, crossReference]) => !nonRootModelNames.has(crossReference.model),
21712293
),
21722294
);
21732295

0 commit comments

Comments
 (0)