TML-2942: disambiguate relations by pointing, retire @relation(name:)#874
TML-2942: disambiguate relations by pointing, retire @relation(name:)#874tensordreams wants to merge 7 commits into
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
@prisma-next/extension-author-tools
@prisma-next/mongo-runtime
@prisma-next/family-mongo
@prisma-next/sql-runtime
@prisma-next/family-sql
@prisma-next/extension-arktype-json
@prisma-next/middleware-cache
@prisma-next/mongo
@prisma-next/extension-paradedb
@prisma-next/extension-pgvector
@prisma-next/extension-postgis
@prisma-next/postgres
@prisma-next/sql-orm-client
@prisma-next/sqlite
@prisma-next/extension-supabase
@prisma-next/target-mongo
@prisma-next/adapter-mongo
@prisma-next/driver-mongo
@prisma-next/contract
@prisma-next/utils
@prisma-next/config
@prisma-next/errors
@prisma-next/framework-components
@prisma-next/operations
@prisma-next/ts-render
@prisma-next/contract-authoring
@prisma-next/ids
@prisma-next/psl-parser
@prisma-next/psl-printer
@prisma-next/cli
@prisma-next/cli-telemetry
@prisma-next/config-loader
@prisma-next/emitter
@prisma-next/language-server
@prisma-next/migration-tools
prisma-next
@prisma-next/vite-plugin-contract-emit
@prisma-next/mongo-codec
@prisma-next/mongo-contract
@prisma-next/mongo-value
@prisma-next/mongo-contract-psl
@prisma-next/mongo-contract-ts
@prisma-next/mongo-emitter
@prisma-next/mongo-schema-ir
@prisma-next/mongo-query-ast
@prisma-next/mongo-orm
@prisma-next/mongo-query-builder
@prisma-next/mongo-lowering
@prisma-next/mongo-wire
@prisma-next/sql-contract
@prisma-next/sql-errors
@prisma-next/sql-operations
@prisma-next/sql-schema-ir
@prisma-next/sql-contract-psl
@prisma-next/sql-contract-ts
@prisma-next/sql-contract-emitter
@prisma-next/sql-lane-query-builder
@prisma-next/sql-relational-core
@prisma-next/sql-builder
@prisma-next/target-postgres
@prisma-next/target-sqlite
@prisma-next/adapter-postgres
@prisma-next/adapter-sqlite
@prisma-next/driver-postgres
@prisma-next/driver-sqlite
commit: |
size-limit report 📦
|
acd5bd6 to
a3fea80
Compare
4aeb245 to
f422aef
Compare
| ...ifDefined('referencesInferred', referencesInferred), | ||
| ...ifDefined('through', through && through.length > 0 ? through : undefined), | ||
| ...ifDefined('through', through), | ||
| ...ifDefined('inverse', inverse !== undefined && inverse.length > 0 ? inverse : undefined), |
There was a problem hiding this comment.
This accepts through: and inverse: on the same list relation and carries both into the candidate. Resolution then checks candidate.inverse first and continues after building a 1:N, so the through: half is silently ignored. Since these two arguments describe different relation shapes, please reject the combination in parseRelationAttribute (and add a negative test) instead of letting priority order decide.
a3fea80 to
f2764a7
Compare
f422aef to
b253568
Compare
…n, retire name:) Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
The attribute-argument-value grammar carried only the head identifier of a dotted value: `through: Junction.field` parsed as the bare `Junction`, dropping `.field`. Extend the identifier-leading value alternative so a qualified `Identifier.Identifier` parses as a `QualifiedName` chain that preserves every segment, reusing the `Dot` handling that type annotations and call names already share. The resolved argument value renders the node's full source, so `getNamedArgument(attr, 'through')` returns the dotted string `"Foo.bar"` for `through: Foo.bar`; the downstream resolver splits on `.`. Bare identifiers (`through: Foo`) stay `Identifier` nodes and bracketed lists (`from: [a, b]`) are unaffected. Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
Resolve self-relation and multiple-M:N-between-same-models ambiguity by
pinning the parent-side junction FK to the relation field named in a
qualified `through: Junction.relationField`. The S3 member-access grammar
now delivers the full dotted value to the resolver, so:
- `ParsedThrough` captures `{ junction, field? }`. `through:` is parsed by
`parseThroughArgument` (junction = head, field = tail) rather than
routed through `stripModelQualifier`, whose after-the-dot semantics are
correct only for `from:`/`to:` (column is the tail), not `through:`
(junction is the head). `field` is threaded onto
`ModelBackrelationCandidate`.
- `findJunctionFkPairs` pins the parent-side FK by junction relation-field
name (`declaringFieldName === field`), and reports an actionable
`PSL_JUNCTION_THROUGH_FIELD_NOT_FK` near-miss when the named field is not
a parent-side junction FK back to the candidate.
- Stale comments describing the head-only grammar are refreshed; the
`to:` member-access test now asserts the tolerated-qualifier behaviour
the built grammar makes live (`to: User.id` lowers like `to: id`).
A bare `through: J` still defers to `PSL_AMBIGUOUS_BACKRELATION_LIST` when
ambiguous; the bare-list convention path is unchanged.
Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
A one-to-many back-relation list field that matches multiple FK-side relations on the target model is ambiguous and today requires @relation(name:) on both sides. Add inverse: <fkFieldName> to pin the back-relation to the FK-side relation whose declaring field it names — the directional replacement for name: on the 1:N back side. - Add inverse to the @relation arg allow-list; parse it as a bare relation-field name onto ParsedRelationAttribute.inverse and thread it onto the back-relation candidate. - In FK-side matching, when the candidate carries inverse: F, select the FK-side relation whose declaring field is F, resolving the ambiguity that today needs name:. - Diagnostic PSL_INVERSE_FIELD_NOT_FK when inverse: names a field that is not an FK-side relation back to the candidate. name:-based disambiguation stays accepted as legacy input. Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
… output The contract infer printer disambiguated relations between the same pair of models with a free-floating @relation(name: "...") string. Replace it with the pointer forms: the FK side already carries from/to, and the back/list side now emits inverse: <fkField> pointing at the FK-side relation field it pairs with. inferRelations threads the FK-side field name onto the back-relation as inverseOf whenever disambiguation is required (multiple FKs between the same models, or a self-relation); buildRelationField emits inverse: from it and no longer emits name: on either side. Legacy @relation(name:) remains accepted as input and is left untouched by format (auto-conversion to pointer form is a deferred follow-up). Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
…lations Adds two PSL fixtures + integration tests proving the S3 pointer-disambiguation forms drive the ORM end-to-end (PGlite, whole-row toEqual, explicit + implicit select): - disambiguated-1n-inverse: two relations between User and Post (Post.author / Post.editor, both N:1 FKs to User) with the User back-relations disambiguated by @relation(inverse: author/editor). include() over each back-relation returns the rows joined through the FK the inverse: pointer names (authoredPosts → author_id, editedPosts → editor_id). - self-ref-mn-through: a self-referential M:N (User.following / User.followers through the Follow junction) disambiguated by @relation(through: Follow.follower / Follow.followee). include() over each end drives the self-join of the junction + target onto the same users table and returns the correct per-user-distinct rows. Both fixtures are emitted through the real contract-emit pipeline (wired into the sql-orm-client emit script) and deserialized at module load, so each test also confirms the disambiguated contract round-trips the full sql contract validation pipeline. Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
…sambiguate with inverse:/through: J.field Complete the clean break begun in S1 (rejecting fields:/references:): the positional @relation("x") and the named @relation(name: "x") forms are now rejected as input in both the SQL and Mongo families, with a guiding PSL_LEGACY_RELATION_NAME diagnostic pointing at the directional replacements. Disambiguation is by pointing only: inverse: <fkField> for a 1:N back-relation with multiple candidates, through: Junction.field for an ambiguous many-to-many. The SQL family already had both pointer forms (S2/S3); the Mongo family gains inverse: parsing and matching here (it has no junction-based M:N recognition, so only the 1:N disambiguator applies). The relation-name string never reached the emitted contract, so the name:->inverse:/through: migration of the test schemas and the relation-backrelation-list parity fixture lowers byte-identically (fixtures:check clean). The legacy-name plumbing (relationName on ParsedRelationAttribute, FkRelationMetadata, and the backrelation candidates) is removed in both families. The two ambiguity diagnostics now recommend inverse:/through: instead of @relation(name:). Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
b253568 to
b52910d
Compare
f2764a7 to
2fad31e
Compare
tensordreams
left a comment
There was a problem hiding this comment.
Slice 3 lands the pointer-based disambiguation cleanly: the member-access grammar, inverse:/through: J.field recognition, actionable near-miss diagnostics, and the printer retiring name: are all correct and well-paired with parity fixtures. The implementation faithfully realises the settled design (design-notes D1 reversal / D2 — legacy name: is rejected with a guiding diagnostic). The findings below are a spec/doc inconsistency inside the PR's own artifacts and two test-coverage gaps rather than correctness defects: the back-relation lowering, junction leg pinning, and round-trip-via-ORM parity all hold.
| | Self-referential M:N (two FKs from the junction to the same model) | the core `through: J.field` case — both ends pin their leg; this is what slice 2 deferred | | ||
| | `through: J.field` where `field` isn't a junction FK back to the candidate | actionable diagnostic (not silent) | | ||
| | `inverse:` naming a field that isn't an FK-side relation to the candidate | actionable diagnostic | | ||
| | legacy `@relation(name:)` schema | still parses; lowers as today; survives `format` unchanged (not auto-converted) | |
There was a problem hiding this comment.
[doc] This edge-case row (and the DoD tick on line 43, "legacy name: still parses and survives format") contradicts the settled design this slice implements. design-notes.md D1 was reversed on 2026-06-26 to a clean break ("legacy fields:/references: and @relation(name:) are rejected at parse time") and D2 states legacy name: "is rejected at parse time (per D1 clean break), not merely dropped from output." The implementation matches D1/D2 — parseRelationAttribute in both SQL (psl-relation-resolution.ts) and Mongo (psl-helpers.ts) emits PSL_LEGACY_RELATION_NAME and returns undefined, and tests assert rejection. Update the spec's edge-case disposition to "rejected with PSL_LEGACY_RELATION_NAME guiding authors to inverse:/through:" and drop the format-survival DoD, per .agents/rules/doc-maintenance.mdc.
| expect(result).toContain('inverse:'); | ||
| }); | ||
|
|
||
| it('points each back-relation at the FK-side field it pairs with', () => { |
There was a problem hiding this comment.
[test] The printer's inverse: emission (asserted here) and inferRelations's inverseOf wiring (asserted in relation-inference.test.ts) are each tested in isolation, but nothing re-parses the printed @relation(inverse: sender) PSL and asserts it lowers back to the same contract. A mis-paired inverseOf (e.g. the back-relation pointing at the wrong childRelFieldName) would pass both unit tests yet fail to round-trip. The MULTI_FK_BETWEEN_SAME_MODELS IR is the natural seed: print it, feed the printed PSL through interpretPslDocumentToSqlContract, and assert the lowered User back-relations match the original pairings (messages→author_id, messagesMessage→recipient_id). This is the print-side analogue of S5·M5's parser→resolver parity.
| ); | ||
| }); | ||
|
|
||
| it('emits an actionable diagnostic when inverse: names a field that is not an FK-side relation', () => { |
There was a problem hiding this comment.
[test] Two cases the PR context calls out for test completeness aren't covered here. (a) Two back-relation list fields pointing at the SAME inverse: (x Post[] @relation(inverse: author) and y Post[] @relation(inverse: author)): both currently resolve and emit two relation nodes over the same FK with no duplicate/collision diagnostic — the slice's own pre-investigated edge table says "a relation that gratuitously specifies inverse:/through: … lean accept", but two SEPARATE back-relations colliding on one FK is a different shape; a test pins the intended behaviour either way. (b) inverse: on a composite-FK back-relation (from: [a, b]): the disambiguator is name-based so it should resolve, but no composite-FK + inverse: test exercises it. Add both so the inverse: contract is fully bounded.
| // back-relation: a bare relation-field name (no member-access grammar). It | ||
| // pins the back-relation to one of several relations linking the same pair of | ||
| // models, the directional replacement for a name-based disambiguator. | ||
| const inverseRaw = getNamedArgument(input.attribute, 'inverse'); |
There was a problem hiding this comment.
[question] The new member-access grammar accepts Identifier.Identifier as a value for any argument, and inverse: takes the raw string here (inverseRaw.trim()). So inverse: Post.author round-trips through the resolver as the literal field name "Post.author", which can never match declaringFieldName, yielding PSL_INVERSE_FIELD_NOT_FK mentioning "Post.author". The spec describes inverse: as "a bare relation-field name (no member-access grammar)" — should the resolver validate bare-only and emit a clearer PSL_INVALID_ATTRIBUTE_ARGUMENT for a dotted inverse: value (mirroring how through: explicitly splits the head/tail in parseThroughArgument), rather than surfacing the confusion as a not-an-FK diagnostic?
Linked issue
Refs TML-2942. Third of the PSL: Directional Relation Syntax stack — stacked on
tml-2941-s2-through-explicit-mn(TML-2941). Spec underprojects/psl-relation-syntax/slices/03-pointer-disambiguation/.At a glance
Disambiguation is by pointing at the relation field, not by a free-floating
@relation(name:)string.name:is rejected at parse time — in both the SQL and Mongo family resolvers — with aPSL_LEGACY_RELATION_NAMEdiagnostic directing authors toinverse:orthrough: Junction.field.Summary
Replaces the name-string disambiguation mechanism with directional pointers:
through: Junction.relationFieldfor ambiguous many-to-many (self-relations, or multiple M:N between the same models), andinverse: <fkField>for a 1:N back-relation with multiple candidates.@relation(name:)is rejected at parse time in both families (the clean-break decision, D1 reversed) — not merely dropped from output. The Mongo family resolver gainsinverse:support (it previously relied onname:for disambiguation).Decision
Three pieces, all in service of "disambiguate by pointing":
@prisma-next/psl-parser— an@relationargument value may be a qualifiedFoo.bar. (This also makes theto: Model.idqualifier work, resolving the S1-deferred edge case.)through: Junction.relationFieldresolves an ambiguous M:N by pinning the parent-side junction FK leg.inverse: <fkField>resolves a 1:N back-relation by pinning the owning FK field.@relation(name:)(and the positional@relation("...")form) is rejected at parse time in both the SQL and Mongo resolvers withPSL_LEGACY_RELATION_NAME. Thecontract inferprinter emitsinverse:on disambiguated back-relations; the entirerelationNameplumbing is removed from the SQL contract pipeline. The Mongo family'sinverse:support mirrors SQL's (with aPSL_INVERSE_FIELD_NOT_FKdiagnostic).How it fits together
packages/1-framework/2-authoring/psl-parser/src/parse.ts): a member-access expression parsesFoo.bar, preserving both segments; the resolver receives the full dotted string and splits it.contract-psl/src/psl-relation-resolution.ts):through.fieldpins the parent-side FK by declaring-field name, resolving the self-referential / multiple-M:N case that previously fell into the ambiguity diagnostic.inverse:branch selects the FK-side relation by declaring-field name, running before the ambiguity branches.psl-relation-resolution.tsandpackages/2-mongo-family/2-authoring/contract-psl/src/psl-helpers.ts):@relation(name:)and positional@relation("...")producePSL_LEGACY_RELATION_NAME;fields:/references:producePSL_LEGACY_FIELDS_REFERENCES(carried from S1).packages/2-sql/9-family/src/core/psl-contract-infer/sql-schema-ir-to-psl-ast.ts): emitsinverse:on a disambiguated back-relation instead ofname:; therelationNameplumbing is wholly removed.psl-helpers.ts): gainsinverse:reading + rejection of legacyname:/positional, mirroring SQL.Behavior changes & evidence
through: J.fielddisambiguates a self-referential / multiple M:N that is otherwisePSL_AMBIGUOUS_BACKRELATION_LIST.contract-psl/src/psl-relation-resolution.ts— evidence:packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.through.test.ts(the ambiguous-without-the-qualifier control proves the qualifier is what resolves it).inverse: <fkField>disambiguates a 1:N back-relation;inverse:≡ legacyname:(byte-identical lowering). evidence:packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.inverse.test.ts.@relation(name:)rejected in both SQL and Mongo; thecontract inferprinter never emitsname:. evidence:packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.relations.test.ts(grep gate).to: Model.idqualifier now works (deferred from S1, resolved by the member-access grammar).to: Post.id≡to: id, byte-identical.test/integration/test/sql-orm-client/self-ref-mn-through-parity.test.tsand…/disambiguated-1n-inverse-parity.test.ts(PGlite, asymmetric seeds so a mis-paired pointer fails).Notes for the reviewer
through:value split (junction = head, field = tail) deliberately does not route throughstripModelQualifier, whose "keep the tail" semantics are correct forfrom:/to:columns but wrong forthrough:.relationNameplumbing (therelationNamefield, its wiring through the contract-builder, its printer consumption) is wholly removed — no dangling reads remain.inverse:addition is the replacement for the Mongo family's previousname:-only disambiguation; thePSL_INVERSE_FIELD_NOT_FKdiagnostic mirrors SQL's.check:upgrade-coverageapplies (touchespackages/3-extensions/sql-orm-client) — see Skill update.Testing performed
pnpm --filter @prisma-next/sql-contract-psl test,@prisma-next/psl-parser test,@prisma-next/family-sql test,@prisma-next/mongo-family test; the relation/M:N integration suite on PGlite. Validated after the rebase:pnpm typecheck:packages(129/129),pnpm --filter -next/sql-contract-psl test(23 files, 314 tests),pnpm fixtures:check(zero drift), andpnpm lint:deps(0 violations).Skill update
This PR rejects
@relation(name:)input (a breaking change for both SQL and Mongo PSL authors) and addsinverse:to Mongo. Touchespackages/3-extensions/sql-orm-client, socheck:upgrade-coverageexpects achanges: []declaration in the in-flight upgrade cycle — flagged as a stack-wide follow-up.Checklist
name:rejection).TML-NNNN: …form.