From 7ce96049b82539d698d46e628ace5df80402906c Mon Sep 17 00:00:00 2001 From: Etienne <45695613+etiennejouan@users.noreply.github.com> Date: Fri, 8 Aug 2025 17:42:50 +0200 Subject: [PATCH] Unique field - add unique property creation/deletion on field (#13539) Done : - add isUnique prop availability on gql update/create fieldMetadata resolvers - add unique index creation logic at update & creation - add unique index deletion logic at update - update unique index if field name updated - edge cases : can't have default value and unique fields / standard default value excluded from index (where clause) / can't have composite unique fields / can't create unique fields on MORPH closes https://github.com/twentyhq/core-team-issues/issues/1222 --- .../field-metadata/field-metadata.module.ts | 2 + .../services/field-metadata.service.ts | 265 +++++++++++++++- .../is-valid-unique-input.util.spec.ts | 35 +++ .../utils/is-valid-unique-input.util.ts | 21 ++ ...repare-field-metadata-for-creation.util.ts | 1 + .../index-field-metadata.exception.ts | 18 ++ .../index-metadata/index-metadata.service.ts | 289 ++++++++++++++--- ...ute-unique-index-where-clause.util.spec.ts | 45 +++ ...idate-can-create-unique-index.util.spec.ts | 66 ++++ .../compute-unique-index-where-clause.util.ts | 77 +++++ .../validate-can-create-unique-index.util.ts | 46 +++ .../search-vector/search-vector.service.ts | 2 +- ...ject-metadata-item-with-fields-map.util.ts | 17 + .../workspace-migration-runner.service.ts | 16 + ...etadata-morph-relation.integration-spec.ts | 9 +- ...-unique-field-metadata.integration-spec.ts | 141 +++++++++ ...th-custom-unique-field.integration-spec.ts | 197 ++++++++++++ ...-unique-field-metadata.integration-spec.ts | 291 ++++++++++++++++++ 18 files changed, 1485 insertions(+), 53 deletions(-) create mode 100644 packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/is-valid-unique-input.util.spec.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/is-valid-unique-input.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/index-metadata/index-field-metadata.exception.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/__tests__/compute-unique-index-where-clause.util.spec.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/__tests__/validate-can-create-unique-index.util.spec.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/compute-unique-index-where-clause.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/validate-can-create-unique-index.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/utils/get-object-metadata-entity-from-object-metadata-item-with-fields-map.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/field-metadata/unique-field/create-one-unique-field-metadata.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/field-metadata/unique-field/record-creation-with-custom-unique-field.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/field-metadata/unique-field/update-one-unique-field-metadata.integration-spec.ts diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts index b29f956422..c9aa11457b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts @@ -25,6 +25,7 @@ import { FieldMetadataServiceV2 } from 'src/engine/metadata-modules/field-metada import { IsFieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/validators/is-field-metadata-default-value.validator'; import { IsFieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/validators/is-field-metadata-options.validator'; import { FlatFieldMetadataModule } from 'src/engine/metadata-modules/flat-field-metadata/flat-field-metadata.module'; +import { IndexMetadataModule } from 'src/engine/metadata-modules/index-metadata/index-metadata.module'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; @@ -66,6 +67,7 @@ import { FieldMetadataService } from './services/field-metadata.service'; WorkspaceMigrationBuilderV2Module, WorkspaceMigrationRunnerV2Module, FlatFieldMetadataModule, + IndexMetadataModule, ], services: [ IsFieldMetadataDefaultValue, diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts index ba0c9207ed..c967add6ec 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts @@ -46,12 +46,17 @@ import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field- import { isFieldMetadataTypeMorphRelation } from 'src/engine/metadata-modules/field-metadata/utils/is-field-metadata-type-morph-relation.util'; import { isFieldMetadataTypeRelation } from 'src/engine/metadata-modules/field-metadata/utils/is-field-metadata-type-relation.util'; import { isSelectOrMultiSelectFieldMetadata } from 'src/engine/metadata-modules/field-metadata/utils/is-select-or-multi-select-field-metadata.util'; +import { isValidUniqueFieldDefaultValueCombination } from 'src/engine/metadata-modules/field-metadata/utils/is-valid-unique-input.util'; import { prepareCustomFieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/utils/prepare-custom-field-metadata-for-options.util'; import { prepareCustomFieldMetadataForCreation } from 'src/engine/metadata-modules/field-metadata/utils/prepare-field-metadata-for-creation.util'; +import { IndexMetadataService } from 'src/engine/metadata-modules/index-metadata/index-metadata.service'; +import { computeUniqueIndexWhereClause } from 'src/engine/metadata-modules/index-metadata/utils/compute-unique-index-where-clause.util'; +import { validateCanCreateUniqueIndex } from 'src/engine/metadata-modules/index-metadata/utils/validate-can-create-unique-index.util'; import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util'; import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; import { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; +import { getObjectMetadataEntityFromObjectMetadataItemWithFieldsMap } from 'src/engine/metadata-modules/utils/get-object-metadata-entity-from-object-metadata-item-with-fields-map.util'; import { validateNameAndLabelAreSyncOrThrow } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util'; import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service'; import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service'; @@ -97,6 +102,7 @@ export class FieldMetadataService extends TypeOrmQueryService 1) { + throw new FieldMetadataException( + 'Unique field cannot bet RELATION or MORPH_RELATION type', + FieldMetadataExceptionCode.INVALID_FIELD_INPUT, + ); + } + + const uniqueIndexMigration = + await this.createUniqueIndexForNewField({ + createdFieldMetadataItem: createdFieldMetadataItems[0], + objectMetadata, + fieldMetadataInput, + workspaceId, + queryRunner, + }); + + migrationActions.push( + ...(isDefined(uniqueIndexMigration) + ? [uniqueIndexMigration] + : []), + ); + } } } @@ -710,6 +808,171 @@ export class FieldMetadataService extends TypeOrmQueryService { + it('should return true if the field has a custom default value and is not unique', () => { + const result = isValidUniqueFieldDefaultValueCombination({ + defaultValue: "'custom value'", + isUnique: false, + type: FieldMetadataType.TEXT, + }); + + expect(result).toBe(true); + }); + + it('should return true if the field has standard default value and is unique', () => { + const result = isValidUniqueFieldDefaultValueCombination({ + defaultValue: "''", + isUnique: true, + type: FieldMetadataType.TEXT, + }); + + expect(result).toBe(true); + }); + + it('should return false if the field has custom default value and is unique', () => { + const result = isValidUniqueFieldDefaultValueCombination({ + defaultValue: "'custom value'", + isUnique: true, + type: FieldMetadataType.TEXT, + }); + + expect(result).toBe(false); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/is-valid-unique-input.util.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/is-valid-unique-input.util.ts new file mode 100644 index 0000000000..e5b4d3e0b4 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/is-valid-unique-input.util.ts @@ -0,0 +1,21 @@ +import { isDeepStrictEqual } from 'util'; + +import { type FieldMetadataType } from 'twenty-shared/types'; + +import { type FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface'; + +import { generateDefaultValue } from 'src/engine/metadata-modules/field-metadata/utils/generate-default-value'; + +export const isValidUniqueFieldDefaultValueCombination = ({ + defaultValue, + isUnique, + type, +}: { + defaultValue: FieldMetadataDefaultValue; + isUnique: boolean; + type: FieldMetadataType; +}) => { + const defaultDefaultValue = generateDefaultValue(type); + + return !isUnique || isDeepStrictEqual(defaultValue, defaultDefaultValue); +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/prepare-field-metadata-for-creation.util.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/prepare-field-metadata-for-creation.util.ts index 119852e870..ca6cfe1606 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/prepare-field-metadata-for-creation.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/prepare-field-metadata-for-creation.util.ts @@ -35,6 +35,7 @@ export const prepareCustomFieldMetadataForCreation = ( fieldMetadataInput?.relationCreationPayload?.targetObjectMetadataId, defaultValue, ...options, + isUnique: fieldMetadataInput.isUnique ?? false, isActive: true, isCustom: true, settings: fieldMetadataInput.settings, diff --git a/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-field-metadata.exception.ts b/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-field-metadata.exception.ts new file mode 100644 index 0000000000..fab7e7ef2b --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-field-metadata.exception.ts @@ -0,0 +1,18 @@ +import { CustomException } from 'src/utils/custom-exception'; + +export class IndexMetadataException extends CustomException { + declare code: IndexMetadataExceptionCode; + constructor( + message: string, + code: IndexMetadataExceptionCode, + { userFriendlyMessage }: { userFriendlyMessage?: string } = {}, + ) { + super(message, code, { userFriendlyMessage }); + } +} + +export enum IndexMetadataExceptionCode { + INDEX_CREATION_FAILED = 'INDEX_CREATION_FAILED', + INDEX_NOT_SUPPORTED_FOR_COMPOSITE_FIELD = 'INDEX_NOT_SUPPORTED_FOR_COMPOSITE_FIELD', + INDEX_NOT_SUPPORTED_FOR_MORH_RELATION_FIELD_AND_RELATION_FIELD = 'INDEX_NOT_SUPPORTED_FOR_MORH_RELATION_FIELD_AND_RELATION_FIELD', +} diff --git a/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.service.ts index 4f6bac73c9..0713468d8b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.service.ts @@ -2,12 +2,23 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import isEmpty from 'lodash.isempty'; +import { type FieldMetadataType } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; -import { type QueryRunner, Repository } from 'typeorm'; +import { In, type QueryRunner, Repository } from 'typeorm'; +import { type CompositeType } from 'src/engine/metadata-modules/field-metadata/interfaces/composite-type.interface'; + +import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types'; import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +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 { + IndexMetadataException, + IndexMetadataExceptionCode, +} from 'src/engine/metadata-modules/index-metadata/index-field-metadata.exception'; import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; import { type IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types'; +import { computeUniqueIndexWhereClause } from 'src/engine/metadata-modules/index-metadata/utils/compute-unique-index-where-clause.util'; import { generateDeterministicIndexName } from 'src/engine/metadata-modules/index-metadata/utils/generate-deterministic-index-name'; import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util'; @@ -73,7 +84,7 @@ export class IndexMetadataService { }, }); - if (existingIndex) { + if (isDefined(existingIndex)) { throw new Error( `Index ${indexName} on object metadata ${objectMetadata.nameSingular} already exists`, ); @@ -91,6 +102,8 @@ export class IndexMetadataService { workspaceId, objectMetadataId: objectMetadata.id, ...(isDefined(indexType) ? { indexType } : {}), + ...(isDefined(indexWhereClause) ? { indexWhereClause } : {}), + ...(isDefined(isUnique) ? { isUnique } : {}), isCustom, }); } catch { @@ -104,6 +117,37 @@ export class IndexMetadataService { `Failed to return saved index ${indexName} on object metadata ${objectMetadata.nameSingular}`, ); } + } + + async createIndex({ + workspaceId, + objectMetadata, + fieldMetadataToIndex, + isUnique, + isCustom, + indexType, + indexWhereClause, + queryRunner, + }: { + workspaceId: string; + objectMetadata: ObjectMetadataEntity; + fieldMetadataToIndex: FieldMetadataEntity[]; + isUnique: boolean; + isCustom: boolean; + indexType?: IndexType; + indexWhereClause?: string; + queryRunner?: QueryRunner; + }) { + await this.createIndexMetadata({ + workspaceId, + objectMetadata, + fieldMetadataToIndex, + indexType, + indexWhereClause, + isUnique, + isCustom, + queryRunner, + }); await this.createIndexCreationMigration({ workspaceId, @@ -116,6 +160,51 @@ export class IndexMetadataService { }); } + async recomputeUniqueCustomIndexMetadataForField({ + workspaceId, + objectMetadata, + updatedFieldMetadata, + queryRunner, + }: { + workspaceId: string; + objectMetadata: ObjectMetadataEntity; + updatedFieldMetadata: FieldMetadataEntity; + queryRunner?: QueryRunner; + }) { + const indexMetadataRepository = queryRunner + ? queryRunner.manager.getRepository(IndexMetadataEntity) + : this.indexMetadataRepository; + + const [index] = await indexMetadataRepository.find({ + where: { + objectMetadataId: objectMetadata.id, + workspaceId, + indexFieldMetadatas: { + fieldMetadataId: In([updatedFieldMetadata.id]), + }, + isUnique: true, + isCustom: true, + }, + relations: ['indexFieldMetadatas.fieldMetadata'], + }); + + if (!isDefined(index)) return; + + const updatedIndex = await indexMetadataRepository.save({ + ...index, + name: `IDX_${generateDeterministicIndexName([ + computeObjectTargetTable(objectMetadata), + updatedFieldMetadata.name, + ])}`, + indexWhereClause: computeUniqueIndexWhereClause(updatedFieldMetadata), + }); + + return { + updatedIndex, + previousName: index.name, + }; + } + async recomputeIndexMetadataForObject( workspaceId: string, updatedObjectMetadata: Pick< @@ -173,12 +262,17 @@ export class IndexMetadataService { return recomputedIndexes; } - async deleteIndexMetadata( - workspaceId: string, - objectMetadata: ObjectMetadataEntity, - fieldMetadataToIndex: Partial[], - queryRunner?: QueryRunner, - ) { + async deleteIndexMetadata({ + workspaceId, + objectMetadata, + fieldMetadataToIndex, + queryRunner, + }: { + workspaceId: string; + objectMetadata: ObjectMetadataEntity; + fieldMetadataToIndex: Partial[]; + queryRunner?: QueryRunner; + }) { const tableName = computeObjectTargetTable(objectMetadata); const columnNames: string[] = fieldMetadataToIndex.map( @@ -216,22 +310,14 @@ export class IndexMetadataService { } } - async createIndexCreationMigration({ - workspaceId, + computeIndexDeletionMigration({ objectMetadata, fieldMetadataToIndex, isUnique, - indexType, - indexWhereClause, - queryRunner, }: { - workspaceId: string; objectMetadata: ObjectMetadataEntity; fieldMetadataToIndex: Partial[]; isUnique: boolean; - indexType?: IndexType; - indexWhereClause?: string; - queryRunner?: QueryRunner; }) { const tableName = computeObjectTargetTable(objectMetadata); @@ -241,7 +327,69 @@ export class IndexMetadataService { const indexName = `IDX_${generateDeterministicIndexName([tableName, ...columnNames])}`; - const migration = { + return { + name: tableName, + action: WorkspaceMigrationTableActionType.ALTER_INDEXES, + indexes: [ + { + action: WorkspaceMigrationIndexActionType.DROP, + name: indexName, + columns: [], + isUnique, + } satisfies WorkspaceMigrationIndexAction, + ], + } satisfies WorkspaceMigrationTableAction; + } + + computeIndexCreationMigration({ + objectMetadata, + fieldMetadataToIndex, + isUnique, + indexType, + indexWhereClause, + }: { + objectMetadata: ObjectMetadataEntity; + fieldMetadataToIndex: (Partial & { + type: FieldMetadataType; + name: string; + })[]; + isUnique: boolean; + indexType?: IndexType; + indexWhereClause?: string; + }) { + const tableName = computeObjectTargetTable(objectMetadata); + + const fieldNames: string[] = fieldMetadataToIndex.map( + (fieldMetadata) => fieldMetadata.name as string, + ); + + const indexName = `IDX_${generateDeterministicIndexName([tableName, ...fieldNames])}`; + + const columnNames = fieldMetadataToIndex.flatMap((field) => { + if (isCompositeFieldMetadataType(field.type)) { + if (!isUnique) + throw new IndexMetadataException( + `Non unique index cannot be created for composite field ${field.name}`, + IndexMetadataExceptionCode.INDEX_NOT_SUPPORTED_FOR_COMPOSITE_FIELD, + ); + + const compositeType = compositeTypeDefinitions.get( + field.type, + ) as CompositeType; + + const uniqueCompositeProperties = compositeType.properties.filter( + (property) => property.isIncludedInUniqueConstraint, + ); + + return uniqueCompositeProperties.map((subField) => + computeCompositeColumnName(field.name, subField), + ); + } + + return [field.name]; + }); + + return { name: tableName, action: WorkspaceMigrationTableActionType.ALTER_INDEXES, indexes: [ @@ -255,6 +403,32 @@ export class IndexMetadataService { }, ], } satisfies WorkspaceMigrationTableAction; + } + + async createIndexCreationMigration({ + workspaceId, + objectMetadata, + fieldMetadataToIndex, + isUnique, + indexType, + indexWhereClause, + queryRunner, + }: { + workspaceId: string; + objectMetadata: ObjectMetadataEntity; + fieldMetadataToIndex: FieldMetadataEntity[]; + isUnique: boolean; + indexType?: IndexType; + indexWhereClause?: string; + queryRunner?: QueryRunner; + }) { + const migration = this.computeIndexCreationMigration({ + objectMetadata, + fieldMetadataToIndex, + isUnique, + indexType, + indexWhereClause, + }); await this.workspaceMigrationService.createCustomMigration( generateMigrationName(`create-${objectMetadata.nameSingular}-index`), @@ -264,6 +438,51 @@ export class IndexMetadataService { ); } + createIndexRecomputeMigrationActions( + objectMetadata: Pick< + ObjectMetadataEntity, + 'nameSingular' | 'isCustom' | 'id' + >, + recomputedIndex: { + indexMetadata: IndexMetadataEntity; + previousName: string; + newName: string; + }, + ) { + const { previousName, newName, indexMetadata } = recomputedIndex; + + const tableName = computeObjectTargetTable(objectMetadata); + + const indexFieldsMetadataOrdered = indexMetadata.indexFieldMetadatas.sort( + (a, b) => a.order - b.order, + ); + + const columnNames = indexFieldsMetadataOrdered.map( + (indexFieldMetadata) => indexFieldMetadata.fieldMetadata.name, + ); + + return { + name: tableName, + action: WorkspaceMigrationTableActionType.ALTER_INDEXES, + indexes: [ + { + action: WorkspaceMigrationIndexActionType.DROP, + name: previousName, + columns: [], + isUnique: indexMetadata.isUnique, + } satisfies WorkspaceMigrationIndexAction, + { + action: WorkspaceMigrationIndexActionType.CREATE, + columns: columnNames, + name: newName, + isUnique: indexMetadata.isUnique, + where: indexMetadata.indexWhereClause, + type: indexMetadata.indexType, + } satisfies WorkspaceMigrationIndexAction, + ], + } satisfies WorkspaceMigrationTableAction; + } + async createIndexRecomputeMigrations( workspaceId: string, objectMetadata: Pick< @@ -278,39 +497,11 @@ export class IndexMetadataService { queryRunner?: QueryRunner, ) { for (const recomputedIndex of recomputedIndexes) { - const { previousName, newName, indexMetadata } = recomputedIndex; - - const tableName = computeObjectTargetTable(objectMetadata); - - const indexFieldsMetadataOrdered = indexMetadata.indexFieldMetadatas.sort( - (a, b) => a.order - b.order, + const migration = this.createIndexRecomputeMigrationActions( + objectMetadata, + recomputedIndex, ); - const columnNames = indexFieldsMetadataOrdered.map( - (indexFieldMetadata) => indexFieldMetadata.fieldMetadata.name, - ); - - const migration = { - name: tableName, - action: WorkspaceMigrationTableActionType.ALTER_INDEXES, - indexes: [ - { - action: WorkspaceMigrationIndexActionType.DROP, - name: previousName, - columns: [], - isUnique: indexMetadata.isUnique, - } satisfies WorkspaceMigrationIndexAction, - { - action: WorkspaceMigrationIndexActionType.CREATE, - columns: columnNames, - name: newName, - isUnique: indexMetadata.isUnique, - where: indexMetadata.indexWhereClause, - type: indexMetadata.indexType, - } satisfies WorkspaceMigrationIndexAction, - ], - } satisfies WorkspaceMigrationTableAction; - await this.workspaceMigrationService.createCustomMigration( generateMigrationName(`update-${objectMetadata.nameSingular}-index`), workspaceId, diff --git a/packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/__tests__/compute-unique-index-where-clause.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/__tests__/compute-unique-index-where-clause.util.spec.ts new file mode 100644 index 0000000000..061297cbf3 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/__tests__/compute-unique-index-where-clause.util.spec.ts @@ -0,0 +1,45 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +import { computeUniqueIndexWhereClause } from 'src/engine/metadata-modules/index-metadata/utils/compute-unique-index-where-clause.util'; +import { getMockFieldMetadataEntity } from 'src/utils/__test__/get-field-metadata-entity.mock'; + +describe('computeUniqueIndexWhereClause', () => { + it('should return undefined if standard default value is not defined', () => { + const fieldMetadata = getMockFieldMetadataEntity({ + workspaceId: 'workspace-id', + objectMetadataId: 'object-id', + type: FieldMetadataType.UUID, + name: 'testField', + }); + + const result = computeUniqueIndexWhereClause(fieldMetadata); + + expect(result).toBeUndefined(); + }); + + it('should return a where clause for a an atomic type field', () => { + const fieldMetadata = getMockFieldMetadataEntity({ + workspaceId: 'workspace-id', + objectMetadataId: 'object-id', + type: FieldMetadataType.TEXT, + name: 'testTextField', + }); + + const result = computeUniqueIndexWhereClause(fieldMetadata); + + expect(result).toBe('"testTextField" != \'\''); + }); + + it('should return a where clause for a composite type field', () => { + const fieldMetadata = getMockFieldMetadataEntity({ + workspaceId: 'workspace-id', + objectMetadataId: 'object-id', + type: FieldMetadataType.EMAILS, + name: 'testEmailsField', + }); + + const result = computeUniqueIndexWhereClause(fieldMetadata); + + expect(result).toBe('"testEmailsFieldPrimaryEmail" != \'\''); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/__tests__/validate-can-create-unique-index.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/__tests__/validate-can-create-unique-index.util.spec.ts new file mode 100644 index 0000000000..a0fb079b05 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/__tests__/validate-can-create-unique-index.util.spec.ts @@ -0,0 +1,66 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +import { FieldMetadataException } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; +import { validateCanCreateUniqueIndex } from 'src/engine/metadata-modules/index-metadata/utils/validate-can-create-unique-index.util'; + +describe('validateCanCreateUniqueIndex', () => { + it('should throw an error if field to create is a MORPH type', () => { + const field = { + name: 'testField', + type: FieldMetadataType.MORPH_RELATION, + isCustom: true, + }; + + expect(() => validateCanCreateUniqueIndex(field)).toThrow( + FieldMetadataException, + ); + expect(() => validateCanCreateUniqueIndex(field)).toThrow( + 'Unique index cannot be created for field testField of type MORPH_RELATION', + ); + }); + + it('should throw an error if field to create is a RELATION type - ONE_TO_MANY', () => { + const field = { + name: 'testField', + type: FieldMetadataType.RELATION, + isCustom: true, + }; + + expect(() => validateCanCreateUniqueIndex(field)).toThrow( + FieldMetadataException, + ); + expect(() => validateCanCreateUniqueIndex(field)).toThrow( + 'Unique index cannot be created for field testField of type RELATION', + ); + }); + + it('should throw an error if field to create is a FULL_NAME type', () => { + const field = { + name: 'testField', + type: FieldMetadataType.FULL_NAME, + isCustom: true, + }; + + expect(() => validateCanCreateUniqueIndex(field)).toThrow( + FieldMetadataException, + ); + expect(() => validateCanCreateUniqueIndex(field)).toThrow( + 'Unique index cannot be created for field testField of type FULL_NAME', + ); + }); + + it('should throw an error if field to create is an ADDRESS type', () => { + const field = { + name: 'testField', + type: FieldMetadataType.ADDRESS, + isCustom: true, + }; + + expect(() => validateCanCreateUniqueIndex(field)).toThrow( + FieldMetadataException, + ); + expect(() => validateCanCreateUniqueIndex(field)).toThrow( + 'Unique index cannot be created for field testField of type ADDRESS', + ); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/compute-unique-index-where-clause.util.ts b/packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/compute-unique-index-where-clause.util.ts new file mode 100644 index 0000000000..04bda6886b --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/compute-unique-index-where-clause.util.ts @@ -0,0 +1,77 @@ +import { isNonEmptyString } from '@sniptt/guards'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types'; +import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { + FieldMetadataException, + 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 { generateDefaultValue } from 'src/engine/metadata-modules/field-metadata/utils/generate-default-value'; +import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; +import { + IndexMetadataException, + IndexMetadataExceptionCode, +} from 'src/engine/metadata-modules/index-metadata/index-field-metadata.exception'; + +export const computeUniqueIndexWhereClause = ( + fieldMetadata: Pick, +) => { + const defaultDefaultValue = generateDefaultValue(fieldMetadata.type); + + if (!isDefined(defaultDefaultValue)) return; + + if ( + fieldMetadata.type === FieldMetadataType.RELATION || + fieldMetadata.type === FieldMetadataType.MORPH_RELATION + ) { + throw new IndexMetadataException( + `Unique index cannot be created for relation or morph relation field ${fieldMetadata.name}`, + IndexMetadataExceptionCode.INDEX_NOT_SUPPORTED_FOR_MORH_RELATION_FIELD_AND_RELATION_FIELD, + ); + } + + if (!isCompositeFieldMetadataType(fieldMetadata.type)) { + return `"${fieldMetadata.name}" != ${defaultDefaultValue}`; + } + + const compositeType = compositeTypeDefinitions.get(fieldMetadata.type); + + if (!isDefined(compositeType)) { + throw new FieldMetadataException( + `Composite type not found for field metadata type: ${fieldMetadata.type}`, + FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR, + ); + } + + const defaultDefaultValueProperties = Object.keys(defaultDefaultValue); + + const columnNamesWithDefaultValues = compositeType.properties + .filter( + (property) => + property.isIncludedInUniqueConstraint && + defaultDefaultValueProperties.includes(property.name), + ) + .map((property) => { + const defaultValue = + defaultDefaultValue[property.name as keyof typeof defaultDefaultValue]; + + if (isNonEmptyString(defaultValue)) { + return [ + computeCompositeColumnName(fieldMetadata, property), + defaultValue, + ]; + } + }) + .filter(isDefined); + + return columnNamesWithDefaultValues.length > 0 + ? columnNamesWithDefaultValues + .map( + ([columnName, defaultValue]) => `"${columnName}" != ${defaultValue}`, + ) + .join(' OR ') + : undefined; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/validate-can-create-unique-index.util.ts b/packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/validate-can-create-unique-index.util.ts new file mode 100644 index 0000000000..35569211f3 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/validate-can-create-unique-index.util.ts @@ -0,0 +1,46 @@ +import { t } from '@lingui/core/macro'; +import { FieldMetadataType } from 'twenty-shared/types'; + +import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types'; +import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { + FieldMetadataException, + FieldMetadataExceptionCode, +} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; +import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; + +export const validateCanCreateUniqueIndex = ( + field: Pick, +) => { + if (field.isCustom === false) + throw new FieldMetadataException( + `Unique index cannot be created on standard field`, + FieldMetadataExceptionCode.INVALID_FIELD_INPUT, + { + userFriendlyMessage: t`Standard fields cannot be unique.`, + }, + ); + + const isCompositeFieldWithNonIncludedUniqueConstraint = + isCompositeFieldMetadataType(field.type) && + !compositeTypeDefinitions + .get(field.type) + ?.properties.some((property) => property.isIncludedInUniqueConstraint); + + if ( + [FieldMetadataType.MORPH_RELATION, FieldMetadataType.RELATION].includes( + field.type, + ) || + isCompositeFieldWithNonIncludedUniqueConstraint + ) { + const fieldType = field.type; + + throw new FieldMetadataException( + `Unique index cannot be created for field ${field.name} of type ${fieldType}`, + FieldMetadataExceptionCode.INVALID_FIELD_INPUT, + { + userFriendlyMessage: t`${fieldType} fields cannot be unique.`, + }, + ); + } +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/search-vector/search-vector.service.ts b/packages/twenty-server/src/engine/metadata-modules/search-vector/search-vector.service.ts index 939f968d13..9a36dc3f3e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/search-vector/search-vector.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/search-vector/search-vector.service.ts @@ -120,7 +120,7 @@ export class SearchVectorService { queryRunner, ); - await this.indexMetadataService.createIndexMetadata({ + await this.indexMetadataService.createIndex({ workspaceId: objectMetadataInput.workspaceId, objectMetadata: createdObjectMetadata, fieldMetadataToIndex: [searchVectorFieldMetadata], diff --git a/packages/twenty-server/src/engine/metadata-modules/utils/get-object-metadata-entity-from-object-metadata-item-with-fields-map.util.ts b/packages/twenty-server/src/engine/metadata-modules/utils/get-object-metadata-entity-from-object-metadata-item-with-fields-map.util.ts new file mode 100644 index 0000000000..a739afe859 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/utils/get-object-metadata-entity-from-object-metadata-item-with-fields-map.util.ts @@ -0,0 +1,17 @@ +import omit from 'lodash.omit'; + +import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; + +export const getObjectMetadataEntityFromObjectMetadataItemWithFieldsMap = ( + objectMetadataItem: ObjectMetadataItemWithFieldMaps, +): ObjectMetadataEntity => { + return { + ...omit(objectMetadataItem, [ + 'fieldsById', + 'fieldIdByName', + 'fieldIdByJoinColumnName', + ]), + fields: Object.values(objectMetadataItem.fieldsById), + }; +}; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service.ts index c65fa89222..f4d3258f60 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service.ts @@ -1,8 +1,13 @@ import { Injectable, Logger } from '@nestjs/common'; +import { t } from '@lingui/core/macro'; import { isDefined } from 'twenty-shared/utils'; import { type QueryRunner, Table, type TableColumn } from 'typeorm'; +import { + IndexMetadataException, + IndexMetadataExceptionCode, +} from 'src/engine/metadata-modules/index-metadata/index-field-metadata.exception'; import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types'; import { type WorkspaceMigrationColumnAction, @@ -246,6 +251,17 @@ export class WorkspaceMigrationRunnerService { if (error.code === '42P07') { return; } + + if (error.code === '23505') { + throw new IndexMetadataException( + `Unique index creation failed because of unique constraint violation`, + IndexMetadataExceptionCode.INDEX_CREATION_FAILED, + { + userFriendlyMessage: t`Cannot enable uniqueness due to existing duplicate values. Please review and fix your data first.`, + }, + ); + } + throw error; } } diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/morph-relation/create-one-field-metadata-morph-relation.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/morph-relation/create-one-field-metadata-morph-relation.integration-spec.ts index 57db4c714a..e8bee11b06 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/morph-relation/create-one-field-metadata-morph-relation.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/morph-relation/create-one-field-metadata-morph-relation.integration-spec.ts @@ -138,10 +138,15 @@ describe('createOne FieldMetadataService morph relation fields', () => { expect(createdField.id).toBeDefined(); expect(createdField.name).toBe('owner'); - expect(createdField.morphRelations[0].targetObjectMetadata.id).toBe( + + const morphRelationTargetIds = createdField.morphRelations.map( + (relation) => relation.targetObjectMetadata.id, + ); + + expect(morphRelationTargetIds).toContain( contextPayload.firstTargetObjectMetadataId, ); - expect(createdField.morphRelations[1].targetObjectMetadata.id).toBe( + expect(morphRelationTargetIds).toContain( contextPayload.secondTargetObjectMetadataId, ); diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/unique-field/create-one-unique-field-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/unique-field/create-one-unique-field-metadata.integration-spec.ts new file mode 100644 index 0000000000..36bc944b56 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/unique-field/create-one-unique-field-metadata.integration-spec.ts @@ -0,0 +1,141 @@ +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 { FieldMetadataType } from 'twenty-shared/types'; + +describe('create one unique field metadata', () => { + let createdObjectMetadataId = ''; + + beforeEach(async () => { + const { + data: { + createOneObject: { id: objectMetadataId }, + }, + } = await createOneObjectMetadata({ + input: { + nameSingular: 'testObject', + namePlural: 'testObjects', + labelSingular: 'Test Object', + labelPlural: 'Test Objects', + icon: 'IconTest', + }, + }); + + createdObjectMetadataId = objectMetadataId; + }); + + afterEach(async () => { + await deleteOneObjectMetadata({ + input: { idToDelete: createdObjectMetadataId }, + }); + }); + + it('should create one unique field metadata', async () => { + const createFieldInput = { + name: 'uniqueTestField', + label: 'Unique Test Field', + type: FieldMetadataType.TEXT, + objectMetadataId: createdObjectMetadataId, + isUnique: true, + }; + + const { data, errors } = await createOneFieldMetadata({ + input: createFieldInput, + gqlFields: ` + id + name + label + type + isUnique + `, + }); + + expect(errors).toBeUndefined(); + expect(data).not.toBeNull(); + expect(data.createOneField).toBeDefined(); + expect(data.createOneField.name).toBe('uniqueTestField'); + expect(data.createOneField.isUnique).toBe(true); + }); + + it('should not create unique field metadata if it has custom default value', async () => { + const createFieldInput = { + name: 'uniqueFieldWithDefault', + label: 'Unique Field With Default', + type: FieldMetadataType.TEXT, + objectMetadataId: createdObjectMetadataId, + isUnique: true, + defaultValue: "'?'", + }; + + const { data, errors } = await createOneFieldMetadata({ + input: createFieldInput, + gqlFields: ` + id + name + label + type + isUnique + `, + expectToFail: true, + }); + + expect(data).toBeNull(); + expect(errors).toBeDefined(); + expect(errors[0].message).toBe('Unique field cannot have a default value'); + }); + + it('should not create unique field metadata for composite type without unique subfields', async () => { + const createFieldInput = { + name: 'uniqueFullName', + label: 'Unique Full Name', + type: FieldMetadataType.FULL_NAME, + objectMetadataId: createdObjectMetadataId, + isUnique: true, + }; + + const { data, errors } = await createOneFieldMetadata({ + input: createFieldInput, + gqlFields: ` + id + name + label + type + isUnique + `, + expectToFail: true, + }); + + expect(data).toBeNull(); + expect(errors).toBeDefined(); + expect(errors[0].message).toContain( + 'Unique index cannot be created for field uniqueFullName of type FULL_NAME', + ); + }); + + it('should create unique field metadata for composite type with unique subfields', async () => { + const createFieldInput = { + name: 'uniqueEmails', + label: 'Unique Emails', + type: FieldMetadataType.EMAILS, + objectMetadataId: createdObjectMetadataId, + isUnique: true, + }; + + const { data, errors } = await createOneFieldMetadata({ + input: createFieldInput, + gqlFields: ` + id + name + label + type + isUnique + `, + }); + + expect(errors).toBeUndefined(); + expect(data).not.toBeNull(); + expect(data.createOneField).toBeDefined(); + expect(data.createOneField.name).toBe('uniqueEmails'); + expect(data.createOneField.isUnique).toBe(true); + }); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/unique-field/record-creation-with-custom-unique-field.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/unique-field/record-creation-with-custom-unique-field.integration-spec.ts new file mode 100644 index 0000000000..24d2a3e5d1 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/unique-field/record-creation-with-custom-unique-field.integration-spec.ts @@ -0,0 +1,197 @@ +import gql from 'graphql-tag'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +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 { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; +import { FieldMetadataType } from 'twenty-shared/types'; + +const createRecordQuery = gql` + mutation CreateFirstRecord($data: TestRecordObjectCreateInput!) { + createTestRecordObject(data: $data) { + id + uniqueTestField + } + } +`; + +describe('create records with custom unique fields', () => { + let createdObjectMetadataId = ''; + let uniqueFieldId = ''; + + beforeEach(async () => { + const { + data: { + createOneObject: { id: objectMetadataId }, + }, + } = await createOneObjectMetadata({ + input: { + nameSingular: 'testRecordObject', + namePlural: 'testRecordObjects', + labelSingular: 'Test Record Object', + labelPlural: 'Test Record Objects', + icon: 'IconTestRecord', + }, + }); + + createdObjectMetadataId = objectMetadataId; + + const { data: createdField } = await createOneFieldMetadata({ + input: { + name: 'uniqueTestField', + label: 'Unique Test Field', + type: FieldMetadataType.TEXT, + objectMetadataId: createdObjectMetadataId, + isUnique: true, + }, + gqlFields: ` + id + name + label + type + isUnique + `, + }); + + uniqueFieldId = createdField.createOneField.id; + }); + + afterEach(async () => { + await deleteOneObjectMetadata({ + input: { idToDelete: createdObjectMetadataId }, + }); + }); + + it('should not create the second record with same value on unique field', async () => { + const firstRecordResponse = await makeGraphqlAPIRequest({ + query: createRecordQuery, + variables: { + data: { + uniqueTestField: 'uniqueValue123', + }, + }, + }); + + expect(firstRecordResponse.body.errors).toBeUndefined(); + expect(firstRecordResponse.body.data.createTestRecordObject).toBeDefined(); + expect( + firstRecordResponse.body.data.createTestRecordObject.uniqueTestField, + ).toBe('uniqueValue123'); + + const secondRecordResponse = await makeGraphqlAPIRequest({ + query: createRecordQuery, + variables: { + data: { + uniqueTestField: 'uniqueValue123', + }, + }, + }); + + expect(secondRecordResponse.body.errors).toBeDefined(); + expect(secondRecordResponse.body.errors[0].message).toContain( + 'Duplicate Unique Test Field with value uniqueValue123. Please set a unique one.', + ); + expect(secondRecordResponse.body.data.createTestRecordObject).toBeNull(); + }); + + it('should create the second record with same value on updated field (previously unique)', async () => { + const firstRecordResponse = await makeGraphqlAPIRequest({ + query: createRecordQuery, + variables: { + data: { + uniqueTestField: 'duplicateValue', + }, + }, + }); + + expect(firstRecordResponse.body.errors).toBeUndefined(); + expect( + firstRecordResponse.body.data.createTestRecordObject.uniqueTestField, + ).toBe('duplicateValue'); + + const { data, errors } = await updateOneFieldMetadata({ + input: { + idToUpdate: uniqueFieldId, + updatePayload: { isUnique: false }, + }, + gqlFields: ` + id + name + isUnique + `, + }); + + expect(errors).toBeUndefined(); + expect(data.updateOneField.isUnique).toBe(false); + + const secondRecordResponse = await makeGraphqlAPIRequest({ + query: createRecordQuery, + variables: { + data: { + uniqueTestField: 'duplicateValue', + }, + }, + }); + + expect(secondRecordResponse.body.errors).toBeUndefined(); + expect(secondRecordResponse.body.data.createTestRecordObject).toBeDefined(); + expect( + secondRecordResponse.body.data.createTestRecordObject.uniqueTestField, + ).toBe('duplicateValue'); + }); + + it('should not create a unique index on field if records with same value already exist', async () => { + await updateOneFieldMetadata({ + input: { + idToUpdate: uniqueFieldId, + updatePayload: { isUnique: false }, + }, + gqlFields: ` + id + name + isUnique + `, + }); + + const firstRecordResponse = await makeGraphqlAPIRequest({ + query: createRecordQuery, + variables: { + data: { + uniqueTestField: 'duplicatedValue', + }, + }, + }); + + expect(firstRecordResponse.body.errors).toBeUndefined(); + + const secondRecordResponse = await makeGraphqlAPIRequest({ + query: createRecordQuery, + variables: { + data: { + uniqueTestField: 'duplicatedValue', + }, + }, + }); + + expect(secondRecordResponse.body.errors).toBeUndefined(); + + const { data, errors } = await updateOneFieldMetadata({ + input: { + idToUpdate: uniqueFieldId, + updatePayload: { isUnique: true }, + }, + gqlFields: ` + id + name + isUnique + `, + expectToFail: true, + }); + + expect(data).toBeNull(); + expect(errors).toBeDefined(); + expect(errors[0].message).toContain( + 'Unique index creation failed because of unique constraint violation', + ); + }); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/unique-field/update-one-unique-field-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/unique-field/update-one-unique-field-metadata.integration-spec.ts new file mode 100644 index 0000000000..e51b6c2354 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/unique-field/update-one-unique-field-metadata.integration-spec.ts @@ -0,0 +1,291 @@ +import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util'; +import { deleteOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/delete-one-field-metadata.util'; +import { updateOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata.util'; +import { 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 { FieldMetadataType } from 'twenty-shared/types'; + +describe('update one unique field metadata', () => { + let createdObjectId = ''; + + beforeEach(async () => { + const { + data: { + createOneObject: { id: objectMetadataId }, + }, + } = await createOneObjectMetadata({ + input: { + nameSingular: 'testObjectToTestUniqueField', + namePlural: 'testObjectsToTestUniqueFields', + labelSingular: 'Test Object To Test Unique Field', + labelPlural: 'Test Objects To Test Unique Fields', + icon: 'IconTest', + }, + }); + + createdObjectId = objectMetadataId; + }); + + afterEach(async () => { + await deleteOneObjectMetadata({ + input: { idToDelete: createdObjectId }, + }); + }); + + it('should update non unique field metadata to unique', async () => { + const { data: createdField } = await createOneFieldMetadata({ + input: { + name: 'testField', + label: 'Test Field', + type: FieldMetadataType.TEXT, + objectMetadataId: createdObjectId, + isUnique: false, + }, + gqlFields: ` + id + name + label + type + isUnique + `, + }); + + const { data, errors } = await updateOneFieldMetadata({ + input: { + idToUpdate: createdField.createOneField.id, + updatePayload: { isUnique: true }, + }, + gqlFields: ` + id + name + label + type + isUnique + `, + }); + + expect(errors).toBeUndefined(); + expect(data).not.toBeNull(); + expect(data.updateOneField).toBeDefined(); + expect(data.updateOneField.isUnique).toBe(true); + }); + + it('should update unique field metadata to non unique', async () => { + const { data: createdField } = await createOneFieldMetadata({ + input: { + name: 'uniqueTestField', + label: 'Unique Test Field', + type: FieldMetadataType.TEXT, + objectMetadataId: createdObjectId, + isUnique: true, + }, + gqlFields: ` + id + name + label + type + isUnique + `, + }); + + const { data, errors } = await updateOneFieldMetadata({ + input: { + idToUpdate: createdField.createOneField.id, + updatePayload: { isUnique: false }, + }, + gqlFields: ` + id + name + label + type + isUnique + `, + }); + + expect(errors).toBeUndefined(); + expect(data).not.toBeNull(); + expect(data.updateOneField).toBeDefined(); + expect(data.updateOneField.isUnique).toBe(false); + }); + + it('should update unique index name if field metadata name is updated', async () => { + const { data: createdField } = await createOneFieldMetadata({ + input: { + name: 'uniqueTestField', + label: 'Unique Test Field', + type: FieldMetadataType.TEXT, + objectMetadataId: createdObjectId, + isUnique: true, + }, + gqlFields: ` + id + name + label + type + isUnique + `, + }); + + const { data, errors } = await updateOneFieldMetadata({ + input: { + idToUpdate: createdField.createOneField.id, + updatePayload: { + name: 'renamedUniqueField', + label: 'Renamed Unique Field', + }, + }, + gqlFields: ` + id + name + label + type + isUnique + `, + }); + + expect(errors).toBeUndefined(); + expect(data).not.toBeNull(); + expect(data.updateOneField).toBeDefined(); + expect(data.updateOneField.name).toBe('renamedUniqueField'); + expect(data.updateOneField.isUnique).toBe(true); + + await updateOneFieldMetadata({ + input: { + idToUpdate: createdField.createOneField.id, + updatePayload: { + isActive: false, + }, + }, + gqlFields: ` + id + `, + }); + + const { data: deletedField, errors: deletedFieldErrors } = + await deleteOneFieldMetadata({ + input: { + idToDelete: createdField.createOneField.id, + }, + }); + + expect(deletedField).not.toBeNull(); + expect(deletedFieldErrors).toBeUndefined(); + }); + + it('should not update to unique field metadata if it has custom default value', async () => { + const { data: createdField } = await createOneFieldMetadata({ + input: { + name: 'fieldWithDefault', + label: 'Field With Default', + type: FieldMetadataType.TEXT, + objectMetadataId: createdObjectId, + defaultValue: "'defaultValue'", + isUnique: false, + }, + gqlFields: ` + id + name + label + type + isUnique + `, + }); + + const { data, errors } = await updateOneFieldMetadata({ + input: { + idToUpdate: createdField.createOneField.id, + updatePayload: { isUnique: true }, + }, + gqlFields: ` + id + name + label + type + isUnique + `, + expectToFail: true, + }); + + expect(data).toBeNull(); + expect(errors).toBeDefined(); + expect(errors[0].message).toBe('Unique field cannot have a default value'); + }); + + it('should not update with custom default value if unique field metadata', async () => { + const { data: createdField } = await createOneFieldMetadata({ + input: { + name: 'uniqueTestField', + label: 'Unique Test Field', + type: FieldMetadataType.TEXT, + objectMetadataId: createdObjectId, + isUnique: true, + }, + gqlFields: ` + id + name + label + type + isUnique + `, + }); + + const { data, errors } = await updateOneFieldMetadata({ + input: { + idToUpdate: createdField.createOneField.id, + updatePayload: { defaultValue: "'defaultValue'" }, + }, + gqlFields: ` + id + name + label + type + isUnique + `, + expectToFail: true, + }); + + expect(data).toBeNull(); + expect(errors).toBeDefined(); + expect(errors[0].message).toBe('Unique field cannot have a default value'); + }); + + it('should not update to unique field metadata if it has composite type without unique subfields', async () => { + const { data: createdField } = await createOneFieldMetadata({ + input: { + name: 'fullNameField', + label: 'Full Name Field', + type: FieldMetadataType.FULL_NAME, + objectMetadataId: createdObjectId, + isUnique: false, + }, + gqlFields: ` + id + name + label + type + isUnique + `, + }); + + const { data, errors } = await updateOneFieldMetadata({ + input: { + idToUpdate: createdField.createOneField.id, + updatePayload: { isUnique: true }, + }, + gqlFields: ` + id + name + label + type + isUnique + `, + expectToFail: true, + }); + + expect(data).toBeNull(); + expect(errors).toBeDefined(); + expect(errors[0].message).toContain( + 'Unique index cannot be created for field fullNameField of type FULL_NAME', + ); + }); +});