teableio/teable

View on GitHub
apps/nestjs-backend/src/features/field/open-api/field-open-api.service.ts

Summary

Maintainability
B
4 hrs
Test Coverage
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { FieldKeyType, FieldOpBuilder, FieldType, IFieldRo } from '@teable/core';
import type {
  IFieldVo,
  IConvertFieldRo,
  IUpdateFieldRo,
  IOtOperation,
  IColumnMeta,
  ILinkFieldOptions,
  IGetFieldsQuery,
} from '@teable/core';
import { PrismaService } from '@teable/db-main-prisma';
import { instanceToPlain } from 'class-transformer';
import { groupBy } from 'lodash';
import { ClsService } from 'nestjs-cls';
import { ThresholdConfig, IThresholdConfig } from '../../../configs/threshold.config';
import { EventEmitterService } from '../../../event-emitter/event-emitter.service';
import { Events } from '../../../event-emitter/events';
import type { IClsStore } from '../../../types/cls';
import { Timing } from '../../../utils/timing';
import { FieldCalculationService } from '../../calculation/field-calculation.service';
import type { IOpsMap } from '../../calculation/reference.service';
import { GraphService } from '../../graph/graph.service';
import { RecordService } from '../../record/record.service';
import { ViewOpenApiService } from '../../view/open-api/view-open-api.service';
import { ViewService } from '../../view/view.service';
import { FieldConvertingService } from '../field-calculate/field-converting.service';
import { FieldCreatingService } from '../field-calculate/field-creating.service';
import { FieldDeletingService } from '../field-calculate/field-deleting.service';
import { FieldSupplementService } from '../field-calculate/field-supplement.service';
import { FieldViewSyncService } from '../field-calculate/field-view-sync.service';
import { FieldService } from '../field.service';
import type { IFieldInstance } from '../model/factory';
import {
  createFieldInstanceByRaw,
  createFieldInstanceByVo,
  rawField2FieldObj,
} from '../model/factory';

@Injectable()
export class FieldOpenApiService {
  private logger = new Logger(FieldOpenApiService.name);
  constructor(
    private readonly graphService: GraphService,
    private readonly prismaService: PrismaService,
    private readonly fieldService: FieldService,
    private readonly viewService: ViewService,
    private readonly viewOpenApiService: ViewOpenApiService,
    private readonly fieldCreatingService: FieldCreatingService,
    private readonly fieldDeletingService: FieldDeletingService,
    private readonly fieldConvertingService: FieldConvertingService,
    private readonly fieldSupplementService: FieldSupplementService,
    private readonly fieldCalculationService: FieldCalculationService,
    private readonly fieldViewSyncService: FieldViewSyncService,
    private readonly recordService: RecordService,
    private readonly eventEmitterService: EventEmitterService,
    private readonly cls: ClsService<IClsStore>,
    @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig
  ) {}

  async planField(tableId: string, fieldId: string) {
    return await this.graphService.planField(tableId, fieldId);
  }

  async planFieldCreate(tableId: string, fieldRo: IFieldRo) {
    return await this.graphService.planFieldCreate(tableId, fieldRo);
  }

  // TODO add delete relative check
  async planFieldConvert(tableId: string, fieldId: string, updateFieldRo: IConvertFieldRo) {
    return await this.graphService.planFieldConvert(tableId, fieldId, updateFieldRo);
  }

  async getFields(tableId: string, query: IGetFieldsQuery) {
    return await this.fieldService.getFieldsByQuery(tableId, {
      ...query,
      filterHidden: query.filterHidden == null ? true : query.filterHidden,
    });
  }

  private async validateLookupField(field: IFieldInstance) {
    if (field.lookupOptions) {
      const { foreignTableId, lookupFieldId, linkFieldId } = field.lookupOptions;
      const foreignField = await this.prismaService.txClient().field.findFirst({
        where: { tableId: foreignTableId, id: lookupFieldId, deletedTime: null },
        select: { id: true },
      });

      if (!foreignField) {
        return false;
      }
      const linkField = await this.prismaService.txClient().field.findFirst({
        where: { id: linkFieldId, deletedTime: null },
        select: { id: true, options: true },
      });
      if (!linkField) {
        return false;
      }
      const linkOptions = JSON.parse(linkField?.options as string) as ILinkFieldOptions;
      return linkOptions.foreignTableId === foreignTableId;
    }
    return true;
  }

