Files
twenty/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts
T
Weiko dec2239ae7 Remove typeorm service (#14116)
## Context
To simplify the way we inject our default datasource, I've recently
removed the token injection that was confusion since we only had once
configured on the module level. Now I'm removing TypeORM service which
allows us to instantiate a new Datasource with the same parameters as
the default one, it was redundant and confusing.
2025-08-28 13:21:26 +02:00

705 lines
24 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 { 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 createdRelatedObjectMetadataCollection =
await this.objectMetadataFieldRelationService.createRelationsAndForeignKeysMetadata(
createObjectInput.workspaceId,
createdObjectMetadata,
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) {
await queryRunner.rollbackTransaction();
}
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,
existingObjectMetadataCombinedWithUpdateInput,
inputPayload,
queryRunner,
);
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) {
await queryRunner.rollbackTransaction();
}
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) {
await queryRunner.rollbackTransaction();
}
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: Pick<
ObjectMetadataItemWithFieldMaps,
'nameSingular' | 'isCustom' | 'id' | 'labelPlural' | 'icon' | 'fieldsById'
>,
objectMetadataForUpdate: Pick<
ObjectMetadataItemWithFieldMaps,
| 'nameSingular'
| 'isCustom'
| 'workspaceId'
| 'id'
| 'labelSingular'
| 'labelPlural'
| 'icon'
| 'fieldsById'
>,
inputPayload: UpdateObjectPayload,
queryRunner: QueryRunner,
): 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(
objectMetadataForUpdate.workspaceId,
objectMetadataForUpdate,
queryRunner,
);
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,
};
}
}