Skip to content

Commit 2845e47

Browse files
committed
add createOpposite option on @relation
1 parent db93764 commit 2845e47

File tree

5 files changed

+148
-5
lines changed

5 files changed

+148
-5
lines changed

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,45 @@ 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 error on missing opposite relation without createOpposite', 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+
userId String
117+
user User @relation(fields: [userId], references: [id])
118+
}
119+
`);
120+
expect(() => runCli('generate', workDir)).toThrow(/missing an opposite relation/);
121+
});
81122
});

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: 94 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,90 @@ export class PrismaSchemaGenerator {
512518
);
513519
}
514520

521+
private hasCreateReverse(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+
/**
530+
* For relation fields with `createOpposite: true`, auto-generates the opposite relation
531+
* field in the Prisma schema so it stays valid.
532+
*/
533+
private generateMissingOppositeRelations(prisma: PrismaModel) {
534+
for (const decl of this.zmodel.declarations) {
535+
if (!isDataModel(decl)) continue;
536+
537+
const allFields = getAllFields(decl, false);
538+
for (const field of allFields) {
539+
if (!isDataModel(field.type.reference?.ref)) continue;
540+
if (!this.hasCreateReverse(field)) continue;
541+
542+
const oppositeModel = field.type.reference!.ref! as DataModel;
543+
const oppositeFields = getAllFields(oppositeModel, false).filter(
544+
(f) => f !== field && f.type.reference?.ref?.name === decl.name,
545+
);
546+
547+
if (oppositeFields.length === 0) {
548+
// missing opposite relation — add it to the Prisma model
549+
const prismaOppositeModel = prisma.findModel(oppositeModel.name);
550+
if (!prismaOppositeModel) continue;
551+
552+
const fieldName = lowerCaseFirst(decl.name);
553+
// check if a field with this name already exists in the Prisma model
554+
if (prismaOppositeModel.fields.some((f) => f.name === fieldName)) continue;
555+
556+
if (field.type.array) {
557+
// the field is an array (e.g., posts Post[]), so the opposite should be a scalar relation
558+
const idFields = getIdFields(prismaOppositeModel.name === decl.name ? decl : oppositeModel);
559+
if (idFields.length === 0) continue;
560+
561+
const idFieldName = idFields[0]!;
562+
const refIdFieldName = fieldName + idFieldName.charAt(0).toUpperCase() + idFieldName.slice(1);
563+
// add FK field if not present
564+
if (!prismaOppositeModel.fields.some((f) => f.name === refIdFieldName)) {
565+
const idField = allFields.find((f) => f.name === idFieldName);
566+
if (idField?.type.type) {
567+
prismaOppositeModel.addField(
568+
refIdFieldName,
569+
new ModelFieldType(idField.type.type, false, false),
570+
);
571+
}
572+
}
573+
prismaOppositeModel.addField(
574+
fieldName,
575+
new ModelFieldType(decl.name, false, false),
576+
[
577+
new PrismaFieldAttribute('@relation', [
578+
new PrismaAttributeArg(
579+
'fields',
580+
new PrismaAttributeArgValue('Array', [
581+
new PrismaAttributeArgValue('FieldReference', new PrismaFieldReference(refIdFieldName)),
582+
]),
583+
),
584+
new PrismaAttributeArg(
585+
'references',
586+
new PrismaAttributeArgValue('Array', [
587+
new PrismaAttributeArgValue('FieldReference', new PrismaFieldReference(idFieldName)),
588+
]),
589+
),
590+
]),
591+
],
592+
);
593+
} else {
594+
// the field is a scalar relation (e.g., greeting Greeting), so the opposite should be an array
595+
prismaOppositeModel.addField(
596+
fieldName,
597+
new ModelFieldType(decl.name, true, false),
598+
);
599+
}
600+
}
601+
}
602+
}
603+
}
604+
515605
private truncate(name: string) {
516606
if (name.length <= IDENTIFIER_NAME_MAX_LENGTH) {
517607
return name;

0 commit comments

Comments
 (0)