  private async markError(tableId: string, field: IFieldInstance, hasError: boolean) {
    if (hasError) {
      !field.hasError && (await this.fieldService.markError(tableId, [field.id], true));
    } else {
      field.hasError && (await this.fieldService.markError(tableId, [field.id], false));
    }
  }

  private async checkAndUpdateError(tableId: string, field: IFieldInstance) {
    const fieldReferenceIds = this.fieldSupplementService.getFieldReferenceIds(field);
    const refFields = await this.prismaService.txClient().field.findMany({
      where: { id: { in: fieldReferenceIds }, deletedTime: null },
      select: { id: true },
    });

    if (refFields.length !== fieldReferenceIds.length) {
      await this.markError(tableId, field, true);
      return;
    }

    const curReference = await this.prismaService.txClient().reference.findMany({
      where: {
        toFieldId: field.id,
      },
    });
    const missingReferenceIds = fieldReferenceIds.filter(
      (refId) => !curReference.find((ref) => ref.fromFieldId === refId)
    );

    for (const refId of missingReferenceIds) {
      await this.prismaService.txClient().reference.create({
        data: {
          fromFieldId: refId,
          toFieldId: field.id,
        },
      });
    }

    if (field.lookupOptions) {
      const isValid = await this.validateLookupField(field);
      await this.markError(tableId, field, !isValid);
    } else {
      await this.markError(tableId, field, false);
    }
  }

  async restoreReference(references: string[]) {
    const fieldRaws = await this.prismaService.txClient().field.findMany({
      where: { id: { in: references }, deletedTime: null },
    });

    for (const refFieldRaw of fieldRaws) {
      const refField = createFieldInstanceByRaw(refFieldRaw);
      await this.checkAndUpdateError(refFieldRaw.tableId, refField);
    }
  }

  @Timing()
  async createFields(
    tableId: string,
    fields: (IFieldVo & { columnMeta?: IColumnMeta; references?: string[] })[]
  ) {
    const newFields = await this.prismaService.$tx(async () => {
      const newFields: { tableId: string; field: IFieldInstance }[] = [];
      for (let i = 0; i < fields.length; i++) {
        const field = fields[i];
        const { columnMeta, references, ...fieldVo } = field;

        const fieldInstance = createFieldInstanceByVo(fieldVo);

        const createResult = await this.fieldCreatingService.alterCreateField(
          tableId,
          fieldInstance,
          columnMeta
        );

        if (references) {
          await this.restoreReference(references);
        }

        newFields.push(...createResult);
      }

      return newFields;
    });

    await this.prismaService.$tx(
      async () => {
        for (const { tableId, field } of newFields) {
          if (field.isComputed) {
            await this.fieldCalculationService.calculateFields(tableId, [field.id]);
            await this.fieldService.resolvePending(tableId, [field.id]);
          }
        }
      },
      { timeout: this.thresholdConfig.bigTransactionTimeout }
    );
  }

  private async getFieldReferenceMap(fieldIds: string[]) {
    const referencesRaw = await this.prismaService.reference.findMany({
      where: {
        fromFieldId: { in: fieldIds },
      },
      select: {
        fromFieldId: true,
        toFieldId: true,
      },
    });
    return groupBy(referencesRaw, 'fromFieldId');
  }

  @Timing()
  async createField(tableId: string, fieldRo: IFieldRo, windowId?: string) {
    const fieldVo = await this.fieldSupplementService.prepareCreateField(tableId, fieldRo);
    const fieldInstance = createFieldInstanceByVo(fieldVo);
    const columnMeta = fieldRo.order && {
      [fieldRo.order.viewId]: { order: fieldRo.order.orderIndex },
    };
    const newFields = await this.prismaService.$tx(async () => {
      return await this.fieldCreatingService.alterCreateField(tableId, fieldInstance, columnMeta);
    });

    await this.prismaService.$tx(
      async () => {
        for (const { tableId, field } of newFields) {
          if (field.isComputed) {
            await this.fieldCalculationService.calculateFields(tableId, [field.id]);
            await this.fieldService.resolvePending(tableId, [field.id]);
          }
        }
      },
      { timeout: this.thresholdConfig.bigTransactionTimeout }
    );

    const referenceMap = await this.getFieldReferenceMap([fieldVo.id]);

    this.eventEmitterService.emitAsync(Events.OPERATION_FIELDS_CREATE, {
      windowId,
      tableId,
      userId: this.cls.get('user.id'),
      fields: [
        {
          ...fieldVo,
          columnMeta,
          references: referenceMap[fieldVo.id]?.map((ref) => ref.toFieldId),
        },
      ],
    });

    return fieldVo;
  }

  @Timing()
  async deleteFields(tableId: string, fieldIds: string[], windowId?: string) {
    const fieldRaws = await this.prismaService.field.findMany({
      where: { tableId, id: { in: fieldIds }, deletedTime: null },
    });
    const fieldVos = fieldIds
      .map((id) => rawField2FieldObj(fieldRaws.find((raw) => raw.id === id)!))
      .filter(Boolean);
    const fields = fieldVos.map(createFieldInstanceByVo);

    if (fields.length !== fieldIds.length) {
      const notExistField = fieldIds.find((id) => !fields.find((field) => field.id === id));
      throw new NotFoundException(`Field ${notExistField} not found`);
    }

    const nonComputedFields = fields.filter((field) => !field.isComputed);
    const records = await this.recordService.getRecordsFields(tableId, {
      projection: nonComputedFields.map((field) => field.id),
      fieldKeyType: FieldKeyType.Id,
      take: -1,
    });

    const columnsMeta = await this.viewService.getColumnsMetaMap(tableId, fieldIds);
    const referenceMap = await this.getFieldReferenceMap(fieldIds);

    await this.prismaService.$tx(async () => {
      await this.fieldViewSyncService.deleteDependenciesByFieldIds(
        tableId,
        fields.map((f) => f.id)
      );
      for (const field of fields) {
        await this.fieldDeletingService.alterDeleteField(tableId, field);
      }
    });

    this.eventEmitterService.emitAsync(Events.OPERATION_FIELDS_DELETE, {
      windowId,
      tableId,
      userId: this.cls.get('user.id'),
      fields: fieldVos.map((field, i) => ({
        ...field,
        columnMeta: columnsMeta[i],
        references: fieldIds.concat(referenceMap[field.id]?.map((ref) => ref.toFieldId) || []),
      })),
      records,
    });

    return fields;
  }

  async deleteField(tableId: string, fieldId: string, windowId?: string) {
    await this.deleteFields(tableId, [fieldId], windowId);
  }

  private async updateUniqProperty(
    tableId: string,
    fieldId: string,
    key: 'name' | 'dbFieldName',
    value: string
  ) {
    const result = await this.prismaService.field
      .findFirstOrThrow({
        where: { id: fieldId, deletedTime: null },
        select: { [key]: true },
      })
      .catch(() => {
        throw new NotFoundException(`Field ${fieldId} not found`);
      });

    const hasDuplicated = await this.prismaService.field.findFirst({
      where: { tableId, [key]: value, deletedTime: null },
      select: { id: true },
    });

    if (hasDuplicated) {
      throw new BadRequestException(`Field ${key} ${value} already exists`);
    }

    return FieldOpBuilder.editor.setFieldProperty.build({
      key,
      oldValue: result[key],
      newValue: value,
    });
  }

