Skip to content

Commit 649e805

Browse files
TML-3037: contract infer output round-trips through contract emit (#1011)
## Linked issue Refs [TML-3037](https://linear.app/prisma-company/issue/TML-3037/contract-infer-output-round-trips-through-contract-emit) · Closes [TML-3024](https://linear.app/prisma-company/issue/TML-3024/contract-infer-correct-back-relation-field-pluralization-dont-double) · Supersedes #998 (closed unmerged — see *Alternatives considered* for why). ## At a glance Here is a fragment of `packages/3-extensions/supabase/scripts/generate-contract.ts`, on `main`, before this PR: ```ts const DOUBLE_PLURALIZED_FIELD_NAMES: ReadonlySet<string> = new Set([ 'icebergNamespaceses', 'icebergTableses', 'identitieses', 'mfaAmrClaimses', 'mfaChallengeses', 'mfaFactorses', 'oauthAuthorizationses', 'oauthConsentses', 'objectses', 'oneTimeTokenses', 'refreshTokenses', 's3MultipartUploadsPartses', // …twenty in total ]); ``` We ship a script that repairs `contract infer`'s output before our own `contract emit` will accept it. Alongside that list are tables of column, default, and index omissions — each with a careful comment explaining which part of our own pipeline rejects our own output. This PR deletes 170 lines of that script and fixes what it was working around. ## Decision 1. **A round-trip instrument exists**: introspect → infer → emit → `db verify --schema-only` against a live database, plus a runtime instrument that builds a real `ExecutionContext` from the emitted contract. Every defect below was reproduced by it before its fix. 2. **Seven infer→emit round-trip defects are fixed** (pluralization, 1:1 back-relations, `Decimal`-on-postgres, identity columns, index types, list literal defaults, `dbgenerated` default drift), and dropped dangling FKs are now explained in infer's output instead of vanishing silently. 3. **The eighth defect — `date` columns — is fixed halfway, deliberately.** The missing `pg/date@1` codec lands (with strict parsing: `2024-02-31` is rejected, not normalized into March), but nothing binds the `@db.Date` spelling to it. That binding belongs to **remove-db-attributes**, a parallel in-flight project that replaces PSL's `@db.*` attributes with first-class scalar types (slice 2 of it is open as #975); its future bare `Date` type is where the pin goes. Wiring the binding through `@db.*` machinery here would extend exactly what that project deletes. The runtime instrument pins the still-broken `.include()` decode so the future binding flips a red assertion instead of landing silently. ## Why the work exists A user sent in a 260-line `fix-inferred-contract.ts` they run after every `contract infer`. Auditing it claim-by-claim against the source turned up the defects. They had written our pack's repair script independently, against a different database, without ever seeing ours. Two people arriving at the same workaround is the finding: `contract infer` writes PSL that `contract emit` rejects, or that `db verify` reports as drift — and no test anywhere did introspect → infer → emit, which is why all of it shipped. So the first commit is the instrument, not a fix: a round-trip journey carrying already-plural table names, 1:1 / 1:N / self-referencing FKs, both identity variants, `serial`, bounded and unbounded `numeric`, `date`, `text[]`, `jsonb`, GIN and hash indexes, and an FK pointing out of scope. ## What was broken **Back-relation names double-pluralized.** `pluralize()` appended `es` to anything ending in `s`, so already-plural table names — most real schemas — came out `sessionses`. Now uses the maintained `pluralize` library. Closes TML-3024, whose bar was "the Supabase generator override is removed and the regenerated contract is unchanged by its removal". It is, and it is. **Infer printed a 1:1 back-relation emit couldn't parse.** A bare `profile Profile?`. The uniqueness detection producing it was correct; the interpreter collected back-relation candidates only `if (field.list)`. We had a snapshot test asserting we print PSL our own emitter rejects. **`Decimal` never worked on Postgres.** Not an infer bug: `Decimal` maps to `pg/numeric@1` on the base-scalar path with no `typeParams`, so any schema with a plain `amount Decimal` field threw `RUNTIME.CODEC_PARAMETERIZATION_MISMATCH` at connect. `NumericParams.precision` was required while every sibling temporal codec's param is optional, and three other layers already handled a missing precision. Infer is simply the first thing that generates a `Decimal`. **`date` columns break through `.include()`.** `@db.Date` inherits `DateTime`'s `pg/timestamptz@1`, whose `decodeJson` rejects the bare `YYYY-MM-DD` that `json_agg` renders. This PR ships the `pg/date@1` codec but leaves it unbound; see Decision 3. **Non-btree indexes could never emit.** Infer prints `@@index(…, type: "gin")`; the Postgres target registered zero index types, so emit threw `unregistered index type "gin"`. The target now registers its built-ins (`btree`, `hash`, `gin`, `gist`, `spgist`, `brin`) via the same `IndexTypeRegistry` mechanism ParadeDB uses for `bm25`; an unregistered type is still rejected. **Identity columns lost their default.** The columns query joined `pg_attribute` but never selected `attidentity`; `serial` worked only because it sets a real `nextval(...)` default. Identity maps onto `autoincrement()` symmetrically — infer emits it and the verify normalizer resolves a live identity column to it, so neither side drifts. PSL doesn't model `GENERATED ALWAYS` vs `BY DEFAULT`; a fresh `db init` from such a contract creates `serial` (pre-existing gap, [TML-3044](https://linear.app/prisma-company/issue/TML-3044)). **`dbgenerated` literal defaults drifted forever.** Emit kept `'{}'::jsonb` as `kind: 'function'`; introspection parsed the same literal to `kind: 'literal'`; `resolvedDefaultsEqual` compares `kind` first, so `db verify` reported such columns `not-equal` permanently. Normalization now happens once, at SchemaIR construction. **Dangling FKs dropped silently.** Infer correctly drops an FK whose target is outside the introspected scope, but said nothing — a user loses every `auth.users` relationship with no indication. It now says so, and points at the likely cause. ## Reviewer notes - **Prior review is absorbed, not pending.** The bulk of this branch carried two review rounds on #998 (a pre-open pass and a round of four architectural findings, all fixed at the root), and this PR itself had a three-lens local review whose artifacts ship in `projects/infer-emit-roundtrip/reviews/pr-1011/`; its findings are fixed in the last three commits (strict date parsing made real, `PSL_*_BACKRELATION_LIST` diagnostic codes renamed to drop the now-false `_LIST` suffix, deferred-binding marker + spec correction). - **The date-deferral delta is one commit** — `fix(contract-psl): defer the @db.Date -> pg/date@1 binding to remove-db-attributes` — and it returns the four type-channel files (`psl-column-resolution.ts`, `psl-named-type-resolution.ts`, the postgres adapter's `control-mutation-defaults.ts`, `scripts/lint-framework-vocabulary.config.json`) byte-identical to `main`. - **The `.include()` decode failure is bigger than dates**: [TML-3054](https://linear.app/prisma-company/issue/TML-3054/include-decode-breaks-for-most-non-trivial-native-types-codec) records that the timestamp codecs reject Postgres's own `json_agg` renderings and `numeric`/`int8` lose precision in the envelope parse. Out of scope here; the pinned date scenario is the first recorded instance of that class. - **The largest commits are the instrument and the pack regeneration.** The pack's `contract.prisma` diff is the proof-of-done: regenerating against this branch produces zero diff. - `projects/infer-emit-roundtrip/` (spec, plan, reproduction record, review artifacts) stays on disk for review; close-out deletes it after merge. `reproduction.md` records the pre-fix state verbatim and is deliberately not updated. ## Breaking changes Upgrade instructions are recorded in `skills/upgrade/prisma-next-upgrade/upgrades/0.15-to-0.16/` and the extension-author mirror. - **`dbgenerated(...)` literal defaults resolve differently** on the next emit — `storageHash` changes. - **Back-relation names change** on a future `contract infer` re-run (`sessionses` → `sessions`). These are public field names consumers type. - **Identity columns need an explicit default under `db verify --strict`** only; non-strict verify filters an undeclared live default out entirely. `@db.Date` columns do **not** change `codecId` in this PR — that entry rides with the future binding. Index-type registration and the `Decimal` fix are not breaking: both previously threw unconditionally. ## Testing performed On the final HEAD (after the review-rework commits): - `pnpm build` · `pnpm typecheck` · `pnpm lint` (incl. `lint:deps`, `lint:casts`, `lint:framework-vocabulary`) · `pnpm fixtures:check` · `pnpm check:upgrade-coverage` — all green - `pnpm test:packages` — 995 files / 13185 passed (3 expected-fail, 1 skipped) - `pnpm test:integration` — 204 files / 1173 passed - `pnpm test:e2e` — 20 files / 109 passed - Both round-trip instruments in isolation — 2 files / 13 passed - `pnpm --filter @prisma-next/extension-supabase run contract:generate` — zero diff against the committed contract ## Skill update `skills/upgrade/prisma-next-upgrade/upgrades/0.15-to-0.16/instructions.md` and the extension-author mirror carry the three breaking-change entries above. No CLI/API surface changed beyond what those entries describe. ## Follow-ups - `Date` binding: asked of the remove-db-attributes project (pin its bare `Date` to `pg/date@1`, via `codecId` — the descriptor carries a marker); the runtime instrument's pinned red assertion flips when it lands. - Deferred round-trip gaps, each filed: [TML-3041](https://linear.app/prisma-company/issue/TML-3041) (`@default(null)`), [TML-3042](https://linear.app/prisma-company/issue/TML-3042) (nullable lists), [TML-3043](https://linear.app/prisma-company/issue/TML-3043) (FK-less relations), [TML-3044](https://linear.app/prisma-company/issue/TML-3044) (identity DDL), [TML-3045](https://linear.app/prisma-company/issue/TML-3045) (array-returning function defaults), [TML-3048](https://linear.app/prisma-company/issue/TML-3048) (named-sequence `nextval`), [TML-3054](https://linear.app/prisma-company/issue/TML-3054) (`.include()` decode class). ## Alternatives considered **Merge #998 as-is.** This branch's first life. Its final review round bound `@db.Date` to `pg/date@1` through a double-keyed `scalarTypeDescriptors` map (scalar names and attribute names sharing one map) to keep the codec id out of family-layer code. It worked, but it extended the exact bespoke `@db.*` channel that remove-db-attributes deletes — new machinery with a planned demolition date. Closing #998 and re-cutting with the binding deferred cost one commit; merging would have cost that project a migration. **Bind the date spelling ourselves via a namespaced constructor (`pg.Date()`).** The existing pack-contribution channel supports it today, but remove-db-attributes already claims the `date` native type for its bare `Date`; shipping a second spelling now means two spellings with two bindings colliding at that project's printer handoff. **Eight separate PRs.** Every defect is an instance of one class, they share one instrument and one acceptance bar, and the evidence they're worth fixing is collective — the pack script shrinking wouldn't appear in any of them. **Make infer emit lists for 1:1 back-relations.** Would have made emit pass by discarding real information. The contract already supports `'1:1'`; the gap was PSL-side only. **Relax the list-default check to permit storage function defaults.** Admits `tags DateTime[] @default(now())`, whose DDL Postgres refuses — moving the error from authoring time to apply time. Review caught it; the literal-default fix makes the array case work without it. ## Checklist - [x] All commits are signed off (`git commit -s`) per the [DCO](../CONTRIBUTING.md#developer-certificate-of-origin-dco). The DCO status check will block merge if any commit is missing a `Signed-off-by:` trailer. - [x] I read [CONTRIBUTING.md](../CONTRIBUTING.md) and the change is scoped to one logical concern. - [x] Tests are updated (or `n/a` if the change is doc-only / refactor with no behavioural delta). - [x] The PR title is in `TML-NNNN: <sentence-case title>` form (Linear ticket prefix + concise title naming the concrete deliverable). See `.claude/skills/create-pr/SKILL.md` for the full convention. - [x] The **Skill update** section above is filled in (or stated `n/a — internal only`). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added PostgreSQL `date` support with reliable JSON and runtime encoding/decoding. * Added support for PostgreSQL identity columns and `autoincrement()` defaults. * Added PostgreSQL index-type support, including hash and GIN indexes. * Improved array default handling and support for unbounded numeric columns. * **Bug Fixes** * Improved one-to-one relation and backrelation detection, cardinality, and diagnostics. * Prevented double-pluralized inferred relation fields. * Added warnings for foreign keys referencing unavailable tables. * **Documentation** * Updated upgrade guidance for identity defaults and relation naming changes. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
1 parent e45302a commit 649e805

58 files changed

Lines changed: 2845 additions & 271 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/1-framework/3-tooling/cli/test/output.errors.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,14 +98,14 @@ describe('formatErrorOutput - issues list label and body fallback', () => {
9898
domain: 'RUN',
9999
summary: 'Failed to resolve contract source',
100100
meta: {
101-
issues: [{ kind: 'PSL_ORPHANED_BACKRELATION_LIST', message: 'orphaned backrelation list' }],
101+
issues: [{ kind: 'PSL_ORPHANED_BACKRELATION', message: 'orphaned backrelation list' }],
102102
},
103103
};
104104

105105
const flags = parseGlobalFlags({ verbose: true, 'no-color': true });
106106
const stripped = stripAnsi(formatErrorOutput(error, flags));
107107

108-
expect(stripped).toContain('[PSL_ORPHANED_BACKRELATION_LIST] orphaned backrelation list');
108+
expect(stripped).toContain('[PSL_ORPHANED_BACKRELATION] orphaned backrelation list');
109109
});
110110

