teableio/teable

View on GitHub
apps/nestjs-backend/src/features/field/field-calculate/field-converting-link.service.ts

Summary

Maintainability
A
3 hrs
Test Coverage
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import type { ILinkCellValue, ILinkFieldOptions, IOtOperation } from '@teable/core';
import {
  Relationship,
  RelationshipRevert,
  FieldType,
  RecordOpBuilder,
  isMultiValueLink,
} from '@teable/core';
import { PrismaService } from '@teable/db-main-prisma';
import { groupBy, isEqual } from 'lodash';
import { FieldCalculationService } from '../../calculation/field-calculation.service';
import { LinkService } from '../../calculation/link.service';
import type { IOpsMap } from '../../calculation/reference.service';
import type { IFieldInstance } from '../model/factory';
import {
  createFieldInstanceByVo,
  createFieldInstanceByRaw,
  rawField2FieldObj,
} from '../model/factory';
import type { LinkFieldDto } from '../model/field-dto/link-field.dto';
import { FieldCreatingService } from './field-creating.service';
import { FieldDeletingService } from './field-deleting.service';
import { FieldSupplementService } from './field-supplement.service';

const isLink = (field: IFieldInstance): field is LinkFieldDto =>
  !field.isLookup && field.type === FieldType.Link;

@Injectable()
export class FieldConvertingLinkService {
  constructor(
    private readonly prismaService: PrismaService,
    private readonly linkService: LinkService,
    private readonly fieldDeletingService: FieldDeletingService,
    private readonly fieldCreatingService: FieldCreatingService,
    private readonly fieldSupplementService: FieldSupplementService,
    private readonly fieldCalculationService: FieldCalculationService
  ) {}

  private async symLinkRelationshipChange(newField: LinkFieldDto) {
    // field options has been modified but symmetricFieldId not change
    const fieldRaw = await this.prismaService.txClient().field.findFirstOrThrow({
      where: { id: newField.options.symmetricFieldId, deletedTime: null },
    });

    const newFieldVo = rawField2FieldObj(fieldRaw);

    const options = newFieldVo.options as ILinkFieldOptions;
    options.relationship = RelationshipRevert[newField.options.relationship];
    options.fkHostTableName = newField.options.fkHostTableName;
    options.selfKeyName = newField.options.foreignKeyName;
    options.foreignKeyName = newField.options.selfKeyName;
    newFieldVo.isMultipleCellValue = isMultiValueLink(options.relationship) || undefined;

    // return modified changes in foreignTable
    return {
      tableId: newField.options.foreignTableId,
      newField: createFieldInstanceByVo(newFieldVo),
      oldField: createFieldInstanceByRaw(fieldRaw),
    };
  }

  private async alterSymmetricFieldChange(
    tableId: string,
    oldField: LinkFieldDto,
    newField: LinkFieldDto
  ) {
    // noting change
    if (
      (!newField.options.symmetricFieldId && !oldField.options.symmetricFieldId) ||
      newField.options.symmetricFieldId === oldField.options.symmetricFieldId
    ) {
      return;
    }

    // delete old symmetric link
    if (oldField.options.symmetricFieldId) {
      const { foreignTableId, symmetricFieldId } = oldField.options;
      const symField = await this.fieldDeletingService.getField(foreignTableId, symmetricFieldId);
      symField && (await this.fieldDeletingService.deleteFieldItem(foreignTableId, symField));
    }

    // create new symmetric link
    if (newField.options.symmetricFieldId) {
      const symmetricField = await this.fieldSupplementService.generateSymmetricField(
        tableId,
        newField
      );
      await this.fieldCreatingService.createFieldItem(
        newField.options.foreignTableId,
        symmetricField
      );
    }
  }

  private async linkOptionsChange(tableId: string, newField: LinkFieldDto, oldField: LinkFieldDto) {
    if (
      newField.options.foreignTableId === oldField.options.foreignTableId &&
      newField.options.relationship === oldField.options.relationship &&
      newField.options.symmetricFieldId === oldField.options.symmetricFieldId
    ) {
      return;
    }

    // change link table, delete link in old table and create link in new table
    if (newField.options.foreignTableId !== oldField.options.foreignTableId) {
      // update current field reference
      await this.prismaService.txClient().reference.deleteMany({
        where: {
          toFieldId: newField.id,
        },
      });
      await this.fieldSupplementService.createReference(newField);
      await this.fieldSupplementService.cleanForeignKey(oldField.options);
      await this.fieldDeletingService.cleanLookupRollupRef(tableId, newField.id);

      await this.fieldSupplementService.createForeignKey(newField.options);
      // change relationship, alter foreign key
    } else if (newField.options.relationship !== oldField.options.relationship) {
      await this.fieldSupplementService.cleanForeignKey(oldField.options);
      await this.fieldSupplementService.createForeignKey(newField.options);
    }

    // change one-way to two-way or two-way to one-way (symmetricFieldId add or delete, symmetricFieldId can not be change)
    await this.alterSymmetricFieldChange(tableId, oldField, newField);
  }

