Unique field - add unique property creation/deletion on field (#13539)

Done : 
- add isUnique prop availability on gql update/create fieldMetadata
resolvers
- add unique index creation logic at update & creation
- add unique index deletion logic at update
- update unique index if field name updated
- edge cases : can't have default value and unique fields / standard
default value excluded from index (where clause) / can't have composite
unique fields / can't create unique fields on MORPH


closes https://github.com/twentyhq/core-team-issues/issues/1222
This commit is contained in:
Etienne
2025-08-08 17:42:50 +02:00
committed by GitHub
parent 415f57ffa5
commit 7ce96049b8
18 changed files with 1485 additions and 53 deletions
@@ -25,6 +25,7 @@ import { FieldMetadataServiceV2 } from 'src/engine/metadata-modules/field-metada
import { IsFieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/validators/is-field-metadata-default-value.validator';
import { IsFieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/validators/is-field-metadata-options.validator';
import { FlatFieldMetadataModule } from 'src/engine/metadata-modules/flat-field-metadata/flat-field-metadata.module';
import { IndexMetadataModule } from 'src/engine/metadata-modules/index-metadata/index-metadata.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@@ -66,6 +67,7 @@ import { FieldMetadataService } from './services/field-metadata.service';
WorkspaceMigrationBuilderV2Module,
WorkspaceMigrationRunnerV2Module,
FlatFieldMetadataModule,
IndexMetadataModule,
],
services: [
IsFieldMetadataDefaultValue,
@@ -46,12 +46,17 @@ import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-
import { isFieldMetadataTypeMorphRelation } from 'src/engine/metadata-modules/field-metadata/utils/is-field-metadata-type-morph-relation.util';
import { isFieldMetadataTypeRelation } from 'src/engine/metadata-modules/field-metadata/utils/is-field-metadata-type-relation.util';
import { isSelectOrMultiSelectFieldMetadata } from 'src/engine/metadata-modules/field-metadata/utils/is-select-or-multi-select-field-metadata.util';
import { isValidUniqueFieldDefaultValueCombination } from 'src/engine/metadata-modules/field-metadata/utils/is-valid-unique-input.util';
import { prepareCustomFieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/utils/prepare-custom-field-metadata-for-options.util';
import { prepareCustomFieldMetadataForCreation } from 'src/engine/metadata-modules/field-metadata/utils/prepare-field-metadata-for-creation.util';
import { IndexMetadataService } from 'src/engine/metadata-modules/index-metadata/index-metadata.service';
import { computeUniqueIndexWhereClause } from 'src/engine/metadata-modules/index-metadata/utils/compute-unique-index-where-clause.util';
import { validateCanCreateUniqueIndex } from 'src/engine/metadata-modules/index-metadata/utils/validate-can-create-unique-index.util';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { getObjectMetadataEntityFromObjectMetadataItemWithFieldsMap } from 'src/engine/metadata-modules/utils/get-object-metadata-entity-from-object-metadata-item-with-fields-map.util';
import { validateNameAndLabelAreSyncOrThrow } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util';
import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service';
import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service';
@@ -97,6 +102,7 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
private readonly fieldMetadataMorphRelationService: FieldMetadataMorphRelationService,
private readonly fieldMetadataRelationService: FieldMetadataRelationService,
private readonly fieldMetadataServiceV2: FieldMetadataServiceV2,
private readonly indexMetadataService: IndexMetadataService,
) {
super(fieldMetadataRepository);
}
@@ -168,6 +174,23 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
);
}
if (
!isValidUniqueFieldDefaultValueCombination({
defaultValue: isDefined(fieldMetadataInput.defaultValue)
? fieldMetadataInput.defaultValue
: existingFieldMetadata.defaultValue,
isUnique: isDefined(fieldMetadataInput.isUnique)
? fieldMetadataInput.isUnique
: (existingFieldMetadata.isUnique ?? false),
type: existingFieldMetadata.type,
})
) {
throw new FieldMetadataException(
'Unique field cannot have a default value',
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
const queryRunner = this.coreDataSource.createQueryRunner();
await queryRunner.connect();
@@ -257,10 +280,60 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
);
}
const workspaceMigrationsOnCustomUniqueIndex: WorkspaceMigrationTableAction[] =
[];
const shouldUpdateUniqueIndex =
isDefined(fieldMetadataInput.name) &&
fieldMetadataInput.name !== existingFieldMetadata.name &&
existingFieldMetadata.isUnique === true;
if (shouldUpdateUniqueIndex) {
workspaceMigrationsOnCustomUniqueIndex.push(
await this.updateUniqueIndexMetdataAndCreateMigrationActions({
fieldMetadataInput,
objectMetadataItemWithFieldMaps,
updatedFieldMetadata,
queryRunner,
}),
);
}
const shouldCreateUniqueIndex =
fieldMetadataInput.isUnique === true && !existingFieldMetadata.isUnique;
if (shouldCreateUniqueIndex) {
workspaceMigrationsOnCustomUniqueIndex.push(
await this.createUniqueIndexMetadataAndCreateMigrationActions({
fieldMetadataItem: updatedFieldMetadata,
objectMetadataItemWithFieldMaps,
fieldMetadataInput,
queryRunner,
}),
);
}
const shouldDeleteUniqueIndex =
(fieldMetadataInput.isUnique === null ||
fieldMetadataInput.isUnique === false) &&
existingFieldMetadata.isUnique;
if (shouldDeleteUniqueIndex) {
workspaceMigrationsOnCustomUniqueIndex.push(
await this.deleteUniqueIndexMetadataAndCreateMigrationActions({
fieldMetadataInput,
objectMetadataItemWithFieldMaps,
updatedFieldMetadata,
queryRunner,
}),
);
}
if (
isDefined(fieldMetadataInput.name) ||
isDefined(updatableFieldInput.options) ||
isDefined(updatableFieldInput.defaultValue)
isDefined(updatableFieldInput.defaultValue) ||
isDefined(updatableFieldInput.isUnique)
) {
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(`update-${updatedFieldMetadata.name}`),
@@ -275,6 +348,7 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
updatedFieldMetadata,
),
} satisfies WorkspaceMigrationTableAction,
...workspaceMigrationsOnCustomUniqueIndex,
],
queryRunner,
);
@@ -670,6 +744,30 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
});
migrationActions.push(...fieldMigrationActions);
if (fieldMetadataInput.isUnique) {
if (createdFieldMetadataItems.length > 1) {
throw new FieldMetadataException(
'Unique field cannot bet RELATION or MORPH_RELATION type',
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
const uniqueIndexMigration =
await this.createUniqueIndexForNewField({
createdFieldMetadataItem: createdFieldMetadataItems[0],
objectMetadata,
fieldMetadataInput,
workspaceId,
queryRunner,
});
migrationActions.push(
...(isDefined(uniqueIndexMigration)
? [uniqueIndexMigration]
: []),
);
}
}
}
@@ -710,6 +808,171 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
}
}
private async createUniqueIndexForNewField({
createdFieldMetadataItem,
objectMetadata,
fieldMetadataInput,
workspaceId,
queryRunner,
}: {
createdFieldMetadataItem: FieldMetadataEntity;
objectMetadata: ObjectMetadataItemWithFieldMaps;
fieldMetadataInput: CreateFieldInput;
workspaceId: string;
queryRunner: QueryRunner;
}) {
if (
isDefined(fieldMetadataInput.defaultValue) &&
!isValidUniqueFieldDefaultValueCombination({
defaultValue: fieldMetadataInput.defaultValue,
isUnique: fieldMetadataInput.isUnique ?? false,
type: fieldMetadataInput.type,
})
)
throw new FieldMetadataException(
'Unique field cannot have a default value',
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
if (fieldMetadataInput.isUnique !== true) return;
validateCanCreateUniqueIndex(createdFieldMetadataItem);
await this.indexMetadataService.createIndexMetadata({
workspaceId,
objectMetadata:
getObjectMetadataEntityFromObjectMetadataItemWithFieldsMap(
objectMetadata,
),
fieldMetadataToIndex: [createdFieldMetadataItem],
isUnique: true,
isCustom: true,
indexWhereClause: computeUniqueIndexWhereClause(createdFieldMetadataItem),
queryRunner,
});
return this.indexMetadataService.computeIndexCreationMigration({
objectMetadata:
getObjectMetadataEntityFromObjectMetadataItemWithFieldsMap(
objectMetadata,
),
fieldMetadataToIndex: [createdFieldMetadataItem],
isUnique: true,
indexWhereClause: computeUniqueIndexWhereClause(createdFieldMetadataItem),
});
}
private async createUniqueIndexMetadataAndCreateMigrationActions({
fieldMetadataItem,
objectMetadataItemWithFieldMaps,
fieldMetadataInput,
queryRunner,
}: {
fieldMetadataItem: FieldMetadataEntity;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
fieldMetadataInput: UpdateFieldInput;
queryRunner: QueryRunner;
}) {
validateCanCreateUniqueIndex(fieldMetadataItem);
await this.indexMetadataService.createIndexMetadata({
workspaceId: fieldMetadataInput.workspaceId,
objectMetadata:
getObjectMetadataEntityFromObjectMetadataItemWithFieldsMap(
objectMetadataItemWithFieldMaps,
),
fieldMetadataToIndex: [fieldMetadataItem],
isUnique: true,
isCustom: true,
indexWhereClause: computeUniqueIndexWhereClause(fieldMetadataItem),
queryRunner,
});
return this.indexMetadataService.computeIndexCreationMigration({
objectMetadata:
getObjectMetadataEntityFromObjectMetadataItemWithFieldsMap(
objectMetadataItemWithFieldMaps,
),
fieldMetadataToIndex: [fieldMetadataItem],
isUnique: true,
indexWhereClause: computeUniqueIndexWhereClause(fieldMetadataItem),
});
}
private async updateUniqueIndexMetdataAndCreateMigrationActions({
fieldMetadataInput,
objectMetadataItemWithFieldMaps,
updatedFieldMetadata,
queryRunner,
}: {
fieldMetadataInput: UpdateFieldInput;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
updatedFieldMetadata: FieldMetadataEntity;
queryRunner: QueryRunner;
}) {
const recomputedIndexPayload =
await this.indexMetadataService.recomputeUniqueCustomIndexMetadataForField(
{
workspaceId: fieldMetadataInput.workspaceId,
objectMetadata:
getObjectMetadataEntityFromObjectMetadataItemWithFieldsMap(
objectMetadataItemWithFieldMaps,
),
updatedFieldMetadata: updatedFieldMetadata,
queryRunner,
},
);
if (!isDefined(recomputedIndexPayload)) {
throw new FieldMetadataException(
'Unique index not found for unique field',
FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR,
);
}
const { updatedIndex, previousName } = recomputedIndexPayload;
return this.indexMetadataService.createIndexRecomputeMigrationActions(
objectMetadataItemWithFieldMaps,
{
indexMetadata: updatedIndex,
previousName,
newName: updatedIndex.name,
},
);
}
private async deleteUniqueIndexMetadataAndCreateMigrationActions({
fieldMetadataInput,
objectMetadataItemWithFieldMaps,
updatedFieldMetadata,
queryRunner,
}: {
fieldMetadataInput: UpdateFieldInput;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
updatedFieldMetadata: FieldMetadataEntity;
queryRunner: QueryRunner;
}) {
await this.indexMetadataService.deleteIndexMetadata({
workspaceId: fieldMetadataInput.workspaceId,
objectMetadata:
getObjectMetadataEntityFromObjectMetadataItemWithFieldsMap(
objectMetadataItemWithFieldMaps,
),
fieldMetadataToIndex: [updatedFieldMetadata],
queryRunner,
});
return this.indexMetadataService.computeIndexDeletionMigration({
objectMetadata:
getObjectMetadataEntityFromObjectMetadataItemWithFieldsMap(
objectMetadataItemWithFieldMaps,
),
fieldMetadataToIndex: [updatedFieldMetadata],
isUnique: fieldMetadataInput.isUnique ?? false,
});
}
private async validateAndCreateFieldMetadataItems(
fieldMetadataInput: CreateFieldInput,
objectMetadata: ObjectMetadataItemWithFieldMaps,
@@ -0,0 +1,35 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { isValidUniqueFieldDefaultValueCombination } from 'src/engine/metadata-modules/field-metadata/utils/is-valid-unique-input.util';
describe('isValidUniqueFieldDefaultValueCombination', () => {
it('should return true if the field has a custom default value and is not unique', () => {
const result = isValidUniqueFieldDefaultValueCombination({
defaultValue: "'custom value'",
isUnique: false,
type: FieldMetadataType.TEXT,
});
expect(result).toBe(true);
});
it('should return true if the field has standard default value and is unique', () => {
const result = isValidUniqueFieldDefaultValueCombination({
defaultValue: "''",
isUnique: true,
type: FieldMetadataType.TEXT,
});
expect(result).toBe(true);
});
it('should return false if the field has custom default value and is unique', () => {
const result = isValidUniqueFieldDefaultValueCombination({
defaultValue: "'custom value'",
isUnique: true,
type: FieldMetadataType.TEXT,
});
expect(result).toBe(false);
});
});
@@ -0,0 +1,21 @@
import { isDeepStrictEqual } from 'util';
import { type FieldMetadataType } from 'twenty-shared/types';
import { type FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
import { generateDefaultValue } from 'src/engine/metadata-modules/field-metadata/utils/generate-default-value';
export const isValidUniqueFieldDefaultValueCombination = ({
defaultValue,
isUnique,
type,
}: {
defaultValue: FieldMetadataDefaultValue;
isUnique: boolean;
type: FieldMetadataType;
}) => {
const defaultDefaultValue = generateDefaultValue(type);
return !isUnique || isDeepStrictEqual(defaultValue, defaultDefaultValue);
};
@@ -35,6 +35,7 @@ export const prepareCustomFieldMetadataForCreation = (
fieldMetadataInput?.relationCreationPayload?.targetObjectMetadataId,
defaultValue,
...options,
isUnique: fieldMetadataInput.isUnique ?? false,
isActive: true,
isCustom: true,
settings: fieldMetadataInput.settings,
@@ -0,0 +1,18 @@
import { CustomException } from 'src/utils/custom-exception';
export class IndexMetadataException extends CustomException {
declare code: IndexMetadataExceptionCode;
constructor(
message: string,
code: IndexMetadataExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
) {
super(message, code, { userFriendlyMessage });
}
}
export enum IndexMetadataExceptionCode {
INDEX_CREATION_FAILED = 'INDEX_CREATION_FAILED',
INDEX_NOT_SUPPORTED_FOR_COMPOSITE_FIELD = 'INDEX_NOT_SUPPORTED_FOR_COMPOSITE_FIELD',
INDEX_NOT_SUPPORTED_FOR_MORH_RELATION_FIELD_AND_RELATION_FIELD = 'INDEX_NOT_SUPPORTED_FOR_MORH_RELATION_FIELD_AND_RELATION_FIELD',
}
@@ -2,12 +2,23 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import isEmpty from 'lodash.isempty';
import { type FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type QueryRunner, Repository } from 'typeorm';
import { In, type QueryRunner, Repository } from 'typeorm';
import { type CompositeType } from 'src/engine/metadata-modules/field-metadata/interfaces/composite-type.interface';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import {
IndexMetadataException,
IndexMetadataExceptionCode,
} from 'src/engine/metadata-modules/index-metadata/index-field-metadata.exception';
import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { type IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types';
import { computeUniqueIndexWhereClause } from 'src/engine/metadata-modules/index-metadata/utils/compute-unique-index-where-clause.util';
import { generateDeterministicIndexName } from 'src/engine/metadata-modules/index-metadata/utils/generate-deterministic-index-name';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util';
@@ -73,7 +84,7 @@ export class IndexMetadataService {
},
});
if (existingIndex) {
if (isDefined(existingIndex)) {
throw new Error(
`Index ${indexName} on object metadata ${objectMetadata.nameSingular} already exists`,
);
@@ -91,6 +102,8 @@ export class IndexMetadataService {
workspaceId,
objectMetadataId: objectMetadata.id,
...(isDefined(indexType) ? { indexType } : {}),
...(isDefined(indexWhereClause) ? { indexWhereClause } : {}),
...(isDefined(isUnique) ? { isUnique } : {}),
isCustom,
});
} catch {
@@ -104,6 +117,37 @@ export class IndexMetadataService {
`Failed to return saved index ${indexName} on object metadata ${objectMetadata.nameSingular}`,
);
}
}
async createIndex({
workspaceId,
objectMetadata,
fieldMetadataToIndex,
isUnique,
isCustom,
indexType,
indexWhereClause,
queryRunner,
}: {
workspaceId: string;
objectMetadata: ObjectMetadataEntity;
fieldMetadataToIndex: FieldMetadataEntity[];
isUnique: boolean;
isCustom: boolean;
indexType?: IndexType;
indexWhereClause?: string;
queryRunner?: QueryRunner;
}) {
await this.createIndexMetadata({
workspaceId,
objectMetadata,
fieldMetadataToIndex,
indexType,
indexWhereClause,
isUnique,
isCustom,
queryRunner,
});
await this.createIndexCreationMigration({
workspaceId,
@@ -116,6 +160,51 @@ export class IndexMetadataService {
});
}
async recomputeUniqueCustomIndexMetadataForField({
workspaceId,
objectMetadata,
updatedFieldMetadata,
queryRunner,
}: {
workspaceId: string;
objectMetadata: ObjectMetadataEntity;
updatedFieldMetadata: FieldMetadataEntity;
queryRunner?: QueryRunner;
}) {
const indexMetadataRepository = queryRunner
? queryRunner.manager.getRepository(IndexMetadataEntity)
: this.indexMetadataRepository;
const [index] = await indexMetadataRepository.find({
where: {
objectMetadataId: objectMetadata.id,
workspaceId,
indexFieldMetadatas: {
fieldMetadataId: In([updatedFieldMetadata.id]),
},
isUnique: true,
isCustom: true,
},
relations: ['indexFieldMetadatas.fieldMetadata'],
});
if (!isDefined(index)) return;
const updatedIndex = await indexMetadataRepository.save({
...index,
name: `IDX_${generateDeterministicIndexName([
computeObjectTargetTable(objectMetadata),
updatedFieldMetadata.name,
])}`,
indexWhereClause: computeUniqueIndexWhereClause(updatedFieldMetadata),
});
return {
updatedIndex,
previousName: index.name,
};
}
async recomputeIndexMetadataForObject(
workspaceId: string,
updatedObjectMetadata: Pick<
@@ -173,12 +262,17 @@ export class IndexMetadataService {
return recomputedIndexes;
}
async deleteIndexMetadata(
workspaceId: string,
objectMetadata: ObjectMetadataEntity,
fieldMetadataToIndex: Partial<FieldMetadataEntity>[],
queryRunner?: QueryRunner,
) {
async deleteIndexMetadata({
workspaceId,
objectMetadata,
fieldMetadataToIndex,
queryRunner,
}: {
workspaceId: string;
objectMetadata: ObjectMetadataEntity;
fieldMetadataToIndex: Partial<FieldMetadataEntity>[];
queryRunner?: QueryRunner;
}) {
const tableName = computeObjectTargetTable(objectMetadata);
const columnNames: string[] = fieldMetadataToIndex.map(
@@ -216,22 +310,14 @@ export class IndexMetadataService {
}
}
async createIndexCreationMigration({
workspaceId,
computeIndexDeletionMigration({
objectMetadata,
fieldMetadataToIndex,
isUnique,
indexType,
indexWhereClause,
queryRunner,
}: {
workspaceId: string;
objectMetadata: ObjectMetadataEntity;
fieldMetadataToIndex: Partial<FieldMetadataEntity>[];
isUnique: boolean;
indexType?: IndexType;
indexWhereClause?: string;
queryRunner?: QueryRunner;
}) {
const tableName = computeObjectTargetTable(objectMetadata);
@@ -241,7 +327,69 @@ export class IndexMetadataService {
const indexName = `IDX_${generateDeterministicIndexName([tableName, ...columnNames])}`;
const migration = {
return {
name: tableName,
action: WorkspaceMigrationTableActionType.ALTER_INDEXES,
indexes: [
{
action: WorkspaceMigrationIndexActionType.DROP,
name: indexName,
columns: [],
isUnique,
} satisfies WorkspaceMigrationIndexAction,
],
} satisfies WorkspaceMigrationTableAction;
}
computeIndexCreationMigration({
objectMetadata,
fieldMetadataToIndex,
isUnique,
indexType,
indexWhereClause,
}: {
objectMetadata: ObjectMetadataEntity;
fieldMetadataToIndex: (Partial<FieldMetadataEntity> & {
type: FieldMetadataType;
name: string;
})[];
isUnique: boolean;
indexType?: IndexType;
indexWhereClause?: string;
}) {
const tableName = computeObjectTargetTable(objectMetadata);
const fieldNames: string[] = fieldMetadataToIndex.map(
(fieldMetadata) => fieldMetadata.name as string,
);
const indexName = `IDX_${generateDeterministicIndexName([tableName, ...fieldNames])}`;
const columnNames = fieldMetadataToIndex.flatMap((field) => {
if (isCompositeFieldMetadataType(field.type)) {
if (!isUnique)
throw new IndexMetadataException(
`Non unique index cannot be created for composite field ${field.name}`,
IndexMetadataExceptionCode.INDEX_NOT_SUPPORTED_FOR_COMPOSITE_FIELD,
);
const compositeType = compositeTypeDefinitions.get(
field.type,
) as CompositeType;
const uniqueCompositeProperties = compositeType.properties.filter(
(property) => property.isIncludedInUniqueConstraint,
);
return uniqueCompositeProperties.map((subField) =>
computeCompositeColumnName(field.name, subField),
);
}
return [field.name];
});
return {
name: tableName,
action: WorkspaceMigrationTableActionType.ALTER_INDEXES,
indexes: [
@@ -255,6 +403,32 @@ export class IndexMetadataService {
},
],
} satisfies WorkspaceMigrationTableAction;
}
async createIndexCreationMigration({
workspaceId,
objectMetadata,
fieldMetadataToIndex,
isUnique,
indexType,
indexWhereClause,
queryRunner,
}: {
workspaceId: string;
objectMetadata: ObjectMetadataEntity;
fieldMetadataToIndex: FieldMetadataEntity[];
isUnique: boolean;
indexType?: IndexType;
indexWhereClause?: string;
queryRunner?: QueryRunner;
}) {
const migration = this.computeIndexCreationMigration({
objectMetadata,
fieldMetadataToIndex,
isUnique,
indexType,
indexWhereClause,
});
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(`create-${objectMetadata.nameSingular}-index`),
@@ -264,6 +438,51 @@ export class IndexMetadataService {
);
}
createIndexRecomputeMigrationActions(
objectMetadata: Pick<
ObjectMetadataEntity,
'nameSingular' | 'isCustom' | 'id'
>,
recomputedIndex: {
indexMetadata: IndexMetadataEntity;
previousName: string;
newName: string;
},
) {
const { previousName, newName, indexMetadata } = recomputedIndex;
const tableName = computeObjectTargetTable(objectMetadata);
const indexFieldsMetadataOrdered = indexMetadata.indexFieldMetadatas.sort(
(a, b) => a.order - b.order,
);
const columnNames = indexFieldsMetadataOrdered.map(
(indexFieldMetadata) => indexFieldMetadata.fieldMetadata.name,
);
return {
name: tableName,
action: WorkspaceMigrationTableActionType.ALTER_INDEXES,
indexes: [
{
action: WorkspaceMigrationIndexActionType.DROP,
name: previousName,
columns: [],
isUnique: indexMetadata.isUnique,
} satisfies WorkspaceMigrationIndexAction,
{
action: WorkspaceMigrationIndexActionType.CREATE,
columns: columnNames,
name: newName,
isUnique: indexMetadata.isUnique,
where: indexMetadata.indexWhereClause,
type: indexMetadata.indexType,
} satisfies WorkspaceMigrationIndexAction,
],
} satisfies WorkspaceMigrationTableAction;
}
async createIndexRecomputeMigrations(
workspaceId: string,
objectMetadata: Pick<
@@ -278,39 +497,11 @@ export class IndexMetadataService {
queryRunner?: QueryRunner,
) {
for (const recomputedIndex of recomputedIndexes) {
const { previousName, newName, indexMetadata } = recomputedIndex;
const tableName = computeObjectTargetTable(objectMetadata);
const indexFieldsMetadataOrdered = indexMetadata.indexFieldMetadatas.sort(
(a, b) => a.order - b.order,
const migration = this.createIndexRecomputeMigrationActions(
objectMetadata,
recomputedIndex,
);
const columnNames = indexFieldsMetadataOrdered.map(
(indexFieldMetadata) => indexFieldMetadata.fieldMetadata.name,
);
const migration = {
name: tableName,
action: WorkspaceMigrationTableActionType.ALTER_INDEXES,
indexes: [
{
action: WorkspaceMigrationIndexActionType.DROP,
name: previousName,
columns: [],
isUnique: indexMetadata.isUnique,
} satisfies WorkspaceMigrationIndexAction,
{
action: WorkspaceMigrationIndexActionType.CREATE,
columns: columnNames,
name: newName,
isUnique: indexMetadata.isUnique,
where: indexMetadata.indexWhereClause,
type: indexMetadata.indexType,
} satisfies WorkspaceMigrationIndexAction,
],
} satisfies WorkspaceMigrationTableAction;
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(`update-${objectMetadata.nameSingular}-index`),
workspaceId,
@@ -0,0 +1,45 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { computeUniqueIndexWhereClause } from 'src/engine/metadata-modules/index-metadata/utils/compute-unique-index-where-clause.util';
import { getMockFieldMetadataEntity } from 'src/utils/__test__/get-field-metadata-entity.mock';
describe('computeUniqueIndexWhereClause', () => {
it('should return undefined if standard default value is not defined', () => {
const fieldMetadata = getMockFieldMetadataEntity({
workspaceId: 'workspace-id',
objectMetadataId: 'object-id',
type: FieldMetadataType.UUID,
name: 'testField',
});
const result = computeUniqueIndexWhereClause(fieldMetadata);
expect(result).toBeUndefined();
});
it('should return a where clause for a an atomic type field', () => {
const fieldMetadata = getMockFieldMetadataEntity({
workspaceId: 'workspace-id',
objectMetadataId: 'object-id',
type: FieldMetadataType.TEXT,
name: 'testTextField',
});
const result = computeUniqueIndexWhereClause(fieldMetadata);
expect(result).toBe('"testTextField" != \'\'');
});
it('should return a where clause for a composite type field', () => {
const fieldMetadata = getMockFieldMetadataEntity({
workspaceId: 'workspace-id',
objectMetadataId: 'object-id',
type: FieldMetadataType.EMAILS,
name: 'testEmailsField',
});
const result = computeUniqueIndexWhereClause(fieldMetadata);
expect(result).toBe('"testEmailsFieldPrimaryEmail" != \'\'');
});
});
@@ -0,0 +1,66 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { FieldMetadataException } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { validateCanCreateUniqueIndex } from 'src/engine/metadata-modules/index-metadata/utils/validate-can-create-unique-index.util';
describe('validateCanCreateUniqueIndex', () => {
it('should throw an error if field to create is a MORPH type', () => {
const field = {
name: 'testField',
type: FieldMetadataType.MORPH_RELATION,
isCustom: true,
};
expect(() => validateCanCreateUniqueIndex(field)).toThrow(
FieldMetadataException,
);
expect(() => validateCanCreateUniqueIndex(field)).toThrow(
'Unique index cannot be created for field testField of type MORPH_RELATION',
);
});
it('should throw an error if field to create is a RELATION type - ONE_TO_MANY', () => {
const field = {
name: 'testField',
type: FieldMetadataType.RELATION,
isCustom: true,
};
expect(() => validateCanCreateUniqueIndex(field)).toThrow(
FieldMetadataException,
);
expect(() => validateCanCreateUniqueIndex(field)).toThrow(
'Unique index cannot be created for field testField of type RELATION',
);
});
it('should throw an error if field to create is a FULL_NAME type', () => {
const field = {
name: 'testField',
type: FieldMetadataType.FULL_NAME,
isCustom: true,
};
expect(() => validateCanCreateUniqueIndex(field)).toThrow(
FieldMetadataException,
);
expect(() => validateCanCreateUniqueIndex(field)).toThrow(
'Unique index cannot be created for field testField of type FULL_NAME',
);
});
it('should throw an error if field to create is an ADDRESS type', () => {
const field = {
name: 'testField',
type: FieldMetadataType.ADDRESS,
isCustom: true,
};
expect(() => validateCanCreateUniqueIndex(field)).toThrow(
FieldMetadataException,
);
expect(() => validateCanCreateUniqueIndex(field)).toThrow(
'Unique index cannot be created for field testField of type ADDRESS',
);
});
});
@@ -0,0 +1,77 @@
import { isNonEmptyString } from '@sniptt/guards';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import {
FieldMetadataException,
FieldMetadataExceptionCode,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { generateDefaultValue } from 'src/engine/metadata-modules/field-metadata/utils/generate-default-value';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import {
IndexMetadataException,
IndexMetadataExceptionCode,
} from 'src/engine/metadata-modules/index-metadata/index-field-metadata.exception';
export const computeUniqueIndexWhereClause = (
fieldMetadata: Pick<FieldMetadataEntity, 'type' | 'name'>,
) => {
const defaultDefaultValue = generateDefaultValue(fieldMetadata.type);
if (!isDefined(defaultDefaultValue)) return;
if (
fieldMetadata.type === FieldMetadataType.RELATION ||
fieldMetadata.type === FieldMetadataType.MORPH_RELATION
) {
throw new IndexMetadataException(
`Unique index cannot be created for relation or morph relation field ${fieldMetadata.name}`,
IndexMetadataExceptionCode.INDEX_NOT_SUPPORTED_FOR_MORH_RELATION_FIELD_AND_RELATION_FIELD,
);
}
if (!isCompositeFieldMetadataType(fieldMetadata.type)) {
return `"${fieldMetadata.name}" != ${defaultDefaultValue}`;
}
const compositeType = compositeTypeDefinitions.get(fieldMetadata.type);
if (!isDefined(compositeType)) {
throw new FieldMetadataException(
`Composite type not found for field metadata type: ${fieldMetadata.type}`,
FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR,
);
}
const defaultDefaultValueProperties = Object.keys(defaultDefaultValue);
const columnNamesWithDefaultValues = compositeType.properties
.filter(
(property) =>
property.isIncludedInUniqueConstraint &&
defaultDefaultValueProperties.includes(property.name),
)
.map((property) => {
const defaultValue =
defaultDefaultValue[property.name as keyof typeof defaultDefaultValue];
if (isNonEmptyString(defaultValue)) {
return [
computeCompositeColumnName(fieldMetadata, property),
defaultValue,
];
}
})
.filter(isDefined);
return columnNamesWithDefaultValues.length > 0
? columnNamesWithDefaultValues
.map(
([columnName, defaultValue]) => `"${columnName}" != ${defaultValue}`,
)
.join(' OR ')
: undefined;
};
@@ -0,0 +1,46 @@
import { t } from '@lingui/core/macro';
import { FieldMetadataType } from 'twenty-shared/types';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import {
FieldMetadataException,
FieldMetadataExceptionCode,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
export const validateCanCreateUniqueIndex = (
field: Pick<FieldMetadataEntity, 'type' | 'name' | 'isCustom'>,
) => {
if (field.isCustom === false)
throw new FieldMetadataException(
`Unique index cannot be created on standard field`,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
{
userFriendlyMessage: t`Standard fields cannot be unique.`,
},
);
const isCompositeFieldWithNonIncludedUniqueConstraint =
isCompositeFieldMetadataType(field.type) &&
!compositeTypeDefinitions
.get(field.type)
?.properties.some((property) => property.isIncludedInUniqueConstraint);
if (
[FieldMetadataType.MORPH_RELATION, FieldMetadataType.RELATION].includes(
field.type,
) ||
isCompositeFieldWithNonIncludedUniqueConstraint
) {
const fieldType = field.type;
throw new FieldMetadataException(
`Unique index cannot be created for field ${field.name} of type ${fieldType}`,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
{
userFriendlyMessage: t`${fieldType} fields cannot be unique.`,
},
);
}
};
@@ -120,7 +120,7 @@ export class SearchVectorService {
queryRunner,
);
await this.indexMetadataService.createIndexMetadata({
await this.indexMetadataService.createIndex({
workspaceId: objectMetadataInput.workspaceId,
objectMetadata: createdObjectMetadata,
fieldMetadataToIndex: [searchVectorFieldMetadata],
@@ -0,0 +1,17 @@
import omit from 'lodash.omit';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
export const getObjectMetadataEntityFromObjectMetadataItemWithFieldsMap = (
objectMetadataItem: ObjectMetadataItemWithFieldMaps,
): ObjectMetadataEntity => {
return {
...omit(objectMetadataItem, [
'fieldsById',
'fieldIdByName',
'fieldIdByJoinColumnName',
]),
fields: Object.values(objectMetadataItem.fieldsById),
};
};