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

teableio / teable / 10474103032

20 Aug 2024 03:00PM UTC coverage: 82.979% (+65.2%) from 17.734%
10474103032

Pull #796

github

web-flow
Merge fb71af529 into ef35a7ae7
Pull Request #796: duplicate rows

4621 of 4844 branches covered (95.4%)

6 of 24 new or added lines in 2 files covered. (25.0%)

30557 of 36825 relevant lines covered (82.98%)

1212.26 hits per line

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

85.98
/apps/nestjs-backend/src/features/selection/selection.service.ts
1
import { BadRequestException, Injectable } from '@nestjs/common';
4✔
2
import type {
4✔
3
  IDateFieldOptions,
4✔
4
  IFieldOptionsRo,
4✔
5
  IFieldOptionsVo,
4✔
6
  IFieldRo,
4✔
7
  IFieldVo,
4✔
8
  INumberFieldOptionsRo,
4✔
9
  IRecord,
4✔
10
  ISingleLineTextFieldOptions,
4✔
11
  IUserFieldOptions,
4✔
12
} from '@teable/core';
4✔
13
import {
4✔
14
  CellValueType,
4✔
15
  FieldKeyType,
4✔
16
  FieldType,
4✔
17
  datetimeFormattingSchema,
4✔
18
  defaultDatetimeFormatting,
4✔
19
  defaultNumberFormatting,
4✔
20
  defaultUserFieldOptions,
4✔
21
  nullsToUndefined,
4✔
22
  numberFormattingSchema,
4✔
23
  parseClipboardText,
4✔
24
  singleLineTextShowAsSchema,
4✔
25
  singleNumberShowAsSchema,
4✔
26
  stringifyClipboardText,
4✔
27
} from '@teable/core';
4✔
28
import { PrismaService } from '@teable/db-main-prisma';
4✔
29
import type {
4✔
30
  IUpdateRecordsRo,
4✔
31
  IRangesToIdQuery,
4✔
32
  IRangesToIdVo,
4✔
33
  IPasteRo,
4✔
34
  IPasteVo,
4✔
35
  IRangesRo,
4✔
36
  IDeleteVo,
4✔
37
  ITemporaryPasteVo,
4✔
38
  IDuplicateVo,
4✔
39
} from '@teable/openapi';
4✔
40
import { IdReturnType, RangeType } from '@teable/openapi';
4✔
41
import { isNumber, isString, map, pick } from 'lodash';
4✔
42
import { ClsService } from 'nestjs-cls';
4✔
43
import { ThresholdConfig, IThresholdConfig } from '../../configs/threshold.config';
4✔
44
import type { IClsStore } from '../../types/cls';
4✔
45
import { AggregationService } from '../aggregation/aggregation.service';
4✔
46
import { FieldCreatingService } from '../field/field-calculate/field-creating.service';
4✔
47
import { FieldSupplementService } from '../field/field-calculate/field-supplement.service';
4✔
48
import { FieldService } from '../field/field.service';
4✔
49
import type { IFieldInstance } from '../field/model/factory';
4✔
50
import { createFieldInstanceByVo } from '../field/model/factory';
4✔
51
import { AttachmentFieldDto } from '../field/model/field-dto/attachment-field.dto';
4✔
52
import { RecordOpenApiService } from '../record/open-api/record-open-api.service';
4✔
53
import { RecordService } from '../record/record.service';
4✔
54