  private async otherToLink(tableId: string, newField: LinkFieldDto) {
    await this.fieldSupplementService.createForeignKey(newField.options);
    await this.fieldSupplementService.createReference(newField);
    if (newField.options.symmetricFieldId) {
      const symmetricField = await this.fieldSupplementService.generateSymmetricField(
        tableId,
        newField
      );
      await this.fieldCreatingService.createFieldItem(
        newField.options.foreignTableId,
        symmetricField
      );
    }
  }

  private async linkToOther(tableId: string, oldField: LinkFieldDto) {
    await this.fieldDeletingService.cleanLookupRollupRef(tableId, oldField.id);
    await this.fieldSupplementService.cleanForeignKey(oldField.options);

    if (oldField.options.symmetricFieldId) {
      const { foreignTableId, symmetricFieldId } = oldField.options;
      const symField = await this.fieldDeletingService.getField(foreignTableId, symmetricFieldId);
      symField && (await this.fieldDeletingService.deleteFieldItem(foreignTableId, symField));
    }
  }

  /**
   * 1. switch link table
   * 2. other field to link field
   * 3. link field to other field
   */
  async deleteOrCreateSupplementLink(
    tableId: string,
    newField: IFieldInstance,
    oldField: IFieldInstance
  ) {
    if (isLink(newField) && isLink(oldField) && !isEqual(newField.options, oldField.options)) {
      return this.linkOptionsChange(tableId, newField, oldField);
    }

    if (!isLink(newField) && isLink(oldField)) {
      return this.linkToOther(tableId, oldField);
    }

    if (isLink(newField) && !isLink(oldField)) {
      return this.otherToLink(tableId, newField);
    }
  }

  async analysisReference(oldField: IFieldInstance) {
    if (!isLink(oldField)) {
      return;
    }

    // self and symmetricLinkField outgoing reference
    const linkFieldIds = [oldField.id];
    if (oldField.options.symmetricFieldId) {
      linkFieldIds.push(oldField.options.symmetricFieldId);
    }

    // LookupField and Rollup field witch linkFieldId is self and symmetricLinkField, should also treat as reference
    const lookupRelatedFields = await this.prismaService.txClient().field.findMany({
      where: {
        lookupLinkedFieldId: { in: linkFieldIds },
        deletedTime: null,
      },
      select: { id: true },
    });

    const references: string[] = lookupRelatedFields.map((field) => field.id);

    const referencesRaw = await this.prismaService.txClient().reference.findMany({
      where: {
        fromFieldId: { in: linkFieldIds },
      },
      select: {
        toFieldId: true,
      },
    });

    return references.concat(referencesRaw.map((r) => r.toFieldId));
  }

  async analysisSupplementLink(newField: IFieldInstance, oldField: IFieldInstance) {
    if (
      isLink(newField) &&
      isLink(oldField) &&
      !isEqual(newField.options, oldField.options) &&
      newField.options.foreignTableId === oldField.options.foreignTableId &&
      newField.options.symmetricFieldId &&
      newField.options.symmetricFieldId === oldField.options.symmetricFieldId &&
      newField.options.relationship !== oldField.options.relationship
    ) {
      return this.symLinkRelationshipChange(newField);
    }
  }

  private async getRecords(tableId: string, field: IFieldInstance) {
    const { dbTableName } = await this.prismaService.txClient().tableMeta.findFirstOrThrow({
      where: { id: tableId },
      select: { dbTableName: true },
    });

    const result = await this.fieldCalculationService.getRecordsBatchByFields({
      [dbTableName]: [field],
    });
    const records = result[dbTableName];
    if (!records) {
      throw new InternalServerErrorException(
        `Can't find recordMap for tableId: ${tableId} and fieldId: ${field.id}`
      );
    }

    return records;
  }

