Morph Relations : deleteOneField (#13349)

This PR adapts teh deleteOneField to make sure morph relations can be
deleted, either
- from a relation type, 
- or from a morph relation type (which is the most obvious case).

This PR covers 
- the deletion of fieldMetadata 
- and the migrationof workspace schemas, by using the already existing
definition of relation migrations

This PR implements a new test suite: "Delete Object metadata with morph
relation" and completes the other test suites for FieldMetadata and
ObjectMetadata creation/deletion.

Last, we added a nitpick from @paul I forgot on a previous PR on
process-nested-realtion-v2

Fixes https://github.com/twentyhq/core-team-issues/issues/1197
This commit is contained in:
Guillim
2025-07-24 14:40:14 +02:00
committed by GitHub
parent 15e13b4267
commit 1ea451c8be
9 changed files with 617 additions and 57 deletions

View File

@ -138,11 +138,12 @@ describe('createOne FieldMetadataService morph relation fields', () => {
expect(createdField.id).toBeDefined();
expect(createdField.name).toBe('owner');
// expect(createdField.relation).toBeUndefined();
// expect(createdField.morphRelations[0].type).toBe(
// contextPayload.relationType,
// );
// expect(createdField.morphRelations[0].targetFieldMetadata.id).toBeDefined();
expect(createdField.morphRelations[0].targetObjectMetadata.id).toBe(
contextPayload.firstTargetObjectMetadataId,
);
expect(createdField.morphRelations[1].targetObjectMetadata.id).toBe(
contextPayload.secondTargetObjectMetadataId,
);
const isManyToOne =
contextPayload.relationType === RelationType.MANY_TO_ONE;
@ -155,8 +156,6 @@ describe('createOne FieldMetadataService morph relation fields', () => {
expect(createdField.settings?.joinColumnName).toBeUndefined();
}
// TODO: check the morphrelation targets are created correctly (wait for Query Morph Relations)
await deleteOneFieldMetadata({
input: { idToDelete: createdField.id },
}).catch();

View File

@ -0,0 +1,236 @@
import { deleteOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/delete-one-field-metadata.util';
import { updateOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata.util';
import { createMorphRelationBetweenObjects } from 'test/integration/metadata/suites/object-metadata/utils/create-morph-relation-between-objects.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { EachTestingContext } from 'twenty-shared/testing';
import { FieldMetadataType } from 'twenty-shared/types';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
describe('deleteOne FieldMetadataService morph relation fields', () => {
let createdObjectMetadataPersonId = '';
let createdObjectMetadataOpportunityId = '';
let createdObjectMetadataCompanyId = '';
beforeEach(async () => {
const {
data: {
createOneObject: { id: objectMetadataPersonId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'personForMorphRelation',
namePlural: 'peopleForMorphRelation',
labelSingular: 'Person For Morph Relation',
labelPlural: 'People For Morph Relation',
icon: 'IconPerson',
},
});
createdObjectMetadataPersonId = objectMetadataPersonId;
const {
data: {
createOneObject: { id: objectMetadataCompanyId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'companyForMorphRelation',
namePlural: 'companiesForMorphRelation',
labelSingular: 'Company For Morph Relation',
labelPlural: 'Companies For Morph Relation',
icon: 'IconCompany',
},
});
createdObjectMetadataCompanyId = objectMetadataCompanyId;
const {
data: {
createOneObject: { id: objectMetadataOpportunityId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'opportunityForMorphRelation',
namePlural: 'opportunitiesForMorphRelation',
labelSingular: 'Opportunity For Morph Relation',
labelPlural: 'Opportunities For Morph Relation',
icon: 'IconOpportunity',
},
});
createdObjectMetadataOpportunityId = objectMetadataOpportunityId;
});
afterEach(async () => {
await deleteOneObjectMetadata({
input: { idToDelete: createdObjectMetadataPersonId },
});
await deleteOneObjectMetadata({
input: { idToDelete: createdObjectMetadataOpportunityId },
});
await deleteOneObjectMetadata({
input: { idToDelete: createdObjectMetadataCompanyId },
});
});
type EachTestingContextArray = EachTestingContext<
(args: {
objectMetadataId: string;
firstTargetObjectMetadataId: string;
secondTargetObjectMetadataId: string;
}) => {
relationType: RelationType;
objectMetadataId: string;
firstTargetObjectMetadataId: string;
secondTargetObjectMetadataId: string;
type: FieldMetadataType;
}
>[];
const eachTestingContextArray: EachTestingContextArray = [
{
title: 'should delete a MORPH_RELATION field type MANY_TO_ONE',
context: ({
objectMetadataId,
firstTargetObjectMetadataId,
secondTargetObjectMetadataId,
}) => ({
relationType: RelationType.MANY_TO_ONE,
objectMetadataId,
firstTargetObjectMetadataId,
secondTargetObjectMetadataId,
type: FieldMetadataType.MORPH_RELATION,
}),
},
{
title: 'should delete a MORPH_RELATION field type ONE_TO_MANY',
context: ({
objectMetadataId,
firstTargetObjectMetadataId,
secondTargetObjectMetadataId,
}) => ({
relationType: RelationType.ONE_TO_MANY,
objectMetadataId,
firstTargetObjectMetadataId,
secondTargetObjectMetadataId,
type: FieldMetadataType.MORPH_RELATION,
}),
},
];
const eachTestingContextForRelationWithMorphRelationTargetArray: EachTestingContextArray =
[
{
title:
'should delete a ONE_TO_MANY RELATION that has a MORPH_RELATION field as target',
context: ({
objectMetadataId,
firstTargetObjectMetadataId,
secondTargetObjectMetadataId,
}) => ({
relationType: RelationType.MANY_TO_ONE,
objectMetadataId,
firstTargetObjectMetadataId,
secondTargetObjectMetadataId,
type: FieldMetadataType.MORPH_RELATION,
}),
},
{
title:
'should delete a MANY_TO_ONE RELATION that has a MORPH_RELATION field as target',
context: ({
objectMetadataId,
firstTargetObjectMetadataId,
secondTargetObjectMetadataId,
}) => ({
relationType: RelationType.ONE_TO_MANY,
objectMetadataId,
firstTargetObjectMetadataId,
secondTargetObjectMetadataId,
type: FieldMetadataType.MORPH_RELATION,
}),
},
];
it.each(eachTestingContextArray)('$title', async ({ context }) => {
const contextPayload = context({
objectMetadataId: createdObjectMetadataOpportunityId,
firstTargetObjectMetadataId: createdObjectMetadataPersonId,
secondTargetObjectMetadataId: createdObjectMetadataCompanyId,
});
const createdField = await createMorphRelationBetweenObjects({
objectMetadataId: contextPayload.objectMetadataId,
firstTargetObjectMetadataId: contextPayload.firstTargetObjectMetadataId,
secondTargetObjectMetadataId: contextPayload.secondTargetObjectMetadataId,
type: contextPayload.type,
relationType: contextPayload.relationType,
});
const deactivatedField = await updateOneFieldMetadata({
input: {
idToUpdate: createdField.id,
updatePayload: { isActive: false },
},
gqlFields: `
id
isActive
`,
});
expect(deactivatedField.data.updateOneField.id).toBe(createdField.id);
const { errors } = await deleteOneFieldMetadata({
input: { idToDelete: createdField.id },
expectToFail: false,
});
expect(errors).toBeUndefined();
});
it.each(eachTestingContextForRelationWithMorphRelationTargetArray)(
'$title',
async ({ context }) => {
const contextPayload = context({
objectMetadataId: createdObjectMetadataOpportunityId,
firstTargetObjectMetadataId: createdObjectMetadataPersonId,
secondTargetObjectMetadataId: createdObjectMetadataCompanyId,
});
const createdField = await createMorphRelationBetweenObjects({
objectMetadataId: contextPayload.objectMetadataId,
firstTargetObjectMetadataId: contextPayload.firstTargetObjectMetadataId,
secondTargetObjectMetadataId:
contextPayload.secondTargetObjectMetadataId,
type: contextPayload.type,
relationType: contextPayload.relationType,
});
const targetRelationField =
createdField.morphRelations[0].targetFieldMetadata;
const deactivatedTargetRelationField = await updateOneFieldMetadata({
input: {
idToUpdate: targetRelationField.id,
updatePayload: { isActive: false },
},
gqlFields: `
id
isActive
`,
});
expect(deactivatedTargetRelationField.data.updateOneField.id).toBe(
targetRelationField.id,
);
const { errors } = await deleteOneFieldMetadata({
input: { idToDelete: targetRelationField.id },
expectToFail: false,
});
expect(errors).toBeUndefined();
},
);
});

View File

@ -1,5 +1,110 @@
import { findManyFieldsMetadataQueryFactory } from 'test/integration/metadata/suites/field-metadata/utils/find-many-fields-metadata-query-factory.util';
import { createMorphRelationBetweenObjects } from 'test/integration/metadata/suites/object-metadata/utils/create-morph-relation-between-objects.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { forceCreateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/force-create-one-object-metadata.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { FieldMetadataType } from 'twenty-shared/types';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
describe('Delete Object metadata with morph relation should succeed', () => {
it('should succeed', () => {
// 'TODO guillim : once delete is implemented'
let opportunityId = '';
let personId = '';
let companyId = '';
let morphRelationField: { id: string };
beforeEach(async () => {
const {
data: {
createOneObject: { id: aId },
},
} = await forceCreateOneObjectMetadata({
input: {
nameSingular: 'opportunityForDelete',
namePlural: 'opportunitiesForDelete',
labelSingular: 'Opportunity For Delete',
labelPlural: 'Opportunities For Delete',
icon: 'IconOpportunity',
},
});
opportunityId = aId;
const {
data: {
createOneObject: { id: bId },
},
} = await forceCreateOneObjectMetadata({
input: {
nameSingular: 'personForDelete',
namePlural: 'peopleForDelete',
labelSingular: 'Person For Delete',
labelPlural: 'People For Delete',
icon: 'IconPerson',
},
});
personId = bId;
const {
data: {
createOneObject: { id: cId },
},
} = await forceCreateOneObjectMetadata({
input: {
nameSingular: 'companyForDelete',
namePlural: 'companiesForDelete',
labelSingular: 'Company For Delete',
labelPlural: 'Companies For Delete',
icon: 'IconCompany',
},
});
companyId = cId;
});
afterEach(async () => {
await deleteOneObjectMetadata({ input: { idToDelete: opportunityId } });
await deleteOneObjectMetadata({ input: { idToDelete: personId } });
await deleteOneObjectMetadata({ input: { idToDelete: companyId } });
});
it('When deleting source object, the relation on the target should be deleted', async () => {
morphRelationField = await createMorphRelationBetweenObjects({
objectMetadataId: opportunityId,
firstTargetObjectMetadataId: personId,
secondTargetObjectMetadataId: companyId,
type: FieldMetadataType.MORPH_RELATION,
relationType: RelationType.MANY_TO_ONE,
});
await deleteOneObjectMetadata({ input: { idToDelete: opportunityId } });
const fieldAfterDeletion = await findFieldMetadata({
fieldMetadataId: morphRelationField.id,
});
expect(fieldAfterDeletion).toBeUndefined();
});
});
const findFieldMetadata = async ({
fieldMetadataId,
}: {
fieldMetadataId: string;
}) => {
const operation = findManyFieldsMetadataQueryFactory({
gqlFields: `
id
name
object { id nameSingular }
relation { type targetFieldMetadata { id } targetObjectMetadata { id } }
settings
`,
input: {
filter: { id: { eq: fieldMetadataId } },
paging: { first: 1 },
},
});
const fields = await makeMetadataAPIRequest(operation);
const field = fields.body.data.fields.edges?.[0]?.node;
return field;
};

View File

@ -4,6 +4,7 @@ import { FieldMetadataType } from 'twenty-shared/types';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { RelationDTO } from 'src/engine/metadata-modules/field-metadata/dtos/relation.dto';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
export const createMorphRelationBetweenObjects = async ({
@ -51,13 +52,6 @@ export const createMorphRelationBetweenObjects = async ({
],
};
// TODO: add morphRelations to the query once available
// morphRelations {
// type
// targetFieldMetadata {
// id
// }
// }
const {
data: { createOneField: createdFieldPerson },
} = await createOneFieldMetadata({
@ -72,9 +66,20 @@ export const createMorphRelationBetweenObjects = async ({
id
nameSingular
}
morphRelations {
type
targetFieldMetadata {
id
}
targetObjectMetadata {
id
}
}
`,
expectToFail: false,
});
return createdFieldPerson as FieldMetadataEntity<FieldMetadataType.MORPH_RELATION>;
return createdFieldPerson as FieldMetadataEntity<FieldMetadataType.MORPH_RELATION> & {
morphRelations: RelationDTO[];
};
};