import { Injectable } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { v4 as uuidV4 } from 'uuid'; import { DataSource, FindOneOptions, Repository } from 'typeorm'; import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm'; import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service'; import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.service'; import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input'; import { WorkspaceMigrationColumnActionType, WorkspaceMigrationColumnDrop, WorkspaceMigrationTableAction, WorkspaceMigrationTableActionType, } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity'; import { TypeORMService } from 'src/database/typeorm/typeorm.service'; import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; import { UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input'; import { WorkspaceMigrationFactory } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.factory'; import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util'; import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util'; import { generateNullable } from 'src/engine/metadata-modules/field-metadata/utils/generate-nullable'; import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto'; import { RelationDefinitionDTO, RelationDefinitionType, } from 'src/engine/metadata-modules/field-metadata/dtos/relation-definition.dto'; import { RelationMetadataEntity, RelationMetadataType, } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; import { DeleteOneFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/delete-field.input'; import { computeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util'; import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util'; import { validateMetadataNameOrThrow, InvalidStringException, NameTooLongException, } from 'src/engine/metadata-modules/utils/validate-metadata-name.utils'; import { WorkspaceCacheVersionService } from 'src/engine/metadata-modules/workspace-cache-version/workspace-cache-version.service'; import { FieldMetadataException, FieldMetadataExceptionCode, } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; import { exceedsDatabaseIdentifierMaximumLength } from 'src/engine/metadata-modules/utils/validate-database-identifier-length.utils'; import { FieldMetadataEntity, FieldMetadataType, } from './field-metadata.entity'; import { isEnumFieldMetadataType } from './utils/is-enum-field-metadata-type.util'; import { generateRatingOptions } from './utils/generate-rating-optionts.util'; import { generateDefaultValue } from './utils/generate-default-value'; @Injectable() export class FieldMetadataService extends TypeOrmQueryService { constructor( @InjectDataSource('metadata') private readonly metadataDataSource: DataSource, @InjectRepository(FieldMetadataEntity, 'metadata') private readonly fieldMetadataRepository: Repository, private readonly objectMetadataService: ObjectMetadataService, private readonly workspaceMigrationFactory: WorkspaceMigrationFactory, private readonly workspaceMigrationService: WorkspaceMigrationService, private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService, private readonly dataSourceService: DataSourceService, private readonly typeORMService: TypeORMService, private readonly workspaceCacheVersionService: WorkspaceCacheVersionService, ) { super(fieldMetadataRepository); } override async createOne( fieldMetadataInput: CreateFieldInput, ): Promise { const queryRunner = this.metadataDataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { const fieldMetadataRepository = queryRunner.manager.getRepository>( FieldMetadataEntity, ); const objectMetadata = await this.objectMetadataService.findOneWithinWorkspace( fieldMetadataInput.workspaceId, { where: { id: fieldMetadataInput.objectMetadataId, }, }, ); if (!objectMetadata) { throw new FieldMetadataException( 'Object metadata does not exist', FieldMetadataExceptionCode.INVALID_FIELD_INPUT, ); } if (!fieldMetadataInput.isRemoteCreation) { assertMutationNotOnRemoteObject(objectMetadata); } // Double check in case the service is directly called if (isEnumFieldMetadataType(fieldMetadataInput.type)) { if ( !fieldMetadataInput.options && fieldMetadataInput.type !== FieldMetadataType.RATING ) { throw new FieldMetadataException( 'Options are required for enum fields', FieldMetadataExceptionCode.INVALID_FIELD_INPUT, ); } } // Generate options for rating fields if (fieldMetadataInput.type === FieldMetadataType.RATING) { fieldMetadataInput.options = generateRatingOptions(); } this.validateFieldMetadataInput(fieldMetadataInput); const fieldAlreadyExists = await fieldMetadataRepository.findOne({ where: { name: fieldMetadataInput.name, objectMetadataId: fieldMetadataInput.objectMetadataId, workspaceId: fieldMetadataInput.workspaceId, }, }); if (fieldAlreadyExists) { throw new FieldMetadataException( 'Field already exists', FieldMetadataExceptionCode.FIELD_ALREADY_EXISTS, ); } const createdFieldMetadata = await fieldMetadataRepository.save({ ...fieldMetadataInput, isNullable: generateNullable( fieldMetadataInput.type, fieldMetadataInput.isNullable, fieldMetadataInput.isRemoteCreation, ), defaultValue: fieldMetadataInput.defaultValue ?? generateDefaultValue(fieldMetadataInput.type), options: fieldMetadataInput.options ? fieldMetadataInput.options.map((option) => ({ ...option, id: uuidV4(), })) : undefined, isActive: true, isCustom: true, }); if (!fieldMetadataInput.isRemoteCreation) { await this.workspaceMigrationService.createCustomMigration( generateMigrationName(`create-${createdFieldMetadata.name}`), fieldMetadataInput.workspaceId, [ { name: computeObjectTargetTable(objectMetadata), action: WorkspaceMigrationTableActionType.ALTER, columns: this.workspaceMigrationFactory.createColumnActions( WorkspaceMigrationColumnActionType.CREATE, createdFieldMetadata, ), } satisfies WorkspaceMigrationTableAction, ], ); await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations( fieldMetadataInput.workspaceId, ); } // TODO: Move viewField creation to a cdc scheduler const dataSourceMetadata = await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail( fieldMetadataInput.workspaceId, ); const workspaceDataSource = await this.typeORMService.connectToDataSource(dataSourceMetadata); const workspaceQueryRunner = workspaceDataSource?.createQueryRunner(); if (!workspaceQueryRunner) { throw new FieldMetadataException( 'Could not create workspace query runner', FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR, ); } await workspaceQueryRunner.connect(); await workspaceQueryRunner.startTransaction(); try { // TODO: use typeorm repository const view = await workspaceQueryRunner?.query( `SELECT id FROM ${dataSourceMetadata.schema}."view" WHERE "objectMetadataId" = '${createdFieldMetadata.objectMetadataId}'`, ); const existingViewFields = await workspaceQueryRunner?.query( `SELECT * FROM ${dataSourceMetadata.schema}."viewField" WHERE "viewId" = '${view[0].id}'`, ); const lastPosition = existingViewFields .map((viewField) => viewField.position) .reduce((acc, position) => { if (position > acc) { return position; } return acc; }, -1); await workspaceQueryRunner?.query( `INSERT INTO ${dataSourceMetadata.schema}."viewField" ("fieldMetadataId", "position", "isVisible", "size", "viewId") VALUES ('${createdFieldMetadata.id}', '${lastPosition + 1}', true, 180, '${ view[0].id }')`, ); await workspaceQueryRunner.commitTransaction(); } catch (error) { await workspaceQueryRunner.rollbackTransaction(); throw error; } finally { await workspaceQueryRunner.release(); } await queryRunner.commitTransaction(); return createdFieldMetadata; } catch (error) { await queryRunner.rollbackTransaction(); throw error; } finally { await queryRunner.release(); await this.workspaceCacheVersionService.incrementVersion( fieldMetadataInput.workspaceId, ); } } override async updateOne( id: string, fieldMetadataInput: UpdateFieldInput, ): Promise { const queryRunner = this.metadataDataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { const fieldMetadataRepository = queryRunner.manager.getRepository>( FieldMetadataEntity, ); const existingFieldMetadata = await fieldMetadataRepository.findOne({ where: { id, workspaceId: fieldMetadataInput.workspaceId, }, }); if (!existingFieldMetadata) { throw new FieldMetadataException( 'Field does not exist', FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND, ); } const objectMetadata = await this.objectMetadataService.findOneWithinWorkspace( fieldMetadataInput.workspaceId, { where: { id: existingFieldMetadata?.objectMetadataId, }, }, ); if (!objectMetadata) { throw new FieldMetadataException( 'Object metadata does not exist', FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND, ); } assertMutationNotOnRemoteObject(objectMetadata); if ( objectMetadata.labelIdentifierFieldMetadataId === existingFieldMetadata.id && fieldMetadataInput.isActive === false ) { throw new FieldMetadataException( 'Cannot deactivate label identifier field', FieldMetadataExceptionCode.FIELD_MUTATION_NOT_ALLOWED, ); } if (fieldMetadataInput.options) { for (const option of fieldMetadataInput.options) { if (!option.id) { throw new FieldMetadataException( 'Option id is required', FieldMetadataExceptionCode.INVALID_FIELD_INPUT, ); } } } this.validateFieldMetadataInput(fieldMetadataInput); const updatableFieldInput = existingFieldMetadata.isCustom === false ? this.buildUpdatableStandardFieldInput( fieldMetadataInput, existingFieldMetadata, ) : fieldMetadataInput; // We're running field update under a transaction, so we can rollback if migration fails await fieldMetadataRepository.update(id, { ...updatableFieldInput, defaultValue: // Todo: we handle default value for all field types. ![ FieldMetadataType.SELECT, FieldMetadataType.MULTI_SELECT, FieldMetadataType.BOOLEAN, ].includes(existingFieldMetadata.type) ? existingFieldMetadata.defaultValue : updatableFieldInput.defaultValue !== null ? updatableFieldInput.defaultValue : null, }); const updatedFieldMetadata = await fieldMetadataRepository.findOne({ where: { id }, }); if (!updatedFieldMetadata) { throw new FieldMetadataException( 'Field does not exist', FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND, ); } if ( fieldMetadataInput.name || updatableFieldInput.options || updatableFieldInput.defaultValue ) { await this.workspaceMigrationService.createCustomMigration( generateMigrationName(`update-${updatedFieldMetadata.name}`), existingFieldMetadata.workspaceId, [ { name: computeObjectTargetTable(objectMetadata), action: WorkspaceMigrationTableActionType.ALTER, columns: this.workspaceMigrationFactory.createColumnActions( WorkspaceMigrationColumnActionType.ALTER, existingFieldMetadata, updatedFieldMetadata, ), } satisfies WorkspaceMigrationTableAction, ], ); await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations( updatedFieldMetadata.workspaceId, ); } await queryRunner.commitTransaction(); return updatedFieldMetadata; } catch (error) { await queryRunner.rollbackTransaction(); throw error; } finally { await queryRunner.release(); await this.workspaceCacheVersionService.incrementVersion( fieldMetadataInput.workspaceId, ); } } public async deleteOneField( input: DeleteOneFieldInput, workspaceId: string, ): Promise { const queryRunner = this.metadataDataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); // transaction not safe as a different queryRunner is used within workspaceMigrationRunnerService try { const fieldMetadataRepository = queryRunner.manager.getRepository>( FieldMetadataEntity, ); const fieldMetadata = await fieldMetadataRepository.findOne({ where: { id: input.id, workspaceId: workspaceId, }, }); if (!fieldMetadata) { throw new FieldMetadataException( 'Field does not exist', FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND, ); } const objectMetadata = await this.objectMetadataService.findOneWithinWorkspace(workspaceId, { where: { id: fieldMetadata?.objectMetadataId, }, }); if (!objectMetadata) { throw new FieldMetadataException( 'Object metadata does not exist', FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND, ); } await fieldMetadataRepository.delete(fieldMetadata.id); await this.workspaceMigrationService.createCustomMigration( generateMigrationName(`delete-${fieldMetadata.name}`), workspaceId, [ { name: computeObjectTargetTable(objectMetadata), action: WorkspaceMigrationTableActionType.ALTER, columns: [ { action: WorkspaceMigrationColumnActionType.DROP, columnName: computeColumnName(fieldMetadata), } satisfies WorkspaceMigrationColumnDrop, ], } satisfies WorkspaceMigrationTableAction, ], ); await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations( workspaceId, ); await queryRunner.commitTransaction(); return fieldMetadata; } catch (error) { await queryRunner.rollbackTransaction(); throw error; } finally { await queryRunner.release(); await this.workspaceCacheVersionService.incrementVersion(workspaceId); } } public async findOneOrFail( id: string, options?: FindOneOptions, ) { const fieldMetadata = await this.fieldMetadataRepository.findOne({ ...options, where: { ...options?.where, id, }, }); if (!fieldMetadata) { throw new FieldMetadataException( 'Field does not exist', FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND, ); } return fieldMetadata; } public async findOneWithinWorkspace( workspaceId: string, options: FindOneOptions, ) { return this.fieldMetadataRepository.findOne({ ...options, where: { ...options.where, workspaceId, }, }); } public async deleteFieldsMetadata(workspaceId: string) { await this.fieldMetadataRepository.delete({ workspaceId }); await this.workspaceCacheVersionService.incrementVersion(workspaceId); } private buildUpdatableStandardFieldInput( fieldMetadataInput: UpdateFieldInput, existingFieldMetadata: FieldMetadataEntity, ) { let fieldMetadataInputOverrided = {}; fieldMetadataInputOverrided = { id: fieldMetadataInput.id, isActive: fieldMetadataInput.isActive, workspaceId: fieldMetadataInput.workspaceId, defaultValue: fieldMetadataInput.defaultValue, }; if (existingFieldMetadata.type === FieldMetadataType.SELECT) { fieldMetadataInputOverrided = { ...fieldMetadataInputOverrided, options: fieldMetadataInput.options, }; } return fieldMetadataInputOverrided as UpdateFieldInput; } public async getRelationDefinitionFromRelationMetadata( fieldMetadataDTO: FieldMetadataDTO, relationMetadata: RelationMetadataEntity, ): Promise { if (fieldMetadataDTO.type !== FieldMetadataType.RELATION) { return null; } const isRelationFromSource = relationMetadata.fromFieldMetadata.id === fieldMetadataDTO.id; // TODO: implement MANY_TO_MANY if ( relationMetadata.relationType === RelationMetadataType.MANY_TO_MANY || relationMetadata.relationType === RelationMetadataType.MANY_TO_ONE ) { throw new FieldMetadataException( ` Relation type ${relationMetadata.relationType} not supported `, FieldMetadataExceptionCode.INVALID_FIELD_INPUT, ); } if (isRelationFromSource) { const direction = relationMetadata.relationType === RelationMetadataType.ONE_TO_ONE ? RelationDefinitionType.ONE_TO_ONE : RelationDefinitionType.ONE_TO_MANY; return { relationId: relationMetadata.id, sourceObjectMetadata: relationMetadata.fromObjectMetadata, sourceFieldMetadata: relationMetadata.fromFieldMetadata, targetObjectMetadata: relationMetadata.toObjectMetadata, targetFieldMetadata: relationMetadata.toFieldMetadata, direction, }; } else { const direction = relationMetadata.relationType === RelationMetadataType.ONE_TO_ONE ? RelationDefinitionType.ONE_TO_ONE : RelationDefinitionType.MANY_TO_ONE; return { relationId: relationMetadata.id, sourceObjectMetadata: relationMetadata.toObjectMetadata, sourceFieldMetadata: relationMetadata.toFieldMetadata, targetObjectMetadata: relationMetadata.fromObjectMetadata, targetFieldMetadata: relationMetadata.fromFieldMetadata, direction, }; } } private validateFieldMetadataInput< T extends UpdateFieldInput | CreateFieldInput, >(fieldMetadataInput: T): T { if (fieldMetadataInput.name) { try { validateMetadataNameOrThrow(fieldMetadataInput.name); } catch (error) { if (error instanceof InvalidStringException) { throw new FieldMetadataException( `Characters used in name "${fieldMetadataInput.name}" are not supported`, FieldMetadataExceptionCode.INVALID_FIELD_INPUT, ); } else if (error instanceof NameTooLongException) { throw new FieldMetadataException( `Name "${fieldMetadataInput.name}" exceeds 63 characters`, FieldMetadataExceptionCode.INVALID_FIELD_INPUT, ); } else { throw error; } } } if (fieldMetadataInput.options) { for (const option of fieldMetadataInput.options) { if (exceedsDatabaseIdentifierMaximumLength(option.value)) { throw new FieldMetadataException( `Option value "${option.value}" exceeds 63 characters`, FieldMetadataExceptionCode.INVALID_FIELD_INPUT, ); } } } return fieldMetadataInput; } }