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

teableio / teable / 10160523059

30 Jul 2024 10:17AM UTC coverage: 81.981% (-0.005%) from 81.986%
10160523059

push

github

web-flow
fix: unmatch operator symbol when select number cellvaluetype field (#778)

4256 of 4455 branches covered (95.53%)

28362 of 34596 relevant lines covered (81.98%)

1215.9 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);
362✔
86

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

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

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

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

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

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

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

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

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

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

362✔
166
  async prepareCalculation(recordData: IRecordData[]) {
362✔
167
    if (!recordData.length) {
4,274✔
168
      return;
2,831✔
169
    }
2,831✔
170
    const { directedGraph, startFieldIds, startRecordIds } =
1,443✔
171
      await this.getDirectedGraph(recordData);
1,443✔
172
    if (!directedGraph.length) {
4,270✔
173
      return;
673✔
174
    }
673✔
175

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

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

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

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

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

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

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

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

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

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

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

362✔
260
  private splitOpsMap(opsMap: IOpsMap) {
362✔
261
    const recordDataDelete: IRecordData[] = [];
2,136✔
262
    const recordDataRemains: IRecordData[] = [];
2,136✔
263
    for (const tableId in opsMap) {
2,136✔
264
      for (const recordId in opsMap[tableId]) {
1,735✔
265
        opsMap[tableId][recordId].forEach((op) => {
3,331✔
266
          const ctx = RecordOpBuilder.editor.setRecord.detect(op);
5,027✔
267
          if (!ctx) {
5,027✔
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,027✔
273
            ctx.oldCellValue &&
1,241✔
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,241✔
281
              recordDataRemains.push({
1,046✔
282
                id: recordId,
1,046✔
283
                fieldId: ctx.fieldId,
1,046✔
284
                newValue: ctx.newCellValue,
1,046✔
285
              });
1,046✔
286
          } else {
5,027✔
287
            recordDataRemains.push({
3,786✔
288
              id: recordId,
3,786✔
289
              fieldId: ctx.fieldId,
3,786✔
290
              oldValue: ctx.oldCellValue,
3,786✔
291
              newValue: ctx.newCellValue,
3,786✔
292
            });
3,786✔
293
          }
3,786✔
294
        });
5,027✔
295
      }
3,331✔
296
    }
1,735✔
297

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

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

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

362✔
334
  // for lookup field, cellValues should be flat and filter
362✔
335
  private flatOriginLookup(lookupValues: unknown[] | unknown) {
362✔
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

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

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

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

49,237✔
367
    if ((field.lookupOptions || field.type === FieldType.Link) && !recordItem.dependencies) {
49,945✔
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,430✔
373
  }
48,430✔
374

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

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

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

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

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

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

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

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

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

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

362✔
430
  private calculateFormula(
362✔
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

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

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

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

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

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

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

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

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

1,760✔
527
    if (field.type === FieldType.Rollup) {
1,760✔
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,492✔
537
    if (field.type === FieldType.Link) {
1,492✔
538
      if (!record.fields[field.id]) {
1,492✔
539
        return null;
260✔
540
      }
260✔
541

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

362✔
679
  getLinkOrderFromTopoOrders(params: {
362✔
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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

49,955✔
869
            return pre;
49,955✔
870
          }, {}),
3,065✔
871
      };
3,185✔
872
    });
3,185✔
873
  }
2,843✔
874

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

977✔
1033
    function visit(node: string, preNode: string) {
977✔
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

977✔
1062
    startFieldIds.forEach((fieldId) => {
977✔
1063
      const field = fieldMap[fieldId];
2,661✔
1064
      if (field.lookupOptions || field.type === FieldType.Link) {
2,661✔
1065
        const options = field.lookupOptions || (field.options as ILinkFieldOptions);
2,427✔
1066
        const { fkHostTableName, selfKeyName, foreignKeyName } = options;
2,427✔
1067
        if (visited.has(fieldId)) {
2,427✔
1068
          return;
28✔
1069
        }
28✔
1070
        visited.add(fieldId);
2,399✔
1071
        query.with(
2,399✔
1072
          fieldId,
2,399✔
1073
          knex
2,399✔
1074
            .distinct({
2,399✔
1075
              toId: `${fkHostTableName}.${selfKeyName}`,
2,399✔
1076
              fromId: `${fkHostTableName}.${foreignKeyName}`,
2,399✔
1077
            })
2,399✔
1078
            .from(fkHostTableName)
2,399✔
1079
            .whereIn(`${fkHostTableName}.${selfKeyName}`, startRecordIds)
2,399✔
1080
            .whereNotNull(`${fkHostTableName}.${foreignKeyName}`)
2,399✔
1081
        );
2,399✔
1082
      } else {
2,661✔
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,633✔
1093

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

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

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

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

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

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

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

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

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