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

teableio / teable / 10345112947

12 Aug 2024 02:48AM CUT coverage: 82.663% (-0.006%) from 82.669%
10345112947

push

github

web-flow
Adding one-click deploy button for RepoCloud.io to README.md (#599)

Adding button enabling community access to one-click deploy template for self-hosting on RepoCloud.io

Signed-off-by: cosark <121065588+cosark@users.noreply.github.com>

4404 of 4625 branches covered (95.22%)

29324 of 35474 relevant lines covered (82.66%)

1241.38 hits per line

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

92.48
/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 { instanceToPlain } from 'class-transformer';
4✔
12
import { Knex } from 'knex';
4✔
13
import { cloneDeep, difference, groupBy, isEmpty, keyBy, unionWith, uniq } from 'lodash';
4✔
14
import { InjectModel } from 'nest-knexjs';
4✔
15
import { preservedDbFieldNames } from '../field/constant';
4✔
16
import type { IFieldInstance, IFieldMap } from '../field/model/factory';
4✔
17
import { createFieldInstanceByRaw, createFieldInstanceByVo } from '../field/model/factory';
4✔
18
import type { AutoNumberFieldDto } from '../field/model/field-dto/auto-number-field.dto';
4✔
19
import type { CreatedTimeFieldDto } from '../field/model/field-dto/created-time-field.dto';
4✔
20
import type { FormulaFieldDto } from '../field/model/field-dto/formula-field.dto';
4✔
21
import type { LastModifiedTimeFieldDto } from '../field/model/field-dto/last-modified-time-field.dto';
4✔
22
import type { ICellChange } from './utils/changes';
4✔
23
import { formatChangesToOps, mergeDuplicateChange } from './utils/changes';
4✔
24
import { isLinkCellValue } from './utils/detect-link';
4✔
25
import type { IAdjacencyMap } from './utils/dfs';
4✔
26
import {
4✔
27
  buildCompressedAdjacencyMap,
4✔
28
  filterDirectedGraph,
4✔
29
  topoOrderWithDepends,
4✔
30
} from './utils/dfs';
4✔
31

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

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

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

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

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

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

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

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

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

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

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

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

2,148✔
115
    saveForeignKeyToDb && (await saveForeignKeyToDb());
2,148✔
116

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

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

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

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

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

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

364✔
153
  getLinkAdjacencyMap(fieldMap: IFieldMap, directedGraph: IGraphItem[]) {
364✔
154
    const linkIdSet = Object.values(fieldMap).reduce((pre, field) => {
1,160✔
155
      if (field.lookupOptions || field.type === FieldType.Link) {
5,347✔
156
        pre.add(field.id);
2,990✔
157
      }
2,990✔
158
      return pre;
5,347✔
159
    }, new Set<string>());
1,160✔
160
    if (linkIdSet.size === 0) {
1,160✔
161
      return {};
152✔
162
    }
152✔
163
    return buildCompressedAdjacencyMap(directedGraph, linkIdSet);
1,008✔
164
  }
1,008✔
165

364✔
166
  async prepareCalculation(recordData: IRecordData[]) {
364✔
167
    if (!recordData.length) {
4,298✔
168
      return;
2,847✔
169
    }
2,847✔
170
    const { directedGraph, startFieldIds, startRecordIds } =
1,451✔
171
      await this.getDirectedGraph(recordData);
1,451✔
172
    if (!directedGraph.length) {
4,288✔
173
      return;
680✔
174
    }
680✔
175

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

771✔
187
    const topoOrdersMap = this.getTopoOrdersMap(startFieldIds, directedGraph);
771✔
188

771✔
189
    const linkAdjacencyMap = this.getLinkAdjacencyMap(fieldMap, directedGraph);
771✔
190

771✔
191
    if (isEmpty(topoOrdersMap)) {
4,134✔
192
      return;
×
193
    }
✔
194

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

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

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

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

364✔
237
  async calculate(recordData: IRecordData[]) {
364✔
238
    const result = await this.prepareCalculation(recordData);
4,296✔
239
    if (!result) {
4,296✔
240
      return;
3,527✔
241
    }
3,527✔
242

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

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

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

2,148✔
298
    return {
2,148✔
299
      recordDataDelete,
2,148✔
300
      recordDataRemains,
2,148✔
301
    };
2,148✔
302
  }
2,148✔
303

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

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

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

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

364✔
357
  private shouldSkipCompute(field: IFieldInstance, recordItem: IRecordItem) {
364✔
358
    if (!field.isComputed && field.type !== FieldType.Link) {
49,947✔
359
      return true;
572✔
360
    }
572✔
361

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

49,239✔
367
    if ((field.lookupOptions || field.type === FieldType.Link) && !recordItem.dependencies) {
49,947✔
368
      // console.log('empty:field', field);
807✔
369
      // console.log('empty:recordItem', JSON.stringify(recordItem, null, 2));
807✔
370
      return true;
807✔
371
    }
807✔
372
    return false;
48,432✔
373
  }
48,432✔
374

364✔
375
  private calculateComputeField(
364✔
376
    field: IFieldInstance,
48,432✔
377
    fieldMap: IFieldMap,
48,432✔
378
    recordItem: IRecordItem
48,432✔
379
  ) {
48,432✔
380
    const record = recordItem.record;
48,432✔
381

48,432✔
382
    if (field.lookupOptions || field.type === FieldType.Link) {
48,432✔
383
      const lookupFieldId = field.lookupOptions
3,708✔
384
        ? field.lookupOptions.lookupFieldId
2,214✔
385
        : (field.options as ILinkFieldOptions).lookupFieldId;
3,708✔
386
      const relationship = field.lookupOptions
3,708✔
387
        ? field.lookupOptions.relationship
2,214✔
388
        : (field.options as ILinkFieldOptions).relationship;
3,708✔
389

3,708✔
390
      if (!lookupFieldId) {
3,708✔
391
        throw new Error('lookupFieldId should not be undefined');
×
392
      }
×
393

3,708✔
394
      if (!relationship) {
3,708✔
395
        throw new Error('relationship should not be undefined');
×
396
      }
×
397

3,708✔
398
      const lookedField = fieldMap[lookupFieldId];
3,708✔
399
      // nameConsole('calculateLookup:dependencies', recordItem.dependencies, fieldMap);
3,708✔
400
      const lookupValues = this.calculateLookup(field, lookedField, recordItem);
3,708✔
401

3,708✔
402
      // console.log('calculateLookup:dependencies', recordItem.dependencies);
3,708✔
403
      // console.log('calculateLookup:lookupValues', lookupValues, recordItem);
3,708✔
404

3,708✔
405
      if (field.isLookup) {
3,708✔
406
        return this.flatOriginLookup(lookupValues);
1,946✔
407
      }
1,946✔
408

1,762✔
409
      return this.calculateRollup(
1,762✔
410
        field,
1,762✔
411
        relationship,
1,762✔
412
        lookedField,
1,762✔
413
        record,
1,762✔
414
        this.joinOriginLookup(lookedField, lookupValues)
1,762✔
415
      );
1,762✔
416
    }
1,762✔
417

44,724✔
418
    if (
44,724✔
419
      field.type === FieldType.Formula ||
44,724✔
420
      field.type === FieldType.AutoNumber ||
48,432✔
421
      field.type === FieldType.CreatedTime ||
48,432✔
422
      field.type === FieldType.LastModifiedTime
6✔
423
    ) {
48,432✔
424
      return this.calculateFormula(field, fieldMap, recordItem);
44,724✔
425
    }
44,724✔
426

×
427
    throw new BadRequestException(`Unsupported field type ${field.type}`);
×
428
  }
×
429

364✔
430
  private calculateFormula(
364✔
431
    field: FormulaFieldDto | AutoNumberFieldDto | CreatedTimeFieldDto | LastModifiedTimeFieldDto,
44,724✔
432
    fieldMap: IFieldMap,
44,724✔
433
    recordItem: IRecordItem
44,724✔
434
  ) {
44,724✔
435
    if (field.hasError) {
44,724✔
436
      return null;
×
437
    }
×
438

44,724✔
439
    try {
44,724✔
440
      const typedValue = evaluate(field.options.expression, fieldMap, recordItem.record);
44,724✔
441
      return typedValue.toPlain();
44,724✔
442
    } catch (e) {
44,724✔
443
      this.logger.error(
×
444
        `calculateFormula error, fieldId: ${field.id}; exp: ${field.options.expression}; recordId: ${recordItem.record.id}, ${(e as { message: string }).message}`
×
445
      );
×
446
      return null;
×
447
    }
×
448
  }
44,724✔
449

364✔
450
  /**
364✔
451
   * lookup values should filter by linkCellValue
364✔
452
   */
364✔
453
  // eslint-disable-next-line sonarjs/cognitive-complexity
364✔
454
  private calculateLookup(
364✔
455
    field: IFieldInstance,
3,708✔
456
    lookedField: IFieldInstance,
3,708✔
457
    recordItem: IRecordItem
3,708✔
458
  ) {
3,708✔
459
    const fieldId = lookedField.id;
3,708✔
460
    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
3,708✔
461
    const dependencies = recordItem.dependencies!;
3,708✔
462
    const lookupOptions = field.lookupOptions
3,708✔
463
      ? field.lookupOptions
2,214✔
464
      : (field.options as ILinkFieldOptions);
3,708✔
465
    const { relationship } = lookupOptions;
3,708✔
466
    const linkFieldId = field.lookupOptions ? field.lookupOptions.linkFieldId : field.id;
3,708✔
467
    const cellValue = recordItem.record.fields[linkFieldId];
3,708✔
468

3,708✔
469
    if (relationship === Relationship.OneMany || relationship === Relationship.ManyMany) {
3,708✔
470
      if (!dependencies) {
2,647✔
471
        return null;
×
472
      }
×
473

2,647✔
474
      // sort lookup values by link cell order
2,647✔
475
      const dependenciesIndexed = keyBy(dependencies, 'id');
2,647✔
476
      const linkCellValues = cellValue as ILinkCellValue[];
2,647✔
477
      // when reset a link cell, the link cell value will be null
2,647✔
478
      // but dependencies will still be there in the first round calculation
2,647✔
479
      if (linkCellValues) {
2,647✔
480
        return linkCellValues
2,152✔
481
          .map((v) => {
2,152✔
482
            return dependenciesIndexed[v.id];
4,236✔
483
          })
4,236✔
484
          .map((depRecord) => depRecord.fields[fieldId]);
2,152✔
485
      }
2,152✔
486

495✔
487
      return null;
495✔
488
    }
495✔
489

1,061✔
490
    if (relationship === Relationship.ManyOne || relationship === Relationship.OneOne) {
3,708✔
491
      if (!dependencies) {
1,061✔
492
        return null;
×
493
      }
×
494
      if (dependencies.length !== 1) {
1,061✔
495
        throw new Error(
×
496
          'dependencies should have only 1 element when relationship is manyOne or oneOne'
×
497
        );
×
498
      }
×
499

1,061✔
500
      const linkCellValue = cellValue as ILinkCellValue;
1,061✔
501
      if (linkCellValue) {
1,061✔
502
        return dependencies[0].fields[fieldId] ?? null;
761✔
503
      }
761✔
504
      return null;
300✔
505
    }
300✔
506
  }
3,708✔
507

364✔
508
  private calculateRollup(
364✔
509
    field: IFieldInstance,
1,762✔
510
    relationship: Relationship,
1,762✔
511
    lookupField: IFieldInstance,
1,762✔
512
    record: IRecord,
1,762✔
513
    lookupValues: unknown
1,762✔
514
  ): unknown {
1,762✔
515
    if (field.type !== FieldType.Link && field.type !== FieldType.Rollup) {
1,762✔
516
      throw new BadRequestException('rollup only support link and rollup field currently');
×
517
    }
×
518

1,762✔
519
    const fieldVo = instanceToPlain(lookupField, { excludePrefixes: ['_'] }) as IFieldVo;
1,762✔
520
    const virtualField = createFieldInstanceByVo({
1,762✔
521
      ...fieldVo,
1,762✔
522
      id: 'values',
1,762✔
523
      isMultipleCellValue:
1,762✔
524
        fieldVo.isMultipleCellValue || isMultiValueLink(relationship) || undefined,
1,762✔
525
    });
1,762✔
526

1,762✔
527
    if (field.type === FieldType.Rollup) {
1,762✔
528
      // console.log('calculateRollup', field, lookupField, record, lookupValues);
268✔
529
      return field
268✔
530
        .evaluate(
268✔
531
          { values: virtualField },
268✔
532
          { ...record, fields: { ...record.fields, values: lookupValues } }
268✔
533
        )
268✔
534
        .toPlain();
268✔
535
    }
268✔
536

1,494✔
537
    if (field.type === FieldType.Link) {
1,494✔
538
      if (!record.fields[field.id]) {
1,494✔
539
        return null;
260✔
540
      }
260✔
541

1,234✔
542
      const result = evaluate(
1,234✔
543
        'TEXT_ALL({values})',
1,234✔
544
        { values: virtualField },
1,234✔
545
        { ...record, fields: { ...record.fields, values: lookupValues } }
1,234✔
546
      );
1,234✔
547

1,234✔
548
      let plain = result.toPlain();
1,234✔
549
      if (!field.isMultipleCellValue && virtualField.isMultipleCellValue) {
1,494✔
550
        plain = virtualField.cellValue2String(plain);
×
551
      }
✔
552
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
1,234✔
553
      return field.updateCellTitle(record.fields[field.id] as any, plain);
1,234✔
554
    }
1,234✔
555
  }
1,762✔
556

364✔
557
  async createAuxiliaryData(allFieldIds: string[]) {
364✔
558
    const prisma = this.prismaService.txClient();
1,292✔
559
    const fieldRaws = await prisma.field.findMany({
1,292✔
560
      where: { id: { in: allFieldIds }, deletedTime: null },
1,292✔
561
    });
1,292✔
562

1,292✔
563
    // if a field that has been looked up  has changed, the link field should be retrieved as context
1,292✔
564
    const extraLinkFieldIds = difference(
1,292✔
565
      fieldRaws
1,292✔
566
        .filter((field) => field.lookupLinkedFieldId)
1,292✔
567
        .map((field) => field.lookupLinkedFieldId as string),
1,292✔
568
      allFieldIds
1,292✔
569
    );
1,292✔
570

1,292✔
571
    const extraLinkFieldRaws = await prisma.field.findMany({
1,292✔
572
      where: { id: { in: extraLinkFieldIds }, deletedTime: null },
1,292✔
573
    });
1,292✔
574

1,292✔
575
    fieldRaws.push(...extraLinkFieldRaws);
1,292✔
576

1,292✔
577
    const fieldId2TableId = fieldRaws.reduce<{ [fieldId: string]: string }>((pre, f) => {
1,292✔
578
      pre[f.id] = f.tableId;
5,631✔
579
      return pre;
5,631✔
580
    }, {});
1,292✔
581

1,292✔
582
    const tableIds = uniq(Object.values(fieldId2TableId));
1,292✔
583
    const tableMeta = await prisma.tableMeta.findMany({
1,292✔
584
      where: { id: { in: tableIds } },
1,292✔
585
      select: { id: true, dbTableName: true },
1,292✔
586
    });
1,292✔
587

1,292✔
588
    const tableId2DbTableName = tableMeta.reduce<{ [tableId: string]: string }>((pre, t) => {
1,292✔
589
      pre[t.id] = t.dbTableName;
2,358✔
590
      return pre;
2,358✔
591
    }, {});
1,292✔
592

1,292✔
593
    const fieldMap = fieldRaws.reduce<IFieldMap>((pre, f) => {
1,292✔
594
      pre[f.id] = createFieldInstanceByRaw(f);
5,631✔
595
      return pre;
5,631✔
596
    }, {});
1,292✔
597

1,292✔
598
    const dbTableName2fields = fieldRaws.reduce<{ [fieldId: string]: IFieldInstance[] }>(
1,292✔
599
      (pre, f) => {
1,292✔
600
        const dbTableName = tableId2DbTableName[f.tableId];
5,631✔
601
        if (pre[dbTableName]) {
5,631✔
602
          pre[dbTableName].push(fieldMap[f.id]);
3,273✔
603
        } else {
5,627✔
604
          pre[dbTableName] = [fieldMap[f.id]];
2,358✔
605
        }
2,358✔
606
        return pre;
5,631✔
607
      },
5,631✔
608
      {}
1,292✔
609
    );
1,292✔
610

1,292✔
611
    const fieldId2DbTableName = fieldRaws.reduce<{ [fieldId: string]: string }>((pre, f) => {
1,292✔
612
      pre[f.id] = tableId2DbTableName[f.tableId];
5,631✔
613
      return pre;
5,631✔
614
    }, {});
1,292✔
615

1,292✔
616
    return {
1,292✔
617
      fieldMap,
1,292✔
618
      fieldId2TableId,
1,292✔
619
      fieldId2DbTableName,
1,292✔
620
      dbTableName2fields,
1,292✔
621
      tableId2DbTableName,
1,292✔
622
    };
1,292✔
623
  }
1,292✔
624

364✔
625
  collectChanges(
364✔
626
    orders: ITopoItemWithRecords[],
2,843✔
627
    fieldMap: IFieldMap,
2,843✔
628
    fieldId2TableId: { [fieldId: string]: string }
2,843✔
629
  ) {
2,843✔
630
    // detail changes
2,843✔
631
    const changes: ICellChange[] = [];
2,843✔
632
    // console.log('collectChanges:orders:', JSON.stringify(orders, null, 2));
2,843✔
633

2,843✔
634
    orders.forEach((item) => {
2,843✔
635
      Object.values(item.recordItemMap || {}).forEach((recordItem) => {
3,177✔
636
        const field = fieldMap[item.id];
49,947✔
637
        const record = recordItem.record;
49,947✔
638
        if (this.shouldSkipCompute(field, recordItem)) {
49,947✔
639
          return;
1,515✔
640
        }
1,515✔
641

48,432✔
642
        const value = this.calculateComputeField(field, fieldMap, recordItem);
48,432✔
643
        // console.log(
48,432✔
644
        //   `calculated: ${field.type}.${field.id}.${record.id}`,
48,432✔
645
        //   recordItem.record.fields,
48,432✔
646
        //   value
48,432✔
647
        // );
48,432✔
648
        const oldValue = record.fields[field.id];
48,432✔
649
        record.fields[field.id] = value;
48,432✔
650
        changes.push({
48,432✔
651
          tableId: fieldId2TableId[field.id],
48,432✔
652
          fieldId: field.id,
48,432✔
653
          recordId: record.id,
48,432✔
654
          oldValue,
48,432✔
655
          newValue: value,
48,432✔
656
        });
48,432✔
657
      });
48,432✔
658
    });
3,177✔
659
    return changes;
2,843✔
660
  }
2,843✔
661

364✔
662
  recordRaw2Record(fields: IFieldInstance[], raw: { [dbFieldName: string]: unknown }): IRecord {
364✔
663
    const fieldsData = fields.reduce<{ [fieldId: string]: unknown }>((acc, field) => {
49,731✔
664
      acc[field.id] = field.convertDBValue2CellValue(raw[field.dbFieldName] as string);
77,212✔
665
      return acc;
77,212✔
666
    }, {});
49,731✔
667

49,731✔
668
    return {
49,731✔
669
      fields: fieldsData,
49,731✔
670
      id: raw.__id as string,
49,731✔
671
      autoNumber: raw.__auto_number as number,
49,731✔
672
      createdTime: (raw.__created_time as Date)?.toISOString(),
49,731✔
673
      lastModifiedTime: (raw.__last_modified_time as Date)?.toISOString(),
49,731✔
674
      createdBy: raw.__created_by as string,
49,731✔
675
      lastModifiedBy: raw.__last_modified_by as string,
49,731✔
676
    };
49,731✔
677
  }
49,731✔
678

364✔
679
  getLinkOrderFromTopoOrders(params: {
364✔
680
    topoOrders: ITopoItem[];
×
681
    fieldMap: IFieldMap;
×
682
  }): ITopoLinkOrder[] {
×
683
    const newOrder: ITopoLinkOrder[] = [];
×
684
    const { topoOrders, fieldMap } = params;
×
685
    // one link fieldId only need to add once
×
686
    const checkSet = new Set<string>();
×
687
    for (const item of topoOrders) {
×
688
      const field = fieldMap[item.id];
×
689
      if (field.lookupOptions) {
×
690
        const { fkHostTableName, selfKeyName, foreignKeyName, relationship, linkFieldId } =
×
691
          field.lookupOptions;
×
692
        if (checkSet.has(linkFieldId)) {
×
693
          continue;
×
694
        }
×
695
        checkSet.add(linkFieldId);
×
696
        newOrder.push({
×
697
          fieldId: linkFieldId,
×
698
          relationship,
×
699
          fkHostTableName,
×
700
          selfKeyName,
×
701
          foreignKeyName,
×
702
        });
×
703
        continue;
×
704
      }
×
705

×
706
      if (field.type === FieldType.Link) {
×
707
        const { fkHostTableName, selfKeyName, foreignKeyName } = field.options;
×
708
        if (checkSet.has(field.id)) {
×
709
          continue;
×
710
        }
×
711
        checkSet.add(field.id);
×
712
        newOrder.push({
×
713
          fieldId: field.id,
×
714
          relationship: field.options.relationship,
×
715
          fkHostTableName,
×
716
          selfKeyName,
×
717
          foreignKeyName,
×
718
        });
×
719
      }
×
720
    }
×
721
    return newOrder;
×
722
  }
×
723

364✔
724
  getRecordIdsByTableName(params: {
364✔
725
    fieldMap: IFieldMap;
1,160✔
726
    fieldId2DbTableName: Record<string, string>;
1,160✔
727
    initialRecordIdMap?: { [dbTableName: string]: Set<string> };
1,160✔
728
    modifiedRecords: IRecordData[];
1,160✔
729
    relatedRecordItems: IRelatedRecordItem[];
1,160✔
730
  }) {
1,160✔
731
    const {
1,160✔
732
      fieldMap,
1,160✔
733
      fieldId2DbTableName,
1,160✔
734
      initialRecordIdMap,
1,160✔
735
      modifiedRecords,
1,160✔
736
      relatedRecordItems,
1,160✔
737
    } = params;
1,160✔
738
    const recordIdsByTableName = cloneDeep(initialRecordIdMap) || {};
1,160✔
739
    const insertId = (fieldId: string, id: string) => {
1,160✔
740
      const dbTableName = fieldId2DbTableName[fieldId];
14,468✔
741
      if (!recordIdsByTableName[dbTableName]) {
14,468✔
742
        recordIdsByTableName[dbTableName] = new Set<string>();
1,550✔
743
      }
1,550✔
744
      recordIdsByTableName[dbTableName].add(id);
14,468✔
745
    };
14,468✔
746

1,160✔
747
    modifiedRecords.forEach((item) => {
1,160✔
748
      insertId(item.fieldId, item.id);
1,852✔
749
      const field = fieldMap[item.fieldId];
1,852✔
750
      if (field.type !== FieldType.Link) {
1,852✔
751
        return;
526✔
752
      }
526✔
753
      const lookupFieldId = field.options.lookupFieldId;
1,326✔
754

1,326✔
755
      const { newValue } = item;
1,326✔
756
      [newValue]
1,326✔
757
        .flat()
1,326✔
758
        .filter(Boolean)
1,326✔
759
        .map((item) => insertId(lookupFieldId, (item as ILinkCellValue).id));
1,326✔
760
    });
1,326✔
761

1,160✔
762
    relatedRecordItems.forEach((item) => {
1,160✔
763
      const field = fieldMap[item.fieldId];
5,602✔
764
      const options = field.lookupOptions ?? (field.options as ILinkFieldOptions);
5,602✔
765

5,602✔
766
      insertId(options.lookupFieldId, item.fromId);
5,602✔
767
      insertId(item.fieldId, item.toId);
5,602✔
768
    });
5,602✔
769

1,160✔
770
    return recordIdsByTableName;
1,160✔
771
  }
1,160✔
772

364✔
773
  async getRecordMapBatch(params: {
364✔
774
    fieldMap: IFieldMap;
1,160✔
775
    fieldId2DbTableName: Record<string, string>;
1,160✔
776
    dbTableName2fields: Record<string, IFieldInstance[]>;
1,160✔
777
    initialRecordIdMap?: { [dbTableName: string]: Set<string> };
1,160✔
778
    modifiedRecords: IRecordData[];
1,160✔
779
    relatedRecordItems: IRelatedRecordItem[];
1,160✔
780
  }) {
1,160✔
781
    const { fieldId2DbTableName, dbTableName2fields, modifiedRecords } = params;
1,160✔
782

1,160✔
783
    const recordIdsByTableName = this.getRecordIdsByTableName(params);
1,160✔
784
    const recordMap = await this.getRecordMap(recordIdsByTableName, dbTableName2fields);
1,160✔
785
    this.coverRecordData(fieldId2DbTableName, modifiedRecords, recordMap);
1,160✔
786

1,160✔
787
    return recordMap;
1,160✔
788
  }
1,160✔
789

364✔
790
  async getRecordMap(
364✔
791
    recordIdsByTableName: Record<string, Set<string>>,
1,160✔
792
    dbTableName2fields: Record<string, IFieldInstance[]>
1,160✔
793
  ) {
1,160✔
794
    const results: {
1,160✔
795
      [dbTableName: string]: { [dbFieldName: string]: unknown }[];
1,160✔
796
    } = {};
1,160✔
797
    for (const dbTableName in recordIdsByTableName) {
1,160✔
798
      // deduplication is needed
1,939✔
799
      const recordIds = Array.from(recordIdsByTableName[dbTableName]);
1,939✔
800
      const dbFieldNames = dbTableName2fields[dbTableName]
1,939✔
801
        .map((f) => f.dbFieldName)
1,939✔
802
        .concat([...preservedDbFieldNames]);
1,939✔
803
      const nativeQuery = this.knex(dbTableName)
1,939✔
804
        .select(dbFieldNames)
1,939✔
805
        .whereIn('__id', recordIds)
1,939✔
806
        .toQuery();
1,939✔
807
      const result = await this.prismaService
1,939✔
808
        .txClient()
1,939✔
809
        .$queryRawUnsafe<{ [dbFieldName: string]: unknown }[]>(nativeQuery);
1,939✔
810
      results[dbTableName] = result;
1,939✔
811
    }
1,939✔
812

1,160✔
813
    return this.formatRecordQueryResult(results, dbTableName2fields);
1,160✔
814
  }
1,160✔
815

364✔
816
  createTopoItemWithRecords(params: {
364✔
817
    topoOrders: ITopoItem[];
2,845✔
818
    tableId2DbTableName: { [tableId: string]: string };
2,845✔
819
    fieldId2TableId: { [fieldId: string]: string };
2,845✔
820
    fieldMap: IFieldMap;
2,845✔
821
    dbTableName2recordMap: { [tableName: string]: IRecordMap };
2,845✔
822
    relatedRecordItemsIndexed: Record<string, IRelatedRecordItem[]>;
2,845✔
823
  }): ITopoItemWithRecords[] {
2,845✔
824
    const {
2,845✔
825
      topoOrders,
2,845✔
826
      fieldMap,
2,845✔
827
      tableId2DbTableName,
2,845✔
828
      fieldId2TableId,
2,845✔
829
      dbTableName2recordMap,
2,845✔
830
      relatedRecordItemsIndexed,
2,845✔
831
    } = params;
2,845✔
832
    return topoOrders.map<ITopoItemWithRecords>((order) => {
2,845✔
833
      const field = fieldMap[order.id];
3,187✔
834
      const fieldId = field.id;
3,187✔
835
      const tableId = fieldId2TableId[order.id];
3,187✔
836
      const dbTableName = tableId2DbTableName[tableId];
3,187✔
837
      const recordMap = dbTableName2recordMap[dbTableName];
3,187✔
838
      const relatedItems = relatedRecordItemsIndexed[fieldId];
3,187✔
839

3,187✔
840
      // console.log('withRecord:order', JSON.stringify(order, null, 2));
3,187✔
841
      // console.log('withRecord:relatedItems', relatedItems);
3,187✔
842
      return {
3,187✔
843
        ...order,
3,187✔
844
        recordItemMap:
3,187✔
845
          recordMap &&
3,187✔
846
          Object.values(recordMap).reduce<Record<string, IRecordItem>>((pre, record) => {
3,067✔
847
            let dependencies: IRecord[] | undefined;
49,957✔
848
            if (relatedItems) {
49,957✔
849
              const options = field.lookupOptions
4,388✔
850
                ? field.lookupOptions
2,790✔
851
                : (field.options as ILinkFieldOptions);
4,388✔
852
              const foreignTableId = options.foreignTableId;
4,388✔
853
              const foreignDbTableName = tableId2DbTableName[foreignTableId];
4,388✔
854
              const foreignRecordMap = dbTableName2recordMap[foreignDbTableName];
4,388✔
855
              const dependentRecordIdsIndexed = groupBy(relatedItems, 'toId');
4,388✔
856
              const dependentRecordIds = dependentRecordIdsIndexed[record.id];
4,388✔
857

4,388✔
858
              if (dependentRecordIds) {
4,388✔
859
                dependencies = dependentRecordIds.map((item) => foreignRecordMap[item.fromId]);
3,716✔
860
              }
3,716✔
861
            }
4,388✔
862

49,957✔
863
            if (dependencies) {
49,957✔
864
              pre[record.id] = { record, dependencies };
3,716✔
865
            } else {
49,957✔
866
              pre[record.id] = { record };
46,241✔
867
            }
46,241✔
868

49,957✔
869
            return pre;
49,957✔
870
          }, {}),
3,067✔
871
      };
3,187✔
872
    });
3,187✔
873
  }
2,845✔
874

364✔
875
  formatRecordQueryResult(
364✔
876
    formattedResults: {
1,160✔
877
      [tableName: string]: { [dbFieldName: string]: unknown }[];
1,160✔
878
    },
1,160✔
879
    dbTableName2fields: { [tableId: string]: IFieldInstance[] }
1,160✔
880
  ) {
1,160✔
881
    return Object.entries(formattedResults).reduce<{
1,160✔
882
      [dbTableName: string]: IRecordMap;
1,160✔
883
    }>((acc, [dbTableName, records]) => {
1,160✔
884
      const fields = dbTableName2fields[dbTableName];
1,939✔
885
      acc[dbTableName] = records.reduce<IRecordMap>((pre, recordRaw) => {
1,939✔
886
        const record = this.recordRaw2Record(fields, recordRaw);
49,519✔
887
        pre[record.id] = record;
49,519✔
888
        return pre;
49,519✔
889
      }, {});
1,939✔
890
      return acc;
1,939✔
891
    }, {});
1,160✔
892
  }
1,160✔
893

364✔
894
  // use modified record data to cover the record data from db
364✔
895
  private coverRecordData(
364✔
896
    fieldId2DbTableName: Record<string, string>,
1,160✔
897
    newRecordData: IRecordData[],
1,160✔
898
    allRecordByDbTableName: { [tableName: string]: IRecordMap }
1,160✔
899
  ) {
1,160✔
900
    newRecordData.forEach((cover) => {
1,160✔
901
      const dbTableName = fieldId2DbTableName[cover.fieldId];
1,852✔
902
      const record = allRecordByDbTableName[dbTableName][cover.id];
1,852✔
903
      if (!record) {
1,852✔
904
        throw new BadRequestException(`Can not find record: ${cover.id} in database`);
×
905
      }
×
906
      record.fields[cover.fieldId] = cover.newValue;
1,852✔
907
    });
1,852✔
908
  }
1,160✔
909

364✔
910
  async getFieldGraphItems(startFieldIds: string[]): Promise<IGraphItem[]> {
364✔
911
    const getResult = async (startFieldIds: string[]) => {
2,971✔
912
      const _knex = this.knex;
2,971✔
913

2,971✔
914
      const nonRecursiveQuery = _knex
2,971✔
915
        .select('from_field_id', 'to_field_id')
2,971✔
916
        .from('reference')
2,971✔
917
        .whereIn('from_field_id', startFieldIds)
2,971✔
918
        .orWhereIn('to_field_id', startFieldIds);
2,971✔
919
      const recursiveQuery = _knex
2,971✔
920
        .select('deps.from_field_id', 'deps.to_field_id')
2,971✔
921
        .from('reference as deps')
2,971✔
922
        .join('connected_reference as cd', function () {
2,971✔
923
          const sql = '?? = ?? AND ?? != ??';
2,971✔
924
          const depsFromField = 'deps.from_field_id';
2,971✔
925
          const depsToField = 'deps.to_field_id';
2,971✔
926
          const cdFromField = 'cd.from_field_id';
2,971✔
927
          const cdToField = 'cd.to_field_id';
2,971✔
928
          this.on(
2,971✔
929
            _knex.raw(sql, [depsFromField, cdFromField, depsToField, cdToField]).wrap('(', ')')
2,971✔
930
          );
2,971✔
931
          this.orOn(
2,971✔
932
            _knex.raw(sql, [depsFromField, cdToField, depsToField, cdFromField]).wrap('(', ')')
2,971✔
933
          );
2,971✔
934
          this.orOn(
2,971✔
935
            _knex.raw(sql, [depsToField, cdFromField, depsFromField, cdToField]).wrap('(', ')')
2,971✔
936
          );
2,971✔
937
          this.orOn(
2,971✔
938
            _knex.raw(sql, [depsToField, cdToField, depsFromField, cdFromField]).wrap('(', ')')
2,971✔
939
          );
2,971✔
940
        });
2,971✔
941
      const cteQuery = nonRecursiveQuery.union(recursiveQuery);
2,971✔
942
      const finalQuery = this.knex
2,971✔
943
        .withRecursive('connected_reference', ['from_field_id', 'to_field_id'], cteQuery)
2,971✔
944
        .distinct('from_field_id', 'to_field_id')
2,971✔
945
        .from('connected_reference')
2,971✔
946
        .toQuery();
2,971✔
947

2,971✔
948
      return (
2,971✔
949
        this.prismaService
2,971✔
950
          .txClient()
2,971✔
951
          // eslint-disable-next-line @typescript-eslint/naming-convention
2,971✔
952
          .$queryRawUnsafe<{ from_field_id: string; to_field_id: string }[]>(finalQuery)
2,971✔
953
      );
2,971✔
954
    };
2,971✔
955

2,971✔
956
    const queryResult = await getResult(startFieldIds);
2,971✔
957

2,971✔
958
    return filterDirectedGraph(
2,971✔
959
      queryResult.map((row) => ({ fromFieldId: row.from_field_id, toFieldId: row.to_field_id })),
2,971✔
960
      startFieldIds
2,971✔
961
    );
2,971✔
962
  }
2,971✔
963

364✔
964
  private mergeDuplicateRecordData(recordData: IRecordData[]) {
364✔
965
    const indexCache: { [key: string]: number } = {};
4,296✔
966
    const mergedChanges: IRecordData[] = [];
4,296✔
967

4,296✔
968
    for (const record of recordData) {
4,296✔
969
      const key = `${record.id}#${record.fieldId}`;
5,183✔
970
      if (indexCache[key] !== undefined) {
5,183✔
971
        mergedChanges[indexCache[key]] = record;
×
972
      } else {
5,183✔
973
        indexCache[key] = mergedChanges.length;
5,183✔
974
        mergedChanges.push(record);
5,183✔
975
      }
5,183✔
976
    }
5,183✔
977
    return mergedChanges;
4,296✔
978
  }
4,296✔
979

364✔
980
  /**
364✔
981
   * affected record changes need extra dependent record to calculate result
364✔
982
   * example: C = A + B
364✔
983
   * A changed, C will be affected and B is the dependent record
364✔
984
   */
364✔
985
  async getDependentRecordItems(
364✔
986
    fieldMap: IFieldMap,
755✔
987
    recordItems: IRelatedRecordItem[]
755✔
988
  ): Promise<IRelatedRecordItem[]> {
755✔
989
    const indexRecordItems = groupBy(recordItems, 'fieldId');
755✔
990

755✔
991
    const queries = Object.entries(indexRecordItems)
755✔
992
      .filter(([fieldId]) => {
755✔
993
        const options =
2,280✔
994
          fieldMap[fieldId].lookupOptions || (fieldMap[fieldId].options as ILinkFieldOptions);
2,280✔
995
        const relationship = options.relationship;
2,280✔
996
        return relationship === Relationship.ManyMany || relationship === Relationship.OneMany;
2,280✔
997
      })
2,280✔
998
      .map(([fieldId, recordItem]) => {
755✔
999
        const options =
1,387✔
1000
          fieldMap[fieldId].lookupOptions || (fieldMap[fieldId].options as ILinkFieldOptions);
1,387✔
1001
        const { fkHostTableName, selfKeyName, foreignKeyName } = options;
1,387✔
1002
        const ids = recordItem.map((item) => item.toId);
1,387✔
1003

1,387✔
1004
        return this.knex
1,387✔
1005
          .select({
1,387✔
1006
            fieldId: this.knex.raw('?', fieldId),
1,387✔
1007
            toId: selfKeyName,
1,387✔
1008
            fromId: foreignKeyName,
1,387✔
1009
          })
1,387✔
1010
          .from(fkHostTableName)
1,387✔
1011
          .whereIn(selfKeyName, ids);
1,387✔
1012
      });
1,387✔
1013

755✔
1014
    if (!queries.length) {
755✔
1015
      return [];
222✔
1016
    }
222✔
1017

533✔
1018
    const [firstQuery, ...restQueries] = queries;
533✔
1019
    const sqlQuery = firstQuery.unionAll(restQueries).toQuery();
533✔
1020
    return this.prismaService.txClient().$queryRawUnsafe<IRelatedRecordItem[]>(sqlQuery);
533✔
1021
  }
533✔
1022

364✔
1023
  affectedRecordItemsQuerySql(
364✔
1024
    startFieldIds: string[],
978✔
1025
    fieldMap: IFieldMap,
978✔
1026
    linkAdjacencyMap: IAdjacencyMap,
978✔
1027
    startRecordIds: string[]
978✔
1028
  ): string {
978✔
1029
    const visited = new Set<string>();
978✔
1030
    const knex = this.knex;
978✔
1031
    const query = knex.queryBuilder();
978✔
1032

978✔
1033
    function visit(node: string, preNode: string) {
978✔
1034
      if (visited.has(node)) {
262✔
1035
        return;
×
1036
      }
×
1037

262✔
1038
      visited.add(node);
262✔
1039
      const options = fieldMap[node].lookupOptions || (fieldMap[node].options as ILinkFieldOptions);
262✔
1040
      const { fkHostTableName, selfKeyName, foreignKeyName } = options;
262✔
1041

262✔
1042
      query.with(
262✔
1043
        node,
262✔
1044
        knex
262✔
1045
          .distinct({
262✔
1046
            toId: `${fkHostTableName}.${selfKeyName}`,
262✔
1047
            fromId: `${preNode}.toId`,
262✔
1048
          })
262✔
1049
          .from(fkHostTableName)
262✔
1050
          .whereNotNull(`${fkHostTableName}.${selfKeyName}`) // toId
262✔
1051
          .join(preNode, `${preNode}.toId`, '=', `${fkHostTableName}.${foreignKeyName}`)
262✔
1052
      );
262✔
1053
      const nextNodes = linkAdjacencyMap[node];
262✔
1054
      // Process outgoing edges
262✔
1055
      if (nextNodes) {
262✔
1056
        for (const neighbor of nextNodes) {
×
1057
          visit(neighbor, node);
×
1058
        }
×
1059
      }
×
1060
    }
262✔
1061

978✔
1062
    startFieldIds.forEach((fieldId) => {
978✔
1063
      const field = fieldMap[fieldId];
2,663✔
1064
      if (field.lookupOptions || field.type === FieldType.Link) {
2,663✔
1065
        const options = field.lookupOptions || (field.options as ILinkFieldOptions);
2,429✔
1066
        const { fkHostTableName, selfKeyName, foreignKeyName } = options;
2,429✔
1067
        if (visited.has(fieldId)) {
2,429✔
1068
          return;
28✔
1069
        }
28✔
1070
        visited.add(fieldId);
2,401✔
1071
        query.with(
2,401✔
1072
          fieldId,
2,401✔
1073
          knex
2,401✔
1074
            .distinct({
2,401✔
1075
              toId: `${fkHostTableName}.${selfKeyName}`,
2,401✔
1076
              fromId: `${fkHostTableName}.${foreignKeyName}`,
2,401✔
1077
            })
2,401✔
1078
            .from(fkHostTableName)
2,401✔
1079
            .whereIn(`${fkHostTableName}.${selfKeyName}`, startRecordIds)
2,401✔
1080
            .whereNotNull(`${fkHostTableName}.${foreignKeyName}`)
2,401✔
1081
        );
2,401✔
1082
      } else {
2,663✔
1083
        query.with(
234✔
1084
          fieldId,
234✔
1085
          knex.unionAll(
234✔
1086
            startRecordIds.map((id) =>
234✔
1087
              knex.select({ toId: knex.raw('?', id), fromId: knex.raw('?', null) })
1,572✔
1088
            )
234✔
1089
          )
234✔
1090
        );
234✔
1091
      }
234✔
1092
      const nextNodes = linkAdjacencyMap[fieldId];
2,635✔
1093

2,635✔
1094
      // start visit
2,635✔
1095
      if (nextNodes) {
2,663✔
1096
        for (const neighbor of nextNodes) {
212✔
1097
          visit(neighbor, fieldId);
262✔
1098
        }
262✔
1099
      }
212✔
1100
    });
2,663✔
1101

978✔
1102
    // union all result
978✔
1103
    query.unionAll(
978✔
1104
      Array.from(visited).map((fieldId) =>
978✔
1105
        knex
2,663✔
1106
          .select({
2,663✔
1107
            fieldId: knex.raw('?', fieldId),
2,663✔
1108
            fromId: knex.ref(`${fieldId}.fromId`),
2,663✔
1109
            toId: knex.ref(`${fieldId}.toId`),
2,663✔
1110
          })
2,663✔
1111
          .from(fieldId)
2,663✔
1112
      )
978✔
1113
    );
978✔
1114

978✔
1115
    return query.toQuery();
978✔
1116
  }
978✔
1117

364✔
1118
  async getAffectedRecordItems(
364✔
1119
    startFieldIds: string[],
978✔
1120
    fieldMap: IFieldMap,
978✔
1121
    linkAdjacencyMap: IAdjacencyMap,
978✔
1122
    startRecordIds: string[]
978✔
1123
  ): Promise<IRelatedRecordItem[]> {
978✔
1124
    const affectedRecordItemsQuerySql = this.affectedRecordItemsQuerySql(
978✔
1125
      startFieldIds,
978✔
1126
      fieldMap,
978✔
1127
      linkAdjacencyMap,
978✔
1128
      startRecordIds
978✔
1129
    );
978✔
1130

978✔
1131
    return this.prismaService
978✔
1132
      .txClient()
978✔
1133
      .$queryRawUnsafe<IRelatedRecordItem[]>(affectedRecordItemsQuerySql);
978✔
1134
  }
978✔
1135

364✔
1136
  async getRelatedItems(
364✔
1137
    startFieldIds: string[],
771✔
1138
    fieldMap: IFieldMap,
771✔
1139
    linkAdjacencyMap: IAdjacencyMap,
771✔
1140
    startRecordIds: string[]
771✔
1141
  ) {
771✔
1142
    if (isEmpty(startRecordIds) || isEmpty(linkAdjacencyMap)) {
771✔
1143
      return [];
16✔
1144
    }
16✔
1145
    const effectedItems = await this.getAffectedRecordItems(
755✔
1146
      startFieldIds,
755✔
1147
      fieldMap,
755✔
1148
      linkAdjacencyMap,
755✔
1149
      startRecordIds
755✔
1150
    );
755✔
1151

755✔
1152
    const dependentItems = await this.getDependentRecordItems(fieldMap, effectedItems);
755✔
1153

755✔
1154
    return unionWith(
755✔
1155
      effectedItems,
755✔
1156
      dependentItems,
755✔
1157
      (left, right) =>
755✔
1158
        left.toId === right.toId && left.fromId === right.fromId && left.fieldId === right.fieldId
61,528✔
1159
    );
755✔
1160
  }
755✔
1161

364✔
1162
  flatGraph(graph: { toFieldId: string; fromFieldId: string }[]) {
364✔
1163
    const allNodes = new Set<string>();
1,292✔
1164
    for (const edge of graph) {
1,292✔
1165
      allNodes.add(edge.fromFieldId);
2,957✔
1166
      allNodes.add(edge.toFieldId);
2,957✔
1167
    }
2,957✔
1168
    return Array.from(allNodes);
1,292✔
1169
  }
1,292✔
1170
}
364✔
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