• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

teableio / teable / 11680926838

05 Nov 2024 09:10AM UTC coverage: 84.507% (-0.005%) from 84.512%
11680926838

Pull #1056

github

web-flow
Merge 20734dbe7 into 1fdc1eec3
Pull Request #1056: fix: convert field error causing the deleted view params

5867 of 6157 branches covered (95.29%)

1 of 1 new or added line in 1 file covered. (100.0%)

2 existing lines in 1 file now uncovered.

38728 of 45828 relevant lines covered (84.51%)

1637.48 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.53
/apps/nestjs-backend/src/features/calculation/reference.service.ts
1
import {
4✔
2
  BadRequestException,
4✔
3
  Injectable,
4✔
4
  InternalServerErrorException,
4✔
5
  Logger,
4✔
6
} from '@nestjs/common';
4✔
7
import type {
4✔
8
  IFieldVo,
4✔
9
  ILinkCellValue,
4✔
10
  ILinkFieldOptions,
4✔
11
  IOtOperation,
4✔
12
  IRecord,
4✔
13
} from '@teable/core';
4✔
14
import { evaluate, FieldType, isMultiValueLink, RecordOpBuilder, Relationship } from '@teable/core';
4✔
15
import { PrismaService } from '@teable/db-main-prisma';
4✔
16
import type { IUserInfoVo } from '@teable/openapi';
4✔
17
import { instanceToPlain } from 'class-transformer';
4✔
18
import { Knex } from 'knex';
4✔
19
import { cloneDeep, difference, groupBy, isEmpty, keyBy, unionWith, uniq } from 'lodash';
4✔
20
import { InjectModel } from 'nest-knexjs';
4✔
21
import { preservedDbFieldNames } from '../field/constant';
4✔
22
import type { IFieldInstance, IFieldMap } from '../field/model/factory';
4✔
23
import { createFieldInstanceByRaw, createFieldInstanceByVo } from '../field/model/factory';
4✔
24
import type { AutoNumberFieldDto } from '../field/model/field-dto/auto-number-field.dto';
4✔
25
import type { CreatedTimeFieldDto } from '../field/model/field-dto/created-time-field.dto';
4✔
26
import type { FormulaFieldDto } from '../field/model/field-dto/formula-field.dto';
4✔
27
import type { LastModifiedTimeFieldDto } from '../field/model/field-dto/last-modified-time-field.dto';
4✔
28
import type { LinkFieldDto } from '../field/model/field-dto/link-field.dto';
4✔
29
import type { ICellChange } from './utils/changes';
4✔
30
import { formatChangesToOps, mergeDuplicateChange } from './utils/changes';
4✔
31
import { isLinkCellValue } from './utils/detect-link';
4✔
32
import type { IAdjacencyMap } from './utils/dfs';
4✔
33
import {
4✔
34
  buildCompressedAdjacencyMap,
4✔
35
  filterDirectedGraph,
4✔
36
  topoOrderWithDepends,
4✔
37
} from './utils/dfs';
4✔
38

4✔
39
// topo item is for field level reference, all id stands for fieldId;
4✔
40
export interface ITopoItem {
4✔
41
  id: string;
4✔
42
  dependencies: string[];
4✔
43
}
4✔
44

4✔
45
export interface IGraphItem {
4✔
46
  fromFieldId: string;
4✔
47
  toFieldId: string;
4✔
48
}
4✔
49

4✔
50
export interface IRecordMap {
4✔
51
  [recordId: string]: IRecord;
4✔
52
}
4✔
53

4✔
54
export interface IRecordItem {
4✔
55
  record: IRecord;
4✔
56
  dependencies?: IRecord[];
4✔
57
}
4✔
58

4✔
59
export interface IRecordData {
4✔
60
  id: string;
4✔
61
  fieldId: string;
4✔
62
  oldValue?: unknown;
4✔
63
  newValue: unknown;
4✔
64
}
4✔
65

4✔
66
export interface IRelatedRecordItem {
4✔
67
  fieldId: string;
4✔
68
  toId: string;
4✔
69
  fromId: string;
4✔
70
}
4✔
71

4✔
72
export interface IOpsMap {
4✔
73
  [tableId: string]: {
4✔
74
    [keyId: string]: IOtOperation[];
4✔
75
  };
4✔
76
}
4✔
77

4✔
78
export interface ITopoItemWithRecords extends ITopoItem {
4✔
79
  recordItemMap?: Record<string, IRecordItem>;
4✔
80
}
4✔
81

4✔
82
export interface ITopoLinkOrder {
4✔
83
  fieldId: string;
4✔
84
  relationship: Relationship;
4✔
85
  fkHostTableName: string;
4✔
86
  selfKeyName: string;
4✔
87
  foreignKeyName: string;
4✔
88
}
4✔
89