111111
it('renders a schema-diff issue (no `message`) with a presence-derived `[missing] path/joined/with/slashes` label', () => {

packages/2-sql/1-core/schema-ir/test/sql-column-ir.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,4 +167,25 @@ describe('SqlColumnIR', () => {
167167
expect(column.resolvedDefault).toEqual({ kind: 'literal', value: 'x' });
168168
});
169169
});
170+
171+
describe('identity columns (introspected, no raw default)', () => {
172+
it('yields a default child node from resolvedDefault alone, with no raw default', () => {
173+
// A `GENERATED ... AS IDENTITY` column has no `column_default` at
174+
// all — the postgres control adapter sets `resolvedDefault` directly
175+
// to `autoincrement()` without a raw expression to parse, so
176+
// `children()` must still produce a default node without a `default`
177+
// (raw) field.
178+
const column = new SqlColumnIR({
179+
name: 'id',
180+
nativeType: 'int4',
181+
nullable: false,
182+
resolvedDefault: { kind: 'function', expression: 'autoincrement()' },
183+
});
184+
expect(column.children()).toEqual([
185+
new SqlColumnDefaultIR({
186+
resolved: { kind: 'function', expression: 'autoincrement()' },
187+
}),
188+
]);
189+
});
190+
});
170191
});

packages/2-sql/2-authoring/contract-psl/src/interpreter.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ import { blindCast } from '@prisma-next/utils/casts';
7979
import { ifDefined } from '@prisma-next/utils/defined';
8080
import { notOk, ok, type Result } from '@prisma-next/utils/result';
8181

82-
import { getAttribute, mapFieldNamesToColumns } from './psl-attribute-parsing';
82+
import { getAttribute, getNamedArgument, mapFieldNamesToColumns } from './psl-attribute-parsing';
8383
import type { ColumnDescriptor } from './psl-column-resolution';
8484
import {
8585
checkUncomposedNamespace,
@@ -103,7 +103,7 @@ import {
103103
interpretRelationAttribute,
104104
type ModelBackrelationCandidate,
105105
normalizeReferentialAction,
106-
validateNavigationListFieldAttributes,
106+
validateBackrelationFieldAttributes,
107107
} from './psl-relation-resolution';
108108
import {
109109
baseModelSpec,
@@ -668,6 +668,21 @@ interface BuildModelNodeResult {
668668
readonly modelAttributeEntities: Readonly<Record<string, Readonly<Record<string, unknown>>>>;
669669
}
670670

671+
/**
672+
* The owning side of a relation is the one that declares `fields`/`references` on its
673+
* `@relation` attribute — those name the FK columns. A singular model-typed field whose
674+
* `@relation` carries only a name (or nothing at all) is the back side: infer prints exactly
675+
* that shape for a 1:1 back-relation whenever the FK needs disambiguating (two FKs between the
676+
* same table pair, or a self-referencing unique FK). Checking for the attribute's mere presence
677+
* would misclassify that back side as the owning side.
678+
*/
679+
function relationAttributeDeclaresOwningSide(relationAttribute: ResolvedAttribute): boolean {
680+
return (
681+
getNamedArgument(relationAttribute, 'fields') !== undefined ||
682+
getNamedArgument(relationAttribute, 'references') !== undefined
683+
);
684+
}
685+
671686
function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult {
672687
const { model, mapping, sourceId, diagnostics } = input;
673688
const tableName = mapping.tableName;
@@ -726,10 +741,20 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
726741

727742
const resultBackrelationCandidates: ModelBackrelationCandidate[] = [];
728743
for (const field of Object.values(model.fields)) {
729-
if (!field.list || !input.modelNames.has(field.typeName)) {
744+
if (!input.modelNames.has(field.typeName)) {
745+
continue;
746+
}
747+
const relationAttribute = getAttribute(field.attributes, 'relation');
748+
if (
749+
!field.list &&
750+
relationAttribute &&
751+
relationAttributeDeclaresOwningSide(relationAttribute)
752+
) {
753+
// The owning side of the relation: it declares fields/references and is
754+
// lowered separately below, by the `relationAttributes` FK-building loop.
730755
continue;
731756
}
732-
const attributesValid = validateNavigationListFieldAttributes({
757+
const attributesValid = validateBackrelationFieldAttributes({
733758
modelName: model.name,
734759
field,
735760
sourceId,
@@ -739,7 +764,6 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
739764
familyId: input.familyId,
740765
targetId: input.targetId,
741766
});
742-
const relationAttribute = getAttribute(field.attributes, 'relation');
743767
let relationName: string | undefined;
744768
if (relationAttribute) {
745769
const parsedRelation = interpretRelationAttribute({
@@ -786,6 +810,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
786810
tableName,
787811
field,
788812
targetModelName: field.typeName,
813+
isList: field.list,
789814
...ifDefined('relationName', relationName),
790815
});
791816
}
@@ -1095,6 +1120,13 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
10951120
continue;
10961121
}
10971122

1123+
if (!relationAttributeDeclaresOwningSide(relationAttribute.relation)) {
1124+
// A singular model-typed field whose `@relation` carries only a name (or nothing) is the
1125+
// back side of a 1:1 relation, already lowered above via backrelationCandidates. It is
1126+
// not the owning side, so it has no fields/references to validate here.
1127+
continue;
1128+
}
1129+
10981130
// Cross-contract-space relation: the target model lives in a different contract space
10991131
// identified by `typeContractSpaceId` (e.g. `supabase:auth.User`).
11001132
if (fieldTypeContractSpaceId !== undefined) {

packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,13 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv
347347
if (field.typeContractSpaceId !== undefined && relationAttribute) {
348348
continue;
349349
}
350+
// A model-typed, non-list field with no `@relation` is the back side of a
351+
// 1:1 relation — the owning side always carries `@relation(fields: [...],
352+
// references: [...])`. It is lowered separately, via the interpreter's
353+
// backrelation-candidate matching, not as a scalar column here.
354+
if (isModelField) {
355+
continue;
356+
}
350357

351358
const isValueObjectField = compositeTypeNames.has(field.typeName);
352359
const isListField = field.list;

packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts

Lines changed: 36 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ export type ModelBackrelationCandidate = {
6262
readonly tableName: string;
6363
readonly field: FieldSymbol;
6464
readonly targetModelName: string;
65+
/** Whether the PSL field itself is list-typed (`Target[]`) rather than singular (`Target?`). A singular candidate is the back side of a 1:1 relation and can never be many-to-many. */
66+
readonly isList: boolean;
6567
readonly relationName?: string;
6668
};
6769

@@ -453,44 +455,48 @@ export function applyBackrelationCandidates(input: {
453455
: [...pairMatches];
454456

455457
if (matches.length === 0) {
456-
const { pairs: junctionPairs, nearMisses } = findJunctionFkPairs({
457-
candidate,
458-
fkRelationsByDeclaringModel: input.fkRelationsByDeclaringModel,
459-
modelIdColumns: input.modelIdColumns,
460-
});
461-
const junctionPair = junctionPairs[0];
462-
if (junctionPairs.length === 1 && junctionPair) {
463-
relationsForModel(input.modelRelations, candidate.modelName).push(
464-
manyToManyRelationNode(candidate, junctionPair),
465-
);
466-
continue;
467-
}
468-
if (junctionPairs.length > 1) {
469-
input.diagnostics.push({
470-
code: 'PSL_AMBIGUOUS_BACKRELATION_LIST',
471-
message: `Backrelation list field "${candidate.modelName}.${candidate.field.name}" matches multiple junction FK pairs for a many-to-many relation. Add @relation(name: "...") (or @relation("...")) to the list field and the junction FK-side relation pointing back at "${candidate.modelName}" to disambiguate.`,
472-
sourceId: input.sourceId,
473-
span: candidate.field.span,
458+
// A singular candidate is the back side of a 1:1 — many-to-many junction
459+
// matching only makes sense for a list-typed backrelation.
460+
if (candidate.isList) {
461+
const { pairs: junctionPairs, nearMisses } = findJunctionFkPairs({
462+
candidate,
463+
fkRelationsByDeclaringModel: input.fkRelationsByDeclaringModel,
464+
modelIdColumns: input.modelIdColumns,
474465
});
475-
continue;
476-
}
477-
const nearMiss = nearMisses[0];
478-
if (nearMiss) {
479-
input.diagnostics.push(junctionNearMissDiagnostic(candidate, nearMiss, input.sourceId));
480-
continue;
466+
const junctionPair = junctionPairs[0];
467+
if (junctionPairs.length === 1 && junctionPair) {
468+
relationsForModel(input.modelRelations, candidate.modelName).push(
469+
manyToManyRelationNode(candidate, junctionPair),
470+
);
471+
continue;
472+
}
473+
if (junctionPairs.length > 1) {
474+
input.diagnostics.push({
475+
code: 'PSL_AMBIGUOUS_BACKRELATION',
476+
message: `Backrelation list field "${candidate.modelName}.${candidate.field.name}" matches multiple junction FK pairs for a many-to-many relation. Add @relation(name: "...") (or @relation("...")) to the list field and the junction FK-side relation pointing back at "${candidate.modelName}" to disambiguate.`,
477+
sourceId: input.sourceId,
478+
span: candidate.field.span,
479+
});
480+
continue;
481+
}
482+
const nearMiss = nearMisses[0];
483+
if (nearMiss) {
484+
input.diagnostics.push(junctionNearMissDiagnostic(candidate, nearMiss, input.sourceId));
485+
continue;
486+
}
481487
}
482488
input.diagnostics.push({
483-
code: 'PSL_ORPHANED_BACKRELATION_LIST',
484-
message: `Backrelation list field "${candidate.modelName}.${candidate.field.name}" has no matching FK-side relation on model "${candidate.targetModelName}". Add @relation(fields: [...], references: [...]) on the FK-side relation or use an explicit join model for many-to-many.`,
489+
code: 'PSL_ORPHANED_BACKRELATION',
490+
message: `Backrelation field "${candidate.modelName}.${candidate.field.name}" has no matching FK-side relation on model "${candidate.targetModelName}". Add @relation(fields: [...], references: [...]) on the FK-side relation${candidate.isList ? ' or use an explicit join model for many-to-many' : ''}.`,
485491
sourceId: input.sourceId,
486492
span: candidate.field.span,
487493
});
488494
continue;
489495
}
490496
if (matches.length > 1) {
491497
input.diagnostics.push({
492-
code: 'PSL_AMBIGUOUS_BACKRELATION_LIST',
493-
message: `Backrelation list field "${candidate.modelName}.${candidate.field.name}" matches multiple FK-side relations on model "${candidate.targetModelName}". Add @relation(name: "...") (or @relation("...")) to both sides to disambiguate.`,
498+
code: 'PSL_AMBIGUOUS_BACKRELATION',
499+
message: `Backrelation field "${candidate.modelName}.${candidate.field.name}" matches multiple FK-side relations on model "${candidate.targetModelName}". Add @relation(name: "...") (or @relation("...")) to both sides to disambiguate.`,
494500
sourceId: input.sourceId,
495501
span: candidate.field.span,
496502
});
@@ -506,7 +512,7 @@ export function applyBackrelationCandidates(input: {
506512
toModel: matched.declaringModelName,
507513
toTable: matched.declaringTableName,
508514
...ifDefined('toNamespaceId', matched.declaringNamespaceId),
509-
cardinality: '1:N',
515+
cardinality: candidate.isList ? '1:N' : '1:1',
510516
on: {
511517
parentTable: candidate.tableName,
512518
parentColumns: matched.referencedColumns,
@@ -517,7 +523,7 @@ export function applyBackrelationCandidates(input: {
517523
}
518524
}
519525

520-
export function validateNavigationListFieldAttributes(input: {
526+
export function validateBackrelationFieldAttributes(input: {
521527
readonly modelName: string;
522528
readonly field: FieldSymbol;
523529
readonly sourceId: string;

packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -500,7 +500,7 @@ model Post {
500500
expect(result.failure.diagnostics).toEqual(
501501
expect.arrayContaining([
502502
expect.objectContaining({
503-
code: 'PSL_ORPHANED_BACKRELATION_LIST',
503+
code: 'PSL_ORPHANED_BACKRELATION',
504504
message: expect.stringContaining('User.posts'),
505505
}),
506506
]),
@@ -534,7 +534,7 @@ model Post {
534534
expect(result.failure.diagnostics).toEqual(
535535
expect.arrayContaining([
536536
expect.objectContaining({
537-
code: 'PSL_AMBIGUOUS_BACKRELATION_LIST',
537+
code: 'PSL_AMBIGUOUS_BACKRELATION',
538538
message: expect.stringContaining('User.posts'),
539539
}),
540540
]),

packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.many-to-many.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,7 @@ model Follow {
428428
expect(result.failure.diagnostics).toEqual(
429429
expect.arrayContaining([
430430
expect.objectContaining({
431-
code: 'PSL_AMBIGUOUS_BACKRELATION_LIST',
431+
code: 'PSL_AMBIGUOUS_BACKRELATION',
432432
message: expect.stringContaining('User.follows'),
433433
}),
434434
]),
@@ -566,7 +566,7 @@ model TagWatch {
566566
expect(result.failure.diagnostics).toEqual(
567567
expect.arrayContaining([
568568
expect.objectContaining({
569-
code: 'PSL_AMBIGUOUS_BACKRELATION_LIST',
569+
code: 'PSL_AMBIGUOUS_BACKRELATION',
570570
message: expect.stringContaining('User.ownedTags'),
571571
}),
572572
]),
@@ -590,7 +590,7 @@ model Tag {
590590
expect(result.failure.diagnostics).toEqual(
591591
expect.arrayContaining([
592592
expect.objectContaining({
593-
code: 'PSL_ORPHANED_BACKRELATION_LIST',
593+
code: 'PSL_ORPHANED_BACKRELATION',
594594
message: expect.stringContaining('Post.tags'),
595595
}),
596596
]),

0 commit comments

Comments
 (0)