Fix unique standard field (#16371)

Fixes https://github.com/twentyhq/twenty/issues/15925

- update field metadata update logic
- uniformize the way index are named
- command to migrate v1-named unique index
- add integration testing

---------

Co-authored-by: prastoin <paul@twenty.com>
This commit is contained in:
Etienne
2025-12-08 19:05:28 +01:00
committed by GitHub
parent 950f452fef
commit 07cfaa78ef
23 changed files with 811 additions and 269 deletions
+1 -1
View File
@@ -67,7 +67,7 @@
"--config",
"./jest-integration.config.ts",
"${relativeFile}",
"--silent=false"
"--silent=false",
],
"cwd": "${workspaceFolder}/packages/twenty-server",
"console": "integratedTerminal",
@@ -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<WorkspaceEntity>,
protected readonly twentyORMGlobalManager: TwentyORMGlobalManager,
@InjectRepository(IndexMetadataEntity)
private readonly indexMetadataRepository: Repository<IndexMetadataEntity>,
protected readonly dataSourceService: DataSourceService,
protected readonly workspaceCacheService: WorkspaceCacheService,
) {
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
options,
dataSource,
}: RunOnWorkspaceArgs): Promise<void> {
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<void> {
await dataSource.query(
`ALTER INDEX "${schemaName}"."${oldIndexName}" RENAME TO "${newIndexName}"`,
[],
undefined,
{ shouldBypassPermissionChecks: true },
);
}
}
@@ -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,
],
})
@@ -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<FieldMetadataEntit
});
if (inputTranspilationResult.status === 'fail') {
throw new FieldMetadataException(
inputTranspilationResult.error.message,
inputTranspilationResult.error.code,
throw new WorkspaceMigrationBuilderExceptionV2(
{
userFriendlyMessage:
inputTranspilationResult.error.userFriendlyMessage,
report: {
...EMPTY_ORCHESTRATOR_FAILURE_REPORT(),
fieldMetadata: [
{
errors: inputTranspilationResult.errors,
type: 'update_field',
flatEntityMinimalInformation: {},
},
],
},
status: 'fail',
},
'Validation errors occurred while updating field',
);
}
@@ -2,99 +2,111 @@
exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test suite Failure cases should fail when morphRelationsCreationPayload has different relation types 1`] = `
{
"error": {
"code": "FIELD_METADATA_RELATION_MALFORMED",
"message": "Morph relation creation payloads must have the same relation type",
"userFriendlyMessage": {
"id": Any<String>,
"errors": [
{
"code": "FIELD_METADATA_RELATION_MALFORMED",
"message": "Morph relation creation payloads must have the same relation type",
"userFriendlyMessage": {
"id": Any<String>,
"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<String>,
"message": "Invalid morph relation input",
},
"value": [
{
"targetObjectMetadataId": Any<String>,
"type": "ONE_TO_MANY",
"errors": [
{
"code": "FIELD_METADATA_RELATION_MALFORMED",
"message": "Morph relation input transpilation failed",
"userFriendlyMessage": {
"id": Any<String>,
"message": "Invalid morph relation input",
},
],
},
"value": [
{
"targetObjectMetadataId": Any<String>,
"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<String>,
"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<String>,
"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<String>,
"message": "At least one relation is require",
"errors": [
{
"code": "FIELD_METADATA_RELATION_MALFORMED",
"message": "Morph relation creation payloads are empty",
"userFriendlyMessage": {
"id": Any<String>,
"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<String>,
"errors": [
{
"code": "INVALID_FIELD_INPUT",
"message": "Relation creation payload is required",
"userFriendlyMessage": {
"id": Any<String>,
"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<String>,
"message": "Invalid morph relation input",
},
"value": [
{
"targetFieldIcon": "IconPet",
"targetFieldLabel": "Pet",
"targetObjectMetadataId": Any<String>,
"type": "ONE_TO_MANY",
"errors": [
{
"code": "FIELD_METADATA_RELATION_MALFORMED",
"message": "Morph relation input transpilation failed",
"userFriendlyMessage": {
"id": Any<String>,
"message": "Invalid morph relation input",
},
],
},
"value": [
{
"targetFieldIcon": "IconPet",
"targetFieldLabel": "Pet",
"targetObjectMetadataId": Any<String>,
"type": "ONE_TO_MANY",
},
],
},
],
"status": "fail",
}
`;
@@ -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)[]>;
@@ -2,7 +2,7 @@ import { type FlatFieldMetadataValidationError } from 'src/engine/metadata-modul
export type FailedFieldInputTranspilation = {
status: 'fail';
error: FlatFieldMetadataValidationError;
errors: FlatFieldMetadataValidationError[];
};
export type SuccessfulFieldInputTranspilation<T> = {
status: 'success';
@@ -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:
@@ -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,
},
],
};
}
@@ -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,
},
],
};
}
@@ -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<FlatFieldMetadataAndIndexToUpdate>(
(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,
};
};
@@ -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<FlatFieldMetadataUpdateSideEffects> => {
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,
};
};
@@ -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<FieldMetadataUpdateIndexSideEffect> => {
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<FieldMetadataUpdateIndexSideEffect> => {
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<FlatFieldMetadata, 'flatFieldMetadata'> & {
workspaceCustomApplicationId: string;
}): FieldInputTranspilationResult<FieldMetadataUpdateIndexSideEffect> => {
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,
},
};
};
@@ -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) {
@@ -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),
},
],
};
}
@@ -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,
},
],
};
}
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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",
}
`;
@@ -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,
});
});
});
@@ -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);
});
});