  async updateField(tableId: string, fieldId: string, updateFieldRo: IUpdateFieldRo) {
    const ops: IOtOperation[] = [];
    if (updateFieldRo.name) {
      const op = await this.updateUniqProperty(tableId, fieldId, 'name', updateFieldRo.name);
      ops.push(op);
    }

    if (updateFieldRo.dbFieldName) {
      const op = await this.updateUniqProperty(
        tableId,
        fieldId,
        'dbFieldName',
        updateFieldRo.dbFieldName
      );
      ops.push(op);
    }

    if (updateFieldRo.description !== undefined) {
      const { description } = await this.prismaService.field
        .findFirstOrThrow({
          where: { id: fieldId, deletedTime: null },
          select: { description: true },
        })
        .catch(() => {
          throw new NotFoundException(`Field ${fieldId} not found`);
        });

      ops.push(
        FieldOpBuilder.editor.setFieldProperty.build({
          key: 'description',
          oldValue: description,
          newValue: updateFieldRo.description,
        })
      );
    }

    await this.prismaService.$tx(async () => {
      await this.fieldService.batchUpdateFields(tableId, [{ fieldId, ops }]);
    });
  }

  async performConvertField({
    tableId,
    newField,
    oldField,
    modifiedOps,
    supplementChange,
  }: {
    tableId: string;
    newField: IFieldInstance;
    oldField: IFieldInstance;
    modifiedOps?: IOpsMap;
    supplementChange?: {
      tableId: string;
      newField: IFieldInstance;
      oldField: IFieldInstance;
    };
  }) {
    // 1. stage close constraint
    await this.fieldConvertingService.closeConstraint(tableId, newField, oldField);

    // 2. stage alter field
    await this.prismaService.$tx(async () => {
      await this.fieldViewSyncService.convertDependenciesByFieldIds(tableId, newField, oldField);
      await this.fieldConvertingService.stageAlter(tableId, newField, oldField);
      await this.fieldConvertingService.deleteOrCreateSupplementLink(tableId, newField, oldField);
      // for modify supplement link
      if (supplementChange) {
        const { tableId, newField, oldField } = supplementChange;
        await this.fieldConvertingService.stageAlter(tableId, newField, oldField);
      }
    });

    // 3. stage apply record changes and calculate field
    await this.prismaService.$tx(
      async () => {
        await this.fieldConvertingService.stageCalculate(tableId, newField, oldField, modifiedOps);

        if (supplementChange) {
          const { tableId, newField, oldField } = supplementChange;
          await this.fieldConvertingService.stageCalculate(tableId, newField, oldField);
        }
      },
      { timeout: this.thresholdConfig.bigTransactionTimeout }
    );

    // 4. stage supplement field constraint
    await this.prismaService.$tx(async () => {
      await this.fieldConvertingService.alterFieldConstraint(tableId, newField, oldField);
    });
  }

  async convertField(
    tableId: string,
    fieldId: string,
    updateFieldRo: IConvertFieldRo,
    windowId?: string
  ): Promise<IFieldVo> {
    // stage analysis and collect field changes
    const { newField, oldField, modifiedOps, supplementChange, references } =
      await this.fieldConvertingService.stageAnalysis(tableId, fieldId, updateFieldRo);
    this.cls.set('oldField', oldField);

    await this.performConvertField({
      tableId,
      newField,
      oldField,
      modifiedOps,
      supplementChange,
    });

    const oldFieldVo = instanceToPlain(oldField, { excludePrefixes: ['_'] }) as IFieldVo;
    const newFieldVo = instanceToPlain(newField, { excludePrefixes: ['_'] }) as IFieldVo;

    if (windowId) {
      this.eventEmitterService.emitAsync(Events.OPERATION_FIELD_CONVERT, {
        windowId,
        tableId,
        userId: this.cls.get('user.id'),
        oldField: oldFieldVo,
        newField: newFieldVo,
        modifiedOps,
        references,
        supplementChange,
      });
    }

    return newFieldVo;
  }

  async getFilterLinkRecords(tableId: string, fieldId: string) {
    const field = await this.fieldService.getField(tableId, fieldId);

    if (field.type !== FieldType.Link) return [];

    const { filter, foreignTableId } = field.options as ILinkFieldOptions;

    if (!foreignTableId || !filter) return [];

    return this.viewOpenApiService.getFilterLinkRecordsByTable(foreignTableId, filter);
  }
}