Files
twenty/packages/twenty-server/src/engine/metadata-modules/relation-metadata/relation-metadata.service.ts
Paul Rastoin 41f3a63962 [BUGFIX] ObjectMetadata item server validation (#10699)
# Introduction
This PR contains several SNAPSHOT files explaining big +

While refactoring the Object Model settings page in
https://github.com/twentyhq/twenty/pull/10653, encountered a critical
issue when submitting either one or both names with `""` empty string
hard corrupting a workspace.

This motivate this PR reviewing server side validation

I feel like we could share zod schema between front and back

## Refactored server validation
What to expect from Names:
- Plural and singular have to be different ( case insensitive and
trimmed check )
- Contains only a-z A-Z and 0-9
- Follows camelCase
- Is not empty => Is not too short ( 1 )
- Is not too long ( 63 )
- Is case insensitive( fooBar and fOoBar now rejected )

What to expect from Labels:
- Plural and singular have to be different ( case insensitive and
trimmed check )
- Is not empty => Is not too short ( 1 )
- Is not too long ( 63 )
- Is case insensitive ( fooBar and fOoBar now rejected )

close https://github.com/twentyhq/twenty/issues/10694

## Creation integrations tests
Created new integrations tests, following
[EachTesting](https://jestjs.io/docs/api#testeachtablename-fn-timeout)
pattern and uses snapshot to assert errors message. These tests cover
several failing use cases and started to implement ones for the happy
path but object metadata item deletion is currently broken unless I'm
mistaken @Weiko is on it

## Notes
- [ ] As we've added new validation rules towards names and labels we
should scan db in order to standardize existing values using either a
migration command or manual check
- [ ] Will review in an other PR the update path, adding integrations
tests and so on
2025-03-11 12:14:37 +01:00

581 lines
19 KiB
TypeScript

import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import camelCase from 'lodash.camelcase';
import { FieldMetadataType, isDefined } from 'twenty-shared';
import { FindOneOptions, In, Repository } from 'typeorm';
import { v4 as uuidV4 } from 'uuid';
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/field-metadata.service';
import { IndexMetadataService } from 'src/engine/metadata-modules/index-metadata/index-metadata.service';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { CreateRelationInput } from 'src/engine/metadata-modules/relation-metadata/dtos/create-relation.input';
import {
RelationMetadataException,
RelationMetadataExceptionCode,
} from 'src/engine/metadata-modules/relation-metadata/relation-metadata.exception';
import { validateFieldNameAvailabilityOrThrow } from 'src/engine/metadata-modules/utils/validate-field-name-availability.utils';
import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name.utils';
import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service';
import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util';
import {
WorkspaceMigrationColumnActionType,
WorkspaceMigrationColumnDrop,
WorkspaceMigrationTableActionType,
} from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.service';
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service';
import { BASE_OBJECT_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
import { InvalidMetadataNameException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata-name.exception';
import {
RelationMetadataEntity,
RelationMetadataType,
RelationOnDeleteAction,
} from './relation-metadata.entity';
@Injectable()
export class RelationMetadataService extends TypeOrmQueryService<RelationMetadataEntity> {
constructor(
@InjectRepository(RelationMetadataEntity, 'metadata')
private readonly relationMetadataRepository: Repository<RelationMetadataEntity>,
@InjectRepository(FieldMetadataEntity, 'metadata')
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
private readonly objectMetadataService: ObjectMetadataService,
private readonly fieldMetadataService: FieldMetadataService,
private readonly workspaceMigrationService: WorkspaceMigrationService,
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
private readonly indexMetadataService: IndexMetadataService,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
) {
super(relationMetadataRepository);
}
override async createOne(
relationMetadataInput: CreateRelationInput,
): Promise<RelationMetadataEntity> {
const objectMetadataMap = await this.getObjectMetadataMap(
relationMetadataInput,
);
try {
validateMetadataNameOrThrow(relationMetadataInput.fromName);
validateMetadataNameOrThrow(relationMetadataInput.toName);
} catch (error) {
if (error instanceof InvalidMetadataNameException)
throw new RelationMetadataException(
error.message,
RelationMetadataExceptionCode.INVALID_RELATION_INPUT,
);
throw error;
}
await this.validateCreateRelationMetadataInput(
relationMetadataInput,
objectMetadataMap,
);
// NOTE: this logic is called to create relation through metadata graphql endpoint (so only for custom field relations)
const isCustom = true;
const columnName = `${camelCase(relationMetadataInput.toName)}Id`;
const fromId = uuidV4();
const toId = uuidV4();
const createdRelationFieldsMetadata =
await this.fieldMetadataRepository.save([
this.createFieldMetadataForRelationMetadata(
relationMetadataInput,
'from',
isCustom,
fromId,
),
this.createFieldMetadataForRelationMetadata(
relationMetadataInput,
'to',
isCustom,
toId,
),
this.createForeignKeyFieldMetadata(relationMetadataInput, columnName),
]);
const createdRelationMetadata = await super.createOne({
...relationMetadataInput,
fromFieldMetadataId: fromId,
toFieldMetadataId: toId,
});
await this.createWorkspaceCustomMigration(
relationMetadataInput,
objectMetadataMap,
columnName,
);
const toObjectMetadata =
objectMetadataMap[relationMetadataInput.toObjectMetadataId];
const foreignKeyFieldMetadata = createdRelationFieldsMetadata.find(
(fieldMetadata) => fieldMetadata.type === FieldMetadataType.UUID,
);
if (!foreignKeyFieldMetadata) {
throw new RelationMetadataException(
`ForeignKey field metadata not found`,
RelationMetadataExceptionCode.RELATION_METADATA_NOT_FOUND,
);
}
const deletedAtFieldMetadata = toObjectMetadata.fields.find(
(fieldMetadata) =>
fieldMetadata.standardId === BASE_OBJECT_STANDARD_FIELD_IDS.deletedAt,
);
this.throwIfDeletedAtFieldMetadataNotFound(deletedAtFieldMetadata);
await this.indexMetadataService.createIndexMetadata(
relationMetadataInput.workspaceId,
toObjectMetadata,
[
foreignKeyFieldMetadata,
deletedAtFieldMetadata as FieldMetadataEntity<FieldMetadataType>,
],
false,
false,
);
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations(
relationMetadataInput.workspaceId,
);
await this.workspaceMetadataVersionService.incrementMetadataVersion(
relationMetadataInput.workspaceId,
);
return createdRelationMetadata;
}
private async validateCreateRelationMetadataInput(
relationMetadataInput: CreateRelationInput,
objectMetadataMap: { [key: string]: ObjectMetadataEntity },
) {
if (
relationMetadataInput.relationType === RelationMetadataType.MANY_TO_MANY
) {
throw new RelationMetadataException(
'Many to many relations are not supported yet',
RelationMetadataExceptionCode.INVALID_RELATION_INPUT,
);
}
if (
objectMetadataMap[relationMetadataInput.fromObjectMetadataId] ===
undefined ||
objectMetadataMap[relationMetadataInput.toObjectMetadataId] === undefined
) {
throw new RelationMetadataException(
"Can't find an existing object matching with fromObjectMetadataId or toObjectMetadataId",
RelationMetadataExceptionCode.RELATION_METADATA_NOT_FOUND,
);
}
await this.checkIfFieldMetadataRelationNameExists(
relationMetadataInput,
objectMetadataMap,
'from',
);
await this.checkIfFieldMetadataRelationNameExists(
relationMetadataInput,
objectMetadataMap,
'to',
);
validateFieldNameAvailabilityOrThrow(
relationMetadataInput.fromName,
objectMetadataMap[relationMetadataInput.fromObjectMetadataId],
);
validateFieldNameAvailabilityOrThrow(
relationMetadataInput.toName,
objectMetadataMap[relationMetadataInput.toObjectMetadataId],
);
}
private async checkIfFieldMetadataRelationNameExists(
relationMetadataInput: CreateRelationInput,
objectMetadataMap: { [key: string]: ObjectMetadataEntity },
relationDirection: 'from' | 'to',
) {
const fieldAlreadyExists =
await this.fieldMetadataService.findOneWithinWorkspace(
relationMetadataInput.workspaceId,
{
where: {
name: relationMetadataInput[`${relationDirection}Name`],
objectMetadataId:
relationMetadataInput[`${relationDirection}ObjectMetadataId`],
},
},
);
if (fieldAlreadyExists) {
throw new RelationMetadataException(
`Field on ${
objectMetadataMap[
relationMetadataInput[`${relationDirection}ObjectMetadataId`]
].nameSingular
} already exists`,
RelationMetadataExceptionCode.RELATION_ALREADY_EXISTS,
);
}
}
private async createWorkspaceCustomMigration(
relationMetadataInput: CreateRelationInput,
objectMetadataMap: { [key: string]: ObjectMetadataEntity },
columnName: string,
) {
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(`create-${relationMetadataInput.fromName}`),
relationMetadataInput.workspaceId,
[
// Create the column
{
name: computeObjectTargetTable(
objectMetadataMap[relationMetadataInput.toObjectMetadataId],
),
action: WorkspaceMigrationTableActionType.ALTER,
columns: [
{
action: WorkspaceMigrationColumnActionType.CREATE,
columnName,
columnType: 'uuid',
isNullable: true,
defaultValue: null,
},
],
},
// Create the foreignKey
{
name: computeObjectTargetTable(
objectMetadataMap[relationMetadataInput.toObjectMetadataId],
),
action: WorkspaceMigrationTableActionType.ALTER,
columns: [
{
action: WorkspaceMigrationColumnActionType.CREATE_FOREIGN_KEY,
columnName,
referencedTableName: computeObjectTargetTable(
objectMetadataMap[relationMetadataInput.fromObjectMetadataId],
),
referencedTableColumnName: 'id',
isUnique:
relationMetadataInput.relationType ===
RelationMetadataType.ONE_TO_ONE,
onDelete: RelationOnDeleteAction.SET_NULL,
},
],
},
],
);
}
private createFieldMetadataForRelationMetadata(
relationMetadataInput: CreateRelationInput,
relationDirection: 'from' | 'to',
isCustom: boolean,
id?: string,
) {
return {
...(id && { id: id }),
name: relationMetadataInput[`${relationDirection}Name`],
label: relationMetadataInput[`${relationDirection}Label`],
description: relationMetadataInput[`${relationDirection}Description`],
icon: relationMetadataInput[`${relationDirection}Icon`],
isCustom,
isActive: true,
isNullable: true,
type: FieldMetadataType.RELATION,
objectMetadataId:
relationMetadataInput[`${relationDirection}ObjectMetadataId`],
workspaceId: relationMetadataInput.workspaceId,
};
}
private createForeignKeyFieldMetadata(
relationMetadataInput: CreateRelationInput,
columnName: string,
) {
return {
name: columnName,
label: `${relationMetadataInput.toLabel} Foreign Key`,
description: relationMetadataInput.toDescription
? `${relationMetadataInput.toDescription} Foreign Key`
: undefined,
icon: undefined,
isCustom: true,
isActive: true,
isNullable: true,
isSystem: true,
type: FieldMetadataType.UUID,
objectMetadataId: relationMetadataInput.toObjectMetadataId,
workspaceId: relationMetadataInput.workspaceId,
settings: { isForeignKey: true },
};
}
private async getObjectMetadataMap(
relationMetadataInput: CreateRelationInput,
): Promise<{ [key: string]: ObjectMetadataEntity }> {
const objectMetadataEntries =
await this.objectMetadataService.findManyWithinWorkspace(
relationMetadataInput.workspaceId,
{
where: {
id: In([
relationMetadataInput.fromObjectMetadataId,
relationMetadataInput.toObjectMetadataId,
]),
},
},
);
return objectMetadataEntries.reduce(
(acc, curr) => {
acc[curr.id] = curr;
return acc;
},
{} as { [key: string]: ObjectMetadataEntity },
);
}
public async findOneWithinWorkspace(
workspaceId: string,
options: FindOneOptions<RelationMetadataEntity>,
) {
return this.relationMetadataRepository.findOne({
...options,
where: {
...options.where,
workspaceId,
},
relations: ['fromFieldMetadata', 'toFieldMetadata'],
});
}
public async deleteOneRelation(
id: string,
workspaceId: string,
): Promise<RelationMetadataEntity> {
// TODO: This logic is duplicated with the BeforeDeleteOneRelation hook
const relationMetadata = await this.relationMetadataRepository.findOne({
where: { id },
relations: [
'fromFieldMetadata',
'toFieldMetadata',
'fromObjectMetadata',
'toObjectMetadata',
],
});
if (!relationMetadata) {
throw new RelationMetadataException(
'Relation does not exist',
RelationMetadataExceptionCode.RELATION_METADATA_NOT_FOUND,
);
}
const foreignKeyFieldMetadataName = `${camelCase(
relationMetadata.toFieldMetadata.name,
)}Id`;
const foreignKeyFieldMetadata = await this.fieldMetadataRepository.findOne({
where: {
name: foreignKeyFieldMetadataName,
objectMetadataId: relationMetadata.toObjectMetadataId,
workspaceId: relationMetadata.workspaceId,
},
});
if (!foreignKeyFieldMetadata) {
throw new RelationMetadataException(
`Foreign key fieldMetadata not found (${foreignKeyFieldMetadataName}) for relation ${relationMetadata.id}`,
RelationMetadataExceptionCode.FOREIGN_KEY_NOT_FOUND,
);
}
await super.deleteOne(id);
// TODO: Move to a cdc scheduler
await this.fieldMetadataService.deleteMany({
id: {
in: [
relationMetadata.fromFieldMetadataId,
relationMetadata.toFieldMetadataId,
foreignKeyFieldMetadata.id,
],
},
});
const columnName = `${camelCase(relationMetadata.toFieldMetadata.name)}Id`;
const objectTargetTable = computeObjectTargetTable(
relationMetadata.toObjectMetadata,
);
await this.deleteRelationWorkspaceCustomMigration(
relationMetadata,
objectTargetTable,
columnName,
);
const deletedAtFieldMetadata = await this.fieldMetadataRepository.findOneBy(
{
objectMetadataId: relationMetadata.toObjectMetadataId,
name: 'deletedAt',
},
);
this.throwIfDeletedAtFieldMetadataNotFound(deletedAtFieldMetadata);
await this.indexMetadataService.deleteIndexMetadata(
workspaceId,
relationMetadata.toObjectMetadata,
[
foreignKeyFieldMetadata,
deletedAtFieldMetadata as FieldMetadataEntity<FieldMetadataType>,
],
);
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations(
relationMetadata.workspaceId,
);
await this.workspaceMetadataVersionService.incrementMetadataVersion(
workspaceId,
);
// TODO: Return id for delete endpoints
return relationMetadata;
}
async findManyRelationMetadataByFieldMetadataIds(
fieldMetadataItems: Array<
Pick<FieldMetadataInterface, 'id' | 'type' | 'objectMetadataId'>
>,
workspaceId: string,
): Promise<(RelationMetadataEntity | NotFoundException)[]> {
const metadataVersion =
await this.workspaceCacheStorageService.getMetadataVersion(workspaceId);
if (!metadataVersion) {
throw new NotFoundException(
`Metadata version not found for workspace ${workspaceId}`,
);
}
const objectMetadataMaps =
await this.workspaceCacheStorageService.getObjectMetadataMaps(
workspaceId,
metadataVersion,
);
if (!objectMetadataMaps) {
throw new NotFoundException(
`Object metadata map not found for workspace ${workspaceId} and metadata version ${metadataVersion}`,
);
}
const mappedResult = fieldMetadataItems.map((fieldMetadataItem) => {
const objectMetadata =
objectMetadataMaps.byId[fieldMetadataItem.objectMetadataId];
if (!objectMetadata) {
return new NotFoundException(
`Object metadata not found for field ${fieldMetadataItem.id}`,
);
}
const fieldMetadata = objectMetadata.fieldsById[fieldMetadataItem.id];
const relationMetadata =
fieldMetadata.fromRelationMetadata ?? fieldMetadata.toRelationMetadata;
if (!relationMetadata) {
return new NotFoundException(
`From object metadata not found for relation ${fieldMetadata?.id}`,
);
}
const fromObjectMetadata =
objectMetadataMaps.byId[relationMetadata.fromObjectMetadataId];
const toObjectMetadata =
objectMetadataMaps.byId[relationMetadata.toObjectMetadataId];
const fromFieldMetadata =
objectMetadataMaps.byId[fromObjectMetadata.id].fieldsById[
relationMetadata.fromFieldMetadataId
];
const toFieldMetadata =
objectMetadataMaps.byId[toObjectMetadata.id].fieldsById[
relationMetadata.toFieldMetadataId
];
return {
...relationMetadata,
fromObjectMetadata,
toObjectMetadata,
fromFieldMetadata,
toFieldMetadata,
};
});
return mappedResult as (RelationMetadataEntity | NotFoundException)[];
}
private async deleteRelationWorkspaceCustomMigration(
relationMetadata: RelationMetadataEntity,
objectTargetTable: string,
columnName: string,
) {
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(
`delete-relation-from-${relationMetadata.fromObjectMetadata.nameSingular}-to-${relationMetadata.toObjectMetadata.nameSingular}`,
),
relationMetadata.workspaceId,
[
// Delete the column
{
name: objectTargetTable,
action: WorkspaceMigrationTableActionType.ALTER,
columns: [
{
action: WorkspaceMigrationColumnActionType.DROP,
columnName,
} satisfies WorkspaceMigrationColumnDrop,
],
},
],
);
}
private throwIfDeletedAtFieldMetadataNotFound(
deletedAtFieldMetadata?: FieldMetadataEntity<FieldMetadataType> | null,
) {
if (!isDefined(deletedAtFieldMetadata)) {
throw new RelationMetadataException(
`Deleted field metadata not found`,
RelationMetadataExceptionCode.RELATION_METADATA_NOT_FOUND,
);
}
}
}