4✔
90
@Injectable()
4✔
91
export class ReferenceService {
4✔
92
  private readonly logger = new Logger(ReferenceService.name);
395✔
93

395✔
94
  constructor(
395✔
95
    private readonly prismaService: PrismaService,
395✔
96
    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex
395✔
97
  ) {}
395✔
98

395✔
99
  /**
395✔
100
   * Strategy of calculation.
395✔
101
   * update link field in a record is a special operation for calculation.
395✔
102
   * when modify a link field in a record, we should update itself and the cells dependent it,
395✔
103
   * there are 3 kinds of scene: add delete and replace
395✔
104
   * 1. when delete a item we should calculate it [before] delete the foreignKey for reference retrieval.
395✔
105
   * 2. when add a item we should calculate it [after] add the foreignKey for reference retrieval.
395✔
106
   * So how do we handle replace?
395✔
107
   * split the replace to [delete] and [others], then do it as same as above.
395✔
108
   *
395✔
109
   * Summarize:
395✔
110
   * 1. calculate the delete operation
395✔
111
   * 2. update foreignKey
395✔
112
   * 3. calculate the others operation
395✔
113
   *
395✔
114
   * saveForeignKeyToDb a method of foreignKey update operation. we should call it after delete operation.
395✔
115
   */
395✔
116
  async calculateOpsMap(opsMap: IOpsMap, saveForeignKeyToDb?: () => Promise<void>) {
395✔
117
    const { recordDataDelete, recordDataRemains } = this.splitOpsMap(opsMap);
2,956✔
118
    // console.log('recordDataDelete', JSON.stringify(recordDataDelete, null, 2));
2,956✔
119
    const resultBefore = await this.calculate(this.mergeDuplicateRecordData(recordDataDelete));
2,956✔
120
    // console.log('resultBefore', JSON.stringify(resultBefore?.changes, null, 2));
2,956✔
121

2,956✔
122
    saveForeignKeyToDb && (await saveForeignKeyToDb());
2,956✔
123

545✔
124
    // console.log('recordDataRemains', JSON.stringify(recordDataRemains, null, 2));
545✔
125
    const resultAfter = await this.calculate(this.mergeDuplicateRecordData(recordDataRemains));
545✔
126
    // console.log('resultAfter', JSON.stringify(resultAfter?.changes, null, 2));
2,956✔
127

2,956✔
128
    const changes = [resultBefore?.changes, resultAfter?.changes]
2,956✔
129
      .filter(Boolean)
2,956✔
130
      .flat() as ICellChange[];
2,956✔
131

2,956✔
132
    const fieldMap = Object.assign({}, resultBefore?.fieldMap, resultAfter?.fieldMap);
2,956✔
133

2,956✔
134
    const tableId2DbTableName = Object.assign(
2,956✔
135
      {},
2,956✔
136
      resultBefore?.tableId2DbTableName,
2,956✔
137
      resultAfter?.tableId2DbTableName
2,956✔
138
    );
2,956✔
139

2,956✔
140
    return {
2,956✔
141
      opsMap: formatChangesToOps(changes),
2,956✔
142
      fieldMap,
2,956✔
143
      tableId2DbTableName,
2,956✔
144
    };
2,956✔
145
  }
2,956✔
146

395✔
147
  getTopoOrdersMap(fieldIds: string[], directedGraph: IGraphItem[]) {
395✔
148
    return fieldIds.reduce<{
1,994✔
149
      [fieldId: string]: ITopoItem[];
1,994✔
150
    }>((pre, fieldId) => {
1,994✔
151
      try {
3,924✔
152
        pre[fieldId] = topoOrderWithDepends(fieldId, directedGraph);
3,924✔
153
      } catch (e) {
3,924✔
154
        throw new BadRequestException((e as { message: string }).message);
×
155
      }
×
156
      return pre;
3,924✔
157
    }, {});
1,994✔
158
  }
1,994✔
159

395✔
160
  getLinkAdjacencyMap(fieldMap: IFieldMap, directedGraph: IGraphItem[]) {
395✔
161
    const linkIdSet = Object.values(fieldMap).reduce((pre, field) => {
1,477✔
162
      if (field.lookupOptions || field.type === FieldType.Link) {
6,321✔
163
        pre.add(field.id);
3,380✔
164
      }
3,380✔
165
      return pre;
6,321✔
166
    }, new Set<string>());
1,477✔
167
    if (linkIdSet.size === 0) {
1,477✔
168
      return {};
316✔
169
    }
316✔
170
    return buildCompressedAdjacencyMap(directedGraph, linkIdSet);
1,161✔
171
  }
1,161✔
172

395✔
173
  async prepareCalculation(recordData: IRecordData[]) {
395✔
174
    if (!recordData.length) {
5,914✔
175
      return;
3,665✔
176
    }
3,665✔
177
    const { directedGraph, startFieldIds, startRecordIds } =
2,249✔
178
      await this.getDirectedGraph(recordData);
2,249✔
179
    if (!directedGraph.length) {
5,896✔
180
      return;
1,317✔
181
    }
1,317✔
182

932✔
183
    // get all related field by undirected graph
932✔
184
    const allFieldIds = uniq(this.flatGraph(directedGraph).concat(startFieldIds));
932✔
185
    // prepare all related data
932✔
186
    const {
932✔
187
      fieldMap,
932✔
188
      fieldId2TableId,
932✔
189
      dbTableName2fields,
932✔
190
      tableId2DbTableName,
932✔
191
      fieldId2DbTableName,
932✔
192
    } = await this.createAuxiliaryData(allFieldIds);
932✔
193

932✔
194
    const topoOrdersMap = this.getTopoOrdersMap(startFieldIds, directedGraph);
932✔
195

932✔
196
    const linkAdjacencyMap = this.getLinkAdjacencyMap(fieldMap, directedGraph);
932✔
197

932✔
198
    if (isEmpty(topoOrdersMap)) {
5,630✔
199
      return;
×
200
    }
✔
201

932✔
202
    const relatedRecordItems = await this.getRelatedItems(
932✔
203
      startFieldIds,
932✔
204
      fieldMap,
932✔
205
      linkAdjacencyMap,
932✔
206
      startRecordIds
932✔
207
    );
932✔
208

932✔
209
    // record data source
932✔
210
    const dbTableName2recordMap = await this.getRecordMapBatch({
932✔
211
      fieldMap,
932✔
212
      fieldId2DbTableName,
932✔
213
      dbTableName2fields,
932✔
214
      modifiedRecords: recordData,
932✔
215
      relatedRecordItems,
932✔
216
    });
932✔
217

932✔
218
    const relatedRecordItemsIndexed = groupBy(relatedRecordItems, 'fieldId');
932✔
219
    // console.log('fieldMap', JSON.stringify(fieldMap, null, 2));
932✔
220
    const orderWithRecordsByFieldId = Object.entries(topoOrdersMap).reduce<{
932✔
221
      [fieldId: string]: ITopoItemWithRecords[];
932✔
222
    }>((pre, [fieldId, topoOrders]) => {
932✔
223
      const orderWithRecords = this.createTopoItemWithRecords({
2,830✔
224
        topoOrders,
2,830✔
225
        fieldMap,
2,830✔
226
        tableId2DbTableName,
2,830✔
227
        fieldId2TableId,
2,830✔
228
        dbTableName2recordMap,
2,830✔
229
        relatedRecordItemsIndexed,
2,830✔
230
      });
2,830✔
231
      pre[fieldId] = orderWithRecords;
2,830✔
232
      return pre;
2,830✔
233
    }, {});
932✔
234

932✔
235
    return {
932✔
236
      fieldMap,
932✔
237
      fieldId2TableId,
932✔
238
      tableId2DbTableName,
932✔
239
      orderWithRecordsByFieldId,
932✔
240
      dbTableName2recordMap,
932✔
241
    };
932✔
242
  }
932✔
243

395✔
244
  async calculate(recordData: IRecordData[]) {
395✔
245
    const result = await this.prepareCalculation(recordData);
5,912✔
246
    if (!result) {
5,912✔
247
      return;
4,982✔
248
    }
4,982✔
249

930✔
250
    const { orderWithRecordsByFieldId, fieldMap, fieldId2TableId, tableId2DbTableName } = result;
930✔
251
    const changes = Object.values(orderWithRecordsByFieldId).reduce<ICellChange[]>(
930✔
252
      (pre, orderWithRecords) => {
930✔
253
        // nameConsole('orderWithRecords:', orderWithRecords, fieldMap);
2,828✔
254
        return pre.concat(this.collectChanges(orderWithRecords, fieldMap, fieldId2TableId));
2,828✔
255
      },
2,828✔
256
      []
930✔
257
    );
930✔
258
    // nameConsole('changes', changes, fieldMap);
930✔
259

930✔
260
    return {
930✔
261
      changes: mergeDuplicateChange(changes),
930✔
262
      fieldMap,
930✔
263
      tableId2DbTableName,
930✔
264
    };
930✔
265
  }
930✔
266

395✔
267
  private splitOpsMap(opsMap: IOpsMap) {
395✔
268
    const recordDataDelete: IRecordData[] = [];
2,956✔
269
    const recordDataRemains: IRecordData[] = [];
2,956✔
270
    for (const tableId in opsMap) {
2,956✔
271
      for (const recordId in opsMap[tableId]) {
2,379✔
272
        opsMap[tableId][recordId].forEach((op) => {
10,635✔
273
          const ctx = RecordOpBuilder.editor.setRecord.detect(op);
31,553✔
274
          if (!ctx) {
31,553✔
275
            throw new Error(
×
276
              'invalid op, it should detect by RecordOpBuilder.editor.setRecord.detect'
×
277
            );
×
278
          }
×
279
          if (isLinkCellValue(ctx.oldCellValue) || isLinkCellValue(ctx.newCellValue)) {
31,553✔
280
            ctx.oldCellValue &&
9,813✔
281
              recordDataDelete.push({
4,772✔
282
                id: recordId,
4,772✔
283
                fieldId: ctx.fieldId,
4,772✔
284
                oldValue: ctx.oldCellValue,
4,772✔
285
                newValue: null,
4,772✔
286
              });
4,772✔
287
            ctx.newCellValue &&
9,813✔
288
              recordDataRemains.push({
9,546✔
289
                id: recordId,
9,546✔
290
                fieldId: ctx.fieldId,
9,546✔
291
                newValue: ctx.newCellValue,
9,546✔
292
              });
9,546✔
293
          } else {
31,553✔
294
            recordDataRemains.push({
21,740✔
295
              id: recordId,
21,740✔
296
              fieldId: ctx.fieldId,
21,740✔
297
              oldValue: ctx.oldCellValue,
21,740✔
298
              newValue: ctx.newCellValue,
21,740✔
299
            });
21,740✔
300
          }
21,740✔
301
        });
31,553✔
302
      }
10,635✔
303
    }
2,379✔
304

2,956✔
305
    return {
2,956✔
306
      recordDataDelete,
2,956✔
307
      recordDataRemains,
2,956✔
308
    };
2,956✔
309
  }
2,956✔
310

395✔
311
  private async getDirectedGraph(recordData: IRecordData[]) {
395✔
312
    let startFieldIds = recordData.map((data) => data.fieldId);
2,249✔
313
    const linkData = recordData.filter(
2,249✔
314
      (data) => isLinkCellValue(data.newValue) || isLinkCellValue(data.oldValue)
2,249✔
315
    );
2,249✔
316
    // const linkIds = linkData
2,249✔
317
    //   .map((data) => [data.newValue, data.oldValue] as ILinkCellValue[])
2,249✔
318
    //   .flat()
2,249✔
319
    //   .filter(Boolean)
2,249✔
320
    //   .map((d) => d.id);
2,249✔
321
    const startRecordIds = uniq(recordData.map((data) => data.id));
2,249✔
322
    const linkFieldIds = linkData.map((data) => data.fieldId);
2,249✔
323

2,249✔
324
    // when link cell change, we need to get all lookup field
2,249✔
325
    if (linkFieldIds.length) {
2,249✔
326
      const lookupFieldRaw = await this.prismaService.txClient().field.findMany({
912✔
327
        where: { lookupLinkedFieldId: { in: linkFieldIds }, deletedTime: null, hasError: null },
912✔
328
        select: { id: true },
912✔
329
      });
912✔
330
      lookupFieldRaw.forEach((field) => startFieldIds.push(field.id));
912✔
331
    }
912✔
332
    startFieldIds = uniq(startFieldIds);
2,249✔
333
    const directedGraph = await this.getFieldGraphItems(startFieldIds);
2,249✔
334
    return {
2,249✔
335
      directedGraph,
2,249✔
336
      startFieldIds,
2,249✔
337
      startRecordIds,
2,249✔
338
    };
2,249✔
339
  }
2,249✔
340

395✔
341
  // for lookup field, cellValues should be flat and filter
395✔
342
  private filterArrayNull(lookupValues: unknown[] | unknown) {
395✔
343
    if (Array.isArray(lookupValues)) {
1,942✔
344
      const flatten = lookupValues.filter((value) => value != null);
1,237✔
345
      return flatten.length ? flatten : null;
1,237✔
346
    }
1,237✔
347
    return lookupValues;
705✔
348
  }
705✔
349

395✔
350
  // for computed field, inner array cellValues should be join to string
395✔
351
  private joinOriginLookup(lookupField: IFieldInstance, lookupValues: unknown[] | unknown) {
395✔
352
    if (Array.isArray(lookupValues)) {
×
353
      const flatten = lookupValues.map((value) => {
×
354
        if (Array.isArray(value)) {
×
355
          return lookupField.cellValue2String(value);
×
356
        }
×
357
        return value;
×
358
      });
×
359
      return flatten.length ? flatten : null;
×
360
    }
×
361
    return lookupValues;
×
362
  }
×
363

395✔
364
  private shouldSkipCompute(field: IFieldInstance, recordItem: IRecordItem) {
395✔
365
    if (!field.isComputed && field.type !== FieldType.Link) {
83,149✔
366
      return true;
26,256✔
367
    }
26,256✔
368

56,893✔
369
    // skip calculate when direct set link cell by input (it has no dependencies)
56,893✔
370
    if (field.type === FieldType.Link && !field.lookupOptions && !recordItem.dependencies) {
83,149✔
371
      return true;
2,568✔
372
    }
2,568✔
373

54,325✔
374
    if ((field.lookupOptions || field.type === FieldType.Link) && !recordItem.dependencies) {
83,149✔
375
      // console.log('empty:field', field);
875✔
376
      // console.log('empty:recordItem', JSON.stringify(recordItem, null, 2));
875✔
377
      return true;
875✔
378
    }
875✔
379
    return false;
53,450✔
380
  }
53,450✔
381

395✔
382
  private getComputedUsers(
395✔
383
    field: IFieldInstance,
30✔
384
    record: IRecord,
30✔
385
    userMap: { [userId: string]: IUserInfoVo }
30✔
386
  ) {
30✔
387
    if (field.type === FieldType.CreatedBy) {
30✔
388
      return record.createdBy ? userMap[record.createdBy] : undefined;
18✔
389
    }
18✔
390
    if (field.type === FieldType.LastModifiedBy) {
12✔
391
      return record.lastModifiedBy ? userMap[record.lastModifiedBy] : undefined;
12✔
392
    }
12✔
393
  }
30✔
394

395✔
395
  private calculateUser(
395✔
396
    field: IFieldInstance,
32✔
397
    record: IRecord,
32✔
398
    userMap?: { [userId: string]: IUserInfoVo }
32✔
399
  ) {
32✔
400
    if (!userMap) {
32✔
401
      return record.fields[field.id];
2✔
402
    }
2✔
403
    const user = this.getComputedUsers(field, record, userMap);
30✔
404
    if (!user) {
32✔
405
      return record.fields[field.id];
8✔
406
    }
8✔
407

22✔
408
    return field.convertDBValue2CellValue({
22✔
409
      id: user.id,
22✔
410
      title: user.name,
22✔
411
      email: user.email,
22✔
412
    });
22✔
413
  }
22✔
414

395✔
415
  // eslint-disable-next-line sonarjs/cognitive-complexity
395✔
416
  private calculateComputeField(
395✔
417
    field: IFieldInstance,
53,450✔
418
    fieldMap: IFieldMap,
53,450✔
419
    recordItem: IRecordItem,
53,450✔
420
    userMap?: { [userId: string]: IUserInfoVo }
53,450✔
421
  ) {
53,450✔
422
    const record = recordItem.record;
53,450✔
423

53,450✔
424
    if (field.lookupOptions || field.type === FieldType.Link) {
53,450✔
425
      const lookupFieldId = field.lookupOptions
8,030✔
426
        ? field.lookupOptions.lookupFieldId
2,330✔
427
        : (field.options as ILinkFieldOptions).lookupFieldId;
8,030✔
428
      const relationship = field.lookupOptions
8,030✔
429
        ? field.lookupOptions.relationship
2,330✔
430
        : (field.options as ILinkFieldOptions).relationship;
8,030✔
431

8,030✔
432
      if (!lookupFieldId) {
8,030✔
433
        throw new Error('lookupFieldId should not be undefined');
×
434
      }
×
435

8,030✔
436
      if (!relationship) {
8,030✔
437
        throw new Error('relationship should not be undefined');
×
438
      }
×
439

8,030✔
440
      const lookedField = fieldMap[lookupFieldId];
8,030✔
441
      // nameConsole('calculateLookup:dependencies', recordItem.dependencies, fieldMap);
8,030✔
442
      const originLookupValues = this.calculateLookup(field, lookedField, recordItem);
8,030✔
443
      const lookupValues = Array.isArray(originLookupValues)
8,030✔
444
        ? originLookupValues.flat()
2,301✔
445
        : originLookupValues;
5,729✔
446

8,030✔
447
      // console.log('calculateLookup:dependencies', recordItem.dependencies);
8,030✔
448
      // console.log('calculateLookup:lookupValues', lookupValues, recordItem);
8,030✔
449

8,030✔
450
      if (field.isLookup) {
8,030✔
451
        return this.filterArrayNull(lookupValues);
1,942✔
452
      }
1,942✔
453

6,088✔
454
      return this.calculateRollupAndLink(field, relationship, lookedField, record, lookupValues);
6,088✔
455
    }
6,088✔
456

45,420✔
457
    if (field.type === FieldType.CreatedBy || field.type === FieldType.LastModifiedBy) {
53,450✔
458
      return this.calculateUser(field, record, userMap);
32✔
459
    }
32✔
460

45,388✔
461
    if (
45,388✔
462
      field.type === FieldType.Formula ||
45,388✔
463
      field.type === FieldType.AutoNumber ||
53,450✔
464
      field.type === FieldType.CreatedTime ||
53,450✔
465
      field.type === FieldType.LastModifiedTime
60✔
466
    ) {
53,450✔
467
      return this.calculateFormula(field, fieldMap, recordItem);
45,388✔
468
    }
45,388✔
469

×
470
    throw new BadRequestException(`Unsupported field type ${field.type}`);
×
471
  }
×
472

395✔
473
  private calculateFormula(
395✔
474
    field: FormulaFieldDto | AutoNumberFieldDto | CreatedTimeFieldDto | LastModifiedTimeFieldDto,
45,388✔
475
    fieldMap: IFieldMap,
45,388✔
476
    recordItem: IRecordItem
45,388✔
477
  ) {
45,388✔
478
    if (field.hasError) {
45,388✔
479
      return null;
×
480
    }
×
481

45,388✔
482
    try {
45,388✔
483
      const typedValue = evaluate(
45,388✔
484
        field.options.expression,
45,388✔
485
        fieldMap,
45,388✔
486
        recordItem.record,
45,388✔
487
        'timeZone' in field.options ? field.options.timeZone : undefined
45,388✔
488
      );
45,388✔
489
      return typedValue.toPlain();
45,388✔
490
    } catch (e) {
45,388✔
491
      this.logger.error(
×
492
        `calculateFormula error, fieldId: ${field.id}; exp: ${field.options.expression}; recordId: ${recordItem.record.id}, ${(e as { message: string }).message}`
×
493
      );
×
494
      return null;
×
495
    }
×
496
  }
45,388✔
497

395✔
498
  /**
395✔
499
   * lookup values should filter by linkCellValue
395✔
500
   */
395✔
501
  // eslint-disable-next-line sonarjs/cognitive-complexity
395✔
502
  private calculateLookup(
395✔
503
    field: IFieldInstance,
8,030✔
504
    lookedField: IFieldInstance,
8,030✔
505
    recordItem: IRecordItem
8,030✔
506
  ) {
8,030✔
507
    const fieldId = lookedField.id;
8,030✔
508
    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
8,030✔
509
    const dependencies = recordItem.dependencies!;
8,030✔
510
    const lookupOptions = field.lookupOptions
8,030✔
511
      ? field.lookupOptions
2,330✔
512
      : (field.options as ILinkFieldOptions);
8,030✔
513
    const { relationship } = lookupOptions;
8,030✔
514
    const linkFieldId = field.lookupOptions ? field.lookupOptions.linkFieldId : field.id;
8,030✔
515
    const cellValue = recordItem.record.fields[linkFieldId];
8,030✔
516

8,030✔
517
    if (relationship === Relationship.OneMany || relationship === Relationship.ManyMany) {
8,030✔
518
      if (!dependencies) {
2,864✔
519
        return null;
×
520
      }
×
521

2,864✔
522
      // sort lookup values by link cell order
2,864✔
523
      const dependenciesIndexed = keyBy(dependencies, 'id');
2,864✔
524
      const linkCellValues = cellValue as ILinkCellValue[];
2,864✔
525
      // when reset a link cell, the link cell value will be null
2,864✔
526
      // but dependencies will still be there in the first round calculation
2,864✔
527
      if (linkCellValues) {
2,864✔
528
        return linkCellValues
2,287✔
529
          .map((v) => {
2,287✔
530
            const result = dependenciesIndexed[v.id];
10,811✔
531
            if (!result) {
10,811✔
532
              throw new InternalServerErrorException(
×
533
                `Record not found for: ${JSON.stringify(v)}, fieldId: ${field.id}`
×
534
              );
×
535
            }
×
536
            return result;
10,811✔
537
          })
10,811✔
538
          .map((depRecord) => depRecord.fields[fieldId]);
2,287✔
539
      }
2,287✔
540

577✔
541
      return null;
577✔
542
    }
577✔
543

5,166✔
544
    if (relationship === Relationship.ManyOne || relationship === Relationship.OneOne) {
8,030✔
545
      if (!dependencies) {
5,166✔
546
        return null;
×
547
      }
×
548
      if (dependencies.length !== 1) {
5,166✔
549
        throw new Error(
×
550
          'dependencies should have only 1 element when relationship is manyOne or oneOne'
×
551
        );
×
552
      }
×
553

5,166✔
554
      const linkCellValue = cellValue as ILinkCellValue;
5,166✔
555
      if (linkCellValue) {
5,166✔
556
        return dependencies[0].fields[fieldId] ?? null;
4,836✔
557
      }
4,836✔
558
      return null;
330✔
559
    }
330✔
560
  }
8,030✔
561

395✔
562
  private calculateLink(
395✔
563
    field: LinkFieldDto,
5,700✔
564
    virtualField: IFieldInstance,
5,700✔
565
    record: IRecord,
5,700✔
566
    lookupValues: unknown
5,700✔
567
  ) {
5,700✔
568
    const linkCellValues = record.fields[field.id] as ILinkCellValue[] | ILinkCellValue | undefined;
5,700✔
569
    if (!linkCellValues) {
5,700✔
570
      return null;
308✔
571
    }
308✔
572

5,392✔
573
    if (virtualField.isMultipleCellValue) {
5,698✔
574
      if (!Array.isArray(lookupValues)) {
904✔
575
        throw new Error('lookupValues should be array when virtualField is multiple cell value');
×
576
      }
×
577

904✔
578
      if (!Array.isArray(linkCellValues)) {
904✔
579
        throw new Error('linkCellValues should be array when virtualField is multiple cell value');
×
580
      }
×
581

904✔
582
      if (linkCellValues.length !== lookupValues.length) {
904✔
583
        throw new Error(
×
584
          'lookupValues length should be same as linkCellValues length, now: ' +
×
585
            linkCellValues.length +
×
586
            ' - ' +
×
587
            lookupValues.length
×
588
        );
×
589
      }
×
590

904✔
591
      const titles = lookupValues.map((item) => {
904✔
592
        return virtualField.item2String(item);
7,816✔
593
      });
7,816✔
594

904✔
595
      return field.updateCellTitle(linkCellValues, titles);
904✔
596
    }
904✔
597

4,488✔
598
    return field.updateCellTitle(linkCellValues, virtualField.cellValue2String(lookupValues));
4,488✔
599
  }
4,488✔
600

395✔
601
  private calculateRollupAndLink(
395✔
602
    field: IFieldInstance,
6,088✔
603
    relationship: Relationship,
6,088✔
604
    lookupField: IFieldInstance,
6,088✔
605
    record: IRecord,
6,088✔
606
    lookupValues: unknown
6,088✔
607
  ): unknown {
6,088✔
608
    if (field.type !== FieldType.Link && field.type !== FieldType.Rollup) {
6,088✔
609
      throw new BadRequestException('rollup only support link and rollup field currently');
×
610
    }
×
611

6,088✔
612
    const fieldVo = instanceToPlain(lookupField, { excludePrefixes: ['_'] }) as IFieldVo;
6,088✔
613
    const virtualField = createFieldInstanceByVo({
6,088✔
614
      ...fieldVo,
6,088✔
615
      id: 'values',
6,088✔
616
      isMultipleCellValue:
6,088✔
617
        fieldVo.isMultipleCellValue || isMultiValueLink(relationship) || undefined,
6,088✔
618
    });
6,088✔
619

6,088✔
620
    if (field.type === FieldType.Rollup) {
6,088✔
621
      // console.log('calculateRollup', field, lookupField, record, lookupValues);
388✔
622
      if (lookupValues == null) {
388✔
623
        return null;
164✔
624
      }
164✔
625
      return field
224✔
626
        .evaluate(
224✔
627
          { values: virtualField },
224✔
628
          { ...record, fields: { ...record.fields, values: lookupValues } }
224✔
629
        )
224✔
630
        .toPlain();
224✔
631
    }
224✔
632

5,700✔
633
    if (field.type === FieldType.Link) {
5,700✔
634
      return this.calculateLink(field, virtualField, record, lookupValues);
5,700✔
635
    }
5,700✔
636
  }
6,088✔
637

395✔
638
  async createAuxiliaryData(allFieldIds: string[]) {
395✔
639
    const prisma = this.prismaService.txClient();
1,994✔
640
    const fieldRaws = await prisma.field.findMany({
1,994✔
641
      where: { id: { in: allFieldIds }, deletedTime: null },
1,994✔
642
    });
1,994✔
643

1,994✔
644
    // if a field that has been looked up  has changed, the link field should be retrieved as context
1,994✔
645
    const extraLinkFieldIds = difference(
1,994✔
646
      fieldRaws
1,994✔
647
        .filter((field) => field.lookupLinkedFieldId)
1,994✔
648
        .map((field) => field.lookupLinkedFieldId as string),
1,994✔
649
      allFieldIds
1,994✔
650
    );
1,994✔
651

1,994✔
652
    const extraLinkFieldRaws = await prisma.field.findMany({
1,994✔
653
      where: { id: { in: extraLinkFieldIds }, deletedTime: null },
1,994✔
654
    });
1,994✔
655

1,994✔
656
    fieldRaws.push(...extraLinkFieldRaws);
1,994✔
657

1,994✔
658
    const fieldId2TableId = fieldRaws.reduce<{ [fieldId: string]: string }>((pre, f) => {
1,994✔
659
      pre[f.id] = f.tableId;
7,403✔
660
      return pre;
7,403✔
661
    }, {});
1,994✔
662

1,994✔
663
    const tableIds = uniq(Object.values(fieldId2TableId));
1,994✔
664
    const tableMeta = await prisma.tableMeta.findMany({
1,994✔
665
      where: { id: { in: tableIds } },
1,994✔
666
      select: { id: true, dbTableName: true },
1,994✔
667
    });
1,994✔
668

1,994✔
669
    const tableId2DbTableName = tableMeta.reduce<{ [tableId: string]: string }>((pre, t) => {
1,994✔
670
      pre[t.id] = t.dbTableName;
3,614✔
671
      return pre;
3,614✔
672
    }, {});
1,994✔
673

1,994✔
674
    const fieldMap = fieldRaws.reduce<IFieldMap>((pre, f) => {
1,994✔
675
      pre[f.id] = createFieldInstanceByRaw(f);
7,403✔
676
      return pre;
7,403✔
677
    }, {});
1,994✔
678

1,994✔
679
    const dbTableName2fields = fieldRaws.reduce<{ [fieldId: string]: IFieldInstance[] }>(
1,994✔
680
      (pre, f) => {
1,994✔
681
        const dbTableName = tableId2DbTableName[f.tableId];
7,403✔
682
        if (pre[dbTableName]) {
7,403✔
683
          pre[dbTableName].push(fieldMap[f.id]);
3,789✔
684
        } else {
7,403✔
685
          pre[dbTableName] = [fieldMap[f.id]];
3,614✔
686
        }
3,614✔
687
        return pre;
7,403✔
688
      },
7,403✔
689
      {}
1,994✔
690
    );
1,994✔
691

1,994✔
692
    const fieldId2DbTableName = fieldRaws.reduce<{ [fieldId: string]: string }>((pre, f) => {
1,994✔
693
      pre[f.id] = tableId2DbTableName[f.tableId];
7,403✔
694
      return pre;
7,403✔
695
    }, {});
1,994✔
696

1,994✔
697
    return {
1,994✔
698
      fieldMap,
1,994✔
699
      fieldId2TableId,
1,994✔
700
      fieldId2DbTableName,
1,994✔
701
      dbTableName2fields,
1,994✔
702
      tableId2DbTableName,
1,994✔
703
    };
1,994✔
704
  }
1,994✔
705

395✔
706
  collectChanges(
395✔
707
    orders: ITopoItemWithRecords[],
3,397✔
708
    fieldMap: IFieldMap,
3,397✔
709
    fieldId2TableId: { [fieldId: string]: string },
3,397✔
710
    userMap?: { [userId: string]: IUserInfoVo }
3,397✔
711
  ) {
3,397✔
712
    // detail changes
3,397✔
713
    const changes: ICellChange[] = [];
3,397✔
714
    // console.log('collectChanges:orders:', JSON.stringify(orders, null, 2));
3,397✔
715

3,397✔
716
    orders.forEach((item) => {
3,397✔
717
      Object.values(item.recordItemMap || {}).forEach((recordItem) => {
3,851✔
718
        const field = fieldMap[item.id];
83,149✔
719
        const record = recordItem.record;
83,149✔
720
        if (this.shouldSkipCompute(field, recordItem)) {
83,149✔
721
          return;
29,699✔
722
        }
29,699✔
723

53,450✔
724
        const value = this.calculateComputeField(field, fieldMap, recordItem, userMap);
53,450✔
725
        // console.log(
53,450✔
726
        //   `calculated: ${field.type}.${field.id}.${record.id}`,
53,450✔
727
        //   recordItem.record.fields,
53,450✔
728
        //   value
53,450✔
729
        // );
53,450✔
730
        const oldValue = record.fields[field.id];
53,450✔
731
        record.fields[field.id] = value;
53,450✔
732
        changes.push({
53,450✔
733
          tableId: fieldId2TableId[field.id],
53,450✔
734
          fieldId: field.id,
53,450✔
735
          recordId: record.id,
53,450✔
736
          oldValue,
53,450✔
737
          newValue: value,
53,450✔
738
        });
53,450✔
739
      });
53,450✔
740
    });
3,851✔
741
    return changes;
3,397✔
742
  }
3,397✔
743

395✔
744
  recordRaw2Record(fields: IFieldInstance[], raw: { [dbFieldName: string]: unknown }): IRecord {
395✔
745
    const fieldsData = fields.reduce<{ [fieldId: string]: unknown }>((acc, field) => {
59,575✔
746
      acc[field.id] = field.convertDBValue2CellValue(raw[field.dbFieldName] as string);
116,464✔
747
      return acc;
116,464✔
748
    }, {});
59,575✔
749

59,575✔
750
    return {
59,575✔
751
      fields: fieldsData,
59,575✔
752
      id: raw.__id as string,
59,575✔
753
      autoNumber: raw.__auto_number as number,
59,575✔
754
      createdTime: (raw.__created_time as Date)?.toISOString(),
59,575✔
755
      lastModifiedTime: (raw.__last_modified_time as Date)?.toISOString(),
59,575✔
756
      createdBy: raw.__created_by as string,
59,575✔
757
      lastModifiedBy: raw.__last_modified_by as string,
59,575✔
758
    };
59,575✔
759
  }
59,575✔
760

395✔
761
  getLinkOrderFromTopoOrders(params: {
395✔
762
    topoOrders: ITopoItem[];
×
763
    fieldMap: IFieldMap;
×
764
  }): ITopoLinkOrder[] {
×
765
    const newOrder: ITopoLinkOrder[] = [];
×
766
    const { topoOrders, fieldMap } = params;
×
767
    // one link fieldId only need to add once
×
768
    const checkSet = new Set<string>();
×
769
    for (const item of topoOrders) {
×
770
      const field = fieldMap[item.id];
×
771
      if (field.lookupOptions) {
×
772
        const { fkHostTableName, selfKeyName, foreignKeyName, relationship, linkFieldId } =
×
773
          field.lookupOptions;
×
774
        if (checkSet.has(linkFieldId)) {
×
775
          continue;
×
776
        }
×
777
        checkSet.add(linkFieldId);
×
778
        newOrder.push({
×
779
          fieldId: linkFieldId,
×
780
          relationship,
×
781
          fkHostTableName,
×
782
          selfKeyName,
×
783
          foreignKeyName,
×
784
        });
×
785
        continue;
×
786
      }
×
787

×
788
      if (field.type === FieldType.Link) {
×
789
        const { fkHostTableName, selfKeyName, foreignKeyName } = field.options;
×
790
        if (checkSet.has(field.id)) {
×
791
          continue;
×
792
        }
×
793
        checkSet.add(field.id);
×
794
        newOrder.push({
×
795
          fieldId: field.id,
×
796
          relationship: field.options.relationship,
×
797
          fkHostTableName,
×
798
          selfKeyName,
×
799
          foreignKeyName,
×
800
        });
×
801
      }
×
802
    }
×
803
    return newOrder;
×
804
  }
×
805

395✔
806
  getRecordIdsByTableName(params: {
395✔
807
    fieldMap: IFieldMap;
1,477✔
808
    fieldId2DbTableName: Record<string, string>;
1,477✔
809
    initialRecordIdMap?: { [dbTableName: string]: Set<string> };
1,477✔
810
    modifiedRecords: IRecordData[];
1,477✔
811
    relatedRecordItems: IRelatedRecordItem[];
1,477✔
812
  }) {
1,477✔
813
    const {
1,477✔
814
      fieldMap,
1,477✔
815
      fieldId2DbTableName,
1,477✔
816
      initialRecordIdMap,
1,477✔
817
      modifiedRecords,
1,477✔
818
      relatedRecordItems,
1,477✔
819
    } = params;
1,477✔
820
    const recordIdsByTableName = cloneDeep(initialRecordIdMap) || {};
1,477✔
821
    const insertId = (fieldId: string, id: string) => {
1,477✔
822
      const dbTableName = fieldId2DbTableName[fieldId];
59,650✔
823
      if (!recordIdsByTableName[dbTableName]) {
59,650✔
824
        recordIdsByTableName[dbTableName] = new Set<string>();
1,820✔
825
      }
1,820✔
826
      recordIdsByTableName[dbTableName].add(id);
59,650✔
827
    };
59,650✔
828

1,477✔
829
    modifiedRecords.forEach((item) => {
1,477✔
830
      insertId(item.fieldId, item.id);
22,122✔
831
      const field = fieldMap[item.fieldId];
22,122✔
832
      if (field.type !== FieldType.Link) {
22,122✔
833
        return;
16,612✔
834
      }
16,612✔
835
      const lookupFieldId = field.options.lookupFieldId;
5,510✔
836

5,510✔
837
      const { newValue } = item;
5,510✔
838
      [newValue]
5,510✔
839
        .flat()
5,510✔
840
        .filter(Boolean)
5,510✔
841
        .map((item) => insertId(lookupFieldId, (item as ILinkCellValue).id));
5,510✔
842
    });
5,510✔
843

1,477✔
844
    relatedRecordItems.forEach((item) => {
1,477✔
845
      const field = fieldMap[item.fieldId];
14,386✔
846
      const options = field.lookupOptions ?? (field.options as ILinkFieldOptions);
14,386✔
847

14,386✔
848
      insertId(options.lookupFieldId, item.fromId);
14,386✔
849
      insertId(item.fieldId, item.toId);
14,386✔
850
    });
14,386✔
851

1,477✔
852
    return recordIdsByTableName;
1,477✔
853
  }
1,477✔
854

395✔
855
  async getRecordMapBatch(params: {
395✔
856
    fieldMap: IFieldMap;
1,477✔
857
    fieldId2DbTableName: Record<string, string>;
1,477✔
858
    dbTableName2fields: Record<string, IFieldInstance[]>;
1,477✔
859
    initialRecordIdMap?: { [dbTableName: string]: Set<string> };
1,477✔
860
    modifiedRecords: IRecordData[];
1,477✔
861
    relatedRecordItems: IRelatedRecordItem[];
1,477✔
862
  }) {
1,477✔
863
    const { fieldId2DbTableName, dbTableName2fields, modifiedRecords } = params;
1,477✔
864

1,477✔
865
    const recordIdsByTableName = this.getRecordIdsByTableName(params);
1,477✔
866
    const recordMap = await this.getRecordMap(recordIdsByTableName, dbTableName2fields);
1,477✔
867
    this.coverRecordData(fieldId2DbTableName, modifiedRecords, recordMap);
1,477✔
868

1,477✔
869
    return recordMap;
1,477✔
870
  }
1,477✔
871

395✔
872
  async getRecordMap(
395✔
873
    recordIdsByTableName: Record<string, Set<string>>,
1,477✔
874
    dbTableName2fields: Record<string, IFieldInstance[]>
1,477✔
875
  ) {
1,477✔
876
    const results: {
1,477✔
877
      [dbTableName: string]: { [dbFieldName: string]: unknown }[];
1,477✔
878
    } = {};
1,477✔
879
    for (const dbTableName in recordIdsByTableName) {
1,477✔
880
      // deduplication is needed
2,365✔
881
      const recordIds = Array.from(recordIdsByTableName[dbTableName]);
2,365✔
882
      const dbFieldNames = dbTableName2fields[dbTableName]
2,365✔
883
        .map((f) => f.dbFieldName)
2,365✔
884
        .concat([...preservedDbFieldNames]);
2,365✔
885
      const nativeQuery = this.knex(dbTableName)
2,365✔
886
        .select(dbFieldNames)
2,365✔
887
        .whereIn('__id', recordIds)
2,365✔
888
        .toQuery();
2,365✔
889
      const result = await this.prismaService
2,365✔
890
        .txClient()
2,365✔
891
        .$queryRawUnsafe<{ [dbFieldName: string]: unknown }[]>(nativeQuery);
2,365✔
892
      results[dbTableName] = result;
2,365✔
893
    }
2,365✔
894

1,477✔
895
    return this.formatRecordQueryResult(results, dbTableName2fields);
1,477✔
896
  }
1,477✔
897

395✔
898
  createTopoItemWithRecords(params: {
395✔
899
    topoOrders: ITopoItem[];
3,399✔
900
    tableId2DbTableName: { [tableId: string]: string };
3,399✔
901
    fieldId2TableId: { [fieldId: string]: string };
3,399✔
902
    fieldMap: IFieldMap;
3,399✔
903
    dbTableName2recordMap: { [tableName: string]: IRecordMap };
3,399✔
904
    relatedRecordItemsIndexed: Record<string, IRelatedRecordItem[]>;
3,399✔
905
  }): ITopoItemWithRecords[] {
3,399✔
906
    const {
3,399✔
907
      topoOrders,
3,399✔
908
      fieldMap,
3,399✔
909
      tableId2DbTableName,
3,399✔
910
      fieldId2TableId,
3,399✔
911
      dbTableName2recordMap,
3,399✔
912
      relatedRecordItemsIndexed,
3,399✔
913
    } = params;
3,399✔
914
    return topoOrders.map<ITopoItemWithRecords>((order) => {
3,399✔
915
      const field = fieldMap[order.id];
3,861✔
916
      const fieldId = field.id;
3,861✔
917
      const tableId = fieldId2TableId[order.id];
3,861✔
918
      const dbTableName = tableId2DbTableName[tableId];
3,861✔
919
      const recordMap = dbTableName2recordMap[dbTableName];
3,861✔
920
      const relatedItems = relatedRecordItemsIndexed[fieldId];
3,861✔
921

3,861✔
922
      // console.log('withRecord:order', JSON.stringify(order, null, 2));
3,861✔
923
      // console.log('withRecord:relatedItems', relatedItems);
3,861✔
924
      return {
3,861✔
925
        ...order,
3,861✔
926
        recordItemMap:
3,861✔
927
          recordMap &&
3,861✔
928
          Object.values(recordMap).reduce<Record<string, IRecordItem>>((pre, record) => {
3,713✔
929
            let dependencies: IRecord[] | undefined;
83,159✔
930
            if (relatedItems) {
83,159✔
931
              const options = field.lookupOptions
11,126✔
932
                ? field.lookupOptions
2,896✔
933
                : (field.options as ILinkFieldOptions);
11,126✔
934
              const foreignTableId = options.foreignTableId;
11,126✔
935
              const foreignDbTableName = tableId2DbTableName[foreignTableId];
11,126✔
936
              const foreignRecordMap = dbTableName2recordMap[foreignDbTableName];
11,126✔
937
              const dependentRecordIdsIndexed = groupBy(relatedItems, 'toId');
11,126✔
938
              const dependentRecordIds = dependentRecordIdsIndexed[record.id];
11,126✔
939

11,126✔
940
              if (dependentRecordIds) {
11,126✔
941
                dependencies = dependentRecordIds.map((item) => foreignRecordMap[item.fromId]);
8,038✔
942
              }
8,038✔
943
            }
11,126✔
944

83,159✔
945
            if (dependencies) {
83,159✔
946
              pre[record.id] = { record, dependencies };
8,038✔
947
            } else {
83,159✔
948
              pre[record.id] = { record };
75,121✔
949
            }
75,121✔
950

83,159✔
951
            return pre;
83,159✔
952
          }, {}),
3,713✔
953
      };
3,861✔
954
    });
3,861✔
955
  }
3,399✔
956

395✔
957
  formatRecordQueryResult(
395✔
958
    formattedResults: {
1,477✔
959
      [tableName: string]: { [dbFieldName: string]: unknown }[];
1,477✔
960
    },
1,477✔
961
    dbTableName2fields: { [tableId: string]: IFieldInstance[] }
1,477✔
962
  ) {
1,477✔
963
    return Object.entries(formattedResults).reduce<{
1,477✔
964
      [dbTableName: string]: IRecordMap;
1,477✔
965
    }>((acc, [dbTableName, records]) => {
1,477✔
966
      const fields = dbTableName2fields[dbTableName];
2,365✔
967
      acc[dbTableName] = records.reduce<IRecordMap>((pre, recordRaw) => {
2,365✔
968
        const record = this.recordRaw2Record(fields, recordRaw);
54,915✔
969
        pre[record.id] = record;
54,915✔
970
        return pre;
54,915✔
971
      }, {});
2,365✔
972
      return acc;
2,365✔
973
    }, {});
1,477✔
974
  }
1,477✔
975

395✔
976
  // use modified record data to cover the record data from db
395✔
977
  private coverRecordData(
395✔
978
    fieldId2DbTableName: Record<string, string>,
1,477✔
979
    newRecordData: IRecordData[],
1,477✔
980
    allRecordByDbTableName: { [tableName: string]: IRecordMap }
1,477✔
981
  ) {
1,477✔
982
    newRecordData.forEach((cover) => {
1,477✔
983
      const dbTableName = fieldId2DbTableName[cover.fieldId];
22,122✔
984
      const record = allRecordByDbTableName[dbTableName][cover.id];
22,122✔
985
      if (!record) {
22,122✔
986
        throw new BadRequestException(`Can not find record: ${cover.id} in database`);
×
987
      }
×
988
      record.fields[cover.fieldId] = cover.newValue;
22,122✔
989
    });
22,122✔
990
  }
1,477✔
991

395✔
992
  async getFieldGraphItems(startFieldIds: string[]): Promise<IGraphItem[]> {
395✔
993
    const getResult = async (startFieldIds: string[]) => {
4,506✔
994
      const _knex = this.knex;
4,506✔
995

4,506✔
996
      const nonRecursiveQuery = _knex
4,506✔
997
        .select('from_field_id', 'to_field_id')
4,506✔
998
        .from('reference')
4,506✔
999
        .whereIn('from_field_id', startFieldIds)
4,506✔
1000
        .orWhereIn('to_field_id', startFieldIds);
4,506✔
1001
      const recursiveQuery = _knex
4,506✔
1002
        .select('deps.from_field_id', 'deps.to_field_id')
4,506✔
1003
        .from('reference as deps')
4,506✔
1004
        .join('connected_reference as cd', function () {
4,506✔
1005
          const sql = '?? = ?? AND ?? != ??';
4,506✔
1006
          const depsFromField = 'deps.from_field_id';
4,506✔
1007
          const depsToField = 'deps.to_field_id';
4,506✔
1008
          const cdFromField = 'cd.from_field_id';
4,506✔
1009
          const cdToField = 'cd.to_field_id';
4,506✔
1010
          this.on(
4,506✔
1011
            _knex.raw(sql, [depsFromField, cdFromField, depsToField, cdToField]).wrap('(', ')')
4,506✔
1012
          );
4,506✔
1013
          this.orOn(
4,506✔
1014
            _knex.raw(sql, [depsFromField, cdToField, depsToField, cdFromField]).wrap('(', ')')
4,506✔
1015
          );
4,506✔
1016
          this.orOn(
4,506✔
1017
            _knex.raw(sql, [depsToField, cdFromField, depsFromField, cdToField]).wrap('(', ')')
4,506✔
1018
          );
4,506✔
1019
          this.orOn(
4,506✔
1020
            _knex.raw(sql, [depsToField, cdToField, depsFromField, cdFromField]).wrap('(', ')')
4,506✔
1021
          );
4,506✔
1022
        });
4,506✔
1023
      const cteQuery = nonRecursiveQuery.union(recursiveQuery);
4,506✔
1024
      const finalQuery = this.knex
4,506✔
1025
        .withRecursive('connected_reference', ['from_field_id', 'to_field_id'], cteQuery)
4,506✔
1026
        .distinct('from_field_id', 'to_field_id')
4,506✔
1027
        .from('connected_reference')
4,506✔
1028
        .toQuery();
4,506✔
1029

4,506✔
1030
      return (
4,506✔
1031
        this.prismaService
4,506✔
1032
          .txClient()
4,506✔
1033
          // eslint-disable-next-line @typescript-eslint/naming-convention
4,506✔
1034
          .$queryRawUnsafe<{ from_field_id: string; to_field_id: string }[]>(finalQuery)
4,506✔
1035
      );
4,506✔
1036
    };
4,506✔
1037

4,506✔
1038
    const queryResult = await getResult(startFieldIds);
4,506✔
1039

4,506✔
1040
    return filterDirectedGraph(
4,506✔
1041
      queryResult.map((row) => ({ fromFieldId: row.from_field_id, toFieldId: row.to_field_id })),
4,506✔
1042
      startFieldIds
4,506✔
1043
    );
4,506✔
1044
  }
4,506✔
1045

395✔
1046
  private mergeDuplicateRecordData(recordData: IRecordData[]) {
395✔
1047
    const indexCache: { [key: string]: number } = {};
5,912✔
1048
    const mergedChanges: IRecordData[] = [];
5,912✔
1049

5,912✔
1050
    for (const record of recordData) {
5,912✔
1051
      const key = `${record.id}#${record.fieldId}`;
36,058✔
1052
      if (indexCache[key] !== undefined) {
36,058✔
1053
        mergedChanges[indexCache[key]] = record;
×
1054
      } else {
36,058✔
1055
        indexCache[key] = mergedChanges.length;
36,058✔
1056
        mergedChanges.push(record);
36,058✔
1057
      }
36,058✔
1058
    }
36,058✔
1059
    return mergedChanges;
5,912✔
1060
  }
5,912✔
1061

395✔
1062
  /**
395✔
1063
   * affected record changes need extra dependent record to calculate result
395✔
1064
   * example: C = A + B
395✔
1065
   * A changed, C will be affected and B is the dependent record
395✔
1066
   */
395✔
1067
  async getDependentRecordItems(
395✔
1068
    fieldMap: IFieldMap,
1,127✔
1069
    recordItems: IRelatedRecordItem[]
1,127✔
1070
  ): Promise<IRelatedRecordItem[]> {
1,127✔
1071
    const indexRecordItems = groupBy(recordItems, 'fieldId');
1,127✔
1072

1,127✔
1073
    const queries = Object.entries(indexRecordItems)
1,127✔
1074
      .filter(([fieldId]) => {
1,127✔
1075
        const options =
2,722✔
1076
          fieldMap[fieldId].lookupOptions || (fieldMap[fieldId].options as ILinkFieldOptions);
2,722✔
1077
        const relationship = options.relationship;
2,722✔
1078
        return relationship === Relationship.ManyMany || relationship === Relationship.OneMany;
2,722✔
1079
      })
2,722✔
1080
      .map(([fieldId, recordItem]) => {
1,127✔
1081
        const options =
1,706✔
1082
          fieldMap[fieldId].lookupOptions || (fieldMap[fieldId].options as ILinkFieldOptions);
1,706✔
1083
        const { fkHostTableName, selfKeyName, foreignKeyName } = options;
1,706✔
1084
        const ids = recordItem.map((item) => item.toId);
1,706✔
1085

1,706✔
1086
        return this.knex
1,706✔
1087
          .select({
1,706✔
1088
            fieldId: this.knex.raw('?', fieldId),
1,706✔
1089
            toId: selfKeyName,
1,706✔
1090
            fromId: foreignKeyName,
1,706✔
1091
          })
1,706✔
1092
          .from(fkHostTableName)
1,706✔
1093
          .whereIn(selfKeyName, ids);
1,706✔
1094
      });
1,706✔
1095

1,127✔
1096
    if (!queries.length) {
1,127✔
1097
      return [];
379✔
1098
    }
379✔
1099

748✔
1100
    const [firstQuery, ...restQueries] = queries;
748✔
1101
    const sqlQuery = firstQuery.unionAll(restQueries).toQuery();
748✔
1102
    return this.prismaService.txClient().$queryRawUnsafe<IRelatedRecordItem[]>(sqlQuery);
748✔
1103
  }
748✔
1104

395✔
1105
  affectedRecordItemsQuerySql(
395✔
1106
    startFieldIds: string[],
1,127✔
1107
    fieldMap: IFieldMap,
1,127✔
1108
    linkAdjacencyMap: IAdjacencyMap,
1,127✔
1109
    startRecordIds: string[]
1,127✔
1110
  ): string {
1,127✔
1111
    const visited = new Set<string>();
1,127✔
1112
    const knex = this.knex;
1,127✔
1113
    const query = knex.queryBuilder();
1,127✔
1114

1,127✔
1115
    function visit(node: string, preNode: string) {
1,127✔
1116
      if (visited.has(node)) {
318✔
UNCOV
1117
        return;
×
UNCOV
1118
      }
×
1119

318✔
1120
      visited.add(node);
318✔
1121
      const options = fieldMap[node].lookupOptions || (fieldMap[node].options as ILinkFieldOptions);
318✔
1122
      const { fkHostTableName, selfKeyName, foreignKeyName } = options;
318✔
1123

318✔
1124
      query.with(
318✔
1125
        node,
318✔
1126
        knex
318✔
1127
          .distinct({
318✔
1128
            toId: `${fkHostTableName}.${selfKeyName}`,
318✔
1129
            fromId: `${preNode}.toId`,
318✔
1130
          })
318✔
1131
          .from(fkHostTableName)
318✔
1132
          .whereNotNull(`${fkHostTableName}.${selfKeyName}`) // toId
318✔
1133
          .join(preNode, `${preNode}.toId`, '=', `${fkHostTableName}.${foreignKeyName}`)
318✔
1134
      );
318✔
1135
      const nextNodes = linkAdjacencyMap[node];
318✔
1136
      // Process outgoing edges
318✔
1137
      if (nextNodes) {
318✔
1138
        for (const neighbor of nextNodes) {
2✔
1139
          visit(neighbor, node);
2✔
1140
        }
2✔
1141
      }
2✔
1142
    }
318✔
1143

1,127✔
1144
    startFieldIds.forEach((fieldId) => {
1,127✔
1145
      const field = fieldMap[fieldId];
3,029✔
1146
      if (field.lookupOptions || field.type === FieldType.Link) {
3,029✔
1147
        const options = field.lookupOptions || (field.options as ILinkFieldOptions);
2,725✔
1148
        const { fkHostTableName, selfKeyName, foreignKeyName } = options;
2,725✔
1149
        if (visited.has(fieldId)) {
2,725✔
1150
          return;
38✔
1151
        }
38✔
1152
        visited.add(fieldId);
2,687✔
1153
        query.with(
2,687✔
1154
          fieldId,
2,687✔
1155
          knex
2,687✔
1156
            .distinct({
2,687✔
1157
              toId: `${fkHostTableName}.${selfKeyName}`,
2,687✔
1158
              fromId: `${fkHostTableName}.${foreignKeyName}`,
2,687✔
1159
            })
2,687✔
1160
            .from(fkHostTableName)
2,687✔
1161
            .whereIn(`${fkHostTableName}.${selfKeyName}`, startRecordIds)
2,687✔
1162
            .whereNotNull(`${fkHostTableName}.${foreignKeyName}`)
2,687✔
1163
        );
2,687✔
1164
      } else {
3,029✔
1165
        query.with(
304✔
1166
          fieldId,
304✔
1167
          knex.unionAll(
304✔
1168
            startRecordIds.map((id) =>
304✔
1169
              knex.select({ toId: knex.raw('?', id), fromId: knex.raw('?', null) })
17,012✔
1170
            )
304✔
1171
          )
304✔
1172
        );
304✔
1173
      }
304✔
1174
      const nextNodes = linkAdjacencyMap[fieldId];
2,991✔
1175

2,991✔
1176
      // start visit
2,991✔
1177
      if (nextNodes) {
3,029✔
1178
        for (const neighbor of nextNodes) {
260✔
1179
          visit(neighbor, fieldId);
316✔
1180
        }
316✔
1181
      }
260✔
1182
    });
3,029✔
1183

1,127✔
1184
    // union all result
1,127✔
1185
    query.unionAll(
1,127✔
1186
      Array.from(visited).map((fieldId) =>
1,127✔
1187
        knex
3,005✔
1188
          .select({
3,005✔
1189
            fieldId: knex.raw('?', fieldId),
3,005✔
1190
            fromId: knex.ref(`${fieldId}.fromId`),
3,005✔
1191
            toId: knex.ref(`${fieldId}.toId`),
3,005✔
1192
          })
3,005✔
1193
          .from(fieldId)
3,005✔
1194
      )
1,127✔
1195
    );
1,127✔
1196

1,127✔
1197
    return query.toQuery();
1,127✔
1198
  }
1,127✔
1199

395✔
1200
  async getAffectedRecordItems(
395✔
1201
    startFieldIds: string[],
1,127✔
1202
    fieldMap: IFieldMap,
1,127✔
1203
    linkAdjacencyMap: IAdjacencyMap,
1,127✔
1204
    startRecordIds: string[]
1,127✔
1205
  ): Promise<IRelatedRecordItem[]> {
1,127✔
1206
    const affectedRecordItemsQuerySql = this.affectedRecordItemsQuerySql(
1,127✔
1207
      startFieldIds,
1,127✔
1208
      fieldMap,
1,127✔
1209
      linkAdjacencyMap,
1,127✔
1210
      startRecordIds
1,127✔
1211
    );
1,127✔
1212

1,127✔
1213
    return this.prismaService
1,127✔
1214
      .txClient()
1,127✔
1215
      .$queryRawUnsafe<IRelatedRecordItem[]>(affectedRecordItemsQuerySql);
1,127✔
1216
  }
1,127✔
1217

395✔
1218
  async getRelatedItems(
395✔
1219
    startFieldIds: string[],
1,181✔
1220
    fieldMap: IFieldMap,
1,181✔
1221
    linkAdjacencyMap: IAdjacencyMap,
1,181✔
1222
    startRecordIds: string[]
1,181✔
1223
  ) {
1,181✔
1224
    if (isEmpty(startRecordIds) || isEmpty(linkAdjacencyMap)) {
1,181✔
1225
      return [];
54✔
1226
    }
54✔
1227
    const effectedItems = await this.getAffectedRecordItems(
1,127✔
1228
      startFieldIds,
1,127✔
1229
      fieldMap,
1,127✔
1230
      linkAdjacencyMap,
1,127✔
1231
      startRecordIds
1,127✔
1232
    );
1,127✔
1233

1,127✔
1234
    const dependentItems = await this.getDependentRecordItems(fieldMap, effectedItems);
1,127✔
1235

1,127✔
1236
    return unionWith(
1,127✔
1237
      effectedItems,
1,127✔
1238
      dependentItems,
1,127✔
1239
      (left, right) =>
1,127✔
1240
        left.toId === right.toId && left.fromId === right.fromId && left.fieldId === right.fieldId
9,118,066✔
1241
    );
1,127✔
1242
  }
1,127✔
1243

395✔
1244
  flatGraph(graph: { toFieldId: string; fromFieldId: string }[]) {
395✔
1245
    const allNodes = new Set<string>();
1,994✔
1246
    for (const edge of graph) {
1,994✔
1247
      allNodes.add(edge.fromFieldId);
3,824✔
1248
      allNodes.add(edge.toFieldId);
3,824✔
1249
    }
3,824✔
1250
    return Array.from(allNodes);
1,994✔
1251
  }
1,994✔
1252
}
395✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc