teableio/teable

View on GitHub
apps/nestjs-backend/src/features/calculation/link.service.ts

Summary

Maintainability
F
4 days
Test Coverage
import { BadRequestException, Injectable } from '@nestjs/common';
import type { ILinkCellValue, ILinkFieldOptions } from '@teable/core';
import { FieldType, Relationship } from '@teable/core';
import type { Field } from '@teable/db-main-prisma';
import { PrismaService } from '@teable/db-main-prisma';
import { Knex } from 'knex';
import { cloneDeep, keyBy, difference, groupBy, isEqual, set } from 'lodash';
import { InjectModel } from 'nest-knexjs';
import type { IFieldInstance, IFieldMap } from '../field/model/factory';
import { createFieldInstanceByRaw } from '../field/model/factory';
import type { LinkFieldDto } from '../field/model/field-dto/link-field.dto';
import { SchemaType } from '../field/util';
import { BatchService } from './batch.service';
import type { ICellChange, ICellContext } from './utils/changes';
import { isLinkCellValue } from './utils/detect-link';

export interface IFkRecordMap {
  [fieldId: string]: {
    [recordId: string]: IFkRecordItem;
  };
}

export interface IFkRecordItem {
  oldKey: string | string[] | null; // null means record have no foreignKey
  newKey: string | string[] | null; // null means to delete the foreignKey
}

export interface IRecordMapByTableId {
  [tableId: string]: {
    [recordId: string]: {
      [fieldId: string]: unknown;
    };
  };
}

export interface IFieldMapByTableId {
  [tableId: string]: {
    [fieldId: string]: IFieldInstance;
  };
}

export interface ILinkCellContext {
  recordId: string;
  fieldId: string;
  newValue?: { id: string }[] | { id: string };
  oldValue?: { id: string }[] | { id: string };
}

@Injectable()
export class LinkService {
  constructor(
    private readonly prismaService: PrismaService,
    private readonly batchService: BatchService,
    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex
  ) {}

  private validateLinkCell(cell: ILinkCellContext) {
    if (!Array.isArray(cell.newValue)) {
      return cell;
    }
    const checkSet = new Set<string>();
    cell.newValue.forEach((v) => {
      if (checkSet.has(v.id)) {
        throw new BadRequestException(`Cannot set duplicate recordId: ${v.id} in the same cell`);
      }
      checkSet.add(v.id);
    });
    return cell;
  }

  private filterLinkContext(contexts: ILinkCellContext[]): ILinkCellContext[] {
    return contexts
      .filter((ctx) => {
        if (isLinkCellValue(ctx.newValue)) {
          return true;
        }

        return isLinkCellValue(ctx.oldValue);
      })
      .map((ctx) => {
        this.validateLinkCell(ctx);
        return { ...ctx, oldValue: isLinkCellValue(ctx.oldValue) ? ctx.oldValue : undefined };
      });
  }

  private async getRelatedFieldMap(fieldIds: string[]): Promise<IFieldMapByTableId> {
    const fieldRaws = await this.prismaService.txClient().field.findMany({
      where: { id: { in: fieldIds } },
    });
    const fields = fieldRaws.map(createFieldInstanceByRaw) as LinkFieldDto[];

    const symmetricFieldRaws = await this.prismaService.txClient().field.findMany({
      where: {
        id: {
          in: fields
            .filter((field) => field.options.symmetricFieldId)
            .map((field) => field.options.symmetricFieldId as string),
        },
      },
    });

    const symmetricFields = symmetricFieldRaws.map(createFieldInstanceByRaw) as LinkFieldDto[];

    const lookedFieldRaws = await this.prismaService.txClient().field.findMany({
      where: {
        id: {
          in: fields
            .map((field) => field.options.lookupFieldId)
            .concat(symmetricFields.map((field) => field.options.lookupFieldId)),
        },
      },
    });
    const lookedFields = lookedFieldRaws.map(createFieldInstanceByRaw);

    const instanceMap = keyBy([...fields, ...symmetricFields, ...lookedFields], 'id');

    return [...fieldRaws, ...symmetricFieldRaws, ...lookedFieldRaws].reduce<IFieldMapByTableId>(
      (acc, field) => {
        const { tableId, id } = field;
        if (!acc[tableId]) {
          acc[tableId] = {};
        }
        acc[tableId][id] = instanceMap[id];
        return acc;
      },
      {}
    );
  }

