Fix v2 self relation field creation (#15382)

# Introduction
Fixing self relation field creation in v2
- When computing related flat field to delete on object metadata
deletion that has self relation fields
- On self relation creation validation name availability not searching
for the relation target field of the current object field if it's the
field being validated

## Coverage
- added CUD integration testing on self relation fields

close https://github.com/twentyhq/twenty/issues/15153
This commit is contained in:
Paul Rastoin
2025-10-27 16:07:58 +01:00
committed by GitHub
parent c61da3b14a
commit 1bf40d9dca
7 changed files with 1040 additions and 598 deletions

View File

@@ -5,4 +5,5 @@ export const FLAT_FIELD_METADATA_RELATION_PROPERTIES_TO_COMPARE = [
'description',
'isActive',
'standardOverrides',
'icon',
] as const satisfies (typeof ALL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY.fieldMetadata.propertiesToCompare)[number][];

View File

@@ -79,13 +79,17 @@ export const validateFlatFieldMetadataNameAvailability = ({
}
const targetFlatFieldMetadata =
remainingFlatEntityMapsToValidate?.byId[
existingFlatFieldMetadata.relationTargetFieldMetadataId
] ??
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: existingFlatFieldMetadata.relationTargetFieldMetadataId,
flatEntityMaps: flatFieldMetadataMaps,
});
flatFieldMetadata.id ===
existingFlatFieldMetadata.relationTargetFieldMetadataId
? flatFieldMetadata
: (remainingFlatEntityMapsToValidate?.byId[
existingFlatFieldMetadata.relationTargetFieldMetadataId
] ??
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId:
existingFlatFieldMetadata.relationTargetFieldMetadataId,
flatEntityMaps: flatFieldMetadataMaps,
}));
if (!isMorphOrRelationFlatFieldMetadata(targetFlatFieldMetadata)) {
return false;

View File

@@ -59,7 +59,11 @@ export const fromDeleteObjectInputToFlatFieldMetadatasToDelete = ({
});
const flatFieldMetadatasToDelete = objectFlatFieldMetadatas.flatMap(
(flatFieldMetadata) => {
if (isMorphOrRelationFlatFieldMetadata(flatFieldMetadata)) {
if (
isMorphOrRelationFlatFieldMetadata(flatFieldMetadata) &&
flatFieldMetadata.relationTargetObjectMetadataId !==
objectMetadataToDeleteId
) {
const relationTargetFlatFieldMetadata =
findRelationFlatFieldMetadataTargetFlatFieldMetadataOrThrow({
flatFieldMetadata,

View File

@@ -5,6 +5,7 @@ import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
@@ -86,13 +87,18 @@ describe('createOne FieldMetadataService relation fields', () => {
name: 'person',
});
expect(createdField).toMatchObject({
name: 'person',
relation: {
type: RelationType.MANY_TO_ONE,
},
settings: {
joinColumnName: 'personId',
},
});
expect(createdField.id).toBeDefined();
expect(createdField.name).toBe('person');
expect(createdField.relation?.type).toBe(RelationType.MANY_TO_ONE);
expect(createdField.relation?.targetFieldMetadata.id).toBeDefined();
expect(createdField.settings?.joinColumnName).toBe('personId');
if (!isDefined(createdField.relation?.targetFieldMetadata?.id)) {
throw new Error('targetFieldMetadata.id is not defined');
}
@@ -101,12 +107,14 @@ describe('createOne FieldMetadataService relation fields', () => {
fieldMetadataId: createdField.relation.targetFieldMetadata.id,
});
expect(opportunityFieldOnPerson.object.nameSingular).toBe(
'personForRelation',
);
expect(opportunityFieldOnPerson.relation.type).toBe(
RelationType.ONE_TO_MANY,
);
expect(opportunityFieldOnPerson).toMatchObject({
object: {
nameSingular: 'personForRelation',
},
relation: {
type: RelationType.ONE_TO_MANY,
},
});
expect(
opportunityFieldOnPerson.relation.targetFieldMetadata.id,
).toBeDefined();
@@ -125,11 +133,14 @@ describe('createOne FieldMetadataService relation fields', () => {
name: 'person',
});
expect(createdField).toMatchObject({
name: 'person',
relation: {
type: RelationType.ONE_TO_MANY,
},
});
expect(createdField.id).toBeDefined();
expect(createdField.name).toBe('person');
expect(createdField.relation?.type).toBe(RelationType.ONE_TO_MANY);
expect(createdField.relation?.targetFieldMetadata.id).toBeDefined();
expect(createdField.settings?.joinColumnName).toBeUndefined();
if (!isDefined(createdField.relation?.targetFieldMetadata?.id)) {
@@ -140,22 +151,114 @@ describe('createOne FieldMetadataService relation fields', () => {
fieldMetadataId: createdField.relation.targetFieldMetadata.id,
});
expect(opportunityFieldOnPerson.object.nameSingular).toBe(
'personForRelation',
);
expect(opportunityFieldOnPerson.relation.type).toBe(
RelationType.MANY_TO_ONE,
);
expect(opportunityFieldOnPerson).toMatchObject({
object: {
nameSingular: 'personForRelation',
},
relation: {
type: RelationType.MANY_TO_ONE,
},
settings: {
joinColumnName: 'opportunityId',
},
});
expect(
opportunityFieldOnPerson.relation.targetFieldMetadata.id,
).toBeDefined();
expect(
opportunityFieldOnPerson.relation.targetObjectMetadata.id,
).toBeDefined();
});
expect(opportunityFieldOnPerson.settings?.joinColumnName).toBe(
'opportunityId',
);
it('MANY TO ONE self-relation field creation', async () => {
const createdField = await createRelationBetweenObjects({
objectMetadataId: createdObjectMetadataPersonId,
targetObjectMetadataId: createdObjectMetadataPersonId,
relationType: RelationType.MANY_TO_ONE,
type: FieldMetadataType.RELATION,
name: 'manager',
});
expect(createdField).toMatchObject({
name: 'manager',
relation: {
type: RelationType.MANY_TO_ONE,
targetObjectMetadata: {
id: createdObjectMetadataPersonId,
},
},
settings: {
joinColumnName: 'managerId',
},
});
jestExpectToBeDefined(createdField.relation?.targetFieldMetadata.id);
const reportsFieldOnPerson = await findFieldMetadata({
fieldMetadataId: createdField.relation.targetFieldMetadata.id,
});
expect(reportsFieldOnPerson).toMatchObject({
object: {
nameSingular: 'personForRelation',
id: createdObjectMetadataPersonId,
},
relation: {
type: RelationType.ONE_TO_MANY,
targetObjectMetadata: {
id: createdObjectMetadataPersonId,
},
},
});
expect(reportsFieldOnPerson.relation.targetFieldMetadata.id).toBeDefined();
expect(reportsFieldOnPerson.settings?.joinColumnName).toBeUndefined();
});
it('ONE TO MANY self-relation field creation', async () => {
const createdField = await createRelationBetweenObjects({
objectMetadataId: createdObjectMetadataPersonId,
targetObjectMetadataId: createdObjectMetadataPersonId,
relationType: RelationType.ONE_TO_MANY,
type: FieldMetadataType.RELATION,
name: 'reports',
targetFieldLabel: 'reportTarget',
});
expect(createdField).toMatchObject({
name: 'reports',
relation: {
type: RelationType.ONE_TO_MANY,
targetObjectMetadata: {
id: createdObjectMetadataPersonId,
},
},
});
expect(createdField.relation?.targetFieldMetadata.id).toBeDefined();
expect(createdField.settings?.joinColumnName).toBeUndefined();
if (!isDefined(createdField.relation?.targetFieldMetadata?.id)) {
throw new Error('targetFieldMetadata.id is not defined');
}
const managerFieldOnPerson = await findFieldMetadata({
fieldMetadataId: createdField.relation.targetFieldMetadata.id,
});
expect(managerFieldOnPerson).toMatchObject({
object: {
nameSingular: 'personForRelation',
id: createdObjectMetadataPersonId,
},
relation: {
type: RelationType.MANY_TO_ONE,
targetObjectMetadata: {
id: createdObjectMetadataPersonId,
},
},
settings: {
joinColumnName: 'reporttargetId',
},
});
expect(managerFieldOnPerson.relation.targetFieldMetadata.id).toBeDefined();
});
});

View File

@@ -4,9 +4,10 @@ import { createOneObjectMetadata } from 'test/integration/metadata/suites/object
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { getMockCreateObjectInput } from 'test/integration/metadata/suites/object-metadata/utils/generate-mock-create-object-metadata-input';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { extractRecordIdsAndDatesAsExpectAny } from 'test/utils/extract-record-ids-and-dates-as-expect-any';
import { type EachTestingContext } from 'twenty-shared/testing';
import { FieldMetadataType } from 'twenty-shared/types';
import { extractRecordIdsAndDatesAsExpectAny } from 'test/utils/extract-record-ids-and-dates-as-expect-any';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
@@ -322,10 +323,57 @@ describe('Field metadata relation creation should fail', () => {
},
});
expect(errors).toBeDefined();
expect(errors).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny(errors),
);
expectOneNotInternalServerErrorSnapshot({
errors,
});
},
);
it('should fail when creating MANY_TO_ONE self-relation with same field name and label', async () => {
const { errors } = await createOneFieldMetadata({
expectToFail: true,
input: {
objectMetadataId: globalTestContext.objectMetadataIds.sourceObjectId,
name: 'manager',
label: 'Manager',
isLabelSyncedWithName: false,
type: FieldMetadataType.RELATION,
relationCreationPayload: {
targetFieldLabel: 'Manager',
type: RelationType.MANY_TO_ONE,
targetObjectMetadataId:
globalTestContext.objectMetadataIds.sourceObjectId,
targetFieldIcon: 'IconUser',
},
},
});
expectOneNotInternalServerErrorSnapshot({
errors,
});
});
it('should fail when creating ONE_TO_MANY self-relation with same field name and label', async () => {
const { errors } = await createOneFieldMetadata({
expectToFail: true,
input: {
objectMetadataId: globalTestContext.objectMetadataIds.sourceObjectId,
name: 'manager',
label: 'Manager',
isLabelSyncedWithName: false,
type: FieldMetadataType.RELATION,
relationCreationPayload: {
targetFieldLabel: 'Manager',
type: RelationType.ONE_TO_MANY,
targetObjectMetadataId:
globalTestContext.objectMetadataIds.sourceObjectId,
targetFieldIcon: 'IconUser',
},
},
});
expectOneNotInternalServerErrorSnapshot({
errors,
});
});
});

