Prevent field name conflicts (#13280)

Fixes https://github.com/twentyhq/twenty/issues/13184
This commit is contained in:
Charles Bochet
2025-07-18 21:38:36 +02:00
committed by GitHub
parent fdf958bb27
commit 191bbb9e12
10 changed files with 402 additions and 88 deletions

View File

@ -17,6 +17,7 @@ import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { View } from '@/views/types/View'; import { View } from '@/views/types/View';
import { ViewType } from '@/views/types/ViewType'; import { ViewType } from '@/views/types/ViewType';
import { ApolloError } from '@apollo/client';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@ -149,14 +150,8 @@ export const SettingsObjectNewFieldConfigure = () => {
setIsSaving(false); setIsSaving(false);
} catch (error) { } catch (error) {
setIsSaving(false); setIsSaving(false);
const isDuplicateFieldNameInObject = (error as Error).message.includes(
'duplicate key value violates unique constraint "IndexOnNameObjectMetadataIdAndWorkspaceIdUnique"',
);
enqueueErrorSnackBar({ enqueueErrorSnackBar({
message: isDuplicateFieldNameInObject apolloError: error instanceof ApolloError ? error : undefined,
? t`Please use different names for your source and destination fields`
: undefined,
}); });
} }
}; };

View File

@ -77,7 +77,7 @@ export class FieldMetadataMorphRelationService {
} }
const relationFieldMetadataForCreate = const relationFieldMetadataForCreate =
await this.fieldMetadataRelationService.addCustomRelationFieldMetadataForCreation( this.fieldMetadataRelationService.computeCustomRelationFieldMetadataForCreation(
{ {
fieldMetadataInput: fieldMetadataForCreate, fieldMetadataInput: fieldMetadataForCreate,
relationCreationPayload: relation, relationCreationPayload: relation,
@ -94,6 +94,7 @@ export class FieldMetadataMorphRelationService {
fieldMetadataInput: relationFieldMetadataForCreate, fieldMetadataInput: relationFieldMetadataForCreate,
fieldMetadataType: relationFieldMetadataForCreate.type, fieldMetadataType: relationFieldMetadataForCreate.type,
objectMetadataMaps, objectMetadataMaps,
objectMetadata,
}, },
); );
@ -118,7 +119,7 @@ export class FieldMetadataMorphRelationService {
); );
const targetFieldMetadataToCreateWithRelation = const targetFieldMetadataToCreateWithRelation =
await this.fieldMetadataRelationService.addCustomRelationFieldMetadataForCreation( this.fieldMetadataRelationService.computeCustomRelationFieldMetadataForCreation(
{ {
fieldMetadataInput: targetFieldMetadataToCreate, fieldMetadataInput: targetFieldMetadataToCreate,
relationCreationPayload: { relationCreationPayload: {

View File

@ -1,5 +1,6 @@
import { Injectable, ValidationError } from '@nestjs/common'; import { Injectable, ValidationError } from '@nestjs/common';
import { t } from '@lingui/core/macro';
import { plainToInstance } from 'class-transformer'; import { plainToInstance } from 'class-transformer';
import { IsEnum, IsString, IsUUID, validateOrReject } from 'class-validator'; import { IsEnum, IsString, IsUUID, validateOrReject } from 'class-validator';
import { FieldMetadataType } from 'twenty-shared/types'; import { FieldMetadataType } from 'twenty-shared/types';
@ -17,6 +18,7 @@ import {
FieldMetadataException, FieldMetadataException,
FieldMetadataExceptionCode, FieldMetadataExceptionCode,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { computeRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-relation-field-join-column-name.util';
import { prepareCustomFieldMetadataForCreation } from 'src/engine/metadata-modules/field-metadata/utils/prepare-field-metadata-for-creation.util'; import { prepareCustomFieldMetadataForCreation } from 'src/engine/metadata-modules/field-metadata/utils/prepare-field-metadata-for-creation.util';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { RelationOnDeleteAction } from 'src/engine/metadata-modules/relation-metadata/relation-on-delete-action.type'; import { RelationOnDeleteAction } from 'src/engine/metadata-modules/relation-metadata/relation-on-delete-action.type';
@ -28,7 +30,6 @@ import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/v
import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util'; import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util';
import { isFieldMetadataInterfaceOfType } from 'src/engine/utils/is-field-metadata-of-type.util'; import { isFieldMetadataInterfaceOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { computeRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-relation-field-join-column-name.util';
export class RelationCreationPayloadValidation { export class RelationCreationPayloadValidation {
@IsUUID() @IsUUID()
@ -93,7 +94,7 @@ export class FieldMetadataRelationService {
}); });
const targetFieldMetadataToCreateWithRelation = const targetFieldMetadataToCreateWithRelation =
await this.addCustomRelationFieldMetadataForCreation({ this.computeCustomRelationFieldMetadataForCreation({
fieldMetadataInput: targetFieldMetadataToCreate, fieldMetadataInput: targetFieldMetadataToCreate,
relationCreationPayload: { relationCreationPayload: {
targetObjectMetadataId: objectMetadata.id, targetObjectMetadataId: objectMetadata.id,
@ -134,9 +135,13 @@ export class FieldMetadataRelationService {
fieldMetadataInput, fieldMetadataInput,
fieldMetadataType, fieldMetadataType,
objectMetadataMaps, objectMetadataMaps,
objectMetadata,
}: Pick< }: Pick<
ValidateFieldMetadataArgs<T>, ValidateFieldMetadataArgs<T>,
'fieldMetadataInput' | 'fieldMetadataType' | 'objectMetadataMaps' | 'fieldMetadataInput'
| 'fieldMetadataType'
| 'objectMetadataMaps'
| 'objectMetadata'
>): Promise<T> { >): Promise<T> {
// TODO: clean typings, we should try to validate both update and create inputs in the same function // TODO: clean typings, we should try to validate both update and create inputs in the same function
const isRelation = const isRelation =
@ -150,6 +155,11 @@ export class FieldMetadataRelationService {
.relationCreationPayload, .relationCreationPayload,
) )
) { ) {
validateFieldNameAvailabilityOrThrow(
`${fieldMetadataInput.name}Id`,
objectMetadata,
);
const relationCreationPayload = ( const relationCreationPayload = (
fieldMetadataInput as unknown as CreateFieldInput fieldMetadataInput as unknown as CreateFieldInput
).relationCreationPayload; ).relationCreationPayload;
@ -180,6 +190,24 @@ export class FieldMetadataRelationService {
computedMetadataNameFromLabel, computedMetadataNameFromLabel,
objectMetadataTarget, objectMetadataTarget,
); );
validateFieldNameAvailabilityOrThrow(
`${computedMetadataNameFromLabel}Id`,
objectMetadataTarget,
);
if (
computedMetadataNameFromLabel === fieldMetadataInput.name &&
objectMetadata.id === objectMetadataTarget.id
) {
throw new FieldMetadataException(
`Name "${computedMetadataNameFromLabel}" cannot be the same on both side of the relation`,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
{
userFriendlyMessage: t`Name "${computedMetadataNameFromLabel}" cannot be the same on both side of the relation`,
},
);
}
} }
} }
@ -285,7 +313,7 @@ export class FieldMetadataRelationService {
}); });
} }
addCustomRelationFieldMetadataForCreation({ computeCustomRelationFieldMetadataForCreation({
fieldMetadataInput, fieldMetadataInput,
relationCreationPayload, relationCreationPayload,
joinColumnName, joinColumnName,

View File

@ -32,6 +32,7 @@ import {
computeColumnName, computeColumnName,
computeCompositeColumnName, computeCompositeColumnName,
} from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util'; } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { computeRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-relation-field-join-column-name.util';
import { createMigrationActions } from 'src/engine/metadata-modules/field-metadata/utils/create-migration-actions.util'; import { createMigrationActions } from 'src/engine/metadata-modules/field-metadata/utils/create-migration-actions.util';
import { generateRatingOptions } from 'src/engine/metadata-modules/field-metadata/utils/generate-rating-optionts.util'; import { generateRatingOptions } from 'src/engine/metadata-modules/field-metadata/utils/generate-rating-optionts.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
@ -57,7 +58,6 @@ import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util'; import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service'; import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service';
import { ViewService } from 'src/modules/view/services/view.service'; import { ViewService } from 'src/modules/view/services/view.service';
import { computeRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-relation-field-join-column-name.util';
@Injectable() @Injectable()
export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntity> { export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntity> {
@ -671,7 +671,7 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
if (fieldMetadataInput.type === FieldMetadataType.RELATION) { if (fieldMetadataInput.type === FieldMetadataType.RELATION) {
const relationFieldMetadataForCreate = const relationFieldMetadataForCreate =
await this.fieldMetadataRelationService.addCustomRelationFieldMetadataForCreation( this.fieldMetadataRelationService.computeCustomRelationFieldMetadataForCreation(
{ {
fieldMetadataInput: fieldMetadataForCreate, fieldMetadataInput: fieldMetadataForCreate,
relationCreationPayload: fieldMetadataInput.relationCreationPayload, relationCreationPayload: fieldMetadataInput.relationCreationPayload,
@ -686,6 +686,7 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
fieldMetadataInput: relationFieldMetadataForCreate, fieldMetadataInput: relationFieldMetadataForCreate,
fieldMetadataType: fieldMetadataForCreate.type, fieldMetadataType: fieldMetadataForCreate.type,
objectMetadataMaps, objectMetadataMaps,
objectMetadata,
}, },
); );

View File

@ -12,7 +12,7 @@ import { InvalidMetadataException } from 'src/engine/metadata-modules/utils/exce
export const fieldMetadataGraphqlApiExceptionHandler = (error: Error) => { export const fieldMetadataGraphqlApiExceptionHandler = (error: Error) => {
if (error instanceof InvalidMetadataException) { if (error instanceof InvalidMetadataException) {
throw new UserInputError(error.message); throw new UserInputError(error);
} }
if (error instanceof FieldMetadataException) { if (error instanceof FieldMetadataException) {

View File

@ -1,4 +1,5 @@
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { FieldMetadataType } from 'twenty-shared/types';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types'; import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util'; import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
@ -39,14 +40,17 @@ export const validateFieldNameAvailabilityOrThrow = (
if ( if (
Object.values(objectMetadata.fieldsById).some( Object.values(objectMetadata.fieldsById).some(
(field) => field.name === name, (field) =>
field.name === name ||
(field.type === FieldMetadataType.RELATION &&
`${field.name}Id` === name),
) )
) { ) {
throw new InvalidMetadataException( throw new InvalidMetadataException(
`Name "${name}" is not available`, `Name "${name}" is not available as it is already used by another field`,
InvalidMetadataExceptionCode.NOT_AVAILABLE, InvalidMetadataExceptionCode.NOT_AVAILABLE,
{ {
userFriendlyMessage: t`This name is not available.`, userFriendlyMessage: t`This name is not available as it is already used by another field`,
}, },
); );
} }

View File

@ -5,9 +5,10 @@ exports[`Field metadata morph relation creation should fail relation MANY_TO_ONE
{ {
"extensions": { "extensions": {
"code": "BAD_USER_INPUT", "code": "BAD_USER_INPUT",
"userFriendlyMessage": "An error occurred.", "subCode": "Name not available",
"userFriendlyMessage": "This name is not available as it is already used by another field",
}, },
"message": "Name "collisionfieldlabel" is not available", "message": "Name "collisionfieldlabel" is not available as it is already used by another field",
"name": "UserInputError", "name": "UserInputError",
}, },
] ]
@ -18,6 +19,7 @@ exports[`Field metadata morph relation creation should fail relation MANY_TO_ONE
{ {
"extensions": { "extensions": {
"code": "BAD_USER_INPUT", "code": "BAD_USER_INPUT",
"subCode": "Invalid label",
"userFriendlyMessage": "An error occurred.", "userFriendlyMessage": "An error occurred.",
}, },
"message": "Invalid label: " "", "message": "Invalid label: " "",
@ -31,6 +33,7 @@ exports[`Field metadata morph relation creation should fail relation MANY_TO_ONE
{ {
"extensions": { "extensions": {
"code": "BAD_USER_INPUT", "code": "BAD_USER_INPUT",
"subCode": "Exceeds max length",
"userFriendlyMessage": "An error occurred.", "userFriendlyMessage": "An error occurred.",
}, },
"message": "Name is too long: it exceeds the 63 characters limit.", "message": "Name is too long: it exceeds the 63 characters limit.",
@ -44,6 +47,7 @@ exports[`Field metadata morph relation creation should fail relation MANY_TO_ONE
{ {
"extensions": { "extensions": {
"code": "BAD_USER_INPUT", "code": "BAD_USER_INPUT",
"subCode": "Input too short",
"userFriendlyMessage": "An error occurred.", "userFriendlyMessage": "An error occurred.",
}, },
"message": "Input is too short: """, "message": "Input is too short: """,
@ -98,9 +102,10 @@ exports[`Field metadata morph relation creation should fail relation ONE_TO_MANY
{ {
"extensions": { "extensions": {
"code": "BAD_USER_INPUT", "code": "BAD_USER_INPUT",
"userFriendlyMessage": "An error occurred.", "subCode": "Name not available",
"userFriendlyMessage": "This name is not available as it is already used by another field",
}, },
"message": "Name "collisionfieldlabel" is not available", "message": "Name "collisionfieldlabel" is not available as it is already used by another field",
"name": "UserInputError", "name": "UserInputError",
}, },
] ]
@ -111,6 +116,7 @@ exports[`Field metadata morph relation creation should fail relation ONE_TO_MANY
{ {
"extensions": { "extensions": {
"code": "BAD_USER_INPUT", "code": "BAD_USER_INPUT",
"subCode": "Invalid label",
"userFriendlyMessage": "An error occurred.", "userFriendlyMessage": "An error occurred.",
}, },
"message": "Invalid label: " "", "message": "Invalid label: " "",
@ -124,6 +130,7 @@ exports[`Field metadata morph relation creation should fail relation ONE_TO_MANY
{ {
"extensions": { "extensions": {
"code": "BAD_USER_INPUT", "code": "BAD_USER_INPUT",
"subCode": "Exceeds max length",
"userFriendlyMessage": "An error occurred.", "userFriendlyMessage": "An error occurred.",
}, },
"message": "Name is too long: it exceeds the 63 characters limit.", "message": "Name is too long: it exceeds the 63 characters limit.",
@ -137,6 +144,7 @@ exports[`Field metadata morph relation creation should fail relation ONE_TO_MANY
{ {
"extensions": { "extensions": {
"code": "BAD_USER_INPUT", "code": "BAD_USER_INPUT",
"subCode": "Input too short",
"userFriendlyMessage": "An error occurred.", "userFriendlyMessage": "An error occurred.",
}, },
"message": "Input is too short: """, "message": "Input is too short: """,

View File

@ -1,23 +1,39 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Field metadata relation creation should fail relation MANY_TO_ONE when targetFieldLabel conflicts with an existing field on target object metadata id 1`] = ` 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": { "extensions": {
"code": "BAD_USER_INPUT", "code": "BAD_USER_INPUT",
"userFriendlyMessage": "An error occurred.", "subCode": "Name not available",
"userFriendlyMessage": "This name is not available as it is already used by another field",
}, },
"message": "Name "collisionfieldlabel" is not available", "message": "Name "fieldNameBisId" is not available as it is already used by another field",
"name": "UserInputError", "name": "UserInputError",
}, },
] ]
`; `;
exports[`Field metadata relation creation should fail relation MANY_TO_ONE when targetFieldLabel contains only whitespace 1`] = ` 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": { "extensions": {
"code": "BAD_USER_INPUT", "code": "BAD_USER_INPUT",
"subCode": "Name not available",
"userFriendlyMessage": "This name is not available as it is already used by another field",
},
"message": "Name "fieldName" is not available as it is already used by another field",
"name": "UserInputError",
},
]
`;
exports[`Field metadata relation creation should fail relation MANY_TO_ONE (relationCreationPayload) when targetFieldLabel contains only whitespace 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "Invalid label",
"userFriendlyMessage": "An error occurred.", "userFriendlyMessage": "An error occurred.",
}, },
"message": "Invalid label: " "", "message": "Invalid label: " "",
@ -26,11 +42,12 @@ exports[`Field metadata relation creation should fail relation MANY_TO_ONE when
] ]
`; `;
exports[`Field metadata relation creation should fail relation MANY_TO_ONE when targetFieldLabel exceeds maximum length 1`] = ` exports[`Field metadata relation creation should fail relation MANY_TO_ONE (relationCreationPayload) when targetFieldLabel exceeds maximum length 1`] = `
[ [
{ {
"extensions": { "extensions": {
"code": "BAD_USER_INPUT", "code": "BAD_USER_INPUT",
"subCode": "Exceeds max length",
"userFriendlyMessage": "An error occurred.", "userFriendlyMessage": "An error occurred.",
}, },
"message": "Name is too long: it exceeds the 63 characters limit.", "message": "Name is too long: it exceeds the 63 characters limit.",
@ -39,11 +56,12 @@ exports[`Field metadata relation creation should fail relation MANY_TO_ONE when
] ]
`; `;
exports[`Field metadata relation creation should fail relation MANY_TO_ONE when targetFieldLabel is empty 1`] = ` exports[`Field metadata relation creation should fail relation MANY_TO_ONE (relationCreationPayload) when targetFieldLabel is empty 1`] = `
[ [
{ {
"extensions": { "extensions": {
"code": "BAD_USER_INPUT", "code": "BAD_USER_INPUT",
"subCode": "Input too short",
"userFriendlyMessage": "An error occurred.", "userFriendlyMessage": "An error occurred.",
}, },
"message": "Input is too short: """, "message": "Input is too short: """,
@ -52,7 +70,7 @@ exports[`Field metadata relation creation should fail relation MANY_TO_ONE when
] ]
`; `;
exports[`Field metadata relation creation should fail relation MANY_TO_ONE when targetObjectMetadataId is unknown 1`] = ` exports[`Field metadata relation creation should fail relation MANY_TO_ONE (relationCreationPayload) when targetObjectMetadataId is unknown 1`] = `
[ [
{ {
"extensions": { "extensions": {
@ -65,7 +83,7 @@ exports[`Field metadata relation creation should fail relation MANY_TO_ONE when
] ]
`; `;
exports[`Field metadata relation creation should fail relation MANY_TO_ONE when type is a wrong value 1`] = ` exports[`Field metadata relation creation should fail relation MANY_TO_ONE (relationCreationPayload) when type is a wrong value 1`] = `
[ [
{ {
"extensions": { "extensions": {
@ -79,7 +97,7 @@ exports[`Field metadata relation creation should fail relation MANY_TO_ONE when
] ]
`; `;
exports[`Field metadata relation creation should fail relation MANY_TO_ONE when type is not provided 1`] = ` exports[`Field metadata relation creation should fail relation MANY_TO_ONE (relationCreationPayload) when type is not provided 1`] = `
[ [
{ {
"extensions": { "extensions": {
@ -93,24 +111,68 @@ exports[`Field metadata relation creation should fail relation MANY_TO_ONE when
] ]
`; `;
exports[`Field metadata relation creation should fail relation ONE_TO_MANY when targetFieldLabel conflicts with an existing field on target object metadata id 1`] = ` exports[`Field metadata relation creation should fail relation MANY_TO_ONE when {name}Id is already used 1`] = `
[ [
{ {
"extensions": { "extensions": {
"code": "BAD_USER_INPUT", "code": "BAD_USER_INPUT",
"userFriendlyMessage": "An error occurred.", "subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Name is not available, it may be duplicating another field's name.",
}, },
"message": "Name "collisionfieldlabel" is not available", "message": "Name "fieldNameBisId" is not available, check that it is not duplicating another field's name.",
"name": "UserInputError", "name": "UserInputError",
}, },
] ]
`; `;
exports[`Field metadata relation creation should fail relation ONE_TO_MANY when targetFieldLabel contains only whitespace 1`] = ` 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": { "extensions": {
"code": "BAD_USER_INPUT", "code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "An error occurred.",
},
"message": "Relation creation payload is invalid: targetObjectMetadataId must be a UUID",
"name": "UserInputError",
},
]
`;
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`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "Name not available",
"userFriendlyMessage": "This name is not available as it is already used by another field",
},
"message": "Name "fieldNameBisId" is not available as it is already used by another field",
"name": "UserInputError",
},
]
`;
exports[`Field metadata relation creation should fail relation ONE_TO_MANY (relationCreationPayload) when targetFieldLabel conflicts with an existing field on target object metadata id 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "Name not available",
"userFriendlyMessage": "This name is not available as it is already used by another field",
},
"message": "Name "fieldName" is not available as it is already used by another field",
"name": "UserInputError",
},
]
`;
exports[`Field metadata relation creation should fail relation ONE_TO_MANY (relationCreationPayload) when targetFieldLabel contains only whitespace 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "Invalid label",
"userFriendlyMessage": "An error occurred.", "userFriendlyMessage": "An error occurred.",
}, },
"message": "Invalid label: " "", "message": "Invalid label: " "",
@ -119,11 +181,12 @@ exports[`Field metadata relation creation should fail relation ONE_TO_MANY when
] ]
`; `;
exports[`Field metadata relation creation should fail relation ONE_TO_MANY when targetFieldLabel exceeds maximum length 1`] = ` exports[`Field metadata relation creation should fail relation ONE_TO_MANY (relationCreationPayload) when targetFieldLabel exceeds maximum length 1`] = `
[ [
{ {
"extensions": { "extensions": {
"code": "BAD_USER_INPUT", "code": "BAD_USER_INPUT",
"subCode": "Exceeds max length",
"userFriendlyMessage": "An error occurred.", "userFriendlyMessage": "An error occurred.",
}, },
"message": "Name is too long: it exceeds the 63 characters limit.", "message": "Name is too long: it exceeds the 63 characters limit.",
@ -132,11 +195,12 @@ exports[`Field metadata relation creation should fail relation ONE_TO_MANY when
] ]
`; `;
exports[`Field metadata relation creation should fail relation ONE_TO_MANY when targetFieldLabel is empty 1`] = ` exports[`Field metadata relation creation should fail relation ONE_TO_MANY (relationCreationPayload) when targetFieldLabel is empty 1`] = `
[ [
{ {
"extensions": { "extensions": {
"code": "BAD_USER_INPUT", "code": "BAD_USER_INPUT",
"subCode": "Input too short",
"userFriendlyMessage": "An error occurred.", "userFriendlyMessage": "An error occurred.",
}, },
"message": "Input is too short: """, "message": "Input is too short: """,
@ -145,7 +209,7 @@ exports[`Field metadata relation creation should fail relation ONE_TO_MANY when
] ]
`; `;
exports[`Field metadata relation creation should fail relation ONE_TO_MANY when targetObjectMetadataId is unknown 1`] = ` exports[`Field metadata relation creation should fail relation ONE_TO_MANY (relationCreationPayload) when targetObjectMetadataId is unknown 1`] = `
[ [
{ {
"extensions": { "extensions": {
@ -158,7 +222,7 @@ exports[`Field metadata relation creation should fail relation ONE_TO_MANY when
] ]
`; `;
exports[`Field metadata relation creation should fail relation ONE_TO_MANY when type is a wrong value 1`] = ` exports[`Field metadata relation creation should fail relation ONE_TO_MANY (relationCreationPayload) when type is a wrong value 1`] = `
[ [
{ {
"extensions": { "extensions": {
@ -172,7 +236,7 @@ exports[`Field metadata relation creation should fail relation ONE_TO_MANY when
] ]
`; `;
exports[`Field metadata relation creation should fail relation ONE_TO_MANY when type is not provided 1`] = ` exports[`Field metadata relation creation should fail relation ONE_TO_MANY (relationCreationPayload) when type is not provided 1`] = `
[ [
{ {
"extensions": { "extensions": {
@ -185,3 +249,31 @@ exports[`Field metadata relation creation should fail relation ONE_TO_MANY when
}, },
] ]
`; `;
exports[`Field metadata relation creation should fail relation ONE_TO_MANY when {name}Id is already used 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Name is not available, it may be duplicating another field's name.",
},
"message": "Name "fieldNameBisId" is not available, check that it is not duplicating another field's name.",
"name": "UserInputError",
},
]
`;
exports[`Field metadata relation creation should fail relation ONE_TO_MANY when target and source are the same object and name are the same 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "An error occurred.",
},
"message": "Relation creation payload is invalid: targetObjectMetadataId must be a UUID",
"name": "UserInputError",
},
]
`;

View File

@ -15,60 +15,138 @@ type GlobalTestContext = {
targetObjectId: string; targetObjectId: string;
sourceObjectId: string; sourceObjectId: string;
}; };
collisionFieldName: string;
collisionFieldNameWithId: string;
collisionFieldLabel: string; collisionFieldLabel: string;
collisionFieldLabelWithId: string;
}; };
const globalTestContext: GlobalTestContext = { const globalTestContext: GlobalTestContext = {
objectMetadataIds: { objectMetadataIds: {
targetObjectId: '', targetObjectId: '',
sourceObjectId: '', sourceObjectId: '',
}, },
collisionFieldLabel: 'collisionfieldlabel', collisionFieldLabel: 'Field Name',
collisionFieldName: 'fieldName',
collisionFieldNameWithId: 'fieldNameBisId',
collisionFieldLabelWithId: 'Field Name Bis Id',
}; };
type TestedRelationCreationPayload = Partial< type TestedRelationCreationPayload = Partial<
NonNullable<CreateFieldInput['relationCreationPayload']> NonNullable<CreateFieldInput['relationCreationPayload']>
>; >;
type TestedContext = {
input: {
name?: string;
relationCreationPayload?: TestedRelationCreationPayload;
};
};
type CreateOneObjectMetadataItemTestingContext = EachTestingContext< type CreateOneObjectMetadataItemTestingContext = EachTestingContext<
| TestedRelationCreationPayload TestedContext | ((context: GlobalTestContext) => TestedContext)
| ((context: GlobalTestContext) => TestedRelationCreationPayload)
>[]; >[];
describe('Field metadata relation creation should fail', () => { describe('Field metadata relation creation should fail', () => {
const failingLabelsCreationTestsUseCase: CreateOneObjectMetadataItemTestingContext = const failingLabelsCreationTestsUseCase: CreateOneObjectMetadataItemTestingContext =
[ [
// TODO @prastoin add coverage other fields such as the Type, icon etc etc ( using edge cases fuzzing etc ) // TODO @prastoin add coverage other fields such as the Type, icon etc etc ( using edge cases fuzzing etc )
{ {
title: 'when targetFieldLabel is empty', title: '(relationCreationPayload) when targetFieldLabel is empty',
context: { targetFieldLabel: '' }, context: {
}, input: { relationCreationPayload: { targetFieldLabel: '' } },
{ },
title: 'when targetFieldLabel exceeds maximum length',
context: { targetFieldLabel: 'A'.repeat(64) },
},
{
// Not handled gracefully should be refactored
title: 'when targetObjectMetadataId is unknown',
context: { targetObjectMetadataId: faker.string.uuid() },
},
{
title: 'when targetFieldLabel contains only whitespace',
context: { targetFieldLabel: ' ' },
}, },
{ {
title: title:
'when targetFieldLabel conflicts with an existing field on target object metadata id', '(relationCreationPayload) when targetFieldLabel exceeds maximum length',
context: {
input: {
relationCreationPayload: { targetFieldLabel: 'A'.repeat(64) },
},
},
},
{
// Not handled gracefully should be refactored
title:
'(relationCreationPayload) when targetObjectMetadataId is unknown',
context: {
input: {
relationCreationPayload: {
targetObjectMetadataId: faker.string.uuid(),
},
},
},
},
{
title:
'(relationCreationPayload) when targetFieldLabel contains only whitespace',
context: {
input: {
relationCreationPayload: { targetFieldLabel: ' ' },
},
},
},
{
title:
'(relationCreationPayload) when targetFieldLabel conflicts with an existing field on target object metadata id',
context: ({ collisionFieldLabel, objectMetadataIds }) => ({ context: ({ collisionFieldLabel, objectMetadataIds }) => ({
targetObjectMetadataId: objectMetadataIds.targetObjectId, input: {
targetFieldLabel: collisionFieldLabel, relationCreationPayload: {
targetObjectMetadataId: objectMetadataIds.targetObjectId,
targetFieldLabel: collisionFieldLabel,
},
},
}), }),
}, },
{ {
title: 'when type is not provided', title:
context: { type: undefined }, '(relationCreationPayload) when targetFieldLabel conflicts with an existing {name}Id on target object metadata id',
context: ({ collisionFieldLabelWithId, objectMetadataIds }) => ({
input: {
relationCreationPayload: {
targetObjectMetadataId: objectMetadataIds.targetObjectId,
targetFieldLabel: collisionFieldLabelWithId,
},
},
}),
}, },
{ {
title: 'when type is a wrong value', title: '(relationCreationPayload) when type is not provided',
context: { type: 'wrong' as RelationType }, context: {
input: {
relationCreationPayload: { type: undefined },
},
},
},
{
title: '(relationCreationPayload) when type is a wrong value',
context: {
input: {
relationCreationPayload: { type: 'wrong' as RelationType },
},
},
},
{
title: 'when {name}Id is already used',
context: ({ collisionFieldNameWithId }) => ({
input: {
name: collisionFieldNameWithId,
relationCreationPayload: { targetFieldIcon: '' },
},
}),
},
{
title:
'when target and source are the same object and name are the same',
context: {
input: {
name: 'relationName',
relationCreationPayload: {
targetObjectMetadataId:
globalTestContext.objectMetadataIds.sourceObjectId,
targetFieldLabel: 'Relation Name',
},
},
},
}, },
]; ];
@ -79,8 +157,8 @@ describe('Field metadata relation creation should fail', () => {
}, },
} = await createOneObjectMetadata({ } = await createOneObjectMetadata({
input: getMockCreateObjectInput({ input: getMockCreateObjectInput({
namePlural: 'collisionRelations', namePlural: 'sourceObjects',
nameSingular: 'collisionRelation', nameSingular: 'sourceObject',
}), }),
}); });
@ -90,8 +168,8 @@ describe('Field metadata relation creation should fail', () => {
}, },
} = await createOneObjectMetadata({ } = await createOneObjectMetadata({
input: getMockCreateObjectInput({ input: getMockCreateObjectInput({
namePlural: 'collisionRelationTargets', namePlural: 'targetObjects',
nameSingular: 'collisionRelationTarget', nameSingular: 'targetObject',
}), }),
}); });
@ -100,17 +178,54 @@ describe('Field metadata relation creation should fail', () => {
targetObjectId, targetObjectId,
}; };
const { data } = await createOneFieldMetadata({ const { data: collisionFieldWithLabelTargetData } =
input: { await createOneFieldMetadata({
objectMetadataId: targetObjectId, input: {
name: globalTestContext.collisionFieldLabel, objectMetadataId: targetObjectId,
label: 'LabelThatCouldBeAnything', name: globalTestContext.collisionFieldName,
isLabelSyncedWithName: false, label: 'LabelThatCouldBeAnything',
type: FieldMetadataType.TEXT, isLabelSyncedWithName: false,
}, type: FieldMetadataType.TEXT,
}); },
});
expect(data).toBeDefined(); const { data: collisionFieldWithIdTargetData } =
await createOneFieldMetadata({
input: {
objectMetadataId: targetObjectId,
name: globalTestContext.collisionFieldNameWithId,
label: 'LabelThatCouldBeAnything',
isLabelSyncedWithName: false,
type: FieldMetadataType.TEXT,
},
});
const { data: collisionFieldWithLabelSourceData } =
await createOneFieldMetadata({
input: {
objectMetadataId: sourceObjectId,
name: globalTestContext.collisionFieldName,
label: 'LabelThatCouldBeAnything',
isLabelSyncedWithName: false,
type: FieldMetadataType.TEXT,
},
});
const { data: collisionFieldWithIdSourceData } =
await createOneFieldMetadata({
input: {
objectMetadataId: sourceObjectId,
name: globalTestContext.collisionFieldNameWithId,
label: 'LabelThatCouldBeAnything',
isLabelSyncedWithName: false,
type: FieldMetadataType.TEXT,
},
});
expect(collisionFieldWithLabelTargetData).toBeDefined();
expect(collisionFieldWithIdTargetData).toBeDefined();
expect(collisionFieldWithLabelSourceData).toBeDefined();
expect(collisionFieldWithIdSourceData).toBeDefined();
}); });
afterAll(async () => { afterAll(async () => {
@ -128,14 +243,21 @@ describe('Field metadata relation creation should fail', () => {
it.each(failingLabelsCreationTestsUseCase)( it.each(failingLabelsCreationTestsUseCase)(
'relation ONE_TO_MANY $title', 'relation ONE_TO_MANY $title',
async ({ context }) => { async ({ context }) => {
const computedContext = const computedRelationCreationPayload =
typeof context === 'function' ? context(globalTestContext) : context; typeof context === 'function'
? context(globalTestContext).input.relationCreationPayload
: context.input.relationCreationPayload;
const computedName =
typeof context === 'function'
? context(globalTestContext).input.name
: context.input.name;
const { errors } = await createOneFieldMetadata({ const { errors } = await createOneFieldMetadata({
expectToFail: true, expectToFail: true,
input: { input: {
objectMetadataId: globalTestContext.objectMetadataIds.sourceObjectId, objectMetadataId: globalTestContext.objectMetadataIds.sourceObjectId,
name: 'fieldname', name: computedName ?? 'fieldname',
label: 'Relation field', label: 'Relation field',
isLabelSyncedWithName: false, isLabelSyncedWithName: false,
type: FieldMetadataType.RELATION, type: FieldMetadataType.RELATION,
@ -145,7 +267,7 @@ describe('Field metadata relation creation should fail', () => {
targetObjectMetadataId: targetObjectMetadataId:
globalTestContext.objectMetadataIds.targetObjectId, globalTestContext.objectMetadataIds.targetObjectId,
targetFieldIcon: 'IconBuildingSkyscraper', targetFieldIcon: 'IconBuildingSkyscraper',
...computedContext, ...computedRelationCreationPayload,
}, },
}, },
}); });
@ -158,14 +280,21 @@ describe('Field metadata relation creation should fail', () => {
it.each(failingLabelsCreationTestsUseCase)( it.each(failingLabelsCreationTestsUseCase)(
'relation MANY_TO_ONE $title', 'relation MANY_TO_ONE $title',
async ({ context }) => { async ({ context }) => {
const computedContext = const computedRelationCreationPayload =
typeof context === 'function' ? context(globalTestContext) : context; typeof context === 'function'
? context(globalTestContext).input.relationCreationPayload
: context.input.relationCreationPayload;
const computedName =
typeof context === 'function'
? context(globalTestContext).input.name
: context.input.name;
const { errors } = await createOneFieldMetadata({ const { errors } = await createOneFieldMetadata({
expectToFail: true, expectToFail: true,
input: { input: {
objectMetadataId: globalTestContext.objectMetadataIds.sourceObjectId, objectMetadataId: globalTestContext.objectMetadataIds.sourceObjectId,
name: 'fieldname', name: computedName ?? 'fieldname',
label: 'Relation field', label: 'Relation field',
isLabelSyncedWithName: false, isLabelSyncedWithName: false,
type: FieldMetadataType.RELATION, type: FieldMetadataType.RELATION,
@ -175,7 +304,7 @@ describe('Field metadata relation creation should fail', () => {
targetObjectMetadataId: targetObjectMetadataId:
globalTestContext.objectMetadataIds.targetObjectId, globalTestContext.objectMetadataIds.targetObjectId,
targetFieldIcon: 'IconBuildingSkyscraper', targetFieldIcon: 'IconBuildingSkyscraper',
...computedContext, ...computedRelationCreationPayload,
}, },
}, },
}); });

View File

@ -8,6 +8,8 @@ import { createOneObjectMetadata } from 'test/integration/metadata/suites/object
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { FieldMetadataType } from 'twenty-shared/types'; import { FieldMetadataType } from 'twenty-shared/types';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
describe('updateOne', () => { describe('updateOne', () => {
describe('FieldMetadataService name/label sync', () => { describe('FieldMetadataService name/label sync', () => {
let listingObjectId = ''; let listingObjectId = '';
@ -115,5 +117,59 @@ describe('updateOne', () => {
'Name is not synced with label. Expected name: "testName", got newName', 'Name is not synced with label. Expected name: "testName", got newName',
); );
}); });
it('should throw if the field name is not available because of other field with the same name', async () => {
await createOneFieldMetadata({
input: {
objectMetadataId: listingObjectId,
type: FieldMetadataType.TEXT,
name: 'otherTestName',
label: 'Test name',
},
});
const { errors } = await updateOneFieldMetadata({
input: {
idToUpdate: testFieldId,
updatePayload: { name: 'testName' },
},
});
// Assert
expect(errors[0].message).toBe(
'Name "testName" is not available, check that it is not duplicating another field\'s name.',
);
});
it('should throw if the field name is not available because of other relation field using the same {name}Id', async () => {
// Arrange
await createOneFieldMetadata({
input: {
objectMetadataId: listingObjectId,
type: FieldMetadataType.RELATION,
name: 'children',
label: 'Children',
relationCreationPayload: {
targetObjectMetadataId: listingObjectId,
targetFieldLabel: 'parent',
targetFieldIcon: 'IconBuildingSkyscraper',
type: RelationType.ONE_TO_MANY,
},
},
});
// Act
const { errors } = await updateOneFieldMetadata({
input: {
idToUpdate: testFieldId,
updatePayload: { name: 'parentId' },
},
});
// Assert
expect(errors[0].message).toBe(
'Name "parentId" is not available, check that it is not duplicating another field\'s name.',
);
});
}); });
}); });