  // eslint-disable-next-line sonarjs/cognitive-complexity
  private updateForeignCellForManyMany(params: {
    fkItem: IFkRecordItem;
    recordId: string;
    symmetricFieldId: string;
    sourceLookedFieldId: string;
    sourceRecordMap: IRecordMapByTableId['tableId'];
    foreignRecordMap: IRecordMapByTableId['tableId'];
  }) {
    const {
      fkItem,
      recordId,
      symmetricFieldId,
      sourceLookedFieldId,
      foreignRecordMap,
      sourceRecordMap,
    } = params;
    const oldKey = (fkItem.oldKey || []) as string[];
    const newKey = (fkItem.newKey || []) as string[];

    const toDelete = difference(oldKey, newKey);
    const toAdd = difference(newKey, oldKey);

    // Update link cell values for symmetric field of the foreign table
    if (toDelete.length) {
      toDelete.forEach((foreignRecordId) => {
        const foreignCellValue = foreignRecordMap[foreignRecordId][symmetricFieldId] as
          | ILinkCellValue[]
          | null;

        if (foreignCellValue) {
          const filteredCellValue = foreignCellValue.filter((item) => item.id !== recordId);
          foreignRecordMap[foreignRecordId][symmetricFieldId] = filteredCellValue.length
            ? filteredCellValue
            : null;
        }
      });
    }

    if (toAdd.length) {
      toAdd.forEach((foreignRecordId) => {
        const sourceRecordTitle = sourceRecordMap[recordId][sourceLookedFieldId] as
          | string
          | undefined;
        const newForeignRecord = foreignRecordMap[foreignRecordId];
        if (!newForeignRecord) {
          throw new BadRequestException(
            `Consistency error, recordId ${foreignRecordId} is not exist`
          );
        }
        const foreignCellValue = newForeignRecord[symmetricFieldId] as ILinkCellValue[] | null;
        if (foreignCellValue) {
          newForeignRecord[symmetricFieldId] = foreignCellValue.concat({
            id: recordId,
            title: sourceRecordTitle,
          });
        } else {
          newForeignRecord[symmetricFieldId] = [{ id: recordId, title: sourceRecordTitle }];
        }
      });
    }
  }

  private updateForeignCellForManyOne(params: {
    fkItem: IFkRecordItem;
    recordId: string;
    symmetricFieldId: string;
    sourceLookedFieldId: string;
    sourceRecordMap: IRecordMapByTableId['tableId'];
    foreignRecordMap: IRecordMapByTableId['tableId'];
  }) {
    const {
      fkItem,
      recordId,
      symmetricFieldId,
      sourceLookedFieldId,
      foreignRecordMap,
      sourceRecordMap,
    } = params;
    const oldKey = fkItem.oldKey as string | null;
    const newKey = fkItem.newKey as string | null;

    // Update link cell values for symmetric field of the foreign table
    if (oldKey) {
      const foreignCellValue = foreignRecordMap[oldKey][symmetricFieldId] as
        | ILinkCellValue[]
        | null;

      if (foreignCellValue) {
        const filteredCellValue = foreignCellValue.filter((item) => item.id !== recordId);
        foreignRecordMap[oldKey][symmetricFieldId] = filteredCellValue.length
          ? filteredCellValue
          : null;
      }
    }

    if (newKey) {
      const sourceRecordTitle = sourceRecordMap[recordId][sourceLookedFieldId] as
        | string
        | undefined;
      const newForeignRecord = foreignRecordMap[newKey];
      if (!newForeignRecord) {
        throw new BadRequestException(`Consistency error, recordId ${newKey} is not exist`);
      }
      const foreignCellValue = newForeignRecord[symmetricFieldId] as ILinkCellValue[] | null;
      if (foreignCellValue) {
        newForeignRecord[symmetricFieldId] = foreignCellValue.concat({
          id: recordId,
          title: sourceRecordTitle,
        });
      } else {
        newForeignRecord[symmetricFieldId] = [{ id: recordId, title: sourceRecordTitle }];
      }
    }
  }

  private updateForeignCellForOneMany(params: {
    fkItem: IFkRecordItem;
    recordId: string;
    symmetricFieldId: string;
    sourceLookedFieldId: string;
    sourceRecordMap: IRecordMapByTableId['tableId'];
    foreignRecordMap: IRecordMapByTableId['tableId'];
  }) {
    const {
      fkItem,
      recordId,
      symmetricFieldId,
      sourceLookedFieldId,
      foreignRecordMap,
      sourceRecordMap,
    } = params;

    const oldKey = (fkItem.oldKey || []) as string[];
    const newKey = (fkItem.newKey || []) as string[];

    const toDelete = difference(oldKey, newKey);
    const toAdd = difference(newKey, oldKey);

    if (toDelete.length) {
      toDelete.forEach((foreignRecordId) => {
        foreignRecordMap[foreignRecordId][symmetricFieldId] = null;
      });
    }

    if (toAdd.length) {
      const sourceRecordTitle = sourceRecordMap[recordId][sourceLookedFieldId] as
        | string
        | undefined;

      toAdd.forEach((foreignRecordId) => {
        foreignRecordMap[foreignRecordId][symmetricFieldId] = {
          id: recordId,
          title: sourceRecordTitle,
        };
      });
    }
  }