4✔
55
@Injectable()
4✔
56
export class SelectionService {
4✔
57
  constructor(
138✔
58
    private readonly recordService: RecordService,
138✔
59
    private readonly fieldService: FieldService,
138✔
60
    private readonly prismaService: PrismaService,
138✔
61
    private readonly aggregationService: AggregationService,
138✔
62
    private readonly recordOpenApiService: RecordOpenApiService,
138✔
63
    private readonly fieldCreatingService: FieldCreatingService,
138✔
64
    private readonly fieldSupplementService: FieldSupplementService,
138✔
65
    private readonly cls: ClsService<IClsStore>,
138✔
66
    @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig
138✔
67
  ) {}
138✔
68

138✔
69
  async getIdsFromRanges(tableId: string, query: IRangesToIdQuery): Promise<IRangesToIdVo> {
138✔
70
    const { returnType } = query;
18✔
71
    if (returnType === IdReturnType.RecordId) {
18✔
72
      return {
6✔
73
        recordIds: await this.rowSelectionToIds(tableId, query),
6✔
74
      };
6✔
75
    }
6✔
76

12✔
77
    if (returnType === IdReturnType.FieldId) {
18✔
78
      return {
6✔
79
        fieldIds: await this.columnSelectionToIds(tableId, query),
6✔
80
      };
6✔
81
    }
6✔
82

6✔
83
    if (returnType === IdReturnType.All) {
6✔
84
      return {
6✔
85
        fieldIds: await this.columnSelectionToIds(tableId, query),
6✔
86
        recordIds: await this.rowSelectionToIds(tableId, query),
6✔
87
      };
6✔
88
    }
6✔
89

×
90
    throw new BadRequestException('Invalid return type');
×
91
  }
×
92

138✔
93
  private async columnSelectionToIds(tableId: string, query: IRangesToIdQuery): Promise<string[]> {
138✔
94
    const { type, viewId, ranges, excludeFieldIds } = query;
12✔
95
    const result = await this.fieldService.getDocIdsByQuery(tableId, {
12✔
96
      viewId,
12✔
97
      filterHidden: true,
12✔
98
      excludeFieldIds,
12✔
99
    });
12✔
100

12✔
101
    if (type === RangeType.Rows) {
12✔
102
      return result.ids;
4✔
103
    }
4✔
104

8✔
105
    if (type === RangeType.Columns) {
12✔
106
      return ranges.reduce<string[]>((acc, range) => {
4✔
107
        return acc.concat(result.ids.slice(range[0], range[1] + 1));
4✔
108
      }, []);
4✔
109
    }
4✔
110

4✔
111
    const [start, end] = ranges;
4✔
112
    return result.ids.slice(start[0], end[0] + 1);
4✔
113
  }
4✔
114

138✔
115
  private async rowSelectionToIds(tableId: string, query: IRangesToIdQuery): Promise<string[]> {
138✔
116
    const { type, ranges } = query;
12✔
117
    if (type === RangeType.Columns) {
12✔
118
      const result = await this.recordService.getDocIdsByQuery(tableId, {
4✔
119
        ...query,
4✔
120
        skip: 0,
4✔
121
        take: -1,
4✔
122
      });
4✔
123
      return result.ids;
4✔
124
    }
4✔
125

8✔
126
    if (type === RangeType.Rows) {
12✔
127
      let recordIds: string[] = [];
4✔
128
      const total = ranges.reduce((acc, range) => acc + range[1] - range[0] + 1, 0);
4✔
129
      if (total > this.thresholdConfig.maxReadRows) {
4✔
130
        throw new BadRequestException(`Exceed max read rows ${this.thresholdConfig.maxReadRows}`);
×
131
      }
×
132
      for (const [start, end] of ranges) {
4✔
133
        const result = await this.recordService.getDocIdsByQuery(tableId, {
4✔
134
          ...query,
4✔
135
          skip: start,
4✔
136
          take: end + 1 - start,
4✔
137
        });
4✔
138
        recordIds = recordIds.concat(result.ids);
4✔
139
      }
4✔
140

4✔
141
      return ranges.reduce<string[]>((acc, range) => {
4✔
142
        return acc.concat(recordIds.slice(range[0], range[1] + 1));
4✔
143
      }, []);
4✔
144
    }
4✔
145

4✔
146
    const [start, end] = ranges;
4✔
147
    const total = end[1] - start[1] + 1;
4✔
148
    if (total > this.thresholdConfig.maxReadRows) {
12✔
149
      throw new BadRequestException(`Exceed max read rows ${this.thresholdConfig.maxReadRows}`);
×
150
    }
✔
151
    const result = await this.recordService.getDocIdsByQuery(tableId, {
4✔
152
      ...query,
4✔
153
      skip: start[1],
4✔
154
      take: end[1] + 1 - start[1],
4✔
155
    });
4✔
156

4✔
157
    return result.ids;
4✔
158
  }
4✔
159

138✔
160
  private fieldsToProjection(fields: IFieldVo[], fieldKeyType: FieldKeyType) {
138✔
161
    return fields.map((f) => f[fieldKeyType]);
14✔
162
  }
14✔
163

138✔
164
  private async columnsSelectionCtx(tableId: string, rangesRo: IRangesRo) {
138✔
165
    const { ranges, type, excludeFieldIds, ...queryRo } = rangesRo;
×
166

×
167
    const fields = await this.fieldService.getFieldsByQuery(tableId, {
×
168
      viewId: queryRo.viewId,
×
169
      filterHidden: true,
×
170
      excludeFieldIds,
×
171
    });
×
172

×
173
    const records = await this.recordService.getRecordsFields(tableId, {
×
174
      ...queryRo,
×
175
      skip: 0,
×
176
      take: -1,
×
177
      fieldKeyType: FieldKeyType.Id,
×
178
      projection: this.fieldsToProjection(fields, FieldKeyType.Id),
×
179
    });
×
180

×
181
    return {
×
182
      records,
×
183
      fields: ranges.reduce((acc, range) => {
×
184
        return acc.concat(fields.slice(range[0], range[1] + 1));
×
185
      }, [] as IFieldVo[]),
×
186
    };
×
187
  }
×
188

138✔
189
  private async rowsSelectionCtx(tableId: string, rangesRo: IRangesRo) {
138✔
190
    const { ranges, type, excludeFieldIds, ...queryRo } = rangesRo;
2✔
191
    const fields = await this.fieldService.getFieldsByQuery(tableId, {
2✔
192
      viewId: queryRo.viewId,
2✔
193
      filterHidden: true,
2✔
194
      excludeFieldIds,
2✔
195
    });
2✔
196
    let records: Pick<IRecord, 'id' | 'fields'>[] = [];
2✔
197
    for (const [start, end] of ranges) {
2✔
198
      const recordsFields = await this.recordService.getRecordsFields(tableId, {
4✔
199
        ...queryRo,
4✔
200
        skip: start,
4✔
201
        take: end + 1 - start,
4✔
202
        fieldKeyType: FieldKeyType.Id,
4✔
203
        projection: this.fieldsToProjection(fields, FieldKeyType.Id),
4✔
204
      });
4✔
205
      records = records.concat(recordsFields);
4✔
206
    }
4✔
207

2✔
208
    return {
2✔
209
      records,
2✔
210
      fields,
2✔
211
    };
2✔
212
  }
2✔
213

138✔
214
  private async defaultSelectionCtx(tableId: string, rangesRo: IRangesRo) {
138✔
215
    const { ranges, type, excludeFieldIds, ...queryRo } = rangesRo;
10✔
216
    const [start, end] = ranges;
10✔
217
    const fields = await this.fieldService.getFieldInstances(tableId, {
10✔
218
      viewId: queryRo.viewId,
10✔
219
      filterHidden: true,
10✔
220
      excludeFieldIds,
10✔
221
    });
10✔
222

10✔
223
    const records = await this.recordService.getRecordsFields(tableId, {
10✔
224
      ...queryRo,
10✔
225
      skip: start[1],
10✔
226
      take: end[1] + 1 - start[1],
10✔
227
      fieldKeyType: FieldKeyType.Id,
10✔
228
      projection: this.fieldsToProjection(fields, FieldKeyType.Id),
10✔
229
    });
10✔
230
    return { records, fields: fields.slice(start[0], end[0] + 1) };
10✔
231
  }
10✔
232

138✔
233
  private async parseRange(
138✔
234
    tableId: string,
14✔
235
    rangesRo: IRangesRo
14✔
236
  ): Promise<{ cellCount: number; columnCount: number; rowCount: number }> {
14✔
237
    const { ranges, type, excludeFieldIds, ...queryRo } = rangesRo;
14✔
238
    switch (type) {
14✔
239
      case RangeType.Columns: {
14!
240
        const { rowCount } = await this.aggregationService.performRowCount(tableId, queryRo);
×
241
        const columnCount = ranges.reduce((acc, range) => acc + range[1] - range[0] + 1, 0);
×
242
        const cellCount = rowCount * columnCount;
×
243

×
244
        return { cellCount, columnCount, rowCount };
×
245
      }
×
246
      case RangeType.Rows: {
14!
247
        const fields = await this.fieldService.getFieldsByQuery(tableId, {
×
248
          viewId: queryRo.viewId,
×
249
          filterHidden: true,
×
250
          excludeFieldIds,
×
251
        });
×
252
        const columnCount = fields.length;
×
253
        const rowCount = ranges.reduce((acc, range) => acc + range[1] - range[0] + 1, 0);
×
254
        const cellCount = rowCount * columnCount;
×
255

×
256
        return { cellCount, columnCount, rowCount };
×
257
      }
×
258
      default: {
14✔
259
        const [start, end] = ranges;
14✔
260
        const columnCount = end[0] - start[0] + 1;
14✔
261
        const rowCount = end[1] - start[1] + 1;
14✔
262
        const cellCount = rowCount * columnCount;
14✔
263

14✔
264
        return { cellCount, columnCount, rowCount };
14✔
265
      }
14✔
266
    }
14✔
267
  }
14✔
268

138✔
269
  private async getSelectionCtxByRange(tableId: string, rangesRo: IRangesRo) {
138✔
270
    const { type } = rangesRo;
12✔
271
    switch (type) {
12✔
272
      case RangeType.Columns: {
12✔
273
        return await this.columnsSelectionCtx(tableId, rangesRo);
×
274
      }
×
275
      case RangeType.Rows: {
12✔
276
        return await this.rowsSelectionCtx(tableId, rangesRo);
2✔
277
      }
2✔
278
      default:
12✔
279
        return await this.defaultSelectionCtx(tableId, rangesRo);
10✔
280
    }
12✔
281
  }
12✔
282

138✔
283
  private optionsRoToVoByCvType(
138✔
284
    cellValueType: CellValueType,
14✔
285
    options: IFieldOptionsVo = {}
14✔
286
  ): { type: FieldType; options: IFieldOptionsRo } {
14✔
287
    switch (cellValueType) {
14✔
288
      case CellValueType.Number: {
14✔
289
        const numberOptions = options as INumberFieldOptionsRo;
6✔
290
        const formattingRes = numberFormattingSchema.safeParse(numberOptions?.formatting);
6✔
291
        const showAsRes = singleNumberShowAsSchema.safeParse(numberOptions?.showAs);
6✔
292
        return {
6✔
293
          type: FieldType.Number,
6✔
294
          options: {
6✔
295
            formatting: formattingRes.success ? formattingRes?.data : defaultNumberFormatting,
6!
296
            showAs: showAsRes.success ? showAsRes?.data : undefined,
6!
297
          },
6✔
298
        };
6✔
299
      }
6✔
300
      case CellValueType.DateTime: {
14✔
301
        const dateOptions = options as IDateFieldOptions;
2✔
302
        const formattingRes = datetimeFormattingSchema.safeParse(dateOptions?.formatting);
2✔
303
        return {
2✔
304
          type: FieldType.Date,
2✔
305
          options: {
2✔
306
            formatting: formattingRes.success ? formattingRes?.data : defaultDatetimeFormatting,
2!
307
          },
2✔
308
        };
2✔
309
      }
2✔
310
      case CellValueType.String: {
14✔
311
        const singleLineTextOptions = options as ISingleLineTextFieldOptions;
2✔
312
        const showAsRes = singleLineTextShowAsSchema.safeParse(singleLineTextOptions.showAs);
2✔
313
        return {
2✔
314
          type: FieldType.SingleLineText,
2✔
315
          options: {
2✔
316
            showAs: showAsRes.success ? showAsRes?.data : undefined,
2!
317
          },
2✔
318
        };
2✔
319
      }
2✔
320
      case CellValueType.Boolean: {
14✔
321
        return {
2✔
322
          type: FieldType.Checkbox,
2✔
323
          options: {},
2✔
324
        };
2✔
325
      }
2✔
326
      default:
14✔
327
        throw new BadRequestException('Invalid cellValueType');
2✔
328
    }
14✔
329
  }
14✔
330

138✔
331
  private lookupOptionsRoToVo(field: IFieldVo): { type: FieldType; options: IFieldOptionsRo } {
138✔
332
    const { type, isMultipleCellValue, options } = field;
6✔
333
    if (type === FieldType.SingleSelect && isMultipleCellValue) {
6✔
334
      return {
2✔
335
        type: FieldType.MultipleSelect,
2✔
336
        options,
2✔
337
      };
2✔
338
    }
2✔
339
    if (type === FieldType.User && isMultipleCellValue) {
6✔
340
      const userOptions = options as IUserFieldOptions;
2✔
341
      return {
2✔
342
        type,
2✔
343
        options: {
2✔
344
          ...userOptions,
2✔
345
          isMultiple: true,
2✔
346
        },
2✔
347
      };
2✔
348
    }
2✔
349
    return { type, options };
2✔
350
  }
2✔
351

138✔
352
  private fieldVoToRo(field?: IFieldVo): IFieldRo {
138✔
353
    if (!field) {
16✔
354
      return {
2✔
355
        type: FieldType.SingleLineText,
2✔
356
      };
2✔
357
    }
2✔
358
    const { isComputed, isLookup } = field;
14✔
359
    const baseField = pick(field, 'name', 'type', 'options', 'description');
14✔
360

14✔
361
    if (isComputed && !isLookup) {
16✔
362
      if ([FieldType.CreatedBy, FieldType.LastModifiedBy].includes(field.type)) {
8✔
363
        return {
4✔
364
          ...baseField,
4✔
365
          type: FieldType.User,
4✔
366
          options: defaultUserFieldOptions,
4✔
367
        };
4✔
368
      }
4✔
369
      return {
4✔
370
        ...baseField,
4✔
371
        ...this.optionsRoToVoByCvType(field.cellValueType, field.options),
4✔
372
      };
4✔
373
    }
4✔
374

6✔
375
    if (isLookup) {
14!
376
      return {
×
377
        ...baseField,
×
378
        ...this.lookupOptionsRoToVo(field),
×
379
      };
×
380
    }
✔
381

6✔
382
    return baseField;
6✔
383
  }
6✔
384

138✔
385
  private async expandColumns({
138✔
386
    tableId,
8✔
387
    header,
8✔
388
    numColsToExpand,
8✔
389
  }: {
8✔
390
    tableId: string;
8✔
391
    header: IFieldVo[];
8✔
392
    numColsToExpand: number;
8✔
393
  }) {
8✔
394
    const colLen = header.length;
8✔
395
    const res: IFieldVo[] = [];
8✔
396
    for (let i = colLen - numColsToExpand; i < colLen; i++) {
8✔
397
      const field = this.fieldVoToRo(header[i]);
6✔
398
      const fieldVo = await this.fieldSupplementService.prepareCreateField(tableId, field);
6✔
399
      const fieldInstance = createFieldInstanceByVo(fieldVo);
6✔
400
      // expend columns do not need to calculate
6✔
401
      await this.fieldCreatingService.alterCreateField(tableId, fieldInstance);
6✔
402
      res.push(fieldVo);
6✔
403
    }
6✔
404
    return res;
8✔
405
  }
8✔
406

138✔
407
  private async collectionAttachment({
138✔
408
    fields,
2✔
409
    tableData,
2✔
410
  }: {
2✔
411
    tableData: string[][];
2✔
412
    fields: IFieldInstance[];
2✔
413
  }) {
2✔
414
    const attachmentFieldsIndex = fields
2✔
415
      .map((field, index) => (field.type === FieldType.Attachment ? index : null))
2✔
416
      .filter(isNumber);
2✔
417

2✔
418
    const tokens = tableData.reduce((acc, recordData) => {
2✔
419
      const tokensInRecord = attachmentFieldsIndex.reduce((acc, index) => {
4✔
420
        if (!recordData[index]) return acc;
4✔
421

4✔
422
        const tokensAndNames = recordData[index]
4✔
423
          .split(',')
4✔
424
          .map(AttachmentFieldDto.getTokenAndNameByString);
4✔
425
        return acc.concat(map(tokensAndNames, 'token').filter(isString));
4✔
426
      }, [] as string[]);
4✔
427
      return acc.concat(tokensInRecord);
4✔
428
    }, [] as string[]);
2✔
429

2✔
430
    const attachments = await this.prismaService.attachments.findMany({
2✔
431
      where: {
2✔
432
        token: {
2✔
433
          in: tokens,
2✔
434
        },
2✔
435
      },
2✔
436
      select: {
2✔
437
        token: true,
2✔
438
        size: true,
2✔
439
        mimetype: true,
2✔
440
        width: true,
2✔
441
        height: true,
2✔
442
        path: true,
2✔
443
      },
2✔
444
    });
2✔
445
    return attachments.map(nullsToUndefined);
2✔
446
  }
2✔
447

138✔
448
  private parseCopyContent(content: string): string[][] {
138✔
449
    return parseClipboardText(content);
8✔
450
  }
8✔
451

138✔
452
  private stringifyCopyContent(content: string[][]): string {
138✔
453
    return stringifyClipboardText(content);
6✔
454
  }
6✔
455

138✔
456
  private calculateExpansion(
138✔
457
    tableSize: [number, number],
12✔
458
    cell: [number, number],
12✔
459
    tableDataSize: [number, number]
12✔
460
  ): [number, number] {
12✔
461
    const permissions = this.cls.get('permissions');
12✔
462
    const [numCols, numRows] = tableSize;
12✔
463
    const [dataNumCols, dataNumRows] = tableDataSize;
12✔
464

12✔
465
    const endCol = cell[0] + dataNumCols;
12✔
466
    const endRow = cell[1] + dataNumRows;
12✔
467

12✔
468
    const numRowsToExpand = Math.max(0, endRow - numRows);
12✔
469
    const numColsToExpand = Math.max(0, endCol - numCols);
12✔
470

12✔
471
    const hasFieldCreatePermission = permissions.includes('field|create');
12✔
472
    const hasRecordCreatePermission = permissions.includes('record|create');
12✔
473
    return [
12✔
474
      hasFieldCreatePermission ? numColsToExpand : 0,
12✔
475
      hasRecordCreatePermission ? numRowsToExpand : 0,
12!
476
    ];
12✔
477
  }
12✔
478

138✔
479
  private async tableDataToRecords({
138✔
480
    tableId,
12✔
481
    tableData,
12✔
482
    fields,
12✔
483
    headerFields,
12✔
484
  }: {
12✔
485
    tableId: string;
12✔
486
    tableData: string[][];
12✔
487
    fields: IFieldInstance[];
12✔
488
    headerFields: IFieldInstance[] | undefined;
12✔
489
  }) {
12✔
490
    const fieldConvertContext = await this.fieldConvertContext(tableId, tableData, fields);
12✔
491

12✔
492
    const records: { fields: IRecord['fields'] }[] = [];
12✔
493
    fields.forEach((field, col) => {
12✔
494
      if (field.isComputed) {
28!
495
        return;
×
496
      }
×
497
      tableData.forEach((cellCols, row) => {
28✔
498
        const stringValue = cellCols?.[col] ?? null;
60!
499
        const recordField = records[row]?.fields || {};
60✔
500

60✔
501
        if (stringValue === null) {
60!
502
          recordField[field.id] = null;
×
503
        } else {
60✔
504
          switch (field.type) {
60✔
505
            case FieldType.Attachment:
60!
506
              {
×
507
                recordField[field.id] = field.convertStringToCellValue(
×
508
                  stringValue,
×
509
                  fieldConvertContext?.attachments
×
510
                );
×
511
              }
×
512
              break;
×
513
            case FieldType.Date:
60✔
514
              // handle format
4✔
515
              recordField[field.id] = (headerFields?.[col] || field).convertStringToCellValue(
4!
516
                stringValue
4✔
517
              );
4✔
518
              break;
4✔
519
            default:
60✔
520
              recordField[field.id] = stringValue || null;
56!
521
          }
60✔
522
        }
60✔
523

60✔
524
        records[row] = {
60✔
525
          fields: recordField,
60✔
526
        };
60✔
527
      });
60✔
528
    });
28✔
529
    return records;
12✔
530
  }
12✔
531

138✔
532
  private fillCells(
138✔
533
    oldRecords: {
16✔
534
      id: string;
16✔
535
      fields: IRecord['fields'];
16✔
536
    }[],
16✔
537
    newRecords?: { fields: IRecord['fields'] }[]
16✔
538
  ): IUpdateRecordsRo {
16✔
539
    return {
16✔
540
      fieldKeyType: FieldKeyType.Id,
16✔
541
      typecast: true,
16✔
542
      records: oldRecords.map(({ id, fields }, index) => {
16✔
543
        const newFields = newRecords?.[index]?.fields;
28✔
544
        const updateFields = newFields ? { ...fields, ...newFields } : {};
28✔
545
        return {
28✔
546
          id,
28✔
547
          fields: updateFields,
28✔
548
        };
28✔
549
      }),
28✔
550
    };
16✔
551
  }
16✔
552

138✔
553
  private async fieldConvertContext(
138✔
554
    tableId: string,
12✔
555
    tableData: string[][],
12✔
556
    fields: IFieldInstance[]
12✔
557
  ) {
12✔
558
    const hasFieldType = (type: FieldType) => fields.some((field) => field.type === type);
12✔
559

12✔
560
    const loadAttachments = hasFieldType(FieldType.Attachment)
12!
561
      ? this.collectionAttachment({ fields, tableData })
×
562
      : Promise.resolve(undefined);
12✔
563

12✔
564
    const [attachments] = await Promise.all([loadAttachments]);
12✔
565

12✔
566
    return {
12✔
567
      attachments: attachments,
12✔
568
    };
12✔
569
  }
12✔
570

138✔
571
  async copy(tableId: string, rangesRo: IRangesRo) {
138✔
572
    const { cellCount } = await this.parseRange(tableId, rangesRo);
6✔
573

6✔
574
    if (cellCount > this.thresholdConfig.maxCopyCells) {
6!
575
      throw new BadRequestException(`Exceed max copy cells ${this.thresholdConfig.maxCopyCells}`);
×
576
    }
×
577

6✔
578
    const { fields, records } = await this.getSelectionCtxByRange(tableId, rangesRo);
6✔
579
    const fieldInstances = fields.map(createFieldInstanceByVo);
6✔
580
    const rectangleData = records.map((record) =>
6✔
581
      fieldInstances.map((fieldInstance) =>
14✔
582
        fieldInstance.cellValue2String(record.fields[fieldInstance.id] as never)
28✔
583
      )
6✔
584
    );
6✔
585
    return {
6✔
586
      content: this.stringifyCopyContent(rectangleData),
6✔
587
      header: fields,
6✔
588
    };
6✔
589
  }
6✔
590

138✔
591
  // If the pasted selection is twice the size of the content,
138✔
592
  // the content is automatically expanded to the selection size
138✔
593
  private expandPasteContent(pasteData: string[][], range: [[number, number], [number, number]]) {
138✔
594
    const [start, end] = range;
12✔
595
    const [startCol, startRow] = start;
12✔
596
    const [endCol, endRow] = end;
12✔
597

12✔
598
    const rangeRows = endRow - startRow + 1;
12✔
599
    const rangeCols = endCol - startCol + 1;
12✔
600

12✔
601
    const pasteRows = pasteData.length;
12✔
602
    const pasteCols = pasteData[0].length;
12✔
603

12✔
604
    if (rangeRows % pasteRows !== 0 || rangeCols % pasteCols !== 0) {
12✔
605
      return pasteData;
10✔
606
    }
10✔
607

2✔
608
    return Array.from({ length: rangeRows }, (_, i) =>
2✔
609
      Array.from({ length: rangeCols }, (_, j) => pasteData[i % pasteRows][j % pasteCols])
8✔
610
    );
2✔
611
  }
2✔
612

138✔
613
  // Paste does not support non-contiguous selections,
138✔
614
  // the first selection is taken by default.
138✔
615
  private getRangeCell(
138✔
616
    maxRange: [number, number][],
14✔
617
    range: [number, number][],
14✔
618
    type?: RangeType
14✔
619
  ): [[number, number], [number, number]] {
14✔
620
    const [maxStart, maxEnd] = maxRange;
14✔
621
    const [maxStartCol, maxStartRow] = maxStart;
14✔
622
    const [maxEndCol, maxEndRow] = maxEnd;
14✔
623

14✔
624
    if (type === RangeType.Columns) {
14✔
625
      return [
2✔
626
        [range[0][0], maxStartRow],
2✔
627
        [range[0][1], maxEndRow],
2✔
628
      ];
2✔
629
    }
2✔
630

12✔
631
    if (type === RangeType.Rows) {
14✔
632
      return [
2✔
633
        [maxStartCol, range[0][0]],
2✔
634
        [maxEndCol, range[0][1]],
2✔
635
      ];
2✔
636
    }
2✔
637
    return [range[0], range[1]];
10✔
638
  }
10✔
639

138✔
640
  // For pasting to add new lines
138✔
641
  async temporaryPaste(tableId: string, pasteRo: IPasteRo) {
138✔
642
    const { content, header = [], viewId, ranges, excludeFieldIds } = pasteRo;
×
643

×
644
    const fields = await this.fieldService.getFieldInstances(tableId, {
×
645
      viewId,
×
646
      filterHidden: true,
×
647
      excludeFieldIds: excludeFieldIds,
×
648
    });
×
649

×
650
    const rangeCell = ranges as [[number, number], [number, number]];
×
651
    const startColumnIndex = rangeCell[0][0];
×
652

×
653
    const tableData = this.expandPasteContent(this.parseCopyContent(content), rangeCell);
×
654
    const tableColCount = tableData[0].length;
×
655
    const effectFields = fields.slice(startColumnIndex, startColumnIndex + tableColCount);
×
656
    let result: ITemporaryPasteVo = [];
×
657

×
658
    await this.prismaService.$tx(async () => {
×
659
      const newRecords = await this.tableDataToRecords({
×
660
        tableId,
×
661
        tableData,
×
662
        headerFields: header.map(createFieldInstanceByVo),
×
663
        fields: effectFields,
×
664
      });
×
665

×
666
      result = await this.recordOpenApiService.validateFieldsAndTypecast(
×
667
        tableId,
×
668
        newRecords,
×
669
        FieldKeyType.Id,
×
670
        true
×
671
      );
×
672
    });
×
673

×
674
    return result;
×
675
  }
×
676

138✔
677
  async paste(
138✔
678
    tableId: string,
8✔
679
    pasteRo: IPasteRo,
8✔
680
    expansionChecker?: (col: number, row: number) => Promise<void>
8✔
681
  ) {
8✔
682
    const { content, header = [], ...rangesRo } = pasteRo;
8✔
683
    const { ranges, type, ...queryRo } = rangesRo;
8✔
684
    const { viewId } = queryRo;
8✔
685
    const { cellCount } = await this.parseRange(tableId, rangesRo);
8✔
686

8✔
687
    if (cellCount > this.thresholdConfig.maxPasteCells) {
8!
688
      throw new BadRequestException(`Exceed max paste cells ${this.thresholdConfig.maxPasteCells}`);
×
689
    }
×
690

8✔
691
    const { rowCount: rowCountInView } = await this.aggregationService.performRowCount(
8✔
692
      tableId,
8✔
693
      queryRo
8✔
694
    );
8✔
695
    const fields = await this.fieldService.getFieldInstances(tableId, {
8✔
696
      viewId,
8✔
697
      filterHidden: true,
8✔
698
      excludeFieldIds: rangesRo.excludeFieldIds,
8✔
699
    });
8✔
700

8✔
701
    const tableSize: [number, number] = [fields.length, rowCountInView];
8✔
702
    const rangeCell = this.getRangeCell(
8✔
703
      [
8✔
704
        [0, 0],
8✔
705
        [tableSize[0] - 1, tableSize[1] - 1],
8✔
706
      ],
8✔
707
      ranges,
8✔
708
      type
8✔
709
    );
8✔
710

8✔
711
    const tableData = this.expandPasteContent(this.parseCopyContent(content), rangeCell);
8✔
712
    const tableColCount = tableData[0].length;
8✔
713
    const tableRowCount = tableData.length;
8✔
714

8✔
715
    const cell = rangeCell[0];
8✔
716
    const [col, row] = cell;
8✔
717

8✔
718
    const effectFields = fields.slice(col, col + tableColCount);
8✔
719

8✔
720
    const projection = effectFields.map((f) => f.id);
8✔
721

8✔
722
    const records = await this.recordService.getRecordsFields(tableId, {
8✔
723
      ...queryRo,
8✔
724
      projection,
8✔
725
      skip: row,
8✔
726
      take: tableData.length,
8✔
727
      fieldKeyType: FieldKeyType.Id,
8✔
728
    });
8✔
729

8✔
730
    const [numColsToExpand, numRowsToExpand] = this.calculateExpansion(tableSize, cell, [
8✔
731
      tableColCount,
8✔
732
      tableRowCount,
8✔
733
    ]);
8✔
734
    await expansionChecker?.(numColsToExpand, numRowsToExpand);
8!
735

8✔
736
    const updateRange: IPasteVo['ranges'] = [cell, cell];
8✔
737

8✔
738
    const expandColumns = await this.prismaService.$tx(async () => {
8✔
739
      // Expansion col
8✔
740
      return await this.expandColumns({
8✔
741
        tableId,
8✔
742
        header,
8✔
743
        numColsToExpand,
8✔
744
      });
8✔
745
    });
8✔
746

8✔
747
    await this.prismaService.$tx(async () => {
8✔
748
      const updateFields = effectFields.concat(expandColumns.map(createFieldInstanceByVo));
8✔
749

8✔
750
      // get all effect records, contains update and need create record
8✔
751
      const newRecords = await this.tableDataToRecords({
8✔
752
        tableId,
8✔
753
        tableData,
8✔
754
        headerFields: header.map(createFieldInstanceByVo),
8✔
755
        fields: updateFields,
8✔
756
      });
8✔
757

8✔
758
      // Warning: Update before creating
8✔
759
      // Fill cells
8✔
760
      const updateNewRecords = newRecords.slice(0, records.length);
8✔
761
      const updateRecordsRo = this.fillCells(records, updateNewRecords);
8✔
762
      await this.recordOpenApiService.updateRecords(tableId, updateRecordsRo);
8✔
763

8✔
764
      // create record
8✔
765
      if (numRowsToExpand) {
8✔
766
        const createNewRecords = newRecords.slice(records.length);
2✔
767
        const createRecordsRo = {
2✔
768
          fieldKeyType: FieldKeyType.Id,
2✔
769
          typecast: true,
2✔
770
          records: createNewRecords,
2✔
771
        };
2✔
772
        await this.recordOpenApiService.createRecords(tableId, createRecordsRo);
2✔
773
      }
2✔
774

8✔
775
      updateRange[1] = [col + updateFields.length - 1, row + updateFields.length - 1];
8✔
776
    });
8✔
777

8✔
778
    return updateRange;
8✔
779
  }
8✔
780

138✔
781
  async clear(tableId: string, rangesRo: IRangesRo) {
138✔
782
    const { fields, records } = await this.getSelectionCtxByRange(tableId, rangesRo);
2✔
783
    const fieldInstances = fields.map(createFieldInstanceByVo);
2✔
784
    const updateRecords = await this.tableDataToRecords({
2✔
785
      tableId,
2✔
786
      tableData: Array.from({ length: records.length }, () => []),
2✔
787
      fields: fieldInstances,
2✔
788
      headerFields: undefined,
2✔
789
    });
2✔
790
    const updateRecordsRo = this.fillCells(records, updateRecords);
2✔
791
    await this.recordOpenApiService.updateRecords(tableId, updateRecordsRo);
2✔
792
  }
2✔
793

138✔
794
  async delete(tableId: string, rangesRo: IRangesRo): Promise<IDeleteVo> {
138✔
795
    const { records } = await this.getSelectionCtxByRange(tableId, rangesRo);
8✔
796
    const recordIds = records.map(({ id }) => id);
8✔
797
    await this.recordOpenApiService.deleteRecords(tableId, recordIds);
8✔
798
    return { ids: recordIds };
8✔
799
  }
8✔
800

138✔
801
  async duplicate(tableId: string, rangesRo: IRangesRo): Promise<IDuplicateVo> {
138✔
NEW
802
    const { records } = await this.getSelectionCtxByRange(tableId, rangesRo);
×
NEW
803
    const fields = records.map(({ fields }) => {
×
NEW
804
      return { fields: fields };
×
NEW
805
    });
×
NEW
806

×
NEW
807
    const createRecordsRo = {
×
NEW
808
      fieldKeyType: FieldKeyType.Id,
×
NEW
809
      records: fields,
×
NEW
810
    };
×
NEW
811
    return await this.recordOpenApiService.createRecords(tableId, createRecordsRo);
×
NEW
812
  }
×
813
}
138✔
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