View File

@@ -1,9 +1,11 @@
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { updateOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { createRelationBetweenObjects } from 'test/integration/metadata/suites/object-metadata/utils/create-relation-between-objects.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { getMockCreateObjectInput } from 'test/integration/metadata/suites/object-metadata/utils/generate-mock-create-object-metadata-input';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
import { FieldMetadataType } from 'twenty-shared/types';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
@@ -103,3 +105,173 @@ describe('Field metadata relation update should succeed', () => {
expect(data.updateOneField.isActive).toBe(false);
});
});
describe('Field metadata self-relation update should succeed', () => {
let personObjectId: string;
beforeEach(async () => {
const {
data: {
createOneObject: { id: objectId },
},
} = await createOneObjectMetadata({
input: getMockCreateObjectInput({
namePlural: 'peopleForUpdate',
nameSingular: 'personForUpdate',
}),
});
personObjectId = objectId;
});
afterEach(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: personObjectId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: {
idToDelete: personObjectId,
},
});
});
it('should successfully update MANY_TO_ONE self-relation field label', async () => {
const createdField = await createRelationBetweenObjects({
objectMetadataId: personObjectId,
targetObjectMetadataId: personObjectId,
relationType: RelationType.MANY_TO_ONE,
type: FieldMetadataType.RELATION,
name: 'supervisor',
});
jestExpectToBeDefined(createdField.id);
const { data } = await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: createdField.id,
updatePayload: {
label: 'Direct Supervisor',
description: 'The direct supervisor of this person',
},
},
gqlFields: `
id
label
description
`,
});
expect(data.updateOneField).toMatchObject({
id: createdField.id,
label: 'Direct Supervisor',
description: 'The direct supervisor of this person',
});
});
it('should successfully update ONE_TO_MANY self-relation field label', async () => {
const createdField = await createRelationBetweenObjects({
objectMetadataId: personObjectId,
targetObjectMetadataId: personObjectId,
relationType: RelationType.ONE_TO_MANY,
type: FieldMetadataType.RELATION,
name: 'directReports',
targetFieldLabel: 'supervisor',
});
jestExpectToBeDefined(createdField.id);
const { data } = await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: createdField.id,
updatePayload: {
label: 'Direct Reports',
description: 'All direct reports under this person',
icon: 'IconUsers',
},
},
gqlFields: `
id
label
description
icon
`,
});
expect(data.updateOneField).toMatchObject({
id: createdField.id,
label: 'Direct Reports',
description: 'All direct reports under this person',
icon: 'IconUsers',
});
});
it('should successfully update both sides of self-relation independently', async () => {
const createdField = await createRelationBetweenObjects({
objectMetadataId: personObjectId,
targetObjectMetadataId: personObjectId,
relationType: RelationType.MANY_TO_ONE,
type: FieldMetadataType.RELATION,
name: 'mentor',
});
jestExpectToBeDefined(createdField.id);
jestExpectToBeDefined(createdField.relation?.targetFieldMetadata.id);
const mentorFieldId = createdField.id;
const menteesFieldId = createdField.relation.targetFieldMetadata.id;
// Update the MANY_TO_ONE side
const { data: mentorUpdateData } = await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: mentorFieldId,
updatePayload: {
label: 'Mentor',
description: 'The mentor of this person',
},
},
gqlFields: `
id
label
description
`,
});
expect(mentorUpdateData.updateOneField).toMatchObject({
id: mentorFieldId,
label: 'Mentor',
description: 'The mentor of this person',
});
// Update the ONE_TO_MANY side
const { data: menteesUpdateData } = await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: menteesFieldId,
updatePayload: {
label: 'Mentees',
description: 'All mentees under this person',
},
},
gqlFields: `
id
label
description
`,
});
expect(menteesUpdateData.updateOneField).toMatchObject({
id: menteesFieldId,
label: 'Mentees',
description: 'All mentees under this person',
});
});
});