  private updateForeignCellForOneOne(params: {
    fkItem: IFkRecordItem;
    recordId: string;
    symmetricFieldId: string;
    sourceLookedFieldId: string;
    sourceRecordMap: IRecordMapByTableId['tableId'];
    foreignRecordMap: IRecordMapByTableId['tableId'];
  }) {
    const {
      fkItem,
      recordId,
      symmetricFieldId,
      sourceLookedFieldId,
      foreignRecordMap,
      sourceRecordMap,
    } = params;

    const oldKey = fkItem.oldKey as string | undefined;
    const newKey = fkItem.newKey as string | undefined;

    if (oldKey) {
      foreignRecordMap[oldKey][symmetricFieldId] = null;
    }

    if (newKey) {
      const sourceRecordTitle = sourceRecordMap[recordId][sourceLookedFieldId] as
        | string
        | undefined;

      foreignRecordMap[newKey][symmetricFieldId] = {
        id: recordId,
        title: sourceRecordTitle,
      };
    }
  }

  // update link cellValue title for the user input value of the source table
  private fixLinkCellTitle(params: {
    newKey: string | string[] | null;
    recordId: string;
    linkFieldId: string;
    foreignLookedFieldId: string;
    sourceRecordMap: IRecordMapByTableId['tableId'];
    foreignRecordMap: IRecordMapByTableId['tableId'];
  }) {
    const {
      newKey,
      recordId,
      linkFieldId,
      foreignLookedFieldId,
      foreignRecordMap,
      sourceRecordMap,
    } = params;

    if (!newKey) {
      return;
    }

    if (Array.isArray(newKey)) {
      sourceRecordMap[recordId][linkFieldId] = newKey.map((key) => ({
        id: key,
        title: foreignRecordMap[key][foreignLookedFieldId] as string | undefined,
      }));
      return;
    }

    const foreignRecordTitle = foreignRecordMap[newKey][foreignLookedFieldId] as string | undefined;
    sourceRecordMap[recordId][linkFieldId] = { id: newKey, title: foreignRecordTitle };
  }

  // eslint-disable-next-line sonarjs/cognitive-complexity
  private async updateLinkRecord(
    tableId: string,
    fkRecordMap: IFkRecordMap,
    fieldMapByTableId: { [tableId: string]: IFieldMap },
    originRecordMapByTableId: IRecordMapByTableId
  ): Promise<IRecordMapByTableId> {
    const recordMapByTableId = cloneDeep(originRecordMapByTableId);
    for (const fieldId in fkRecordMap) {
      const linkField = fieldMapByTableId[tableId][fieldId] as LinkFieldDto;
      const linkFieldId = linkField.id;
      const relationship = linkField.options.relationship;
      const foreignTableId = linkField.options.foreignTableId;
      const foreignLookedFieldId = linkField.options.lookupFieldId;

      const sourceRecordMap = recordMapByTableId[tableId];
      const foreignRecordMap = recordMapByTableId[foreignTableId];
      const symmetricFieldId = linkField.options.symmetricFieldId;

      for (const recordId in fkRecordMap[fieldId]) {
        const fkItem = fkRecordMap[fieldId][recordId];

        this.fixLinkCellTitle({
          newKey: fkItem.newKey,
          recordId,
          linkFieldId,
          foreignLookedFieldId,
          sourceRecordMap,
          foreignRecordMap,
        });

        if (!symmetricFieldId) {
          continue;
        }
        const symmetricField = fieldMapByTableId[foreignTableId][symmetricFieldId] as LinkFieldDto;
        const sourceLookedFieldId = symmetricField.options.lookupFieldId;
        const params = {
          fkItem,
          recordId,
          symmetricFieldId,
          sourceLookedFieldId,
          sourceRecordMap,
          foreignRecordMap,
        };
        if (relationship === Relationship.ManyMany) {
          this.updateForeignCellForManyMany(params);
        }
        if (relationship === Relationship.ManyOne) {
          this.updateForeignCellForManyOne(params);
        }
        if (relationship === Relationship.OneMany) {
          this.updateForeignCellForOneMany(params);
        }
        if (relationship === Relationship.OneOne) {
          this.updateForeignCellForOneOne(params);
        }
      }
    }
    return recordMapByTableId;
  }

  private async getForeignKeys(
    recordIds: string[],
    linkRecordIds: string[],
    options: ILinkFieldOptions
  ) {
    const { fkHostTableName, selfKeyName, foreignKeyName } = options;

    const query = this.knex(fkHostTableName)
      .select({
        id: selfKeyName,
        foreignId: foreignKeyName,
      })
      .whereIn(selfKeyName, recordIds)
      .orWhereIn(foreignKeyName, linkRecordIds)
      .whereNotNull(selfKeyName)
      .whereNotNull(foreignKeyName)
      .toQuery();

    return this.prismaService
      .txClient()
      .$queryRawUnsafe<{ id: string; foreignId: string }[]>(query);
  }

  async getAllForeignKeys(options: ILinkFieldOptions) {
    const { fkHostTableName, selfKeyName, foreignKeyName } = options;

    const query = this.knex(fkHostTableName)
      .select({
        id: selfKeyName,
        foreignId: foreignKeyName,
      })
      .whereNotNull(selfKeyName)
      .whereNotNull(foreignKeyName)
      .toQuery();

    return this.prismaService
      .txClient()
      .$queryRawUnsafe<{ id: string; foreignId: string }[]>(query);
  }

  private async getJoinedForeignKeys(linkRecordIds: string[], options: ILinkFieldOptions) {
    const { fkHostTableName, selfKeyName, foreignKeyName } = options;

    const query = this.knex(fkHostTableName)
      .select({
        id: selfKeyName,
        foreignId: foreignKeyName,
      })
      .whereIn(selfKeyName, function () {
        this.select(selfKeyName)
          .from(fkHostTableName)
          .whereIn(foreignKeyName, linkRecordIds)
          .whereNotNull(selfKeyName);
      })
      .whereNotNull(foreignKeyName)
      .toQuery();

    return this.prismaService
      .txClient()
      .$queryRawUnsafe<{ id: string; foreignId: string }[]>(query);
  }

  /**
   * Checks if there are duplicate associations in one-to-one and one-to-many relationships.
   */
  private checkForIllegalDuplicateLinks(
    field: LinkFieldDto,
    recordIds: string[],
    indexedCellContext: Record<string, ILinkCellContext>
  ) {
    const relationship = field.options.relationship;
    if (relationship === Relationship.ManyMany || relationship === Relationship.ManyOne) {
      return;
    }
    const checkSet = new Set<string>();

    recordIds.forEach((recordId) => {
      const cellValue = indexedCellContext[`${field.id}-${recordId}`].newValue;
      if (!cellValue) {
        return;
      }
      if (Array.isArray(cellValue)) {
        cellValue.forEach((item) => {
          if (checkSet.has(item.id)) {
            throw new BadRequestException(
              `Consistency error, ${relationship} link field {${field.id}} unable to link a record (${item.id}) more than once`
            );
          }
          checkSet.add(item.id);
        });
        return;
      }
      if (checkSet.has(cellValue.id)) {
        throw new BadRequestException(
          `Consistency error, ${relationship} link field {${field.id}} unable to link a record (${cellValue.id}) more than once`
        );
      }
      checkSet.add(cellValue.id);
    });
  }

  // eslint-disable-next-line sonarjs/cognitive-complexity
  private parseFkRecordItem(
    field: LinkFieldDto,
    cellContexts: ILinkCellContext[],
    foreignKeys: {
      id: string;
      foreignId: string;
    }[]
  ): Record<string, IFkRecordItem> {
    const relationship = field.options.relationship;
    const foreignKeysIndexed = groupBy(foreignKeys, 'id');
    const foreignKeysReverseIndexed =
      relationship === Relationship.OneMany || relationship === Relationship.OneOne
        ? groupBy(foreignKeys, 'foreignId')
        : undefined;

    // eslint-disable-next-line sonarjs/cognitive-complexity
    return cellContexts.reduce<IFkRecordMap['fieldId']>((acc, cellContext) => {
      // this two relations only have one key in one recordId
      const id = cellContext.recordId;
      const foreignKeys = foreignKeysIndexed[id];
      if (relationship === Relationship.OneOne || relationship === Relationship.ManyOne) {
        const newCellValue = cellContext.newValue as ILinkCellValue | undefined;
        if ((foreignKeys?.length ?? 0) > 1) {
          throw new Error('duplicate foreign key from database');
        }

        const foreignRecordId = foreignKeys?.[0].foreignId;
        const oldKey = foreignRecordId || null;
        const newKey = newCellValue?.id || null;
        if (oldKey === newKey) {
          return acc;
        }

        if (newKey && foreignKeysReverseIndexed?.[newKey]) {
          throw new BadRequestException(
            `Consistency error, ${relationship} link field {${field.id}} unable to link a record (${newKey}) more than once`
          );
        }

        acc[id] = { oldKey, newKey };
        return acc;
      }

      if (relationship === Relationship.ManyMany || relationship === Relationship.OneMany) {
        const newCellValue = cellContext.newValue as ILinkCellValue[] | undefined;
        const oldKey = foreignKeys?.map((key) => key.foreignId) ?? null;
        const newKey = newCellValue?.map((item) => item.id) ?? null;

        const extraKey = difference(newKey ?? [], oldKey ?? []);

        extraKey.forEach((key) => {
          if (foreignKeysReverseIndexed?.[key]) {
            throw new BadRequestException(
              `Consistency error, ${relationship} link field {${field.id}} unable to link a record (${key}) more than once`
            );
          }
        });
        acc[id] = {
          oldKey,
          newKey,
        };
        return acc;
      }
      return acc;
    }, {});
  }

