Files
twenty/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts
T

741 lines
25 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { type Query, type QueryOptions } from '@ptc-org/nestjs-query-core';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { FieldMetadataType } from 'twenty-shared/types';
import { capitalize, isDefined } from 'twenty-shared/utils';
import {
DataSource,
In,
Repository,
type FindManyOptions,
type FindOneOptions,
type QueryRunner,
} from 'typeorm';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { IndexMetadataService } from 'src/engine/metadata-modules/index-metadata/index-metadata.service';
import { type DeleteOneObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/delete-object.input';
import {
type UpdateObjectPayload,
type UpdateOneObjectInput,
} from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
import {
ObjectMetadataException,
ObjectMetadataExceptionCode,
} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
import { ObjectMetadataFieldRelationService } from 'src/engine/metadata-modules/object-metadata/services/object-metadata-field-relation.service';
import { ObjectMetadataMigrationService } from 'src/engine/metadata-modules/object-metadata/services/object-metadata-migration.service';
import { ObjectMetadataRelatedRecordsService } from 'src/engine/metadata-modules/object-metadata/services/object-metadata-related-records.service';
import { buildDefaultFieldsForCustomObject } from 'src/engine/metadata-modules/object-metadata/utils/build-default-fields-for-custom-object.util';
import {
validateLowerCasedAndTrimmedStringsAreDifferentOrThrow,
validateObjectMetadataInputLabelsOrThrow,
validateObjectMetadataInputNamesOrThrow,
} from 'src/engine/metadata-modules/object-metadata/utils/validate-object-metadata-input.util';
import { SearchVectorService } from 'src/engine/metadata-modules/search-vector/search-vector.service';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { validateMetadataIdentifierFieldMetadataIds } from 'src/engine/metadata-modules/utils/validate-metadata-identifier-field-metadata-id.utils';
import { validateNameAndLabelAreSyncOrThrow } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util';
import { validatesNoOtherObjectWithSameNameExistsOrThrows } from 'src/engine/metadata-modules/utils/validate-no-other-object-with-same-name-exists-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';
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service';
import { CUSTOM_OBJECT_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
import { isSearchableFieldType } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/is-searchable-field.util';
import { ObjectMetadataEntity } from './object-metadata.entity';
import { type CreateObjectInput } from './dtos/create-object.input';
@Injectable()
export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEntity> {
constructor(
@InjectRepository(ObjectMetadataEntity)
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
private readonly dataSourceService: DataSourceService,
private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService,
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
private readonly searchVectorService: SearchVectorService,
private readonly objectMetadataFieldRelationService: ObjectMetadataFieldRelationService,
private readonly objectMetadataMigrationService: ObjectMetadataMigrationService,
private readonly objectMetadataRelatedRecordsService: ObjectMetadataRelatedRecordsService,
private readonly indexMetadataService: IndexMetadataService,
private readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService,
@InjectDataSource()
private readonly coreDataSource: DataSource,
private readonly featureFlagService: FeatureFlagService,
private readonly objectMetadataServiceV2: ObjectMetadataServiceV2,
) {
super(objectMetadataRepository);
}
override async query(
query: Query<ObjectMetadataEntity>,
opts?: QueryOptions<ObjectMetadataEntity> | undefined,
): Promise<ObjectMetadataEntity[]> {
const start = performance.now();
const result = super.query(query, opts);
const end = performance.now();
// eslint-disable-next-line no-console
console.log(`metadata query time: ${end - start} ms`);
return result;
}
override async createOne(
createObjectInput: CreateObjectInput,
): Promise<ObjectMetadataEntity> {
const isWorkspaceMigrationV2Enabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
createObjectInput.workspaceId,
);
if (isWorkspaceMigrationV2Enabled) {
const flatObjectMetadata = await this.objectMetadataServiceV2.createOne({
createObjectInput,
workspaceId: createObjectInput.workspaceId,
});
const createdObjectMetadata = await this.objectMetadataRepository.findOne(
{
where: {
id: flatObjectMetadata.id,
workspaceId: createObjectInput.workspaceId,
},
},
);
if (!isDefined(createdObjectMetadata)) {
throw new ObjectMetadataException(
'Created object metadata not found',
ObjectMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
);
}
return createdObjectMetadata;
}
const queryRunner = this.coreDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const objectMetadataRepository =
queryRunner.manager.getRepository(ObjectMetadataEntity);
const { objectMetadataMaps } =
await this.workspaceMetadataCacheService.getExistingOrRecomputeMetadataMaps(
{
workspaceId: createObjectInput.workspaceId,
},
);
const lastDataSourceMetadata =
await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
createObjectInput.workspaceId,
);
createObjectInput.labelSingular = capitalize(
createObjectInput.labelSingular,
);
createObjectInput.labelPlural = capitalize(createObjectInput.labelPlural);
validateObjectMetadataInputNamesOrThrow(createObjectInput);
validateObjectMetadataInputLabelsOrThrow(createObjectInput);
validateLowerCasedAndTrimmedStringsAreDifferentOrThrow({
inputs: [createObjectInput.nameSingular, createObjectInput.namePlural],
message:
'The singular and plural names cannot be the same for an object',
});
validateLowerCasedAndTrimmedStringsAreDifferentOrThrow({
inputs: [
createObjectInput.labelPlural,
createObjectInput.labelSingular,
],
message:
'The singular and plural labels cannot be the same for an object',
});
if (createObjectInput.isLabelSyncedWithName === true) {
validateNameAndLabelAreSyncOrThrow({
label: createObjectInput.labelSingular,
name: createObjectInput.nameSingular,
});
validateNameAndLabelAreSyncOrThrow({
label: createObjectInput.labelPlural,
name: createObjectInput.namePlural,
});
}
validatesNoOtherObjectWithSameNameExistsOrThrows({
objectMetadataNamePlural: createObjectInput.namePlural,
objectMetadataNameSingular: createObjectInput.nameSingular,
objectMetadataMaps,
});
const baseCustomFields = buildDefaultFieldsForCustomObject(
createObjectInput.workspaceId,
);
const labelIdentifierFieldMetadataId = baseCustomFields.find(
(field) => field.standardId === CUSTOM_OBJECT_STANDARD_FIELD_IDS.name,
)?.id;
if (!isDefined(labelIdentifierFieldMetadataId)) {
throw new ObjectMetadataException(
'Label identifier field metadata not created properly',
ObjectMetadataExceptionCode.MISSING_CUSTOM_OBJECT_DEFAULT_LABEL_IDENTIFIER_FIELD,
);
}
const createdObjectMetadata = await objectMetadataRepository.save({
...createObjectInput,
dataSourceId: lastDataSourceMetadata.id,
targetTableName: 'DEPRECATED',
isActive: true,
isCustom: !createObjectInput.isRemote,
isSystem: false,
isRemote: createObjectInput.isRemote,
isSearchable: !createObjectInput.isRemote,
fields: createObjectInput.isRemote ? [] : baseCustomFields,
labelIdentifierFieldMetadataId,
});
if (createObjectInput.isRemote) {
throw new Error('Remote objects are not supported yet');
} else {
const fieldsById = createdObjectMetadata.fields.reduce(
(acc, field) => ({
...acc,
[field.id]: field,
}),
{},
);
const createdRelatedObjectMetadataCollection =
await this.objectMetadataFieldRelationService.createRelationsAndForeignKeysMetadata(
createObjectInput.workspaceId,
{ ...createdObjectMetadata, fieldsById },
objectMetadataMaps,
queryRunner,
);
await this.objectMetadataMigrationService.createTableMigration(
createdObjectMetadata,
queryRunner,
);
await this.objectMetadataMigrationService.createColumnsMigrations(
createdObjectMetadata,
createdObjectMetadata.fields,
queryRunner,
);
await this.objectMetadataMigrationService.createRelationMigrations(
createdObjectMetadata,
createdRelatedObjectMetadataCollection,
queryRunner,
);
await this.searchVectorService.createSearchVectorFieldForObject(
createObjectInput,
createdObjectMetadata,
queryRunner,
);
}
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrationsWithinTransaction(
createdObjectMetadata.workspaceId,
queryRunner,
);
await queryRunner.commitTransaction();
// After commit, do non-transactional work
await this.workspacePermissionsCacheService.recomputeRolesPermissionsCache(
{
workspaceId: createObjectInput.workspaceId,
},
);
await this.objectMetadataRelatedRecordsService.createObjectRelatedRecords(
createdObjectMetadata,
);
await this.workspaceMetadataVersionService.incrementMetadataVersion(
createObjectInput.workspaceId,
);
return createdObjectMetadata;
} catch (error) {
if (queryRunner.isTransactionActive) {
try {
await queryRunner.rollbackTransaction();
} catch (error) {
// eslint-disable-next-line no-console
console.trace(`Failed to rollback transaction: ${error.message}`);
}
}
throw error;
} finally {
await queryRunner.release();
}
}
public async updateOneObject(
updateObjectInput: UpdateOneObjectInput,
workspaceId: string,
): Promise<ObjectMetadataEntity> {
const queryRunner = this.coreDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const objectMetadataRepository =
queryRunner.manager.getRepository(ObjectMetadataEntity);
const { objectMetadataMaps } =
await this.workspaceMetadataCacheService.getExistingOrRecomputeMetadataMaps(
{ workspaceId },
);
const inputId = updateObjectInput.id;
const inputPayload = {
...updateObjectInput.update,
...(isDefined(updateObjectInput.update.labelSingular)
? {
labelSingular: capitalize(updateObjectInput.update.labelSingular),
}
: {}),
...(isDefined(updateObjectInput.update.labelPlural)
? { labelPlural: capitalize(updateObjectInput.update.labelPlural) }
: {}),
};
validateObjectMetadataInputNamesOrThrow(inputPayload);
const existingObjectMetadata = objectMetadataMaps.byId[inputId];
if (!existingObjectMetadata) {
throw new ObjectMetadataException(
'Object does not exist',
ObjectMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
);
}
const existingObjectMetadataCombinedWithUpdateInput = {
...existingObjectMetadata,
...inputPayload,
};
validatesNoOtherObjectWithSameNameExistsOrThrows({
objectMetadataNameSingular:
existingObjectMetadataCombinedWithUpdateInput.nameSingular,
objectMetadataNamePlural:
existingObjectMetadataCombinedWithUpdateInput.namePlural,
existingObjectMetadataId:
existingObjectMetadataCombinedWithUpdateInput.id,
objectMetadataMaps,
});
if (existingObjectMetadataCombinedWithUpdateInput.isLabelSyncedWithName) {
validateNameAndLabelAreSyncOrThrow({
label: existingObjectMetadataCombinedWithUpdateInput.labelSingular,
name: existingObjectMetadataCombinedWithUpdateInput.nameSingular,
});
validateNameAndLabelAreSyncOrThrow({
label: existingObjectMetadataCombinedWithUpdateInput.labelPlural,
name: existingObjectMetadataCombinedWithUpdateInput.namePlural,
});
}
if (
isDefined(inputPayload.nameSingular) ||
isDefined(inputPayload.namePlural)
) {
validateLowerCasedAndTrimmedStringsAreDifferentOrThrow({
inputs: [
existingObjectMetadataCombinedWithUpdateInput.nameSingular,
existingObjectMetadataCombinedWithUpdateInput.namePlural,
],
message:
'The singular and plural names cannot be the same for an object',
});
}
validateMetadataIdentifierFieldMetadataIds({
fieldMetadataItems: Object.values(existingObjectMetadata.fieldsById),
labelIdentifierFieldMetadataId:
inputPayload.labelIdentifierFieldMetadataId,
imageIdentifierFieldMetadataId:
inputPayload.imageIdentifierFieldMetadataId,
});
const updatedObject = await objectMetadataRepository.save({
...existingObjectMetadata,
...inputPayload,
});
const { didUpdateLabelOrIcon } =
await this.handleObjectNameAndLabelUpdates({
existingObjectMetadata,
objectMetadataForUpdate:
existingObjectMetadataCombinedWithUpdateInput,
inputPayload,
queryRunner,
objectMetadataMaps,
});
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrationsWithinTransaction(
workspaceId,
queryRunner,
);
if (inputPayload.labelIdentifierFieldMetadataId) {
const labelIdentifierFieldMetadata =
existingObjectMetadata.fieldsById[
inputPayload.labelIdentifierFieldMetadataId
];
if (isSearchableFieldType(labelIdentifierFieldMetadata.type)) {
await this.searchVectorService.updateSearchVector(
inputId,
[
{
name: labelIdentifierFieldMetadata.name,
type: labelIdentifierFieldMetadata.type,
},
],
workspaceId,
queryRunner,
);
}
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrationsWithinTransaction(
workspaceId,
queryRunner,
);
}
await queryRunner.commitTransaction();
// After commit, do non-transactional work
await this.workspacePermissionsCacheService.recomputeRolesPermissionsCache(
{
workspaceId,
},
);
if (didUpdateLabelOrIcon) {
await this.objectMetadataRelatedRecordsService.updateObjectViews(
updatedObject,
workspaceId,
);
}
await this.workspaceMetadataVersionService.incrementMetadataVersion(
workspaceId,
);
const formattedUpdatedObject = {
...updatedObject,
createdAt: new Date(updatedObject.createdAt),
updatedAt: new Date(updatedObject.updatedAt),
};
return formattedUpdatedObject;
} catch (error) {
if (queryRunner.isTransactionActive) {
try {
await queryRunner.rollbackTransaction();
} catch (error) {
// eslint-disable-next-line no-console
console.trace(`Failed to rollback transaction: ${error.message}`);
}
}
throw error;
} finally {
await queryRunner.release();
}
}
public async deleteOneObject(
deleteObjectInput: DeleteOneObjectInput,
workspaceId: string,
): Promise<Partial<ObjectMetadataEntity>> {
const isWorkspaceMigrationV2Enabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
workspaceId,
);
if (isWorkspaceMigrationV2Enabled) {
return await this.objectMetadataServiceV2.deleteOne({
deleteObjectInput,
workspaceId,
});
}
const queryRunner = this.coreDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const objectMetadataRepository =
queryRunner.manager.getRepository(ObjectMetadataEntity);
const fieldMetadataRepository =
queryRunner.manager.getRepository(FieldMetadataEntity);
const objectMetadata = await objectMetadataRepository.findOne({
relations: [
'fields',
'fields.object',
'fields.relationTargetFieldMetadata',
'fields.relationTargetFieldMetadata.object',
],
where: {
id: deleteObjectInput.id,
workspaceId,
},
});
if (!objectMetadata) {
throw new ObjectMetadataException(
'Object does not exist',
ObjectMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
);
}
if (objectMetadata.isRemote) {
throw new ObjectMetadataException(
'Remote objects are not supported yet',
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
);
} else {
await this.objectMetadataMigrationService.deleteAllRelationsAndDropTable(
objectMetadata,
workspaceId,
queryRunner,
);
}
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrationsWithinTransaction(
workspaceId,
queryRunner,
);
const fieldMetadataIds = objectMetadata.fields.map((field) => field.id);
const relationMetadataIds = objectMetadata.fields.flatMap((field) => {
if (
isFieldMetadataEntityOfType(field, FieldMetadataType.MORPH_RELATION)
) {
return field.relationTargetFieldMetadata.id;
}
return [];
});
await fieldMetadataRepository.delete({
id: In(fieldMetadataIds.concat(relationMetadataIds)),
});
await objectMetadataRepository.delete(objectMetadata.id);
await queryRunner.commitTransaction();
// After commit, do non-transactional work
await this.workspaceMetadataVersionService.incrementMetadataVersion(
workspaceId,
);
await this.workspacePermissionsCacheService.recomputeRolesPermissionsCache(
{
workspaceId,
},
);
await this.objectMetadataRelatedRecordsService.deleteObjectViews(
objectMetadata,
workspaceId,
);
return objectMetadata;
} catch (error) {
if (queryRunner.isTransactionActive) {
try {
await queryRunner.rollbackTransaction();
} catch (error) {
// eslint-disable-next-line no-console
console.trace(`Failed to rollback transaction: ${error.message}`);
}
}
throw error;
} finally {
await queryRunner.release();
}
}
public async findOneWithinWorkspace(
workspaceId: string,
options: FindOneOptions<ObjectMetadataEntity>,
): Promise<ObjectMetadataEntity | null> {
return this.objectMetadataRepository.findOne({
relations: [
'fields',
'indexMetadatas',
'indexMetadatas.indexFieldMetadatas',
],
...options,
where: {
...options.where,
workspaceId,
},
});
}
public async findManyWithinWorkspace(
workspaceId: string,
options?: FindManyOptions<ObjectMetadataEntity>,
) {
return this.objectMetadataRepository.find({
relations: [
'fields.object',
'fields',
'fields.relationTargetObjectMetadata',
],
...options,
where: {
...options?.where,
workspaceId,
},
order: {
...options?.order,
},
});
}
public async deleteObjectsMetadata(workspaceId: string) {
await this.objectMetadataRepository.delete({ workspaceId });
}
private async handleObjectNameAndLabelUpdates({
existingObjectMetadata,
objectMetadataForUpdate,
inputPayload,
queryRunner,
objectMetadataMaps,
}: {
existingObjectMetadata: Pick<
ObjectMetadataItemWithFieldMaps,
'nameSingular' | 'isCustom' | 'id' | 'labelPlural' | 'icon' | 'fieldsById'
>;
objectMetadataForUpdate: Pick<
ObjectMetadataItemWithFieldMaps,
| 'nameSingular'
| 'isCustom'
| 'workspaceId'
| 'id'
| 'labelSingular'
| 'labelPlural'
| 'icon'
| 'fieldsById'
>;
inputPayload: UpdateObjectPayload;
queryRunner: QueryRunner;
objectMetadataMaps: ObjectMetadataMaps;
}): Promise<{ didUpdateLabelOrIcon: boolean }> {
const newTargetTableName = computeObjectTargetTable(
objectMetadataForUpdate,
);
const existingTargetTableName = computeObjectTargetTable(
existingObjectMetadata,
);
if (newTargetTableName !== existingTargetTableName) {
await this.objectMetadataMigrationService.createRenameTableMigration(
existingObjectMetadata,
objectMetadataForUpdate,
objectMetadataForUpdate.workspaceId,
queryRunner,
);
const relationMetadataCollection =
await this.objectMetadataFieldRelationService.updateRelationsAndForeignKeysMetadata(
{
workspaceId: objectMetadataForUpdate.workspaceId,
updatedObjectMetadata: objectMetadataForUpdate,
queryRunner,
objectMetadataMaps,
},
);
await this.objectMetadataMigrationService.updateRelationMigrations(
existingObjectMetadata,
objectMetadataForUpdate,
relationMetadataCollection,
objectMetadataForUpdate.workspaceId,
queryRunner,
);
const morphRelationFieldMetadataToUpdate =
await this.objectMetadataFieldRelationService.updateMorphRelationsJoinColumnName(
{
existingObjectMetadata,
objectMetadataForUpdate,
queryRunner,
},
);
await this.objectMetadataMigrationService.updateMorphRelationMigrations({
workspaceId: objectMetadataForUpdate.workspaceId,
morphRelationFieldMetadataToUpdate: morphRelationFieldMetadataToUpdate,
queryRunner,
});
await this.objectMetadataMigrationService.recomputeEnumNames(
objectMetadataForUpdate,
objectMetadataForUpdate.workspaceId,
queryRunner,
);
const recomputedIndexes =
await this.indexMetadataService.recomputeIndexMetadataForObject(
objectMetadataForUpdate.workspaceId,
objectMetadataForUpdate,
queryRunner,
);
await this.indexMetadataService.createIndexRecomputeMigrations(
objectMetadataForUpdate.workspaceId,
objectMetadataForUpdate,
recomputedIndexes,
queryRunner,
);
if (
(inputPayload.labelPlural || inputPayload.icon) &&
(inputPayload.labelPlural !== existingObjectMetadata.labelPlural ||
inputPayload.icon !== existingObjectMetadata.icon)
) {
return {
didUpdateLabelOrIcon: true,
};
}
}
return {
didUpdateLabelOrIcon: false,
};
}
}