Fix and refactor relation field name collision validation (#15920)

# Introduction
Fixes https://github.com/twentyhq/private-issues/issues/371
We weren't strictly validating relation field collision on join column
name availability of the target field object

## Refactor
Extracted morph or relation specific condition out of the common flat
field metadata name validate availability to be located in the dedicated
morph or relation flat field validator

## Tests
Added two tests, ONE_TO_MANY and MANY_TO_ONE in order to cover the use
case
This commit is contained in:
Paul Rastoin
2025-11-20 16:10:35 +01:00
committed by GitHub
parent 9c6d8330df
commit e5255df1a1
11 changed files with 484 additions and 86 deletions
@@ -4,6 +4,7 @@ import { msg } from '@lingui/core/macro';
import { isDefined } from 'class-validator';
import { FieldMetadataExceptionCode } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { FlatEntityPropertiesUpdates } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-properties-updates.type';
import { type FlatFieldMetadataTypeValidator } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-type-validator.type';
import { FlatFieldMetadataValidationError } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-validation-error.type';
import { validateEnumSelectFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-enum-flat-field-metadata.util';
@@ -14,6 +15,11 @@ const DEFAULT_NO_VALIDATION = async (): Promise<
FlatFieldMetadataValidationError[]
> => [];
export type GenericValidateFlatFieldMetadataTypeSpecificitiesArgs =
FlatEntityValidationArgs<'fieldMetadata'> & {
updates?: FlatEntityPropertiesUpdates<'fieldMetadata'>;
};
@Injectable()
export class FlatFieldMetadataTypeValidatorService {
constructor() {}
@@ -51,7 +57,7 @@ export class FlatFieldMetadataTypeValidatorService {
};
public async validateFlatFieldMetadataTypeSpecificities(
args: FlatEntityValidationArgs<'fieldMetadata'>,
args: GenericValidateFlatFieldMetadataTypeSpecificitiesArgs,
): Promise<FlatFieldMetadataValidationError[]> {
const { flatEntityToValidate } = args;
const fieldType = flatEntityToValidate.type;
@@ -1,11 +1,14 @@
import { type FieldMetadataType } from 'twenty-shared/types';
import { type GenericValidateFlatFieldMetadataTypeSpecificitiesArgs } from 'src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-type-validator.service';
import { type FlatFieldMetadataValidationError } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-validation-error.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatEntityValidationArgs } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/flat-entity-validation-args.type';
export type FlatFieldMetadataTypeValidationArgs<T extends FieldMetadataType> =
Omit<FlatEntityValidationArgs<'fieldMetadata'>, 'flatEntityToValidate'> & {
Omit<
GenericValidateFlatFieldMetadataTypeSpecificitiesArgs,
'flatEntityToValidate'
> & {
flatEntityToValidate: FlatFieldMetadata<T>;
};
@@ -0,0 +1,59 @@
import { RelationType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
type ObjectFieldNamesAndJoinColumnNames = {
fieldNames: string[];
joinColumnNames: string[];
};
export const getObjectFieldNamesAndJoinColumnNames = ({
flatFieldMetadataMaps,
flatObjectMetadata,
}: {
flatObjectMetadata: FlatObjectMetadata;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
}): {
objectFieldNamesAndJoinColumnNames: ObjectFieldNamesAndJoinColumnNames;
} => {
const objectFlatFieldMetadatas =
findManyFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityMaps: flatFieldMetadataMaps,
flatEntityIds: flatObjectMetadata.fieldMetadataIds,
});
const initialAccumulator: ObjectFieldNamesAndJoinColumnNames = {
joinColumnNames: [],
fieldNames: [],
};
const objectFieldNamesAndJoinColumnNames = objectFlatFieldMetadatas.reduce(
(acc, flatFieldMetadata) => {
if (
isMorphOrRelationFlatFieldMetadata(flatFieldMetadata) &&
flatFieldMetadata.settings.relationType === RelationType.MANY_TO_ONE &&
isDefined(flatFieldMetadata.settings.joinColumnName)
) {
return {
...acc,
fieldNames: [...acc.fieldNames, flatFieldMetadata.name],
joinColumnNames: [
...acc.joinColumnNames,
flatFieldMetadata.settings.joinColumnName,
],
};
}
return {
...acc,
fieldNames: [...acc.fieldNames, flatFieldMetadata.name],
};
},
initialAccumulator,
);
return { objectFieldNamesAndJoinColumnNames };
};
@@ -1,20 +1,15 @@
import { msg } from '@lingui/core/macro';
import {
FieldMetadataType,
compositeTypeDefinitions,
} from 'twenty-shared/types';
import { compositeTypeDefinitions } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { FieldMetadataExceptionCode } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { type FlatFieldMetadataValidationError } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-validation-error.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
import { getObjectFieldNamesAndJoinColumnNames } from 'src/engine/metadata-modules/flat-field-metadata/utils/get-object-field-names-and-join-column-names.util';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
const getReservedCompositeFieldNames = (
@@ -41,15 +36,13 @@ const getReservedCompositeFieldNames = (
};
export const validateFlatFieldMetadataNameAvailability = ({
flatFieldMetadata,
name,
flatFieldMetadataMaps,
remainingFlatEntityMapsToValidate,
flatObjectMetadata,
}: {
flatFieldMetadata: FlatFieldMetadata;
name: string;
flatObjectMetadata: FlatObjectMetadata;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
remainingFlatEntityMapsToValidate?: FlatEntityMaps<FlatFieldMetadata>;
}): FlatFieldMetadataValidationError[] => {
const errors: FlatFieldMetadataValidationError[] = [];
const objectFlatFieldMetadatas =
@@ -60,63 +53,37 @@ export const validateFlatFieldMetadataNameAvailability = ({
const reservedCompositeFieldsNames = getReservedCompositeFieldNames(
objectFlatFieldMetadatas,
);
const flatFieldMetadataName = flatFieldMetadata.name;
if (
!isFlatFieldMetadataOfType(
// CHALLENGE Could remove the is morph relation assertion
flatFieldMetadata,
FieldMetadataType.MORPH_RELATION,
) &&
objectFlatFieldMetadatas.some((existingFlatFieldMetadata) => {
const firstDegreeCollision =
existingFlatFieldMetadata.name === flatFieldMetadataName;
if (firstDegreeCollision) {
return true;
}
if (!isMorphOrRelationFlatFieldMetadata(existingFlatFieldMetadata)) {
return false;
}
const targetFlatFieldMetadata =
flatFieldMetadata.id ===
existingFlatFieldMetadata.relationTargetFieldMetadataId
? flatFieldMetadata
: (remainingFlatEntityMapsToValidate?.byId[
existingFlatFieldMetadata.relationTargetFieldMetadataId
] ??
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId:
existingFlatFieldMetadata.relationTargetFieldMetadataId,
flatEntityMaps: flatFieldMetadataMaps,
}));
if (!isMorphOrRelationFlatFieldMetadata(targetFlatFieldMetadata)) {
return false;
}
return (
targetFlatFieldMetadata.settings.joinColumnName ===
flatFieldMetadataName
);
})
) {
if (reservedCompositeFieldsNames.includes(name)) {
errors.push({
code: FieldMetadataExceptionCode.NOT_AVAILABLE,
value: flatFieldMetadataName,
message: `Name "${flatFieldMetadataName}" is not available as it is already used by another field`,
userFriendlyMessage: msg`Name "${flatFieldMetadataName}" is not available as it is already used by another field`,
code: FieldMetadataExceptionCode.RESERVED_KEYWORD,
message: `Name "${name}" is reserved composite field name`,
value: name,
userFriendlyMessage: msg`Name "${name}" is not available`,
});
}
if (reservedCompositeFieldsNames.includes(flatFieldMetadataName)) {
const { objectFieldNamesAndJoinColumnNames } =
getObjectFieldNamesAndJoinColumnNames({
flatFieldMetadataMaps,
flatObjectMetadata,
});
if (objectFieldNamesAndJoinColumnNames.fieldNames.includes(name)) {
errors.push({
code: FieldMetadataExceptionCode.RESERVED_KEYWORD,
message: `Name "${flatFieldMetadataName}" is reserved composite field name`,
value: flatFieldMetadataName,
userFriendlyMessage: msg`Name "${flatFieldMetadataName}" is not available`,
code: FieldMetadataExceptionCode.NOT_AVAILABLE,
value: name,
message: `Name "${name}" is not available as it is already used by another field`,
userFriendlyMessage: msg`Name "${name}" is not available as it is already used by another field`,
});
}
if (objectFieldNamesAndJoinColumnNames.joinColumnNames.includes(name)) {
errors.push({
code: FieldMetadataExceptionCode.NOT_AVAILABLE,
value: name,
message: `Name "${name}" is not available as it is already used by another join column name`,
userFriendlyMessage: msg`Name "${name}" is not available as it is already used by join column name`,
});
}
@@ -0,0 +1,79 @@
import { msg } from '@lingui/core/macro';
import { RelationType } from 'twenty-shared/types';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { FieldMetadataExceptionCode } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { type MorphOrRelationFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/morph-or-relation-field-metadata-type.type';
import { type MetadataFlatEntityAndRelatedFlatEntityMapsForValidation } from 'src/engine/metadata-modules/flat-entity/types/metadata-flat-entity-and-related-flat-entity-maps-for-validation.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatFieldMetadataValidationError } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-validation-error.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { validateFlatFieldMetadataNameAvailability } from 'src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-flat-field-metadata-name-availability.util';
export const validateMorphOrRelationFlatFieldJoinColumName = ({
flatFieldMetadata,
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: {
flatFieldMetadataMaps,
flatObjectMetadataMaps,
},
}: {
flatFieldMetadata: FlatFieldMetadata<MorphOrRelationFieldMetadataType>;
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: MetadataFlatEntityAndRelatedFlatEntityMapsForValidation<'fieldMetadata'>;
}): FlatFieldMetadataValidationError[] => {
const errors: FlatFieldMetadataValidationError[] = [];
switch (flatFieldMetadata.settings.relationType) {
case RelationType.MANY_TO_ONE: {
if (!isDefined(flatFieldMetadata.settings.joinColumnName)) {
errors.push({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message:
'Many to one field metadata should carry the join column name in its settings',
userFriendlyMessage: msg`A many to one relation field should always declare a join column`,
});
return errors;
}
const flatObjectMetadata = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: flatFieldMetadata.objectMetadataId,
flatEntityMaps: flatObjectMetadataMaps,
});
if (!isDefined(flatObjectMetadata)) {
errors.push({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: 'Could not find relation field parent flat object',
userFriendlyMessage: msg`Could not find relation field parent flat object`,
});
return errors;
}
errors.push(
...validateFlatFieldMetadataNameAvailability({
name: flatFieldMetadata.settings.joinColumnName,
flatFieldMetadataMaps,
flatObjectMetadata,
}),
);
break;
}
case RelationType.ONE_TO_MANY: {
if (isDefined(flatFieldMetadata.settings.joinColumnName)) {
errors.push({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message:
'One to many field metadata should not carry the join column name in its settings',
userFriendlyMessage: msg`A one to many relation field should never declare a join column`,
});
}
break;
}
default: {
assertUnreachable(flatFieldMetadata.settings.relationType);
}
}
return errors;
};
@@ -3,8 +3,12 @@ import { isDefined, isValidUuid } from 'twenty-shared/utils';
import { FieldMetadataExceptionCode } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { type MorphOrRelationFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/morph-or-relation-field-metadata-type.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatFieldMetadataTypeValidationArgs } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-type-validator.type';
import { type FlatFieldMetadataValidationError } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-validation-error.type';
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
import { validateMorphOrRelationFlatFieldJoinColumName } from 'src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-join-column-name.util';
import { findFlatEntityPropertyUpdate } from 'src/engine/workspace-manager/workspace-migration-v2/utils/find-flat-entity-property-update.util';
export const validateMorphOrRelationFlatFieldMetadata = async ({
flatEntityToValidate: flatFieldMetadataToValidate,
@@ -12,6 +16,7 @@ export const validateMorphOrRelationFlatFieldMetadata = async ({
flatFieldMetadataMaps,
flatObjectMetadataMaps,
},
updates,
remainingFlatEntityMapsToValidate,
}: FlatFieldMetadataTypeValidationArgs<MorphOrRelationFieldMetadataType>): Promise<
FlatFieldMetadataValidationError[]
@@ -39,25 +44,29 @@ export const validateMorphOrRelationFlatFieldMetadata = async ({
const errors: FlatFieldMetadataValidationError[] = [];
const targetRelationFlatObjectMetadata =
flatObjectMetadataMaps?.byId[relationTargetObjectMetadataId];
const targetFlatObjectMetadata = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: relationTargetObjectMetadataId,
flatEntityMaps: flatObjectMetadataMaps,
});
if (!isDefined(targetRelationFlatObjectMetadata)) {
const targetFlatFieldMetadata =
remainingFlatEntityMapsToValidate?.byId[relationTargetFieldMetadataId] ??
findFlatEntityByIdInFlatEntityMaps({
flatEntityId: relationTargetFieldMetadataId,
flatEntityMaps: flatFieldMetadataMaps,
});
if (!isDefined(targetFlatObjectMetadata)) {
errors.push({
code: FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
message: 'Relation target object metadata not found',
userFriendlyMessage: msg`Object targeted by the relation not found`,
});
return errors;
}
const targetRelationFlatFieldMetadata =
remainingFlatEntityMapsToValidate?.byId[relationTargetFieldMetadataId] ??
flatFieldMetadataMaps.byId[relationTargetFieldMetadataId];
if (
isDefined(targetRelationFlatObjectMetadata) &&
!isDefined(targetRelationFlatFieldMetadata)
) {
if (!isDefined(targetFlatFieldMetadata)) {
errors.push({
code: FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND,
message: isDefined(remainingFlatEntityMapsToValidate)
@@ -65,7 +74,33 @@ export const validateMorphOrRelationFlatFieldMetadata = async ({
: 'Relation field target metadata not found',
userFriendlyMessage: msg`Relation field target metadata not found`,
});
} else if (!isMorphOrRelationFlatFieldMetadata(targetFlatFieldMetadata)) {
errors.push({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message:
'A relation field can only target a MORPH_RELATION or another RELATION field',
userFriendlyMessage: msg`Invalid relation field target`,
});
}
if (
!isDefined(updates) ||
isDefined(
findFlatEntityPropertyUpdate({
flatEntityUpdates: updates,
property: 'settings',
}),
)
)
errors.push(
...validateMorphOrRelationFlatFieldJoinColumName({
flatFieldMetadata: flatFieldMetadataToValidate,
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: {
flatFieldMetadataMaps,
flatObjectMetadataMaps,
},
}),
);
return errors;
};
@@ -131,7 +131,7 @@ export class FlatFieldMetadataValidatorService {
validationResult.errors.push(
...validateFlatFieldMetadataName(flatFieldMetadataToValidate.name),
...validateFlatFieldMetadataNameAvailability({
flatFieldMetadata: flatFieldMetadataToValidate,
name: flatFieldMetadataToValidate.name,
flatFieldMetadataMaps: optimisticFlatFieldMetadataMaps,
flatObjectMetadata,
}),
@@ -152,6 +152,7 @@ export class FlatFieldMetadataValidatorService {
const fieldMetadataTypeValidationErrors =
await this.flatFieldMetadataTypeValidatorService.validateFlatFieldMetadataTypeSpecificities(
{
updates,
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: {
flatFieldMetadataMaps: optimisticFlatFieldMetadataMaps,
flatObjectMetadataMaps,
@@ -313,9 +314,8 @@ export class FlatFieldMetadataValidatorService {
validationResult.errors.push(
...validateFlatFieldMetadataNameAvailability({
flatFieldMetadata: flatFieldMetadataToValidate,
name: flatFieldMetadataToValidate.name,
flatFieldMetadataMaps: optimisticFlatFieldMetadataMaps,
remainingFlatEntityMapsToValidate,
flatObjectMetadata: parentFlatObjectMetadata,
}),
);
@@ -117,8 +117,8 @@ exports[`failing createOne FieldMetadataService morph relation fields v2 Morh re
"errors": [
{
"code": "NOT_AVAILABLE",
"message": "Name "fieldNamePersonForMorphRelationSecondId" is not available as it is already used by another field",
"userFriendlyMessage": "Name "fieldNamePersonForMorphRelationSecondId" is not available as it is already used by another field",
"message": "Name "fieldNamePersonForMorphRelationSecondId" is not available as it is already used by another join column name",
"userFriendlyMessage": "Name "fieldNamePersonForMorphRelationSecondId" is not available as it is already used by join column name",
"value": "fieldNamePersonForMorphRelationSecondId",
},
],
@@ -487,7 +487,7 @@ describe('failing createOne FieldMetadataService morph relation fields v2', () =
input: {
label: 'colliding field label',
name: `fieldNamePersonForMorphRelationSecondId`,
objectMetadataId: createdObjectMetadataPersonId,
objectMetadataId: createdObjectMetadataCompanyId,
type: FieldMetadataType.TEXT,
},
expectToFail: true,
@@ -1455,3 +1455,181 @@ exports[`Field metadata relation creation should fail should fail when creating
"name": "GraphQLError",
}
`;
exports[`Field metadata relation creation should fail should fail when creating a relation field with name that conflicts with existing field name 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<String>,
"name": "fieldName",
"objectMetadataId": Any<String>,
},
"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",
},
],
"flatEntityMinimalInformation": {
"id": Any<String>,
"name": "relatedObject",
"objectMetadataId": Any<String>,
},
"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<String>,
"name": "IDX_7ee6c7ff1e4648b0665f60cd5e4",
},
"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",
}
`;
exports[`Field metadata relation creation should fail should fail when creating a relation field with name that conflicts with existing join column name 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"cronTrigger": [],
"databaseEventTrigger": [],
"fieldMetadata": [
{
"errors": [
{
"code": "NOT_AVAILABLE",
"message": "Name "companyId" is not available as it is already used by another join column name",
"userFriendlyMessage": "Name "companyId" is not available as it is already used by join column name",
"value": "companyId",
},
],
"flatEntityMinimalInformation": {
"id": Any<String>,
"name": "companyId",
"objectMetadataId": Any<String>,
},
"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",
},
],
"flatEntityMinimalInformation": {
"id": Any<String>,
"name": "related",
"objectMetadataId": Any<String>,
},
"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<String>,
"name": "IDX_ed8240d8bc1e69a39dab1c17fe2",
},
"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",
}
`;
@@ -1,13 +1,16 @@
import { faker } from '@faker-js/faker';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { 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 {
eachTestingContextFilter,
type EachTestingContext,
} from 'twenty-shared/testing';
import { FieldMetadataType } from 'twenty-shared/types';
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';
@@ -252,7 +255,7 @@ describe('Field metadata relation creation should fail', () => {
}
});
it.each(failingLabelsCreationTestsUseCase)(
it.each(eachTestingContextFilter(failingLabelsCreationTestsUseCase))(
'relation ONE_TO_MANY $title',
async ({ context }) => {
const computedRelationCreationPayload =
@@ -291,7 +294,7 @@ describe('Field metadata relation creation should fail', () => {
},
);
it.each(failingLabelsCreationTestsUseCase)(
it.each(eachTestingContextFilter(failingLabelsCreationTestsUseCase))(
'relation MANY_TO_ONE $title',
async ({ context }) => {
const computedRelationCreationPayload =
@@ -376,4 +379,72 @@ describe('Field metadata relation creation should fail', () => {
errors,
});
});
it('should fail when creating a relation field with name that conflicts with existing field name', async () => {
const { errors } = await createOneFieldMetadata({
expectToFail: true,
input: {
objectMetadataId: globalTestContext.objectMetadataIds.sourceObjectId,
name: globalTestContext.collisionFieldName,
label: 'Duplicate Field',
isLabelSyncedWithName: false,
type: FieldMetadataType.RELATION,
relationCreationPayload: {
targetFieldLabel: 'Related Object',
type: RelationType.MANY_TO_ONE,
targetObjectMetadataId:
globalTestContext.objectMetadataIds.targetObjectId,
targetFieldIcon: 'IconLink',
},
},
});
expectOneNotInternalServerErrorSnapshot({
errors,
});
});
it('should fail when creating a relation field with name that conflicts with existing join column name', async () => {
const relationFieldName = 'company';
await createOneFieldMetadata({
expectToFail: false,
input: {
objectMetadataId: globalTestContext.objectMetadataIds.sourceObjectId,
name: relationFieldName,
label: 'Company',
isLabelSyncedWithName: false,
type: FieldMetadataType.RELATION,
relationCreationPayload: {
targetFieldLabel: 'company',
type: RelationType.MANY_TO_ONE,
targetObjectMetadataId:
globalTestContext.objectMetadataIds.targetObjectId,
targetFieldIcon: 'IconBuilding',
},
},
});
const { errors } = await createOneFieldMetadata({
expectToFail: true,
input: {
objectMetadataId: globalTestContext.objectMetadataIds.sourceObjectId,
name: `${relationFieldName}Id`,
label: 'Company Id Field',
isLabelSyncedWithName: false,
type: FieldMetadataType.RELATION,
relationCreationPayload: {
targetFieldLabel: 'Related',
type: RelationType.MANY_TO_ONE,
targetObjectMetadataId:
globalTestContext.objectMetadataIds.targetObjectId,
targetFieldIcon: 'IconLink',
},
},
});
expectOneNotInternalServerErrorSnapshot({
errors,
});
});
});