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

teableio / teable / 10678299786

03 Sep 2024 07:15AM UTC coverage: 84.443% (-0.008%) from 84.451%
10678299786

Pull #882

github

web-flow
Merge e0439f1df into 7040c5030
Pull Request #882: fix: undo link / formula field badcases

5039 of 5285 branches covered (95.35%)

16 of 34 new or added lines in 5 files covered. (47.06%)

2 existing lines in 1 file now uncovered.

33066 of 39158 relevant lines covered (84.44%)

1230.21 hits per line

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

92.68
/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 { ICellChange } from './utils/changes';
4✔
24
import { formatChangesToOps, mergeDuplicateChange } from './utils/changes';
4✔
25
import { isLinkCellValue } from './utils/detect-link';
4✔
26
import type { IAdjacencyMap } from './utils/dfs';
4✔
27
import {
4✔
28
  buildCompressedAdjacencyMap,
4✔
29
  filterDirectedGraph,
4✔
30
  topoOrderWithDepends,
4✔
31
} from './utils/dfs';
4✔
32

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

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

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

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

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

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

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

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

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

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

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

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

2,448✔
116
    saveForeignKeyToDb && (await saveForeignKeyToDb());
2,448✔
117

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

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

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

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

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

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

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

377✔
167
  async prepareCalculation(recordData: IRecordData[]) {
377✔
168
    if (!recordData.length) {
4,898✔
169
      return;
3,252✔
170
    }
3,252✔
171
    const { directedGraph, startFieldIds, startRecordIds } =
1,646✔
172
      await this.getDirectedGraph(recordData);
1,646✔
173
    if (!directedGraph.length) {
4,890✔
174
      return;
825✔
175
    }
825✔
176

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

821✔
188
    const topoOrdersMap = this.getTopoOrdersMap(startFieldIds, directedGraph);
821✔
189

821✔
190
    const linkAdjacencyMap = this.getLinkAdjacencyMap(fieldMap, directedGraph);
821✔
191

821✔
192
    if (isEmpty(topoOrdersMap)) {
4,726✔
193
      return;
×
194
    }
✔
195

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

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

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

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

377✔
238
  async calculate(recordData: IRecordData[]) {
377✔
239
    const result = await this.prepareCalculation(recordData);
4,896✔
240
    if (!result) {
4,896✔
241
      return;
4,077✔
242
    }
4,077✔
243

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

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

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

2,448✔
299
    return {
2,448✔
300
      recordDataDelete,
2,448✔
301
      recordDataRemains,
2,448✔
302
    };
2,448✔
303
  }
2,448✔
304

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

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

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

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

377✔
358
  private shouldSkipCompute(field: IFieldInstance, recordItem: IRecordItem) {
377✔
359
    if (!field.isComputed && field.type !== FieldType.Link) {
50,243✔
360
      return true;
567✔
361
    }
567✔
362

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

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

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

377✔
389
  // eslint-disable-next-line sonarjs/cognitive-complexity
377✔
390
  private calculateComputeField(
377✔
391
    field: IFieldInstance,
48,711✔
392
    fieldMap: IFieldMap,
48,711✔
393
    recordItem: IRecordItem,
48,711✔
394
    userMap?: { [userId: string]: IUserInfoVo }
48,711✔
395
  ) {
48,711✔
396
    const record = recordItem.record;
48,711✔
397

48,711✔
398
    if (field.lookupOptions || field.type === FieldType.Link) {
48,711✔
399
      const lookupFieldId = field.lookupOptions
3,769✔
400
        ? field.lookupOptions.lookupFieldId
2,200✔
401
        : (field.options as ILinkFieldOptions).lookupFieldId;
3,769✔
402
      const relationship = field.lookupOptions
3,769✔
403
        ? field.lookupOptions.relationship
2,200✔
404
        : (field.options as ILinkFieldOptions).relationship;
3,769✔
405

3,769✔
406
      if (!lookupFieldId) {
3,769✔
407
        throw new Error('lookupFieldId should not be undefined');
×
408
      }
×
409

3,769✔
410
      if (!relationship) {
3,769✔
411
        throw new Error('relationship should not be undefined');
×
412
      }
×
413

3,769✔
414
      const lookedField = fieldMap[lookupFieldId];
3,769✔
415
      // nameConsole('calculateLookup:dependencies', recordItem.dependencies, fieldMap);
3,769✔
416
      const lookupValues = this.calculateLookup(field, lookedField, recordItem);
3,769✔
417

3,769✔
418
      // console.log('calculateLookup:dependencies', recordItem.dependencies);
3,769✔
419
      // console.log('calculateLookup:lookupValues', lookupValues, recordItem);
3,769✔
420

3,769✔
421
      if (field.isLookup) {
3,769✔
422
        return this.flatOriginLookup(lookupValues);
1,930✔
423
      }
1,930✔
424

1,839✔
425
      return this.calculateRollup(
1,839✔
426
        field,
1,839✔
427
        relationship,
1,839✔
428
        lookedField,
1,839✔
429
        record,
1,839✔
430
        this.joinOriginLookup(lookedField, lookupValues)
1,839✔
431
      );
1,839✔
432
    }
1,839✔
433

44,942✔
434
    if (field.type === FieldType.CreatedBy || field.type === FieldType.LastModifiedBy) {
48,711✔
435
      if (!userMap) {
32✔
436
        return record.fields[field.id];
2✔
437
      }
2✔
438
      const user = this.getComputedUsers(field, record, userMap);
30✔
439
      if (!user) {
32✔
440
        return record.fields[field.id];
8✔
441
      }
8✔
442

22✔
443
      return field.convertDBValue2CellValue({
22✔
444
        id: user.id,
22✔
445
        title: user.name,
22✔
446
        email: user.email,
22✔
447
      });
22✔
448
    }
22✔
449

44,910✔
450
    if (
44,910✔
451
      field.type === FieldType.Formula ||
44,910✔
452
      field.type === FieldType.AutoNumber ||
48,711✔
453
      field.type === FieldType.CreatedTime ||
48,711✔
454
      field.type === FieldType.LastModifiedTime
60✔
455
    ) {
48,711✔
456
      return this.calculateFormula(field, fieldMap, recordItem);
44,910✔
457
    }
44,910✔
458

×
459
    throw new BadRequestException(`Unsupported field type ${field.type}`);
×
460
  }
×
461

377✔
462
  private calculateFormula(
377✔
463
    field: FormulaFieldDto | AutoNumberFieldDto | CreatedTimeFieldDto | LastModifiedTimeFieldDto,
44,910✔
464
    fieldMap: IFieldMap,
44,910✔
465
    recordItem: IRecordItem
44,910✔
466
  ) {
44,910✔
467
    if (field.hasError) {
44,910✔
468
      return null;
×
469
    }
×
470

44,910✔
471
    try {
44,910✔
472
      const typedValue = evaluate(field.options.expression, fieldMap, recordItem.record);
44,910✔
473
      return typedValue.toPlain();
44,910✔
474
    } catch (e) {
44,910✔
475
      this.logger.error(
×
476
        `calculateFormula error, fieldId: ${field.id}; exp: ${field.options.expression}; recordId: ${recordItem.record.id}, ${(e as { message: string }).message}`
×
477
      );
×
478
      return null;
×
479
    }
×
480
  }
44,910✔
481

377✔
482
  /**
377✔
483
   * lookup values should filter by linkCellValue
377✔
484
   */
377✔
485
  // eslint-disable-next-line sonarjs/cognitive-complexity
377✔
486
  private calculateLookup(
377✔
487
    field: IFieldInstance,
3,769✔
488
    lookedField: IFieldInstance,
3,769✔
489
    recordItem: IRecordItem
3,769✔
490
  ) {
3,769✔
491
    const fieldId = lookedField.id;
3,769✔
492
    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
3,769✔
493
    const dependencies = recordItem.dependencies!;
3,769✔
494
    const lookupOptions = field.lookupOptions
3,769✔
495
      ? field.lookupOptions
2,200✔
496
      : (field.options as ILinkFieldOptions);
3,769✔
497
    const { relationship } = lookupOptions;
3,769✔
498
    const linkFieldId = field.lookupOptions ? field.lookupOptions.linkFieldId : field.id;
3,769✔
499
    const cellValue = recordItem.record.fields[linkFieldId];
3,769✔
500

3,769✔
501
    if (relationship === Relationship.OneMany || relationship === Relationship.ManyMany) {
3,769✔
502
      if (!dependencies) {
2,667✔
503
        return null;
×
504
      }
×
505

2,667✔
506
      // sort lookup values by link cell order
2,667✔
507
      const dependenciesIndexed = keyBy(dependencies, 'id');
2,667✔
508
      const linkCellValues = cellValue as ILinkCellValue[];
2,667✔
509
      // when reset a link cell, the link cell value will be null
2,667✔
510
      // but dependencies will still be there in the first round calculation
2,667✔
511
      if (linkCellValues) {
2,667✔
512
        return linkCellValues
2,166✔
513
          .map((v) => {
2,166✔
514
            return dependenciesIndexed[v.id];
4,264✔
515
          })
4,264✔
516
          .map((depRecord) => depRecord.fields[fieldId]);
2,166✔
517
      }
2,166✔
518

501✔
519
      return null;
501✔
520
    }
501✔
521

1,102✔
522
    if (relationship === Relationship.ManyOne || relationship === Relationship.OneOne) {
3,769✔
523
      if (!dependencies) {
1,102✔
524
        return null;
×
525
      }
×
526
      if (dependencies.length !== 1) {
1,102✔
527
        throw new Error(
×
528
          'dependencies should have only 1 element when relationship is manyOne or oneOne'
×
529
        );
×
530
      }
×
531

1,102✔
532
      const linkCellValue = cellValue as ILinkCellValue;
1,102✔
533
      if (linkCellValue) {
1,102✔
534
        return dependencies[0].fields[fieldId] ?? null;
796✔
535
      }
796✔
536
      return null;
306✔
537
    }
306✔
538
  }
3,769✔
539

377✔
540
  private calculateRollup(
377✔
541
    field: IFieldInstance,
1,839✔
542
    relationship: Relationship,
1,839✔
543
    lookupField: IFieldInstance,
1,839✔
544
    record: IRecord,
1,839✔
545
    lookupValues: unknown
1,839✔
546
  ): unknown {
1,839✔
547
    if (field.type !== FieldType.Link && field.type !== FieldType.Rollup) {
1,839✔
548
      throw new BadRequestException('rollup only support link and rollup field currently');
×
549
    }
×
550

1,839✔
551
    const fieldVo = instanceToPlain(lookupField, { excludePrefixes: ['_'] }) as IFieldVo;
1,839✔
552
    const virtualField = createFieldInstanceByVo({
1,839✔
553
      ...fieldVo,
1,839✔
554
      id: 'values',
1,839✔
555
      isMultipleCellValue:
1,839✔
556
        fieldVo.isMultipleCellValue || isMultiValueLink(relationship) || undefined,
1,839✔
557
    });
1,839✔
558

1,839✔
559
    if (field.type === FieldType.Rollup) {
1,839✔
560
      // console.log('calculateRollup', field, lookupField, record, lookupValues);
270✔
561
      return field
270✔
562
        .evaluate(
270✔
563
          { values: virtualField },
270✔
564
          { ...record, fields: { ...record.fields, values: lookupValues } }
270✔
565
        )
270✔
566
        .toPlain();
270✔
567
    }
270✔
568

1,569✔
569
    if (field.type === FieldType.Link) {
1,569✔
570
      if (!record.fields[field.id]) {
1,569✔
571
        return null;
272✔
572
      }
272✔
573

1,297✔
574
      const result = evaluate(
1,297✔
575
        'TEXT_ALL({values})',
1,297✔
576
        { values: virtualField },
1,297✔
577
        { ...record, fields: { ...record.fields, values: lookupValues } }
1,297✔
578
      );
1,297✔
579

1,297✔
580
      let plain = result.toPlain();
1,297✔
581
      if (!field.isMultipleCellValue && virtualField.isMultipleCellValue) {
1,569✔
582
        plain = virtualField.cellValue2String(plain);
×
583
      }
✔
584
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
1,297✔
585
      return field.updateCellTitle(record.fields[field.id] as any, plain);
1,297✔
586
    }
1,297✔
587
  }
1,839✔
588

377✔
589
  async createAuxiliaryData(allFieldIds: string[]) {
377✔
590
    const prisma = this.prismaService.txClient();
1,444✔
591
    const fieldRaws = await prisma.field.findMany({
1,444✔
592
      where: { id: { in: allFieldIds }, deletedTime: null },
1,444✔
593
    });
1,444✔
594

1,444✔
595
    // if a field that has been looked up  has changed, the link field should be retrieved as context
1,444✔
596
    const extraLinkFieldIds = difference(
1,444✔
597
      fieldRaws
1,444✔
598
        .filter((field) => field.lookupLinkedFieldId)
1,444✔
599
        .map((field) => field.lookupLinkedFieldId as string),
1,444✔
600
      allFieldIds
1,444✔
601
    );
1,444✔
602

1,444✔
603
    const extraLinkFieldRaws = await prisma.field.findMany({
1,444✔
604
      where: { id: { in: extraLinkFieldIds }, deletedTime: null },
1,444✔
605
    });
1,444✔
606

1,444✔
607
    fieldRaws.push(...extraLinkFieldRaws);
1,444✔
608

1,444✔
609
    const fieldId2TableId = fieldRaws.reduce<{ [fieldId: string]: string }>((pre, f) => {
1,444✔
610
      pre[f.id] = f.tableId;
5,967✔
611
      return pre;
5,967✔
612
    }, {});
1,444✔
613

1,444✔
614
    const tableIds = uniq(Object.values(fieldId2TableId));
1,444✔
615
    const tableMeta = await prisma.tableMeta.findMany({
1,444✔
616
      where: { id: { in: tableIds } },
1,444✔
617
      select: { id: true, dbTableName: true },
1,444✔
618
    });
1,444✔
619

1,444✔
620
    const tableId2DbTableName = tableMeta.reduce<{ [tableId: string]: string }>((pre, t) => {
1,444✔
621
      pre[t.id] = t.dbTableName;
2,582✔
622
      return pre;
2,582✔
623
    }, {});
1,444✔
624

1,444✔
625
    const fieldMap = fieldRaws.reduce<IFieldMap>((pre, f) => {
1,444✔
626
      pre[f.id] = createFieldInstanceByRaw(f);
5,967✔
627
      return pre;
5,967✔
628
    }, {});
1,444✔
629

1,444✔
630
    const dbTableName2fields = fieldRaws.reduce<{ [fieldId: string]: IFieldInstance[] }>(
1,444✔
631
      (pre, f) => {
1,444✔
632
        const dbTableName = tableId2DbTableName[f.tableId];
5,967✔
633
        if (pre[dbTableName]) {
5,967✔
634
          pre[dbTableName].push(fieldMap[f.id]);
3,385✔
635
        } else {
5,963✔
636
          pre[dbTableName] = [fieldMap[f.id]];
2,582✔
637
        }
2,582✔
638
        return pre;
5,967✔
639
      },
5,967✔
640
      {}
1,444✔
641
    );
1,444✔
642

1,444✔
643
    const fieldId2DbTableName = fieldRaws.reduce<{ [fieldId: string]: string }>((pre, f) => {
1,444✔
644
      pre[f.id] = tableId2DbTableName[f.tableId];
5,967✔
645
      return pre;
5,967✔
646
    }, {});
1,444✔
647

1,444✔
648
    return {
1,444✔
649
      fieldMap,
1,444✔
650
      fieldId2TableId,
1,444✔
651
      fieldId2DbTableName,
1,444✔
652
      dbTableName2fields,
1,444✔
653
      tableId2DbTableName,
1,444✔
654
    };
1,444✔
655
  }
1,444✔
656

377✔
657
  collectChanges(
377✔
658
    orders: ITopoItemWithRecords[],
3,004✔
659
    fieldMap: IFieldMap,
3,004✔
660
    fieldId2TableId: { [fieldId: string]: string },
3,004✔
661
    userMap?: { [userId: string]: IUserInfoVo }
3,004✔
662
  ) {
3,004✔
663
    // detail changes
3,004✔
664
    const changes: ICellChange[] = [];
3,004✔
665
    // console.log('collectChanges:orders:', JSON.stringify(orders, null, 2));
3,004✔
666

3,004✔
667
    orders.forEach((item) => {
3,004✔
668
      Object.values(item.recordItemMap || {}).forEach((recordItem) => {
3,381✔
669
        const field = fieldMap[item.id];
50,243✔
670
        const record = recordItem.record;
50,243✔
671
        if (this.shouldSkipCompute(field, recordItem)) {
50,243✔
672
          return;
1,532✔
673
        }
1,532✔
674

48,711✔
675
        const value = this.calculateComputeField(field, fieldMap, recordItem, userMap);
48,711✔
676
        // console.log(
48,711✔
677
        //   `calculated: ${field.type}.${field.id}.${record.id}`,
48,711✔
678
        //   recordItem.record.fields,
48,711✔
679
        //   value
48,711✔
680
        // );
48,711✔
681
        const oldValue = record.fields[field.id];
48,711✔
682
        record.fields[field.id] = value;
48,711✔
683
        changes.push({
48,711✔
684
          tableId: fieldId2TableId[field.id],
48,711✔
685
          fieldId: field.id,
48,711✔
686
          recordId: record.id,
48,711✔
687
          oldValue,
48,711✔
688
          newValue: value,
48,711✔
689
        });
48,711✔
690
      });
48,711✔
691
    });
3,381✔
692
    return changes;
3,004✔
693
  }
3,004✔
694

377✔
695
  recordRaw2Record(fields: IFieldInstance[], raw: { [dbFieldName: string]: unknown }): IRecord {
377✔
696
    const fieldsData = fields.reduce<{ [fieldId: string]: unknown }>((acc, field) => {
50,019✔
697
      acc[field.id] = field.convertDBValue2CellValue(raw[field.dbFieldName] as string);
77,627✔
698
      return acc;
77,627✔
699
    }, {});
50,019✔
700

50,019✔
701
    return {
50,019✔
702
      fields: fieldsData,
50,019✔
703
      id: raw.__id as string,
50,019✔
704
      autoNumber: raw.__auto_number as number,
50,019✔
705
      createdTime: (raw.__created_time as Date)?.toISOString(),
50,019✔
706
      lastModifiedTime: (raw.__last_modified_time as Date)?.toISOString(),
50,019✔
707
      createdBy: raw.__created_by as string,
50,019✔
708
      lastModifiedBy: raw.__last_modified_by as string,
50,019✔
709
    };
50,019✔
710
  }
50,019✔
711

377✔
712
  getLinkOrderFromTopoOrders(params: {
377✔
713
    topoOrders: ITopoItem[];
×
714
    fieldMap: IFieldMap;
×
715
  }): ITopoLinkOrder[] {
×
716
    const newOrder: ITopoLinkOrder[] = [];
×
717
    const { topoOrders, fieldMap } = params;
×
718
    // one link fieldId only need to add once
×
719
    const checkSet = new Set<string>();
×
720
    for (const item of topoOrders) {
×
721
      const field = fieldMap[item.id];
×
722
      if (field.lookupOptions) {
×
723
        const { fkHostTableName, selfKeyName, foreignKeyName, relationship, linkFieldId } =
×
724
          field.lookupOptions;
×
725
        if (checkSet.has(linkFieldId)) {
×
726
          continue;
×
727
        }
×
728
        checkSet.add(linkFieldId);
×
729
        newOrder.push({
×
730
          fieldId: linkFieldId,
×
731
          relationship,
×
732
          fkHostTableName,
×
733
          selfKeyName,
×
734
          foreignKeyName,
×
735
        });
×
736
        continue;
×
737
      }
×
738

×
739
      if (field.type === FieldType.Link) {
×
740
        const { fkHostTableName, selfKeyName, foreignKeyName } = field.options;
×
741
        if (checkSet.has(field.id)) {
×
742
          continue;
×
743
        }
×
744
        checkSet.add(field.id);
×
745
        newOrder.push({
×
746
          fieldId: field.id,
×
747
          relationship: field.options.relationship,
×
748
          fkHostTableName,
×
749
          selfKeyName,
×
750
          foreignKeyName,
×
751
        });
×
752
      }
×
753
    }
×
754
    return newOrder;
×
755
  }
×
756

377✔
757
  getRecordIdsByTableName(params: {
377✔
758
    fieldMap: IFieldMap;
1,283✔
759
    fieldId2DbTableName: Record<string, string>;
1,283✔
760
    initialRecordIdMap?: { [dbTableName: string]: Set<string> };
1,283✔
761
    modifiedRecords: IRecordData[];
1,283✔
762
    relatedRecordItems: IRelatedRecordItem[];
1,283✔
763
  }) {
1,283✔
764
    const {
1,283✔
765
      fieldMap,
1,283✔
766
      fieldId2DbTableName,
1,283✔
767
      initialRecordIdMap,
1,283✔
768
      modifiedRecords,
1,283✔
769
      relatedRecordItems,
1,283✔
770
    } = params;
1,283✔
771
    const recordIdsByTableName = cloneDeep(initialRecordIdMap) || {};
1,283✔
772
    const insertId = (fieldId: string, id: string) => {
1,283✔
773
      const dbTableName = fieldId2DbTableName[fieldId];
14,738✔
774
      if (!recordIdsByTableName[dbTableName]) {
14,738✔
775
        recordIdsByTableName[dbTableName] = new Set<string>();
1,638✔
776
      }
1,638✔
777
      recordIdsByTableName[dbTableName].add(id);
14,738✔
778
    };
14,738✔
779

1,283✔
780
    modifiedRecords.forEach((item) => {
1,283✔
781
      insertId(item.fieldId, item.id);
1,930✔
782
      const field = fieldMap[item.fieldId];
1,930✔
783
      if (field.type !== FieldType.Link) {
1,930✔
784
        return;
534✔
785
      }
534✔
786
      const lookupFieldId = field.options.lookupFieldId;
1,396✔
787

1,396✔
788
      const { newValue } = item;
1,396✔
789
      [newValue]
1,396✔
790
        .flat()
1,396✔
791
        .filter(Boolean)
1,396✔
792
        .map((item) => insertId(lookupFieldId, (item as ILinkCellValue).id));
1,396✔
793
    });
1,396✔
794

1,283✔
795
    relatedRecordItems.forEach((item) => {
1,283✔
796
      const field = fieldMap[item.fieldId];
5,670✔
797
      const options = field.lookupOptions ?? (field.options as ILinkFieldOptions);
5,670✔
798

5,670✔
799
      insertId(options.lookupFieldId, item.fromId);
5,670✔
800
      insertId(item.fieldId, item.toId);
5,670✔
801
    });
5,670✔
802

1,283✔
803
    return recordIdsByTableName;
1,283✔
804
  }
1,283✔
805

377✔
806
  async getRecordMapBatch(params: {
377✔
807
    fieldMap: IFieldMap;
1,283✔
808
    fieldId2DbTableName: Record<string, string>;
1,283✔
809
    dbTableName2fields: Record<string, IFieldInstance[]>;
1,283✔
810
    initialRecordIdMap?: { [dbTableName: string]: Set<string> };
1,283✔
811
    modifiedRecords: IRecordData[];
1,283✔
812
    relatedRecordItems: IRelatedRecordItem[];
1,283✔
813
  }) {
1,283✔
814
    const { fieldId2DbTableName, dbTableName2fields, modifiedRecords } = params;
1,283✔
815

1,283✔
816
    const recordIdsByTableName = this.getRecordIdsByTableName(params);
1,283✔
817
    const recordMap = await this.getRecordMap(recordIdsByTableName, dbTableName2fields);
1,283✔
818
    this.coverRecordData(fieldId2DbTableName, modifiedRecords, recordMap);
1,283✔
819

1,283✔
820
    return recordMap;
1,283✔
821
  }
1,283✔
822

377✔
823
  async getRecordMap(
377✔
824
    recordIdsByTableName: Record<string, Set<string>>,
1,283✔
825
    dbTableName2fields: Record<string, IFieldInstance[]>
1,283✔
826
  ) {
1,283✔
827
    const results: {
1,283✔
828
      [dbTableName: string]: { [dbFieldName: string]: unknown }[];
1,283✔
829
    } = {};
1,283✔
830
    for (const dbTableName in recordIdsByTableName) {
1,283✔
831
      // deduplication is needed
2,100✔
832
      const recordIds = Array.from(recordIdsByTableName[dbTableName]);
2,100✔
833
      const dbFieldNames = dbTableName2fields[dbTableName]
2,100✔
834
        .map((f) => f.dbFieldName)
2,100✔
835
        .concat([...preservedDbFieldNames]);
2,100✔
836
      const nativeQuery = this.knex(dbTableName)
2,100✔
837
        .select(dbFieldNames)
2,100✔
838
        .whereIn('__id', recordIds)
2,100✔
839
        .toQuery();
2,100✔
840
      const result = await this.prismaService
2,100✔
841
        .txClient()
2,100✔
842
        .$queryRawUnsafe<{ [dbFieldName: string]: unknown }[]>(nativeQuery);
2,100✔
843
      results[dbTableName] = result;
2,100✔
844
    }
2,100✔
845

1,283✔
846
    return this.formatRecordQueryResult(results, dbTableName2fields);
1,283✔
847
  }
1,283✔
848

377✔
849
  createTopoItemWithRecords(params: {
377✔
850
    topoOrders: ITopoItem[];
3,006✔
851
    tableId2DbTableName: { [tableId: string]: string };
3,006✔
852
    fieldId2TableId: { [fieldId: string]: string };
3,006✔
853
    fieldMap: IFieldMap;
3,006✔
854
    dbTableName2recordMap: { [tableName: string]: IRecordMap };
3,006✔
855
    relatedRecordItemsIndexed: Record<string, IRelatedRecordItem[]>;
3,006✔
856
  }): ITopoItemWithRecords[] {
3,006✔
857
    const {
3,006✔
858
      topoOrders,
3,006✔
859
      fieldMap,
3,006✔
860
      tableId2DbTableName,
3,006✔
861
      fieldId2TableId,
3,006✔
862
      dbTableName2recordMap,
3,006✔
863
      relatedRecordItemsIndexed,
3,006✔
864
    } = params;
3,006✔
865
    return topoOrders.map<ITopoItemWithRecords>((order) => {
3,006✔
866
      const field = fieldMap[order.id];
3,391✔
867
      const fieldId = field.id;
3,391✔
868
      const tableId = fieldId2TableId[order.id];
3,391✔
869
      const dbTableName = tableId2DbTableName[tableId];
3,391✔
870
      const recordMap = dbTableName2recordMap[dbTableName];
3,391✔
871
      const relatedItems = relatedRecordItemsIndexed[fieldId];
3,391✔
872

3,391✔
873
      // console.log('withRecord:order', JSON.stringify(order, null, 2));
3,391✔
874
      // console.log('withRecord:relatedItems', relatedItems);
3,391✔
875
      return {
3,391✔
876
        ...order,
3,391✔
877
        recordItemMap:
3,391✔
878
          recordMap &&
3,391✔
879
          Object.values(recordMap).reduce<Record<string, IRecordItem>>((pre, record) => {
3,267✔
880
            let dependencies: IRecord[] | undefined;
50,253✔
881
            if (relatedItems) {
50,253✔
882
              const options = field.lookupOptions
4,423✔
883
                ? field.lookupOptions
2,744✔
884
                : (field.options as ILinkFieldOptions);
4,423✔
885
              const foreignTableId = options.foreignTableId;
4,423✔
886
              const foreignDbTableName = tableId2DbTableName[foreignTableId];
4,423✔
887
              const foreignRecordMap = dbTableName2recordMap[foreignDbTableName];
4,423✔
888
              const dependentRecordIdsIndexed = groupBy(relatedItems, 'toId');
4,423✔
889
              const dependentRecordIds = dependentRecordIdsIndexed[record.id];
4,423✔
890

4,423✔
891
              if (dependentRecordIds) {
4,423✔
892
                dependencies = dependentRecordIds.map((item) => foreignRecordMap[item.fromId]);
3,777✔
893
              }
3,777✔
894
            }
4,423✔
895

50,253✔
896
            if (dependencies) {
50,253✔
897
              pre[record.id] = { record, dependencies };
3,777✔
898
            } else {
50,253✔
899
              pre[record.id] = { record };
46,476✔
900
            }
46,476✔
901

50,253✔
902
            return pre;
50,253✔
903
          }, {}),
3,267✔
904
      };
3,391✔
905
    });
3,391✔
906
  }
3,006✔
907

377✔
908
  formatRecordQueryResult(
377✔
909
    formattedResults: {
1,283✔
910
      [tableName: string]: { [dbFieldName: string]: unknown }[];
1,283✔
911
    },
1,283✔
912
    dbTableName2fields: { [tableId: string]: IFieldInstance[] }
1,283✔
913
  ) {
1,283✔
914
    return Object.entries(formattedResults).reduce<{
1,283✔
915
      [dbTableName: string]: IRecordMap;
1,283✔
916
    }>((acc, [dbTableName, records]) => {
1,283✔
917
      const fields = dbTableName2fields[dbTableName];
2,100✔
918
      acc[dbTableName] = records.reduce<IRecordMap>((pre, recordRaw) => {
2,100✔
919
        const record = this.recordRaw2Record(fields, recordRaw);
49,802✔
920
        pre[record.id] = record;
49,802✔
921
        return pre;
49,802✔
922
      }, {});
2,100✔
923
      return acc;
2,100✔
924
    }, {});
1,283✔
925
  }
1,283✔
926

377✔
927
  // use modified record data to cover the record data from db
377✔
928
  private coverRecordData(
377✔
929
    fieldId2DbTableName: Record<string, string>,
1,283✔
930
    newRecordData: IRecordData[],
1,283✔
931
    allRecordByDbTableName: { [tableName: string]: IRecordMap }
1,283✔
932
  ) {
1,283✔
933
    newRecordData.forEach((cover) => {
1,283✔
934
      const dbTableName = fieldId2DbTableName[cover.fieldId];
1,930✔
935
      const record = allRecordByDbTableName[dbTableName][cover.id];
1,930✔
936
      if (!record) {
1,930✔
937
        throw new BadRequestException(`Can not find record: ${cover.id} in database`);
×
938
      }
×
939
      record.fields[cover.fieldId] = cover.newValue;
1,930✔
940
    });
1,930✔
941
  }
1,283✔
942

377✔
943
  async getFieldGraphItems(startFieldIds: string[]): Promise<IGraphItem[]> {
377✔
944
    const getResult = async (startFieldIds: string[]) => {
3,332✔
945
      const _knex = this.knex;
3,332✔
946

3,332✔
947
      const nonRecursiveQuery = _knex
3,332✔
948
        .select('from_field_id', 'to_field_id')
3,332✔
949
        .from('reference')
3,332✔
950
        .whereIn('from_field_id', startFieldIds)
3,332✔
951
        .orWhereIn('to_field_id', startFieldIds);
3,332✔
952
      const recursiveQuery = _knex
3,332✔
953
        .select('deps.from_field_id', 'deps.to_field_id')
3,332✔
954
        .from('reference as deps')
3,332✔
955
        .join('connected_reference as cd', function () {
3,332✔
956
          const sql = '?? = ?? AND ?? != ??';
3,332✔
957
          const depsFromField = 'deps.from_field_id';
3,332✔
958
          const depsToField = 'deps.to_field_id';
3,332✔
959
          const cdFromField = 'cd.from_field_id';
3,332✔
960
          const cdToField = 'cd.to_field_id';
3,332✔
961
          this.on(
3,332✔
962
            _knex.raw(sql, [depsFromField, cdFromField, depsToField, cdToField]).wrap('(', ')')
3,332✔
963
          );
3,332✔
964
          this.orOn(
3,332✔
965
            _knex.raw(sql, [depsFromField, cdToField, depsToField, cdFromField]).wrap('(', ')')
3,332✔
966
          );
3,332✔
967
          this.orOn(
3,332✔
968
            _knex.raw(sql, [depsToField, cdFromField, depsFromField, cdToField]).wrap('(', ')')
3,332✔
969
          );
3,332✔
970
          this.orOn(
3,332✔
971
            _knex.raw(sql, [depsToField, cdToField, depsFromField, cdFromField]).wrap('(', ')')
3,332✔
972
          );
3,332✔
973
        });
3,332✔
974
      const cteQuery = nonRecursiveQuery.union(recursiveQuery);
3,332✔
975
      const finalQuery = this.knex
3,332✔
976
        .withRecursive('connected_reference', ['from_field_id', 'to_field_id'], cteQuery)
3,332✔
977
        .distinct('from_field_id', 'to_field_id')
3,332✔
978
        .from('connected_reference')
3,332✔
979
        .toQuery();
3,332✔
980

3,332✔
981
      return (
3,332✔
982
        this.prismaService
3,332✔
983
          .txClient()
3,332✔
984
          // eslint-disable-next-line @typescript-eslint/naming-convention
3,332✔
985
          .$queryRawUnsafe<{ from_field_id: string; to_field_id: string }[]>(finalQuery)
3,332✔
986
      );
3,332✔
987
    };
3,332✔
988

3,332✔
989
    const queryResult = await getResult(startFieldIds);
3,332✔
990

3,332✔
991
    return filterDirectedGraph(
3,332✔
992
      queryResult.map((row) => ({ fromFieldId: row.from_field_id, toFieldId: row.to_field_id })),
3,332✔
993
      startFieldIds
3,332✔
994
    );
3,332✔
995
  }
3,332✔
996

377✔
997
  private mergeDuplicateRecordData(recordData: IRecordData[]) {
377✔
998
    const indexCache: { [key: string]: number } = {};
4,896✔
999
    const mergedChanges: IRecordData[] = [];
4,896✔
1000

4,896✔
1001
    for (const record of recordData) {
4,896✔
1002
      const key = `${record.id}#${record.fieldId}`;
5,434✔
1003
      if (indexCache[key] !== undefined) {
5,434✔
1004
        mergedChanges[indexCache[key]] = record;
×
1005
      } else {
5,434✔
1006
        indexCache[key] = mergedChanges.length;
5,434✔
1007
        mergedChanges.push(record);
5,434✔
1008
      }
5,434✔
1009
    }
5,434✔
1010
    return mergedChanges;
4,896✔
1011
  }
4,896✔
1012

377✔
1013
  /**
377✔
1014
   * affected record changes need extra dependent record to calculate result
377✔
1015
   * example: C = A + B
377✔
1016
   * A changed, C will be affected and B is the dependent record
377✔
1017
   */
377✔
1018
  async getDependentRecordItems(
377✔
1019
    fieldMap: IFieldMap,
797✔
1020
    recordItems: IRelatedRecordItem[]
797✔
1021
  ): Promise<IRelatedRecordItem[]> {
797✔
1022
    const indexRecordItems = groupBy(recordItems, 'fieldId');
797✔
1023

797✔
1024
    const queries = Object.entries(indexRecordItems)
797✔
1025
      .filter(([fieldId]) => {
797✔
1026
        const options =
2,342✔
1027
          fieldMap[fieldId].lookupOptions || (fieldMap[fieldId].options as ILinkFieldOptions);
2,342✔
1028
        const relationship = options.relationship;
2,342✔
1029
        return relationship === Relationship.ManyMany || relationship === Relationship.OneMany;
2,342✔
1030
      })
2,342✔
1031
      .map(([fieldId, recordItem]) => {
797✔
1032
        const options =
1,414✔
1033
          fieldMap[fieldId].lookupOptions || (fieldMap[fieldId].options as ILinkFieldOptions);
1,414✔
1034
        const { fkHostTableName, selfKeyName, foreignKeyName } = options;
1,414✔
1035
        const ids = recordItem.map((item) => item.toId);
1,414✔
1036

1,414✔
1037
        return this.knex
1,414✔
1038
          .select({
1,414✔
1039
            fieldId: this.knex.raw('?', fieldId),
1,414✔
1040
            toId: selfKeyName,
1,414✔
1041
            fromId: foreignKeyName,
1,414✔
1042
          })
1,414✔
1043
          .from(fkHostTableName)
1,414✔
1044
          .whereIn(selfKeyName, ids);
1,414✔
1045
      });
1,414✔
1046

797✔
1047
    if (!queries.length) {
797✔
1048
      return [];
232✔
1049
    }
232✔
1050

565✔
1051
    const [firstQuery, ...restQueries] = queries;
565✔
1052
    const sqlQuery = firstQuery.unionAll(restQueries).toQuery();
565✔
1053
    return this.prismaService.txClient().$queryRawUnsafe<IRelatedRecordItem[]>(sqlQuery);
565✔
1054
  }
565✔
1055

377✔
1056
  affectedRecordItemsQuerySql(
377✔
1057
    startFieldIds: string[],
1,031✔
1058
    fieldMap: IFieldMap,
1,031✔
1059
    linkAdjacencyMap: IAdjacencyMap,
1,031✔
1060
    startRecordIds: string[]
1,031✔
1061
  ): string {
1,031✔
1062
    const visited = new Set<string>();
1,031✔
1063
    const knex = this.knex;
1,031✔
1064
    const query = knex.queryBuilder();
1,031✔
1065

1,031✔
1066
    function visit(node: string, preNode: string) {
1,031✔
1067
      if (visited.has(node)) {
277✔
UNCOV
1068
        return;
×
UNCOV
1069
      }
×
1070

277✔
1071
      visited.add(node);
277✔
1072
      const options = fieldMap[node].lookupOptions || (fieldMap[node].options as ILinkFieldOptions);
277✔
1073
      const { fkHostTableName, selfKeyName, foreignKeyName } = options;
277✔
1074

277✔
1075
      query.with(
277✔
1076
        node,
277✔
1077
        knex
277✔
1078
          .distinct({
277✔
1079
            toId: `${fkHostTableName}.${selfKeyName}`,
277✔
1080
            fromId: `${preNode}.toId`,
277✔
1081
          })
277✔
1082
          .from(fkHostTableName)
277✔
1083
          .whereNotNull(`${fkHostTableName}.${selfKeyName}`) // toId
277✔
1084
          .join(preNode, `${preNode}.toId`, '=', `${fkHostTableName}.${foreignKeyName}`)
277✔
1085
      );
277✔
1086
      const nextNodes = linkAdjacencyMap[node];
277✔
1087
      // Process outgoing edges
277✔
1088
      if (nextNodes) {
277✔
1089
        for (const neighbor of nextNodes) {
×
1090
          visit(neighbor, node);
×
1091
        }
×
1092
      }
×
1093
    }
277✔
1094

1,031✔
1095
    startFieldIds.forEach((fieldId) => {
1,031✔
1096
      const field = fieldMap[fieldId];
2,734✔
1097
      if (field.lookupOptions || field.type === FieldType.Link) {
2,734✔
1098
        const options = field.lookupOptions || (field.options as ILinkFieldOptions);
2,495✔
1099
        const { fkHostTableName, selfKeyName, foreignKeyName } = options;
2,495✔
1100
        if (visited.has(fieldId)) {
2,495✔
1101
          return;
33✔
1102
        }
33✔
1103
        visited.add(fieldId);
2,462✔
1104
        query.with(
2,462✔
1105
          fieldId,
2,462✔
1106
          knex
2,462✔
1107
            .distinct({
2,462✔
1108
              toId: `${fkHostTableName}.${selfKeyName}`,
2,462✔
1109
              fromId: `${fkHostTableName}.${foreignKeyName}`,
2,462✔
1110
            })
2,462✔
1111
            .from(fkHostTableName)
2,462✔
1112
            .whereIn(`${fkHostTableName}.${selfKeyName}`, startRecordIds)
2,462✔
1113
            .whereNotNull(`${fkHostTableName}.${foreignKeyName}`)
2,462✔
1114
        );
2,462✔
1115
      } else {
2,734✔
1116
        query.with(
239✔
1117
          fieldId,
239✔
1118
          knex.unionAll(
239✔
1119
            startRecordIds.map((id) =>
239✔
1120
              knex.select({ toId: knex.raw('?', id), fromId: knex.raw('?', null) })
1,508✔
1121
            )
239✔
1122
          )
239✔
1123
        );
239✔
1124
      }
239✔
1125
      const nextNodes = linkAdjacencyMap[fieldId];
2,701✔
1126

2,701✔
1127
      // start visit
2,701✔
1128
      if (nextNodes) {
2,734✔
1129
        for (const neighbor of nextNodes) {
227✔
1130
          visit(neighbor, fieldId);
277✔
1131
        }
277✔
1132
      }
227✔
1133
    });
2,734✔
1134

1,031✔
1135
    // union all result
1,031✔
1136
    query.unionAll(
1,031✔
1137
      Array.from(visited).map((fieldId) =>
1,031✔
1138
        knex
2,739✔
1139
          .select({
2,739✔
1140
            fieldId: knex.raw('?', fieldId),
2,739✔
1141
            fromId: knex.ref(`${fieldId}.fromId`),
2,739✔
1142
            toId: knex.ref(`${fieldId}.toId`),
2,739✔
1143
          })
2,739✔
1144
          .from(fieldId)
2,739✔
1145
      )
1,031✔
1146
    );
1,031✔
1147

1,031✔
1148
    return query.toQuery();
1,031✔
1149
  }
1,031✔
1150

377✔
1151
  async getAffectedRecordItems(
377✔
1152
    startFieldIds: string[],
1,031✔
1153
    fieldMap: IFieldMap,
1,031✔
1154
    linkAdjacencyMap: IAdjacencyMap,
1,031✔
1155
    startRecordIds: string[]
1,031✔
1156
  ): Promise<IRelatedRecordItem[]> {
1,031✔
1157
    const affectedRecordItemsQuerySql = this.affectedRecordItemsQuerySql(
1,031✔
1158
      startFieldIds,
1,031✔
1159
      fieldMap,
1,031✔
1160
      linkAdjacencyMap,
1,031✔
1161
      startRecordIds
1,031✔
1162
    );
1,031✔
1163

1,031✔
1164
    return this.prismaService
1,031✔
1165
      .txClient()
1,031✔
1166
      .$queryRawUnsafe<IRelatedRecordItem[]>(affectedRecordItemsQuerySql);
1,031✔
1167
  }
1,031✔
1168

377✔
1169
  async getRelatedItems(
377✔
1170
    startFieldIds: string[],
821✔
1171
    fieldMap: IFieldMap,
821✔
1172
    linkAdjacencyMap: IAdjacencyMap,
821✔
1173
    startRecordIds: string[]
821✔
1174
  ) {
821✔
1175
    if (isEmpty(startRecordIds) || isEmpty(linkAdjacencyMap)) {
821✔
1176
      return [];
24✔
1177
    }
24✔
1178
    const effectedItems = await this.getAffectedRecordItems(
797✔
1179
      startFieldIds,
797✔
1180
      fieldMap,
797✔
1181
      linkAdjacencyMap,
797✔
1182
      startRecordIds
797✔
1183
    );
797✔
1184

797✔
1185
    const dependentItems = await this.getDependentRecordItems(fieldMap, effectedItems);
797✔
1186

797✔
1187
    return unionWith(
797✔
1188
      effectedItems,
797✔
1189
      dependentItems,
797✔
1190
      (left, right) =>
797✔
1191
        left.toId === right.toId && left.fromId === right.fromId && left.fieldId === right.fieldId
62,288✔
1192
    );
797✔
1193
  }
797✔
1194

377✔
1195
  flatGraph(graph: { toFieldId: string; fromFieldId: string }[]) {
377✔
1196
    const allNodes = new Set<string>();
1,444✔
1197
    for (const edge of graph) {
1,444✔
1198
      allNodes.add(edge.fromFieldId);
3,106✔
1199
      allNodes.add(edge.toFieldId);
3,106✔
1200
    }
3,106✔
1201
    return Array.from(allNodes);
1,444✔
1202
  }
1,444✔
1203
}
377✔
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