Skip to content

Commit ecf09c8

Browse files
committed
fix(delegate): self relation support
fixes #1764
1 parent be28f2e commit ecf09c8

File tree

2 files changed

+190
-53
lines changed

2 files changed

+190
-53
lines changed

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

Lines changed: 93 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import {
2-
AbstractDeclaration,
32
AttributeArg,
43
BooleanLiteral,
54
ConfigArrayExpr,
@@ -295,17 +294,17 @@ export class PrismaSchemaGenerator {
295294
decl.comments.forEach((c) => model.addComment(c));
296295
this.getCustomAttributesAsComments(decl).forEach((c) => model.addComment(c));
297296

298-
// generate relation fields on base models linking to concrete models
297+
// physical: generate relation fields on base models linking to concrete models
299298
this.generateDelegateRelationForBase(model, decl);
300299

301-
// generate reverse relation fields on concrete models
300+
// physical: generate reverse relation fields on concrete models
302301
this.generateDelegateRelationForConcrete(model, decl);
303302

304-
// expand relations on other models that reference delegated models to concrete models
303+
// logical: expand relations on other models that reference delegated models to concrete models
305304
this.expandPolymorphicRelations(model, decl);
306305

307-
// name relations inherited from delegate base models for disambiguation
308-
this.nameRelationsInheritedFromDelegate(model, decl);
306+
// logical: ensure relations inherited from delegate models
307+
this.ensureRelationsInheritedFromDelegate(model, decl);
309308
}
310309

311310
private generateDelegateRelationForBase(model: PrismaDataModel, decl: DataModel) {
@@ -403,7 +402,7 @@ export class PrismaSchemaGenerator {
403402

404403
// find concrete models that inherit from this field's model type
405404
const concreteModels = dataModel.$container.declarations.filter(
406-
(d) => isDataModel(d) && isDescendantOf(d, fieldType)
405+
(d): d is DataModel => isDataModel(d) && isDescendantOf(d, fieldType)
407406
);
408407

409408
concreteModels.forEach((concrete) => {
@@ -418,10 +417,9 @@ export class PrismaSchemaGenerator {
418417
);
419418

420419
const relAttr = getAttribute(field, '@relation');
420+
let relAttrAdded = false;
421421
if (relAttr) {
422-
const fieldsArg = getAttributeArg(relAttr, 'fields');
423-
const nameArg = getAttributeArg(relAttr, 'name') as LiteralExpr;
424-
if (fieldsArg) {
422+
if (getAttributeArg(relAttr, 'fields')) {
425423
// for reach foreign key field pointing to the delegate model, we need to create an aux foreign key
426424
// to point to the concrete model
427425
const relationFieldPairs = getRelationKeyPairs(field);
@@ -450,10 +448,7 @@ export class PrismaSchemaGenerator {
450448

451449
const addedRel = new PrismaFieldAttribute('@relation', [
452450
// use field name as relation name for disambiguation
453-
new PrismaAttributeArg(
454-
undefined,
455-
new AttributeArgValue('String', nameArg?.value || auxRelationField.name)
456-
),
451+
new PrismaAttributeArg(undefined, new AttributeArgValue('String', auxRelationField.name)),
457452
new PrismaAttributeArg('fields', fieldsArg),
458453
new PrismaAttributeArg('references', referencesArg),
459454
]);
@@ -467,12 +462,12 @@ export class PrismaSchemaGenerator {
467462
)
468463
);
469464
}
470-
471465
auxRelationField.attributes.push(addedRel);
472-
} else {
473-
auxRelationField.attributes.push(this.makeFieldAttribute(relAttr as DataModelFieldAttribute));
466+
relAttrAdded = true;
474467
}
475-
} else {
468+
}
469+
470+
if (!relAttrAdded) {
476471
auxRelationField.attributes.push(
477472
new PrismaFieldAttribute('@relation', [
478473
// use field name as relation name for disambiguation
@@ -487,7 +482,7 @@ export class PrismaSchemaGenerator {
487482
private replicateForeignKey(
488483
model: PrismaDataModel,
489484
dataModel: DataModel,
490-
concreteModel: AbstractDeclaration,
485+
concreteModel: DataModel,
491486
origForeignKey: DataModelField
492487
) {
493488
// aux fk name format: delegate_aux_[model]_[fkField]_[concrete]
@@ -499,24 +494,18 @@ export class PrismaSchemaGenerator {
499494
// `@map` attribute should not be inherited
500495
addedFkField.attributes = addedFkField.attributes.filter((attr) => !('name' in attr && attr.name === '@map'));
501496

497+
// `@unique` attribute should be recreated with disambiguated name
498+
addedFkField.attributes = addedFkField.attributes.filter(
499+
(attr) => !('name' in attr && attr.name === '@unique')
500+
);
501+
const uniqueAttr = addedFkField.addAttribute('@unique');
502+
const constraintName = this.truncate(`${concreteModel.name}_${addedFkField.name}_unique`);
503+
uniqueAttr.args.push(new PrismaAttributeArg('map', new AttributeArgValue('String', constraintName)));
504+
502505
// fix its name
503506
const addedFkFieldName = `${dataModel.name}_${origForeignKey.name}_${concreteModel.name}`;
504507
addedFkField.name = this.truncate(`${DELEGATE_AUX_RELATION_PREFIX}_${addedFkFieldName}`);
505508

506-
// we also need to make sure `@unique` constraint's `map` parameter is fixed to avoid conflict
507-
const uniqueAttr = addedFkField.attributes.find(
508-
(attr) => (attr as PrismaFieldAttribute).name === '@unique'
509-
) as PrismaFieldAttribute;
510-
if (uniqueAttr) {
511-
const mapArg = uniqueAttr.args.find((arg) => arg.name === 'map');
512-
const constraintName = this.truncate(`${addedFkField.name}_unique`);
513-
if (mapArg) {
514-
mapArg.value = new AttributeArgValue('String', constraintName);
515-
} else {
516-
uniqueAttr.args.push(new PrismaAttributeArg('map', new AttributeArgValue('String', constraintName)));
517-
}
518-
}
519-
520509
// we also need to go through model-level `@@unique` and replicate those involving fk fields
521510
this.replicateForeignKeyModelLevelUnique(model, dataModel, origForeignKey, addedFkField);
522511

@@ -596,13 +585,11 @@ export class PrismaSchemaGenerator {
596585
return shortName;
597586
}
598587

599-
private nameRelationsInheritedFromDelegate(model: PrismaDataModel, decl: DataModel) {
588+
private ensureRelationsInheritedFromDelegate(model: PrismaDataModel, decl: DataModel) {
600589
if (this.mode !== 'logical') {
601590
return;
602591
}
603592

604-
// the logical schema needs to name relations inherited from delegate base models for disambiguation
605-
606593
decl.fields.forEach((f) => {
607594
if (!isDataModel(f.type.reference?.ref)) {
608595
// only process relation fields
@@ -636,30 +623,68 @@ export class PrismaSchemaGenerator {
636623
if (!oppositeRelationField) {
637624
return;
638625
}
626+
const oppositeRelationAttr = getAttribute(oppositeRelationField, '@relation');
639627

640628
const fieldType = f.type.reference.ref;
641629

642630
// relation name format: delegate_aux_[relationType]_[oppositeRelationField]_[concrete]
643-
const relAttr = getAttribute(f, '@relation');
644-
const name = `${fieldType.name}_${oppositeRelationField.name}_${decl.name}`;
645-
const relName = this.truncate(`${DELEGATE_AUX_RELATION_PREFIX}_${name}`);
646-
647-
if (relAttr) {
648-
const nameArg = getAttributeArg(relAttr, 'name');
649-
if (!nameArg) {
650-
const prismaRelAttr = prismaField.attributes.find(
651-
(attr) => (attr as PrismaFieldAttribute).name === '@relation'
652-
) as PrismaFieldAttribute;
653-
if (prismaRelAttr) {
654-
prismaRelAttr.args.unshift(
655-
new PrismaAttributeArg(undefined, new AttributeArgValue('String', relName))
656-
);
657-
}
658-
}
631+
const relName = this.truncate(
632+
`${DELEGATE_AUX_RELATION_PREFIX}_${fieldType.name}_${oppositeRelationField.name}_${decl.name}`
633+
);
634+
635+
// recreate `@relation` attribute
636+
prismaField.attributes = prismaField.attributes.filter(
637+
(attr) => (attr as PrismaFieldAttribute).name !== '@relation'
638+
);
639+
640+
if (
641+
// array relation doesn't need FK
642+
f.type.array ||
643+
// opposite relation already has FK, we don't need to generate on this side
644+
(oppositeRelationAttr && getAttributeArg(oppositeRelationAttr, 'fields'))
645+
) {
646+
prismaField.attributes.push(
647+
new PrismaFieldAttribute('@relation', [
648+
new PrismaAttributeArg(undefined, new AttributeArgValue('String', relName)),
649+
])
650+
);
659651
} else {
652+
// generate FK field
653+
const oppositeModelIds = getIdFields(oppositeRelationField.$container as DataModel);
654+
const fkFieldNames: string[] = [];
655+
656+
oppositeModelIds.forEach((idField) => {
657+
const fkFieldName = this.truncate(`${DELEGATE_AUX_RELATION_PREFIX}_${f.name}_${idField.name}`);
658+
model.addField(fkFieldName, new ModelFieldType(idField.type.type!, false, f.type.optional), [
659+
// one-to-one relation requires FK field to be unique, we're just including it
660+
// in all cases since it doesn't hurt
661+
new PrismaFieldAttribute('@unique'),
662+
]);
663+
fkFieldNames.push(fkFieldName);
664+
});
665+
660666
prismaField.attributes.push(
661667
new PrismaFieldAttribute('@relation', [
662668
new PrismaAttributeArg(undefined, new AttributeArgValue('String', relName)),
669+
new PrismaAttributeArg(
670+
'fields',
671+
new AttributeArgValue(
672+
'Array',
673+
fkFieldNames.map(
674+
(fk) => new AttributeArgValue('FieldReference', new PrismaFieldReference(fk))
675+
)
676+
)
677+
),
678+
new PrismaAttributeArg(
679+
'references',
680+
new AttributeArgValue(
681+
'Array',
682+
oppositeModelIds.map(
683+
(idField) =>
684+
new AttributeArgValue('FieldReference', new PrismaFieldReference(idField.name))
685+
)
686+
)
687+
),
663688
])
664689
);
665690
}
@@ -690,9 +715,24 @@ export class PrismaSchemaGenerator {
690715

691716
private getOppositeRelationField(oppositeModel: DataModel, relationField: DataModelField) {
692717
const relName = this.getRelationName(relationField);
693-
return oppositeModel.fields.find(
718+
const matches = oppositeModel.fields.filter(
694719
(f) => f.type.reference?.ref === relationField.$container && this.getRelationName(f) === relName
695720
);
721+
722+
if (matches.length === 0) {
723+
return undefined;
724+
} else if (matches.length === 1) {
725+
return matches[0];
726+
} else {
727+
// if there are multiple matches, prefer to use the one with the same field name,
728+
// this can happen with self-relations
729+
const withNameMatch = matches.find((f) => f.name === relationField.name);
730+
if (withNameMatch) {
731+
return withNameMatch;
732+
} else {
733+
return matches[0];
734+
}
735+
}
696736
}
697737

698738
private getRelationName(field: DataModelField) {

tests/integration/tests/enhancements/with-delegate/enhanced-client.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1407,4 +1407,101 @@ describe('Polymorphism Test', () => {
14071407
r = await db.post.findFirst({ include: { comments: true } });
14081408
expect(r).toMatchObject({ ...post, comments: [comment] });
14091409
});
1410+
1411+
it('works with one-to-one self relation', async () => {
1412+
const { enhance } = await loadSchema(
1413+
`
1414+
model User {
1415+
id Int @id @default(autoincrement())
1416+
successorId Int? @unique
1417+
successor User? @relation("BlogOwnerHistory", fields: [successorId], references: [id])
1418+
predecessor User? @relation("BlogOwnerHistory")
1419+
type String
1420+
@@delegate(type)
1421+
}
1422+
1423+
model Person extends User {
1424+
}
1425+
1426+
model Organization extends User {
1427+
}
1428+
`,
1429+
{ enhancements: ['delegate'] }
1430+
);
1431+
1432+
const db = enhance();
1433+
const u1 = await db.person.create({ data: {} });
1434+
const u2 = await db.organization.create({
1435+
data: { predecessor: { connect: { id: u1.id } } },
1436+
include: { predecessor: true },
1437+
});
1438+
expect(u2).toMatchObject({ id: u2.id, predecessor: { id: u1.id } });
1439+
const foundP1 = await db.person.findUnique({ where: { id: u1.id }, include: { successor: true } });
1440+
expect(foundP1).toMatchObject({ id: u1.id, successor: { id: u2.id } });
1441+
});
1442+
1443+
it('works with one-to-many self relation', async () => {
1444+
const { enhance } = await loadSchema(
1445+
`
1446+
model User {
1447+
id Int @id @default(autoincrement())
1448+
name String?
1449+
parentId Int?
1450+
parent User? @relation("ParentChild", fields: [parentId], references: [id])
1451+
children User[] @relation("ParentChild")
1452+
type String
1453+
@@delegate(type)
1454+
}
1455+
1456+
model Person extends User {
1457+
}
1458+
1459+
model Organization extends User {
1460+
}
1461+
`,
1462+
{ enhancements: ['delegate'] }
1463+
);
1464+
1465+
const db = enhance();
1466+
const u1 = await db.person.create({ data: {} });
1467+
const u2 = await db.organization.create({
1468+
data: { parent: { connect: { id: u1.id } } },
1469+
include: { parent: true },
1470+
});
1471+
expect(u2).toMatchObject({ id: u2.id, parent: { id: u1.id } });
1472+
const foundP1 = await db.person.findUnique({ where: { id: u1.id }, include: { children: true } });
1473+
expect(foundP1).toMatchObject({ id: u1.id, children: [{ id: u2.id }] });
1474+
});
1475+
1476+
it('works with many-to-many self relation', async () => {
1477+
const { enhance } = await loadSchema(
1478+
`
1479+
model User {
1480+
id Int @id @default(autoincrement())
1481+
name String?
1482+
followedBy User[] @relation("UserFollows")
1483+
following User[] @relation("UserFollows")
1484+
type String
1485+
@@delegate(type)
1486+
}
1487+
1488+
model Person extends User {
1489+
}
1490+
1491+
model Organization extends User {
1492+
}
1493+
`,
1494+
{ enhancements: ['delegate'] }
1495+
);
1496+
1497+
const db = enhance();
1498+
const u1 = await db.person.create({ data: {} });
1499+
const u2 = await db.organization.create({
1500+
data: { following: { connect: { id: u1.id } } },
1501+
include: { following: true },
1502+
});
1503+
expect(u2).toMatchObject({ id: u2.id, following: [{ id: u1.id }] });
1504+
const foundP1 = await db.person.findUnique({ where: { id: u1.id }, include: { followedBy: true } });
1505+
expect(foundP1).toMatchObject({ id: u1.id, followedBy: [{ id: u2.id }] });
1506+
});
14101507
});

0 commit comments

Comments
 (0)