Files
twenty/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts
T
Marie 0d5fedfb0a Fix user friendly message for non available object name (#14210)
Fixes https://github.com/twentyhq/twenty/issues/13577.

When naming or renaming an object, we are already checking that the name
was available compared to other existing objects.
But we also need to check that the relation fields that will be created
or updated are available as well: we create relation fields on
Attachment, Note, Favorite, Task and TimelineActivites, that bear the
name of the object. Thus, it is not possible to create an object that
has the same name as one of the fields on Attachment, Note etc: name,
createdAt, ... are not valid names.
2025-09-01 16:35:40 +00:00

726 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) {
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,
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) {
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,
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,
};
}
}