  async oneWayToTwoWay(newField: LinkFieldDto) {
    const { foreignTableId, relationship, symmetricFieldId } = newField.options;
    const foreignKeys = await this.linkService.getAllForeignKeys(newField.options);
    const foreignKeyMap = groupBy(foreignKeys, 'foreignId');

    const opsMap: {
      [recordId: string]: IOtOperation[];
    } = {};

    Object.keys(foreignKeyMap).forEach((foreignId) => {
      const ids = foreignKeyMap[foreignId].map((item) => item.id);
      // relational behavior needs to be reversed
      if (relationship === Relationship.ManyMany || relationship === Relationship.OneMany) {
        opsMap[foreignId] = [
          RecordOpBuilder.editor.setRecord.build({
            fieldId: symmetricFieldId as string,
            newCellValue: { id: ids[0] },
            oldCellValue: null,
          }),
        ];
      }

      if (relationship === Relationship.OneOne || relationship === Relationship.ManyOne) {
        opsMap[foreignId] = [
          RecordOpBuilder.editor.setRecord.build({
            fieldId: symmetricFieldId as string,
            newCellValue: ids.map((id) => ({ id })),
            oldCellValue: null,
          }),
        ];
      }
    });

    return { [foreignTableId]: opsMap };
  }

  async modifyLinkOptions(tableId: string, newField: LinkFieldDto, oldField: LinkFieldDto) {
    if (
      newField.options.foreignTableId === oldField.options.foreignTableId &&
      newField.options.relationship === oldField.options.relationship &&
      newField.options.symmetricFieldId &&
      !newField.options.isOneWay &&
      oldField.options.isOneWay
    ) {
      return this.oneWayToTwoWay(newField);
    }
    return this.convertLink(tableId, newField, oldField);
  }

  /**
   * convert oldCellValue to new link field cellValue
   * if oldCellValue is not in foreignTable, create new record in foreignTable
   */
  // eslint-disable-next-line sonarjs/cognitive-complexity
  async convertLink(tableId: string, newField: LinkFieldDto, oldField: IFieldInstance) {
    const fieldId = newField.id;
    const foreignTableId = newField.options.foreignTableId;
    const lookupFieldRaw = await this.prismaService.txClient().field.findFirstOrThrow({
      where: { id: newField.options.lookupFieldId, deletedTime: null },
    });
    const lookupField = createFieldInstanceByRaw(lookupFieldRaw);

    const records = await this.getRecords(tableId, oldField);
    // TODO: should not get all records in foreignTable, only get records witch title is not exist in candidate records link cell value title
    const foreignRecords = await this.getRecords(foreignTableId, lookupField);

    const primaryNameToIdMap = foreignRecords.reduce<{ [name: string]: string }>((pre, record) => {
      const str = lookupField.cellValue2String(record.fields[lookupField.id]);
      pre[str] = record.id;
      return pre;
    }, {});

    const recordOpsMap: IOpsMap = { [tableId]: {}, [foreignTableId]: {} };
    const checkSet = new Set<string>();
    // eslint-disable-next-line sonarjs/cognitive-complexity
    records.forEach((record) => {
      const oldCellValue = record.fields[fieldId];
      if (oldCellValue == null) {
        return;
      }
      let newCellValueTitle: string[];
      if (newField.isMultipleCellValue) {
        newCellValueTitle = oldField.isMultipleCellValue
          ? (oldCellValue as unknown[]).map((item) => oldField.item2String(item))
          : oldField.item2String(oldCellValue).split(', ');
      } else {
        newCellValueTitle = oldField.isMultipleCellValue
          ? [oldField.item2String((oldCellValue as unknown[])[0])]
          : [oldField.item2String(oldCellValue).split(', ')[0]];
      }

      const newCellValue: ILinkCellValue[] = [];
      function pushNewCellValue(linkCell: ILinkCellValue) {
        // OneMany and OneOne relationship only allow link to one same recordId
        if (
          newField.options.relationship === Relationship.OneMany ||
          newField.options.relationship === Relationship.OneOne
        ) {
          if (checkSet.has(linkCell.id)) return;
          checkSet.add(linkCell.id);
          return newCellValue.push(linkCell);
        }
        return newCellValue.push(linkCell);
      }

      newCellValueTitle.forEach((title) => {
        if (primaryNameToIdMap[title]) {
          pushNewCellValue({ id: primaryNameToIdMap[title], title });
        }
      });

      if (!recordOpsMap[tableId][record.id]) {
        recordOpsMap[tableId][record.id] = [];
      }
      recordOpsMap[tableId][record.id].push(
        RecordOpBuilder.editor.setRecord.build({
          fieldId,
          newCellValue: newField.isMultipleCellValue ? newCellValue : newCellValue[0],
          oldCellValue,
        })
      );
    });

    return recordOpsMap;
  }
}