From 1bf40d9dca23d73cb18b0652b28b6acedc91efa8 Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Mon, 27 Oct 2025 16:07:58 +0100 Subject: [PATCH] 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 --- ...relation-properties-to-compare.constant.ts | 1 + ...t-field-metadata-name-availability.util.ts | 18 +- ...-to-flat-field-metadatas-to-delete.util.ts | 6 +- ...relation-creation.integration-spec.ts.snap | 1236 +++++++++-------- ...ield-metadata-relation.integration-spec.ts | 147 +- ...data-relation-creation.integration-spec.ts | 58 +- ...tadata-relation-update.integration-spec.ts | 172 +++ 7 files changed, 1040 insertions(+), 598 deletions(-) diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/constants/flat-field-metadata-relation-properties-to-compare.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/constants/flat-field-metadata-relation-properties-to-compare.constant.ts index 978224ba6da..33cf391778c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/constants/flat-field-metadata-relation-properties-to-compare.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/constants/flat-field-metadata-relation-properties-to-compare.constant.ts @@ -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][]; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-flat-field-metadata-name-availability.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-flat-field-metadata-name-availability.util.ts index 2c6ba146f50..cfe960b5268 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-flat-field-metadata-name-availability.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-flat-field-metadata-name-availability.util.ts @@ -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; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-delete-object-input-to-flat-field-metadatas-to-delete.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-delete-object-input-to-flat-field-metadatas-to-delete.util.ts index 8eebc4eb380..42cf086c247 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-delete-object-input-to-flat-field-metadatas-to-delete.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-delete-object-input-to-flat-field-metadatas-to-delete.util.ts @@ -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, diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/__snapshots__/failing-field-metadata-relation-creation.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/__snapshots__/failing-field-metadata-relation-creation.integration-spec.ts.snap index fddc1989114..6adc7e4fb64 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/__snapshots__/failing-field-metadata-relation-creation.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/__snapshots__/failing-field-metadata-relation-creation.integration-spec.ts.snap @@ -1,636 +1,616 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Field metadata relation creation should fail relation MANY_TO_ONE (relationCreationPayload) when targetFieldLabel conflicts with an existing {name}Id on target object metadata id 1`] = ` -[ - { - "extensions": { - "code": "METADATA_VALIDATION_FAILED", - "errors": { - "cronTrigger": [], - "databaseEventTrigger": [], - "fieldMetadata": [ - { - "errors": [ - { - "code": "NOT_AVAILABLE", - "message": "Name "fieldNameBisId" is not available as it is already used by another field", - "userFriendlyMessage": "Name "fieldNameBisId" is not available as it is already used by another field", - "value": "fieldNameBisId", - }, - ], - "flatEntityMinimalInformation": { - "id": Any, - "name": "fieldNameBisId", - "objectMetadataId": Any, +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "cronTrigger": [], + "databaseEventTrigger": [], + "fieldMetadata": [ + { + "errors": [ + { + "code": "NOT_AVAILABLE", + "message": "Name "fieldNameBisId" is not available as it is already used by another field", + "userFriendlyMessage": "Name "fieldNameBisId" is not available as it is already used by another field", + "value": "fieldNameBisId", }, - "status": "fail", - "type": "create_field", + ], + "flatEntityMinimalInformation": { + "id": Any, + "name": "fieldNameBisId", + "objectMetadataId": Any, }, - ], - "index": [], - "objectMetadata": [], - "routeTrigger": [], - "serverlessFunction": [], - "view": [], - "viewField": [], - "viewFilter": [], - "viewGroup": [], - }, - "message": "Validation failed for 0 object(s) and 0 field(s)", - "summary": { - "invalidCronTrigger": 0, - "invalidDatabaseEventTrigger": 0, - "invalidFieldMetadata": 0, - "invalidIndex": 0, - "invalidObjectMetadata": 0, - "invalidRouteTrigger": 0, - "invalidServerlessFunction": 0, - "invalidView": 0, - "invalidViewField": 0, - "invalidViewFilter": 0, - "invalidViewGroup": 0, - "totalErrors": 0, - }, - "userFriendlyMessage": "Validation failed for 0 object(s) and 0 field(s)", + "status": "fail", + "type": "create_field", + }, + ], + "index": [], + "objectMetadata": [], + "routeTrigger": [], + "serverlessFunction": [], + "view": [], + "viewField": [], + "viewFilter": [], + "viewGroup": [], }, - "message": "Multiple validation errors occurred while creating fields", - "name": "GraphQLError", + "message": "Validation failed for 0 object(s) and 0 field(s)", + "summary": { + "invalidCronTrigger": 0, + "invalidDatabaseEventTrigger": 0, + "invalidFieldMetadata": 0, + "invalidIndex": 0, + "invalidObjectMetadata": 0, + "invalidRouteTrigger": 0, + "invalidServerlessFunction": 0, + "invalidView": 0, + "invalidViewField": 0, + "invalidViewFilter": 0, + "invalidViewGroup": 0, + "totalErrors": 0, + }, + "userFriendlyMessage": "Validation failed for 0 object(s) and 0 field(s)", }, -] + "message": "Multiple validation errors occurred while creating fields", + "name": "GraphQLError", +} `; exports[`Field metadata relation creation should fail relation MANY_TO_ONE (relationCreationPayload) when targetFieldLabel conflicts with an existing field on target object metadata id 1`] = ` -[ - { - "extensions": { - "code": "METADATA_VALIDATION_FAILED", - "errors": { - "cronTrigger": [], - "databaseEventTrigger": [], - "fieldMetadata": [ - { - "errors": [ - { - "code": "NOT_AVAILABLE", - "message": "Name "fieldName" is not available as it is already used by another field", - "userFriendlyMessage": "Name "fieldName" is not available as it is already used by another field", - "value": "fieldName", - }, - ], - "flatEntityMinimalInformation": { - "id": Any, - "name": "fieldName", - "objectMetadataId": Any, +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "cronTrigger": [], + "databaseEventTrigger": [], + "fieldMetadata": [ + { + "errors": [ + { + "code": "NOT_AVAILABLE", + "message": "Name "fieldName" is not available as it is already used by another field", + "userFriendlyMessage": "Name "fieldName" is not available as it is already used by another field", + "value": "fieldName", }, - "status": "fail", - "type": "create_field", + ], + "flatEntityMinimalInformation": { + "id": Any, + "name": "fieldName", + "objectMetadataId": Any, }, - ], - "index": [], - "objectMetadata": [], - "routeTrigger": [], - "serverlessFunction": [], - "view": [], - "viewField": [], - "viewFilter": [], - "viewGroup": [], - }, - "message": "Validation failed for 0 object(s) and 0 field(s)", - "summary": { - "invalidCronTrigger": 0, - "invalidDatabaseEventTrigger": 0, - "invalidFieldMetadata": 0, - "invalidIndex": 0, - "invalidObjectMetadata": 0, - "invalidRouteTrigger": 0, - "invalidServerlessFunction": 0, - "invalidView": 0, - "invalidViewField": 0, - "invalidViewFilter": 0, - "invalidViewGroup": 0, - "totalErrors": 0, - }, - "userFriendlyMessage": "Validation failed for 0 object(s) and 0 field(s)", + "status": "fail", + "type": "create_field", + }, + ], + "index": [], + "objectMetadata": [], + "routeTrigger": [], + "serverlessFunction": [], + "view": [], + "viewField": [], + "viewFilter": [], + "viewGroup": [], }, - "message": "Multiple validation errors occurred while creating fields", - "name": "GraphQLError", + "message": "Validation failed for 0 object(s) and 0 field(s)", + "summary": { + "invalidCronTrigger": 0, + "invalidDatabaseEventTrigger": 0, + "invalidFieldMetadata": 0, + "invalidIndex": 0, + "invalidObjectMetadata": 0, + "invalidRouteTrigger": 0, + "invalidServerlessFunction": 0, + "invalidView": 0, + "invalidViewField": 0, + "invalidViewFilter": 0, + "invalidViewGroup": 0, + "totalErrors": 0, + }, + "userFriendlyMessage": "Validation failed for 0 object(s) and 0 field(s)", }, -] + "message": "Multiple validation errors occurred while creating fields", + "name": "GraphQLError", +} `; exports[`Field metadata relation creation should fail relation MANY_TO_ONE (relationCreationPayload) when targetFieldLabel contains only whitespace 1`] = ` -[ - { - "extensions": { - "code": "METADATA_VALIDATION_FAILED", - "errors": { - "cronTrigger": [], - "databaseEventTrigger": [], - "fieldMetadata": [ - { - "errors": [ - { - "code": "INVALID_FIELD_INPUT", - "message": "Name is too short", - "userFriendlyMessage": "Name is too short", - "value": "", - }, - { - "code": "INVALID_FIELD_INPUT", - "message": "Name is not valid: it must start with lowercase letter and contain only alphanumeric letters", - "userFriendlyMessage": "Name is not valid: it must start with lowercase letter and contain only alphanumeric letters", - "value": "", - }, - ], - "flatEntityMinimalInformation": { - "id": Any, - "name": "", - "objectMetadataId": Any, +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "cronTrigger": [], + "databaseEventTrigger": [], + "fieldMetadata": [ + { + "errors": [ + { + "code": "INVALID_FIELD_INPUT", + "message": "Name is too short", + "userFriendlyMessage": "Name is too short", + "value": "", }, - "status": "fail", - "type": "create_field", + { + "code": "INVALID_FIELD_INPUT", + "message": "Name is not valid: it must start with lowercase letter and contain only alphanumeric letters", + "userFriendlyMessage": "Name is not valid: it must start with lowercase letter and contain only alphanumeric letters", + "value": "", + }, + ], + "flatEntityMinimalInformation": { + "id": Any, + "name": "", + "objectMetadataId": Any, }, - ], - "index": [], - "objectMetadata": [], - "routeTrigger": [], - "serverlessFunction": [], - "view": [], - "viewField": [], - "viewFilter": [], - "viewGroup": [], - }, - "message": "Validation failed for 0 object(s) and 0 field(s)", - "summary": { - "invalidCronTrigger": 0, - "invalidDatabaseEventTrigger": 0, - "invalidFieldMetadata": 0, - "invalidIndex": 0, - "invalidObjectMetadata": 0, - "invalidRouteTrigger": 0, - "invalidServerlessFunction": 0, - "invalidView": 0, - "invalidViewField": 0, - "invalidViewFilter": 0, - "invalidViewGroup": 0, - "totalErrors": 0, - }, - "userFriendlyMessage": "Validation failed for 0 object(s) and 0 field(s)", + "status": "fail", + "type": "create_field", + }, + ], + "index": [], + "objectMetadata": [], + "routeTrigger": [], + "serverlessFunction": [], + "view": [], + "viewField": [], + "viewFilter": [], + "viewGroup": [], }, - "message": "Multiple validation errors occurred while creating fields", - "name": "GraphQLError", + "message": "Validation failed for 0 object(s) and 0 field(s)", + "summary": { + "invalidCronTrigger": 0, + "invalidDatabaseEventTrigger": 0, + "invalidFieldMetadata": 0, + "invalidIndex": 0, + "invalidObjectMetadata": 0, + "invalidRouteTrigger": 0, + "invalidServerlessFunction": 0, + "invalidView": 0, + "invalidViewField": 0, + "invalidViewFilter": 0, + "invalidViewGroup": 0, + "totalErrors": 0, + }, + "userFriendlyMessage": "Validation failed for 0 object(s) and 0 field(s)", }, -] + "message": "Multiple validation errors occurred while creating fields", + "name": "GraphQLError", +} `; exports[`Field metadata relation creation should fail relation MANY_TO_ONE (relationCreationPayload) when targetFieldLabel exceeds maximum length 1`] = ` -[ - { - "extensions": { - "code": "METADATA_VALIDATION_FAILED", - "errors": { - "cronTrigger": [], - "databaseEventTrigger": [], - "fieldMetadata": [ - { - "errors": [ - { - "code": "INVALID_FIELD_INPUT", - "message": "Name is too long", - "userFriendlyMessage": "Name is too long", - "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - }, - ], - "flatEntityMinimalInformation": { - "id": Any, - "name": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "objectMetadataId": Any, +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "cronTrigger": [], + "databaseEventTrigger": [], + "fieldMetadata": [ + { + "errors": [ + { + "code": "INVALID_FIELD_INPUT", + "message": "Name is too long", + "userFriendlyMessage": "Name is too long", + "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", }, - "status": "fail", - "type": "create_field", + ], + "flatEntityMinimalInformation": { + "id": Any, + "name": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "objectMetadataId": Any, }, - ], - "index": [], - "objectMetadata": [], - "routeTrigger": [], - "serverlessFunction": [], - "view": [], - "viewField": [], - "viewFilter": [], - "viewGroup": [], - }, - "message": "Validation failed for 0 object(s) and 0 field(s)", - "summary": { - "invalidCronTrigger": 0, - "invalidDatabaseEventTrigger": 0, - "invalidFieldMetadata": 0, - "invalidIndex": 0, - "invalidObjectMetadata": 0, - "invalidRouteTrigger": 0, - "invalidServerlessFunction": 0, - "invalidView": 0, - "invalidViewField": 0, - "invalidViewFilter": 0, - "invalidViewGroup": 0, - "totalErrors": 0, - }, - "userFriendlyMessage": "Validation failed for 0 object(s) and 0 field(s)", + "status": "fail", + "type": "create_field", + }, + ], + "index": [], + "objectMetadata": [], + "routeTrigger": [], + "serverlessFunction": [], + "view": [], + "viewField": [], + "viewFilter": [], + "viewGroup": [], }, - "message": "Multiple validation errors occurred while creating fields", - "name": "GraphQLError", + "message": "Validation failed for 0 object(s) and 0 field(s)", + "summary": { + "invalidCronTrigger": 0, + "invalidDatabaseEventTrigger": 0, + "invalidFieldMetadata": 0, + "invalidIndex": 0, + "invalidObjectMetadata": 0, + "invalidRouteTrigger": 0, + "invalidServerlessFunction": 0, + "invalidView": 0, + "invalidViewField": 0, + "invalidViewFilter": 0, + "invalidViewGroup": 0, + "totalErrors": 0, + }, + "userFriendlyMessage": "Validation failed for 0 object(s) and 0 field(s)", }, -] + "message": "Multiple validation errors occurred while creating fields", + "name": "GraphQLError", +} `; exports[`Field metadata relation creation should fail relation MANY_TO_ONE (relationCreationPayload) when targetFieldLabel is empty 1`] = ` -[ - { - "extensions": { - "code": "METADATA_VALIDATION_FAILED", - "errors": { - "cronTrigger": [], - "databaseEventTrigger": [], - "fieldMetadata": [ - { - "errors": [ - { - "code": "INVALID_FIELD_INPUT", - "message": "Name is too short", - "userFriendlyMessage": "Name is too short", - "value": "", - }, - { - "code": "INVALID_FIELD_INPUT", - "message": "Name is not valid: it must start with lowercase letter and contain only alphanumeric letters", - "userFriendlyMessage": "Name is not valid: it must start with lowercase letter and contain only alphanumeric letters", - "value": "", - }, - ], - "flatEntityMinimalInformation": { - "id": Any, - "name": "", - "objectMetadataId": Any, +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "cronTrigger": [], + "databaseEventTrigger": [], + "fieldMetadata": [ + { + "errors": [ + { + "code": "INVALID_FIELD_INPUT", + "message": "Name is too short", + "userFriendlyMessage": "Name is too short", + "value": "", }, - "status": "fail", - "type": "create_field", + { + "code": "INVALID_FIELD_INPUT", + "message": "Name is not valid: it must start with lowercase letter and contain only alphanumeric letters", + "userFriendlyMessage": "Name is not valid: it must start with lowercase letter and contain only alphanumeric letters", + "value": "", + }, + ], + "flatEntityMinimalInformation": { + "id": Any, + "name": "", + "objectMetadataId": Any, }, - ], - "index": [], - "objectMetadata": [], - "routeTrigger": [], - "serverlessFunction": [], - "view": [], - "viewField": [], - "viewFilter": [], - "viewGroup": [], - }, - "message": "Validation failed for 0 object(s) and 0 field(s)", - "summary": { - "invalidCronTrigger": 0, - "invalidDatabaseEventTrigger": 0, - "invalidFieldMetadata": 0, - "invalidIndex": 0, - "invalidObjectMetadata": 0, - "invalidRouteTrigger": 0, - "invalidServerlessFunction": 0, - "invalidView": 0, - "invalidViewField": 0, - "invalidViewFilter": 0, - "invalidViewGroup": 0, - "totalErrors": 0, - }, - "userFriendlyMessage": "Validation failed for 0 object(s) and 0 field(s)", + "status": "fail", + "type": "create_field", + }, + ], + "index": [], + "objectMetadata": [], + "routeTrigger": [], + "serverlessFunction": [], + "view": [], + "viewField": [], + "viewFilter": [], + "viewGroup": [], }, - "message": "Multiple validation errors occurred while creating fields", - "name": "GraphQLError", + "message": "Validation failed for 0 object(s) and 0 field(s)", + "summary": { + "invalidCronTrigger": 0, + "invalidDatabaseEventTrigger": 0, + "invalidFieldMetadata": 0, + "invalidIndex": 0, + "invalidObjectMetadata": 0, + "invalidRouteTrigger": 0, + "invalidServerlessFunction": 0, + "invalidView": 0, + "invalidViewField": 0, + "invalidViewFilter": 0, + "invalidViewGroup": 0, + "totalErrors": 0, + }, + "userFriendlyMessage": "Validation failed for 0 object(s) and 0 field(s)", }, -] + "message": "Multiple validation errors occurred while creating fields", + "name": "GraphQLError", +} `; exports[`Field metadata relation creation should fail relation MANY_TO_ONE (relationCreationPayload) when targetObjectMetadataId is unknown 1`] = ` -[ - { - "extensions": { - "code": "METADATA_VALIDATION_FAILED", - "errors": { - "cronTrigger": [], - "databaseEventTrigger": [], - "fieldMetadata": [], - "index": [], - "objectMetadata": [ - { - "errors": [ - { - "code": "FIELD_METADATA_RELATION_MALFORMED", - "message": "Object metadata relation target not found for relation creation payload", - "userFriendlyMessage": "Object targeted by field to create not found", - "value": { - "targetFieldIcon": "IconBuildingSkyscraper", - "targetFieldLabel": "defaultTargetFieldLabel", - "targetObjectMetadataId": Any, - "type": "MANY_TO_ONE", - }, +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "cronTrigger": [], + "databaseEventTrigger": [], + "fieldMetadata": [], + "index": [], + "objectMetadata": [ + { + "errors": [ + { + "code": "FIELD_METADATA_RELATION_MALFORMED", + "message": "Object metadata relation target not found for relation creation payload", + "userFriendlyMessage": "Object targeted by field to create not found", + "value": { + "targetFieldIcon": "IconBuildingSkyscraper", + "targetFieldLabel": "defaultTargetFieldLabel", + "targetObjectMetadataId": Any, + "type": "MANY_TO_ONE", }, - ], - "flatEntityMinimalInformation": {}, - "type": "create_field", - }, - ], - "routeTrigger": [], - "serverlessFunction": [], - "view": [], - "viewField": [], - "viewFilter": [], - "viewGroup": [], - }, - "message": "Validation failed for 0 object(s) and 0 field(s)", - "summary": { - "invalidCronTrigger": 0, - "invalidDatabaseEventTrigger": 0, - "invalidFieldMetadata": 0, - "invalidIndex": 0, - "invalidObjectMetadata": 0, - "invalidRouteTrigger": 0, - "invalidServerlessFunction": 0, - "invalidView": 0, - "invalidViewField": 0, - "invalidViewFilter": 0, - "invalidViewGroup": 0, - "totalErrors": 0, - }, - "userFriendlyMessage": "Validation failed for 0 object(s) and 0 field(s)", + }, + ], + "flatEntityMinimalInformation": {}, + "type": "create_field", + }, + ], + "routeTrigger": [], + "serverlessFunction": [], + "view": [], + "viewField": [], + "viewFilter": [], + "viewGroup": [], }, - "message": "Multiple validation errors occurred while creating field", - "name": "GraphQLError", + "message": "Validation failed for 0 object(s) and 0 field(s)", + "summary": { + "invalidCronTrigger": 0, + "invalidDatabaseEventTrigger": 0, + "invalidFieldMetadata": 0, + "invalidIndex": 0, + "invalidObjectMetadata": 0, + "invalidRouteTrigger": 0, + "invalidServerlessFunction": 0, + "invalidView": 0, + "invalidViewField": 0, + "invalidViewFilter": 0, + "invalidViewGroup": 0, + "totalErrors": 0, + }, + "userFriendlyMessage": "Validation failed for 0 object(s) and 0 field(s)", }, -] + "message": "Multiple validation errors occurred while creating field", + "name": "GraphQLError", +} `; exports[`Field metadata relation creation should fail relation MANY_TO_ONE (relationCreationPayload) when type is a wrong value 1`] = ` -[ - { - "extensions": { - "code": "METADATA_VALIDATION_FAILED", - "errors": { - "cronTrigger": [], - "databaseEventTrigger": [], - "fieldMetadata": [], - "index": [], - "objectMetadata": [ - { - "errors": [ - { - "code": "FIELD_METADATA_RELATION_MALFORMED", - "message": "Relation creation payload is invalid", - "userFriendlyMessage": "Invalid relation creation payload", - "value": { - "targetFieldIcon": "IconBuildingSkyscraper", - "targetFieldLabel": "defaultTargetFieldLabel", - "targetObjectMetadataId": Any, - "type": "wrong", - }, +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "cronTrigger": [], + "databaseEventTrigger": [], + "fieldMetadata": [], + "index": [], + "objectMetadata": [ + { + "errors": [ + { + "code": "FIELD_METADATA_RELATION_MALFORMED", + "message": "Relation creation payload is invalid", + "userFriendlyMessage": "Invalid relation creation payload", + "value": { + "targetFieldIcon": "IconBuildingSkyscraper", + "targetFieldLabel": "defaultTargetFieldLabel", + "targetObjectMetadataId": Any, + "type": "wrong", }, - ], - "flatEntityMinimalInformation": {}, - "type": "create_field", - }, - ], - "routeTrigger": [], - "serverlessFunction": [], - "view": [], - "viewField": [], - "viewFilter": [], - "viewGroup": [], - }, - "message": "Validation failed for 0 object(s) and 0 field(s)", - "summary": { - "invalidCronTrigger": 0, - "invalidDatabaseEventTrigger": 0, - "invalidFieldMetadata": 0, - "invalidIndex": 0, - "invalidObjectMetadata": 0, - "invalidRouteTrigger": 0, - "invalidServerlessFunction": 0, - "invalidView": 0, - "invalidViewField": 0, - "invalidViewFilter": 0, - "invalidViewGroup": 0, - "totalErrors": 0, - }, - "userFriendlyMessage": "Validation failed for 0 object(s) and 0 field(s)", + }, + ], + "flatEntityMinimalInformation": {}, + "type": "create_field", + }, + ], + "routeTrigger": [], + "serverlessFunction": [], + "view": [], + "viewField": [], + "viewFilter": [], + "viewGroup": [], }, - "message": "Multiple validation errors occurred while creating field", - "name": "GraphQLError", + "message": "Validation failed for 0 object(s) and 0 field(s)", + "summary": { + "invalidCronTrigger": 0, + "invalidDatabaseEventTrigger": 0, + "invalidFieldMetadata": 0, + "invalidIndex": 0, + "invalidObjectMetadata": 0, + "invalidRouteTrigger": 0, + "invalidServerlessFunction": 0, + "invalidView": 0, + "invalidViewField": 0, + "invalidViewFilter": 0, + "invalidViewGroup": 0, + "totalErrors": 0, + }, + "userFriendlyMessage": "Validation failed for 0 object(s) and 0 field(s)", }, -] + "message": "Multiple validation errors occurred while creating field", + "name": "GraphQLError", +} `; exports[`Field metadata relation creation should fail relation MANY_TO_ONE (relationCreationPayload) when type is not provided 1`] = ` -[ - { - "extensions": { - "code": "METADATA_VALIDATION_FAILED", - "errors": { - "cronTrigger": [], - "databaseEventTrigger": [], - "fieldMetadata": [], - "index": [], - "objectMetadata": [ - { - "errors": [ - { - "code": "FIELD_METADATA_RELATION_MALFORMED", - "message": "Relation creation payload is invalid", - "userFriendlyMessage": "Invalid relation creation payload", - "value": { - "targetFieldIcon": "IconBuildingSkyscraper", - "targetFieldLabel": "defaultTargetFieldLabel", - "targetObjectMetadataId": Any, - }, +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "cronTrigger": [], + "databaseEventTrigger": [], + "fieldMetadata": [], + "index": [], + "objectMetadata": [ + { + "errors": [ + { + "code": "FIELD_METADATA_RELATION_MALFORMED", + "message": "Relation creation payload is invalid", + "userFriendlyMessage": "Invalid relation creation payload", + "value": { + "targetFieldIcon": "IconBuildingSkyscraper", + "targetFieldLabel": "defaultTargetFieldLabel", + "targetObjectMetadataId": Any, }, - ], - "flatEntityMinimalInformation": {}, - "type": "create_field", - }, - ], - "routeTrigger": [], - "serverlessFunction": [], - "view": [], - "viewField": [], - "viewFilter": [], - "viewGroup": [], - }, - "message": "Validation failed for 0 object(s) and 0 field(s)", - "summary": { - "invalidCronTrigger": 0, - "invalidDatabaseEventTrigger": 0, - "invalidFieldMetadata": 0, - "invalidIndex": 0, - "invalidObjectMetadata": 0, - "invalidRouteTrigger": 0, - "invalidServerlessFunction": 0, - "invalidView": 0, - "invalidViewField": 0, - "invalidViewFilter": 0, - "invalidViewGroup": 0, - "totalErrors": 0, - }, - "userFriendlyMessage": "Validation failed for 0 object(s) and 0 field(s)", + }, + ], + "flatEntityMinimalInformation": {}, + "type": "create_field", + }, + ], + "routeTrigger": [], + "serverlessFunction": [], + "view": [], + "viewField": [], + "viewFilter": [], + "viewGroup": [], }, - "message": "Multiple validation errors occurred while creating field", - "name": "GraphQLError", + "message": "Validation failed for 0 object(s) and 0 field(s)", + "summary": { + "invalidCronTrigger": 0, + "invalidDatabaseEventTrigger": 0, + "invalidFieldMetadata": 0, + "invalidIndex": 0, + "invalidObjectMetadata": 0, + "invalidRouteTrigger": 0, + "invalidServerlessFunction": 0, + "invalidView": 0, + "invalidViewField": 0, + "invalidViewFilter": 0, + "invalidViewGroup": 0, + "totalErrors": 0, + }, + "userFriendlyMessage": "Validation failed for 0 object(s) and 0 field(s)", }, -] + "message": "Multiple validation errors occurred while creating field", + "name": "GraphQLError", +} `; exports[`Field metadata relation creation should fail relation MANY_TO_ONE when {name}Id is already used 1`] = ` -[ - { - "extensions": { - "code": "METADATA_VALIDATION_FAILED", - "errors": { - "cronTrigger": [], - "databaseEventTrigger": [], - "fieldMetadata": [ - { - "errors": [ - { - "code": "NOT_AVAILABLE", - "message": "Name "fieldNameBisId" is not available as it is already used by another field", - "userFriendlyMessage": "Name "fieldNameBisId" is not available as it is already used by another field", - "value": "fieldNameBisId", - }, - ], - "flatEntityMinimalInformation": { - "id": Any, - "name": "fieldNameBisId", - "objectMetadataId": Any, +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "cronTrigger": [], + "databaseEventTrigger": [], + "fieldMetadata": [ + { + "errors": [ + { + "code": "NOT_AVAILABLE", + "message": "Name "fieldNameBisId" is not available as it is already used by another field", + "userFriendlyMessage": "Name "fieldNameBisId" is not available as it is already used by another field", + "value": "fieldNameBisId", }, - "status": "fail", - "type": "create_field", + ], + "flatEntityMinimalInformation": { + "id": Any, + "name": "fieldNameBisId", + "objectMetadataId": Any, }, - { - "errors": [ - { - "code": "FIELD_METADATA_NOT_FOUND", - "message": "Relation field target metadata not found in both existing and about to be created field metadatas", - "userFriendlyMessage": "Relation field target metadata not found", - }, - ], - "flatEntityMinimalInformation": { - "id": Any, - "name": "defaulttargetfieldlabel", - "objectMetadataId": Any, + "status": "fail", + "type": "create_field", + }, + { + "errors": [ + { + "code": "FIELD_METADATA_NOT_FOUND", + "message": "Relation field target metadata not found in both existing and about to be created field metadatas", + "userFriendlyMessage": "Relation field target metadata not found", }, - "status": "fail", - "type": "create_field", + ], + "flatEntityMinimalInformation": { + "id": Any, + "name": "defaulttargetfieldlabel", + "objectMetadataId": Any, }, - ], - "index": [ - { - "errors": [ - { - "code": "INDEX_FIELD_NOT_FOUND", - "message": "Could not find index field related field metadata", - "userFriendlyMessage": "Field referenced in index does not exist", - }, - ], - "flatEntityMinimalInformation": { - "id": Any, - "name": "IDX_747db609dd2bb7abe30d5d1926c", + "status": "fail", + "type": "create_field", + }, + ], + "index": [ + { + "errors": [ + { + "code": "INDEX_FIELD_NOT_FOUND", + "message": "Could not find index field related field metadata", + "userFriendlyMessage": "Field referenced in index does not exist", }, - "status": "fail", - "type": "create_index", + ], + "flatEntityMinimalInformation": { + "id": Any, + "name": "IDX_747db609dd2bb7abe30d5d1926c", }, - ], - "objectMetadata": [], - "routeTrigger": [], - "serverlessFunction": [], - "view": [], - "viewField": [], - "viewFilter": [], - "viewGroup": [], - }, - "message": "Validation failed for 0 object(s) and 0 field(s)", - "summary": { - "invalidCronTrigger": 0, - "invalidDatabaseEventTrigger": 0, - "invalidFieldMetadata": 0, - "invalidIndex": 0, - "invalidObjectMetadata": 0, - "invalidRouteTrigger": 0, - "invalidServerlessFunction": 0, - "invalidView": 0, - "invalidViewField": 0, - "invalidViewFilter": 0, - "invalidViewGroup": 0, - "totalErrors": 0, - }, - "userFriendlyMessage": "Validation failed for 0 object(s) and 0 field(s)", + "status": "fail", + "type": "create_index", + }, + ], + "objectMetadata": [], + "routeTrigger": [], + "serverlessFunction": [], + "view": [], + "viewField": [], + "viewFilter": [], + "viewGroup": [], }, - "message": "Multiple validation errors occurred while creating fields", - "name": "GraphQLError", + "message": "Validation failed for 0 object(s) and 0 field(s)", + "summary": { + "invalidCronTrigger": 0, + "invalidDatabaseEventTrigger": 0, + "invalidFieldMetadata": 0, + "invalidIndex": 0, + "invalidObjectMetadata": 0, + "invalidRouteTrigger": 0, + "invalidServerlessFunction": 0, + "invalidView": 0, + "invalidViewField": 0, + "invalidViewFilter": 0, + "invalidViewGroup": 0, + "totalErrors": 0, + }, + "userFriendlyMessage": "Validation failed for 0 object(s) and 0 field(s)", }, -] + "message": "Multiple validation errors occurred while creating fields", + "name": "GraphQLError", +} `; exports[`Field metadata relation creation should fail relation MANY_TO_ONE when target and source are the same object and name are the same 1`] = ` -[ - { - "extensions": { - "code": "METADATA_VALIDATION_FAILED", - "errors": { - "cronTrigger": [], - "databaseEventTrigger": [], - "fieldMetadata": [], - "index": [], - "objectMetadata": [ - { - "errors": [ - { - "code": "FIELD_METADATA_RELATION_MALFORMED", - "message": "Relation creation payload is invalid", - "userFriendlyMessage": "Invalid relation creation payload", - "value": { - "targetFieldIcon": "IconBuildingSkyscraper", - "targetFieldLabel": "Relation Name", - "targetObjectMetadataId": Any, - "type": "MANY_TO_ONE", - }, +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "cronTrigger": [], + "databaseEventTrigger": [], + "fieldMetadata": [], + "index": [], + "objectMetadata": [ + { + "errors": [ + { + "code": "FIELD_METADATA_RELATION_MALFORMED", + "message": "Relation creation payload is invalid", + "userFriendlyMessage": "Invalid relation creation payload", + "value": { + "targetFieldIcon": "IconBuildingSkyscraper", + "targetFieldLabel": "Relation Name", + "targetObjectMetadataId": Any, + "type": "MANY_TO_ONE", }, - ], - "flatEntityMinimalInformation": {}, - "type": "create_field", - }, - ], - "routeTrigger": [], - "serverlessFunction": [], - "view": [], - "viewField": [], - "viewFilter": [], - "viewGroup": [], - }, - "message": "Validation failed for 0 object(s) and 0 field(s)", - "summary": { - "invalidCronTrigger": 0, - "invalidDatabaseEventTrigger": 0, - "invalidFieldMetadata": 0, - "invalidIndex": 0, - "invalidObjectMetadata": 0, - "invalidRouteTrigger": 0, - "invalidServerlessFunction": 0, - "invalidView": 0, - "invalidViewField": 0, - "invalidViewFilter": 0, - "invalidViewGroup": 0, - "totalErrors": 0, - }, - "userFriendlyMessage": "Validation failed for 0 object(s) and 0 field(s)", + }, + ], + "flatEntityMinimalInformation": {}, + "type": "create_field", + }, + ], + "routeTrigger": [], + "serverlessFunction": [], + "view": [], + "viewField": [], + "viewFilter": [], + "viewGroup": [], }, - "message": "Multiple validation errors occurred while creating field", - "name": "GraphQLError", + "message": "Validation failed for 0 object(s) and 0 field(s)", + "summary": { + "invalidCronTrigger": 0, + "invalidDatabaseEventTrigger": 0, + "invalidFieldMetadata": 0, + "invalidIndex": 0, + "invalidObjectMetadata": 0, + "invalidRouteTrigger": 0, + "invalidServerlessFunction": 0, + "invalidView": 0, + "invalidViewField": 0, + "invalidViewFilter": 0, + "invalidViewGroup": 0, + "totalErrors": 0, + }, + "userFriendlyMessage": "Validation failed for 0 object(s) and 0 field(s)", }, -] + "message": "Multiple validation errors occurred while creating field", + "name": "GraphQLError", +} `; exports[`Field metadata relation creation should fail relation ONE_TO_MANY (relationCreationPayload) when targetFieldLabel conflicts with an existing {name}Id on target object metadata id 1`] = ` @@ -1345,3 +1325,133 @@ exports[`Field metadata relation creation should fail relation ONE_TO_MANY when }, ] `; + +exports[`Field metadata relation creation should fail should fail when creating MANY_TO_ONE self-relation with same field name and label 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "cronTrigger": [], + "databaseEventTrigger": [], + "fieldMetadata": [ + { + "errors": [ + { + "code": "NOT_AVAILABLE", + "message": "Name "manager" is not available as it is already used by another field", + "userFriendlyMessage": "Name "manager" is not available as it is already used by another field", + "value": "manager", + }, + ], + "flatEntityMinimalInformation": { + "id": Any, + "name": "manager", + "objectMetadataId": Any, + }, + "status": "fail", + "type": "create_field", + }, + ], + "index": [], + "objectMetadata": [], + "routeTrigger": [], + "serverlessFunction": [], + "view": [], + "viewField": [], + "viewFilter": [], + "viewGroup": [], + }, + "message": "Validation failed for 0 object(s) and 0 field(s)", + "summary": { + "invalidCronTrigger": 0, + "invalidDatabaseEventTrigger": 0, + "invalidFieldMetadata": 0, + "invalidIndex": 0, + "invalidObjectMetadata": 0, + "invalidRouteTrigger": 0, + "invalidServerlessFunction": 0, + "invalidView": 0, + "invalidViewField": 0, + "invalidViewFilter": 0, + "invalidViewGroup": 0, + "totalErrors": 0, + }, + "userFriendlyMessage": "Validation failed for 0 object(s) and 0 field(s)", + }, + "message": "Multiple validation errors occurred while creating fields", + "name": "GraphQLError", +} +`; + +exports[`Field metadata relation creation should fail should fail when creating ONE_TO_MANY self-relation with same field name and label 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "cronTrigger": [], + "databaseEventTrigger": [], + "fieldMetadata": [ + { + "errors": [ + { + "code": "NOT_AVAILABLE", + "message": "Name "manager" is not available as it is already used by another field", + "userFriendlyMessage": "Name "manager" is not available as it is already used by another field", + "value": "manager", + }, + ], + "flatEntityMinimalInformation": { + "id": Any, + "name": "manager", + "objectMetadataId": Any, + }, + "status": "fail", + "type": "create_field", + }, + ], + "index": [ + { + "errors": [ + { + "code": "INDEX_FIELD_NOT_FOUND", + "message": "Could not find index field related field metadata", + "userFriendlyMessage": "Field referenced in index does not exist", + }, + ], + "flatEntityMinimalInformation": { + "id": Any, + "name": "IDX_772faa136925e6c4141fd45ac0e", + }, + "status": "fail", + "type": "create_index", + }, + ], + "objectMetadata": [], + "routeTrigger": [], + "serverlessFunction": [], + "view": [], + "viewField": [], + "viewFilter": [], + "viewGroup": [], + }, + "message": "Validation failed for 0 object(s) and 0 field(s)", + "summary": { + "invalidCronTrigger": 0, + "invalidDatabaseEventTrigger": 0, + "invalidFieldMetadata": 0, + "invalidIndex": 0, + "invalidObjectMetadata": 0, + "invalidRouteTrigger": 0, + "invalidServerlessFunction": 0, + "invalidView": 0, + "invalidViewField": 0, + "invalidViewFilter": 0, + "invalidViewGroup": 0, + "totalErrors": 0, + }, + "userFriendlyMessage": "Validation failed for 0 object(s) and 0 field(s)", + }, + "message": "Multiple validation errors occurred while creating fields", + "name": "GraphQLError", +} +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/create-one-field-metadata-relation.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/create-one-field-metadata-relation.integration-spec.ts index 6e68b8b3a0a..c1581402c48 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/create-one-field-metadata-relation.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/create-one-field-metadata-relation.integration-spec.ts @@ -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(); }); }); diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/failing-field-metadata-relation-creation.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/failing-field-metadata-relation-creation.integration-spec.ts index 8337f283d3e..f51215266cd 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/failing-field-metadata-relation-creation.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/failing-field-metadata-relation-creation.integration-spec.ts @@ -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, + }); + }); }); diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/successful-field-metadata-relation-update.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/successful-field-metadata-relation-update.integration-spec.ts index f30db634483..1a1543a399f 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/successful-field-metadata-relation-update.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/successful-field-metadata-relation-update.integration-spec.ts @@ -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', + }); + }); +});