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

teableio / teable / 11008393896

24 Sep 2024 06:39AM UTC coverage: 84.949% (-0.005%) from 84.954%
11008393896

push

github

web-flow
feat: expand base select props (#938)

5623 of 5927 branches covered (94.87%)

36929 of 43472 relevant lines covered (84.95%)

1164.88 hits per line

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

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

4✔
34
// topo item is for field level reference, all id stands for fieldId;
4✔
35
export interface ITopoItem {
4✔
36
  id: string;
4✔
37
  dependencies: string[];
4✔
38
}
4✔
39

4✔
40
export interface IGraphItem {
4✔
41
  fromFieldId: string;
4✔
42
  toFieldId: string;
4✔
43
}
4✔
44

4✔
45
export interface IRecordMap {
4✔
46
  [recordId: string]: IRecord;
4✔
47
}
4✔
48

4✔
49
export interface IRecordItem {
4✔
50
  record: IRecord;
4✔
51
  dependencies?: IRecord[];
4✔
52
}
4✔
53

4✔
54
export interface IRecordData {
4✔
55
  id: string;
4✔
56
  fieldId: string;
4✔
57
  oldValue?: unknown;
4✔
58
  newValue: unknown;
4✔
59
}
4✔
60

4✔
61
export interface IRelatedRecordItem {
4✔
62
  fieldId: string;
4✔
63
  toId: string;
4✔
64
  fromId: string;
4✔
65
}
4✔
66

4✔
67
export interface IOpsMap {
4✔
68
  [tableId: string]: {
4✔
69
    [keyId: string]: IOtOperation[];
4✔
70
  };
4✔
71
}
4✔
72

4✔
73
export interface ITopoItemWithRecords extends ITopoItem {
4✔
74
  recordItemMap?: Record<string, IRecordItem>;
4✔
75
}
4✔
76

4✔
77
export interface ITopoLinkOrder {
4✔
78
  fieldId: string;
4✔
79
  relationship: Relationship;
4✔
80
  fkHostTableName: string;
4✔
81
  selfKeyName: string;
4✔
82
  foreignKeyName: string;
4✔
83
}
4✔
84

4✔
85
@Injectable()
4✔
86
export class ReferenceService {
4✔
87
  private readonly logger = new Logger(ReferenceService.name);
393✔
88

393✔
89
  constructor(
393✔
90
    private readonly prismaService: PrismaService,
393✔
91
    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex
393✔
92
  ) {}
393✔
93

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

2,821✔
117
    saveForeignKeyToDb && (await saveForeignKeyToDb());
2,821✔
118

528✔
119
    // console.log('recordDataRemains', JSON.stringify(recordDataRemains, null, 2));
528✔
120
    const resultAfter = await this.calculate(this.mergeDuplicateRecordData(recordDataRemains));
528✔
121
    // console.log('resultAfter', JSON.stringify(resultAfter?.changes, null, 2));
2,821✔
122

2,821✔
123
    const changes = [resultBefore?.changes, resultAfter?.changes]
2,821✔
124
      .filter(Boolean)
2,821✔
125
      .flat() as ICellChange[];
2,821✔
126

2,821✔
127
    const fieldMap = Object.assign({}, resultBefore?.fieldMap, resultAfter?.fieldMap);
2,821✔
128

2,821✔
129
    const tableId2DbTableName = Object.assign(
2,821✔
130
      {},
2,821✔
131
      resultBefore?.tableId2DbTableName,
2,821✔
132
      resultAfter?.tableId2DbTableName
2,821✔
133
    );
2,821✔
134

2,821✔
135
    return {
2,821✔
136
      opsMap: formatChangesToOps(changes),
2,821✔
137
      fieldMap,
2,821✔
138
      tableId2DbTableName,
2,821✔
139
    };
2,821✔
140
  }
2,821✔
141

393✔
142
  getTopoOrdersMap(fieldIds: string[], directedGraph: IGraphItem[]) {
393✔
143
    return fieldIds.reduce<{
1,895✔
144
      [fieldId: string]: ITopoItem[];
1,895✔
145
    }>((pre, fieldId) => {
1,895✔
146
      try {
3,702✔
147
        pre[fieldId] = topoOrderWithDepends(fieldId, directedGraph);
3,702✔
148
      } catch (e) {
3,702✔
149
        throw new BadRequestException((e as { message: string }).message);
×
150
      }
×
151
      return pre;
3,702✔
152
    }, {});
1,895✔
153
  }
1,895✔
154

393✔
155
  getLinkAdjacencyMap(fieldMap: IFieldMap, directedGraph: IGraphItem[]) {
393✔
156
    const linkIdSet = Object.values(fieldMap).reduce((pre, field) => {
1,389✔
157
      if (field.lookupOptions || field.type === FieldType.Link) {
5,985✔
158
        pre.add(field.id);
3,229✔
159
      }
3,229✔
160
      return pre;
5,985✔
161
    }, new Set<string>());
1,389✔
162
    if (linkIdSet.size === 0) {
1,389✔
163
      return {};
264✔
164
    }
264✔
165
    return buildCompressedAdjacencyMap(directedGraph, linkIdSet);
1,125✔
166
  }
1,125✔
167

393✔
168
  async prepareCalculation(recordData: IRecordData[]) {
393✔
169
    if (!recordData.length) {
5,644✔
170
      return;
3,491✔
171
    }
3,491✔
172
    const { directedGraph, startFieldIds, startRecordIds } =
2,153✔
173
      await this.getDirectedGraph(recordData);
2,153✔
174
    if (!directedGraph.length) {
5,632✔
175
      return;
1,248✔
176
    }
1,248✔
177

905✔
178
    // get all related field by undirected graph
905✔
179
    const allFieldIds = uniq(this.flatGraph(directedGraph).concat(startFieldIds));
905✔
180
    // prepare all related data
905✔
181
    const {
905✔
182
      fieldMap,
905✔
183
      fieldId2TableId,
905✔
184
      dbTableName2fields,
905✔
185
      tableId2DbTableName,
905✔
186
      fieldId2DbTableName,
905✔
187
    } = await this.createAuxiliaryData(allFieldIds);
905✔
188

905✔
189
    const topoOrdersMap = this.getTopoOrdersMap(startFieldIds, directedGraph);
905✔
190

905✔
191
    const linkAdjacencyMap = this.getLinkAdjacencyMap(fieldMap, directedGraph);
905✔
192

905✔
193
    if (isEmpty(topoOrdersMap)) {
5,380✔
194
      return;
×
195
    }
✔
196

905✔
197
    const relatedRecordItems = await this.getRelatedItems(
905✔
198
      startFieldIds,
905✔
199
      fieldMap,
905✔
200
      linkAdjacencyMap,
905✔
201
      startRecordIds
905✔
202
    );
905✔
203

905✔
204
    // record data source
905✔
205
    const dbTableName2recordMap = await this.getRecordMapBatch({
905✔
206
      fieldMap,
905✔
207
      fieldId2DbTableName,
905✔
208
      dbTableName2fields,
905✔
209
      modifiedRecords: recordData,
905✔
210
      relatedRecordItems,
905✔
211
    });
905✔
212

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

905✔
230
    return {
905✔
231
      fieldMap,
905✔
232
      fieldId2TableId,
905✔
233
      tableId2DbTableName,
905✔
234
      orderWithRecordsByFieldId,
905✔
235
      dbTableName2recordMap,
905✔
236
    };
905✔
237
  }
905✔
238

393✔
239
  async calculate(recordData: IRecordData[]) {
393✔
240
    const result = await this.prepareCalculation(recordData);
5,642✔
241
    if (!result) {
5,642✔
242
      return;
4,739✔
243
    }
4,739✔
244

903✔
245
    const { orderWithRecordsByFieldId, fieldMap, fieldId2TableId, tableId2DbTableName } = result;
903✔
246
    const changes = Object.values(orderWithRecordsByFieldId).reduce<ICellChange[]>(
903✔
247
      (pre, orderWithRecords) => {
903✔
248
        // nameConsole('orderWithRecords:', orderWithRecords, fieldMap);
2,681✔
249
        return pre.concat(this.collectChanges(orderWithRecords, fieldMap, fieldId2TableId));
2,681✔
250
      },
2,681✔
251
      []
903✔
252
    );
903✔
253
    // nameConsole('changes', changes, fieldMap);
903✔
254

903✔
255
    return {
903✔
256
      changes: mergeDuplicateChange(changes),
903✔
257
      fieldMap,
903✔
258
      tableId2DbTableName,
903✔
259
    };
903✔
260
  }
903✔
261

393✔
262
  private splitOpsMap(opsMap: IOpsMap) {
393✔
263
    const recordDataDelete: IRecordData[] = [];
2,821✔
264
    const recordDataRemains: IRecordData[] = [];
2,821✔
265
    for (const tableId in opsMap) {
2,821✔
266
      for (const recordId in opsMap[tableId]) {
2,279✔
267
        opsMap[tableId][recordId].forEach((op) => {
4,141✔
268
          const ctx = RecordOpBuilder.editor.setRecord.detect(op);
6,042✔
269
          if (!ctx) {
6,042✔
270
            throw new Error(
×
271
              'invalid op, it should detect by RecordOpBuilder.editor.setRecord.detect'
×
272
            );
×
273
          }
×
274
          if (isLinkCellValue(ctx.oldCellValue) || isLinkCellValue(ctx.newCellValue)) {
6,042✔
275
            ctx.oldCellValue &&
1,760✔
276
              recordDataDelete.push({
749✔
277
                id: recordId,
749✔
278
                fieldId: ctx.fieldId,
749✔
279
                oldValue: ctx.oldCellValue,
749✔
280
                newValue: null,
749✔
281
              });
749✔
282
            ctx.newCellValue &&
1,760✔
283
              recordDataRemains.push({
1,499✔
284
                id: recordId,
1,499✔
285
                fieldId: ctx.fieldId,
1,499✔
286
                newValue: ctx.newCellValue,
1,499✔
287
              });
1,499✔
288
          } else {
6,042✔
289
            recordDataRemains.push({
4,282✔
290
              id: recordId,
4,282✔
291
              fieldId: ctx.fieldId,
4,282✔
292
              oldValue: ctx.oldCellValue,
4,282✔
293
              newValue: ctx.newCellValue,
4,282✔
294
            });
4,282✔
295
          }
4,282✔
296
        });
6,042✔
297
      }
4,141✔
298
    }
2,279✔
299

2,821✔
300
    return {
2,821✔
301
      recordDataDelete,
2,821✔
302
      recordDataRemains,
2,821✔
303
    };
2,821✔
304
  }
2,821✔
305

393✔
306
  private async getDirectedGraph(recordData: IRecordData[]) {
393✔
307
    let startFieldIds = recordData.map((data) => data.fieldId);
2,153✔
308
    const linkData = recordData.filter(
2,153✔
309
      (data) => isLinkCellValue(data.newValue) || isLinkCellValue(data.oldValue)
2,153✔
310
    );
2,153✔
311
    // const linkIds = linkData
2,153✔
312
    //   .map((data) => [data.newValue, data.oldValue] as ILinkCellValue[])
2,153✔
313
    //   .flat()
2,153✔
314
    //   .filter(Boolean)
2,153✔
315
    //   .map((d) => d.id);
2,153✔
316
    const startRecordIds = recordData.map((data) => data.id);
2,153✔
317
    const linkFieldIds = linkData.map((data) => data.fieldId);
2,153✔
318

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

393✔
336
  // for lookup field, cellValues should be flat and filter
393✔
337
  private filterArrayNull(lookupValues: unknown[] | unknown) {
393✔
338
    if (Array.isArray(lookupValues)) {
1,932✔
339
      const flatten = lookupValues.filter((value) => value != null);
1,227✔
340
      return flatten.length ? flatten : null;
1,227✔
341
    }
1,227✔
342
    return lookupValues;
705✔
343
  }
705✔
344

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

393✔
359
  private shouldSkipCompute(field: IFieldInstance, recordItem: IRecordItem) {
393✔
360
    if (!field.isComputed && field.type !== FieldType.Link) {
50,583✔
361
      return true;
627✔
362
    }
627✔
363

49,956✔
364
    // skip calculate when direct set link cell by input (it has no dependencies)
49,956✔
365
    if (field.type === FieldType.Link && !field.lookupOptions && !recordItem.dependencies) {
50,583✔
366
      return true;
160✔
367
    }
160✔
368

49,796✔
369
    if ((field.lookupOptions || field.type === FieldType.Link) && !recordItem.dependencies) {
50,583✔
370
      // console.log('empty:field', field);
833✔
371
      // console.log('empty:recordItem', JSON.stringify(recordItem, null, 2));
833✔
372
      return true;
833✔
373
    }
833✔
374
    return false;
48,963✔
375
  }
48,963✔
376

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

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

22✔
403
    return field.convertDBValue2CellValue({
22✔
404
      id: user.id,
22✔
405
      title: user.name,
22✔
406
      email: user.email,
22✔
407
    });
22✔
408
  }
22✔
409

393✔
410
  // eslint-disable-next-line sonarjs/cognitive-complexity
393✔
411
  private calculateComputeField(
393✔
412
    field: IFieldInstance,
48,963✔
413
    fieldMap: IFieldMap,
48,963✔
414
    recordItem: IRecordItem,
48,963✔
415
    userMap?: { [userId: string]: IUserInfoVo }
48,963✔
416
  ) {
48,963✔
417
    const record = recordItem.record;
48,963✔
418

48,963✔
419
    if (field.lookupOptions || field.type === FieldType.Link) {
48,963✔
420
      const lookupFieldId = field.lookupOptions
3,895✔
421
        ? field.lookupOptions.lookupFieldId
2,262✔
422
        : (field.options as ILinkFieldOptions).lookupFieldId;
3,895✔
423
      const relationship = field.lookupOptions
3,895✔
424
        ? field.lookupOptions.relationship
2,262✔
425
        : (field.options as ILinkFieldOptions).relationship;
3,895✔
426

3,895✔
427
      if (!lookupFieldId) {
3,895✔
428
        throw new Error('lookupFieldId should not be undefined');
×
429
      }
×
430

3,895✔
431
      if (!relationship) {
3,895✔
432
        throw new Error('relationship should not be undefined');
×
433
      }
×
434

3,895✔
435
      const lookedField = fieldMap[lookupFieldId];
3,895✔
436
      // nameConsole('calculateLookup:dependencies', recordItem.dependencies, fieldMap);
3,895✔
437
      const originLookupValues = this.calculateLookup(field, lookedField, recordItem);
3,895✔
438
      const lookupValues = Array.isArray(originLookupValues)
3,895✔
439
        ? originLookupValues.flat()
2,230✔
440
        : originLookupValues;
1,665✔
441

3,895✔
442
      // console.log('calculateLookup:dependencies', recordItem.dependencies);
3,895✔
443
      // console.log('calculateLookup:lookupValues', lookupValues, recordItem);
3,895✔
444

3,895✔
445
      if (field.isLookup) {
3,895✔
446
        return this.filterArrayNull(lookupValues);
1,932✔
447
      }
1,932✔
448

1,963✔
449
      return this.calculateRollupAndLink(field, relationship, lookedField, record, lookupValues);
1,963✔
450
    }
1,963✔
451

45,068✔
452
    if (field.type === FieldType.CreatedBy || field.type === FieldType.LastModifiedBy) {
48,963✔
453
      return this.calculateUser(field, record, userMap);
32✔
454
    }
32✔
455

45,036✔
456
    if (
45,036✔
457
      field.type === FieldType.Formula ||
45,036✔
458
      field.type === FieldType.AutoNumber ||
48,963✔
459
      field.type === FieldType.CreatedTime ||
48,963✔
460
      field.type === FieldType.LastModifiedTime
60✔
461
    ) {
48,963✔
462
      return this.calculateFormula(field, fieldMap, recordItem);
45,036✔
463
    }
45,036✔
464

×
465
    throw new BadRequestException(`Unsupported field type ${field.type}`);
×
466
  }
×
467

393✔
468
  private calculateFormula(
393✔
469
    field: FormulaFieldDto | AutoNumberFieldDto | CreatedTimeFieldDto | LastModifiedTimeFieldDto,
45,036✔
470
    fieldMap: IFieldMap,
45,036✔
471
    recordItem: IRecordItem
45,036✔
472
  ) {
45,036✔
473
    if (field.hasError) {
45,036✔
474
      return null;
×
475
    }
×
476

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

393✔
493
  /**
393✔
494
   * lookup values should filter by linkCellValue
393✔
495
   */
393✔
496
  // eslint-disable-next-line sonarjs/cognitive-complexity
393✔
497
  private calculateLookup(
393✔
498
    field: IFieldInstance,
3,895✔
499
    lookedField: IFieldInstance,
3,895✔
500
    recordItem: IRecordItem
3,895✔
501
  ) {
3,895✔
502
    const fieldId = lookedField.id;
3,895✔
503
    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
3,895✔
504
    const dependencies = recordItem.dependencies!;
3,895✔
505
    const lookupOptions = field.lookupOptions
3,895✔
506
      ? field.lookupOptions
2,262✔
507
      : (field.options as ILinkFieldOptions);
3,895✔
508
    const { relationship } = lookupOptions;
3,895✔
509
    const linkFieldId = field.lookupOptions ? field.lookupOptions.linkFieldId : field.id;
3,895✔
510
    const cellValue = recordItem.record.fields[linkFieldId];
3,895✔
511

3,895✔
512
    if (relationship === Relationship.OneMany || relationship === Relationship.ManyMany) {
3,895✔
513
      if (!dependencies) {
2,755✔
514
        return null;
×
515
      }
×
516

2,755✔
517
      // sort lookup values by link cell order
2,755✔
518
      const dependenciesIndexed = keyBy(dependencies, 'id');
2,755✔
519
      const linkCellValues = cellValue as ILinkCellValue[];
2,755✔
520
      // when reset a link cell, the link cell value will be null
2,755✔
521
      // but dependencies will still be there in the first round calculation
2,755✔
522
      if (linkCellValues) {
2,755✔
523
        return linkCellValues
2,216✔
524
          .map((v) => {
2,216✔
525
            return dependenciesIndexed[v.id];
4,342✔
526
          })
4,342✔
527
          .map((depRecord) => depRecord.fields[fieldId]);
2,216✔
528
      }
2,216✔
529

539✔
530
      return null;
539✔
531
    }
539✔
532

1,140✔
533
    if (relationship === Relationship.ManyOne || relationship === Relationship.OneOne) {
3,895✔
534
      if (!dependencies) {
1,140✔
535
        return null;
×
536
      }
×
537
      if (dependencies.length !== 1) {
1,140✔
538
        throw new Error(
×
539
          'dependencies should have only 1 element when relationship is manyOne or oneOne'
×
540
        );
×
541
      }
×
542

1,140✔
543
      const linkCellValue = cellValue as ILinkCellValue;
1,140✔
544
      if (linkCellValue) {
1,140✔
545
        return dependencies[0].fields[fieldId] ?? null;
822✔
546
      }
822✔
547
      return null;
318✔
548
    }
318✔
549
  }
3,895✔
550

393✔
551
  private calculateLink(
393✔
552
    field: LinkFieldDto,
1,633✔
553
    virtualField: IFieldInstance,
1,633✔
554
    record: IRecord,
1,633✔
555
    lookupValues: unknown
1,633✔
556
  ) {
1,633✔
557
    const linkCellValues = record.fields[field.id] as ILinkCellValue[] | ILinkCellValue | undefined;
1,633✔
558
    if (!linkCellValues) {
1,633✔
559
      return null;
294✔
560
    }
294✔
561

1,339✔
562
    if (virtualField.isMultipleCellValue) {
1,631✔
563
      if (!Array.isArray(lookupValues)) {
865✔
564
        throw new Error('lookupValues should be array when virtualField is multiple cell value');
×
565
      }
×
566

865✔
567
      if (!Array.isArray(linkCellValues)) {
865✔
568
        throw new Error('linkCellValues should be array when virtualField is multiple cell value');
×
569
      }
×
570

865✔
571
      if (linkCellValues.length !== lookupValues.length) {
865✔
572
        throw new Error(
×
573
          'lookupValues length should be same as linkCellValues length, now: ' +
×
574
            linkCellValues.length +
×
575
            ' - ' +
×
576
            lookupValues.length
×
577
        );
×
578
      }
×
579

865✔
580
      const titles = lookupValues.map((item) => {
865✔
581
        return virtualField.item2String(item);
1,393✔
582
      });
1,393✔
583

865✔
584
      return field.updateCellTitle(linkCellValues, titles);
865✔
585
    }
865✔
586

474✔
587
    return field.updateCellTitle(linkCellValues, virtualField.cellValue2String(lookupValues));
474✔
588
  }
474✔
589

393✔
590
  private calculateRollupAndLink(
393✔
591
    field: IFieldInstance,
1,963✔
592
    relationship: Relationship,
1,963✔
593
    lookupField: IFieldInstance,
1,963✔
594
    record: IRecord,
1,963✔
595
    lookupValues: unknown
1,963✔
596
  ): unknown {
1,963✔
597
    if (field.type !== FieldType.Link && field.type !== FieldType.Rollup) {
1,963✔
598
      throw new BadRequestException('rollup only support link and rollup field currently');
×
599
    }
×
600

1,963✔
601
    const fieldVo = instanceToPlain(lookupField, { excludePrefixes: ['_'] }) as IFieldVo;
1,963✔
602
    const virtualField = createFieldInstanceByVo({
1,963✔
603
      ...fieldVo,
1,963✔
604
      id: 'values',
1,963✔
605
      isMultipleCellValue:
1,963✔
606
        fieldVo.isMultipleCellValue || isMultiValueLink(relationship) || undefined,
1,963✔
607
    });
1,963✔
608

1,963✔
609
    if (field.type === FieldType.Rollup) {
1,963✔
610
      // console.log('calculateRollup', field, lookupField, record, lookupValues);
330✔
611
      if (lookupValues == null) {
330✔
612
        return null;
128✔
613
      }
128✔
614
      return field
202✔
615
        .evaluate(
202✔
616
          { values: virtualField },
202✔
617
          { ...record, fields: { ...record.fields, values: lookupValues } }
202✔
618
        )
202✔
619
        .toPlain();
202✔
620
    }
202✔
621

1,633✔
622
    if (field.type === FieldType.Link) {
1,633✔
623
      return this.calculateLink(field, virtualField, record, lookupValues);
1,633✔
624
    }
1,633✔
625
  }
1,963✔
626

393✔
627
  async createAuxiliaryData(allFieldIds: string[]) {
393✔
628
    const prisma = this.prismaService.txClient();
1,895✔
629
    const fieldRaws = await prisma.field.findMany({
1,895✔
630
      where: { id: { in: allFieldIds }, deletedTime: null },
1,895✔
631
    });
1,895✔
632

1,895✔
633
    // if a field that has been looked up  has changed, the link field should be retrieved as context
1,895✔
634
    const extraLinkFieldIds = difference(
1,895✔
635
      fieldRaws
1,895✔
636
        .filter((field) => field.lookupLinkedFieldId)
1,895✔
637
        .map((field) => field.lookupLinkedFieldId as string),
1,895✔
638
      allFieldIds
1,895✔
639
    );
1,895✔
640

1,895✔
641
    const extraLinkFieldRaws = await prisma.field.findMany({
1,895✔
642
      where: { id: { in: extraLinkFieldIds }, deletedTime: null },
1,895✔
643
    });
1,895✔
644

1,895✔
645
    fieldRaws.push(...extraLinkFieldRaws);
1,895✔
646

1,895✔
647
    const fieldId2TableId = fieldRaws.reduce<{ [fieldId: string]: string }>((pre, f) => {
1,895✔
648
      pre[f.id] = f.tableId;
7,023✔
649
      return pre;
7,023✔
650
    }, {});
1,895✔
651

1,895✔
652
    const tableIds = uniq(Object.values(fieldId2TableId));
1,895✔
653
    const tableMeta = await prisma.tableMeta.findMany({
1,895✔
654
      where: { id: { in: tableIds } },
1,895✔
655
      select: { id: true, dbTableName: true },
1,895✔
656
    });
1,895✔
657

1,895✔
658
    const tableId2DbTableName = tableMeta.reduce<{ [tableId: string]: string }>((pre, t) => {
1,895✔
659
      pre[t.id] = t.dbTableName;
3,432✔
660
      return pre;
3,432✔
661
    }, {});
1,895✔
662

1,895✔
663
    const fieldMap = fieldRaws.reduce<IFieldMap>((pre, f) => {
1,895✔
664
      pre[f.id] = createFieldInstanceByRaw(f);
7,023✔
665
      return pre;
7,023✔
666
    }, {});
1,895✔
667

1,895✔
668
    const dbTableName2fields = fieldRaws.reduce<{ [fieldId: string]: IFieldInstance[] }>(
1,895✔
669
      (pre, f) => {
1,895✔
670
        const dbTableName = tableId2DbTableName[f.tableId];
7,023✔
671
        if (pre[dbTableName]) {
7,023✔
672
          pre[dbTableName].push(fieldMap[f.id]);
3,591✔
673
        } else {
7,023✔
674
          pre[dbTableName] = [fieldMap[f.id]];
3,432✔
675
        }
3,432✔
676
        return pre;
7,023✔
677
      },
7,023✔
678
      {}
1,895✔
679
    );
1,895✔
680

1,895✔
681
    const fieldId2DbTableName = fieldRaws.reduce<{ [fieldId: string]: string }>((pre, f) => {
1,895✔
682
      pre[f.id] = tableId2DbTableName[f.tableId];
7,023✔
683
      return pre;
7,023✔
684
    }, {});
1,895✔
685

1,895✔
686
    return {
1,895✔
687
      fieldMap,
1,895✔
688
      fieldId2TableId,
1,895✔
689
      fieldId2DbTableName,
1,895✔
690
      dbTableName2fields,
1,895✔
691
      tableId2DbTableName,
1,895✔
692
    };
1,895✔
693
  }
1,895✔
694

393✔
695
  collectChanges(
393✔
696
    orders: ITopoItemWithRecords[],
3,186✔
697
    fieldMap: IFieldMap,
3,186✔
698
    fieldId2TableId: { [fieldId: string]: string },
3,186✔
699
    userMap?: { [userId: string]: IUserInfoVo }
3,186✔
700
  ) {
3,186✔
701
    // detail changes
3,186✔
702
    const changes: ICellChange[] = [];
3,186✔
703
    // console.log('collectChanges:orders:', JSON.stringify(orders, null, 2));
3,186✔
704

3,186✔
705
    orders.forEach((item) => {
3,186✔
706
      Object.values(item.recordItemMap || {}).forEach((recordItem) => {
3,611✔
707
        const field = fieldMap[item.id];
50,583✔
708
        const record = recordItem.record;
50,583✔
709
        if (this.shouldSkipCompute(field, recordItem)) {
50,583✔
710
          return;
1,620✔
711
        }
1,620✔
712

48,963✔
713
        const value = this.calculateComputeField(field, fieldMap, recordItem, userMap);
48,963✔
714
        // console.log(
48,963✔
715
        //   `calculated: ${field.type}.${field.id}.${record.id}`,
48,963✔
716
        //   recordItem.record.fields,
48,963✔
717
        //   value
48,963✔
718
        // );
48,963✔
719
        const oldValue = record.fields[field.id];
48,963✔
720
        record.fields[field.id] = value;
48,963✔
721
        changes.push({
48,963✔
722
          tableId: fieldId2TableId[field.id],
48,963✔
723
          fieldId: field.id,
48,963✔
724
          recordId: record.id,
48,963✔
725
          oldValue,
48,963✔
726
          newValue: value,
48,963✔
727
        });
48,963✔
728
      });
48,963✔
729
    });
3,611✔
730
    return changes;
3,186✔
731
  }
3,186✔
732

393✔
733
  recordRaw2Record(fields: IFieldInstance[], raw: { [dbFieldName: string]: unknown }): IRecord {
393✔
734
    const fieldsData = fields.reduce<{ [fieldId: string]: unknown }>((acc, field) => {
50,699✔
735
      acc[field.id] = field.convertDBValue2CellValue(raw[field.dbFieldName] as string);
78,565✔
736
      return acc;
78,565✔
737
    }, {});
50,699✔
738

50,699✔
739
    return {
50,699✔
740
      fields: fieldsData,
50,699✔
741
      id: raw.__id as string,
50,699✔
742
      autoNumber: raw.__auto_number as number,
50,699✔
743
      createdTime: (raw.__created_time as Date)?.toISOString(),
50,699✔
744
      lastModifiedTime: (raw.__last_modified_time as Date)?.toISOString(),
50,699✔
745
      createdBy: raw.__created_by as string,
50,699✔
746
      lastModifiedBy: raw.__last_modified_by as string,
50,699✔
747
    };
50,699✔
748
  }
50,699✔
749

393✔
750
  getLinkOrderFromTopoOrders(params: {
393✔
751
    topoOrders: ITopoItem[];
×
752
    fieldMap: IFieldMap;
×
753
  }): ITopoLinkOrder[] {
×
754
    const newOrder: ITopoLinkOrder[] = [];
×
755
    const { topoOrders, fieldMap } = params;
×
756
    // one link fieldId only need to add once
×
757
    const checkSet = new Set<string>();
×
758
    for (const item of topoOrders) {
×
759
      const field = fieldMap[item.id];
×
760
      if (field.lookupOptions) {
×
761
        const { fkHostTableName, selfKeyName, foreignKeyName, relationship, linkFieldId } =
×
762
          field.lookupOptions;
×
763
        if (checkSet.has(linkFieldId)) {
×
764
          continue;
×
765
        }
×
766
        checkSet.add(linkFieldId);
×
767
        newOrder.push({
×
768
          fieldId: linkFieldId,
×
769
          relationship,
×
770
          fkHostTableName,
×
771
          selfKeyName,
×
772
          foreignKeyName,
×
773
        });
×
774
        continue;
×
775
      }
×
776

×
777
      if (field.type === FieldType.Link) {
×
778
        const { fkHostTableName, selfKeyName, foreignKeyName } = field.options;
×
779
        if (checkSet.has(field.id)) {
×
780
          continue;
×
781
        }
×
782
        checkSet.add(field.id);
×
783
        newOrder.push({
×
784
          fieldId: field.id,
×
785
          relationship: field.options.relationship,
×
786
          fkHostTableName,
×
787
          selfKeyName,
×
788
          foreignKeyName,
×
789
        });
×
790
      }
×
791
    }
×
792
    return newOrder;
×
793
  }
×
794

393✔
795
  getRecordIdsByTableName(params: {
393✔
796
    fieldMap: IFieldMap;
1,389✔
797
    fieldId2DbTableName: Record<string, string>;
1,389✔
798
    initialRecordIdMap?: { [dbTableName: string]: Set<string> };
1,389✔
799
    modifiedRecords: IRecordData[];
1,389✔
800
    relatedRecordItems: IRelatedRecordItem[];
1,389✔
801
  }) {
1,389✔
802
    const {
1,389✔
803
      fieldMap,
1,389✔
804
      fieldId2DbTableName,
1,389✔
805
      initialRecordIdMap,
1,389✔
806
      modifiedRecords,
1,389✔
807
      relatedRecordItems,
1,389✔
808
    } = params;
1,389✔
809
    const recordIdsByTableName = cloneDeep(initialRecordIdMap) || {};
1,389✔
810
    const insertId = (fieldId: string, id: string) => {
1,389✔
811
      const dbTableName = fieldId2DbTableName[fieldId];
15,264✔
812
      if (!recordIdsByTableName[dbTableName]) {
15,264✔
813
        recordIdsByTableName[dbTableName] = new Set<string>();
1,764✔
814
      }
1,764✔
815
      recordIdsByTableName[dbTableName].add(id);
15,264✔
816
    };
15,264✔
817

1,389✔
818
    modifiedRecords.forEach((item) => {
1,389✔
819
      insertId(item.fieldId, item.id);
2,052✔
820
      const field = fieldMap[item.fieldId];
2,052✔
821
      if (field.type !== FieldType.Link) {
2,052✔
822
        return;
594✔
823
      }
594✔
824
      const lookupFieldId = field.options.lookupFieldId;
1,458✔
825

1,458✔
826
      const { newValue } = item;
1,458✔
827
      [newValue]
1,458✔
828
        .flat()
1,458✔
829
        .filter(Boolean)
1,458✔
830
        .map((item) => insertId(lookupFieldId, (item as ILinkCellValue).id));
1,458✔
831
    });
1,458✔
832

1,389✔
833
    relatedRecordItems.forEach((item) => {
1,389✔
834
      const field = fieldMap[item.fieldId];
5,844✔
835
      const options = field.lookupOptions ?? (field.options as ILinkFieldOptions);
5,844✔
836

5,844✔
837
      insertId(options.lookupFieldId, item.fromId);
5,844✔
838
      insertId(item.fieldId, item.toId);
5,844✔
839
    });
5,844✔
840

1,389✔
841
    return recordIdsByTableName;
1,389✔
842
  }
1,389✔
843

393✔
844
  async getRecordMapBatch(params: {
393✔
845
    fieldMap: IFieldMap;
1,389✔
846
    fieldId2DbTableName: Record<string, string>;
1,389✔
847
    dbTableName2fields: Record<string, IFieldInstance[]>;
1,389✔
848
    initialRecordIdMap?: { [dbTableName: string]: Set<string> };
1,389✔
849
    modifiedRecords: IRecordData[];
1,389✔
850
    relatedRecordItems: IRelatedRecordItem[];
1,389✔
851
  }) {
1,389✔
852
    const { fieldId2DbTableName, dbTableName2fields, modifiedRecords } = params;
1,389✔
853

1,389✔
854
    const recordIdsByTableName = this.getRecordIdsByTableName(params);
1,389✔
855
    const recordMap = await this.getRecordMap(recordIdsByTableName, dbTableName2fields);
1,389✔
856
    this.coverRecordData(fieldId2DbTableName, modifiedRecords, recordMap);
1,389✔
857

1,389✔
858
    return recordMap;
1,389✔
859
  }
1,389✔
860

393✔
861
  async getRecordMap(
393✔
862
    recordIdsByTableName: Record<string, Set<string>>,
1,389✔
863
    dbTableName2fields: Record<string, IFieldInstance[]>
1,389✔
864
  ) {
1,389✔
865
    const results: {
1,389✔
866
      [dbTableName: string]: { [dbFieldName: string]: unknown }[];
1,389✔
867
    } = {};
1,389✔
868
    for (const dbTableName in recordIdsByTableName) {
1,389✔
869
      // deduplication is needed
2,248✔
870
      const recordIds = Array.from(recordIdsByTableName[dbTableName]);
2,248✔
871
      const dbFieldNames = dbTableName2fields[dbTableName]
2,248✔
872
        .map((f) => f.dbFieldName)
2,248✔
873
        .concat([...preservedDbFieldNames]);
2,248✔
874
      const nativeQuery = this.knex(dbTableName)
2,248✔
875
        .select(dbFieldNames)
2,248✔
876
        .whereIn('__id', recordIds)
2,248✔
877
        .toQuery();
2,248✔
878
      const result = await this.prismaService
2,248✔
879
        .txClient()
2,248✔
880
        .$queryRawUnsafe<{ [dbFieldName: string]: unknown }[]>(nativeQuery);
2,248✔
881
      results[dbTableName] = result;
2,248✔
882
    }
2,248✔
883

1,389✔
884
    return this.formatRecordQueryResult(results, dbTableName2fields);
1,389✔
885
  }
1,389✔
886

393✔
887
  createTopoItemWithRecords(params: {
393✔
888
    topoOrders: ITopoItem[];
3,188✔
889
    tableId2DbTableName: { [tableId: string]: string };
3,188✔
890
    fieldId2TableId: { [fieldId: string]: string };
3,188✔
891
    fieldMap: IFieldMap;
3,188✔
892
    dbTableName2recordMap: { [tableName: string]: IRecordMap };
3,188✔
893
    relatedRecordItemsIndexed: Record<string, IRelatedRecordItem[]>;
3,188✔
894
  }): ITopoItemWithRecords[] {
3,188✔
895
    const {
3,188✔
896
      topoOrders,
3,188✔
897
      fieldMap,
3,188✔
898
      tableId2DbTableName,
3,188✔
899
      fieldId2TableId,
3,188✔
900
      dbTableName2recordMap,
3,188✔
901
      relatedRecordItemsIndexed,
3,188✔
902
    } = params;
3,188✔
903
    return topoOrders.map<ITopoItemWithRecords>((order) => {
3,188✔
904
      const field = fieldMap[order.id];
3,621✔
905
      const fieldId = field.id;
3,621✔
906
      const tableId = fieldId2TableId[order.id];
3,621✔
907
      const dbTableName = tableId2DbTableName[tableId];
3,621✔
908
      const recordMap = dbTableName2recordMap[dbTableName];
3,621✔
909
      const relatedItems = relatedRecordItemsIndexed[fieldId];
3,621✔
910

3,621✔
911
      // console.log('withRecord:order', JSON.stringify(order, null, 2));
3,621✔
912
      // console.log('withRecord:relatedItems', relatedItems);
3,621✔
913
      return {
3,621✔
914
        ...order,
3,621✔
915
        recordItemMap:
3,621✔
916
          recordMap &&
3,621✔
917
          Object.values(recordMap).reduce<Record<string, IRecordItem>>((pre, record) => {
3,481✔
918
            let dependencies: IRecord[] | undefined;
50,593✔
919
            if (relatedItems) {
50,593✔
920
              const options = field.lookupOptions
4,571✔
921
                ? field.lookupOptions
2,816✔
922
                : (field.options as ILinkFieldOptions);
4,571✔
923
              const foreignTableId = options.foreignTableId;
4,571✔
924
              const foreignDbTableName = tableId2DbTableName[foreignTableId];
4,571✔
925
              const foreignRecordMap = dbTableName2recordMap[foreignDbTableName];
4,571✔
926
              const dependentRecordIdsIndexed = groupBy(relatedItems, 'toId');
4,571✔
927
              const dependentRecordIds = dependentRecordIdsIndexed[record.id];
4,571✔
928

4,571✔
929
              if (dependentRecordIds) {
4,571✔
930
                dependencies = dependentRecordIds.map((item) => foreignRecordMap[item.fromId]);
3,903✔
931
              }
3,903✔
932
            }
4,571✔
933

50,593✔
934
            if (dependencies) {
50,593✔
935
              pre[record.id] = { record, dependencies };
3,903✔
936
            } else {
50,593✔
937
              pre[record.id] = { record };
46,690✔
938
            }
46,690✔
939

50,593✔
940
            return pre;
50,593✔
941
          }, {}),
3,481✔
942
      };
3,621✔
943
    });
3,621✔
944
  }
3,188✔
945

393✔
946
  formatRecordQueryResult(
393✔
947
    formattedResults: {
1,389✔
948
      [tableName: string]: { [dbFieldName: string]: unknown }[];
1,389✔
949
    },
1,389✔
950
    dbTableName2fields: { [tableId: string]: IFieldInstance[] }
1,389✔
951
  ) {
1,389✔
952
    return Object.entries(formattedResults).reduce<{
1,389✔
953
      [dbTableName: string]: IRecordMap;
1,389✔
954
    }>((acc, [dbTableName, records]) => {
1,389✔
955
      const fields = dbTableName2fields[dbTableName];
2,248✔
956
      acc[dbTableName] = records.reduce<IRecordMap>((pre, recordRaw) => {
2,248✔
957
        const record = this.recordRaw2Record(fields, recordRaw);
50,072✔
958
        pre[record.id] = record;
50,072✔
959
        return pre;
50,072✔
960
      }, {});
2,248✔
961
      return acc;
2,248✔
962
    }, {});
1,389✔
963
  }
1,389✔
964

393✔
965
  // use modified record data to cover the record data from db
393✔
966
  private coverRecordData(
393✔
967
    fieldId2DbTableName: Record<string, string>,
1,389✔
968
    newRecordData: IRecordData[],
1,389✔
969
    allRecordByDbTableName: { [tableName: string]: IRecordMap }
1,389✔
970
  ) {
1,389✔
971
    newRecordData.forEach((cover) => {
1,389✔
972
      const dbTableName = fieldId2DbTableName[cover.fieldId];
2,052✔
973
      const record = allRecordByDbTableName[dbTableName][cover.id];
2,052✔
974
      if (!record) {
2,052✔
975
        throw new BadRequestException(`Can not find record: ${cover.id} in database`);
×
976
      }
×
977
      record.fields[cover.fieldId] = cover.newValue;
2,052✔
978
    });
2,052✔
979
  }
1,389✔
980

393✔
981
  async getFieldGraphItems(startFieldIds: string[]): Promise<IGraphItem[]> {
393✔
982
    const getResult = async (startFieldIds: string[]) => {
4,244✔
983
      const _knex = this.knex;
4,244✔
984

4,244✔
985
      const nonRecursiveQuery = _knex
4,244✔
986
        .select('from_field_id', 'to_field_id')
4,244✔
987
        .from('reference')
4,244✔
988
        .whereIn('from_field_id', startFieldIds)
4,244✔
989
        .orWhereIn('to_field_id', startFieldIds);
4,244✔
990
      const recursiveQuery = _knex
4,244✔
991
        .select('deps.from_field_id', 'deps.to_field_id')
4,244✔
992
        .from('reference as deps')
4,244✔
993
        .join('connected_reference as cd', function () {
4,244✔
994
          const sql = '?? = ?? AND ?? != ??';
4,244✔
995
          const depsFromField = 'deps.from_field_id';
4,244✔
996
          const depsToField = 'deps.to_field_id';
4,244✔
997
          const cdFromField = 'cd.from_field_id';
4,244✔
998
          const cdToField = 'cd.to_field_id';
4,244✔
999
          this.on(
4,244✔
1000
            _knex.raw(sql, [depsFromField, cdFromField, depsToField, cdToField]).wrap('(', ')')
4,244✔
1001
          );
4,244✔
1002
          this.orOn(
4,244✔
1003
            _knex.raw(sql, [depsFromField, cdToField, depsToField, cdFromField]).wrap('(', ')')
4,244✔
1004
          );
4,244✔
1005
          this.orOn(
4,244✔
1006
            _knex.raw(sql, [depsToField, cdFromField, depsFromField, cdToField]).wrap('(', ')')
4,244✔
1007
          );
4,244✔
1008
          this.orOn(
4,244✔
1009
            _knex.raw(sql, [depsToField, cdToField, depsFromField, cdFromField]).wrap('(', ')')
4,244✔
1010
          );
4,244✔
1011
        });
4,244✔
1012
      const cteQuery = nonRecursiveQuery.union(recursiveQuery);
4,244✔
1013
      const finalQuery = this.knex
4,244✔
1014
        .withRecursive('connected_reference', ['from_field_id', 'to_field_id'], cteQuery)
4,244✔
1015
        .distinct('from_field_id', 'to_field_id')
4,244✔
1016
        .from('connected_reference')
4,244✔
1017
        .toQuery();
4,244✔
1018

4,244✔
1019
      return (
4,244✔
1020
        this.prismaService
4,244✔
1021
          .txClient()
4,244✔
1022
          // eslint-disable-next-line @typescript-eslint/naming-convention
4,244✔
1023
          .$queryRawUnsafe<{ from_field_id: string; to_field_id: string }[]>(finalQuery)
4,244✔
1024
      );
4,244✔
1025
    };
4,244✔
1026

4,244✔
1027
    const queryResult = await getResult(startFieldIds);
4,244✔
1028

4,244✔
1029
    return filterDirectedGraph(
4,244✔
1030
      queryResult.map((row) => ({ fromFieldId: row.from_field_id, toFieldId: row.to_field_id })),
4,244✔
1031
      startFieldIds
4,244✔
1032
    );
4,244✔
1033
  }
4,244✔
1034

393✔
1035
  private mergeDuplicateRecordData(recordData: IRecordData[]) {
393✔
1036
    const indexCache: { [key: string]: number } = {};
5,642✔
1037
    const mergedChanges: IRecordData[] = [];
5,642✔
1038

5,642✔
1039
    for (const record of recordData) {
5,642✔
1040
      const key = `${record.id}#${record.fieldId}`;
6,530✔
1041
      if (indexCache[key] !== undefined) {
6,530✔
1042
        mergedChanges[indexCache[key]] = record;
×
1043
      } else {
6,530✔
1044
        indexCache[key] = mergedChanges.length;
6,530✔
1045
        mergedChanges.push(record);
6,530✔
1046
      }
6,530✔
1047
    }
6,530✔
1048
    return mergedChanges;
5,642✔
1049
  }
5,642✔
1050

393✔
1051
  /**
393✔
1052
   * affected record changes need extra dependent record to calculate result
393✔
1053
   * example: C = A + B
393✔
1054
   * A changed, C will be affected and B is the dependent record
393✔
1055
   */
393✔
1056
  async getDependentRecordItems(
393✔
1057
    fieldMap: IFieldMap,
851✔
1058
    recordItems: IRelatedRecordItem[]
851✔
1059
  ): Promise<IRelatedRecordItem[]> {
851✔
1060
    const indexRecordItems = groupBy(recordItems, 'fieldId');
851✔
1061

851✔
1062
    const queries = Object.entries(indexRecordItems)
851✔
1063
      .filter(([fieldId]) => {
851✔
1064
        const options =
2,456✔
1065
          fieldMap[fieldId].lookupOptions || (fieldMap[fieldId].options as ILinkFieldOptions);
2,456✔
1066
        const relationship = options.relationship;
2,456✔
1067
        return relationship === Relationship.ManyMany || relationship === Relationship.OneMany;
2,456✔
1068
      })
2,456✔
1069
      .map(([fieldId, recordItem]) => {
851✔
1070
        const options =
1,502✔
1071
          fieldMap[fieldId].lookupOptions || (fieldMap[fieldId].options as ILinkFieldOptions);
1,502✔
1072
        const { fkHostTableName, selfKeyName, foreignKeyName } = options;
1,502✔
1073
        const ids = recordItem.map((item) => item.toId);
1,502✔
1074

1,502✔
1075
        return this.knex
1,502✔
1076
          .select({
1,502✔
1077
            fieldId: this.knex.raw('?', fieldId),
1,502✔
1078
            toId: selfKeyName,
1,502✔
1079
            fromId: foreignKeyName,
1,502✔
1080
          })
1,502✔
1081
          .from(fkHostTableName)
1,502✔
1082
          .whereIn(selfKeyName, ids);
1,502✔
1083
      });
1,502✔
1084

851✔
1085
    if (!queries.length) {
851✔
1086
      return [];
248✔
1087
    }
248✔
1088

603✔
1089
    const [firstQuery, ...restQueries] = queries;
603✔
1090
    const sqlQuery = firstQuery.unionAll(restQueries).toQuery();
603✔
1091
    return this.prismaService.txClient().$queryRawUnsafe<IRelatedRecordItem[]>(sqlQuery);
603✔
1092
  }
603✔
1093

393✔
1094
  affectedRecordItemsQuerySql(
393✔
1095
    startFieldIds: string[],
1,091✔
1096
    fieldMap: IFieldMap,
1,091✔
1097
    linkAdjacencyMap: IAdjacencyMap,
1,091✔
1098
    startRecordIds: string[]
1,091✔
1099
  ): string {
1,091✔
1100
    const visited = new Set<string>();
1,091✔
1101
    const knex = this.knex;
1,091✔
1102
    const query = knex.queryBuilder();
1,091✔
1103

1,091✔
1104
    function visit(node: string, preNode: string) {
1,091✔
1105
      if (visited.has(node)) {
295✔
1106
        return;
×
1107
      }
×
1108

295✔
1109
      visited.add(node);
295✔
1110
      const options = fieldMap[node].lookupOptions || (fieldMap[node].options as ILinkFieldOptions);
295✔
1111
      const { fkHostTableName, selfKeyName, foreignKeyName } = options;
295✔
1112

295✔
1113
      query.with(
295✔
1114
        node,
295✔
1115
        knex
295✔
1116
          .distinct({
295✔
1117
            toId: `${fkHostTableName}.${selfKeyName}`,
295✔
1118
            fromId: `${preNode}.toId`,
295✔
1119
          })
295✔
1120
          .from(fkHostTableName)
295✔
1121
          .whereNotNull(`${fkHostTableName}.${selfKeyName}`) // toId
295✔
1122
          .join(preNode, `${preNode}.toId`, '=', `${fkHostTableName}.${foreignKeyName}`)
295✔
1123
      );
295✔
1124
      const nextNodes = linkAdjacencyMap[node];
295✔
1125
      // Process outgoing edges
295✔
1126
      if (nextNodes) {
295✔
1127
        for (const neighbor of nextNodes) {
×
1128
          visit(neighbor, node);
×
1129
        }
×
1130
      }
×
1131
    }
295✔
1132

1,091✔
1133
    startFieldIds.forEach((fieldId) => {
1,091✔
1134
      const field = fieldMap[fieldId];
2,870✔
1135
      if (field.lookupOptions || field.type === FieldType.Link) {
2,870✔
1136
        const options = field.lookupOptions || (field.options as ILinkFieldOptions);
2,613✔
1137
        const { fkHostTableName, selfKeyName, foreignKeyName } = options;
2,613✔
1138
        if (visited.has(fieldId)) {
2,613✔
1139
          return;
33✔
1140
        }
33✔
1141
        visited.add(fieldId);
2,580✔
1142
        query.with(
2,580✔
1143
          fieldId,
2,580✔
1144
          knex
2,580✔
1145
            .distinct({
2,580✔
1146
              toId: `${fkHostTableName}.${selfKeyName}`,
2,580✔
1147
              fromId: `${fkHostTableName}.${foreignKeyName}`,
2,580✔
1148
            })
2,580✔
1149
            .from(fkHostTableName)
2,580✔
1150
            .whereIn(`${fkHostTableName}.${selfKeyName}`, startRecordIds)
2,580✔
1151
            .whereNotNull(`${fkHostTableName}.${foreignKeyName}`)
2,580✔
1152
        );
2,580✔
1153
      } else {
2,870✔
1154
        query.with(
257✔
1155
          fieldId,
257✔
1156
          knex.unionAll(
257✔
1157
            startRecordIds.map((id) =>
257✔
1158
              knex.select({ toId: knex.raw('?', id), fromId: knex.raw('?', null) })
1,530✔
1159
            )
257✔
1160
          )
257✔
1161
        );
257✔
1162
      }
257✔
1163
      const nextNodes = linkAdjacencyMap[fieldId];
2,837✔
1164

2,837✔
1165
      // start visit
2,837✔
1166
      if (nextNodes) {
2,870✔
1167
        for (const neighbor of nextNodes) {
245✔
1168
          visit(neighbor, fieldId);
295✔
1169
        }
295✔
1170
      }
245✔
1171
    });
2,870✔
1172

1,091✔
1173
    // union all result
1,091✔
1174
    query.unionAll(
1,091✔
1175
      Array.from(visited).map((fieldId) =>
1,091✔
1176
        knex
2,875✔
1177
          .select({
2,875✔
1178
            fieldId: knex.raw('?', fieldId),
2,875✔
1179
            fromId: knex.ref(`${fieldId}.fromId`),
2,875✔
1180
            toId: knex.ref(`${fieldId}.toId`),
2,875✔
1181
          })
2,875✔
1182
          .from(fieldId)
2,875✔
1183
      )
1,091✔
1184
    );
1,091✔
1185

1,091✔
1186
    return query.toQuery();
1,091✔
1187
  }
1,091✔
1188

393✔
1189
  async getAffectedRecordItems(
393✔
1190
    startFieldIds: string[],
1,091✔
1191
    fieldMap: IFieldMap,
1,091✔
1192
    linkAdjacencyMap: IAdjacencyMap,
1,091✔
1193
    startRecordIds: string[]
1,091✔
1194
  ): Promise<IRelatedRecordItem[]> {
1,091✔
1195
    const affectedRecordItemsQuerySql = this.affectedRecordItemsQuerySql(
1,091✔
1196
      startFieldIds,
1,091✔
1197
      fieldMap,
1,091✔
1198
      linkAdjacencyMap,
1,091✔
1199
      startRecordIds
1,091✔
1200
    );
1,091✔
1201

1,091✔
1202
    return this.prismaService
1,091✔
1203
      .txClient()
1,091✔
1204
      .$queryRawUnsafe<IRelatedRecordItem[]>(affectedRecordItemsQuerySql);
1,091✔
1205
  }
1,091✔
1206

393✔
1207
  async getRelatedItems(
393✔
1208
    startFieldIds: string[],
905✔
1209
    fieldMap: IFieldMap,
905✔
1210
    linkAdjacencyMap: IAdjacencyMap,
905✔
1211
    startRecordIds: string[]
905✔
1212
  ) {
905✔
1213
    if (isEmpty(startRecordIds) || isEmpty(linkAdjacencyMap)) {
905✔
1214
      return [];
54✔
1215
    }
54✔
1216
    const effectedItems = await this.getAffectedRecordItems(
851✔
1217
      startFieldIds,
851✔
1218
      fieldMap,
851✔
1219
      linkAdjacencyMap,
851✔
1220
      startRecordIds
851✔
1221
    );
851✔
1222

851✔
1223
    const dependentItems = await this.getDependentRecordItems(fieldMap, effectedItems);
851✔
1224

851✔
1225
    return unionWith(
851✔
1226
      effectedItems,
851✔
1227
      dependentItems,
851✔
1228
      (left, right) =>
851✔
1229
        left.toId === right.toId && left.fromId === right.fromId && left.fieldId === right.fieldId
63,917✔
1230
    );
851✔
1231
  }
851✔
1232

393✔
1233
  flatGraph(graph: { toFieldId: string; fromFieldId: string }[]) {
393✔
1234
    const allNodes = new Set<string>();
1,895✔
1235
    for (const edge of graph) {
1,895✔
1236
      allNodes.add(edge.fromFieldId);
3,643✔
1237
      allNodes.add(edge.toFieldId);
3,643✔
1238
    }
3,643✔
1239
    return Array.from(allNodes);
1,895✔
1240
  }
1,895✔
1241
}
393✔
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