  /**
   * Tip: for single source of truth principle, we should only trust foreign key recordId
   *
   * 1. get all edited recordId and group by fieldId
   * 2. get all exist foreign key recordId
   */
  private async getFkRecordMap(
    fieldMap: IFieldMap,
    cellContexts: ILinkCellContext[]
  ): Promise<IFkRecordMap> {
    const fkRecordMap: IFkRecordMap = {};

    const cellGroupByFieldId = groupBy(cellContexts, (ctx) => ctx.fieldId);
    const indexedCellContext = keyBy(cellContexts, (ctx) => `${ctx.fieldId}-${ctx.recordId}`);
    for (const fieldId in cellGroupByFieldId) {
      const field = fieldMap[fieldId];
      if (!field) {
        throw new BadRequestException(`Field ${fieldId} not found`);
      }

      if (field.type !== FieldType.Link) {
        throw new BadRequestException(`Field ${fieldId} is not link field`);
      }

      const recordIds = cellGroupByFieldId[fieldId].map((ctx) => ctx.recordId);
      const linkRecordIds = cellGroupByFieldId[fieldId]
        .map((ctx) =>
          [ctx.oldValue, ctx.newValue]
            .flat()
            .filter(Boolean)
            .map((item) => item?.id as string)
        )
        .flat();

      const foreignKeys = await this.getForeignKeys(recordIds, linkRecordIds, field.options);
      this.checkForIllegalDuplicateLinks(field, recordIds, indexedCellContext);

      fkRecordMap[fieldId] = this.parseFkRecordItem(
        field,
        cellGroupByFieldId[fieldId],
        foreignKeys
      );
    }

    return fkRecordMap;
  }

  // create the key for recordMapByTableId but leave the undefined value for the next step
  private getRecordMapStruct(
    tableId: string,
    fieldMapByTableId: { [tableId: string]: IFieldMap },
    cellContexts: ILinkCellContext[]
  ) {
    const recordMapByTableId: IRecordMapByTableId = {};

    for (const cellContext of cellContexts) {
      const { recordId, fieldId, newValue, oldValue } = cellContext;
      const linkRecordIds = [oldValue, newValue]
        .flat()
        .filter(Boolean)
        .map((item) => item?.id as string);
      const field = fieldMapByTableId[tableId][fieldId] as LinkFieldDto;
      const foreignTableId = field.options.foreignTableId;
      const symmetricFieldId = field.options.symmetricFieldId;
      const symmetricField = symmetricFieldId
        ? (fieldMapByTableId[foreignTableId][symmetricFieldId] as LinkFieldDto)
        : undefined;
      const foreignLookedFieldId = field.options.lookupFieldId;
      const lookedFieldId = symmetricField?.options.lookupFieldId;

      set(recordMapByTableId, [tableId, recordId, fieldId], undefined);
      lookedFieldId && set(recordMapByTableId, [tableId, recordId, lookedFieldId], undefined);

      // create object key for record in looked field
      linkRecordIds.forEach((linkRecordId) => {
        symmetricFieldId &&
          set(recordMapByTableId, [foreignTableId, linkRecordId, symmetricFieldId], undefined);
        set(recordMapByTableId, [foreignTableId, linkRecordId, foreignLookedFieldId], undefined);
      });
    }

    return recordMapByTableId;
  }

  // eslint-disable-next-line sonarjs/cognitive-complexity
  private async fetchRecordMap(
    tableId2DbTableName: { [tableId: string]: string },
    fieldMapByTableId: { [tableId: string]: IFieldMap },
    recordMapByTableId: IRecordMapByTableId,
    cellContexts: ICellContext[],
    fromReset?: boolean
  ): Promise<IRecordMapByTableId> {
    const cellContextGroup = keyBy(cellContexts, (ctx) => `${ctx.recordId}-${ctx.fieldId}`);
    for (const tableId in recordMapByTableId) {
      const recordLookupFieldsMap = recordMapByTableId[tableId];
      const recordIds = Object.keys(recordLookupFieldsMap);
      const fieldIds = Array.from(
        Object.values(recordLookupFieldsMap).reduce<Set<string>>((pre, cur) => {
          for (const fieldId in cur) {
            pre.add(fieldId);
          }
          return pre;
        }, new Set())
      );

      const dbFieldName2FieldId: { [dbFieldName: string]: string } = {};
      const dbFieldNames = fieldIds.map((fieldId) => {
        const field = fieldMapByTableId[tableId][fieldId];
        // dbForeignName is not exit in fieldMapByTableId
        if (!field) {
          return fieldId;
        }
        dbFieldName2FieldId[field.dbFieldName] = fieldId;
        return field.dbFieldName;
      });

      const nativeQuery = this.knex(tableId2DbTableName[tableId])
        .select(dbFieldNames.concat('__id'))
        .whereIn('__id', recordIds)
        .toQuery();

      const recordRaw = await this.prismaService
        .txClient()
        .$queryRawUnsafe<{ [dbTableName: string]: unknown }[]>(nativeQuery);

      recordRaw.forEach((record) => {
        const recordId = record.__id as string;
        delete record.__id;
        for (const dbFieldName in record) {
          const fieldId = dbFieldName2FieldId[dbFieldName];
          let cellValue = record[dbFieldName];

          // dbForeignName is not exit in fieldMapByTableId
          if (!fieldId) {
            recordLookupFieldsMap[recordId][dbFieldName] = cellValue;
            continue;
          }
          const field = fieldMapByTableId[tableId][fieldId];
          if (fromReset && field.type === FieldType.Link) {
            continue;
          }

          // Overlay with new data, especially cellValue in primary field
          const inputData = cellContextGroup[`${recordId}-${fieldId}`];
          if (field.type !== FieldType.Link && inputData !== undefined) {
            recordLookupFieldsMap[recordId][fieldId] = inputData.newValue ?? undefined;
            continue;
          }

          cellValue = field.convertDBValue2CellValue(cellValue);

          recordLookupFieldsMap[recordId][fieldId] = cellValue ?? undefined;
        }
      }, {});
    }

    return recordMapByTableId;
  }

  private async getTableId2DbTableName(tableIds: string[]) {
    const tableRaws = await this.prismaService.txClient().tableMeta.findMany({
      where: {
        id: {
          in: tableIds,
        },
      },
      select: {
        id: true,
        dbTableName: true,
      },
    });
    return tableRaws.reduce<{ [tableId: string]: string }>((acc, cur) => {
      acc[cur.id] = cur.dbTableName;
      return acc;
    }, {});
  }

  private diffLinkCellChange(
    fieldMapByTableId: { [tableId: string]: IFieldMap },
    originRecordMapByTableId: IRecordMapByTableId,
    updatedRecordMapByTableId: IRecordMapByTableId
  ): ICellChange[] {
    const changes: ICellChange[] = [];

    for (const tableId in originRecordMapByTableId) {
      const originRecords = originRecordMapByTableId[tableId];
      const updatedRecords = updatedRecordMapByTableId[tableId];
      const fieldMap = fieldMapByTableId[tableId];

      for (const recordId in originRecords) {
        const originFields = originRecords[recordId];
        const updatedFields = updatedRecords[recordId];

        for (const fieldId in originFields) {
          if (fieldMap[fieldId].type !== FieldType.Link) {
            continue;
          }

          const oldValue = originFields[fieldId];
          const newValue = updatedFields[fieldId];

          if (!isEqual(oldValue, newValue)) {
            changes.push({ tableId, recordId, fieldId, oldValue, newValue });
          }
        }
      }
    }

    return changes;
  }

  private async getDerivateByCellContexts(
    tableId: string,
    tableId2DbTableName: { [tableId: string]: string },
    fieldMapByTableId: { [tableId: string]: IFieldMap },
    linkContexts: ILinkCellContext[],
    cellContexts: ICellContext[],
    fromReset?: boolean
  ): Promise<{
    cellChanges: ICellChange[];
    saveForeignKeyToDb: () => Promise<void>;
  }> {
    const fieldMap = fieldMapByTableId[tableId];
    const recordMapStruct = this.getRecordMapStruct(tableId, fieldMapByTableId, linkContexts);

    // console.log('fieldMapByTableId', fieldMapByTableId);
    const fkRecordMap = await this.getFkRecordMap(fieldMap, linkContexts);

    const originRecordMapByTableId = await this.fetchRecordMap(
      tableId2DbTableName,
      fieldMapByTableId,
      recordMapStruct,
      cellContexts,
      fromReset
    );

    const updatedRecordMapByTableId = await this.updateLinkRecord(
      tableId,
      fkRecordMap,
      fieldMapByTableId,
      originRecordMapByTableId
    );

    const cellChanges = this.diffLinkCellChange(
      fieldMapByTableId,
      originRecordMapByTableId,
      updatedRecordMapByTableId
    );

    return {
      cellChanges,
      saveForeignKeyToDb: async () => {
        return this.saveForeignKeyToDb(fieldMapByTableId[tableId], fkRecordMap);
      },
    };
  }

  private async saveForeignKeyForManyMany(
    field: LinkFieldDto,
    fkMap: { [recordId: string]: IFkRecordItem }
  ) {
    const { selfKeyName, foreignKeyName, fkHostTableName } = field.options;

    const toDelete: [string, string][] = [];
    const toAdd: [string, string][] = [];
    for (const recordId in fkMap) {
      const fkItem = fkMap[recordId];
      const oldKey = (fkItem.oldKey || []) as string[];
      const newKey = (fkItem.newKey || []) as string[];

      difference(oldKey, newKey).forEach((key) => toDelete.push([recordId, key]));
      difference(newKey, oldKey).forEach((key) => toAdd.push([recordId, key]));
    }

    if (toDelete.length) {
      const query = this.knex(fkHostTableName)
        .whereIn([selfKeyName, foreignKeyName], toDelete)
        .delete()
        .toQuery();
      await this.prismaService.txClient().$executeRawUnsafe(query);
    }

    if (toAdd.length) {
      const query = this.knex(fkHostTableName)
        .insert(
          toAdd.map(([source, target]) => ({
            [selfKeyName]: source,
            [foreignKeyName]: target,
          }))
        )
        .toQuery();
      await this.prismaService.txClient().$executeRawUnsafe(query);
    }
  }

  private async saveForeignKeyForManyOne(
    field: LinkFieldDto,
    fkMap: { [recordId: string]: IFkRecordItem }
  ) {
    const { selfKeyName, foreignKeyName, fkHostTableName } = field.options;

    const toDelete: [string, string][] = [];
    const toAdd: [string, string][] = [];
    for (const recordId in fkMap) {
      const fkItem = fkMap[recordId];
      const oldKey = fkItem.oldKey as string | null;
      const newKey = fkItem.newKey as string | null;

      oldKey && toDelete.push([recordId, oldKey]);
      newKey && toAdd.push([recordId, newKey]);
    }

    if (toDelete.length) {
      const query = this.knex(fkHostTableName)
        .update({ [foreignKeyName]: null })
        .whereIn([selfKeyName, foreignKeyName], toDelete)
        .toQuery();
      await this.prismaService.txClient().$executeRawUnsafe(query);
    }

    if (toAdd.length) {
      await this.batchService.batchUpdateDB(
        fkHostTableName,
        selfKeyName,
        [{ dbFieldName: foreignKeyName, schemaType: SchemaType.String }],
        toAdd.map(([recordId, foreignRecordId]) => ({
          id: recordId,
          values: { [foreignKeyName]: foreignRecordId },
        }))
      );
    }
  }

  private async saveForeignKeyForOneMany(
    field: LinkFieldDto,
    fkMap: { [recordId: string]: IFkRecordItem }
  ) {
    const { selfKeyName, foreignKeyName, fkHostTableName, isOneWay } = field.options;

    if (isOneWay) {
      this.saveForeignKeyForManyMany(field, fkMap);
      return;
    }
    const toDelete: [string, string][] = [];
    const toAdd: [string, string][] = [];
    for (const recordId in fkMap) {
      const fkItem = fkMap[recordId];
      const oldKey = (fkItem.oldKey || []) as string[];
      const newKey = (fkItem.newKey || []) as string[];

      difference(oldKey, newKey).forEach((key) => toDelete.push([recordId, key]));
      difference(newKey, oldKey).forEach((key) => toAdd.push([recordId, key]));
    }

    if (toDelete.length) {
      const query = this.knex(fkHostTableName)
        .update({ [selfKeyName]: null })
        .whereIn([selfKeyName, foreignKeyName], toDelete)
        .toQuery();
      await this.prismaService.txClient().$executeRawUnsafe(query);
    }

    if (toAdd.length) {
      await this.batchService.batchUpdateDB(
        fkHostTableName,
        foreignKeyName,
        [{ dbFieldName: selfKeyName, schemaType: SchemaType.String }],
        toAdd.map(([recordId, foreignRecordId]) => ({
          id: foreignRecordId,
          values: { [selfKeyName]: recordId },
        }))
      );
    }
  }

  private async saveForeignKeyForOneOne(
    field: LinkFieldDto,
    fkMap: { [recordId: string]: IFkRecordItem }
  ) {
    const { selfKeyName, foreignKeyName, fkHostTableName } = field.options;
    if (selfKeyName === '__id') {
      await this.saveForeignKeyForManyOne(field, fkMap);
    } else {
      const toDelete: [string, string][] = [];
      const toAdd: [string, string][] = [];
      for (const recordId in fkMap) {
        const fkItem = fkMap[recordId];
        const oldKey = fkItem.oldKey as string | null;
        const newKey = fkItem.newKey as string | null;

        oldKey && toDelete.push([recordId, oldKey]);
        newKey && toAdd.push([recordId, newKey]);
      }

      if (toDelete.length) {
        const query = this.knex(fkHostTableName)
          .update({ [selfKeyName]: null })
          .whereIn([selfKeyName, foreignKeyName], toDelete)
          .toQuery();
        await this.prismaService.txClient().$executeRawUnsafe(query);
      }

      if (toAdd.length) {
        await this.batchService.batchUpdateDB(
          fkHostTableName,
          foreignKeyName,
          [{ dbFieldName: selfKeyName, schemaType: SchemaType.String }],
          toAdd.map(([recordId, foreignRecordId]) => ({
            id: foreignRecordId,
            values: { [selfKeyName]: recordId },
          }))
        );
      }
    }
  }

  private async saveForeignKeyToDb(fieldMap: IFieldMap, fkRecordMap: IFkRecordMap) {
    for (const fieldId in fkRecordMap) {
      const fkMap = fkRecordMap[fieldId];
      const field = fieldMap[fieldId] as LinkFieldDto;
      const relationship = field.options.relationship;
      if (relationship === Relationship.ManyMany) {
        await this.saveForeignKeyForManyMany(field, fkMap);
      }
      if (relationship === Relationship.ManyOne) {
        await this.saveForeignKeyForManyOne(field, fkMap);
      }
      if (relationship === Relationship.OneMany) {
        await this.saveForeignKeyForOneMany(field, fkMap);
      }
      if (relationship === Relationship.OneOne) {
        await this.saveForeignKeyForOneOne(field, fkMap);
      }
    }
  }

