Skip to content

Commit 042dc49

Browse files
committed
add createOpposite option on @relation
x
1 parent db93764 commit 042dc49

File tree

5 files changed

+286
-5
lines changed

5 files changed

+286
-5
lines changed

packages/cli/test/plugins/prisma-plugin.test.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,143 @@ model User {
7878
runCli('generate', workDir);
7979
expect(fs.existsSync(path.join(workDir, 'relative/schema.prisma'))).toBe(true);
8080
});
81+
82+
it('should auto-generate opposite relation field with createOpposite: true', async () => {
83+
const { workDir } = await createProject(`
84+
plugin prisma {
85+
provider = '@core/prisma'
86+
}
87+
88+
model User {
89+
id String @id @default(cuid())
90+
}
91+
92+
model Post {
93+
id String @id @default(cuid())
94+
userId String
95+
user User @relation(fields: [userId], references: [id], createOpposite: true)
96+
}
97+
`);
98+
runCli('generate', workDir);
99+
const prismaSchema = fs.readFileSync(path.join(workDir, 'zenstack/schema.prisma'), 'utf8');
100+
expect(prismaSchema).toContain('post Post[]');
101+
expect(prismaSchema).not.toContain('createOpposite');
102+
});
103+
104+
it('should distinguish multiple relations to the same model by relation name', async () => {
105+
const { workDir } = await createProject(`
106+
plugin prisma {
107+
provider = '@core/prisma'
108+
}
109+
110+
model User {
111+
id String @id @default(cuid())
112+
}
113+
114+
model Post {
115+
id String @id @default(cuid())
116+
createdById String
117+
createdBy User @relation("CreatedBy", fields: [createdById], references: [id], createOpposite: true)
118+
updatedById String
119+
updatedBy User @relation("UpdatedBy", fields: [updatedById], references: [id], createOpposite: true)
120+
}
121+
`);
122+
runCli('generate', workDir);
123+
const prismaSchema = fs.readFileSync(path.join(workDir, 'zenstack/schema.prisma'), 'utf8');
124+
// Both opposite relations should be generated with disambiguated names
125+
expect(prismaSchema).toContain('createdBy Post[]');
126+
expect(prismaSchema).toContain('updatedBy Post[]');
127+
expect(prismaSchema).toContain('"CreatedBy"');
128+
expect(prismaSchema).toContain('"UpdatedBy"');
129+
});
130+
131+
it('should error on field name collision with createOpposite', async () => {
132+
const { workDir } = await createProject(`
133+
plugin prisma {
134+
provider = '@core/prisma'
135+
}
136+
137+
model User {
138+
id String @id @default(cuid())
139+
post String
140+
}
141+
142+
model Post {
143+
id String @id @default(cuid())
144+
userId String
145+
user User @relation(fields: [userId], references: [id], createOpposite: true)
146+
}
147+
`);
148+
// The Prisma plugin catches errors internally, so verify the prisma schema was NOT generated
149+
runCli('generate', workDir);
150+
expect(fs.existsSync(path.join(workDir, 'zenstack/schema.prisma'))).toBe(false);
151+
});
152+
153+
it('should handle composite key models with createOpposite on scalar side', async () => {
154+
const { workDir } = await createProject(`
155+
plugin prisma {
156+
provider = '@core/prisma'
157+
}
158+
159+
model Tenant {
160+
orgId String
161+
tenantId String
162+
name String
163+
164+
@@id([orgId, tenantId])
165+
}
166+
167+
model Invoice {
168+
id String @id @default(cuid())
169+
tenantOrgId String
170+
tenantTenantId String
171+
tenant Tenant @relation(fields: [tenantOrgId, tenantTenantId], references: [orgId, tenantId], createOpposite: true)
172+
}
173+
`);
174+
runCli('generate', workDir);
175+
const prismaSchema = fs.readFileSync(path.join(workDir, 'zenstack/schema.prisma'), 'utf8');
176+
expect(prismaSchema).toContain('invoice Invoice[]');
177+
});
178+
179+
it('should handle createOpposite on array side using correct model id fields', async () => {
180+
const { workDir } = await createProject(`
181+
plugin prisma {
182+
provider = '@core/prisma'
183+
}
184+
185+
model User {
186+
uuid String @id @default(cuid())
187+
posts Post[] @relation(createOpposite: true)
188+
}
189+
190+
model Post {
191+
id String @id @default(cuid())
192+
}
193+
`);
194+
runCli('generate', workDir);
195+
const prismaSchema = fs.readFileSync(path.join(workDir, 'zenstack/schema.prisma'), 'utf8');
196+
// FK should reference User's uuid field, not Post's id
197+
expect(prismaSchema).toContain('userUuid String');
198+
expect(prismaSchema).toContain('user User');
199+
expect(prismaSchema).toContain('@relation(fields: [userUuid], references: [uuid])');
200+
});
201+
202+
it('should error on missing opposite relation without createOpposite', async () => {
203+
const { workDir } = await createProject(`
204+
plugin prisma {
205+
provider = '@core/prisma'
206+
}
207+
208+
model User {
209+
id String @id @default(cuid())
210+
}
211+
212+
model Post {
213+
id String @id @default(cuid())
214+
userId String
215+
user User @relation(fields: [userId], references: [id])
216+
}
217+
`);
218+
expect(() => runCli('generate', workDir)).toThrow(/missing an opposite relation/);
219+
});
81220
});

