At field creation we are checking the availability of the name by comparing it to the other fields' names' on the object; but for composite fields the fields' names' as indicated in the repository do not exactly match the column names' on the tables (e.g "createdBy" field is actually represented by columns createdByName, createdBySource etc.). In this PR we prevent the conflict with the standard composite fields' names. There is still room for errors with the custom composite fields: for example a custom composite field "address" of type address on a custom object "listing" will introduce the columns addressAddressStreet1, addressAddressStreet2 etc. while we won't prevent the user from later creating a custom field named "addressAddressStreet1". For now I decided not to tackle this as this seem extremely edgy + would impact performance on creation of all fields while never actually useful (I think).
479 lines
16 KiB
TypeScript
479 lines
16 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 { FindOneOptions, In, Repository } from 'typeorm';
|
|
import { v4 as uuidV4 } from 'uuid';
|
|
|
|
import {
|
|
FieldMetadataEntity,
|
|
FieldMetadataType,
|
|
} from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
|
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/field-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 { InvalidStringException } from 'src/engine/metadata-modules/utils/exceptions/invalid-string.exception';
|
|
import { validateFieldNameAvailabilityOrThrow } from 'src/engine/metadata-modules/utils/validate-field-name-availability.utils';
|
|
import { validateMetadataNameValidityOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-validity.utils';
|
|
import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/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 { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service';
|
|
|
|
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,
|
|
) {
|
|
super(relationMetadataRepository);
|
|
}
|
|
|
|
override async createOne(
|
|
relationMetadataInput: CreateRelationInput,
|
|
): Promise<RelationMetadataEntity> {
|
|
const objectMetadataMap = await this.getObjectMetadataMap(
|
|
relationMetadataInput,
|
|
);
|
|
|
|
try {
|
|
validateMetadataNameValidityOrThrow(relationMetadataInput.fromName);
|
|
validateMetadataNameValidityOrThrow(relationMetadataInput.toName);
|
|
} catch (error) {
|
|
if (error instanceof InvalidStringException) {
|
|
throw new RelationMetadataException(
|
|
`Characters used in name "${relationMetadataInput.fromName}" or "${relationMetadataInput.toName}" are not supported`,
|
|
RelationMetadataExceptionCode.INVALID_RELATION_INPUT,
|
|
);
|
|
} else {
|
|
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();
|
|
|
|
await this.fieldMetadataService.createMany([
|
|
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,
|
|
);
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
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,
|
|
);
|
|
|
|
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations(
|
|
relationMetadata.workspaceId,
|
|
);
|
|
|
|
await this.workspaceMetadataVersionService.incrementMetadataVersion(
|
|
workspaceId,
|
|
);
|
|
|
|
// TODO: Return id for delete endpoints
|
|
return relationMetadata;
|
|
}
|
|
|
|
async findManyRelationMetadataByFieldMetadataIds(
|
|
fieldMetadataIds: string[],
|
|
): Promise<(RelationMetadataEntity | NotFoundException)[]> {
|
|
const relationMetadataCollection =
|
|
await this.relationMetadataRepository.find({
|
|
where: [
|
|
{
|
|
fromFieldMetadataId: In(fieldMetadataIds),
|
|
},
|
|
{
|
|
toFieldMetadataId: In(fieldMetadataIds),
|
|
},
|
|
],
|
|
relations: [
|
|
'fromObjectMetadata',
|
|
'toObjectMetadata',
|
|
'fromFieldMetadata',
|
|
'toFieldMetadata',
|
|
],
|
|
});
|
|
|
|
const mappedResult = fieldMetadataIds.map((fieldMetadataId) => {
|
|
const foundRelationMetadataItem = relationMetadataCollection.find(
|
|
(relationMetadataItem) =>
|
|
relationMetadataItem.fromFieldMetadataId === fieldMetadataId ||
|
|
relationMetadataItem.toFieldMetadataId === fieldMetadataId,
|
|
);
|
|
|
|
return (
|
|
foundRelationMetadataItem ??
|
|
// TODO: return a relation metadata not found exception
|
|
new NotFoundException(
|
|
`RelationMetadata with fieldMetadataId ${fieldMetadataId} not found`,
|
|
)
|
|
);
|
|
});
|
|
|
|
return mappedResult;
|
|
}
|
|
|
|
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,
|
|
],
|
|
},
|
|
],
|
|
);
|
|
}
|
|
}
|