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

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

View File

@ -1,5 +1,6 @@
import { Injectable, ValidationError } from '@nestjs/common';
import { t } from '@lingui/core/macro';
import { plainToInstance } from 'class-transformer';
import { IsEnum, IsString, IsUUID, validateOrReject } from 'class-validator';
import { FieldMetadataType } from 'twenty-shared/types';
@ -17,6 +18,7 @@ import {
FieldMetadataException,
FieldMetadataExceptionCode,
} 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 { 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';
@ -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 { isFieldMetadataInterfaceOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
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 {
@IsUUID()
@ -93,7 +94,7 @@ export class FieldMetadataRelationService {
});
const targetFieldMetadataToCreateWithRelation =
await this.addCustomRelationFieldMetadataForCreation({
this.computeCustomRelationFieldMetadataForCreation({
fieldMetadataInput: targetFieldMetadataToCreate,
relationCreationPayload: {
targetObjectMetadataId: objectMetadata.id,
@ -134,9 +135,13 @@ export class FieldMetadataRelationService {
fieldMetadataInput,
fieldMetadataType,
objectMetadataMaps,
objectMetadata,
}: Pick<
ValidateFieldMetadataArgs<T>,
'fieldMetadataInput' | 'fieldMetadataType' | 'objectMetadataMaps'
| 'fieldMetadataInput'
| 'fieldMetadataType'
| 'objectMetadataMaps'
| 'objectMetadata'
>): Promise<T> {
// TODO: clean typings, we should try to validate both update and create inputs in the same function
const isRelation =
@ -150,6 +155,11 @@ export class FieldMetadataRelationService {
.relationCreationPayload,
)
) {
validateFieldNameAvailabilityOrThrow(
`${fieldMetadataInput.name}Id`,
objectMetadata,
);
const relationCreationPayload = (
fieldMetadataInput as unknown as CreateFieldInput
).relationCreationPayload;
@ -180,6 +190,24 @@ export class FieldMetadataRelationService {
computedMetadataNameFromLabel,
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,
relationCreationPayload,
joinColumnName,

View File

@ -32,6 +32,7 @@ import {
computeColumnName,
computeCompositeColumnName,
} 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 { 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';
@ -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 { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.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()
export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntity> {
@ -671,7 +671,7 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
if (fieldMetadataInput.type === FieldMetadataType.RELATION) {
const relationFieldMetadataForCreate =
await this.fieldMetadataRelationService.addCustomRelationFieldMetadataForCreation(
this.fieldMetadataRelationService.computeCustomRelationFieldMetadataForCreation(
{
fieldMetadataInput: fieldMetadataForCreate,
relationCreationPayload: fieldMetadataInput.relationCreationPayload,
@ -686,6 +686,7 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
fieldMetadataInput: relationFieldMetadataForCreate,
fieldMetadataType: fieldMetadataForCreate.type,
objectMetadataMaps,
objectMetadata,
},
);

View File

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

View File

@ -1,4 +1,5 @@
import { t } from '@lingui/core/macro';
import { FieldMetadataType } from 'twenty-shared/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';
@ -39,14 +40,17 @@ export const validateFieldNameAvailabilityOrThrow = (
if (
Object.values(objectMetadata.fieldsById).some(
(field) => field.name === name,
(field) =>
field.name === name ||
(field.type === FieldMetadataType.RELATION &&
`${field.name}Id` === name),
)
) {
throw new InvalidMetadataException(
`Name "${name}" is not available`,
`Name "${name}" is not available as it is already used by another field`,
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`,
},
);
}