Skip to content

Commit 508b9e0

Browse files
authored
TML-2794: M:N slice 5 — PSL authors many-to-many (junction → N:M + through) (#819)
**Slice 5 of the [SQL ORM: Many-to-Many End to End](https://linear.app/prisma-company/project/sql-orm-many-to-many-end-to-end-c178df40ca3a) project** (follow-on group). Refs TML-2794. > **Stacked PR** — base is `tml-2790-mn-demo-examples` (#742, slice 4); below that `tml-2787-slice-3-write` (#683) → `main`. ## Overview PSL could not author a navigable many-to-many relation: the relation resolver emitted only `N:1`/`1:N`, and a bare list field (`tags Tag[]`) with no FK pair was diagnosed as `PSL_ORPHANED_BACKRELATION_LIST` — `cardinality: 'N:M' + through` existed only via the TS contract builder. This PR teaches the PSL interpreter **explicit-junction recognition** (form 1, pinned at pickup): when a bare list field has a recognisable junction model — composite `@@id([a, b])` covering exactly the FK columns of two `N:1` relations to the two side models — the list lowers to a navigable `N:M` relation through that junction, shape-identical to `rel.manyToMany` output. ## Changes - `psl-relation-resolution.ts`: junction recognition in the previously-orphaned branch — `findJunctionFkPairs` + id-coverage gate + `manyToManyRelationNode`; child FK must reference the target's **full id** (set-equal), `through.childColumns` reordered to id order (the runtime joins `childColumns[i] = targetColumns[i]` by index); decline → existing orphaned diagnostic. Self-referential M:N disambiguated by the list's `@relation` name (pins the parent-side FK); unnamed symmetric lists get the ambiguity diagnostic. Surrogate-id join models are deliberately not recognised. Direct FK matches still win (stay `1:N`). - 10 lowering unit tests (`interpreter.relations.many-to-many.test.ts`): both directions, composite keys, swapped-order FK references, self-referential, diagnostic preservation, `validateContract` round-trip. - PSL-authored integration fixture `fixtures/mn-psl/` (`User ↔ Tag` via `user_tags`, mirroring the TS fixture) wired into the sql-orm-client emit pipeline so `fixtures:check` regenerates and diffs it. - `mn-psl-parity.test.ts`: 8 integration tests proving the full M:N ORM API (include explicit+implicit, `some`/`none`/`every`, connect, disconnect, nested create) against the PSL-emitted contract. ## Why The PG demo emits its contract from PSL, so it cannot show the M:N API until PSL can author it (slice 6 is blocked on this). Form 1 was chosen over Prisma-style implicit lists as the smallest surface that matches the existing diagnostic's guidance; implicit lists remain a clean follow-up. ## Scope PSL authoring layer + fixtures/tests only. No sql-orm-client runtime changes, no TS-builder changes, no demo changes (slice 6). Implicit-list authoring (form 2) deliberately excluded. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Improved many-to-many backrelation lowering via explicit junction models, including composite-key support and correct through/column ordering. * Enhanced FK/backrelation metadata to improve many-to-many resolution across namespaces. * **Bug Fixes / Improvements** * Added richer diagnostics for orphaned, ambiguous, and invalid many-to-many junction scenarios. * Strengthened full SQL contract validation for N:M through configurations (pairing, referenced column existence, and storage-type consistency). * **Tests** * Added unit tests for many-to-many lowering and diagnostics. * Added integration parity coverage for includes, filters, and nested writes using the mn-psl fixture. * **Documentation / Chores** * Updated Prisma Next emit flow and refreshed mn-psl fixture typing for UUID-style IDs. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
1 parent 524c016 commit 508b9e0

25 files changed

Lines changed: 2761 additions & 78 deletions

File tree

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

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
type SqlModelStorage,
3838
SqlStorage,
3939
type SqlStorageInput,
40+
type StorageColumn,
4041
type StorageTable,
4142
type StorageTypeInstanceInput,
4243
} from './types';
@@ -846,5 +847,208 @@ export function validateSqlContractFully<T extends Contract<SqlStorage>>(
846847
);
847848
}
848849
validateModelStorageReferences(validated);
850+
validateRelationThroughConsistency(validated);
849851
return validated;
850852
}
853+
854+
/** Storage column lookup for through-consistency validation. */
855+
function lookupStorageColumn(
856+
contract: Contract<SqlStorage>,
857+
namespaceId: string,
858+
tableName: string,
859+
columnName: string,
860+
): StorageColumn | undefined {
861+
const rawTable = contract.storage.namespaces[namespaceId]?.entries.table?.[tableName];
862+
if (rawTable === undefined) {
863+
return undefined;
864+
}
865+
const table = blindCast<StorageTable, 'structurally validated storage table'>(rawTable);
866+
return table.columns[columnName];
867+
}
868+
869+
/**
870+
* Two storage columns share a type when their `nativeType` and `typeParams`
871+
* match. The contract is canonicalized, so `typeParams` key order is stable and
872+
* a JSON comparison is exact. `codecId` and `nullable` are intentionally not
873+
* compared: they do not change the database-level type that governs a join.
874+
*/
875+
function sameStorageType(a: StorageColumn, b: StorageColumn): boolean {
876+
return (
877+
a.nativeType === b.nativeType &&
878+
JSON.stringify(a.typeParams ?? null) === JSON.stringify(b.typeParams ?? null)
879+
);
880+
}
881+
882+
function describeColumnType(column: StorageColumn): string {
883+
return column.typeParams === undefined
884+
? column.nativeType
885+
: `${column.nativeType} ${JSON.stringify(column.typeParams)}`;
886+
}
887+
888+
/**
889+
* Validates one side of an N:M join: the junction columns and the model
890+
* columns they pair against positionally must be equal in number, exist in
891+
* their tables, and share the same storage type (`nativeType` + `typeParams`).
892+
* The junction's storage foreign keys already guarantee this for user-declared
893+
* FK constraints, but `through` is a logical descriptor never tied to them by
894+
* the rest of validation — and the TS builder accepts explicit join columns
895+
* without requiring a junction FK at all — so this checks the columns directly
896+
* against storage, one path regardless of how the junction was authored.
897+
*
898+
* Joined columns must be the *same* storage type, not merely compatible:
899+
* relying on implicit conversion (e.g. `text`↔`character`) is unsafe on writes
900+
* — `character(n)` space-padding makes such coercions non-associative — and no
901+
* ADR sanctions heterogeneous junction columns. Equality is the conservative
902+
* default; it can be relaxed deliberately if a real use case ever appears.
903+
*/
904+
function validateThroughJoinSide(input: {
905+
readonly contract: Contract<SqlStorage>;
906+
readonly qualifiedName: string;
907+
readonly modelColumns: readonly string[];
908+
readonly modelColumnsLabel: string;
909+
/**
910+
* The model side of the join, when resolvable. Absent for a cross-space
911+
* target whose storage lives in another contract: the length and
912+
* junction-column-existence checks still run, but the target-column
913+
* existence and type checks are skipped because the target table is not
914+
* available here. `fieldToColumn` is present when `modelColumns` are domain
915+
* field names (the `on.*Fields` arrays), absent when they are already storage
916+
* column names (the `through.*Columns` arrays).
917+
*/
918+
readonly model?: {
919+
readonly namespaceId: string;
920+
readonly table: string;
921+
readonly fieldToColumn?: Readonly<Record<string, { readonly column: string }>>;
922+
};
923+
readonly junctionColumns: readonly string[];
924+
readonly junctionColumnsLabel: string;
925+
readonly junctionNamespaceId: string;
926+
readonly junctionTable: string;
927+
}): void {
928+
const fail = (detail: string): ContractValidationError =>
929+
new ContractValidationError(
930+
`Many-to-many relation "${input.qualifiedName}" ${detail}`,
931+
'storage',
932+
);
933+
if (input.junctionColumns.length !== input.modelColumns.length) {
934+
throw fail(
935+
`pairs ${input.junctionColumnsLabel} (${input.junctionColumns.length}) with ${input.modelColumnsLabel} (${input.modelColumns.length}) of differing length; they join positionally and must match.`,
936+
);
937+
}
938+
for (const [index, junctionColumnName] of input.junctionColumns.entries()) {
939+
const modelColumnRef = input.modelColumns[index];
940+
if (modelColumnRef === undefined) {
941+
continue;
942+
}
943+
const junctionColumn = lookupStorageColumn(
944+
input.contract,
945+
input.junctionNamespaceId,
946+
input.junctionTable,
947+
junctionColumnName,
948+
);
949+
if (junctionColumn === undefined) {
950+
throw fail(
951+
`${input.junctionColumnsLabel} references column "${junctionColumnName}" absent from junction table "${input.junctionNamespaceId}.${input.junctionTable}".`,
952+
);
953+
}
954+
// Cross-space target: its storage lives in another contract, so the target
955+
// column's existence and type cannot be checked here. Length and
956+
// junction-column existence have already been validated above.
957+
const model = input.model;
958+
if (model === undefined) {
959+
continue;
960+
}
961+
let modelColumnName = modelColumnRef;
962+
if (model.fieldToColumn !== undefined) {
963+
const mapped = model.fieldToColumn[modelColumnRef];
964+
if (mapped === undefined) {
965+
throw fail(
966+
`${input.modelColumnsLabel} references field "${modelColumnRef}" absent from model on table "${model.namespaceId}.${model.table}".`,
967+
);
968+
}
969+
modelColumnName = mapped.column;
970+
}
971+
const modelColumn = lookupStorageColumn(
972+
input.contract,
973+
model.namespaceId,
974+
model.table,
975+
modelColumnName,
976+
);
977+
if (modelColumn === undefined) {
978+
throw fail(
979+
`${input.modelColumnsLabel} references column "${modelColumnName}" absent from table "${model.namespaceId}.${model.table}".`,
980+
);
981+
}
982+
if (!sameStorageType(junctionColumn, modelColumn)) {
983+
throw fail(
984+
`joins "${input.junctionTable}.${junctionColumnName}" (${describeColumnType(junctionColumn)}) with "${model.table}.${modelColumnName}" (${describeColumnType(modelColumn)}) of differing storage type; junction columns must match the type of the column they reference.`,
985+
);
986+
}
987+
}
988+
}
989+
990+
/**
991+
* Validates that every N:M relation's `through` descriptor is consistent with
992+
* the storage columns it joins: both join sides match in column count,
993+
* reference columns that exist in their tables, and pair columns of the same
994+
* storage type. Without this, a `through` that disagrees with storage surfaces
995+
* as a silently wrong JOIN at query time rather than a validation error here.
996+
*/
997+
function validateRelationThroughConsistency(contract: Contract<SqlStorage>): void {
998+
for (const [namespaceId, namespace] of Object.entries(contract.domain.namespaces)) {
999+
for (const [modelName, model] of Object.entries(namespace.models)) {
1000+
for (const [relationName, relation] of Object.entries(model.relations)) {
1001+
if (relation.cardinality !== 'N:M') continue;
1002+
const qualifiedName = `${namespaceId}.${modelName}.${relationName}`;
1003+
const { on, through } = relation;
1004+
const modelStorage = blindCast<SqlModelStorage, 'SQL contract model storage'>(
1005+
model.storage,
1006+
);
1007+
// Parent side: the owning model's localFields (domain field names) join
1008+
// the junction's parentColumns (storage columns).
1009+
validateThroughJoinSide({
1010+
contract,
1011+
qualifiedName,
1012+
modelColumns: on.localFields,
1013+
modelColumnsLabel: 'on.localFields',
1014+
model: {
1015+
namespaceId,
1016+
table: modelStorage.table,
1017+
fieldToColumn: modelStorage.fields,
1018+
},
1019+
junctionColumns: through.parentColumns,
1020+
junctionColumnsLabel: 'through.parentColumns',
1021+
junctionNamespaceId: through.namespaceId,
1022+
junctionTable: through.table,
1023+
});
1024+
// Child side: the junction's childColumns join the target model's
1025+
// targetColumns. Length and junction-column existence are checked
1026+
// regardless; a cross-space target lives in another contract, so its
1027+
// column existence and type are checked only when it is resolvable here.
1028+
const targetModel =
1029+
relation.to.space === undefined
1030+
? contract.domain.namespaces[relation.to.namespace]?.models[relation.to.model]
1031+
: undefined;
1032+
const targetModelSide =
1033+
targetModel === undefined
1034+
? undefined
1035+
: {
1036+
namespaceId: relation.to.namespace,
1037+
table: blindCast<SqlModelStorage, 'SQL contract model storage'>(targetModel.storage)
1038+
.table,
1039+
};
1040+
validateThroughJoinSide({
1041+
contract,
1042+
qualifiedName,
1043+
modelColumns: through.targetColumns,
1044+
modelColumnsLabel: 'through.targetColumns',
1045+
...ifDefined('model', targetModelSide),
1046+
junctionColumns: through.childColumns,
1047+
junctionColumnsLabel: 'through.childColumns',
1048+
junctionNamespaceId: through.namespaceId,
1049+
junctionTable: through.table,
1050+
});
1051+
}
1052+
}
1053+
}
1054+
}

0 commit comments

Comments
 (0)