Skip to content

TML-3068: reject a singular back-relation over a non-unique FK - #1015

Merged
wmadden-electric merged 1 commit into
mainfrom
tml-3068-emit-accepts-a-singular-back-relation-over-a-non-unique-fk
Jul 21, 2026
Merged

TML-3068: reject a singular back-relation over a non-unique FK#1015
wmadden-electric merged 1 commit into
mainfrom
tml-3068-emit-accepts-a-singular-back-relation-over-a-non-unique-fk

Conversation

@wmadden-electric

@wmadden-electric wmadden-electric commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Linked issue

Refs TML-3068

At a glance

On main, this schema emits a contract claiming a 1:1 relation the database cannot guarantee:

model Profile {
  id     Int  @id
  userId Int              // no @unique
  user   User @relation(fields: [userId], references: [id])
}

model User {
  id      Int      @id
  profile Profile? // silently lowered to cardinality '1:1'
}

After this PR, that schema fails emit with PSL_NON_UNIQUE_BACKRELATION, telling the author to add @unique/@@unique to the FK fields or make the field a list.

Decision

Before lowering a singular back-relation to cardinality: '1:1', the interpreter verifies the matched FK's local columns exactly cover one of the declaring model's unique sets — single @unique, composite @@unique, or the primary key. Exact cover means order-insensitive set equality: @@unique([a, b]) does not qualify an FK on [a] alone (a superset unique guarantees nothing about the subset). On failure the new diagnostic joins the existing family (PSL_ORPHANED_BACKRELATION, PSL_AMBIGUOUS_BACKRELATION).

The gap: #1011 taught the interpreter to accept the singular back-relation shape contract infer prints — but infer only prints it when the FK is unique, so the acceptance path never checked. Hand-authored PSL has no such guarantee.

The implementation is lifted from the parked branch worktree/contract-infer-relationships-bcbafa (the fkColumnsAreUnique exact-cover check and its boundary tests), adapted to main's generalized diagnostic family.

Behavior changes & evidence

  • Non-unique singular back-relations are rejected at emit with a fix-naming diagnostic. Implementation: packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts (fkColumnsAreUnique + the check in the singular exactly-one-match path); packages/2-sql/2-authoring/contract-psl/src/interpreter.ts (builds modelUniqueColumnSets alongside the existing modelIdColumns). Evidence: packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.one-to-one.test.ts — both rejection cases reproduced red against main's code before the fix.
  • Valid 1:1 shapes are unaffected: composite @@unique exact match, order-insensitive column order, and PK-covering FKs all still lower to '1:1' (same test file).

Reviewer notes

  • contract infer output is unaffected — infer's uniqueness detection was already correct; this closes the hand-authoring path only. No fixture or pack contract changed (pnpm fixtures:check clean).
  • One pre-existing portal "C_N" does not exist flake reproduced in sql-orm-client integration tests during validation and passed in isolation — the documented portal-race family, unrelated to this diff.

Testing performed

  • pnpm typecheck · pnpm lint · pnpm fixtures:check — green
  • pnpm test:packages — 13190 passed
  • pnpm test:integration — 1172/1173 + 1 portal-race flake, flaked file 5/5 green in isolation
  • contract-psl suite 24 files / 377 passed; CLI suite 106 files / 1346 passed

Skill update

n/a — new diagnostic code only; wording follows the existing backrelation family. No CLI/API surface change.

Alternatives considered

Trust the shape because infer produces it. That was #1011's implicit position; it holds only for inferred PSL. Emit is a validation boundary for hand-authored schemas too.

Rewrite instead of lifting. The parked branch's check and boundary tests were already reviewed once (exact-cover semantics, superset rejection); rewriting would re-derive settled decisions.

Checklist

  • All commits are signed off (git commit -s) per the DCO. The DCO status check will block merge if any commit is missing a Signed-off-by: trailer.
  • I read CONTRIBUTING.md and the change is scoped to one logical concern.
  • Tests are updated (or n/a if the change is doc-only / refactor with no behavioural delta).
  • 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.
  • The Skill update section above is filled in (or stated n/a — internal only).

Summary by CodeRabbit

  • Bug Fixes

    • Improved one-to-one back-relation validation by requiring foreign-key columns to be uniquely constrained.
    • Prevented ambiguous singular relations from being created when uniqueness cannot be verified.
    • Added support for composite unique constraints, including differing column order and primary-key-based uniqueness.
  • Tests

    • Added coverage for valid and invalid one-to-one back-relation scenarios, including diagnostic reporting.

applyBackrelationCandidates lowered a matched singular back-relation
candidate (e.g. `profile Profile?`) to cardinality '1:1' without
checking whether the matched FK's local columns are actually unique
on the declaring model. A hand-authored singular back-relation over a
plain (non-unique) FK was silently accepted as 1:1, a shape `contract
infer` never produces on its own.

Add fkColumnsAreUnique, an exact-cover check against the declaring
model's id and @unique/@@unique column sets, and run it before
emitting the 1:1 relation for a singular candidate. A non-unique match
now emits PSL_NON_UNIQUE_BACKRELATION, naming both fixes (add
@unique/@@unique to the FK, or make the field a list). Thread
modelUniqueColumnSets through interpretPslDocumentToSqlContract the
same way modelIdColumns already flows to applyBackrelationCandidates.

Implementation lifted from the parked branch
worktree/contract-infer-relationships-bcbafa, adapted to main's
current diagnostic-code family (PSL_ORPHANED_BACKRELATION /
PSL_AMBIGUOUS_BACKRELATION already cover both list and singular
candidates on main, so only the missing uniqueness check needed
lifting) and to main's already-unified isList-based candidate shape.

Refs: TML-3068
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
@wmadden-electric
wmadden-electric requested a review from a team as a code owner July 21, 2026 09:13
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 36efdb2f-5dcd-4688-a07d-d47fdf9df957

📥 Commits

Reviewing files that changed from the base of the PR and between 649e805 and 8da9b0d.

📒 Files selected for processing (3)
  • packages/2-sql/2-authoring/contract-psl/src/interpreter.ts
  • packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts
  • packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.one-to-one.test.ts

📝 Walkthrough

Walkthrough

The PSL interpreter now passes all model unique column sets into backrelation resolution. Singular backrelations require exact foreign-key uniqueness, with diagnostics emitted for non-unique matches. New tests cover identifier, composite unique, column-order, subset, and non-unique cases.

Changes

1:1 backrelation uniqueness

Layer / File(s) Summary
Collect model uniqueness metadata
packages/2-sql/2-authoring/contract-psl/src/interpreter.ts
The interpreter aggregates each model’s identifier and @@unique column sets and passes them to backrelation resolution.
Validate singular backrelations
packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts, packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.one-to-one.test.ts
Singular backrelations require exact unique foreign-key column matches; invalid matches emit PSL_NON_UNIQUE_BACKRELATION, with positive and negative cases covered by tests.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PSLInterpreter
  participant BackrelationResolver
  participant UniquenessChecker
  participant Diagnostics
  PSLInterpreter->>BackrelationResolver: provide model unique column sets
  BackrelationResolver->>UniquenessChecker: check singular FK columns
  UniquenessChecker-->>BackrelationResolver: return uniqueness result
  BackrelationResolver->>Diagnostics: emit PSL_NON_UNIQUE_BACKRELATION when invalid
Loading

Possibly related PRs

  • prisma/prisma-next#1011: Modifies the same contract-PSL backrelation resolution path and singular foreign-key matching logic.

Suggested reviewers: sevinf

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: rejecting singular back-relations when the FK is not unique.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch tml-3068-emit-accepts-a-singular-back-relation-over-a-non-unique-fk

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Jul 21, 2026

Copy link
Copy Markdown

Open in StackBlitz

@prisma-next/extension-author-tools

npm i https://pkg.pr.new/@prisma-next/extension-author-tools@1015

@prisma-next/mongo-runtime

npm i https://pkg.pr.new/@prisma-next/mongo-runtime@1015

@prisma-next/family-mongo

npm i https://pkg.pr.new/@prisma-next/family-mongo@1015

@prisma-next/sql-runtime

npm i https://pkg.pr.new/@prisma-next/sql-runtime@1015

@prisma-next/family-sql

npm i https://pkg.pr.new/@prisma-next/family-sql@1015

@prisma-next/extension-arktype-json

npm i https://pkg.pr.new/@prisma-next/extension-arktype-json@1015

@prisma-next/middleware-cache

npm i https://pkg.pr.new/@prisma-next/middleware-cache@1015

@prisma-next/mongo

npm i https://pkg.pr.new/@prisma-next/mongo@1015

@prisma-next/extension-paradedb

npm i https://pkg.pr.new/@prisma-next/extension-paradedb@1015

@prisma-next/extension-pgvector

npm i https://pkg.pr.new/@prisma-next/extension-pgvector@1015

@prisma-next/extension-postgis

npm i https://pkg.pr.new/@prisma-next/extension-postgis@1015

@prisma-next/postgres

npm i https://pkg.pr.new/@prisma-next/postgres@1015

@prisma-next/sql-orm-client

npm i https://pkg.pr.new/@prisma-next/sql-orm-client@1015

@prisma-next/sqlite

npm i https://pkg.pr.new/@prisma-next/sqlite@1015

@prisma-next/extension-supabase

npm i https://pkg.pr.new/@prisma-next/extension-supabase@1015

@prisma-next/target-mongo

npm i https://pkg.pr.new/@prisma-next/target-mongo@1015

@prisma-next/adapter-mongo

npm i https://pkg.pr.new/@prisma-next/adapter-mongo@1015

@prisma-next/driver-mongo

npm i https://pkg.pr.new/@prisma-next/driver-mongo@1015

@prisma-next/contract

npm i https://pkg.pr.new/@prisma-next/contract@1015

@prisma-next/utils

npm i https://pkg.pr.new/@prisma-next/utils@1015

@prisma-next/config

npm i https://pkg.pr.new/@prisma-next/config@1015

@prisma-next/errors

npm i https://pkg.pr.new/@prisma-next/errors@1015

@prisma-next/framework-components

npm i https://pkg.pr.new/@prisma-next/framework-components@1015

@prisma-next/operations

npm i https://pkg.pr.new/@prisma-next/operations@1015

@prisma-next/ts-render

npm i https://pkg.pr.new/@prisma-next/ts-render@1015

@prisma-next/contract-authoring

npm i https://pkg.pr.new/@prisma-next/contract-authoring@1015

@prisma-next/ids

npm i https://pkg.pr.new/@prisma-next/ids@1015

@prisma-next/psl-parser

npm i https://pkg.pr.new/@prisma-next/psl-parser@1015

@prisma-next/psl-printer

npm i https://pkg.pr.new/@prisma-next/psl-printer@1015

@prisma-next/cli

npm i https://pkg.pr.new/@prisma-next/cli@1015

@prisma-next/cli-telemetry

npm i https://pkg.pr.new/@prisma-next/cli-telemetry@1015

@prisma-next/config-loader

npm i https://pkg.pr.new/@prisma-next/config-loader@1015

@prisma-next/emitter

npm i https://pkg.pr.new/@prisma-next/emitter@1015

@prisma-next/language-server

npm i https://pkg.pr.new/@prisma-next/language-server@1015

@prisma-next/migration-tools

npm i https://pkg.pr.new/@prisma-next/migration-tools@1015

prisma-next

npm i https://pkg.pr.new/prisma-next@1015

@prisma-next/vite-plugin-contract-emit

npm i https://pkg.pr.new/@prisma-next/vite-plugin-contract-emit@1015

@prisma-next/mongo-codec

npm i https://pkg.pr.new/@prisma-next/mongo-codec@1015

@prisma-next/mongo-contract

npm i https://pkg.pr.new/@prisma-next/mongo-contract@1015

@prisma-next/mongo-value

npm i https://pkg.pr.new/@prisma-next/mongo-value@1015

@prisma-next/mongo-contract-psl

npm i https://pkg.pr.new/@prisma-next/mongo-contract-psl@1015

@prisma-next/mongo-contract-ts

npm i https://pkg.pr.new/@prisma-next/mongo-contract-ts@1015

@prisma-next/mongo-emitter

npm i https://pkg.pr.new/@prisma-next/mongo-emitter@1015

@prisma-next/mongo-schema-ir

npm i https://pkg.pr.new/@prisma-next/mongo-schema-ir@1015

@prisma-next/mongo-query-ast

npm i https://pkg.pr.new/@prisma-next/mongo-query-ast@1015

@prisma-next/mongo-orm

npm i https://pkg.pr.new/@prisma-next/mongo-orm@1015

@prisma-next/mongo-query-builder

npm i https://pkg.pr.new/@prisma-next/mongo-query-builder@1015

@prisma-next/mongo-lowering

npm i https://pkg.pr.new/@prisma-next/mongo-lowering@1015

@prisma-next/mongo-wire

npm i https://pkg.pr.new/@prisma-next/mongo-wire@1015

@prisma-next/sql-contract

npm i https://pkg.pr.new/@prisma-next/sql-contract@1015

@prisma-next/sql-errors

npm i https://pkg.pr.new/@prisma-next/sql-errors@1015

@prisma-next/sql-operations

npm i https://pkg.pr.new/@prisma-next/sql-operations@1015

@prisma-next/sql-schema-ir

npm i https://pkg.pr.new/@prisma-next/sql-schema-ir@1015

@prisma-next/sql-contract-psl

npm i https://pkg.pr.new/@prisma-next/sql-contract-psl@1015

@prisma-next/sql-contract-ts

npm i https://pkg.pr.new/@prisma-next/sql-contract-ts@1015

@prisma-next/sql-contract-emitter

npm i https://pkg.pr.new/@prisma-next/sql-contract-emitter@1015

@prisma-next/sql-lane-query-builder

npm i https://pkg.pr.new/@prisma-next/sql-lane-query-builder@1015

@prisma-next/sql-relational-core

npm i https://pkg.pr.new/@prisma-next/sql-relational-core@1015

@prisma-next/sql-builder

npm i https://pkg.pr.new/@prisma-next/sql-builder@1015

@prisma-next/target-postgres

npm i https://pkg.pr.new/@prisma-next/target-postgres@1015

@prisma-next/target-sqlite

npm i https://pkg.pr.new/@prisma-next/target-sqlite@1015

@prisma-next/adapter-postgres

npm i https://pkg.pr.new/@prisma-next/adapter-postgres@1015

@prisma-next/adapter-sqlite

npm i https://pkg.pr.new/@prisma-next/adapter-sqlite@1015

@prisma-next/driver-postgres

npm i https://pkg.pr.new/@prisma-next/driver-postgres@1015

@prisma-next/driver-sqlite

npm i https://pkg.pr.new/@prisma-next/driver-sqlite@1015

commit: 8da9b0d

@github-actions

Copy link
Copy Markdown

size-limit report 📦

Path Size
postgres / no-emit 160.18 KB (0%)
postgres / emit 143.57 KB (0%)
mongo / no-emit 99.3 KB (0%)
mongo / emit 89.43 KB (0%)
cf-worker / no-emit 186.47 KB (0%)
cf-worker / emit 167.82 KB (0%)

@wmadden-electric
wmadden-electric added this pull request to the merge queue Jul 21, 2026
Merged via the queue into main with commit 089dc17 Jul 21, 2026
31 of 32 checks passed
@wmadden-electric
wmadden-electric deleted the tml-3068-emit-accepts-a-singular-back-relation-over-a-non-unique-fk branch July 21, 2026 09:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants