diff --git a/.vscode/launch.json b/.vscode/launch.json index bd3a306621..01396f38eb 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -67,7 +67,7 @@ "--config", "./jest-integration.config.ts", "${relativeFile}", - "--silent=false" + "--silent=false", ], "cwd": "${workspaceFolder}/packages/twenty-server", "console": "integratedTerminal", diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-13/1-13-rename-unique-index.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-13/1-13-rename-unique-index.command.ts new file mode 100644 index 0000000000..7cf7551d40 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-13/1-13-rename-unique-index.command.ts @@ -0,0 +1,125 @@ +import { Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Command } from 'nest-commander'; +import { isDefined } from 'twenty-shared/utils'; +import { Repository } from 'typeorm'; + +import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner'; +import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; +import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; +import { generateDeterministicIndexNameV2 } from 'src/engine/metadata-modules/index-metadata/utils/generate-deterministic-index-name-v2'; +import { WorkspaceDataSource } from 'src/engine/twenty-orm/datasource/workspace.datasource'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; +import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util'; + +@Command({ + name: 'upgrade:1-13:rename-index-name', + description: 'Rename indexes to use the new v2 index name format', +}) +export class RenameIndexNameCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner { + protected readonly logger = new Logger(RenameIndexNameCommand.name); + + constructor( + @InjectRepository(WorkspaceEntity) + protected readonly workspaceRepository: Repository, + protected readonly twentyORMGlobalManager: TwentyORMGlobalManager, + @InjectRepository(IndexMetadataEntity) + private readonly indexMetadataRepository: Repository, + protected readonly dataSourceService: DataSourceService, + protected readonly workspaceCacheService: WorkspaceCacheService, + ) { + super(workspaceRepository, twentyORMGlobalManager, dataSourceService); + } + + override async runOnWorkspace({ + workspaceId, + options, + dataSource, + }: RunOnWorkspaceArgs): Promise { + const isDryRun = options.dryRun || false; + + if (!isDefined(dataSource)) { + throw new Error( + `Could not find data source for workspace ${workspaceId}, should never occur`, + ); + } + + const schemaName = getWorkspaceSchemaName(workspaceId); + + if (isDryRun) { + this.logger.log('Dry run mode: No changes will be applied'); + } + + const indexes = await this.indexMetadataRepository.find({ + where: { + workspaceId, + }, + relations: ['objectMetadata', 'indexFieldMetadatas.fieldMetadata'], + }); + + let hasIndexNameChanges = false; + + for (const index of indexes) { + const indexNameV2 = generateDeterministicIndexNameV2({ + flatObjectMetadata: { + nameSingular: index.objectMetadata.nameSingular, + isCustom: index.objectMetadata.isCustom, + }, + relatedFieldNames: index.indexFieldMetadatas.map( + (indexFieldMetadata) => ({ + name: indexFieldMetadata.fieldMetadata.name, + }), + ), + isUnique: index.isUnique, + }); + + if (indexNameV2 === index.name) { + this.logger.log(`Index ${index.name} is V2`); + continue; + } else { + this.logger.log(`Renaming index ${index.name} to ${indexNameV2}`); + hasIndexNameChanges = true; + if (!isDryRun) { + await this.renameIndexOnDatabase( + dataSource, + schemaName, + index.name, + indexNameV2, + ); + + await this.indexMetadataRepository.update(index.id, { + name: indexNameV2, + }); + } + } + } + if (hasIndexNameChanges) { + this.logger.log('Invalidating workspace cache'); + + if (!isDryRun) { + await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [ + 'flatFieldMetadataMaps', + 'flatIndexMaps', + ]); + } + } + } + + private async renameIndexOnDatabase( + dataSource: WorkspaceDataSource, + schemaName: string, + oldIndexName: string, + newIndexName: string, + ): Promise { + await dataSource.query( + `ALTER INDEX "${schemaName}"."${oldIndexName}" RENAME TO "${newIndexName}"`, + [], + undefined, + { shouldBypassPermissionChecks: true }, + ); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-13/1-13-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-13/1-13-upgrade-version-command.module.ts index ca30e18f5d..b05f4db5f0 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/1-13/1-13-upgrade-version-command.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-13/1-13-upgrade-version-command.module.ts @@ -5,12 +5,14 @@ import { BackfillPageLayoutUniversalIdentifiersCommand } from 'src/database/comm import { BackfillViewMainGroupByFieldMetadataIdCommand } from 'src/database/commands/upgrade-version-command/1-13/1-13-backfill-view-main-group-by-field-metadata-id.command'; import { CleanEmptyStringNullInTextFieldsCommand } from 'src/database/commands/upgrade-version-command/1-13/1-13-clean-empty-string-null-in-text-fields.command'; import { DeduplicateRoleTargetsCommand } from 'src/database/commands/upgrade-version-command/1-13/1-13-deduplicate-role-targets.command'; +import { RenameIndexNameCommand } from 'src/database/commands/upgrade-version-command/1-13/1-13-rename-unique-index.command'; import { UpdateRoleTargetsUniqueConstraintMigrationCommand } from 'src/database/commands/upgrade-version-command/1-13/1-13-update-role-targets-unique-constraint-migration.command'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { PageLayoutTabEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout-tab.entity'; import { PageLayoutWidgetEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout-widget.entity'; @@ -26,6 +28,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache WorkspaceEntity, ObjectMetadataEntity, FieldMetadataEntity, + IndexMetadataEntity, ViewEntity, ViewGroupEntity, FeatureFlagEntity, @@ -43,6 +46,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache BackfillViewMainGroupByFieldMetadataIdCommand, BackfillPageLayoutUniversalIdentifiersCommand, DeduplicateRoleTargetsCommand, + RenameIndexNameCommand, UpdateRoleTargetsUniqueConstraintMigrationCommand, ], exports: [ @@ -50,6 +54,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache BackfillViewMainGroupByFieldMetadataIdCommand, BackfillPageLayoutUniversalIdentifiersCommand, DeduplicateRoleTargetsCommand, + RenameIndexNameCommand, UpdateRoleTargetsUniqueConstraintMigrationCommand, ], }) 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 c19a866853..feb781313c 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 @@ -22,6 +22,7 @@ import { fromCreateFieldInputToFlatFieldMetadatasToCreate } from 'src/engine/met import { fromDeleteFieldInputToFlatFieldMetadatasToDelete } from 'src/engine/metadata-modules/flat-field-metadata/utils/from-delete-field-input-to-flat-field-metadatas-to-delete.util'; import { fromUpdateFieldInputToFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/from-update-field-input-to-flat-field-metadata.util'; import { throwOnFieldInputTranspilationsError } from 'src/engine/metadata-modules/flat-field-metadata/utils/throw-on-field-input-transpilations-error.util'; +import { EMPTY_ORCHESTRATOR_FAILURE_REPORT } from 'src/engine/workspace-manager/workspace-migration-v2/constant/empty-orchestrator-failure-report.constant'; import { WorkspaceMigrationBuilderExceptionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-builder-exception-v2'; import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration-v2/services/workspace-migration-validate-build-and-run-service'; @@ -183,13 +184,21 @@ export class FieldMetadataService extends TypeOrmQueryService, + "errors": [ + { + "code": "FIELD_METADATA_RELATION_MALFORMED", "message": "Morph relation creation payloads must have the same relation type", + "userFriendlyMessage": { + "id": Any, + "message": "Morph relation creation payloads must have the same relation type", + }, }, - }, + ], "status": "fail", } `; exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test suite Failure cases should fail when morphRelationsCreationPayload has invalid relation payload 1`] = ` { - "error": { - "code": "FIELD_METADATA_RELATION_MALFORMED", - "message": "Morph relation input transpilation failed", - "userFriendlyMessage": { - "id": Any, - "message": "Invalid morph relation input", - }, - "value": [ - { - "targetObjectMetadataId": Any, - "type": "ONE_TO_MANY", + "errors": [ + { + "code": "FIELD_METADATA_RELATION_MALFORMED", + "message": "Morph relation input transpilation failed", + "userFriendlyMessage": { + "id": Any, + "message": "Invalid morph relation input", }, - ], - }, + "value": [ + { + "targetObjectMetadataId": Any, + "type": "ONE_TO_MANY", + }, + ], + }, + ], "status": "fail", } `; exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test suite Failure cases should fail when morphRelationsCreationPayload has several references to same object metadata 1`] = ` { - "error": { - "code": "FIELD_METADATA_RELATION_MALFORMED", - "message": "Morph relation creation payloads must have only relation to the same object metadata", - "userFriendlyMessage": { - "id": Any, - "message": "Morph relation creation payloads must only contain relation to the same object metadata", + "errors": [ + { + "code": "FIELD_METADATA_RELATION_MALFORMED", + "message": "Morph relation creation payloads must have only relation to the same object metadata", + "userFriendlyMessage": { + "id": Any, + "message": "Morph relation creation payloads must only contain relation to the same object metadata", + }, }, - }, + ], "status": "fail", } `; exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test suite Failure cases should fail when morphRelationsCreationPayload is empty array 1`] = ` { - "error": { - "code": "FIELD_METADATA_RELATION_MALFORMED", - "message": "Morph relation creation payloads are empty", - "userFriendlyMessage": { - "id": Any, - "message": "At least one relation is require", + "errors": [ + { + "code": "FIELD_METADATA_RELATION_MALFORMED", + "message": "Morph relation creation payloads are empty", + "userFriendlyMessage": { + "id": Any, + "message": "At least one relation is require", + }, }, - }, + ], "status": "fail", } `; exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test suite Failure cases should fail when morphRelationsCreationPayload is missing 1`] = ` { - "error": { - "code": "INVALID_FIELD_INPUT", - "message": "Relation creation payload is required", - "userFriendlyMessage": { - "id": Any, + "errors": [ + { + "code": "INVALID_FIELD_INPUT", "message": "Relation creation payload is required", + "userFriendlyMessage": { + "id": Any, + "message": "Relation creation payload is required", + }, + "value": undefined, }, - "value": undefined, - }, + ], "status": "fail", } `; exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test suite Failure cases should fail when target object metadata is not found 1`] = ` { - "error": { - "code": "FIELD_METADATA_RELATION_MALFORMED", - "message": "Morph relation input transpilation failed", - "userFriendlyMessage": { - "id": Any, - "message": "Invalid morph relation input", - }, - "value": [ - { - "targetFieldIcon": "IconPet", - "targetFieldLabel": "Pet", - "targetObjectMetadataId": Any, - "type": "ONE_TO_MANY", + "errors": [ + { + "code": "FIELD_METADATA_RELATION_MALFORMED", + "message": "Morph relation input transpilation failed", + "userFriendlyMessage": { + "id": Any, + "message": "Invalid morph relation input", }, - ], - }, + "value": [ + { + "targetFieldIcon": "IconPet", + "targetFieldLabel": "Pet", + "targetObjectMetadataId": Any, + "type": "ONE_TO_MANY", + }, + ], + }, + ], "status": "fail", } `; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/constants/flat-field-metadata-editable-properties.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/constants/flat-field-metadata-editable-properties.constant.ts index 9f8807e0f2..55c348670a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/constants/flat-field-metadata-editable-properties.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/constants/flat-field-metadata-editable-properties.constant.ts @@ -21,5 +21,7 @@ export const FLAT_FIELD_METADATA_EDITABLE_PROPERTIES = { 'label', 'options', 'settings', + //TODO : uncomment once universal identifier on standard fields is migrated + // 'isUnique', // not editable for standard fields with standard unique constraint ], } as const satisfies Record<'standard' | 'custom', (keyof FlatFieldMetadata)[]>; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/types/field-input-transpilation-result.type.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/types/field-input-transpilation-result.type.ts index 083bed827d..231caffa8d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/types/field-input-transpilation-result.type.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/types/field-input-transpilation-result.type.ts @@ -2,7 +2,7 @@ import { type FlatFieldMetadataValidationError } from 'src/engine/metadata-modul export type FailedFieldInputTranspilation = { status: 'fail'; - error: FlatFieldMetadataValidationError; + errors: FlatFieldMetadataValidationError[]; }; export type SuccessfulFieldInputTranspilation = { status: 'success'; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-create-field-input-to-flat-field-metadatas-to-create.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-create-field-input-to-flat-field-metadatas-to-create.util.ts index aa290f0916..01e463af30 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-create-field-input-to-flat-field-metadatas-to-create.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-create-field-input-to-flat-field-metadatas-to-create.util.ts @@ -42,10 +42,12 @@ export const fromCreateFieldInputToFlatFieldMetadatasToCreate = async ({ if (rawCreateFieldInput.isRemoteCreation) { return { status: 'fail', - error: { - code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT, - message: "Remote fields aren't supported", - }, + errors: [ + { + code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT, + message: "Remote fields aren't supported", + }, + ], }; } @@ -60,11 +62,13 @@ export const fromCreateFieldInputToFlatFieldMetadatasToCreate = async ({ if (!isDefined(parentFlatObjectMetadata)) { return { status: 'fail', - error: { - code: FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND, - message: 'Provided object metadata id does not exist', - userFriendlyMessage: msg`Created field metadata, parent object metadata not found`, - }, + errors: [ + { + code: FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND, + message: 'Provided object metadata id does not exist', + userFriendlyMessage: msg`Created field metadata, parent object metadata not found`, + }, + ], }; } @@ -149,10 +153,12 @@ export const fromCreateFieldInputToFlatFieldMetadatasToCreate = async ({ case FieldMetadataType.TS_VECTOR: { return { status: 'fail', - error: { - code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT, - message: 'TS Vector is not supported for field creation', - }, + errors: [ + { + code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT, + message: 'TS Vector is not supported for field creation', + }, + ], }; } case FieldMetadataType.UUID: diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-morph-relation-create-field-input-to-flat-field-metadatas.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-morph-relation-create-field-input-to-flat-field-metadatas.util.ts index ac9b3c1ebe..cff0fa08f9 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-morph-relation-create-field-input-to-flat-field-metadatas.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-morph-relation-create-field-input-to-flat-field-metadatas.util.ts @@ -44,12 +44,14 @@ export const fromMorphRelationCreateFieldInputToFlatFieldMetadatas = async ({ ) { return { status: 'fail', - error: { - code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT, - message: `Relation creation payload is required`, - userFriendlyMessage: msg`Relation creation payload is required`, - value: rawMorphCreationPayload, - }, + errors: [ + { + code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT, + message: `Relation creation payload is required`, + userFriendlyMessage: msg`Relation creation payload is required`, + value: rawMorphCreationPayload, + }, + ], }; } diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-relation-create-field-input-to-flat-field-metadatas.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-relation-create-field-input-to-flat-field-metadatas.util.ts index 06ba47486c..7615c3d52b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-relation-create-field-input-to-flat-field-metadatas.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-relation-create-field-input-to-flat-field-metadatas.util.ts @@ -37,12 +37,14 @@ export const fromRelationCreateFieldInputToFlatFieldMetadatas = async ({ if (!isDefined(rawCreationPayload)) { return { status: 'fail', - error: { - code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT, - message: `Relation creation payload is required`, - userFriendlyMessage: msg`Relation creation payload is required`, - value: rawCreationPayload, - }, + errors: [ + { + code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT, + message: `Relation creation payload is required`, + userFriendlyMessage: msg`Relation creation payload is required`, + value: rawCreationPayload, + }, + ], }; } diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-update-field-input-to-flat-field-metadata.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-update-field-input-to-flat-field-metadata.util.ts index 79e2c695f3..388ada54cf 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-update-field-input-to-flat-field-metadata.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-update-field-input-to-flat-field-metadata.util.ts @@ -6,13 +6,11 @@ import { } from 'twenty-shared/utils'; import { type UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input'; -import { - FieldMetadataException, - FieldMetadataExceptionCode, -} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; +import { FieldMetadataExceptionCode } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type'; import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; import { type FieldInputTranspilationResult } from 'src/engine/metadata-modules/flat-field-metadata/types/field-input-transpilation-result.type'; +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 { computeFlatFieldToUpdateAndRelatedFlatFieldToUpdate } from 'src/engine/metadata-modules/flat-field-metadata/utils/compute-flat-field-to-update-and-related-flat-field-to-update.util'; import { computeFlatFieldToUpdateFromMorphRelationUpdatePayload } from 'src/engine/metadata-modules/flat-field-metadata/utils/compute-flat-field-to-update-from-morph-relation-update-payload.util'; @@ -66,11 +64,13 @@ export const fromUpdateFieldInputToFlatFieldMetadata = ({ if (!isDefined(existingFlatFieldMetadataToUpdate)) { return { status: 'fail', - error: { - code: FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND, - message: 'Field metadata to update not found', - userFriendlyMessage: msg`Field metadata to update not found`, - }, + errors: [ + { + code: FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND, + message: 'Field metadata to update not found', + userFriendlyMessage: msg`Field metadata to update not found`, + }, + ], }; } @@ -80,10 +80,16 @@ export const fromUpdateFieldInputToFlatFieldMetadata = ({ }); if (!isDefined(flatObjectMetadata)) { - throw new FieldMetadataException( - 'Field to update object metadata not found', - FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND, - ); + return { + status: 'fail', + errors: [ + { + code: FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND, + message: 'Field to update object metadata not found', + userFriendlyMessage: msg`Field to update object metadata not found`, + }, + ], + }; } const { flatFieldMetadataFromTo, relatedFlatFieldMetadatasFromTo } = @@ -112,99 +118,121 @@ export const fromUpdateFieldInputToFlatFieldMetadata = ({ flatIndexMetadatasToCreate: [], }; - const initialAccumulator: FlatFieldMetadataAndIndexToUpdate = { + const initialAccumulator: FlatFieldMetadataAndIndexToUpdate & { + errors: FlatFieldMetadataValidationError[]; + } = { ...structuredClone(FLAT_FIELD_METADATA_UPDATE_EMPTY_SIDE_EFFECTS), flatFieldMetadatasToUpdate: [], flatFieldMetadatasToCreate: flatFieldMetadatasToCreate, flatIndexMetadatasToCreate: flatIndexMetadatasToCreate, + errors: [], }; - const optimisticiallyUpdatedFlatFieldMetadatas = [ + const { errors: sideEffectErrors, ...sideEffectFlatEntityOperations } = [ flatFieldMetadataFromTo, ...relatedFlatFieldMetadatasFromTo, - ].reduce( - (accumulator, { fromFlatFieldMetadata, toFlatFieldMetadata }) => { - const { - flatViewGroupsToCreate, - flatViewGroupsToDelete, - flatViewGroupsToUpdate, - flatIndexMetadatasToUpdate, - flatViewFiltersToDelete, - flatViewFiltersToUpdate, - flatIndexMetadatasToCreate, - flatIndexMetadatasToDelete, - flatViewsToDelete, - flatViewFieldsToDelete, - flatViewsToUpdate, - } = handleFlatFieldMetadataUpdateSideEffect({ - flatViewFilterMaps, - flatViewGroupMaps, - flatObjectMetadataMaps: existingFlatObjectMetadataMaps, - fromFlatFieldMetadata, - flatFieldMetadataMaps, - flatIndexMaps, - toFlatFieldMetadata, - flatViewMaps, - flatViewFieldMaps, - }); + ].reduce< + FlatFieldMetadataAndIndexToUpdate & { + errors: FlatFieldMetadataValidationError[]; + } + >((accumulator, { fromFlatFieldMetadata, toFlatFieldMetadata }) => { + const sideEffectResult = handleFlatFieldMetadataUpdateSideEffect({ + flatViewFilterMaps, + flatViewGroupMaps, + flatObjectMetadataMaps: existingFlatObjectMetadataMaps, + fromFlatFieldMetadata, + flatFieldMetadataMaps, + flatIndexMaps, + toFlatFieldMetadata, + flatViewMaps, + flatViewFieldMaps, + workspaceCustomApplicationId, + }); + if (sideEffectResult.status === 'fail') { return { - flatFieldMetadatasToUpdate: [ - ...accumulator.flatFieldMetadatasToUpdate, - toFlatFieldMetadata, - ], - flatIndexMetadatasToUpdate: [ - ...accumulator.flatIndexMetadatasToUpdate, - ...flatIndexMetadatasToUpdate, - ], - flatFieldMetadatasToCreate: [...accumulator.flatFieldMetadatasToCreate], - flatViewFiltersToDelete: [ - ...accumulator.flatViewFiltersToDelete, - ...flatViewFiltersToDelete, - ], - flatViewFiltersToUpdate: [ - ...accumulator.flatViewFiltersToUpdate, - ...flatViewFiltersToUpdate, - ], - flatViewGroupsToCreate: [ - ...accumulator.flatViewGroupsToCreate, - ...flatViewGroupsToCreate, - ], - flatViewGroupsToDelete: [ - ...accumulator.flatViewGroupsToDelete, - ...flatViewGroupsToDelete, - ], - flatViewGroupsToUpdate: [ - ...accumulator.flatViewGroupsToUpdate, - ...flatViewGroupsToUpdate, - ], - flatIndexMetadatasToDelete: [ - ...accumulator.flatIndexMetadatasToDelete, - ...flatIndexMetadatasToDelete, - ], - flatIndexMetadatasToCreate: [ - ...accumulator.flatIndexMetadatasToCreate, - ...flatIndexMetadatasToCreate, - ], - flatViewsToDelete: [ - ...accumulator.flatViewsToDelete, - ...flatViewsToDelete, - ], - flatViewFieldsToDelete: [ - ...accumulator.flatViewFieldsToDelete, - ...flatViewFieldsToDelete, - ], - flatViewsToUpdate: [ - ...accumulator.flatViewsToUpdate, - ...flatViewsToUpdate, - ], + ...accumulator, + errors: [...accumulator.errors, ...sideEffectResult.errors], }; - }, - initialAccumulator, - ); + } + + const { + flatViewGroupsToCreate, + flatViewGroupsToDelete, + flatViewGroupsToUpdate, + flatIndexMetadatasToUpdate, + flatViewFiltersToDelete, + flatViewFiltersToUpdate, + flatIndexMetadatasToCreate, + flatIndexMetadatasToDelete, + flatViewsToDelete, + flatViewFieldsToDelete, + flatViewsToUpdate, + } = sideEffectResult.result; + + return { + flatFieldMetadatasToUpdate: [ + ...accumulator.flatFieldMetadatasToUpdate, + toFlatFieldMetadata, + ], + flatIndexMetadatasToUpdate: [ + ...accumulator.flatIndexMetadatasToUpdate, + ...flatIndexMetadatasToUpdate, + ], + flatFieldMetadatasToCreate: [...accumulator.flatFieldMetadatasToCreate], + flatViewFiltersToDelete: [ + ...accumulator.flatViewFiltersToDelete, + ...flatViewFiltersToDelete, + ], + flatViewFiltersToUpdate: [ + ...accumulator.flatViewFiltersToUpdate, + ...flatViewFiltersToUpdate, + ], + flatViewGroupsToCreate: [ + ...accumulator.flatViewGroupsToCreate, + ...flatViewGroupsToCreate, + ], + flatViewGroupsToDelete: [ + ...accumulator.flatViewGroupsToDelete, + ...flatViewGroupsToDelete, + ], + flatViewGroupsToUpdate: [ + ...accumulator.flatViewGroupsToUpdate, + ...flatViewGroupsToUpdate, + ], + flatIndexMetadatasToDelete: [ + ...accumulator.flatIndexMetadatasToDelete, + ...flatIndexMetadatasToDelete, + ], + flatIndexMetadatasToCreate: [ + ...accumulator.flatIndexMetadatasToCreate, + ...flatIndexMetadatasToCreate, + ], + flatViewsToDelete: [ + ...accumulator.flatViewsToDelete, + ...flatViewsToDelete, + ], + flatViewFieldsToDelete: [ + ...accumulator.flatViewFieldsToDelete, + ...flatViewFieldsToDelete, + ], + flatViewsToUpdate: [ + ...accumulator.flatViewsToUpdate, + ...flatViewsToUpdate, + ], + errors: accumulator.errors, + }; + }, initialAccumulator); + + if (sideEffectErrors.length > 0) { + return { + status: 'fail', + errors: sideEffectErrors, + }; + } return { status: 'success', - result: optimisticiallyUpdatedFlatFieldMetadatas, + result: sideEffectFlatEntityOperations, }; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/handle-flat-field-metadata-update-side-effect.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/handle-flat-field-metadata-update-side-effect.util.ts index c31354e646..3dd0b7eab1 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/handle-flat-field-metadata-update-side-effect.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/handle-flat-field-metadata-update-side-effect.util.ts @@ -1,6 +1,7 @@ import { type FromTo } from 'twenty-shared/types'; import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type'; +import { type FieldInputTranspilationResult } from 'src/engine/metadata-modules/flat-field-metadata/types/field-input-transpilation-result.type'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { handleEnumFlatFieldMetadataUpdateSideEffects } from 'src/engine/metadata-modules/flat-field-metadata/utils/handle-enum-flat-field-metadata-update-side-effects.util'; import { @@ -34,7 +35,9 @@ type HandleFlatFieldMetadataUpdateSideEffectArgs = FromTo< | 'flatViewGroupMaps' | 'flatViewMaps' | 'flatViewFieldMaps' - >; + > & { + workspaceCustomApplicationId: string; + }; export const FLAT_FIELD_METADATA_UPDATE_EMPTY_SIDE_EFFECTS: FlatFieldMetadataUpdateSideEffects = { @@ -61,7 +64,8 @@ export const handleFlatFieldMetadataUpdateSideEffect = ({ flatViewGroupMaps, flatViewMaps, flatViewFieldMaps, -}: HandleFlatFieldMetadataUpdateSideEffectArgs): FlatFieldMetadataUpdateSideEffects => { + workspaceCustomApplicationId, +}: HandleFlatFieldMetadataUpdateSideEffectArgs): FieldInputTranspilationResult => { const sideEffectResult = structuredClone( FLAT_FIELD_METADATA_UPDATE_EMPTY_SIDE_EFFECTS, ); @@ -114,18 +118,25 @@ export const handleFlatFieldMetadataUpdateSideEffect = ({ sideEffectResult.flatViewFiltersToDelete.push(...flatViewFiltersToDelete); } - const { - flatIndexMetadatasToUpdate, - flatIndexMetadatasToCreate, - flatIndexMetadatasToDelete, - } = handleIndexChangesDuringFieldUpdate({ + const indexChangesSideEffectResult = handleIndexChangesDuringFieldUpdate({ fromFlatFieldMetadata, toFlatFieldMetadata, flatIndexMaps, flatObjectMetadataMaps, flatFieldMetadataMaps, + workspaceCustomApplicationId, }); + if (indexChangesSideEffectResult.status === 'fail') { + return indexChangesSideEffectResult; + } + + const { + flatIndexMetadatasToUpdate, + flatIndexMetadatasToCreate, + flatIndexMetadatasToDelete, + } = indexChangesSideEffectResult.result; + sideEffectResult.flatIndexMetadatasToUpdate.push( ...flatIndexMetadatasToUpdate, ); @@ -136,5 +147,8 @@ export const handleFlatFieldMetadataUpdateSideEffect = ({ ...flatIndexMetadatasToDelete, ); - return sideEffectResult; + return { + status: 'success', + result: sideEffectResult, + }; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/handle-index-changes-during-field-update.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/handle-index-changes-during-field-update.util.ts index 234d062c85..5df63d8b39 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/handle-index-changes-during-field-update.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/handle-index-changes-during-field-update.util.ts @@ -1,7 +1,11 @@ +import { msg } from '@lingui/core/macro'; import { type FromTo } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { FieldMetadataExceptionCode } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-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 { type FieldInputTranspilationResult } from 'src/engine/metadata-modules/flat-field-metadata/types/field-input-transpilation-result.type'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { findFieldRelatedIndexes } from 'src/engine/metadata-modules/flat-field-metadata/utils/find-field-related-index.util'; import { generateIndexForFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/generate-index-for-flat-field-metadata.util'; @@ -23,7 +27,9 @@ type FromToFlatFieldMetadataAndFlatEntityMaps = FromTo< Pick< AllFlatEntityMaps, 'flatObjectMetadataMaps' | 'flatFieldMetadataMaps' | 'flatIndexMaps' - >; + > & { + workspaceCustomApplicationId: string; + }; const FIELD_METADATA_UPDATE_INDEX_SIDE_EFFECT: FieldMetadataUpdateIndexSideEffect = { flatIndexMetadatasToUpdate: [], @@ -37,14 +43,18 @@ export const handleIndexChangesDuringFieldUpdate = ({ flatIndexMaps, flatObjectMetadataMaps, flatFieldMetadataMaps, -}: FromToFlatFieldMetadataAndFlatEntityMaps): FieldMetadataUpdateIndexSideEffect => { + workspaceCustomApplicationId, +}: FromToFlatFieldMetadataAndFlatEntityMaps): FieldInputTranspilationResult => { if ( !hasIndexRelevantChanges({ fromFlatFieldMetadata, toFlatFieldMetadata, }) ) { - return FIELD_METADATA_UPDATE_INDEX_SIDE_EFFECT; + return { + status: 'success', + result: FIELD_METADATA_UPDATE_INDEX_SIDE_EFFECT, + }; } const flatObjectMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({ @@ -71,6 +81,7 @@ export const handleIndexChangesDuringFieldUpdate = ({ relatedIndexes, flatObjectMetadata, flatFieldMetadataMaps, + workspaceCustomApplicationId, }); }; @@ -87,9 +98,12 @@ const handleNoExistingIndexes = ({ }: { toFlatFieldMetadata: FlatFieldMetadata; flatObjectMetadata: FlatObjectMetadata; -}): FieldMetadataUpdateIndexSideEffect => { +}): FieldInputTranspilationResult => { if (!toFlatFieldMetadata.isUnique) { - return FIELD_METADATA_UPDATE_INDEX_SIDE_EFFECT; + return { + status: 'success', + result: FIELD_METADATA_UPDATE_INDEX_SIDE_EFFECT, + }; } const newIndex = generateIndexForFlatFieldMetadata({ @@ -99,8 +113,11 @@ const handleNoExistingIndexes = ({ }); return { - ...FIELD_METADATA_UPDATE_INDEX_SIDE_EFFECT, - flatIndexMetadatasToCreate: [newIndex], + status: 'success', + result: { + ...FIELD_METADATA_UPDATE_INDEX_SIDE_EFFECT, + flatIndexMetadatasToCreate: [newIndex], + }, }; }; @@ -110,14 +127,14 @@ const handleExistingIndexes = ({ relatedIndexes, flatObjectMetadata, flatFieldMetadataMaps, + workspaceCustomApplicationId, }: { relatedIndexes: FlatIndexMetadata[]; flatObjectMetadata: FlatObjectMetadata; flatFieldMetadataMaps: AllFlatEntityMaps['flatFieldMetadataMaps']; -} & FromTo< - FlatFieldMetadata, - 'flatFieldMetadata' ->): FieldMetadataUpdateIndexSideEffect => { +} & FromTo & { + workspaceCustomApplicationId: string; + }): FieldInputTranspilationResult => { if ( toFlatFieldMetadata.isUnique === false && !isMorphOrRelationFlatFieldMetadata(fromFlatFieldMetadata) @@ -135,11 +152,33 @@ const handleExistingIndexes = ({ (index) => index.name === expectedUniqueIndex.name, ); + if ( + isDefined(uniqueIndexToDelete) && + ((isDefined(uniqueIndexToDelete.applicationId) && + uniqueIndexToDelete.applicationId !== workspaceCustomApplicationId) || + !uniqueIndexToDelete.isCustom) + ) { + return { + status: 'fail', + errors: [ + { + code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT, + message: + 'Cannot delete unique index that have not been created by the workspace custom application', + userFriendlyMessage: msg`Cannot delete unique index that have not been created by the workspace custom application`, + }, + ], + }; + } + return { - ...FIELD_METADATA_UPDATE_INDEX_SIDE_EFFECT, - flatIndexMetadatasToDelete: uniqueIndexToDelete - ? [uniqueIndexToDelete] - : [], + status: 'success', + result: { + ...FIELD_METADATA_UPDATE_INDEX_SIDE_EFFECT, + flatIndexMetadatasToDelete: uniqueIndexToDelete + ? [uniqueIndexToDelete] + : [], + }, }; } const updatedIndexes = recomputeIndexOnFlatFieldMetadataNameUpdate({ @@ -154,7 +193,10 @@ const handleExistingIndexes = ({ }); return { - ...FIELD_METADATA_UPDATE_INDEX_SIDE_EFFECT, - flatIndexMetadatasToUpdate: updatedIndexes, + status: 'success', + result: { + ...FIELD_METADATA_UPDATE_INDEX_SIDE_EFFECT, + flatIndexMetadatasToUpdate: updatedIndexes, + }, }; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/throw-on-field-input-transpilations-error.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/throw-on-field-input-transpilations-error.util.ts index b7840bd942..35decdfa6b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/throw-on-field-input-transpilations-error.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/throw-on-field-input-transpilations-error.util.ts @@ -17,7 +17,7 @@ export const throwOnFieldInputTranspilationsError: ThrowOnFieldInputTranspilatio ) => { const failedInputTranspilationErrors = inputTranspilationResults.flatMap( (transpilationResult) => - transpilationResult.status === 'fail' ? transpilationResult.error : [], + transpilationResult.status === 'fail' ? transpilationResult.errors : [], ); if (failedInputTranspilationErrors.length > 0) { diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-creation-payload.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-creation-payload.util.ts index 9f9b806825..add7c5a056 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-creation-payload.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-creation-payload.util.ts @@ -32,11 +32,13 @@ export const validateMorphRelationCreationPayload = async ({ if (morphRelationCreationPayload.length === 0) { return { status: 'fail', - error: { - code: FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED, - message: 'Morph relation creation payloads are empty', - userFriendlyMessage: msg`At least one relation is require`, - }, + errors: [ + { + code: FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED, + message: 'Morph relation creation payloads are empty', + userFriendlyMessage: msg`At least one relation is require`, + }, + ], }; } @@ -51,12 +53,14 @@ export const validateMorphRelationCreationPayload = async ({ if (allRelationType.length > 1) { return { status: 'fail', - error: { - code: FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED, - message: - 'Morph relation creation payloads must have the same relation type', - userFriendlyMessage: msg`Morph relation creation payloads must have the same relation type`, - }, + errors: [ + { + code: FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED, + message: + 'Morph relation creation payloads must have the same relation type', + userFriendlyMessage: msg`Morph relation creation payloads must have the same relation type`, + }, + ], }; } @@ -70,12 +74,14 @@ export const validateMorphRelationCreationPayload = async ({ if (allRelatedObjectMetadataIdsSet.includes(objectMetadataId)) { return { status: 'fail', - error: { - code: FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED, - message: - 'Morph relation creation payloads must not target source object metadata', - userFriendlyMessage: msg`Morph relation creation payloads must only contain relation to other object metadata`, - }, + errors: [ + { + code: FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED, + message: + 'Morph relation creation payloads must not target source object metadata', + userFriendlyMessage: msg`Morph relation creation payloads must only contain relation to other object metadata`, + }, + ], }; } @@ -84,12 +90,14 @@ export const validateMorphRelationCreationPayload = async ({ ) { return { status: 'fail', - error: { - code: FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED, - message: - 'Morph relation creation payloads must have only relation to the same object metadata', - userFriendlyMessage: msg`Morph relation creation payloads must only contain relation to the same object metadata`, - }, + errors: [ + { + code: FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED, + message: + 'Morph relation creation payloads must have only relation to the same object metadata', + userFriendlyMessage: msg`Morph relation creation payloads must only contain relation to the same object metadata`, + }, + ], }; } @@ -127,14 +135,18 @@ export const validateMorphRelationCreationPayload = async ({ if (relationCreationPayloadReport.failed.length > 0) { return { status: 'fail', - error: { - code: FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED, - message: 'Morph relation input transpilation failed', - userFriendlyMessage: msg`Invalid morph relation input`, - value: relationCreationPayloadReport.failed - .map((failedTranspilation) => failedTranspilation.error.value) - .filter(isDefined), - }, + errors: [ + { + code: FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED, + message: 'Morph relation input transpilation failed', + userFriendlyMessage: msg`Invalid morph relation input`, + value: relationCreationPayloadReport.failed + .flatMap((failedTranspilation) => + failedTranspilation.errors.map((error) => error.value), + ) + .filter(isDefined), + }, + ], }; } diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-relation-creation-payload.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-relation-creation-payload.util.ts index f041ef6860..f9578b0664 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-relation-creation-payload.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-relation-creation-payload.util.ts @@ -39,12 +39,14 @@ export const validateRelationCreationPayload = async ({ if (error instanceof FieldMetadataException) { return { status: 'fail', - error: { - code: FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED, - message: `Relation creation payload is invalid`, - userFriendlyMessage: msg`Invalid relation creation payload`, - value: relationCreationPayload, - }, + errors: [ + { + code: FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED, + message: `Relation creation payload is invalid`, + userFriendlyMessage: msg`Invalid relation creation payload`, + value: relationCreationPayload, + }, + ], }; } else { throw error; @@ -59,12 +61,14 @@ export const validateRelationCreationPayload = async ({ if (!isDefined(targetFlatObjectMetadata)) { return { status: 'fail', - error: { - code: FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED, - message: `Object metadata relation target not found for relation creation payload`, - userFriendlyMessage: msg`Object targeted by field to create not found`, - value: relationCreationPayload, - }, + errors: [ + { + code: FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED, + message: `Object metadata relation target not found for relation creation payload`, + userFriendlyMessage: msg`Object targeted by field to create not found`, + value: relationCreationPayload, + }, + ], }; } diff --git a/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-field-index.decorator.ts b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-field-index.decorator.ts index 0aa6a57281..f656288fb0 100644 --- a/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-field-index.decorator.ts +++ b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-field-index.decorator.ts @@ -1,4 +1,4 @@ -import { generateDeterministicIndexName } from 'src/engine/metadata-modules/index-metadata/utils/generate-deterministic-index-name'; +import { generateDeterministicIndexNameV2 } from 'src/engine/metadata-modules/index-metadata/utils/generate-deterministic-index-name-v2'; import { type WorkspaceIndexOptions } from 'src/engine/twenty-orm/decorators/workspace-index.decorator'; import { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args.storage'; import { getColumnsForIndex } from 'src/engine/twenty-orm/utils/get-default-columns-for-index.util'; @@ -30,10 +30,18 @@ export function WorkspaceFieldIndex( ]; metadataArgsStorage.addIndexes({ - name: `IDX_${generateDeterministicIndexName([ - convertClassNameToObjectMetadataName(target.constructor.name), - ...columns, - ])}`, + name: generateDeterministicIndexNameV2({ + flatObjectMetadata: { + nameSingular: convertClassNameToObjectMetadataName( + target.constructor.name, + ), + isCustom: false, + }, + relatedFieldNames: columns.map((column) => ({ + name: column, + })), + isUnique: options?.isUnique ?? false, + }), columns, target: target.constructor, gate, diff --git a/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-index.decorator.ts b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-index.decorator.ts index 47c2944bf2..5a68af3177 100644 --- a/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-index.decorator.ts +++ b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-index.decorator.ts @@ -1,5 +1,5 @@ import { type IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types'; -import { generateDeterministicIndexName } from 'src/engine/metadata-modules/index-metadata/utils/generate-deterministic-index-name'; +import { generateDeterministicIndexNameV2 } from 'src/engine/metadata-modules/index-metadata/utils/generate-deterministic-index-name-v2'; import { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args.storage'; import { convertClassNameToObjectMetadataName } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/convert-class-to-object-metadata-name.util'; import { TypedReflect } from 'src/utils/typed-reflect'; @@ -26,12 +26,18 @@ export function WorkspaceIndex( ); metadataArgsStorage.addIndexes({ - name: `IDX_${ - options?.isUnique ? 'UNIQUE_' : '' - }${generateDeterministicIndexName([ - convertClassNameToObjectMetadataName(target.name), - ...columns, - ])}`, + name: generateDeterministicIndexNameV2({ + flatObjectMetadata: { + nameSingular: convertClassNameToObjectMetadataName( + target.constructor.name, + ), + isCustom: false, + }, + relatedFieldNames: columns.map((column) => ({ + name: column, + })), + isUnique: options?.isUnique ?? false, + }), columns, target: target, gate, diff --git a/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-is-unique.decorator.ts b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-is-unique.decorator.ts index adaa1e87ba..98b0d3186a 100644 --- a/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-is-unique.decorator.ts +++ b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-is-unique.decorator.ts @@ -1,4 +1,4 @@ -import { generateDeterministicIndexName } from 'src/engine/metadata-modules/index-metadata/utils/generate-deterministic-index-name'; +import { generateDeterministicIndexNameV2 } from 'src/engine/metadata-modules/index-metadata/utils/generate-deterministic-index-name-v2'; import { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args.storage'; import { convertClassNameToObjectMetadataName } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/convert-class-to-object-metadata-name.util'; import { TypedReflect } from 'src/utils/typed-reflect'; @@ -19,10 +19,18 @@ export function WorkspaceIsUnique(): PropertyDecorator { const columns = [propertyKey.toString()]; metadataArgsStorage.addIndexes({ - name: `IDX_${generateDeterministicIndexName([ - convertClassNameToObjectMetadataName(target.constructor.name), - ...columns, - ])}`, + name: generateDeterministicIndexNameV2({ + flatObjectMetadata: { + nameSingular: convertClassNameToObjectMetadataName( + target.constructor.name, + ), + isCustom: false, + }, + relatedFieldNames: columns.map((column) => ({ + name: column, + })), + isUnique: true, + }), columns, target: target.constructor, gate, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/factories/standard-index.factory.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/factories/standard-index.factory.ts index 89018a67f4..5d826af3cb 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/factories/standard-index.factory.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/factories/standard-index.factory.ts @@ -5,12 +5,11 @@ import { type WorkspaceSyncContext } from 'src/engine/workspace-manager/workspac import { type IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types'; -import { generateDeterministicIndexName } from 'src/engine/metadata-modules/index-metadata/utils/generate-deterministic-index-name'; +import { generateDeterministicIndexNameV2 } from 'src/engine/metadata-modules/index-metadata/utils/generate-deterministic-index-name-v2'; import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { type BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; import { CustomWorkspaceEntity } from 'src/engine/twenty-orm/custom.workspace-entity'; import { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args.storage'; -import { computeTableName } from 'src/engine/utils/compute-table-name.util'; import { isGatedAndNotEnabled } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/is-gate-and-not-enabled.util'; @Injectable() @@ -124,7 +123,18 @@ export class StandardIndexFactory { const indexMetadata: PartialIndexMetadata = { workspaceId: context.workspaceId, objectMetadataId: customObjectMetadata.id, - name: `IDX_${generateDeterministicIndexName([computeTableName(customObjectName, true), ...workspaceIndexMetadataArgs.columns])}`, + name: generateDeterministicIndexNameV2({ + flatObjectMetadata: { + nameSingular: customObjectName, + isCustom: true, + }, + relatedFieldNames: workspaceIndexMetadataArgs.columns.map( + (column) => ({ + name: column, + }), + ), + isUnique: workspaceIndexMetadataArgs.isUnique, + }), columns: workspaceIndexMetadataArgs.columns, isCustom: false, isUnique: workspaceIndexMetadataArgs.isUnique, diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/failing-update-one-standard-field-metadata.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/failing-update-one-standard-field-metadata.integration-spec.ts.snap index 2ef96b9dd6..a7534c3faf 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/failing-update-one-standard-field-metadata.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/failing-update-one-standard-field-metadata.integration-spec.ts.snap @@ -35,3 +35,66 @@ exports[`Standard field metadata update should be ignored when trying to update "name": "ForbiddenError", } `; + +exports[`Standard field with standard unique index update should fail on isUnique change should fail when trying to remove unique constraint on standard field with standard index 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "agent": [], + "cronTrigger": [], + "databaseEventTrigger": [], + "fieldMetadata": [ + { + "errors": [ + { + "code": "INVALID_FIELD_INPUT", + "message": "Cannot delete unique index that have not been created by the workspace custom application", + "userFriendlyMessage": "Cannot delete unique index that have not been created by the workspace custom application", + }, + ], + "flatEntityMinimalInformation": {}, + "type": "update_field", + }, + ], + "index": [], + "objectMetadata": [], + "pageLayout": [], + "pageLayoutTab": [], + "pageLayoutWidget": [], + "role": [], + "roleTarget": [], + "routeTrigger": [], + "serverlessFunction": [], + "view": [], + "viewField": [], + "viewFilter": [], + "viewGroup": [], + }, + "message": "Validation failed for 0 object(s) and 0 field(s)", + "summary": { + "invalidAgent": 0, + "invalidCronTrigger": 0, + "invalidDatabaseEventTrigger": 0, + "invalidFieldMetadata": 0, + "invalidIndex": 0, + "invalidObjectMetadata": 0, + "invalidPageLayout": 0, + "invalidPageLayoutTab": 0, + "invalidPageLayoutWidget": 0, + "invalidRole": 0, + "invalidRoleTarget": 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": "Validation errors occurred while updating field", + "name": "GraphQLError", +} +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/failing-update-one-standard-field-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/failing-update-one-standard-field-metadata.integration-spec.ts index 8d9fa1d454..2c541af091 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/failing-update-one-standard-field-metadata.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/failing-update-one-standard-field-metadata.integration-spec.ts @@ -103,3 +103,60 @@ describe('Standard field metadata update should be ignored', () => { }, ); }); + +// TODO: Enable this test once isUnique set as editable on standard fields +xdescribe('Standard field with standard unique index update should fail on isUnique change', () => { + let companyDomainNameFieldMetadataId: string; + + beforeAll(async () => { + const { objects } = await findManyObjectMetadata({ + expectToFail: false, + input: { + filter: {}, + paging: { first: 100 }, + }, + gqlFields: ` + id + nameSingular + fieldsList { + id + name + label + isCustom + isUnique + } + `, + }); + + const companyObject = objects.find((o) => o.nameSingular === 'company'); + + jestExpectToBeDefined(companyObject); + + const companyDomainNameField = companyObject.fieldsList?.find( + (field) => field.name === 'domainName' && !field.isCustom, + ); + + jestExpectToBeDefined(companyDomainNameField); + companyDomainNameFieldMetadataId = companyDomainNameField.id; + }); + + it('should fail when trying to remove unique constraint on standard field with standard index', async () => { + const { errors } = await updateOneFieldMetadata({ + input: { + idToUpdate: companyDomainNameFieldMetadataId, + updatePayload: { + isUnique: false, + }, + }, + gqlFields: ` + id + name + isUnique + `, + }); + + expectOneNotInternalServerErrorSnapshot({ + errors, + }); + }); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/successful-update-one-standard-field-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/successful-update-one-standard-field-metadata.integration-spec.ts index 70828c32c0..f523460525 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/successful-update-one-standard-field-metadata.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/successful-update-one-standard-field-metadata.integration-spec.ts @@ -1,11 +1,16 @@ 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 { findManyObjectMetadataWithIndexes } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata-with-indexes.util'; import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util'; +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 { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test'; import { eachTestingContextFilter, type EachTestingContext, } from 'twenty-shared/testing'; +import { isDefined } from 'twenty-shared/utils'; import { type FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto'; import { type UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input'; @@ -253,3 +258,125 @@ describe('Standard field metadata update should succeed', () => { }, ); }); + +// TODO: Enable this test once isUnique set as editable on standard fields +xdescribe('Standard field isUnique update should succeed', () => { + let nameFieldMetadata: FieldMetadataDTO | undefined; + let customObjectId: string; + + beforeEach(async () => { + const customObject = { + labelSingular: `Test Unique Standard Field`, + labelPlural: `Test Unique Standard Fields`, + namePlural: `testUniqueStandardField`, + nameSingular: `testUniqueStandardFields`, + description: 'Test unique standard field for isUnique update', + icon: 'IconBox', + isLabelSyncedWithName: false, + }; + + const { data } = await createOneObjectMetadata({ + expectToFail: false, + input: customObject, + gqlFields: ` + id + nameSingular + `, + }); + + customObjectId = data.createOneObject.id; + + const { objects } = await findManyObjectMetadata({ + expectToFail: false, + input: { + filter: {}, + paging: { first: 100 }, + }, + gqlFields: ` + id + nameSingular + fieldsList { + id + name + label + isUnique + isActive + isCustom + type + } + `, + }); + + const customObjectWithFields = objects.find((o) => o.id === customObjectId); + + jestExpectToBeDefined(customObjectWithFields?.fieldsList); + + nameFieldMetadata = customObjectWithFields.fieldsList?.find( + (field) => field.name === 'name', + ); + }); + + afterEach(async () => { + await updateOneObjectMetadata({ + expectToFail: false, + input: { + idToUpdate: customObjectId, + updatePayload: { + isActive: false, + }, + }, + }); + + await deleteOneObjectMetadata({ + input: { + idToDelete: customObjectId, + }, + }); + }); + + it('should set isUnique to true on standard field', async () => { + jestExpectToBeDefined(nameFieldMetadata); + + const { data } = await updateOneFieldMetadata({ + input: { + idToUpdate: nameFieldMetadata.id, + updatePayload: { + isUnique: true, + }, + }, + expectToFail: false, + gqlFields: ` + id + name + label + isUnique + isActive + isCustom + `, + }); + + expect(data.updateOneField.id).toBe(nameFieldMetadata.id); + expect(data.updateOneField.name).toBe('name'); + expect(data.updateOneField.isUnique).toBe(true); + + const objects = await findManyObjectMetadataWithIndexes({ + expectToFail: false, + }); + + const customObject = objects.find((obj) => obj.id === customObjectId); + + jestExpectToBeDefined(customObject); + + const nameFieldIndex = customObject.indexMetadataList.find((index) => + index.indexFieldMetadataList.some( + (indexField) => + isDefined(nameFieldMetadata) && + indexField.fieldMetadataId === nameFieldMetadata.id, + ), + ); + + jestExpectToBeDefined(nameFieldIndex); + expect(nameFieldIndex.isUnique).toBe(true); + expect(nameFieldIndex.isCustom).toBe(true); + }); +});