diff --git a/docs/architecture docs/adrs/ADR 121 - Contract.d.ts structure and relation typing.md b/docs/architecture docs/adrs/ADR 121 - Contract.d.ts structure and relation typing.md index 94293dc6f5..917f298427 100644 --- a/docs/architecture docs/adrs/ADR 121 - Contract.d.ts structure and relation typing.md +++ b/docs/architecture docs/adrs/ADR 121 - Contract.d.ts structure and relation typing.md @@ -40,7 +40,7 @@ model Post { id Int @id @default(autoincrement()) title String userId Int - user User @relation(fields: [userId], references: [id]) + user User @relation(from: [userId], to: [id]) createdAt DateTime @default(now()) } ``` @@ -235,7 +235,7 @@ model User { model Profile { id Int @id - user User @relation(fields: [userId], references: [id]) + user User @relation(from: [userId], to: [id]) userId Int @unique } ``` @@ -264,7 +264,7 @@ model User { model Post { id Int @id - user User @relation(fields: [userId], references: [id]) + user User @relation(from: [userId], to: [id]) userId Int } ``` diff --git a/docs/architecture docs/adrs/ADR 226 - Cross-contract foreign-key references.md b/docs/architecture docs/adrs/ADR 226 - Cross-contract foreign-key references.md index 2789294f8b..62287f919b 100644 --- a/docs/architecture docs/adrs/ADR 226 - Cross-contract foreign-key references.md +++ b/docs/architecture docs/adrs/ADR 226 - Cross-contract foreign-key references.md @@ -19,7 +19,7 @@ namespace public { model Profile { id String @id @default(uuid()) userId Uuid @unique - user supabase:auth.AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade) + user supabase:auth.AuthUser @relation(from: [userId], to: [id], onDelete: Cascade) } } ``` diff --git a/docs/architecture docs/subsystems/2. Contract Emitter & Types.md b/docs/architecture docs/subsystems/2. Contract Emitter & Types.md index fb802d856c..1c34809541 100644 --- a/docs/architecture docs/subsystems/2. Contract Emitter & Types.md +++ b/docs/architecture docs/subsystems/2. Contract Emitter & Types.md @@ -31,7 +31,7 @@ model User { model Post { id Int @id @default(autoincrement()) title String - user User @relation(fields: [userId], references: [id]) + user User @relation(from: [userId], to: [id]) userId Int @@index([userId]) } diff --git a/docs/architecture docs/subsystems/6. Ecosystem Extensions & Packs.md b/docs/architecture docs/subsystems/6. Ecosystem Extensions & Packs.md index e6f0bf95d3..f0d2b09220 100644 --- a/docs/architecture docs/subsystems/6. Ecosystem Extensions & Packs.md +++ b/docs/architecture docs/subsystems/6. Ecosystem Extensions & Packs.md @@ -375,7 +375,7 @@ namespace public { model Profile { id String @id @default(uuid()) userId Uuid @unique // @unique makes this a 1:1 relationship - user supabase:auth.AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade) + user supabase:auth.AuthUser @relation(from: [userId], to: [id], onDelete: Cascade) @@map("profile") } } diff --git a/examples/mongo-blog-leaderboard/src/contract.prisma b/examples/mongo-blog-leaderboard/src/contract.prisma index e78cd93167..fac17cf47b 100644 --- a/examples/mongo-blog-leaderboard/src/contract.prisma +++ b/examples/mongo-blog-leaderboard/src/contract.prisma @@ -22,7 +22,7 @@ model Post { kind String authorId ObjectId createdAt DateTime - author User @relation(fields: [authorId], references: [id]) + author User @relation(from: [authorId], to: [id]) @@discriminator(kind) @@index([authorId]) diff --git a/examples/mongo-demo/migrations/app/20260409T1030_migration/contract.prisma b/examples/mongo-demo/migrations/app/20260409T1030_migration/contract.prisma index 014f9be541..274ecd8cf5 100644 --- a/examples/mongo-demo/migrations/app/20260409T1030_migration/contract.prisma +++ b/examples/mongo-demo/migrations/app/20260409T1030_migration/contract.prisma @@ -24,7 +24,7 @@ model Post { kind String authorId ObjectId createdAt DateTime - author User @relation(fields: [authorId], references: [id]) + author User @relation(from: [authorId], to: [id]) @@discriminator(kind) @@map("posts") } diff --git a/examples/mongo-demo/migrations/app/20260626T1605_add_user_role_enum/contract.prisma b/examples/mongo-demo/migrations/app/20260626T1605_add_user_role_enum/contract.prisma index 326c8c168f..4785c9bf30 100644 --- a/examples/mongo-demo/migrations/app/20260626T1605_add_user_role_enum/contract.prisma +++ b/examples/mongo-demo/migrations/app/20260626T1605_add_user_role_enum/contract.prisma @@ -32,7 +32,7 @@ model Post { kind String authorId ObjectId createdAt DateTime - author User @relation(fields: [authorId], references: [id]) + author User @relation(from: [authorId], to: [id]) @@discriminator(kind) @@map("posts") } diff --git a/examples/mongo-demo/migrations/app/20260626T1916_add_posts_indexes/contract.prisma b/examples/mongo-demo/migrations/app/20260626T1916_add_posts_indexes/contract.prisma index 04298e7234..7d6660bd2d 100644 --- a/examples/mongo-demo/migrations/app/20260626T1916_add_posts_indexes/contract.prisma +++ b/examples/mongo-demo/migrations/app/20260626T1916_add_posts_indexes/contract.prisma @@ -32,7 +32,7 @@ model Post { kind String authorId ObjectId createdAt DateTime - author User @relation(fields: [authorId], references: [id]) + author User @relation(from: [authorId], to: [id]) @@discriminator(kind) @@index([authorId]) @@index([createdAt(sort: Desc), authorId]) diff --git a/examples/mongo-demo/src/contract.prisma b/examples/mongo-demo/src/contract.prisma index 04298e7234..7d6660bd2d 100644 --- a/examples/mongo-demo/src/contract.prisma +++ b/examples/mongo-demo/src/contract.prisma @@ -32,7 +32,7 @@ model Post { kind String authorId ObjectId createdAt DateTime - author User @relation(fields: [authorId], references: [id]) + author User @relation(from: [authorId], to: [id]) @@discriminator(kind) @@index([authorId]) @@index([createdAt(sort: Desc), authorId]) diff --git a/examples/prisma-next-cloudflare-worker/src/prisma/contract.prisma b/examples/prisma-next-cloudflare-worker/src/prisma/contract.prisma index 5a6d4581a5..588c4142c0 100644 --- a/examples/prisma-next-cloudflare-worker/src/prisma/contract.prisma +++ b/examples/prisma-next-cloudflare-worker/src/prisma/contract.prisma @@ -30,7 +30,7 @@ model Post { userId String createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id]) + user User @relation(from: [userId], to: [id]) @@map("post") } @@ -44,7 +44,7 @@ model Task { userId String createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id]) + user User @relation(from: [userId], to: [id]) @@discriminator(type) @@map("task") diff --git a/examples/prisma-next-demo/migrations/app/20260422T0720_initial/contract.prisma b/examples/prisma-next-demo/migrations/app/20260422T0720_initial/contract.prisma index 8fcdefa6f8..6ad041e7cf 100644 --- a/examples/prisma-next-demo/migrations/app/20260422T0720_initial/contract.prisma +++ b/examples/prisma-next-demo/migrations/app/20260422T0720_initial/contract.prisma @@ -46,7 +46,7 @@ model Post { createdAt DateTime @default(now()) embedding Embedding1536? - user User @relation(fields: [userId], references: [id]) +user User @relation(from: [userId], to: [id]) tags Tag[] @@map("post") @@ -65,8 +65,8 @@ model PostTag { postId Uuid tagId Uuid - post Post @relation(fields: [postId], references: [id]) - tag Tag @relation(fields: [tagId], references: [id]) + post Post @relation(from: [postId], to: [id]) + tag Tag @relation(from: [tagId], to: [id]) @@id([postId, tagId]) @@map("post_tag") @@ -81,7 +81,7 @@ model Task { userId Uuid createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id]) + user User @relation(from: [userId], to: [id]) @@discriminator(type) @@map("task") diff --git a/examples/prisma-next-demo/src/prisma/contract.prisma b/examples/prisma-next-demo/src/prisma/contract.prisma index 8fcdefa6f8..6ad041e7cf 100644 --- a/examples/prisma-next-demo/src/prisma/contract.prisma +++ b/examples/prisma-next-demo/src/prisma/contract.prisma @@ -46,7 +46,7 @@ model Post { createdAt DateTime @default(now()) embedding Embedding1536? - user User @relation(fields: [userId], references: [id]) +user User @relation(from: [userId], to: [id]) tags Tag[] @@map("post") @@ -65,8 +65,8 @@ model PostTag { postId Uuid tagId Uuid - post Post @relation(fields: [postId], references: [id]) - tag Tag @relation(fields: [tagId], references: [id]) + post Post @relation(from: [postId], to: [id]) + tag Tag @relation(from: [tagId], to: [id]) @@id([postId, tagId]) @@map("post_tag") @@ -81,7 +81,7 @@ model Task { userId Uuid createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id]) + user User @relation(from: [userId], to: [id]) @@discriminator(type) @@map("task") diff --git a/examples/react-router-demo/src/prisma/contract.prisma b/examples/react-router-demo/src/prisma/contract.prisma index d7e1db4590..3ab2ea32fe 100644 --- a/examples/react-router-demo/src/prisma/contract.prisma +++ b/examples/react-router-demo/src/prisma/contract.prisma @@ -15,7 +15,7 @@ model Post { userId String createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id]) + user User @relation(from: [userId], to: [id]) @@map("post") } diff --git a/examples/retail-store/migrations/app/20260513T0505_initial/contract.prisma b/examples/retail-store/migrations/app/20260513T0505_initial/contract.prisma index 0b64d08c5a..89b8bcf6e9 100644 --- a/examples/retail-store/migrations/app/20260513T0505_initial/contract.prisma +++ b/examples/retail-store/migrations/app/20260513T0505_initial/contract.prisma @@ -77,7 +77,7 @@ model Cart { id ObjectId @id @map("_id") userId ObjectId items CartItem[] - user User @relation(fields: [userId], references: [id]) + user User @relation(from: [userId], to: [id]) @@map("carts") @@unique([userId]) } @@ -89,7 +89,7 @@ model Order { shippingAddress String type String statusHistory StatusEntry[] - user User @relation(fields: [userId], references: [id]) + user User @relation(from: [userId], to: [id]) invoices Invoice[] @@map("orders") @@index([userId]) @@ -114,7 +114,7 @@ model Invoice { tax Float total Float issuedAt DateTime - order Order @relation(fields: [orderId], references: [id]) + order Order @relation(from: [orderId], to: [id]) @@map("invoices") @@index([orderId]) @@index([issuedAt(sort: Desc)], sparse: true) diff --git a/examples/retail-store/migrations/app/20260513T0507_add_product_category_index/contract.prisma b/examples/retail-store/migrations/app/20260513T0507_add_product_category_index/contract.prisma index 859823afef..cb7057b7e9 100644 --- a/examples/retail-store/migrations/app/20260513T0507_add_product_category_index/contract.prisma +++ b/examples/retail-store/migrations/app/20260513T0507_add_product_category_index/contract.prisma @@ -78,7 +78,7 @@ model Cart { id ObjectId @id @map("_id") userId ObjectId items CartItem[] - user User @relation(fields: [userId], references: [id]) + user User @relation(from: [userId], to: [id]) @@map("carts") @@unique([userId]) } @@ -90,7 +90,7 @@ model Order { shippingAddress String type String statusHistory StatusEntry[] - user User @relation(fields: [userId], references: [id]) + user User @relation(from: [userId], to: [id]) invoices Invoice[] @@map("orders") @@index([userId]) @@ -115,7 +115,7 @@ model Invoice { tax Float total Float issuedAt DateTime - order Order @relation(fields: [orderId], references: [id]) + order Order @relation(from: [orderId], to: [id]) @@map("invoices") @@index([orderId]) @@index([issuedAt(sort: Desc)], sparse: true) diff --git a/examples/retail-store/migrations/app/20260513T0508_backfill_product_status/contract.prisma b/examples/retail-store/migrations/app/20260513T0508_backfill_product_status/contract.prisma index dbb7363d40..2ae5a1d589 100644 --- a/examples/retail-store/migrations/app/20260513T0508_backfill_product_status/contract.prisma +++ b/examples/retail-store/migrations/app/20260513T0508_backfill_product_status/contract.prisma @@ -80,7 +80,7 @@ model Cart { id ObjectId @id @map("_id") userId ObjectId items CartItem[] - user User @relation(fields: [userId], references: [id]) + user User @relation(from: [userId], to: [id]) @@map("carts") @@unique([userId]) } @@ -92,7 +92,7 @@ model Order { shippingAddress String type String statusHistory StatusEntry[] - user User @relation(fields: [userId], references: [id]) + user User @relation(from: [userId], to: [id]) invoices Invoice[] @@map("orders") @@index([userId]) @@ -117,7 +117,7 @@ model Invoice { tax Float total Float issuedAt DateTime - order Order @relation(fields: [orderId], references: [id]) + order Order @relation(from: [orderId], to: [id]) @@map("invoices") @@index([orderId]) @@index([issuedAt(sort: Desc)], sparse: true) diff --git a/examples/retail-store/migrations/app/20260628T0931_add_product_status_order_type_enums/contract.prisma b/examples/retail-store/migrations/app/20260628T0931_add_product_status_order_type_enums/contract.prisma index 975343b9f4..4d557fcecd 100644 --- a/examples/retail-store/migrations/app/20260628T0931_add_product_status_order_type_enums/contract.prisma +++ b/examples/retail-store/migrations/app/20260628T0931_add_product_status_order_type_enums/contract.prisma @@ -93,7 +93,7 @@ model Cart { id ObjectId @id @map("_id") userId ObjectId items CartItem[] - user User @relation(fields: [userId], references: [id]) + user User @relation(from: [userId], to: [id]) @@map("carts") @@unique([userId]) } @@ -105,7 +105,7 @@ model Order { shippingAddress String type OrderType statusHistory StatusEntry[] - user User @relation(fields: [userId], references: [id]) + user User @relation(from: [userId], to: [id]) invoices Invoice[] @@map("orders") @@index([userId]) @@ -130,7 +130,7 @@ model Invoice { tax Float total Float issuedAt DateTime - order Order @relation(fields: [orderId], references: [id]) + order Order @relation(from: [orderId], to: [id]) @@map("invoices") @@index([orderId]) @@index([issuedAt(sort: Desc)], sparse: true) diff --git a/examples/retail-store/src/contract.prisma b/examples/retail-store/src/contract.prisma index 975343b9f4..4d557fcecd 100644 --- a/examples/retail-store/src/contract.prisma +++ b/examples/retail-store/src/contract.prisma @@ -93,7 +93,7 @@ model Cart { id ObjectId @id @map("_id") userId ObjectId items CartItem[] - user User @relation(fields: [userId], references: [id]) + user User @relation(from: [userId], to: [id]) @@map("carts") @@unique([userId]) } @@ -105,7 +105,7 @@ model Order { shippingAddress String type OrderType statusHistory StatusEntry[] - user User @relation(fields: [userId], references: [id]) + user User @relation(from: [userId], to: [id]) invoices Invoice[] @@map("orders") @@index([userId]) @@ -130,7 +130,7 @@ model Invoice { tax Float total Float issuedAt DateTime - order Order @relation(fields: [orderId], references: [id]) + order Order @relation(from: [orderId], to: [id]) @@map("invoices") @@index([orderId]) @@index([issuedAt(sort: Desc)], sparse: true) diff --git a/examples/supabase/src/contract.prisma b/examples/supabase/src/contract.prisma index f215b62892..e4d51ae829 100644 --- a/examples/supabase/src/contract.prisma +++ b/examples/supabase/src/contract.prisma @@ -7,7 +7,7 @@ namespace public { id Uuid @id @default(uuid()) username String userId Uuid @unique - user supabase:auth.AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade) + user supabase:auth.AuthUser @relation(from: [userId], to: [id], onDelete: Cascade) @@map("profile") } diff --git a/packages/1-framework/2-authoring/psl-printer/src/ast-to-print-document.ts b/packages/1-framework/2-authoring/psl-printer/src/ast-to-print-document.ts index 16899ccaf3..d6020a0164 100644 --- a/packages/1-framework/2-authoring/psl-printer/src/ast-to-print-document.ts +++ b/packages/1-framework/2-authoring/psl-printer/src/ast-to-print-document.ts @@ -269,7 +269,11 @@ function hasFullRelation(field: PslField): boolean { ) .map((a) => [a.name, a.value.trim()]), ); - return named['fields'] !== undefined && named['references'] !== undefined; + // Canonical `from:`/`to:` declare the FK dependency edge; legacy + // `fields:`/`references:` are accepted as an input alias for the same edge. + const hasFrom = named['from'] !== undefined || named['fields'] !== undefined; + const hasTo = named['to'] !== undefined || named['references'] !== undefined; + return hasFrom && hasTo; } function relationReferencedModel( diff --git a/packages/1-framework/2-authoring/psl-printer/test/print-psl-from-ast.test.ts b/packages/1-framework/2-authoring/psl-printer/test/print-psl-from-ast.test.ts index 0736f25f86..a9f090435c 100644 --- a/packages/1-framework/2-authoring/psl-printer/test/print-psl-from-ast.test.ts +++ b/packages/1-framework/2-authoring/psl-printer/test/print-psl-from-ast.test.ts @@ -643,6 +643,85 @@ describe('printPslFromAst', () => { expect(printed).toContain('@relation(fields: [authorId], references: [id])'); }); + it('topologically orders models from a canonical @relation(from:, to:)', () => { + // The FK-owning model is declared first; the referenced model must still + // print before it, which only happens when the printer recognises the + // canonical `from:`/`to:` relation keys as a dependency edge. + const models: PslModel[] = [ + { + kind: 'model', + name: 'Post', + fields: [ + { + kind: 'field', + name: 'id', + typeName: 'Int', + optional: false, + list: false, + attributes: [attr('field', 'id', [], 0)], + span: span(0), + }, + { + kind: 'field', + name: 'authorId', + typeName: 'Int', + optional: false, + list: false, + attributes: [], + span: span(0), + }, + { + kind: 'field', + name: 'author', + typeName: 'User', + optional: false, + list: false, + attributes: [ + attr( + 'field', + 'relation', + [ + { kind: 'named', name: 'from', value: '[authorId]', span: span(1) }, + { kind: 'named', name: 'to', value: '[id]', span: span(2) }, + ], + 3, + ), + ], + span: span(0), + }, + ], + attributes: [], + span: span(0), + }, + { + kind: 'model', + name: 'User', + fields: [ + { + kind: 'field', + name: 'id', + typeName: 'Int', + optional: false, + list: false, + attributes: [attr('field', 'id', [], 0)], + span: span(0), + }, + ], + attributes: [], + span: span(0), + }, + ]; + const ast: PslDocumentAst = { + kind: 'document', + sourceId: 't', + namespaces: [makeNs(UNSPECIFIED_PSL_NAMESPACE_ID, models, [], 0)], + span: span(0), + }; + const printed = printPslFromAst(ast); + expect(printed).toContain('@relation(from: [authorId], to: [id])'); + expect(printed.indexOf('model User {')).toBeLessThan(printed.indexOf('model Post {')); + }); + describe('namespace blocks', () => { function idModel(name: string): PslModel { return { diff --git a/packages/1-framework/3-tooling/cli/src/commands/init/templates/code-templates.ts b/packages/1-framework/3-tooling/cli/src/commands/init/templates/code-templates.ts index 6c3a8c0545..0e26bb6a1e 100644 --- a/packages/1-framework/3-tooling/cli/src/commands/init/templates/code-templates.ts +++ b/packages/1-framework/3-tooling/cli/src/commands/init/templates/code-templates.ts @@ -126,7 +126,7 @@ model Post { id Int @id @default(autoincrement()) title String content String? - author User @relation(fields: [authorId], references: [id]) + author User @relation(from: [authorId], to: [id]) authorId Int createdAt DateTime @default(now()) updatedAt temporal.updatedAt() @@ -150,7 +150,7 @@ model Post { id ObjectId @id @map("_id") title String content String? - author User @relation(fields: [authorId], references: [id]) + author User @relation(from: [authorId], to: [id]) authorId ObjectId @@map("posts") } diff --git a/packages/1-framework/3-tooling/cli/test/commands/init/__snapshots__/templates.test.ts.snap b/packages/1-framework/3-tooling/cli/test/commands/init/__snapshots__/templates.test.ts.snap index 8b5133e9d3..733ca10e04 100644 --- a/packages/1-framework/3-tooling/cli/test/commands/init/__snapshots__/templates.test.ts.snap +++ b/packages/1-framework/3-tooling/cli/test/commands/init/__snapshots__/templates.test.ts.snap @@ -247,7 +247,7 @@ model Post { id ObjectId @id @map("_id") title String content String? - author User @relation(fields: [authorId], references: [id]) + author User @relation(from: [authorId], to: [id]) authorId ObjectId @@map("posts") } @@ -606,7 +606,7 @@ model Post { id Int @id @default(autoincrement()) title String content String? - author User @relation(fields: [authorId], references: [id]) + author User @relation(from: [authorId], to: [id]) authorId Int createdAt DateTime @default(now()) updatedAt temporal.updatedAt() diff --git a/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts b/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts index 5a8258813f..83c9b5dfdf 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts @@ -63,6 +63,7 @@ import { parseIndexFieldList, parseQuotedStringLiteral, parseRelationAttribute, + resolveTargetIdFieldNames, } from './psl-helpers'; /** @@ -1022,9 +1023,39 @@ export function interpretPslDocumentToMongoContract( for (const field of Object.values(pslModel.fields)) { if (isRelationField(field, modelNames)) { - const relation = parseRelationAttribute(field.attributes); + const diagnosticsBefore = diagnostics.length; + const relation = parseRelationAttribute({ + attributes: field.attributes, + modelName: pslModel.name, + fieldName: field.name, + sourceId, + diagnostics, + }); + if (diagnostics.length > diagnosticsBefore) { + // The relation attribute was rejected (legacy fields:/references:, a + // to: without a from:, a malformed value). The pushed diagnostic fails + // the interpret; the field carries no usable FK or backrelation shape. + continue; + } - if (field.list || !(relation?.fields && relation?.references)) { + const targetModel = allModels.find((m) => m.name === field.typeName); + + let references = relation?.references; + if (relation?.fields && relation.referencesInferred) { + const targetIdFields = targetModel ? resolveTargetIdFieldNames(targetModel) : undefined; + if (!targetIdFields) { + diagnostics.push({ + code: 'PSL_INVALID_RELATION_ATTRIBUTE', + message: `Relation field "${pslModel.name}.${field.name}" omits to: but target model "${field.typeName}" declares no @id to infer the referenced field(s) from`, + sourceId, + span: field.span, + }); + continue; + } + references = targetIdFields; + } + + if (field.list || !(relation?.fields && references)) { backrelationCandidates.push({ modelName: pslModel.name, fieldName: field.name, @@ -1036,33 +1067,30 @@ export function interpretPslDocumentToMongoContract( continue; } - if (relation?.fields && relation?.references) { - const localMapped = relation.fields.map((f) => fieldMappings.pslNameToMapped.get(f) ?? f); + const localMapped = relation.fields.map((f) => fieldMappings.pslNameToMapped.get(f) ?? f); - const targetModel = allModels.find((m) => m.name === field.typeName); - const targetFieldMappings = targetModel ? resolveFieldMappings(targetModel) : undefined; - const targetMapped = relation.references.map( - (f) => targetFieldMappings?.pslNameToMapped.get(f) ?? f, - ); + const targetFieldMappings = targetModel ? resolveFieldMappings(targetModel) : undefined; + const targetMapped = references.map( + (f) => targetFieldMappings?.pslNameToMapped.get(f) ?? f, + ); - relations[field.name] = { - to: mongoCrossRef(field.typeName), - cardinality: 'N:1' as const, - on: { - localFields: localMapped, - targetFields: targetMapped, - }, - }; - - allFkRelations.push({ - declaringModel: pslModel.name, - fieldName: field.name, - targetModel: field.typeName, - ...ifDefined('relationName', relation.relationName), + relations[field.name] = { + to: mongoCrossRef(field.typeName), + cardinality: 'N:1' as const, + on: { localFields: localMapped, targetFields: targetMapped, - }); - } + }, + }; + + allFkRelations.push({ + declaringModel: pslModel.name, + fieldName: field.name, + targetModel: field.typeName, + ...(relation.relationName !== undefined ? { relationName: relation.relationName } : {}), + localFields: localMapped, + targetFields: targetMapped, + }); continue; } @@ -1176,7 +1204,7 @@ export function interpretPslDocumentToMongoContract( if (matches.length === 0) { diagnostics.push({ code: 'PSL_ORPHANED_BACKRELATION', - message: `Backrelation list field "${candidate.modelName}.${candidate.fieldName}" 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.`, + message: `Backrelation list field "${candidate.modelName}.${candidate.fieldName}" has no matching FK-side relation on model "${candidate.targetModelName}". Add @relation(from: [...], to: [...]) on the FK-side relation or use an explicit join model for many-to-many.`, sourceId, span: candidate.field.span, }); diff --git a/packages/2-mongo-family/2-authoring/contract-psl/src/psl-helpers.ts b/packages/2-mongo-family/2-authoring/contract-psl/src/psl-helpers.ts index f28453a8dd..3a466e80fa 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/src/psl-helpers.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/src/psl-helpers.ts @@ -1,4 +1,5 @@ -import type { ResolvedAttribute, ResolvedAttributeArg } from '@prisma-next/psl-parser'; +import type { ContractSourceDiagnostic } from '@prisma-next/config/config-types'; +import type { ModelSymbol, ResolvedAttribute } from '@prisma-next/psl-parser'; import { parseQuotedStringLiteral } from '@prisma-next/psl-parser'; import { ifDefined } from '@prisma-next/utils/defined'; @@ -95,40 +96,143 @@ export interface ParsedRelationAttribute { readonly relationName?: string; readonly fields?: readonly string[]; readonly references?: readonly string[]; + /** + * Set when local FK fields are declared (`from:`) but the referenced key is + * omitted (`to:` absent). The caller resolves the referenced columns from the + * target model's `@id`. `references` stays undefined in this case; the two + * never co-occur. + */ + readonly referencesInferred?: true; } -export function parseRelationAttribute( - attributes: readonly ResolvedAttribute[], -): ParsedRelationAttribute | undefined { - const relationAttr = getAttribute(attributes, 'relation'); +/** + * Parses a single `@relation` directional argument value (`from:`/`to:`). A + * single field may be bare (`from: userId`) or bracketed (`from: [userId]`); + * composites must be bracketed (`from: [a, b]`). + */ +function parseRelationFieldArgument(raw: string): readonly string[] | undefined { + const trimmed = raw.trim(); + const entries = trimmed.startsWith('[') ? parseFieldList(trimmed) : [trimmed]; + if (entries.length === 0 || entries.some((entry) => entry.length === 0)) { + return undefined; + } + return entries; +} + +export function parseRelationAttribute(input: { + readonly attributes: readonly ResolvedAttribute[]; + readonly modelName: string; + readonly fieldName: string; + readonly sourceId: string; + readonly diagnostics: ContractSourceDiagnostic[]; +}): ParsedRelationAttribute | undefined { + const relationAttr = getAttribute(input.attributes, 'relation'); if (!relationAttr) return undefined; let relationName: string | undefined; - let fieldsArg: ResolvedAttributeArg | undefined; - let referencesArg: ResolvedAttributeArg | undefined; + let fromRaw: string | undefined; + let toRaw: string | undefined; for (const arg of relationAttr.args) { if (arg.kind === 'positional') { relationName = stripQuotes(arg.value); } else if (arg.name === 'name') { relationName = stripQuotes(arg.value); - } else if (arg.name === 'fields') { - fieldsArg = arg; - } else if (arg.name === 'references') { - referencesArg = arg; + } else if (arg.name === 'fields' || arg.name === 'references') { + input.diagnostics.push({ + code: 'PSL_LEGACY_FIELDS_REFERENCES', + message: `Relation field "${input.modelName}.${input.fieldName}" uses @relation(fields:/references:), which is no longer supported — use from:/to: instead`, + sourceId: input.sourceId, + span: arg.span, + }); + return undefined; + } else if (arg.name === 'from') { + fromRaw = arg.value; + } else if (arg.name === 'to') { + toRaw = arg.value; } } - const fields = fieldsArg ? parseFieldList(fieldsArg.value) : undefined; - const references = referencesArg ? parseFieldList(referencesArg.value) : undefined; + if (toRaw !== undefined && fromRaw === undefined) { + input.diagnostics.push({ + code: 'PSL_INVALID_RELATION_ATTRIBUTE', + message: `Relation field "${input.modelName}.${input.fieldName}" requires a from argument naming the local foreign-key field(s)`, + sourceId: input.sourceId, + span: relationAttr.span, + }); + return undefined; + } + + let fields: readonly string[] | undefined; + let references: readonly string[] | undefined; + let referencesInferred: true | undefined; + if (fromRaw !== undefined) { + const parsedFields = parseRelationFieldArgument(fromRaw); + if (!parsedFields) { + input.diagnostics.push({ + code: 'PSL_INVALID_RELATION_ATTRIBUTE', + message: `Relation field "${input.modelName}.${input.fieldName}" requires a bare field or bracketed list for from`, + sourceId: input.sourceId, + span: relationAttr.span, + }); + return undefined; + } + fields = parsedFields; + + if (toRaw !== undefined) { + const parsedReferences = parseRelationFieldArgument(toRaw); + if (!parsedReferences) { + input.diagnostics.push({ + code: 'PSL_INVALID_RELATION_ATTRIBUTE', + message: `Relation field "${input.modelName}.${input.fieldName}" requires a bare field or bracketed list for to`, + sourceId: input.sourceId, + span: relationAttr.span, + }); + return undefined; + } + references = parsedReferences; + } else { + // `to:` omitted ⇒ the referenced columns default to the target model's + // `@id`. The caller, which holds the target model, resolves them. + referencesInferred = true; + } + } return { ...ifDefined('relationName', relationName), ...ifDefined('fields', fields), ...ifDefined('references', references), + ...ifDefined('referencesInferred', referencesInferred), }; } +/** + * Resolves a model's `@id` field names in declaration order — an inline `@id` + * on a single field, or a model-level `@@id([...])` list. Returns undefined + * when the model declares no identity, which is what makes an omitted `to:` + * un-inferable for a relation targeting it. + */ +export function resolveTargetIdFieldNames(model: ModelSymbol): readonly string[] | undefined { + const blockId = getAttribute(model.attributes, 'id'); + if (blockId) { + const raw = getNamedArgument(blockId, 'fields') ?? getPositionalArgument(blockId); + const fields = raw ? parseFieldList(raw) : undefined; + if (fields && fields.length > 0) { + return fields; + } + return undefined; + } + + const inlineIdFields = Object.values(model.fields).filter((field) => + field.attributes.some((attribute) => attribute.name === 'id'), + ); + if (inlineIdFields.length === 1) { + const idField = inlineIdFields[0]; + return idField ? [idField.name] : undefined; + } + return undefined; +} + function stripQuotes(value: string): string { if (value.startsWith('"') && value.endsWith('"')) { return value.slice(1, -1); diff --git a/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.test.ts b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.test.ts index 9ce5715b20..271b6af30d 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.test.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.test.ts @@ -364,11 +364,11 @@ describe('interpretPslDocumentToMongoContract', () => { id ObjectId @id @map("_id") title String authorId ObjectId - author User @relation(fields: [authorId], references: [id]) + author User @relation(from: [authorId], to: [id]) } `; - it('creates N:1 reference relation from @relation with fields/references', () => { + it('creates N:1 reference relation from @relation with from/to', () => { const ir = interpretOk(blogSchema); expect(model(ir, 'Post').relations).toMatchObject({ @@ -408,7 +408,7 @@ describe('interpretPslDocumentToMongoContract', () => { model Child { id ObjectId @id @map("_id") parentId ObjectId @map("parent_id") - parent Parent @relation(fields: [parentId], references: [id]) + parent Parent @relation(from: [parentId], to: [id]) } `); @@ -448,8 +448,8 @@ describe('interpretPslDocumentToMongoContract', () => { title String creatorId ObjectId assigneeId ObjectId - creator User @relation("created", fields: [creatorId], references: [id]) - assignee User @relation("assigned", fields: [assigneeId], references: [id]) + creator User @relation("created", from: [creatorId], to: [id]) + assignee User @relation("assigned", from: [assigneeId], to: [id]) } `); @@ -478,8 +478,8 @@ describe('interpretPslDocumentToMongoContract', () => { id ObjectId @id @map("_id") creatorId ObjectId assigneeId ObjectId - creator User @relation("created", fields: [creatorId], references: [id]) - assignee User @relation("assigned", fields: [assigneeId], references: [id]) + creator User @relation("created", from: [creatorId], to: [id]) + assignee User @relation("assigned", from: [assigneeId], to: [id]) } `); @@ -504,7 +504,7 @@ describe('interpretPslDocumentToMongoContract', () => { model Profile { id ObjectId @id @map("_id") userId ObjectId - user User @relation(fields: [userId], references: [id]) + user User @relation(from: [userId], to: [id]) } `); @@ -554,6 +554,198 @@ describe('interpretPslDocumentToMongoContract', () => { }); }); + describe('from/to relation vocabulary', () => { + describe('legacy fields/references rejection', () => { + it('rejects @relation(fields:, references:) with a guiding diagnostic', () => { + const result = interpret(` + model User { + id ObjectId @id @map("_id") + posts Post[] + } + + model Post { + id ObjectId @id @map("_id") + authorId ObjectId + author User @relation(fields: [authorId], references: [id]) + } + `); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_LEGACY_FIELDS_REFERENCES', + message: expect.stringContaining('use from:/to:'), + }), + ]), + ); + }); + + it('rejects a lone legacy fields: argument', () => { + const result = interpret(` + model User { + id ObjectId @id @map("_id") + posts Post[] + } + + model Post { + id ObjectId @id @map("_id") + authorId ObjectId + author User @relation(fields: [authorId]) + } + `); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'PSL_LEGACY_FIELDS_REFERENCES' }), + ]), + ); + }); + + it('rejects a lone legacy references: argument', () => { + const result = interpret(` + model User { + id ObjectId @id @map("_id") + posts Post[] + } + + model Post { + id ObjectId @id @map("_id") + authorId ObjectId + author User @relation(references: [id]) + } + `); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'PSL_LEGACY_FIELDS_REFERENCES' }), + ]), + ); + }); + }); + + describe('to inference (omit to: ⇒ target @id)', () => { + it('infers the single-column target @id when to: is omitted', () => { + const inferred = interpretOk(` + model User { + id ObjectId @id @map("_id") + posts Post[] + } + + model Post { + id ObjectId @id @map("_id") + authorId ObjectId + author User @relation(from: authorId) + } + `); + const explicit = interpretOk(` + model User { + id ObjectId @id @map("_id") + posts Post[] + } + + model Post { + id ObjectId @id @map("_id") + authorId ObjectId + author User @relation(from: [authorId], to: [id]) + } + `); + + expect(inferred).toEqual(explicit); + expect(model(inferred, 'Post').relations).toMatchObject({ + author: { + to: crossRef('User'), + cardinality: 'N:1', + on: { localFields: ['authorId'], targetFields: ['_id'] }, + }, + }); + }); + + it('rejects an omitted to: when the target model has no @id', () => { + const result = interpret(` + model Tag { + label String + } + + model Post { + id ObjectId @id @map("_id") + tagLabel String + tag Tag @relation(from: tagLabel) + } + `); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'PSL_INVALID_RELATION_ATTRIBUTE' }), + ]), + ); + }); + }); + + describe('value forms', () => { + it('accepts a bare single from:/to: field equivalently to a bracketed one', () => { + const bare = interpretOk(` + model User { + id ObjectId @id @map("_id") + posts Post[] + } + + model Post { + id ObjectId @id @map("_id") + authorId ObjectId + author User @relation(from: authorId, to: id) + } + `); + const bracketed = interpretOk(` + model User { + id ObjectId @id @map("_id") + posts Post[] + } + + model Post { + id ObjectId @id @map("_id") + authorId ObjectId + author User @relation(from: [authorId], to: [id]) + } + `); + + expect(bare).toEqual(bracketed); + }); + }); + + describe('both-or-neither diagnostic', () => { + it('rejects a to: without a from:', () => { + const result = interpret(` + model User { + id ObjectId @id @map("_id") + posts Post[] + } + + model Post { + id ObjectId @id @map("_id") + authorId ObjectId + author User @relation(to: [id]) + } + `); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'PSL_INVALID_RELATION_ATTRIBUTE' }), + ]), + ); + }); + }); + }); + describe('@id validation', () => { it('emits diagnostic when model has no @id field', () => { const result = interpret(` @@ -902,7 +1094,7 @@ describe('interpretPslDocumentToMongoContract', () => { content String authorId ObjectId createdAt DateTime - author User @relation(fields: [authorId], references: [id]) + author User @relation(from: [authorId], to: [id]) @@map("posts") } `); @@ -1628,7 +1820,7 @@ describe('interpretPslDocumentToMongoContract', () => { model Post { id ObjectId @id @map("_id") authorId ObjectId - author User @relation(fields: [authorId], references: [id]) + author User @relation(from: [authorId], to: [id]) @@index([author]) } @@ -1651,7 +1843,7 @@ describe('interpretPslDocumentToMongoContract', () => { model Post { id ObjectId @id @map("_id") authorId ObjectId - author User @relation(fields: [authorId], references: [id]) + author User @relation(from: [authorId], to: [id]) @@unique([author]) } @@ -1673,7 +1865,7 @@ describe('interpretPslDocumentToMongoContract', () => { model Post { id ObjectId @id @map("_id") authorId ObjectId - author User @relation(fields: [authorId], references: [id]) + author User @relation(from: [authorId], to: [id]) @@textIndex([author]) } diff --git a/packages/2-sql/2-authoring/contract-psl/README.md b/packages/2-sql/2-authoring/contract-psl/README.md index 913787a851..fc92fe3a81 100644 --- a/packages/2-sql/2-authoring/contract-psl/README.md +++ b/packages/2-sql/2-authoring/contract-psl/README.md @@ -51,7 +51,7 @@ Unsupported PSL constructs in v1 (strict errors): - Scalar lists like `String[]` - Enum lists and named-type lists - **Relation navigation lists are supported** when they can be matched to an FK-side relation: - - Example: `User.posts Post[]` + `Post.user User @relation(fields: [userId], references: [id])` + - Example: `User.posts Post[]` + `Post.user User @relation(from: [userId], to: [id])` - Matching may use `@relation("Name")` or `@relation(name: "Name")` when multiple candidates exist - Navigation list fields accept only `@relation` (name-only form); other field attributes are strict errors - **Implicit Prisma ORM many-to-many remains unsupported** (list navigation on both sides without explicit join model) diff --git a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts index f9d26fef13..05be785666 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -106,6 +106,7 @@ import { interpretRelationAttribute, type ModelBackrelationCandidate, normalizeReferentialAction, + resolveTargetIdFieldNames, validateNavigationListFieldAttributes, } from './psl-relation-resolution'; @@ -578,7 +579,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult if (parsedRelation.fields || parsedRelation.references) { diagnostics.push({ code: 'PSL_INVALID_RELATION_ATTRIBUTE', - message: `Backrelation list field "${model.name}.${field.name}" cannot declare fields/references; define them on the FK-side relation field`, + message: `Backrelation list field "${model.name}.${field.name}" cannot declare fields/references or from/to; define them on the FK-side relation field`, sourceId, span: relationAttribute.span, }); @@ -908,7 +909,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult if (!parsedRelation.fields || !parsedRelation.references) { diagnostics.push({ code: 'PSL_INVALID_RELATION_ATTRIBUTE', - message: `Relation field "${model.name}.${relationAttribute.field.name}" requires fields and references arguments`, + message: `Cross-space relation field "${model.name}.${relationAttribute.field.name}" requires from and to arguments; to cannot be inferred across contract spaces`, sourceId, span: relationAttribute.relation.span, }); @@ -1061,10 +1062,10 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult if (!parsedRelation) { continue; } - if (!parsedRelation.fields || !parsedRelation.references) { + if (!parsedRelation.fields) { diagnostics.push({ code: 'PSL_INVALID_RELATION_ATTRIBUTE', - message: `Relation field "${model.name}.${relationAttribute.field.name}" requires fields and references arguments`, + message: `Relation field "${model.name}.${relationAttribute.field.name}" requires a from argument naming the local foreign-key field(s)`, sourceId, span: relationAttribute.relation.span, }); @@ -1087,6 +1088,21 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult continue; } + // An omitted `to:` references the target model's `@id`; an explicit + // `to:`/`references:` names the referenced fields directly. + const referenceFieldNames = parsedRelation.referencesInferred + ? resolveTargetIdFieldNames(targetMapping.model) + : parsedRelation.references; + if (!referenceFieldNames) { + diagnostics.push({ + code: 'PSL_INVALID_RELATION_ATTRIBUTE', + message: `Relation field "${model.name}.${relationAttribute.field.name}" omits to and target model "${targetMapping.model.name}" has no @id to reference; add a to argument naming the referenced field(s)`, + sourceId, + span: relationAttribute.relation.span, + }); + continue; + } + const localColumns = mapFieldNamesToColumns({ modelName: model.name, fieldNames: parsedRelation.fields, @@ -1101,7 +1117,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult } const referencedColumns = mapFieldNamesToColumns({ modelName: targetMapping.model.name, - fieldNames: parsedRelation.references, + fieldNames: referenceFieldNames, mapping: targetMapping, sourceId, diagnostics, diff --git a/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts b/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts index e81d407470..37d0c9628c 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts @@ -1,6 +1,8 @@ import type { ContractSourceDiagnostic } from '@prisma-next/config/config-types'; import type { AuthoringContributions } from '@prisma-next/framework-components/authoring'; import type { + ArgType, + FieldRefScope, FieldSymbol, InferAttr, InterpretCtx, @@ -20,12 +22,25 @@ import { optional, str, } from '@prisma-next/psl-parser'; -import type { FieldAttributeAst, SourceFile } from '@prisma-next/psl-parser/syntax'; +import type { + AttributeArgAst, + FieldAttributeAst, + SourceFile, +} from '@prisma-next/psl-parser/syntax'; +import { ArrayLiteralAst } 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 { + getAttribute, + getNamedArgument, + getPositionalArgument, + parseFieldList, +} from './psl-attribute-parsing'; import { checkUncomposedNamespace, reportUncomposedNamespace } from './psl-column-resolution'; export const REFERENTIAL_ACTION_MAP: Record = { @@ -76,18 +91,50 @@ export function normalizeReferentialAction(actionToken: string): ReferentialActi return REFERENTIAL_ACTION_MAP[actionToken]; } +/** + * Accepts a `@relation` directional argument value (`from:`/`to:`): a single + * bare field (`from: userId`) or a bracketed list (`from: [a, b]`), normalised + * to a field-name array. Delegating each shape to its own combinator keeps the + * specific diagnostics (e.g. a nonexistent field) that `oneOf` would collapse + * into a generic mismatch message. + */ +function fieldRefOrList(scope: FieldRefScope): ArgType { + const single = fieldRef(scope); + const bracketed = list(fieldRef(scope), { nonEmpty: true, unique: true }); + return { + kind: 'fieldRefOrList', + label: 'field name or field name[]', + parse: (arg, ctx): Result => { + if (arg instanceof ArrayLiteralAst) { + return bracketed.parse(arg, ctx); + } + const result = single.parse(arg, ctx); + if (!result.ok) { + return result; + } + return ok([result.value]); + }, + }; +} + function relationInvariants( - parsed: { readonly fields?: readonly string[]; readonly references?: readonly string[] }, + parsed: { + readonly from?: readonly string[]; + readonly to?: readonly string[]; + }, ctx: InterpretCtx, ): readonly PslDiagnostic[] { - const hasFields = parsed.fields !== undefined; - const hasReferences = parsed.references !== undefined; - // `fields` and `references` must be both set or both absent — a cross-argument rule that per-argument parsing can't enforce. - if (hasFields !== hasReferences) { + const hasFrom = parsed.from !== undefined; + const hasTo = parsed.to !== undefined; + // `to:` may stand alone only alongside `from:` — a referenced key without + // local FK fields is unresolvable, a cross-argument rule that per-argument + // parsing can't enforce. `from:` alone is fine (references are inferred from + // the target's `@id`). + if (hasTo && !hasFrom) { return [ { - code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', - message: `Relation field "${ctx.selfModel.name}.${ctx.field?.name ?? ''}" requires fields and references arguments`, + code: 'PSL_INVALID_RELATION_ATTRIBUTE', + message: `Relation field "${ctx.selfModel.name}.${ctx.field?.name ?? ''}" requires a from argument naming the local foreign-key field(s)`, sourceId: ctx.sourceId, span: relationAttributeSpan(ctx), }, @@ -96,12 +143,16 @@ function relationInvariants( return []; } +// `from:`/`to:` are the only local-fields/referenced-key arguments; both +// accept a bare field or a bracketed list. The legacy `fields:`/`references:` +// spellings are rejected up front with a guiding diagnostic (see +// interpretRelationAttribute) rather than reported as unknown arguments. const sqlRelation = fieldAttribute('relation', { positional: [{ key: 'name', type: optional(str()) }], named: { name: optional(str()), - fields: optional(list(fieldRef('self'), { nonEmpty: true, unique: true })), - references: optional(list(fieldRef('referenced'), { nonEmpty: true, unique: true })), + from: optional(fieldRefOrList('self')), + to: optional(fieldRefOrList('referenced')), map: optional(str()), onDelete: optional( oneOf( @@ -127,6 +178,27 @@ const sqlRelation = fieldAttribute('relation', { export type SqlRelationOutput = InferAttr; +/** + * The interpreted `@relation` attribute with the directional arguments + * normalised: `from:` lands in `fields` and `to:` in `references`, the names + * the resolution pipeline consumes. + */ +export type ParsedSqlRelation = { + readonly name?: string; + readonly fields?: readonly string[]; + readonly references?: readonly string[]; + /** + * Set when local FK fields are declared (`from:`) but the referenced key is + * omitted (`to:` absent). The caller resolves the referenced columns from + * the target model's `@id`. `references` stays undefined in this case; the + * two never co-occur. + */ + readonly referencesInferred?: true; + readonly map?: string; + readonly onDelete?: SqlRelationOutput['onDelete']; + readonly onUpdate?: SqlRelationOutput['onUpdate']; +}; + function findRelationAttributeNode(field: FieldSymbol): FieldAttributeAst | undefined { for (const attribute of field.node.attributes()) { if (attribute.name()?.path().join('.') === 'relation') { @@ -179,6 +251,23 @@ function buildRelationInterpretCtx(input: { }; } +/** + * Finds a legacy `fields:`/`references:` argument on the `@relation` attribute + * so it can be rejected with a guiding diagnostic instead of the generic + * unknown-argument message the spec would produce. + */ +function findLegacyDirectionalArgument( + attributeNode: FieldAttributeAst, +): AttributeArgAst | undefined { + for (const arg of attributeNode.argList()?.args() ?? []) { + const name = arg.name()?.name(); + if (name === 'fields' || name === 'references') { + return arg; + } + } + return undefined; +} + export function interpretRelationAttribute(input: { readonly selfModel: ModelSymbol; readonly field: FieldSymbol; @@ -186,11 +275,21 @@ export function interpretRelationAttribute(input: { readonly sourceFile: SourceFile; readonly sourceId: string; readonly diagnostics: ContractSourceDiagnostic[]; -}): SqlRelationOutput | undefined { +}): ParsedSqlRelation | undefined { const attributeNode = findRelationAttributeNode(input.field); if (attributeNode === undefined) { return undefined; } + const legacyArgument = findLegacyDirectionalArgument(attributeNode); + if (legacyArgument !== undefined) { + input.diagnostics.push({ + code: 'PSL_LEGACY_FIELDS_REFERENCES', + message: `Relation field "${input.selfModel.name}.${input.field.name}" uses @relation(fields:/references:), which is no longer supported — use from:/to: instead`, + sourceId: input.sourceId, + span: nodePslSpan(legacyArgument.syntax, input.sourceFile), + }); + return undefined; + } const ctx = buildRelationInterpretCtx(input); const result = interpretAttribute(attributeNode, sqlRelation, ctx); if (!result.ok) { @@ -199,7 +298,47 @@ export function interpretRelationAttribute(input: { } return undefined; } - return result.value; + const value = result.value; + const fields = value.from; + const references = value.to; + const referencesInferred: true | undefined = + fields !== undefined && references === undefined ? true : undefined; + return { + ...ifDefined('name', value.name), + ...ifDefined('fields', fields), + ...ifDefined('references', references), + ...ifDefined('referencesInferred', referencesInferred), + ...ifDefined('map', value.map), + ...ifDefined('onDelete', value.onDelete), + ...ifDefined('onUpdate', value.onUpdate), + }; +} + +/** + * Resolves a model's `@id` field names in declaration order — an inline `@id` + * on a single field, or a model-level `@@id([...])` list. Returns undefined + * when the model declares no identity, which is what makes an omitted `to:` + * un-inferable for a relation targeting it. + */ +export function resolveTargetIdFieldNames(model: ModelSymbol): readonly string[] | undefined { + const blockId = getAttribute(model.attributes, 'id'); + if (blockId) { + const raw = getNamedArgument(blockId, 'fields') ?? getPositionalArgument(blockId); + const fields = raw ? parseFieldList(raw) : undefined; + if (fields && fields.length > 0) { + return fields; + } + return undefined; + } + + const inlineIdFields = Object.values(model.fields).filter((field) => + field.attributes.some((attribute) => attribute.name === 'id'), + ); + if (inlineIdFields.length === 1) { + const idField = inlineIdFields[0]; + return idField ? [idField.name] : undefined; + } + return undefined; } export function indexFkRelations(input: { @@ -500,7 +639,7 @@ export function applyBackrelationCandidates(input: { } input.diagnostics.push({ code: 'PSL_ORPHANED_BACKRELATION_LIST', - 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.`, + message: `Backrelation list field "${candidate.modelName}.${candidate.field.name}" has no matching FK-side relation on model "${candidate.targetModelName}". Add @relation(from: [...], to: [...]) on the FK-side relation or use an explicit join model for many-to-many.`, sourceId: input.sourceId, span: candidate.field.span, }); diff --git a/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts b/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts index 1d999a2caa..30158d1191 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts @@ -31,6 +31,8 @@ import { type EnumTypeHandle, enumType } from '@prisma-next/sql-contract-ts/cont import { blindCast } from '@prisma-next/utils/casts'; import { createTestSqlNamespace } from '../../../1-core/contract/test/test-support'; +export { createTestSqlNamespace } from '../../../1-core/contract/test/test-support'; + function testEnumFactory( block: PslExtensionBlock, ctx: AuthoringEntityContext, diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts index c777722a28..04e9ed7788 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts @@ -110,7 +110,7 @@ model Team { model User { id Int @id tags String[] - ghost Ghost @relation(fields: [ghostId], references: [id]) + ghost Ghost @relation(from: [ghostId], to: [id]) ghostId Int } `, @@ -392,7 +392,7 @@ model Post { id Int @id authorId Int reviewerId Int - user User @relation(fields: [authorId, reviewerId], references: [id]) + user User @relation(from: [authorId, reviewerId], to: [id]) } `, sourceId: 'schema.prisma', @@ -424,7 +424,7 @@ model Post { model Post { id Int @id userId Int - user User @relation(fields: [userId], references: [id]) + user User @relation(from: [userId], to: [id]) } `, sourceId: 'schema.prisma', @@ -450,13 +450,13 @@ model Post { const document = symbolTableInputFromParseArgs({ schema: `model User { id Int @id - posts Post[] @relation(fields: [id], references: [userId]) + posts Post[] @relation(from: [id], to: [userId]) } model Post { id Int @id userId Int - user User @relation(fields: [userId], references: [id]) + user User @relation(from: [userId], to: [id]) } `, sourceId: 'schema.prisma', @@ -519,8 +519,8 @@ model Post { id Int @id primaryUserId Int secondaryUserId Int - primaryUser User @relation(fields: [primaryUserId], references: [id]) - secondaryUser User @relation(fields: [secondaryUserId], references: [id]) + primaryUser User @relation(from: [primaryUserId], to: [id]) + secondaryUser User @relation(from: [secondaryUserId], to: [id]) } `, sourceId: 'schema.prisma', diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.namespaces.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.namespaces.test.ts index 6a8b87ee25..3034715c0e 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.namespaces.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.namespaces.test.ts @@ -135,7 +135,7 @@ describe('interpretPslDocumentToSqlContract cross-namespace FK resolution', () = model Post { id Int @id userId Int - user auth.User @relation(fields: [userId], references: [id]) + user auth.User @relation(from: [userId], to: [id]) } } @@ -178,7 +178,7 @@ namespace blog { model Post { id Int @id authorId Int - author User @relation(fields: [authorId], references: [id]) + author User @relation(from: [authorId], to: [id]) } } `, @@ -205,7 +205,7 @@ namespace blog { model Post { id Int @id userId Int - user User @relation(fields: [userId], references: [id]) + user User @relation(from: [userId], to: [id]) } } @@ -244,7 +244,7 @@ namespace auth { model Profile { id Int @id userId Int - user auth.User @relation(fields: [userId], references: [id]) + user auth.User @relation(from: [userId], to: [id]) @@map("profile") } } @@ -298,7 +298,7 @@ namespace auth { model Post { id Int @id userId Int - user wrong.User @relation(fields: [userId], references: [id]) + user wrong.User @relation(from: [userId], to: [id]) } } @@ -333,7 +333,7 @@ describe('interpretPslDocumentToSqlContract cross-contract-space FK (PSL colon-p schema: `model Profile { id Int @id userId Int - user supabase:auth.User @relation(fields: [userId], references: [id]) + user supabase:auth.User @relation(from: [userId], to: [id]) } `, sourceId: 'schema.prisma', @@ -372,7 +372,7 @@ describe('interpretPslDocumentToSqlContract cross-contract-space FK (PSL colon-p schema: `model Profile { id Int @id userId Int - user supabase:User @relation(fields: [userId], references: [id]) + user supabase:User @relation(from: [userId], to: [id]) } `, sourceId: 'schema.prisma', @@ -408,7 +408,7 @@ describe('interpretPslDocumentToSqlContract cross-contract-space FK (PSL colon-p schema: `model Profile { id Int @id userId Int - user supabase:auth.User @relation(fields: [userId], references: [id]) + user supabase:auth.User @relation(from: [userId], to: [id]) } `, sourceId: 'schema.prisma', @@ -435,7 +435,7 @@ describe('interpretPslDocumentToSqlContract cross-contract-space FK (PSL colon-p schema: `model Profile { id Int @id userId Int - posts supabase:auth.Post[] @relation(fields: [userId], references: [id]) + posts supabase:auth.Post[] @relation(from: [userId], to: [id]) } `, sourceId: 'schema.prisma', @@ -466,7 +466,7 @@ describe('interpretPslDocumentToSqlContract cross-contract-space FK (PSL colon-p schema: `model Profile { id Int @id userId Int - user supabase:auth.User @relation(fields: [userId], references: [id], onDelete: Cascade) + user supabase:auth.User @relation(from: [userId], to: [id], onDelete: Cascade) } `, sourceId: 'schema.prisma', @@ -499,7 +499,7 @@ describe('interpretPslDocumentToSqlContract cross-contract-space FK (PSL colon-p schema: `model Profile { id Int @id userId Int - user supabase:auth.User @relation(fields: [userId], references: [id]) + user supabase:auth.User @relation(from: [userId], to: [id]) } `, sourceId: 'schema.prisma', @@ -536,7 +536,7 @@ describe('interpretPslDocumentToSqlContract cross-contract-space FK (PSL colon-p schema: `model Profile { id Int @id userId Int - user supabase:auth.User @relation(fields: [userId], references: [id]) + user supabase:auth.User @relation(from: [userId], to: [id]) } `, sourceId: 'schema.prisma', @@ -572,7 +572,7 @@ describe('interpretPslDocumentToSqlContract cross-contract-space FK (PSL colon-p schema: `model Profile { id Int @id userId Int - user supabase:auth.NonExistentModel @relation(fields: [userId], references: [id]) + user supabase:auth.NonExistentModel @relation(from: [userId], to: [id]) } `, sourceId: 'schema.prisma', diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.from-to.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.from-to.test.ts new file mode 100644 index 0000000000..28915662fd --- /dev/null +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.from-to.test.ts @@ -0,0 +1,327 @@ +import { describe, expect, it } from 'vitest'; +import { interpretPslDocumentToSqlContract } from '../src/interpreter'; +import { + createBuiltinLikeControlMutationDefaults, + createTestSqlNamespace, + modelsOf, + postgresScalarTypeDescriptors, + postgresTarget, + symbolTableInputFromParseArgs, +} from './fixtures'; + +const baseInput = { + target: postgresTarget, + scalarTypeDescriptors: postgresScalarTypeDescriptors, + composedExtensionContracts: new Map(), + createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, +} as const; + +const builtinControlMutationDefaults = createBuiltinLikeControlMutationDefaults(); + +function interpret(schema: string) { + const document = symbolTableInputFromParseArgs({ schema, sourceId: 'schema.prisma' }); + return interpretPslDocumentToSqlContract({ + ...baseInput, + ...document, + controlMutationDefaults: builtinControlMutationDefaults, + }); +} + +type RelationModels = Record }>; + +describe('interpretPslDocumentToSqlContract from/to relation vocabulary', () => { + describe('legacy fields/references rejection', () => { + it('rejects @relation(fields:, references:) with a guiding diagnostic', () => { + const result = interpret(`model User { + id Int @id + posts Post[] +} + +model Post { + id Int @id + userId Int + user User @relation(fields: [userId], references: [id]) +} +`); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_LEGACY_FIELDS_REFERENCES', + message: expect.stringContaining('use from:/to:'), + }), + ]), + ); + }); + + it('rejects a lone legacy fields: argument', () => { + const result = interpret(`model User { + id Int @id + posts Post[] +} + +model Post { + id Int @id + userId Int + user User @relation(fields: [userId]) +} +`); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([expect.objectContaining({ code: 'PSL_LEGACY_FIELDS_REFERENCES' })]), + ); + }); + + it('rejects a lone legacy references: argument', () => { + const result = interpret(`model User { + id Int @id + posts Post[] +} + +model Post { + id Int @id + userId Int + user User @relation(references: [id]) +} +`); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([expect.objectContaining({ code: 'PSL_LEGACY_FIELDS_REFERENCES' })]), + ); + }); + }); + + describe('to inference (omit to: ⇒ target @id)', () => { + it('infers the single-column target @id when to: is omitted', () => { + const inferred = interpret(`model User { + id Int @id + posts Post[] +} + +model Post { + id Int @id + userId Int + user User @relation(from: userId) +} +`); + const explicit = interpret(`model User { + id Int @id + posts Post[] +} + +model Post { + id Int @id + userId Int + user User @relation(from: [userId], to: [id]) +} +`); + + expect(inferred.ok).toBe(true); + expect(explicit.ok).toBe(true); + if (!inferred.ok || !explicit.ok) return; + expect(inferred.value).toEqual(explicit.value); + + const models = modelsOf(inferred.value) as RelationModels; + expect(models['Post']?.relations).toMatchObject({ + user: { + cardinality: 'N:1', + on: { localFields: ['userId'], targetFields: ['id'] }, + }, + }); + }); + + it('infers the composite target @@id when to: is omitted', () => { + const inferred = interpret(`model Account { + tenantId Int + number Int + memberships Membership[] + @@id([tenantId, number]) +} + +model Membership { + id Int @id + accountTenantId Int + accountNumber Int + account Account @relation(from: [accountTenantId, accountNumber]) +} +`); + const explicit = interpret(`model Account { + tenantId Int + number Int + memberships Membership[] + @@id([tenantId, number]) +} + +model Membership { + id Int @id + accountTenantId Int + accountNumber Int + account Account @relation(from: [accountTenantId, accountNumber], to: [tenantId, number]) +} +`); + + expect(inferred.ok).toBe(true); + expect(explicit.ok).toBe(true); + if (!inferred.ok || !explicit.ok) return; + expect(inferred.value).toEqual(explicit.value); + }); + + it('rejects an omitted to: when the target model has no @id', () => { + const result = interpret(`model Tag { + label String +} + +model Post { + id Int @id + tagLabel String + tag Tag @relation(from: tagLabel) +} +`); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'PSL_INVALID_RELATION_ATTRIBUTE' }), + ]), + ); + }); + }); + + describe('value forms', () => { + it('accepts a bare single from: field equivalently to a bracketed one', () => { + const bare = interpret(`model User { + id Int @id + posts Post[] +} + +model Post { + id Int @id + userId Int + user User @relation(from: userId, to: id) +} +`); + const bracketed = interpret(`model User { + id Int @id + posts Post[] +} + +model Post { + id Int @id + userId Int + user User @relation(from: [userId], to: [id]) +} +`); + + expect(bare.ok).toBe(true); + expect(bracketed.ok).toBe(true); + if (!bare.ok || !bracketed.ok) return; + expect(bare.value).toEqual(bracketed.value); + }); + + // The PSL expression grammar does not carry a member-access argument value: + // `parseIdentifierExpr` consumes only the head identifier, so `to: User.id` + // reaches the attribute spec as `to: User`, which names no field on the + // target model and is rejected. This pins the present grammar boundary as a + // regression anchor for a future slice that carries the dotted value. + it('rejects a member-access to: value (qualifier dropped at the grammar layer)', () => { + const qualified = interpret(`model User { + id Int @id + posts Post[] +} + +model Post { + id Int @id + userId Int + user User @relation(from: userId, to: User.id) +} +`); + + expect(qualified.ok).toBe(false); + if (qualified.ok) return; + expect(qualified.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('Field "User" does not exist on model "User"'), + }), + ]), + ); + }); + }); + + describe('both-or-neither diagnostic', () => { + it('rejects a to: without a from:', () => { + const result = interpret(`model User { + id Int @id + posts Post[] +} + +model Post { + id Int @id + userId Int + user User @relation(to: [id]) +} +`); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'PSL_INVALID_RELATION_ATTRIBUTE' }), + ]), + ); + }); + + it('rejects a backrelation list field that declares from/to', () => { + const result = interpret(`model User { + id Int @id + posts Post[] @relation(from: [id], to: [userId]) +} + +model Post { + id Int @id + userId Int + user User @relation(from: userId) +} +`); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'PSL_INVALID_RELATION_ATTRIBUTE' }), + ]), + ); + }); + }); + + describe('self-referential from/to', () => { + it('resolves a named self-referential from/to relation, inferring to: from @id', () => { + const result = interpret(`model Employee { + id Int @id + managerId Int? + manager Employee? @relation("Manages", from: managerId) + reports Employee[] @relation("Manages") +} +`); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const models = modelsOf(result.value) as RelationModels; + expect(models['Employee']?.relations).toMatchObject({ + manager: { + cardinality: 'N:1', + on: { localFields: ['managerId'], targetFields: ['id'] }, + }, + }); + }); + }); +}); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.many-to-many.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.many-to-many.test.ts index 5ba7894b82..918e166845 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.many-to-many.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.many-to-many.test.ts @@ -46,8 +46,8 @@ model Tag { model PostTag { postId Int tagId Int - post Post @relation(fields: [postId], references: [id]) - tag Tag @relation(fields: [tagId], references: [id]) + post Post @relation(from: [postId], to: [id]) + tag Tag @relation(from: [tagId], to: [id]) @@id([postId, tagId]) } @@ -117,8 +117,8 @@ model PostTag { model UserTag { userId Int tagId Int - user User @relation(fields: [userId], references: [id]) - tag Tag @relation(fields: [tagId], references: [id]) + user User @relation(from: [userId], to: [id]) + tag Tag @relation(from: [tagId], to: [id]) @@id([userId, tagId]) } @@ -164,8 +164,8 @@ model UserTag { tagId Int createdAt DateTime note String - user User @relation(fields: [userId], references: [id]) - tag Tag @relation(fields: [tagId], references: [id]) + user User @relation(from: [userId], to: [id]) + tag Tag @relation(from: [tagId], to: [id]) @@id([userId, tagId]) } @@ -209,8 +209,8 @@ model ProjectLabel { projectTenantId Int projectId Int labelId Int - project Project @relation(fields: [projectTenantId, projectId], references: [tenantId, id]) - label Label @relation(fields: [labelId], references: [id]) + project Project @relation(from: [projectTenantId, projectId], to: [tenantId, id]) + label Label @relation(from: [labelId], to: [id]) @@id([projectTenantId, projectId, labelId]) } @@ -271,8 +271,8 @@ model ProjectLabel { projectId Int projectTenantId Int labelId Int - project Project @relation(fields: [projectId, projectTenantId], references: [id, tenantId]) - label Label @relation(fields: [labelId], references: [id]) + project Project @relation(from: [projectId, projectTenantId], to: [id, tenantId]) + label Label @relation(from: [labelId], to: [id]) @@id([projectTenantId, projectId, labelId]) } @@ -332,8 +332,8 @@ model Tag { model PostTag { postId Int tagSlug String - post Post @relation(fields: [postId], references: [id]) - tag Tag @relation(fields: [tagSlug], references: [slug]) + post Post @relation(from: [postId], to: [id]) + tag Tag @relation(from: [tagSlug], to: [slug]) @@id([postId, tagSlug]) } @@ -367,8 +367,8 @@ model PostTag { model Follow { followerId Int followeeId Int - follower User @relation("follower", fields: [followerId], references: [id]) - followee User @relation("followee", fields: [followeeId], references: [id]) + follower User @relation("follower", from: [followerId], to: [id]) + followee User @relation("followee", from: [followeeId], to: [id]) @@id([followerId, followeeId]) } @@ -415,8 +415,8 @@ model Follow { model Follow { followerId Int followeeId Int - follower User @relation("follower", fields: [followerId], references: [id]) - followee User @relation("followee", fields: [followeeId], references: [id]) + follower User @relation("follower", from: [followerId], to: [id]) + followee User @relation("followee", from: [followeeId], to: [id]) @@id([followerId, followeeId]) } @@ -451,8 +451,8 @@ model Tag { model TagOwnership { userId Int tagId Int - user User @relation("owned", fields: [userId], references: [id]) - tag Tag @relation("owned", fields: [tagId], references: [id]) + user User @relation("owned", from: [userId], to: [id]) + tag Tag @relation("owned", from: [tagId], to: [id]) @@id([userId, tagId]) } @@ -460,8 +460,8 @@ model TagOwnership { model TagWatch { userId Int tagId Int - user User @relation("watched", fields: [userId], references: [id]) - tag Tag @relation("watched", fields: [tagId], references: [id]) + user User @relation("watched", from: [userId], to: [id]) + tag Tag @relation("watched", from: [tagId], to: [id]) @@id([userId, tagId]) } @@ -544,8 +544,8 @@ model Tag { model TagOwnership { userId Int tagId Int - user User @relation(fields: [userId], references: [id]) - tag Tag @relation(fields: [tagId], references: [id]) + user User @relation(from: [userId], to: [id]) + tag Tag @relation(from: [tagId], to: [id]) @@id([userId, tagId]) } @@ -553,8 +553,8 @@ model TagOwnership { model TagWatch { userId Int tagId Int - user User @relation(fields: [userId], references: [id]) - tag Tag @relation(fields: [tagId], references: [id]) + user User @relation(from: [userId], to: [id]) + tag Tag @relation(from: [tagId], to: [id]) @@id([userId, tagId]) } @@ -611,8 +611,8 @@ model PostTag { id Int @id postId Int tagId Int - post Post @relation(fields: [postId], references: [id]) - tag Tag @relation(fields: [tagId], references: [id]) + post Post @relation(from: [postId], to: [id]) + tag Tag @relation(from: [tagId], to: [id]) } `); @@ -648,8 +648,8 @@ model Tag { model PostTag { postId Int tagId Int - post Post @relation(fields: [postId], references: [id]) - tag Tag @relation(fields: [tagId], references: [id]) + post Post @relation(from: [postId], to: [id]) + tag Tag @relation(from: [tagId], to: [id]) @@id([postId, tagId]) } @@ -696,14 +696,14 @@ model PostTag { model Tag { id Int @id postId Int - post Post @relation(fields: [postId], references: [id]) + post Post @relation(from: [postId], to: [id]) } model PostTag { postId Int tagId Int - post Post @relation(fields: [postId], references: [id]) - tag Tag @relation(fields: [tagId], references: [id]) + post Post @relation(from: [postId], to: [id]) + tag Tag @relation(from: [tagId], to: [id]) @@id([postId, tagId]) } diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts index 66376f686c..60f9fcaaeb 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts @@ -33,7 +33,7 @@ describe('interpretPslDocumentToSqlContract relations', () => { model Post { id Int @id userId Int - user User @relation(fields: [userId], references: [id]) + user User @relation(from: [userId], to: [id]) } `, sourceId: 'schema.prisma', @@ -87,8 +87,8 @@ model Post { id Int @id authorId Int reviewerId Int - author User @relation("AuthoredPosts", fields: [authorId], references: [id]) - reviewer User @relation(name: "ReviewedPosts", fields: [reviewerId], references: [id]) + author User @relation("AuthoredPosts", from: [authorId], to: [id]) + reviewer User @relation(name: "ReviewedPosts", from: [reviewerId], to: [id]) } `, sourceId: 'schema.prisma', @@ -133,7 +133,7 @@ model Post { model Post { id Int @id userId Int - user User @relation(fields: [userId], references: [id]) + user User @relation(from: [userId], to: [id]) } model Team { @@ -143,7 +143,7 @@ model Team { model Member { id Int @id teamId Int - team Team @relation(fields: [teamId], references: [id]) + team Team @relation(from: [teamId], to: [id]) } `, sourceId: 'schema.prisma', @@ -181,7 +181,7 @@ model Member { schema: `model Employee { id Int @id managerId Int? - manager Employee? @relation("Manages", fields: [managerId], references: [id]) + manager Employee? @relation("Manages", from: [managerId], to: [id]) reports Employee[] @relation("Manages") } `, @@ -223,8 +223,8 @@ model Member { id Int @id managerId Int? mentorId Int? - manager Employee? @relation(fields: [managerId], references: [id]) - mentor Employee? @relation(fields: [mentorId], references: [id]) + manager Employee? @relation(from: [managerId], to: [id]) + mentor Employee? @relation(from: [mentorId], to: [id]) reports Employee[] } `, @@ -258,7 +258,7 @@ model Member { model Member { id Int @id @map("member_id") teamId Int @map("team_ref") - team Team @relation(fields: [teamId], references: [id], map: "team_member_team_ref_fkey") + team Team @relation(from: [teamId], to: [id], map: "team_member_team_ref_fkey") @@map("team_member") } @@ -293,7 +293,7 @@ model Member { model Post { id Int @id userId Int - author User @relation(fields: [userId], references: [id], onDelete: WeirdAction) + author User @relation(from: [userId], to: [id], onDelete: WeirdAction) } `, sourceId: 'schema.prisma', @@ -328,7 +328,7 @@ model Post { model Post { id Int @id userId Int - user User @relation(fields: [missingUserId], references: [id]) + user User @relation(from: [missingUserId], to: [id]) } `, sourceId: 'schema.prisma', @@ -362,7 +362,7 @@ model Post { model Post { id Int @id userId Int - user User @relation(fields: [userId], references: [missingId]) + user User @relation(from: [userId], to: [missingId]) } `, sourceId: 'schema.prisma', @@ -396,7 +396,7 @@ model Post { model Post { id Int @id userId Int - user User @relation(fields: [userId, userId], references: [id]) + user User @relation(from: [userId, userId], to: [id]) } `, sourceId: 'schema.prisma', @@ -421,7 +421,7 @@ model Post { ); }); - it('returns diagnostics when relation omits required fields argument', () => { + it('returns diagnostics when relation omits the required local-fields argument', () => { const document = symbolTableInputFromParseArgs({ schema: `model User { id Int @id @@ -430,7 +430,7 @@ model Post { model Post { id Int @id userId Int - user User @relation(references: [id]) + user User @relation(to: [id]) } `, sourceId: 'schema.prisma', @@ -448,8 +448,9 @@ model Post { expect(result.failure.diagnostics).toEqual( expect.arrayContaining([ expect.objectContaining({ - code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', - message: 'Relation field "Post.user" requires fields and references arguments', + code: 'PSL_INVALID_RELATION_ATTRIBUTE', + message: + 'Relation field "Post.user" requires a from argument naming the local foreign-key field(s)', }), ]), ); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.test.ts index d89863d3b0..fe536d5424 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.test.ts @@ -216,14 +216,14 @@ model Post { id Int @id title String userId Int - author User @relation(fields: [userId], references: [id]) + author User @relation(from: [userId], to: [id]) } model Comment { id Int @id body String postId Int - post Post @relation(fields: [postId], references: [id]) + post Post @relation(from: [postId], to: [id]) } `, sourceId: 'schema.prisma', @@ -433,7 +433,7 @@ model Comment { model Member { id Int @id @map("member_id") teamId Int @map("team_ref") - team Team @relation(fields: [teamId], references: [id]) + team Team @relation(from: [teamId], to: [id]) @@map("team_member") @@index([teamId]) @@unique([teamId, id]) diff --git a/packages/2-sql/2-authoring/contract-psl/test/provider.test.ts b/packages/2-sql/2-authoring/contract-psl/test/provider.test.ts index 326daae7f8..01ea464170 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/provider.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/provider.test.ts @@ -226,7 +226,7 @@ describe('prismaContract provider helper', () => { model Post { id Int @id userId Int - user User @relation(fields: [userId], references: [id]) + user User @relation(from: [userId], to: [id]) } `, 'utf-8', @@ -318,7 +318,7 @@ model Post { model Post { id Int @id userId Int - user User @relation(fields: [userId], references: [id]) + user User @relation(from: [userId], to: [id]) } `, 'utf-8', diff --git a/packages/2-sql/2-authoring/contract-psl/test/psl-ts-namespace-parity.test.ts b/packages/2-sql/2-authoring/contract-psl/test/psl-ts-namespace-parity.test.ts index d4f5faf70b..8ccdf2e2a3 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/psl-ts-namespace-parity.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/psl-ts-namespace-parity.test.ts @@ -44,7 +44,7 @@ namespace public { model Post { id Int @id userId Int - user auth.User @relation(fields: [userId], references: [id]) + user auth.User @relation(from: [userId], to: [id]) } } `, @@ -170,7 +170,7 @@ namespace public { schema: `model Profile { id Int @id userId Int - user supabase:auth.User @relation(fields: [userId], references: [id]) + user supabase:auth.User @relation(from: [userId], to: [id]) } `, sourceId: 'schema.prisma', @@ -242,7 +242,7 @@ namespace public { schema: `model Profile { id Int @id userId Int - user supabase:auth.User @relation(fields: [userId], references: [id]) + user supabase:auth.User @relation(from: [userId], to: [id]) } `, sourceId: 'schema.prisma', diff --git a/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts b/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts index 8d4aeddb2e..872b61a0bb 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts @@ -261,7 +261,7 @@ model Post { id Int @id(map: "post_pkey") authorId Int title String - author User @relation(fields: [authorId], references: [id], map: "post_author_id_fkey", onDelete: Cascade) + author User @relation(from: [authorId], to: [id], map: "post_author_id_fkey", onDelete: Cascade) @@index([authorId], map: "post_author_id_idx") } `; @@ -417,7 +417,7 @@ describe('TS and PSL authoring parity', () => { model Post { id Int @id authorId Int - author User @relation(fields: [authorId], references: [id]) + author User @relation(from: [authorId], to: [id]) } `, sourceId: 'schema.prisma', diff --git a/packages/3-extensions/supabase/test/infer-cross-space-fk.integration.test.ts b/packages/3-extensions/supabase/test/infer-cross-space-fk.integration.test.ts index 126e432cf4..aeda419b21 100644 --- a/packages/3-extensions/supabase/test/infer-cross-space-fk.integration.test.ts +++ b/packages/3-extensions/supabase/test/infer-cross-space-fk.integration.test.ts @@ -16,7 +16,7 @@ * - keep the app's own `public.users` as a `Users` model, * - omit `auth.users` (the pack already describes it), * - emit the FK as the qualified cross-space relation - * `supabase:auth.AuthUser @relation(fields: [userId], references: [id], + * `supabase:auth.AuthUser @relation(from: [userId], to: [id], * onDelete: Cascade)` — matching how `examples/supabase/src/contract.prisma` * hand-authors the same relationship — rather than wiring it to the local * `Users` model or stripping it. @@ -105,9 +105,7 @@ describe('contract infer — cross-space FK into the Supabase pack', () => { const printed = printPsl(ast); expect(printed).toMatch(/\buser\s+supabase:auth\.AuthUser\b/); - expect(printed).toContain( - '@relation(fields: [userId], references: [id], onDelete: Cascade', - ); + expect(printed).toContain('@relation(from: [userId], to: [id], onDelete: Cascade'); } finally { await client.close(); } diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts index bcb050b987..e697551cac 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts @@ -653,7 +653,7 @@ function buildRelationField( } args.push( namedArg( - 'fields', + 'from', `[${rel.fields .map((columnName) => resolveColumnFieldName(fieldNamesByTable, hostTableName, columnName)) .join(', ')}]`, @@ -661,7 +661,7 @@ function buildRelationField( ); args.push( namedArg( - 'references', + 'to', `[${rel.references .map((columnName) => resolveColumnFieldName(fieldNamesByTable, rel.referencedTableName ?? '', columnName), diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract-described-contracts.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract-described-contracts.test.ts index b14185a5d6..86bd30cba6 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract-described-contracts.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract-described-contracts.test.ts @@ -271,7 +271,7 @@ describe('inferPostgresPslContract — described-contract omission', () => { const printed = printPsl(ast); expect(printed).toContain('supabase:auth.AuthUser'); - expect(printed).toContain('@relation(fields: [userId], references: [id], onDelete: Cascade)'); + expect(printed).toContain('@relation(from: [userId], to: [id], onDelete: Cascade)'); }); it('resolves the cross-space FK even when a same-bare-named local table survives (pack-owned coordinate wins over the local shadow)', () => { diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract.test.ts index c90e69409d..84b824b7fe 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract.test.ts @@ -260,7 +260,7 @@ describe('inferPostgresPslContract', () => { id Int @id title String userId Int @map("user_id") - user User @relation(fields: [userId], references: [id]) + user User @relation(from: [userId], to: [id]) @@index([userId]) @@map("post") diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.naming-and-constraints.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.naming-and-constraints.test.ts index 958bdda75e..a84079c010 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.naming-and-constraints.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.naming-and-constraints.test.ts @@ -54,7 +54,7 @@ describe('printPsl', () => { model Login { id Int @id _2faId Int @map("2fa_id") - _2fa Account @relation(fields: [_2faId], references: [id]) + _2fa Account @relation(from: [_2faId], to: [id]) @@map("login") } @@ -123,7 +123,7 @@ describe('printPsl', () => { model Login { id Int @id accountId Int @map("account_id") - account Account @relation(fields: [accountId], references: [userId2]) + account Account @relation(from: [accountId], to: [userId2]) @@map("login") } diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.relations.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.relations.test.ts index 68f009ab73..98c7f5bcbd 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.relations.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.relations.test.ts @@ -54,7 +54,7 @@ describe('printPsl', () => { id Int @id title String userId Int @map("user_id") - user User @relation(fields: [userId], references: [id]) + user User @relation(from: [userId], to: [id]) @@index([userId]) @@map("post") @@ -112,7 +112,7 @@ describe('printPsl', () => { id Int @id userId Int @unique @map("user_id") bio String? - user User @relation(fields: [userId], references: [id]) + user User @relation(from: [userId], to: [id]) @@map("profile") } @@ -184,7 +184,7 @@ describe('printPsl', () => { id Int @id tenantId Int @map("tenant_id") accountId Int @map("account_id") - account Account @relation(fields: [tenantId, accountId], references: [tenantId, id]) + account Account @relation(from: [tenantId, accountId], to: [tenantId, id]) @@unique([tenantId, accountId]) @@map("profile") @@ -229,7 +229,7 @@ describe('printPsl', () => { id Int @id name String managerId Int? @map("manager_id") - manager Employee? @relation(name: "ManagerEmployees", fields: [managerId], references: [id]) + manager Employee? @relation(name: "ManagerEmployees", from: [managerId], to: [id]) employees Employee[] @relation(name: "ManagerEmployees") @@map("employee") @@ -303,8 +303,8 @@ describe('printPsl', () => { id Int @id senderId Int @map("sender_id") recipientId Int @map("recipient_id") - sender User @relation(name: "message_sender_fk", fields: [senderId], references: [id], map: "message_sender_fk") - recipient User @relation(name: "message_recipient_fk", fields: [recipientId], references: [id], map: "message_recipient_fk") + sender User @relation(name: "message_sender_fk", from: [senderId], to: [id], map: "message_sender_fk") + recipient User @relation(name: "message_recipient_fk", from: [recipientId], to: [id], map: "message_recipient_fk") @@map("message") } @@ -380,7 +380,7 @@ describe('printPsl', () => { id Int @id productCategoryId Int @map("product_category_id") productProductId Int @map("product_product_id") - product Product @relation(fields: [productCategoryId, productProductId], references: [categoryId, productId]) + product Product @relation(from: [productCategoryId, productProductId], to: [categoryId, productId]) @@map("review") } @@ -441,7 +441,7 @@ describe('printPsl', () => { model Child { id Int @id parentId Int @map("parent_id") - parent Parent @relation(fields: [parentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + parent Parent @relation(from: [parentId], to: [id], onDelete: Cascade, onUpdate: Cascade) @@map("child") } @@ -498,7 +498,7 @@ describe('printPsl', () => { model Member { id Int @id teamId Int @map("team_id") - team Team @relation(fields: [teamId], references: [id], map: "member_team_id_fkey") + team Team @relation(from: [teamId], to: [id], map: "member_team_id_fkey") @@map("member") } diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.single-dialect-gate.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.single-dialect-gate.test.ts new file mode 100644 index 0000000000..85f56bb1da --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.single-dialect-gate.test.ts @@ -0,0 +1,66 @@ +import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; +import { describe, expect, it } from 'vitest'; +import { printPslFromFlat } from '../fixtures'; + +// A schema exercising both a single-column FK and a composite FK, so the gate +// covers every shape the relation printer emits. The host of each `@relation` +// is the FK-owning side, which is where `from:`/`to:` are rendered. +const schemaIR: SqlSchemaIR = { + tables: { + account: { + name: 'account', + columns: { + tenant_id: { name: 'tenant_id', nativeType: 'int4', nullable: false }, + id: { name: 'id', nativeType: 'int4', nullable: false }, + }, + primaryKey: { columns: ['tenant_id', 'id'] }, + foreignKeys: [], + uniques: [], + indexes: [], + }, + user: { + name: 'user', + columns: { + id: { name: 'id', nativeType: 'int4', nullable: false }, + }, + primaryKey: { columns: ['id'] }, + foreignKeys: [], + uniques: [], + indexes: [], + }, + profile: { + name: 'profile', + columns: { + id: { name: 'id', nativeType: 'int4', nullable: false }, + user_id: { name: 'user_id', nativeType: 'int4', nullable: false }, + tenant_id: { name: 'tenant_id', nativeType: 'int4', nullable: false }, + account_id: { name: 'account_id', nativeType: 'int4', nullable: false }, + }, + primaryKey: { columns: ['id'] }, + foreignKeys: [ + { columns: ['user_id'], referencedTable: 'user', referencedColumns: ['id'] }, + { + columns: ['tenant_id', 'account_id'], + referencedTable: 'account', + referencedColumns: ['tenant_id', 'id'], + }, + ], + uniques: [], + indexes: [], + }, + }, +}; + +describe('contract infer emits single-dialect relation vocabulary', () => { + const printed = printPslFromFlat(schemaIR); + + it('emits no legacy @relation fields:/references: keys', () => { + expect(printed).not.toMatch(/@relation\([^)]*\bfields:/); + expect(printed).not.toMatch(/@relation\([^)]*\breferences:/); + }); + + it('emits the canonical from:/to: keys for FK relations', () => { + expect(printed).toMatch(/@relation\([^)]*\bfrom:/); + expect(printed).toMatch(/@relation\([^)]*\bto:/); + }); +}); diff --git a/projects/psl-relation-syntax/README.md b/projects/psl-relation-syntax/README.md new file mode 100644 index 0000000000..3480746c4d --- /dev/null +++ b/projects/psl-relation-syntax/README.md @@ -0,0 +1,13 @@ +# PSL relation syntax + +Rework PSL's relation declaration to a directional `from:`/`to:`/`through:` vocabulary, +backward-compatible with `fields:`/`references:` (old spelling still parses; the PSL +formatter rewrites it to the canonical form). + +- `spec.md` — project spec (drafted via `drive-specify-project`, after design discussion) +- `plan.md` — project plan / slice composition (drafted via `drive-plan-project`) +- `design-notes.md` — settled design, principles, alternatives, open questions +- `slices/` — per-slice specs and plans + +Sibling project: `projects/sql-orm-many-to-many/` (runtime M:N; retains slice 7 / TML-2933). +Design straw-man: `wip/mn-psl-changes.diff`. diff --git a/projects/psl-relation-syntax/design-notes.md b/projects/psl-relation-syntax/design-notes.md new file mode 100644 index 0000000000..52cdf2450d --- /dev/null +++ b/projects/psl-relation-syntax/design-notes.md @@ -0,0 +1,72 @@ +# Design notes: psl-relation-syntax + +> Synthesized design document for `psl-relation-syntax`. Read this if you want to understand **what the project's design is**, **what principles it serves**, and **what alternatives were considered and rejected**. This document is not a chronological log of decisions — it captures the settled design, standing independently of the discussions that produced it. +> +> Owned by the Orchestrator. Authored directly. **Status: grammar settled (D1–D5) via `drive-discussion`, 2026-06-24; D1 reversed to a clean break on 2026-06-26 (operator decision — legacy input acceptance and the `format` rewrite removed). Slice-level mechanics deferred to slice specs.** + +## Principles this design serves + +- **Directionality reads naturally** — a relation is "from these local fields to that referenced key"; the vocabulary says so. +- **Omit what can be inferred** — single fields need no brackets; a referenced `@id` needs no explicit `to:`; an unambiguous junction is named on one end only. +- **Clean break** — the directional vocabulary is the only accepted syntax; legacy `fields:`/`references:`/`@relation(name:)` are rejected with a guiding diagnostic. A reusable codemod for downstream users is deferred (TML-2957). +- **Disambiguate by pointing, not by naming** — replace the free-floating `@relation(name: "...")` string with a direct reference to the relation field. +- **Explicit over magic** — junction synthesis fires only when there is genuinely no junction to find; an authored junction is never silently ignored. + +## The model + +Canonical vocabulary on `@relation`: + +- `from:` — local FK field(s). Bare for a single field (`from: userId`); bracketed for composites (`from: [followerId, ...]`), brackets required when composite. +- `to:` — the referenced key. Omitted ⇒ infer the target's `@id`. Bare single field or bracketed list. A redundant `Model.` qualifier (`to: Post.id`) is tolerated and preserved verbatim; true cross-model qualified paths belong to the arrow-path slice. +- `through:` — the junction for the navigable M:N side. Named on **one** end when unambiguous (the inverse list field is inferred by type-match); on **both** ends with a relation-field path (`through: Follow.follower`) only to disambiguate self-relations or multiple M:N between the same pair of models. +- `inverse:` — the 1:N back-relation's pointer at the inverse FK field, used only to disambiguate when multiple relations link the same pair of models (`posts Post[] @relation(inverse: editor)`). + +### Decisions + +- **D1 — Clean break: `from`/`to`/`through`/`inverse` only; legacy rejected.** The directional vocabulary is the sole accepted `@relation` syntax. Legacy `fields:`/`references:` and `@relation(name:)` are rejected at parse time with a guiding diagnostic (`PSL_LEGACY_FIELDS_REFERENCES` / `PSL_LEGACY_NAME`) that directs authors to the replacement. The `format` command no longer rewrites relation keywords — it was removed when legacy acceptance was dropped, since `format` cannot operate on now-invalid syntax. The `contract infer` AST printer (`@prisma-next/psl-printer`) renders the canonical spelling. The repo's own schemas (SQL and Mongo families) are migrated in-stack; a reusable downstream codemod is deferred (TML-2957). *(Reverses the original D1, which kept legacy as input-only with a `format`-based auto-rewrite. Operator decision, 2026-06-26.)* +- **D2 — Retire `@relation(name:)` entirely.** Disambiguation is by pointing: `from`/`to` (FK declaration), `through:` (M:N junction side), `inverse:` (1:N back side). One mechanism per case; no string survivor across cardinalities. Legacy `name:` is rejected at parse time (per D1 clean break), not merely dropped from output. +- **D3 — (Removed.)** The conservative `format` canonicalisation pass (keyword migration only) was built in S1·M2 and then removed when D1 was reversed — `format` no longer rewrites relations. The CST formatter still exists for layout normalisation; it simply has no relation-keyword rewrite. +- **D4 — `through:` on one end; infer the inverse.** Unambiguous M:N: one navigable end declares `through: Junction`, the other's inverse list is inferred. Ambiguous (self-relation / multiple M:N between the same models): both ends declare and disambiguate via `through: Junction.relationField`. +- **D5 — Bare-list precedence.** Resolving a bare list (`Tag[]`): (1) other end has `through:` → this is its inferred inverse; (2) both ends bare **and** a junction model links them → recognise that authored junction (preserves shipped slice-5 behaviour); (3) both ends bare **and** no junction model → synthesise a model-less junction table (implicit M:N). + +### Provisional slice slate + +1. `from`/`to` FK foundation: reject legacy `fields:`/`references:` at parse time, resolver reads `from`/`to` with `to:` inference, AST printer emits canonical, repo-wide migration to `from`/`to`. FK relations only. +2. `through: Junction` explicit M:N (one-end declare + inverse inference, D4 unambiguous case). +3. `through: Junction.relationField` + `inverse:` disambiguation (D4 ambiguous case + 1:N back-relation; retires `name:`). +4. Implicit M:N — synthesise a model-less junction table + its migrations (D5 case 3). +5. Arrow-path `through: a -> J.b -> J.c -> T.d` — declare M:N on terminal models without junction relation fields. + +Sequencing: 1 is the foundation; 2 → 3 sequential; 4 and 5 build on 2 (and 3 where ambiguity is possible) and parallelise. + +## Alternatives considered + +- **Parse-both + canonical-emit (the original D1).** Legacy `fields:`/`references:`/`name:` would keep parsing as input-only, with `format` rewriting to canonical and the printer emitting canonical. **Rejected because:** the operator preferred a clean break — the prototype is pre-1.0, breaking changes are acceptable, and carrying a legacy dialect plus a `format`-based auto-migration that can't operate on now-invalid syntax adds surface area for little benefit. *(Reversal decision, 2026-06-26.)* +- **Keep `@relation(name: "...")` for disambiguation.** **Rejected because:** the name is a free-floating token kept in sync across fields by convention; pointing at the relation field is direct and self-checking. +- **`via:` for the 1:N back-relation pointer.** **Rejected because:** homophone of `through`, and `through` wrongly implies an intermediary where a 1:N back-relation is simply the inverse of one FK. `inverse:` is exact (Doctrine/JPA `inversedBy`/`mappedBy` precedent). +- **Aggressive canonicalisation** (formatter drops inferable args / strips qualifiers). **Rejected because:** the `format` rewrite was removed entirely when D1 was reversed, so there is no canonicalisation pass to make aggressive. The formatter does layout only. +- **`through:` required on both ends.** **Rejected because:** inferring the inverse honours omit-what's-inferable; explicit-both is reserved for genuine ambiguity. +- **"Both bare always synthesises" (literal).** **Rejected because:** it would silently ignore an authored junction model and break shipped slice-5 behaviour; D5's precedence synthesises only when no junction exists. + +## Open questions + +_Deferred to slice specs; each carries a working position so execution proceeds without blocking._ + +- **Implicit-M:N synthesis mechanics** (slice 4) — synthesised table/column naming (Prisma's `_AToB`/`A`/`B`, or our own convention) and migration/DDL threading. Working position: mirror Prisma's convention unless DDL threading argues otherwise. +- **Arrow-path grammar** (slice 5) — exact `a -> J.b -> J.c -> T.d` tokenisation + validation. Working position: a distinct lowering from implicit M:N (arrow-path keeps an authored junction *model* with scalar columns; implicit authors none). +- **Diagnostics** — wording for the new arguments, and the "you authored a junction but never referenced it" guard implied by D5's case (2)/(3) boundary. +- **`to:` value grammar** — accepts bare field / bracketed list; a redundant `Model.` qualifier (`to: Post.id`) works via the member-access value grammar landed in slice 3. Cross-model qualified paths are arrow-path territory. + +## Accepted trade-offs + +- **Clean break migration cost.** Downstream PSL authors with legacy schemas must migrate manually (a codemod is deferred as TML-2957). The repo's own ~40 legacy sites are migrated in-stack, byte-identically (the emitted contracts are unchanged — only the PSL spelling moves). +- **The `format` command no longer rewrites relation keywords.** It still normalises layout (indent, newlines) but does not touch `@relation` argument names. Legacy syntax is rejected at parse time, so there is nothing to canonicalise. + +## References + +- Project spec: [`./spec.md`](./spec.md) +- Project plan: [`./plan.md`](./plan.md) +- Straw-man: `wip/mn-psl-changes.diff` +- Sibling project: `projects/sql-orm-many-to-many/` (runtime M:N; retains slice 7 / TML-2933, non-id unique junction targets) +- Primary surfaces: `packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts`; `packages/2-sql/2-authoring/contract-ts/src/build-contract.ts`; `@prisma-next/psl-parser` (generic attribute grammar; member-access value parsing); `@prisma-next/psl-printer` (AST printer for `contract infer`); `packages/2-mongo/2-authoring/mongo-family/src/psl-helpers.ts` (Mongo relation parsing). +- Deferred codemod: **TML-2957** (automated legacy → `from`/`to`/`through`/`inverse` migration for downstream users). diff --git a/projects/psl-relation-syntax/plan.md b/projects/psl-relation-syntax/plan.md new file mode 100644 index 0000000000..80c6c9dc06 --- /dev/null +++ b/projects/psl-relation-syntax/plan.md @@ -0,0 +1,68 @@ +# PSL: Directional Relation Syntax — Plan + +**Spec:** `projects/psl-relation-syntax/spec.md` +**Linear Project:** [PSL: Directional Relation Syntax](https://linear.app/prisma-company/project/psl-directional-relation-syntax-04e6440a8ee4) (planning anchor: [TML-2939](https://linear.app/prisma-company/issue/TML-2939)) + +## At a glance + +A **3-slice core stack** delivers the directional vocabulary proper (`from`/`to` foundation → explicit `through:` → pointer-disambiguation that retires `@relation(name:)`). A **2-slice parallel group** (implicit M:N, arrow-path) builds on the explicit-`through:` hand-off and is **flagged for promotion to a sibling follow-on project** — see [§ Project-boundary note](#project-boundary-note). + +## Composition + +### Stack (deliver in order) + +1. **Slice `01-from-to-fk-foundation`** — Linear: [TML-2940](https://linear.app/prisma-company/issue/TML-2940) + - **Outcome:** `@relation(from:, to:)` authors every FK (1:N / N:1) the legacy `fields:`/`references:` could; legacy parses as an input alias to the same IR; `prisma-next format` and `contract infer` emit canonical; legacy and canonical lower to byte-identical contracts. + - **Builds on:** None (the `@relation` attribute grammar is already generic — no parser change). + - **Hands to:** (a) the resolver reading `from`/`to` with legacy aliasing; (b) the CST `format` keyword-only normalize pass + the AST printer emitting canonical; (c) the backward-compat-equivalence test harness the M:N slices reuse. + - **Focus:** `contract-psl/psl-relation-resolution.ts` (read `from`/`to`), `psl-parser/format/` (normalize pass, D3), `psl-printer` (canonical render), validation. FK relations only — no `through:`/`inverse:`/M:N here. + +2. **Slice `02-through-explicit-mn`** — Linear: [TML-2941](https://linear.app/prisma-company/issue/TML-2941) + - **Outcome:** an unambiguous M:N authored with `through: Junction` on one navigable end lowers to the existing `N:M` + `through` contract shape; the inverse list field is inferred by type-match (D4). + - **Builds on:** Slice 1's `from`/`to` resolver + canonical-emit + equivalence harness. + - **Hands to:** the `through:`-declared junction lowering (the surface slices 3–5 extend). + - **Focus:** `psl-relation-resolution.ts` junction recognition via explicit `through:`; canonical emit of `through:`; runtime-parity fixture through the ORM. No disambiguation, no synthesis. + +3. **Slice `03-pointer-disambiguation`** — Linear: [TML-2942](https://linear.app/prisma-company/issue/TML-2942) + - **Outcome:** ambiguous M:N (self-relation / multiple between the same models) disambiguates via `through: Junction.relationField` on both ends; a 1:N back-relation with multiple candidates via `inverse: `; `@relation(name:)` is absent from canonical output (parsed on input, never emitted). + - **Builds on:** Slice 2's `through:` lowering. + - **Hands to:** the complete point-don't-name disambiguation surface; `name:`-free canonical output. + - **Focus:** `psl-relation-resolution.ts` disambiguation (replace the name-matching arms), `inverse:` for 1:N back-relations, grep gate + round-trip proving `name:` isn't emitted. D2, D4. + +### Parallel group (builds on slice 2; **promotion candidates** — see boundary note) + +- **Slice `04-implicit-mn-synthesis`** — Linear: [TML-2943](https://linear.app/prisma-company/issue/TML-2943) + - **Outcome:** both navigable ends bare **and** no junction model linking them → the framework synthesises a model-less junction table + its `N:M`/`through` relations into the contract; postgres + sqlite emit its DDL. Slice-5 recognition (both bare + a junction model exists) is preserved (D5). + - **Builds on:** Slice 2's `through:` lowering (synthesis lowers to the same shape with a conjured junction). + - **Hands to:** implicit M:N parity with Prisma. + - **Focus:** `contract-ts/build-contract.ts` + `psl-relation-resolution.ts` synthesis path; migration/DDL threading for the synthesised table; runtime-parity fixture. **Distinct blast radius — touches the migration subsystem.** + +- **Slice `05-arrow-path-through`** — Linear: [TML-2944](https://linear.app/prisma-company/issue/TML-2944) + - **Outcome:** an M:N authored on the terminal models via the arrow-path (`through: a -> J.b -> J.c -> T.d`), with the junction model present but carrying no relation fields, lowers to `N:M` + `through`. + - **Builds on:** Slice 2's `through:` lowering (and slice 3 where the arrow-path is itself ambiguous). + - **Hands to:** junction-relation-field-free M:N authoring. + - **Focus:** arrow-path tokenisation + validation, lowering to `through`; runtime-parity fixture. Grammar exact form settled at slice spec. + +## Dependencies (external) + +- [x] Sibling slice 5 (PSL M:N recognition, TML-2794) merged on `main` — slice 2 extends its junction recognition; slice 4 must preserve its bare-list behaviour. +- [x] Sibling slice 0 (`through` contract shape + validator, TML-2784) merged — the lowering target for slices 2–5. +- [ ] Sibling slice 7 / TML-2933 (non-id unique junction targets) — independent; not a blocker. Touches the same `targetColumns` derivation, so coordinate rebases if both are in flight. + +## Sequencing rationale + +Slice 1 is a hard gate: until `from`/`to` resolve and the formatter/printer emit canonical, no slice has a foundation to author `through:`/`inverse:` over. Slices 2 → 3 stack by data dependency (3's disambiguation extends 2's recognition). Slices 4 and 5 both consume slice 2's `through:` lowering and touch disjoint surfaces (4: synthesis + migration; 5: arrow-path grammar), so they parallelise; 5 has a soft dependency on 3 only where an arrow-path is itself ambiguous. + +### Project-boundary note + +The plan lists **5 slices**, above the 1–4 sweet spot (`drive/calibration/sizing.md` flags 5+ as "two projects with one shared umbrella ticket"). The split is real: slices 1–3 deliver the project's named purpose (the directional, point-don't-name vocabulary) and stand alone; slices 4–5 are *additional* M:N boilerplate-elimination forms, and slice 4 reaches into the migration/DDL subsystem — a different blast radius. + +**Recommendation:** at slice-3 close, promote slices 4–5 (TML-2943, TML-2944) to a sibling follow-on project ("M:N boilerplate elimination") with an explicit dependency on this project's `through:` foundation. They are planned here for observability and because they are not yet started; reassigning the two issues to a new Linear Project is trivial. The decision is deferred to that checkpoint (operator-authorisation-gated, per `drive-triage-work`); the core stack (1–3) proceeds regardless. + +## Close-out (required) + +- [ ] Verify all acceptance criteria in `projects/psl-relation-syntax/spec.md` +- [ ] ADR authored (directional relation vocabulary + `name:` retirement + implicit-junction synthesis) +- [ ] Migrate long-lived docs into `docs/` +- [ ] Strip repo-wide references to `projects/psl-relation-syntax/**` +- [ ] Delete `projects/psl-relation-syntax/` diff --git a/projects/psl-relation-syntax/slices/01-from-to-fk-foundation/plan.md b/projects/psl-relation-syntax/slices/01-from-to-fk-foundation/plan.md new file mode 100644 index 0000000000..6488d0eb98 --- /dev/null +++ b/projects/psl-relation-syntax/slices/01-from-to-fk-foundation/plan.md @@ -0,0 +1,49 @@ +# Slice 1 — from/to FK foundation — Dispatch plan + +**Slice spec:** `projects/psl-relation-syntax/slices/01-from-to-fk-foundation/spec.md` +**Linear:** [TML-2940](https://linear.app/prisma-company/issue/TML-2940) + +Three sequential dispatches — the read / format / print faces of the `from`/`to` FK vocabulary. Test-first spine: M1 proves the backward-compat invariant at the contract level; M2 and M3 make the two emit surfaces canonical. + +## M1 — Resolver reads `from`/`to` (legacy as input alias) + backward-compat equivalence + +- **Outcome:** the PSL resolver lowers `@relation(from:, to:)` for FK relations identically to `@relation(fields:, references:)`; a schema authored each way emits byte-identical `contract.json`. +- **Builds on:** none (the `@relation` attribute grammar is already generic — args scanned by string). +- **Hands to:** a resolver that speaks `from`/`to`, with legacy `fields`/`references` aliasing to the same `{ fields, references }` result. The equivalence test harness M3's grep gate complements. +- **Focus:** `packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts` `parseRelation` (~L192–237): read `from`/`to`; alias legacy keys; inference — omit `to:` ⇒ target `@id`, bare single field vs bracketed composite (brackets required when composite), tolerate + carry a redundant `Model.` qualifier on `to:`. Preserve the existing both-or-neither diagnostic (a `from:` with no inferable `to:`). FK relations only. +- **Completed when:** + - [ ] `pnpm --filter @prisma-next/sql-contract-psl test` green with a new test asserting a legacy-spelled and a canonical-spelled relation lower to the **same** resolved relation. + - [ ] A fixture authored both ways emits **byte-identical** `contract.json` (equality assertion, not two snapshots). + - [ ] `cd packages/2-sql/2-authoring/contract-psl && pnpm typecheck` clean. +- **Halt conditions:** + - Reading `from`/`to` turns out to need a parser/grammar change (the generic-grammar assumption is false) → **I12 halt**: surface; do not silently add grammar. + - The contract-emit path can't produce byte-identical output for the two spellings (a non-arg difference leaks) → surface the divergence before forcing equality. + +## M2 — CST `format` canonicalises the keyword (trivia-preserving, idempotent) + +- **Outcome:** `prisma-next format` rewrites `@relation` argument keys `fields`→`from` and `references`→`to` in place, keyword-token only, preserving values, brackets, qualifiers, comments, and alignment; running it twice is a fixpoint. +- **Builds on:** M1's resolver (so the canonicalised output still lowers correctly — though M2 is a pure syntactic transform). +- **Hands to:** `format` as a single-dialect emitter for the keyword. +- **Focus:** `packages/1-framework/2-authoring/psl-parser/src/format/` (`emit.ts` token-streamer; `format.ts` = `parse → emitDocument`). The mechanism is the judgment: a streaming substitution that rewrites the key token when emitting a `@relation` argument key, **vs** a green-tree pre-pass producing a renamed document (`greenToken`/`greenNode` exist; no in-place rewrite helper). Choose the smallest change that keeps `emitDocument` generic and provably trivia-preserving. +- **Completed when:** + - [ ] `pnpm --filter @prisma-next/psl-parser test` green with tests covering: legacy→canonical key rename; a `@relation` line with a trailing comment survives unchanged but for the key; composite bracketed args unchanged; an already-canonical relation is untouched. + - [ ] Idempotence test: `format(format(x)) === format(x)` for a schema with legacy + canonical + commented relations. +- **Halt conditions:** + - The chosen rename mechanism cannot preserve a comment/alignment on the relation line → surface; reconsider mechanism (do not ship a formatter that drops trivia). + +## M3 — `contract infer` AST printer emits canonical + single-dialect gate + +- **Outcome:** PSL generated from an inferred SQL schema emits `from:`/`to:` (never `fields:`/`references:`); a grep gate proves no legacy relation keys survive any toolchain emit (`format` or `contract infer`); `fixtures:check` clean. +- **Builds on:** M1 (the canonical vocabulary) + M2 (format already canonical). +- **Hands to:** the whole-toolchain single-dialect-output guarantee — the slice's spine completed. +- **Focus:** `packages/2-sql/9-family/src/core/psl-contract-infer/sql-schema-ir-to-psl-ast.ts` `buildRelationField` (~L372–410): `namedArg('fields', …)`→`namedArg('from', …)`, `namedArg('references', …)`→`namedArg('to', …)`; keep bracketed/explicit form (no aggressive inference — D3); **leave `namedArg('name', …)` alone** (name retirement is S3). Wire a grep gate asserting emitted/printed PSL carries no `fields:`/`references:` relation keys. +- **Completed when:** + - [ ] `pnpm --filter @prisma-next/sql-family test` (and psl-printer if touched) green; `contract infer` round-trip emits `from:`/`to:`. + - [ ] Grep gate: emitted/printed PSL across the `format` + `infer` test outputs contains no `@relation(...fields:` / `references:` keys. + - [ ] `pnpm fixtures:check` clean (rebuild dist before any integration/e2e check — stale dist masks resolver/printer edits). +- **Halt conditions:** + - A fixture's emitted contract changes shape (not just the PSL spelling) → investigate; the contract is supposed to be unchanged (D1). + +## Hand-off completeness + +M3's hand-off (toolchain emits canonical; legacy never survives a round-trip) + M1's hand-off (legacy ≡ canonical contract) compose to the slice-DoD: backward-compat invariant, single-dialect output, formatter idempotence. No slice-DoD condition is unreachable from the sequence. diff --git a/projects/psl-relation-syntax/slices/01-from-to-fk-foundation/spec.md b/projects/psl-relation-syntax/slices/01-from-to-fk-foundation/spec.md new file mode 100644 index 0000000000..5e837fb49c --- /dev/null +++ b/projects/psl-relation-syntax/slices/01-from-to-fk-foundation/spec.md @@ -0,0 +1,64 @@ +# Slice 1: from/to FK foundation + formatter rewrite + +_Parent project: `projects/psl-relation-syntax/`. Linear: [TML-2940](https://linear.app/prisma-company/issue/TML-2940). Foundation slice — gates S2–S5. Design: `projects/psl-relation-syntax/design-notes.md` decisions **D1**, **D3**._ + +## At a glance + +PSL foreign-key relations gain the canonical directional vocabulary `@relation(from:, to:)`. Legacy `@relation(fields:, references:)` keeps parsing as an **input alias** that lowers to the same contract; the toolchain only ever **emits** the canonical spelling. This slice is FK-only (1:N / N:1) — `through:`/`inverse:`/M:N are later slices. Its headline is the **backward-compat invariant**: legacy and canonical spellings lower to byte-identical contracts. + +```prisma +// these two lower to byte-identical contract.json +user User @relation(fields: [userId], references: [id]) // legacy (input only) +user User @relation(from: userId) // canonical (to: omitted ⇒ target @id) +``` + +## Chosen design + +Settled in design-discussion (D1, D3) — not re-opened here. + +The `@relation` attribute grammar is **generic**: named arguments are scanned by string in the resolver (`getNamedArgument(attribute, 'fields')`). So accepting `from:`/`to:` needs **no parser/grammar change**. Three surfaces: + +1. **Resolver read** — `packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts` (`parseRelation`, ~L192–237). Read `from:`/`to:`; accept legacy `fields:`/`references:` as aliases lowering to the **same** `{ fields, references }` result. Inference: omit `to:` ⇒ the target model's `@id`; a single field is bare (`from: userId`), composites bracketed (`from: [a, b]`), brackets required when composite. (A redundant `Model.` qualifier on `to:` was planned as tolerated, but is **deferred to S5** — the grammar has no member-access value form; see the edge-case table.) Keep the existing "both-or-neither" diagnostic (a `from:` without a `to:` is only an error when the target has no inferable `@id`). +2. **CST format emit** — `packages/1-framework/2-authoring/psl-parser/src/format/`. The formatter is a token-streamer over the red/green CST (`emit.ts`, `emitDocument` → `streamNode` writes each token's text verbatim, normalising only whitespace/indent/alignment). Canonicalisation is therefore a **CST-level key-token rename** (`fields`→`from`, `references`→`to` inside `@relation` argument keys) applied before/within streaming — **keyword token only**; values, brackets, qualifiers, and comments/trivia are untouched (D3). This is the slice's one real implementation judgment (see § Open questions: tree-rewrite vs streaming substitution hook). +3. **AST printer emit** — `packages/1-framework/2-authoring/psl-printer/` (`ast-to-print-document.ts`) and/or the IR→PSL-AST step `packages/2-sql/9-family/src/core/psl-contract-infer/sql-schema-ir-to-psl-ast.ts`, used by `contract infer`. Emit `from:`/`to:` (not `fields:`/`references:`) when rendering a relation from an inferred schema. +4. **Validation** — accept `from:`/`to:` as known relation arguments; legacy keys still accepted on input. + +## Coherence rationale (slice-INVEST · _Small_) + +One coherent outcome — "the `from`/`to` FK vocabulary, end-to-end: read it, format it, print it" — matching the repo's clean **"one new authoring surface end-to-end"** slice pattern. The three surfaces are not three outcomes: they are the read / format / print faces of a single vocabulary, and a reviewer holds them as one idea ("does the toolchain speak `from`/`to` for FKs, and does legacy still mean the same thing?"). The backward-compat equivalence test is the spine that ties them together. *If at plan time the CST normalize pass proves heavier than a single review can hold alongside the rest, split it into its own slice with the resolver-read as its hand-off* — it is the most separable piece. + +## Scope + +**In:** `from:`/`to:` read in the resolver with legacy aliasing + inference (omit-`to:`, bare-vs-bracketed, tolerated `Model.` qualifier); CST `format` keyword-only canonicalisation preserving trivia; AST printer / IR→PSL-AST canonical emit; validation; the backward-compat-equivalence + formatter-idempotence + no-legacy-emitted tests; any fixture wiring needed to prove equivalence. + +**Out:** `through:` / `inverse:` / any M:N (S2–S3); `@relation(name:)` retirement (S3 — it stays emitted-as-today here); aggressive canonicalisation — dropping an inferable `to:`, stripping a redundant `Model.`, normalising brackets (project non-goal, D3); the TS contract builder; migrating a demo to canonical syntax (a later slice / the demo slice). + +## Pre-investigated edge cases + +| Edge case | Disposition | +|---|---| +| Composite FK (`from: [a, b]`, `to: [x, y]`) | brackets required; order-significant; lowers identically to `fields:`/`references:` arrays | +| `to:` omitted | infers the target model's `@id`; if the target has no `@id`, the existing both-or-neither diagnostic still fires | +| Redundant `Model.` qualifier on `to:` (`to: Post.id`) | **DEFERRED to S5 (I12, found in M1).** The PSL expression grammar has no member-access *value* form — `to: Post.id` silently drops `.id` (resolves to `"Post"`). Supporting it needs a psl-parser grammar change, shared with S5 arrow-path's `J.b` member-access. For S1 authors write the bare column (`to: id`); the resolver already strips a qualifier when one appears (forward-correct), and the present boundary is pinned by a regression test. | +| Self-referential FK | `from`/`to` resolve the same as legacy; no special path | +| Optional vs required relation (`User?` / `User`) | orthogonal to the arg vocabulary; unchanged | +| Comment/trivia on a `@relation` line being canonicalised | must survive the key rename (the CST streamer already preserves comments; the rename must not disturb them) | +| Mixed legacy+canonical in one schema | each relation lowers independently; `format` migrates every legacy occurrence | + +## Slice-specific done conditions + +- [ ] **Backward-compat invariant:** a fixture schema authored with legacy `fields:`/`references:` and the same schema authored with canonical `from:`/`to:` lower to **byte-identical** `contract.json` (the test asserts equality, not two separate snapshots). +- [ ] **Single-dialect output:** `prisma-next format` and `contract infer` emit `from:`/`to:` and never `fields:`/`references:` — a grep gate over their output asserts no legacy relation keys remain. +- [ ] **Formatter idempotence:** `format(format(x)) == format(x)` for a schema containing relations (including a legacy one, which becomes canonical on the first pass and stable thereafter). + +_(CI-green, reviewer-accept, and the project-DoD floor cover the rest.)_ + +## Open questions + +1. _CST canonicalisation mechanism: rewrite the green tree (produce a new document with renamed key tokens) vs. a streaming substitution hook in `emitDocument`/`streamNode` keyed on the `@relation` argument-key node._ Working position: prefer the smallest change that keeps `emitDocument` generic — a targeted key-token substitution scoped to relation-attribute argument keys, decided at dispatch against the green-tree mutability constraints. Settle in the slice plan. + +## References + +- Project: `projects/psl-relation-syntax/spec.md`, `design-notes.md` (D1, D3). +- Surfaces: `contract-psl/src/psl-relation-resolution.ts` (`parseRelation`); `psl-parser/src/format/{format,emit}.ts`; `psl-printer/src/ast-to-print-document.ts`; `packages/2-sql/9-family/src/core/psl-contract-infer/sql-schema-ir-to-psl-ast.ts`; CLI `commands/format.ts`. +- Sibling fixture/test patterns: `projects/sql-orm-many-to-many/` (integration-test standard). diff --git a/projects/psl-relation-syntax/spec.md b/projects/psl-relation-syntax/spec.md new file mode 100644 index 0000000000..00ac6de9f6 --- /dev/null +++ b/projects/psl-relation-syntax/spec.md @@ -0,0 +1,90 @@ +# PSL: Directional Relation Syntax + +## Purpose + +Make PSL relations legible and self-checking. Today a relation is declared with Prisma's non-directional `@relation(fields:, references:, name:)` — foreign keys named positionally, disambiguation carried by a free-floating string kept in sync across two fields by convention. This project replaces that with a directional, point-don't-name vocabulary (`from:` / `to:` / `through:` / `inverse:`) so a relation says what it does and disambiguates by referring to the field itself, not a string. + +## At a glance + +Clean break: the directional vocabulary is the only accepted `@relation` syntax. Legacy `fields:`/`references:` and `@relation(name:)` are rejected at parse time with a guiding diagnostic. A reusable downstream codemod is deferred (TML-2957); the repo's own schemas are migrated in-stack. + +```prisma +model Post { + userId Uuid + user User @relation(from: userId) // `to:` omitted ⇒ target @id + tags Tag[] @relation(through: PostTag) // one end declares; inverse inferred +} +model PostTag { + postId Uuid + tagId Uuid + post Post @relation(from: postId) + tag Tag @relation(from: tagId) + @@id([postId, tagId]) +} +``` + +Disambiguation is by pointing, not naming: a 1:N back-relation with multiple candidates uses `inverse: `; an ambiguous M:N (self-relation or multiple between the same models) declares `through: .` on both ends. `@relation(name:)` is rejected at parse time. The full design — decisions D1–D5, principles, rejected alternatives — lives in [`./design-notes.md`](./design-notes.md). + +## Non-goals + +- **The runtime M:N feature itself** — `include` / filter / nested write over junctions already shipped in the sibling project ([SQL ORM: Many-to-Many End to End](https://linear.app/prisma-company/project/sql-orm-many-to-many-end-to-end-c178df40ca3a)). This project changes how relations are *authored*, not how they execute. +- **Non-id, non-null unique junction targets** — sibling slice 7 / TML-2933. +- **Automated downstream migration codemod** — a reusable tool that rewrites legacy `fields:`/`references:`/`name:` schemas to the canonical vocabulary. Deferred as TML-2957; the repo's own schemas are migrated manually in-stack. +- **TS-builder relation-authoring syntax** — `from`/`to`/`through`/`inverse` is a PSL attribute vocabulary; the TS contract builder is touched only where implicit-junction synthesis logic is genuinely shared. + +## Place in the larger world + +- **Sibling — `sql-orm-many-to-many` (runtime M:N).** This is its authoring-surface counterpart. The runtime consumes the **existing** contract `through` / relation shapes; this project changes how those shapes are authored, not the shapes themselves (the contract-shape invariant below makes that precise). +- **Primary surfaces.** `@prisma-next/psl-parser` (generic attribute grammar — accepts the new keywords with no grammar change; member-access value parsing for `through: J.field`); `@prisma-next/sql-contract-psl` `psl-relation-resolution.ts` (lowering); `@prisma-next/psl-printer` (AST printer used by `contract infer`); `@prisma-next/mongo-family` `psl-helpers.ts` (Mongo relation parsing); `@prisma-next/sql-contract-ts` `build-contract.ts` (shared implicit-junction synthesis / parity). +- **ADR.** The directional vocabulary, the retirement of `@relation(name:)`, and implicit-junction synthesis are architecturally durable. An ADR is committed as part of close-out (see Project DoD). + +## Contract-impact + +- **`from`/`to`/`through`/`inverse`: no change to the emitted contract shape.** These lower to the same relation / `through` shapes the sibling already emits. The cross-cutting invariant is that the new authoring syntax produces the same contracts the legacy vocabulary would have — the repo migration proves this byte-identically. +- **Implicit M:N (slice for D5 case 3): additive.** A bare-list M:N with no authored junction synthesises a model-less junction **table** plus its `N:M` + `through` relations into the emitted contract — content the user did not author, expressed through existing contract kinds. The synthesised junction must round-trip `validateContract`; `sql-orm-client` already consumes `through`, so no downstream contract-consumer change is required. + +## Adapter-impact + +- **postgres + sqlite:** the implicit-M:N synthesised junction emits `CREATE TABLE` DDL through the normal migration path, like any authored table. `from`/`to`/`through`/`inverse` are authoring-only and have no adapter impact. +- **mongo:** `from`/`to` FK relations apply to Mongo as well; implicit M:N junction is a SQL-family concept (no junction table in Mongo). + +## Cross-cutting requirements + +- **Contract-shape invariant (D1).** The directional vocabulary lowers to the same relation / `through` shapes the legacy vocabulary would have produced. The repo migration (legacy → `from`/`to`/`through`/`inverse`) is proven byte-identical: `fixtures:check` shows zero contract drift across all migrated schemas. +- **Legacy rejection (D1 clean break).** `@relation(fields:, references:)` and `@relation(name:)` are rejected at parse time with a guiding diagnostic (`PSL_LEGACY_FIELDS_REFERENCES` / `PSL_LEGACY_NAME`) directing authors to the replacement. Both the SQL and Mongo family resolvers enforce this. +- **Single-dialect output.** `contract infer` emits the canonical vocabulary only; a grep gate asserts no legacy keys in printer output. +- **Runtime parity per the integration standard.** Every navigable-relation form (explicit M:N, disambiguated M:N, implicit M:N, arrow-path) carries an emitted fixture exercised end-to-end through the `sql-orm-client` ORM, following the sibling's integration-test standard — whole-row assertions, explicit `select`, ≥1 implicit selection — proving the new authoring drives the already-shipped runtime. +- **Green throughout.** Every merged slice keeps CI green on `main` with `pnpm build`, `pnpm lint:deps`, and `pnpm fixtures:check` clean. + +## Transitional-shape constraints + +- Legacy `@relation(fields:, references:)` and `@relation(name:)` are rejected at parse time; the repo's own schemas are migrated to `from`/`to`/`through`/`inverse` so `fixtures:check` stays clean throughout. +- Demo and example schemas are migrated to the canonical vocabulary within the stack; migration-history snapshots re-emit byte-identically (the migration hash is computed over `migration.json`/`ops.json`, not the `.prisma` source). + +## Project Definition of Done + +_Inherits the team-DoD floor ([`drive/calibration/dod.md`](../../drive/calibration/dod.md) — repo-wide gates, doc/migration, Linear close-out, manual-QA roll-up, ADR audit). Project-specific conditions on top:_ + +- [ ] Every relation form the legacy vocabulary could express is authorable in the directional vocabulary, **plus** the M:N forms legacy PSL could not — explicit junction (`through:`), self / multiple-M:N disambiguation (`through: J.field`), 1:N back-relation disambiguation (`inverse:`), implicit M:N (bare-list synthesis), and arrow-path — each round-tripping `validateContract`. +- [ ] The repo migration from legacy to directional syntax is byte-identical: `fixtures:check` shows zero contract drift across all migrated schemas (SQL and Mongo families). +- [ ] `@relation(fields:, references:)` and `@relation(name:)` are rejected at parse time in both SQL and Mongo family resolvers, with guiding diagnostics. +- [ ] `@relation(name:)` is absent from `contract infer` printer output — grep gate. +- [ ] `contract infer` emits the directional vocabulary only. +- [ ] Each M:N authoring form has runtime-parity coverage through the ORM per the integration standard. +- [ ] At least one demo is authored end-to-end in the canonical vocabulary (or an explicit, recorded rationale for deferring). +- [ ] An ADR for the directional vocabulary + `name:` retirement + implicit-junction synthesis is authored and linked from `docs/architecture docs/adrs/`. + +## Open Questions + +1. _Implicit-junction synthesised table + column naming (the implicit-M:N slice)._ Working position: mirror Prisma's `_AToB` / `A` / `B` convention unless migration/DDL threading argues for our own. +2. _Arrow-path exact grammar and validation (the arrow-path slice)._ Working position: a distinct lowering from implicit M:N (arrow-path keeps an authored junction *model* with scalar columns; implicit authors none); precise tokens settled at slice spec. +3. _End-to-end demo: migrate the PG demo's relations to canonical, or add a fresh example?_ Working position: migrate the PG demo (already PSL-authored) as the end-to-end proof, re-emitting in the same slice. +4. _`to:` accepting a qualified `Model.column` value._ Resolved: the member-access value grammar landed in slice 3, so `to: Post.id` works (`to: Post.id` ≡ `to: id`). Cross-model qualified paths belong to the arrow-path slice. + +## References + +- Linear Project: [PSL: Directional Relation Syntax](https://linear.app/prisma-company/project/psl-directional-relation-syntax-04e6440a8ee4); planning anchor [TML-2939](https://linear.app/prisma-company/issue/TML-2939). +- Design record: [`./design-notes.md`](./design-notes.md) (decisions D1–D5, rejected alternatives, deferred questions). +- Sibling project: `projects/sql-orm-many-to-many/` (`spec.md`, `plan.md`) — runtime M:N; retains slice 7 / TML-2933. +- Straw-man: `wip/mn-psl-changes.diff`. +- ADRs: to be authored at close-out (directional relation vocabulary). diff --git a/projects/supabase-integration/example/prisma/schema.prisma b/projects/supabase-integration/example/prisma/schema.prisma index 0bd8d1302b..9deebc8b87 100644 --- a/projects/supabase-integration/example/prisma/schema.prisma +++ b/projects/supabase-integration/example/prisma/schema.prisma @@ -30,7 +30,7 @@ namespace public { // namespace-qualified model identifier within that contract space. // Cascade across the contract-space boundary is the developer's // explicit opt-in — no diagnostic. - user supabase:auth.User @relation(fields: [userId], references: [id], onDelete: Cascade, map: "profile_userId_fkey") + user supabase:auth.User @relation(from: [userId], to: [id], onDelete: Cascade, map: "profile_userId_fkey") // Inverse of Post.author. No FK lives on this side; cardinality is // captured by the array type. @@ -48,7 +48,7 @@ namespace public { createdAt DateTime @default(now()) // Local within-namespace relation. - author Profile @relation(fields: [authorId], references: [id], onDelete: Cascade, map: "post_authorId_fkey") + author Profile @relation(from: [authorId], to: [id], onDelete: Cascade, map: "post_authorId_fkey") @@map("post") } diff --git a/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.14-to-0.15/instructions.md b/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.14-to-0.15/instructions.md index 3ede7f393f..c55c04b7d8 100644 --- a/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.14-to-0.15/instructions.md +++ b/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.14-to-0.15/instructions.md @@ -72,6 +72,23 @@ changes: - "deriveJsonSchema" - "derivePolymorphicJsonSchema" anyMatch: true + - id: relation-from-to-arguments + summary: | + The PSL `@relation` foreign-key arguments are renamed: `fields:` becomes `from:` + and `references:` becomes `to:`. The legacy spellings are rejected at contract + emission with PSL_LEGACY_FIELDS_REFERENCES, and `contract infer` prints the + canonical `from:`/`to:` keys. Rename the keys in place inside every + `@relation(...)` attribute in extension fixture schemas and example PSL — argument + order and values are unchanged; `@@id(fields:)` and friends are NOT affected. Tests + asserting printed/inferred PSL that contains `@relation(fields: ..., references: + ...)` must expect `@relation(from: ..., to: ...)` instead. A single field may be + written bare (`from: userId`), and `to:` may be omitted when the relation + references the target model's `@id`. + detection: + glob: "**/*.{prisma,ts}" + contains: + - "@relation" + anyMatch: true ---