Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/2-sql/2-authoring/contract-psl/src/interpreter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
});
const relationAttribute = getAttribute(field.attributes, 'relation');
let relationName: string | undefined;
let through: string | undefined;
if (relationAttribute) {
const parsedRelation = interpretRelationAttribute({
selfModel: model,
Expand Down Expand Up @@ -595,6 +596,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
continue;
}
relationName = parsedRelation.name;
through = parsedRelation.through;
}
if (!attributesValid) {
continue;
Expand All @@ -606,6 +608,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
field,
targetModelName: field.typeName,
...ifDefined('relationName', relationName),
...ifDefined('through', through),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[question] When this candidate also matches a 1:N FK pair (applyBackrelationCandidates takes the matches.length === 1 path at psl-relation-resolution.ts:675), through is carried on the candidate but never consulted — the relation lowers as cardinality: '1:N' and the through: declaration is silently dropped. I verified: comments Comment[] @relation(through: NoSuchJunction) on a Post whose Comment has an FK back resolves to a 1:N with no diagnostic. through: is M:N-only per the spec, so a co-occurrence with a 1:N FK match is a user mistake (likely a typo'd junction name); silently honouring the 1:N over the explicit through: masks it. Worth at least a diagnostic when through is set on a candidate that resolves via the 1:N FK-pair path, or documenting that through: is ignored once a 1:N match exists.

});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,13 @@ import type {
FieldAttributeAst,
SourceFile,
} from '@prisma-next/psl-parser/syntax';
import { ArrayLiteralAst } from '@prisma-next/psl-parser/syntax';
import { ArrayLiteralAst, IdentifierAst } from '@prisma-next/psl-parser/syntax';
import type { ReferentialAction } from '@prisma-next/sql-contract/types';
import type { RelationNode } from '@prisma-next/sql-contract-ts/contract-builder';
import { assertDefined, invariant } from '@prisma-next/utils/assertions';
import { ifDefined } from '@prisma-next/utils/defined';
import type { Result } from '@prisma-next/utils/result';
import { ok } from '@prisma-next/utils/result';
import { notOk, ok } from '@prisma-next/utils/result';

import {
getAttribute,
Expand Down Expand Up @@ -77,6 +77,12 @@ export type ModelBackrelationCandidate = {
readonly field: FieldSymbol;
readonly targetModelName: string;
readonly relationName?: string;
/**
* The junction model named by `through:` on the list field. When present,
* many-to-many recognition considers only this junction rather than scanning
* every junction-shaped model linking the two sides.
*/
readonly through?: string;
};

type ModelRelationMetadata = RelationNode;
Expand Down Expand Up @@ -117,6 +123,36 @@ function fieldRefOrList(scope: FieldRefScope): ArgType<readonly string[]> {
};
}

/**
* Reads a bare model-name identifier argument value (`through: PostTag`). The
* expression grammar carries only the head identifier of a member-access
* value, so a qualified `through: PostTag.post` reaches this combinator as
* the bare model name `PostTag` — the qualified disambiguation form is a
* separate grammar change, and the bare name is all this slice recognises.
*/
function modelName(): ArgType<string> {
return {
kind: 'modelName',
label: 'model name',
parse: (arg, ctx): Result<string, readonly PslDiagnostic[]> => {
if (arg instanceof IdentifierAst) {
const name = arg.name();
if (name !== undefined) {
return ok(name);
}
}
return notOk([
{
code: 'PSL_INVALID_ATTRIBUTE_SYNTAX',
message: 'Expected a model name',
sourceId: ctx.sourceId,
span: nodePslSpan(arg.syntax, ctx.sourceFile),
},
]);
},
};
}

function relationInvariants(
parsed: {
readonly from?: readonly string[];
Expand Down Expand Up @@ -153,6 +189,7 @@ const sqlRelation = fieldAttribute('relation', {
name: optional(str()),
from: optional(fieldRefOrList('self')),
to: optional(fieldRefOrList('referenced')),
through: optional(modelName()),
map: optional(str()),
onDelete: optional(
oneOf(
Expand Down Expand Up @@ -194,6 +231,14 @@ export type ParsedSqlRelation = {
* two never co-occur.
*/
readonly referencesInferred?: true;
/**
* The junction model named by `through:` on a navigable list field, used to
* recognise the many-to-many via that explicit junction. A bare model
* identifier (`through: PostTag`); the qualified relation-field form
* (`through: PostTag.post`) is a separate member-access grammar and does not
* reach the resolver as a dotted value — only its head identifier survives.
*/
readonly through?: string;
readonly map?: string;
readonly onDelete?: SqlRelationOutput['onDelete'];
readonly onUpdate?: SqlRelationOutput['onUpdate'];
Expand Down Expand Up @@ -308,6 +353,7 @@ export function interpretRelationAttribute(input: {
...ifDefined('fields', fields),
...ifDefined('references', references),
...ifDefined('referencesInferred', referencesInferred),
...ifDefined('through', value.through),
...ifDefined('map', value.map),
...ifDefined('onDelete', value.onDelete),
...ifDefined('onUpdate', value.onUpdate),
Expand Down Expand Up @@ -491,6 +537,12 @@ function findJunctionFkPairs(input: {
const pairs: JunctionFkPair[] = [];
const nearMisses: JunctionNearMiss[] = [];
for (const [junctionModelName, junctionFks] of input.fkRelationsByDeclaringModel) {
// An explicit `through:` names the junction directly: skip every other
// junction-shaped model so recognition and near-miss reporting are scoped
// to the authored junction. A bare list (no `through:`) scans all of them.
if (input.candidate.through !== undefined && junctionModelName !== input.candidate.through) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

through: only affects the junction scan here, but findJunctionFkPairs is reached only after pairMatches is empty in applyBackrelationCandidates. That means an explicit list field like tags Tag[] @relation(through: PostTag) is silently lowered as a direct 1:N if Tag also has any FK back to Post, ignoring the authored junction. through: should either short-circuit into the junction path before direct FK matching, or report a conflict when both a direct FK and the named junction are present.

Comment on lines +540 to +543

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug] Scoping recognition to the named junction is correct, but the near-miss is only recorded when the named model links both sides (the recording lives inside the parentFk/childFk double loop). When through: names a model that has an FK to only one side, or names a model that doesn't exist / declares no FKs, no near-miss is recorded and the candidate falls through to the generic PSL_ORPHANED_BACKRELATION_LIST — whose message ("has no matching FK-side relation ... use an explicit join model") never mentions through: or the named junction. I verified this: through: NoSuchJunction and a one-sided through: PostTag (FK to Post only) both emit PSL_ORPHANED_BACKRELATION_LIST.

The slice spec's edge-case table (projects/psl-relation-syntax/slices/02-through-explicit-mn/spec.md) explicitly lists "through: names a model that isn't junction-shaped (no FK to one side / @@id ≠ the two FKs)" as requiring an actionable diagnostic that reuses the near-miss reasons — "do not silently fall through." The @@id ≠ two FKs sub-case is handled (the existing test covers it); the "no FK to one side" sub-case is not. Consider recording a dedicated near-miss / PSL_THROUGH_JUNCTION_NOT_FOUND diagnostic when candidate.through is set and the named model isn't recognised as a junction for this candidate, so the message can name the through: value and point the user at the missing FK side.

continue;
}
const idColumns = input.modelIdColumns.get(junctionModelName);
for (const parentFk of junctionFks) {
if (parentFk.targetModelName !== input.candidate.modelName) {
Expand Down
Loading
Loading