  /**
   * strategy
   * 0: define `main table` is where foreign key located in, `foreign table` is where foreign key referenced to
   * 1. generate foreign key changes, cache effected recordIds, both main table and foreign table
   * 2. update foreign key by changes and submit origin op
   * 3. check and generate op to update main table by cached recordIds
   * 4. check and generate op to update foreign table by cached recordIds
   */
  async getDerivateByLink(tableId: string, cellContexts: ICellContext[], fromReset?: boolean) {
    const linkContexts = this.filterLinkContext(cellContexts as ILinkCellContext[]);
    if (!linkContexts.length) {
      return;
    }
    const fieldIds = linkContexts.map((ctx) => ctx.fieldId);
    const fieldMapByTableId = await this.getRelatedFieldMap(fieldIds);
    const tableId2DbTableName = await this.getTableId2DbTableName(Object.keys(fieldMapByTableId));

    return this.getDerivateByCellContexts(
      tableId,
      tableId2DbTableName,
      fieldMapByTableId,
      linkContexts,
      cellContexts,
      fromReset
    );
  }

  private parseFkRecordItemToDelete(
    options: ILinkFieldOptions,
    toDeleteRecordIds: string[],
    foreignKeys: {
      id: string;
      foreignId: string;
    }[]
  ): Record<string, IFkRecordItem> {
    const relationship = options.relationship;
    const foreignKeysIndexed = groupBy(foreignKeys, 'id');
    const toDeleteSet = new Set(toDeleteRecordIds);

    return Object.keys(foreignKeysIndexed).reduce<IFkRecordMap['fieldId']>((acc, id) => {
      // this two relations only have one key in one recordId
      const foreignKeys = foreignKeysIndexed[id];
      if (relationship === Relationship.OneOne || relationship === Relationship.ManyOne) {
        if ((foreignKeys?.length ?? 0) > 1) {
          throw new Error('duplicate foreign key from database');
        }

        const foreignRecordId = foreignKeys?.[0].foreignId;
        const oldKey = foreignRecordId || null;
        if (!toDeleteSet.has(foreignRecordId)) {
          return acc;
        }

        acc[id] = { oldKey, newKey: null };
        return acc;
      }

      if (relationship === Relationship.ManyMany || relationship === Relationship.OneMany) {
        const oldKey = foreignKeys?.map((key) => key.foreignId) ?? null;
        if (!oldKey) {
          return acc;
        }

        const newKey = oldKey.filter((key) => !toDeleteSet.has(key));

        if (newKey.length === oldKey.length) {
          return acc;
        }

        acc[id] = {
          oldKey,
          newKey: newKey.length ? newKey : null,
        };
        return acc;
      }
      return acc;
    }, {});
  }

  private async getContextByDelete(linkFieldRaws: Field[], recordIds: string[]) {
    const cellContextsMap: { [tableId: string]: ICellContext[] } = {};

    const keyToValue = (key: string | string[] | null) =>
      key ? (Array.isArray(key) ? key.map((id) => ({ id })) : { id: key }) : null;

    for (const fieldRaws of linkFieldRaws) {
      const options = JSON.parse(fieldRaws.options as string) as ILinkFieldOptions;
      const tableId = fieldRaws.tableId;
      const foreignKeys = await this.getJoinedForeignKeys(recordIds, options);
      const fieldItems = this.parseFkRecordItemToDelete(options, recordIds, foreignKeys);
      if (!cellContextsMap[tableId]) {
        cellContextsMap[tableId] = [];
      }
      Object.keys(fieldItems).forEach((recordId) => {
        const { oldKey, newKey } = fieldItems[recordId];
        cellContextsMap[tableId].push({
          fieldId: fieldRaws.id,
          recordId,
          oldValue: keyToValue(oldKey),
          newValue: keyToValue(newKey),
        });
      });
    }

    return cellContextsMap;
  }

  async getRelatedLinkFieldRaws(tableId: string) {
    const { id: primaryFieldId } = await this.prismaService
      .txClient()
      .field.findFirstOrThrow({
        where: { tableId, deletedTime: null, isPrimary: true },
        select: { id: true },
      })
      .catch(() => {
        throw new BadRequestException(`Primary field not found`);
      });

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

    const referenceFieldIds = references.map((ref) => ref.toFieldId);

    return await this.prismaService.txClient().field.findMany({
      where: {
        id: { in: referenceFieldIds },
        type: FieldType.Link,
        isLookup: null,
        deletedTime: null,
      },
    });
  }

  async getDeleteRecordUpdateContext(tableId: string, recordIds: string[]) {
    const linkFieldRaws = await this.getRelatedLinkFieldRaws(tableId);

    return await this.getContextByDelete(linkFieldRaws, recordIds);
  }
}