packages/language/res/stdlib.zmodel

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,8 +364,9 @@ attribute @@index(_ fields: FieldReference[], name: String?, map: String?, lengt
364364
* @param map: Defines a custom name for the foreign key in the database.
365365
* @param onUpdate: Defines the referential action to perform when a referenced entry in the referenced model is being updated.
366366
* @param onDelete: Defines the referential action to perform when a referenced entry in the referenced model is being deleted.
367+
* @param createOpposite: When true, auto-generates the opposite relation field in the Prisma schema.
367368
*/
368-
attribute @relation(_ name: String?, fields: FieldReference[]?, references: TransitiveFieldReference[]?, onDelete: ReferentialAction?, onUpdate: ReferentialAction?, map: String?) @@@prisma
369+
attribute @relation(_ name: String?, fields: FieldReference[]?, references: TransitiveFieldReference[]?, onDelete: ReferentialAction?, onUpdate: ReferentialAction?, map: String?, createOpposite: Boolean?) @@@prisma
369370

370371
/**
371372
* Maps a field name or enum value from the schema to a column with a different name in the database.

packages/language/src/validators/datamodel-validator.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
DataModel,
88
ReferenceExpr,
99
TypeDef,
10+
isBooleanLiteral,
1011
isDataModel,
1112
isEnum,
1213
isStringLiteral,
@@ -258,6 +259,12 @@ export default class DataModelValidator implements AstValidator<DataModel> {
258259
});
259260

260261
if (oppositeFields.length === 0) {
262+
// skip error if createOpposite: true is set — the opposite will be auto-generated
263+
const createOppositeArg = thisRelation.attr?.args.find((a) => a.name === 'createOpposite');
264+
if (createOppositeArg && isBooleanLiteral(createOppositeArg.value) && createOppositeArg.value.value === true) {
265+
return;
266+
}
267+
261268
const info: DiagnosticInfo<AstNode, string> = {
262269
node: field,
263270
code: IssueCodes.MissingOppositeRelation,

packages/sdk/src/prisma/prisma-builder.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ export class PrismaModel {
4444
return e;
4545
}
4646

47+
findModel(name: string): Model | undefined {
48+
return this.models.find((m) => m.name === name);
49+
}
50+
4751
toString(): string {
4852
return [...this.datasources, ...this.generators, ...this.enums, ...this.models]
4953
.map((d) => d.toString())

packages/sdk/src/prisma/prisma-schema-generator.ts

Lines changed: 134 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,9 @@ export class PrismaSchemaGenerator {
111111
this.generateDefaultGenerator(prisma);
112112
}
113113

114+
// auto-generate missing opposite relation fields so Prisma schema is always valid
115+
this.generateMissingOppositeRelations(prisma);
116+
114117
return this.PRELUDE + prisma.toString();
115118
}
116119

@@ -335,12 +338,15 @@ export class PrismaSchemaGenerator {
335338
return AstUtils.streamAst(expr).some(isAuthInvocation);
336339
}
337340

341+
// Args that are ZenStack-only and should not appear in the generated Prisma schema
342+
private readonly NON_PRISMA_RELATION_ARGS = ['createOpposite'];
343+
338344
private makeFieldAttribute(attr: DataFieldAttribute) {
339345
const attrName = attr.decl.ref!.name;
340-
return new PrismaFieldAttribute(
341-
attrName,
342-
attr.args.map((arg) => this.makeAttributeArg(arg)),
343-
);
346+
const args = attr.args
347+
.filter((arg) => !(attrName === '@relation' && arg.name && this.NON_PRISMA_RELATION_ARGS.includes(arg.name)))
348+
.map((arg) => this.makeAttributeArg(arg));
349+
return new PrismaFieldAttribute(attrName, args);
344350
}
345351

346352
private makeAttributeArg(arg: AttributeArg): PrismaAttributeArg {
@@ -512,6 +518,130 @@ export class PrismaSchemaGenerator {
512518
);
513519
}
514520

521+
private hasCreateOpposite(field: DataField): boolean {
522+
const relAttr = field.attributes.find((attr) => attr.decl.ref?.name === '@relation');
523+
if (!relAttr) return false;
524+
const createOppositeArg = relAttr.args.find((arg) => arg.name === 'createOpposite');
525+
if (!createOppositeArg) return false;
526+
return isLiteralExpr(createOppositeArg.value) && createOppositeArg.value.value === true;
527+
}
528+
529+
private getRelationName(field: DataField): string | undefined {
530+
const relAttr = field.attributes.find((attr) => attr.decl.ref?.name === '@relation');
531+
if (!relAttr) return undefined;
532+
const nameArg = relAttr.args.find((arg) => !arg.name || arg.name === 'name');
533+
if (!nameArg) return undefined;
534+
return isStringLiteral(nameArg.value) ? (nameArg.value.value as string) : undefined;
535+
}
536+
537+
/**
538+
* For relation fields with `createOpposite: true`, auto-generates the opposite relation
539+
* field in the Prisma schema so it stays valid.
540+
*/
541+
private generateMissingOppositeRelations(prisma: PrismaModel) {
542+
for (const decl of this.zmodel.declarations) {
543+
if (!isDataModel(decl)) continue;
544+
545+
const allFields = getAllFields(decl, false);
546+
for (const field of allFields) {
547+
if (!isDataModel(field.type.reference?.ref)) continue;
548+
if (!this.hasCreateOpposite(field)) continue;
549+
550+
const relationName = this.getRelationName(field);
551+
const oppositeModel = field.type.reference!.ref! as DataModel;
552+
553+
// match opposite fields by both target model name and relation name
554+
const oppositeFields = getAllFields(oppositeModel, false).filter((f) => {
555+
if (f === field || f.type.reference?.ref?.name !== decl.name) return false;
556+
return this.getRelationName(f) === relationName;
557+
});
558+
559+
if (oppositeFields.length === 0) {
560+
// missing opposite relation — add it to the Prisma model
561+
const prismaOppositeModel = prisma.findModel(oppositeModel.name);
562+
if (!prismaOppositeModel) continue;
563+
564+
// use relation name to disambiguate when multiple relations target the same model
565+
const fieldName = relationName
566+
? lowerCaseFirst(relationName)
567+
: lowerCaseFirst(decl.name);
568+
if (prismaOppositeModel.fields.some((f) => f.name === fieldName)) {
569+
throw new Error(
570+
`Cannot auto-generate opposite relation field "${fieldName}" on model "${oppositeModel.name}": a field with that name already exists`,
571+
);
572+
}
573+
574+
// build @relation args for the generated field, including relation name if present
575+
const buildRelationAttr = (extraArgs: PrismaAttributeArg[]) => {
576+
const args: PrismaAttributeArg[] = [];
577+
if (relationName) {
578+
args.push(
579+
new PrismaAttributeArg(
580+
undefined,
581+
new PrismaAttributeArgValue('String', relationName),
582+
),
583+
);
584+
}
585+
args.push(...extraArgs);
586+
return new PrismaFieldAttribute('@relation', args);
587+
};
588+
589+
if (field.type.array) {
590+
// the field is an array (e.g., posts Post[]), so the opposite should be a scalar relation
591+
const idFields = getIdFields(decl);
592+
if (idFields.length === 0) continue;
593+
594+
// create FK fields for all id fields (supports composite keys)
595+
idFields.forEach((idFieldName) => {
596+
const refIdFieldName = fieldName + idFieldName.charAt(0).toUpperCase() + idFieldName.slice(1);
597+
if (!prismaOppositeModel.fields.some((f) => f.name === refIdFieldName)) {
598+
const idField = allFields.find((f) => f.name === idFieldName);
599+
if (idField?.type.type) {
600+
prismaOppositeModel.addField(
601+
refIdFieldName,
602+
new ModelFieldType(idField.type.type, false, false),
603+
);
604+
}
605+
}
606+
});
607+
608+
prismaOppositeModel.addField(
609+
fieldName,
610+
new ModelFieldType(decl.name, false, false),
611+
[
612+
buildRelationAttr([
613+
new PrismaAttributeArg(
614+
'fields',
615+
new PrismaAttributeArgValue('Array', idFields.map(
616+
(idFieldName) => new PrismaAttributeArgValue('FieldReference',
617+
new PrismaFieldReference(fieldName + idFieldName.charAt(0).toUpperCase() + idFieldName.slice(1))),
618+
)),
619+
),
620+
new PrismaAttributeArg(
621+
'references',
622+
new PrismaAttributeArgValue('Array', idFields.map(
623+
(idFieldName) => new PrismaAttributeArgValue('FieldReference', new PrismaFieldReference(idFieldName)),
624+
)),
625+
),
626+
]),
627+
],
628+
);
629+
} else {
630+
// the field is a scalar relation (e.g., user User), so the opposite should be an array
631+
const attrs = relationName
632+
? [buildRelationAttr([])]
633+
: [];
634+
prismaOppositeModel.addField(
635+
fieldName,
636+
new ModelFieldType(decl.name, true, false),
637+
attrs,
638+
);
639+
}
640+
}
641+
}
642+
}
643+
}
644+
515645
private truncate(name: string) {
516646
if (name.length <= IDENTIFIER_NAME_MAX_LENGTH) {
517647
return name;

0 commit comments

Comments
 (0)