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

teableio / teable / 14828572373

05 May 2025 03:40AM UTC coverage: 80.511% (-0.001%) from 80.512%
14828572373

Pull #1504

github

web-flow
Merge cc5349b00 into f701856cc
Pull Request #1504: feat: multi-line field names & batch collapse by group

7698 of 8166 branches covered (94.27%)

22 of 23 new or added lines in 1 file covered. (95.65%)

5 existing lines in 2 files now uncovered.

36829 of 45744 relevant lines covered (80.51%)

1759.57 hits per line

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

88.16
/apps/nestjs-backend/src/features/record/record.service.ts
1
/* eslint-disable @typescript-eslint/naming-convention */
4✔
2
import {
3
  BadRequestException,
4
  Injectable,
5
  InternalServerErrorException,
6
  Logger,
7
  NotFoundException,
8
} from '@nestjs/common';
9
import type {
10
  IAttachmentCellValue,
11
  IColumnMeta,
12
  IExtraResult,
13
  IFilter,
14
  IFilterSet,
15
  IGridColumnMeta,
16
  IGroup,
17
  ILinkCellValue,
18
  IRecord,
19
  ISnapshotBase,
20
  ISortItem,
21
} from '@teable/core';
22
import {
23
  and,
24
  CellFormat,
25
  CellValueType,
26
  DbFieldType,
27
  FieldKeyType,
28
  FieldType,
29
  generateRecordId,
30
  HttpErrorCode,
31
  identify,
32
  IdPrefix,
33
  mergeFilter,
34
  mergeWithDefaultFilter,
35
  mergeWithDefaultSort,
36
  or,
37
  parseGroup,
38
  Relationship,
39
} from '@teable/core';
40
import type { Prisma } from '@teable/db-main-prisma';
41
import { PrismaService } from '@teable/db-main-prisma';
42
import type {
43
  ICreateRecordsRo,
44
  IGetRecordQuery,
45
  IGetRecordsRo,
46
  IGroupHeaderPoint,
47
  IGroupHeaderRef,
48
  IGroupPoint,
49
  IGroupPointsVo,
50
  IRecordStatusVo,
51
  IRecordsVo,
52
} from '@teable/openapi';
53
import { GroupPointType, UploadType } from '@teable/openapi';
54
import { Knex } from 'knex';
55
import { get, difference, keyBy, orderBy, uniqBy, toNumber } from 'lodash';
56
import { InjectModel } from 'nest-knexjs';
57
import { ClsService } from 'nestjs-cls';
58
import { CacheService } from '../../cache/cache.service';
59
import { ThresholdConfig, IThresholdConfig } from '../../configs/threshold.config';
60
import { CustomHttpException } from '../../custom.exception';
61
import { InjectDbProvider } from '../../db-provider/db.provider';
62
import { IDbProvider } from '../../db-provider/db.provider.interface';
63
import { RawOpType } from '../../share-db/interface';
64
import type { IClsStore } from '../../types/cls';
65
import { convertValueToStringify, string2Hash } from '../../utils';
66
import { handleDBValidationErrors } from '../../utils/db-validation-error';
67
import { generateFilterItem } from '../../utils/filter';
68
import {
69
  generateTableThumbnailPath,
70
  getTableThumbnailToken,
71
} from '../../utils/generate-thumbnail-path';
72
import { Timing } from '../../utils/timing';
73
import { AttachmentsStorageService } from '../attachments/attachments-storage.service';
74
import StorageAdapter from '../attachments/plugins/adapter';
75
import { BatchService } from '../calculation/batch.service';
76
import type { IVisualTableDefaultField } from '../field/constant';
77
import { preservedDbFieldNames } from '../field/constant';
78
import type { IFieldInstance } from '../field/model/factory';
79
import { createFieldInstanceByRaw } from '../field/model/factory';
80
import { TableIndexService } from '../table/table-index.service';
81
import { ROW_ORDER_FIELD_PREFIX } from '../view/constant';
82
import { RecordPermissionService } from './record-permission.service';
83
import { IFieldRaws } from './type';
84

85
type IUserFields = { id: string; dbFieldName: string }[];
86

87
function removeUndefined<T extends Record<string, unknown>>(obj: T) {
9,212✔
88
  return Object.fromEntries(Object.entries(obj).filter(([_, v]) => v !== undefined)) as T;
9,212✔
89
}
9,212✔
90

91
export interface IRecordInnerRo {
92
  id: string;
93
  fields: Record<string, unknown>;
94
  createdBy?: string;
95
  lastModifiedBy?: string;
96
  createdTime?: string;
97
  lastModifiedTime?: string;
98
  autoNumber?: number;
99
  order?: Record<string, number>; // viewId: index
100
}
101

102
@Injectable()
103
export class RecordService {
4✔
104
  private logger = new Logger(RecordService.name);
435✔
105

106
  constructor(
435✔
107
    private readonly prismaService: PrismaService,
435✔
108
    private readonly batchService: BatchService,
435✔
109
    private readonly cls: ClsService<IClsStore>,
435✔
110
    private readonly cacheService: CacheService,
435✔
111
    private readonly attachmentStorageService: AttachmentsStorageService,
435✔
112
    private readonly recordPermissionService: RecordPermissionService,
435✔
113
    private readonly tableIndexService: TableIndexService,
435✔
114
    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,
435✔
115
    @InjectDbProvider() private readonly dbProvider: IDbProvider,
435✔
116
    @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig
435✔
117
  ) {}
435✔
118

119
  private dbRecord2RecordFields(
435✔
120
    record: IRecord['fields'],
45,713✔
121
    fields: IFieldInstance[],
45,713✔
122
    fieldKeyType: FieldKeyType = FieldKeyType.Id,
45,713✔
123
    cellFormat: CellFormat = CellFormat.Json
45,713✔
124
  ) {
45,713✔
125
    return fields.reduce<IRecord['fields']>((acc, field) => {
45,713✔
126
      const fieldNameOrId = field[fieldKeyType];
257,603✔
127
      const dbCellValue = record[field.dbFieldName];
257,603✔
128
      const cellValue = field.convertDBValue2CellValue(dbCellValue);
257,603✔
129
      if (cellValue != null) {
257,603✔
130
        acc[fieldNameOrId] =
183,145✔
131
          cellFormat === CellFormat.Text ? field.cellValue2String(cellValue) : cellValue;
183,145✔
132
      }
183,145✔
133
      return acc;
257,603✔
134
    }, {});
45,713✔
135
  }
45,713✔
136

137
  async getAllRecordCount(dbTableName: string) {
435✔
138
    const sqlNative = this.knex(dbTableName).count({ count: '*' }).toSQL().toNative();
12✔
139

140
    const queryResult = await this.prismaService
12✔
141
      .txClient()
12✔
142
      .$queryRawUnsafe<{ count?: number }[]>(sqlNative.sql, ...sqlNative.bindings);
12✔
143
    return Number(queryResult[0]?.count ?? 0);
12✔
144
  }
12✔
145

146
  async getDbValueMatrix(
435✔
147
    dbTableName: string,
×
148
    userFields: IUserFields,
×
149
    rowIndexFieldNames: string[],
×
150
    createRecordsRo: ICreateRecordsRo
×
151
  ) {
×
152
    const rowCount = await this.getAllRecordCount(dbTableName);
×
153
    const dbValueMatrix: unknown[][] = [];
×
154
    for (let i = 0; i < createRecordsRo.records.length; i++) {
×
155
      const recordData = createRecordsRo.records[i].fields;
×
156
      // 1. collect cellValues
×
157
      const recordValues = userFields.map<unknown>((field) => {
×
158
        const cellValue = recordData[field.id];
×
159
        if (cellValue == null) {
×
160
          return null;
×
161
        }
×
162
        return cellValue;
×
163
      });
×
164

165
      // 2. generate rowIndexValues
×
166
      const rowIndexValues = rowIndexFieldNames.map(() => rowCount + i);
×
167

168
      // 3. generate id, __created_time, __created_by, __version
×
169
      const systemValues = [generateRecordId(), new Date().toISOString(), 'admin', 1];
×
170

171
      dbValueMatrix.push([...recordValues, ...rowIndexValues, ...systemValues]);
×
172
    }
×
173
    return dbValueMatrix;
×
174
  }
×
175

176
  async getDbTableName(tableId: string) {
435✔
177
    const tableMeta = await this.prismaService
9,660✔
178
      .txClient()
9,660✔
179
      .tableMeta.findUniqueOrThrow({
9,660✔
180
        where: { id: tableId },
9,660✔
181
        select: { dbTableName: true },
9,660✔
182
      })
9,660✔
183
      .catch(() => {
9,660✔
184
        throw new NotFoundException(`Table ${tableId} not found`);
×
185
      });
×
186
    return tableMeta.dbTableName;
9,660✔
187
  }
9,660✔
188

189
  private async getLinkCellIds(tableId: string, field: IFieldInstance, recordId: string) {
435✔
190
    const prisma = this.prismaService.txClient();
108✔
191
    const dbTableName = await prisma.tableMeta.findFirstOrThrow({
108✔
192
      where: { id: tableId },
108✔
193
      select: { dbTableName: true },
108✔
194
    });
108✔
195
    const linkCellQuery = this.knex(dbTableName)
108✔
196
      .select({
108✔
197
        id: '__id',
108✔
198
        linkField: field.dbFieldName,
108✔
199
      })
108✔
200
      .where('__id', recordId)
108✔
201
      .toQuery();
108✔
202

203
    const result = await prisma.$queryRawUnsafe<
108✔
204
      {
205
        id: string;
206
        linkField: string | null;
207
      }[]
208
    >(linkCellQuery);
108✔
209
    return result
108✔
210
      .map(
108✔
211
        (item) =>
108✔
212
          field.convertDBValue2CellValue(item.linkField) as ILinkCellValue | ILinkCellValue[]
108✔
213
      )
214
      .filter(Boolean)
108✔
215
      .flat()
108✔
216
      .map((item) => item.id);
108✔
217
  }
108✔
218

219
  private async buildLinkSelectedSort(
435✔
220
    queryBuilder: Knex.QueryBuilder,
52✔
221
    dbTableName: string,
52✔
222
    filterLinkCellSelected: [string, string]
52✔
223
  ) {
52✔
224
    const prisma = this.prismaService.txClient();
52✔
225
    const [fieldId, recordId] = filterLinkCellSelected;
52✔
226
    const fieldRaw = await prisma.field
52✔
227
      .findFirstOrThrow({
52✔
228
        where: { id: fieldId, deletedTime: null },
52✔
229
      })
52✔
230
      .catch(() => {
52✔
231
        throw new NotFoundException(`Field ${fieldId} not found`);
×
232
      });
×
233
    const field = createFieldInstanceByRaw(fieldRaw);
52✔
234
    if (!field.isMultipleCellValue) {
52✔
235
      return;
24✔
236
    }
24✔
237

238
    const ids = await this.getLinkCellIds(fieldRaw.tableId, field, recordId);
28✔
239
    if (!ids.length) {
52✔
240
      return;
12✔
241
    }
12✔
242

243
    // sql capable for sqlite
16✔
244
    const valuesQuery = ids
16✔
245
      .map((id, index) => `SELECT ${index + 1} AS sort_order, '${id}' AS id`)
16✔
246
      .join(' UNION ALL ');
16✔
247

248
    queryBuilder
16✔
249
      .with('ordered_ids', this.knex.raw(`${valuesQuery}`))
16✔
250
      .leftJoin('ordered_ids', function () {
16✔
251
        this.on(`${dbTableName}.__id`, '=', 'ordered_ids.id');
16✔
252
      })
16✔
253
      .orderBy('ordered_ids.sort_order');
16✔
254
  }
16✔
255

256
  private isJunctionTable(dbTableName: string) {
435✔
257
    if (dbTableName.includes('.')) {
24✔
258
      return dbTableName.split('.')[1].startsWith('junction');
12✔
259
    }
12✔
260
    return dbTableName.split('_')[1].startsWith('junction');
12✔
261
  }
12✔
262

263
  // eslint-disable-next-line sonarjs/cognitive-complexity
435✔
264
  async buildLinkSelectedQuery(
435✔
265
    queryBuilder: Knex.QueryBuilder,
102✔
266
    tableId: string,
102✔
267
    dbTableName: string,
102✔
268
    filterLinkCellSelected: [string, string] | string
102✔
269
  ) {
102✔
270
    const prisma = this.prismaService.txClient();
102✔
271
    const fieldId = Array.isArray(filterLinkCellSelected)
102✔
272
      ? filterLinkCellSelected[0]
76✔
273
      : filterLinkCellSelected;
26✔
274
    const recordId = Array.isArray(filterLinkCellSelected) ? filterLinkCellSelected[1] : undefined;
102✔
275

276
    const fieldRaw = await prisma.field
102✔
277
      .findFirstOrThrow({
102✔
278
        where: { id: fieldId, deletedTime: null },
102✔
279
      })
102✔
280
      .catch(() => {
102✔
281
        throw new NotFoundException(`Field ${fieldId} not found`);
×
282
      });
×
283

284
    const field = createFieldInstanceByRaw(fieldRaw);
102✔
285

286
    if (field.type !== FieldType.Link) {
102✔
287
      throw new BadRequestException('You can only filter by link field');
×
288
    }
×
289
    const { foreignTableId, fkHostTableName, selfKeyName, foreignKeyName } = field.options;
102✔
290
    if (foreignTableId !== tableId) {
102✔
291
      throw new BadRequestException('Field is not linked to current table');
×
292
    }
×
293

294
    if (fkHostTableName !== dbTableName) {
102✔
295
      queryBuilder.leftJoin(
82✔
296
        `${fkHostTableName}`,
82✔
297
        `${dbTableName}.__id`,
82✔
298
        '=',
82✔
299
        `${fkHostTableName}.${foreignKeyName}`
82✔
300
      );
301
      if (recordId) {
82✔
302
        queryBuilder.where(`${fkHostTableName}.${selfKeyName}`, recordId);
60✔
303
        return;
60✔
304
      }
60✔
305
      queryBuilder.whereNotNull(`${fkHostTableName}.${foreignKeyName}`);
22✔
306
      return;
22✔
307
    }
22✔
308

309
    if (recordId) {
100✔
310
      queryBuilder.where(`${dbTableName}.${selfKeyName}`, recordId);
16✔
311
      return;
16✔
312
    }
16✔
313
    queryBuilder.whereNotNull(`${dbTableName}.${selfKeyName}`);
4✔
314
  }
4✔
315

316
  async buildLinkCandidateQuery(
435✔
317
    queryBuilder: Knex.QueryBuilder,
106✔
318
    tableId: string,
106✔
319
    filterLinkCellCandidate: [string, string] | string
106✔
320
  ) {
106✔
321
    const prisma = this.prismaService.txClient();
106✔
322
    const fieldId = Array.isArray(filterLinkCellCandidate)
106✔
323
      ? filterLinkCellCandidate[0]
80✔
324
      : filterLinkCellCandidate;
26✔
325
    const recordId = Array.isArray(filterLinkCellCandidate)
106✔
326
      ? filterLinkCellCandidate[1]
80✔
327
      : undefined;
26✔
328

329
    const fieldRaw = await prisma.field
106✔
330
      .findFirstOrThrow({
106✔
331
        where: { id: fieldId, deletedTime: null },
106✔
332
      })
106✔
333
      .catch(() => {
106✔
334
        throw new NotFoundException(`Field ${fieldId} not found`);
×
335
      });
×
336

337
    const field = createFieldInstanceByRaw(fieldRaw);
106✔
338

339
    if (field.type !== FieldType.Link) {
106✔
340
      throw new BadRequestException('You can only filter by link field');
×
341
    }
×
342
    const { foreignTableId, fkHostTableName, selfKeyName, foreignKeyName, relationship } =
106✔
343
      field.options;
106✔
344
    if (foreignTableId !== tableId) {
106✔
345
      throw new BadRequestException('Field is not linked to current table');
×
346
    }
×
347
    if (relationship === Relationship.OneMany) {
106✔
348
      if (this.isJunctionTable(fkHostTableName)) {
24✔
349
        queryBuilder.whereNotIn('__id', function () {
8✔
350
          this.select(foreignKeyName).from(fkHostTableName);
20✔
351
        });
20✔
352
      } else {
24✔
353
        queryBuilder.where(selfKeyName, null);
16✔
354
      }
16✔
355
    }
24✔
356
    if (relationship === Relationship.OneOne) {
106✔
357
      if (selfKeyName === '__id') {
32✔
358
        queryBuilder.whereNotIn('__id', function () {
24✔
359
          this.select(foreignKeyName).from(fkHostTableName).whereNotNull(foreignKeyName);
60✔
360
        });
60✔
361
      } else {
32✔
362
        queryBuilder.where(selfKeyName, null);
8✔
363
      }
8✔
364
    }
32✔
365

366
    if (recordId) {
106✔
367
      const linkIds = await this.getLinkCellIds(fieldRaw.tableId, field, recordId);
80✔
368
      if (linkIds.length) {
80✔
369
        queryBuilder.whereNotIn('__id', linkIds);
30✔
370
      }
30✔
371
    }
80✔
372
  }
106✔
373

374
  private async getNecessaryFieldMap(
435✔
375
    tableId: string,
1,568✔
376
    filter?: IFilter,
1,568✔
377
    orderBy?: ISortItem[],
1,568✔
378
    groupBy?: IGroup,
1,568✔
379
    search?: [string, string?, boolean?],
1,568✔
380
    projection?: string[]
1,568✔
381
  ) {
1,568✔
382
    if (filter || orderBy?.length || groupBy?.length || search) {
1,568✔
383
      // The field Meta is needed to construct the filter if it exists
914✔
384
      const fields = await this.getFieldsByProjection(tableId, this.convertProjection(projection));
914✔
385
      return fields.reduce(
914✔
386
        (map, field) => {
914✔
387
          map[field.id] = field;
9,228✔
388
          map[field.name] = field;
9,228✔
389
          return map;
9,228✔
390
        },
9,228✔
391
        {} as Record<string, IFieldInstance>
914✔
392
      );
393
    }
914✔
394
  }
1,568✔
395

396
  private async getTinyView(tableId: string, viewId?: string) {
435✔
397
    if (!viewId) {
1,568✔
398
      return;
1,358✔
399
    }
1,358✔
400

401
    return this.prismaService
210✔
402
      .txClient()
210✔
403
      .view.findFirstOrThrow({
210✔
404
        select: { id: true, type: true, filter: true, sort: true, group: true, columnMeta: true },
210✔
405
        where: { tableId, id: viewId, deletedTime: null },
210✔
406
      })
210✔
407
      .catch(() => {
210✔
408
        throw new NotFoundException(`View ${viewId} not found`);
×
409
      });
×
410
  }
210✔
411

412
  public parseSearch(
435✔
413
    search: [string, string?, boolean?],
50✔
414
    fieldMap?: Record<string, IFieldInstance>
50✔
415
  ): [string, string?, boolean?] {
50✔
416
    const [searchValue, fieldId, hideNotMatchRow] = search;
50✔
417

418
    if (!fieldMap) {
50✔
419
      throw new Error('fieldMap is required when search is set');
×
420
    }
×
421

422
    if (!fieldId) {
50✔
423
      return [searchValue, fieldId, hideNotMatchRow];
6✔
424
    }
6✔
425

426
    const fieldIds = fieldId?.split(',');
50✔
427

428
    fieldIds.forEach((id) => {
50✔
429
      const field = fieldMap[id];
44✔
430
      if (!field) {
44✔
431
        throw new NotFoundException(`Field ${id} not found`);
×
432
      }
×
433
    });
44✔
434

435
    return [searchValue, fieldId, hideNotMatchRow];
50✔
436
  }
50✔
437

438
  async prepareQuery(
435✔
439
    tableId: string,
1,520✔
440
    query: Pick<
1,520✔
441
      IGetRecordsRo,
442
      | 'viewId'
443
      | 'orderBy'
444
      | 'groupBy'
445
      | 'filter'
446
      | 'search'
447
      | 'filterLinkCellSelected'
448
      | 'ignoreViewQuery'
449
    >
1,520✔
450
  ) {
1,520✔
451
    const viewId = query.ignoreViewQuery ? undefined : query.viewId;
1,520✔
452
    const {
1,520✔
453
      orderBy: extraOrderBy,
1,520✔
454
      groupBy: extraGroupBy,
1,520✔
455
      filter: extraFilter,
1,520✔
456
      search: originSearch,
1,520✔
457
    } = query;
1,520✔
458
    const dbTableName = await this.getDbTableName(tableId);
1,520✔
459
    const { viewCte, builder, enabledFieldIds } = await this.recordPermissionService.wrapView(
1,520✔
460
      tableId,
1,520✔
461
      this.knex.queryBuilder(),
1,520✔
462
      {
1,520✔
463
        viewId: query.viewId,
1,520✔
464
        keepPrimaryKey: Boolean(query.filterLinkCellSelected),
1,520✔
465
      }
1,520✔
466
    );
467

468
    const queryBuilder = builder.from(viewCte ?? dbTableName);
1,520✔
469

470
    const view = await this.getTinyView(tableId, viewId);
1,520✔
471

472
    const filter = mergeWithDefaultFilter(view?.filter, extraFilter);
1,520✔
473
    const orderBy = mergeWithDefaultSort(view?.sort, extraOrderBy);
1,520✔
474
    const groupBy = parseGroup(extraGroupBy);
1,520✔
475
    const fieldMap = await this.getNecessaryFieldMap(
1,520✔
476
      tableId,
1,520✔
477
      filter,
1,520✔
478
      orderBy,
1,520✔
479
      groupBy,
1,520✔
480
      originSearch,
1,520✔
481
      enabledFieldIds
1,520✔
482
    );
483

484
    const search = originSearch ? this.parseSearch(originSearch, fieldMap) : undefined;
1,520✔
485

486
    return {
1,520✔
487
      queryBuilder,
1,520✔
488
      dbTableName,
1,520✔
489
      viewCte,
1,520✔
490
      filter,
1,520✔
491
      search,
1,520✔
492
      orderBy,
1,520✔
493
      groupBy,
1,520✔
494
      fieldMap,
1,520✔
495
    };
1,520✔
496
  }
1,520✔
497

498
  async getBasicOrderIndexField(dbTableName: string, viewId: string | undefined) {
435✔
499
    const columnName = `${ROW_ORDER_FIELD_PREFIX}_${viewId}`;
1,476✔
500
    const exists = await this.dbProvider.checkColumnExist(
1,476✔
501
      dbTableName,
1,476✔
502
      columnName,
1,476✔
503
      this.prismaService.txClient()
1,476✔
504
    );
505

506
    if (exists) {
1,476✔
507
      return columnName;
52✔
508
    }
52✔
509
    return '__auto_number';
1,424✔
510
  }
1,424✔
511

512
  /**
435✔
513
   * Builds a query based on filtering and sorting criteria.
514
   *
515
   * This method creates a `Knex` query builder that constructs SQL queries based on the provided
516
   * filtering and sorting parameters. It also takes into account the context of the current user,
517
   * which is crucial for ensuring the security and relevance of data access.
518
   *
519
   * @param {string} tableId - The unique identifier of the table to determine the target of the query.
520
   * @param {Pick<IGetRecordsRo, 'viewId' | 'orderBy' | 'filter' | 'filterLinkCellCandidate'>} query - An object of query parameters, including view ID, sorting rules, filtering conditions, etc.
521
   * @returns {Promise<Knex.QueryBuilder>} Returns an instance of the Knex query builder encapsulating the constructed SQL query.
522
   */
435✔
523
  async buildFilterSortQuery(
435✔
524
    tableId: string,
1,520✔
525
    query: Pick<
1,520✔
526
      IGetRecordsRo,
527
      | 'viewId'
528
      | 'ignoreViewQuery'
529
      | 'orderBy'
530
      | 'groupBy'
531
      | 'filter'
532
      | 'search'
533
      | 'filterLinkCellCandidate'
534
      | 'filterLinkCellSelected'
535
      | 'collapsedGroupIds'
536
      | 'selectedRecordIds'
537
    >
1,520✔
538
  ) {
1,520✔
539
    // Prepare the base query builder, filtering conditions, sorting rules, grouping rules and field mapping
1,520✔
540
    const { dbTableName, queryBuilder, viewCte, filter, search, orderBy, groupBy, fieldMap } =
1,520✔
541
      await this.prepareQuery(tableId, query);
1,520✔
542

543
    // Retrieve the current user's ID to build user-related query conditions
1,520✔
544
    const currentUserId = this.cls.get('user.id');
1,520✔
545

546
    const viewQueryDbTableName = viewCte ?? dbTableName;
1,520✔
547

548
    if (query.filterLinkCellSelected && query.filterLinkCellCandidate) {
1,520✔
549
      throw new BadRequestException(
×
550
        'filterLinkCellSelected and filterLinkCellCandidate can not be set at the same time'
×
551
      );
552
    }
×
553

554
    if (query.selectedRecordIds) {
1,520✔
555
      query.filterLinkCellCandidate
4✔
556
        ? queryBuilder.whereNotIn(`${viewQueryDbTableName}.__id`, query.selectedRecordIds)
2✔
557
        : queryBuilder.whereIn(`${viewQueryDbTableName}.__id`, query.selectedRecordIds);
2✔
558
    }
4✔
559

560
    if (query.filterLinkCellCandidate) {
1,520✔
561
      await this.buildLinkCandidateQuery(queryBuilder, tableId, query.filterLinkCellCandidate);
80✔
562
    }
80✔
563

564
    if (query.filterLinkCellSelected) {
1,520✔
565
      await this.buildLinkSelectedQuery(
78✔
566
        queryBuilder,
78✔
567
        tableId,
78✔
568
        viewQueryDbTableName,
78✔
569
        query.filterLinkCellSelected
78✔
570
      );
571
    }
78✔
572

573
    // Add filtering conditions to the query builder
1,520✔
574
    this.dbProvider
1,520✔
575
      .filterQuery(queryBuilder, fieldMap, filter, { withUserId: currentUserId })
1,520✔
576
      .appendQueryBuilder();
1,520✔
577
    // Add sorting rules to the query builder
1,520✔
578
    this.dbProvider
1,520✔
579
      .sortQuery(queryBuilder, fieldMap, [...(groupBy ?? []), ...orderBy])
1,520✔
580
      .appendSortBuilder();
1,520✔
581

582
    if (search && search[2] && fieldMap) {
1,520✔
583
      const searchFields = await this.getSearchFields(fieldMap, search, query?.viewId);
44✔
584
      const tableIndex = await this.tableIndexService.getActivatedTableIndexes(tableId);
44✔
585
      queryBuilder.where((builder) => {
44✔
586
        this.dbProvider.searchQuery(builder, searchFields, tableIndex, search);
132✔
587
      });
132✔
588
    }
44✔
589

590
    // ignore sorting when filterLinkCellSelected is set
1,520✔
591
    if (query.filterLinkCellSelected && Array.isArray(query.filterLinkCellSelected)) {
1,520✔
592
      await this.buildLinkSelectedSort(
52✔
593
        queryBuilder,
52✔
594
        viewQueryDbTableName,
52✔
595
        query.filterLinkCellSelected
52✔
596
      );
597
    } else {
1,520✔
598
      const basicSortIndex = await this.getBasicOrderIndexField(dbTableName, query.viewId);
1,468✔
599
      // view sorting added by default
1,468✔
600
      queryBuilder.orderBy(`${viewQueryDbTableName}.${basicSortIndex}`, 'asc');
1,468✔
601
    }
1,468✔
602

603
    this.logger.debug('buildFilterSortQuery: %s', queryBuilder.toQuery());
1,520✔
604
    // If you return `queryBuilder` directly and use `await` to receive it,
1,520✔
605
    // it will perform a query DB operation, which we obviously don't want to see here
1,520✔
606
    return { queryBuilder, dbTableName, viewCte };
1,520✔
607
  }
1,520✔
608

609
  convertProjection(fieldKeys?: string[]) {
435✔
610
    return fieldKeys?.reduce<Record<string, boolean>>((acc, cur) => {
3,484✔
611
      acc[cur] = true;
533✔
612
      return acc;
533✔
613
    }, {});
3,484✔
614
  }
3,484✔
615

616
  async getRecordsById(
435✔
617
    tableId: string,
87✔
618
    recordIds: string[],
87✔
619
    withPermission = true
87✔
620
  ): Promise<IRecordsVo> {
87✔
621
    const recordSnapshot = await this[
87✔
622
      withPermission ? 'getSnapshotBulkWithPermission' : 'getSnapshotBulk'
87✔
623
    ](tableId, recordIds, undefined, FieldKeyType.Id);
87✔
624

625
    if (!recordSnapshot.length) {
87✔
626
      throw new NotFoundException('Can not get records');
×
627
    }
×
628

629
    return {
87✔
630
      records: recordSnapshot.map((r) => r.data),
87✔
631
    };
87✔
632
  }
87✔
633

634
  private async getViewProjection(
435✔
635
    tableId: string,
1,373✔
636
    query: IGetRecordsRo
1,373✔
637
  ): Promise<Record<string, boolean> | undefined> {
1,373✔
638
    const viewId = query.viewId;
1,373✔
639
    if (!viewId) {
1,373✔
640
      return;
1,219✔
641
    }
1,219✔
642

643
    const fieldKeyType = query.fieldKeyType || FieldKeyType.Name;
1,373✔
644
    const view = await this.prismaService.txClient().view.findFirstOrThrow({
1,373✔
645
      where: { id: viewId, deletedTime: null },
1,373✔
646
      select: { id: true, columnMeta: true },
1,373✔
647
    });
1,373✔
648

649
    const columnMeta = JSON.parse(view.columnMeta) as IColumnMeta;
154✔
650
    const useVisible = Object.values(columnMeta).some((column) => 'visible' in column);
154✔
651
    const useHidden = Object.values(columnMeta).some((column) => 'hidden' in column);
154✔
652

653
    if (!useVisible && !useHidden) {
1,373✔
654
      return;
150✔
655
    }
150✔
656

657
    const fieldRaws = await this.prismaService.txClient().field.findMany({
4✔
658
      where: { tableId, deletedTime: null },
4✔
659
      select: { id: true, name: true, dbFieldName: true },
4✔
660
    });
4✔
661

662
    const fieldMap = keyBy(fieldRaws, 'id');
4✔
663

664
    const projection = Object.entries(columnMeta).reduce<Record<string, boolean>>(
4✔
665
      (acc, [fieldId, column]) => {
4✔
666
        const field = fieldMap[fieldId];
30✔
667
        if (!field) return acc;
30✔
668

669
        const fieldKey = field[fieldKeyType];
30✔
670

671
        if (useVisible) {
30✔
672
          if ('visible' in column && column.visible) {
×
673
            acc[fieldKey] = true;
×
674
          }
×
675
        } else if (useHidden) {
30✔
676
          if (!('hidden' in column) || !column.hidden) {
30✔
677
            acc[fieldKey] = true;
26✔
678
          }
26✔
679
        } else {
30✔
680
          acc[fieldKey] = true;
×
681
        }
×
682

683
        return acc;
30✔
684
      },
30✔
685
      {}
4✔
686
    );
687

688
    return Object.keys(projection).length > 0 ? projection : undefined;
1,373✔
689
  }
1,373✔
690

691
  async getRecords(tableId: string, query: IGetRecordsRo): Promise<IRecordsVo> {
435✔
692
    const queryResult = await this.getDocIdsByQuery(tableId, {
1,393✔
693
      ignoreViewQuery: query.ignoreViewQuery ?? false,
1,393✔
694
      viewId: query.viewId,
1,393✔
695
      skip: query.skip,
1,393✔
696
      take: query.take,
1,393✔
697
      filter: query.filter,
1,393✔
698
      orderBy: query.orderBy,
1,393✔
699
      search: query.search,
1,393✔
700
      groupBy: query.groupBy,
1,393✔
701
      filterLinkCellCandidate: query.filterLinkCellCandidate,
1,393✔
702
      filterLinkCellSelected: query.filterLinkCellSelected,
1,393✔
703
      selectedRecordIds: query.selectedRecordIds,
1,393✔
704
    });
1,393✔
705

706
    const projection = query.projection
1,393✔
707
      ? this.convertProjection(query.projection)
20✔
708
      : await this.getViewProjection(tableId, query);
1,373✔
709

710
    const recordSnapshot = await this.getSnapshotBulkWithPermission(
1,373✔
711
      tableId,
1,373✔
712
      queryResult.ids,
1,373✔
713
      projection,
1,373✔
714
      query.fieldKeyType || FieldKeyType.Name,
1,393✔
715
      query.cellFormat
1,393✔
716
    );
717

718
    return {
1,393✔
719
      records: recordSnapshot.map((r) => r.data),
1,393✔
720
      extra: queryResult.extra,
1,393✔
721
    };
1,393✔
722
  }
1,393✔
723

724
  async getRecord(
435✔
725
    tableId: string,
726✔
726
    recordId: string,
726✔
727
    query: IGetRecordQuery,
726✔
728
    withPermission = true
726✔
729
  ): Promise<IRecord> {
726✔
730
    const { projection, fieldKeyType = FieldKeyType.Name, cellFormat } = query;
726✔
731
    const recordSnapshot = await this[
726✔
732
      withPermission ? 'getSnapshotBulkWithPermission' : 'getSnapshotBulk'
726✔
733
    ](tableId, [recordId], this.convertProjection(projection), fieldKeyType, cellFormat);
726✔
734

735
    if (!recordSnapshot.length) {
726✔
736
      throw new NotFoundException('Can not get record');
6✔
737
    }
6✔
738

739
    return recordSnapshot[0].data;
720✔
740
  }
720✔
741

742
  async getCellValue(tableId: string, recordId: string, fieldId: string) {
435✔
743
    const record = await this.getRecord(tableId, recordId, {
328✔
744
      projection: [fieldId],
328✔
745
      fieldKeyType: FieldKeyType.Id,
328✔
746
    });
328✔
747
    return record.fields[fieldId];
328✔
748
  }
328✔
749

750
  async getMaxRecordOrder(dbTableName: string) {
435✔
751
    const sqlNative = this.knex(dbTableName).max('__auto_number', { as: 'max' }).toSQL().toNative();
1,736✔
752

753
    const result = await this.prismaService
1,736✔
754
      .txClient()
1,736✔
755
      .$queryRawUnsafe<{ max?: number }[]>(sqlNative.sql, ...sqlNative.bindings);
1,736✔
756

757
    return Number(result[0]?.max ?? 0) + 1;
1,736✔
758
  }
1,736✔
759

760
  async batchDeleteRecords(tableId: string, recordIds: string[]) {
435✔
761
    const dbTableName = await this.getDbTableName(tableId);
77✔
762
    // get version by recordIds, __id as id, __version as version
77✔
763
    const nativeQuery = this.knex(dbTableName)
77✔
764
      .select('__id as id', '__version as version')
77✔
765
      .whereIn('__id', recordIds)
77✔
766
      .toQuery();
77✔
767
    const recordRaw = await this.prismaService
77✔
768
      .txClient()
77✔
769
      .$queryRawUnsafe<{ id: string; version: number }[]>(nativeQuery);
77✔
770

771
    if (recordIds.length !== recordRaw.length) {
77✔
772
      throw new BadRequestException('delete record not found');
×
773
    }
×
774

775
    const recordRawMap = keyBy(recordRaw, 'id');
77✔
776

777
    const dataList = recordIds.map((recordId) => ({
77✔
778
      docId: recordId,
152✔
779
      version: recordRawMap[recordId].version,
152✔
780
    }));
152✔
781

782
    await this.batchService.saveRawOps(tableId, RawOpType.Del, IdPrefix.Record, dataList);
77✔
783

784
    await this.batchDel(tableId, recordIds);
77✔
785
  }
77✔
786

787
  private async getViewIndexColumns(dbTableName: string) {
435✔
788
    const columnInfoQuery = this.dbProvider.columnInfo(dbTableName);
44✔
789
    const columns = await this.prismaService
44✔
790
      .txClient()
44✔
791
      .$queryRawUnsafe<{ name: string }[]>(columnInfoQuery);
44✔
792
    return columns
44✔
793
      .filter((column) => column.name.startsWith(ROW_ORDER_FIELD_PREFIX))
44✔
794
      .map((column) => column.name);
44✔
795
  }
44✔
796

797
  async getRecordIndexes(
435✔
798
    tableId: string,
24✔
799
    recordIds: string[],
24✔
800
    viewId?: string
24✔
801
  ): Promise<Record<string, number>[] | undefined> {
24✔
802
    const dbTableName = await this.getDbTableName(tableId);
24✔
803
    const allViewIndexColumns = await this.getViewIndexColumns(dbTableName);
24✔
804
    const viewIndexColumns = viewId
24✔
805
      ? (() => {
8✔
806
          const viewIndexColumns = allViewIndexColumns.filter((column) => column.endsWith(viewId));
8✔
807
          return viewIndexColumns.length === 0 ? ['__auto_number'] : viewIndexColumns;
8✔
808
        })()
8✔
809
      : allViewIndexColumns;
16✔
810

811
    if (!viewIndexColumns.length) {
24✔
812
      return;
2✔
813
    }
2✔
814

815
    // get all viewIndexColumns value for __id in recordIds
22✔
816
    const indexQuery = this.knex(dbTableName)
22✔
817
      .select(
22✔
818
        viewIndexColumns.reduce<Record<string, string>>((acc, columnName) => {
22✔
819
          if (columnName === '__auto_number') {
22✔
820
            acc[viewId as string] = '__auto_number';
4✔
821
            return acc;
4✔
822
          }
4✔
823
          const theViewId = columnName.substring(ROW_ORDER_FIELD_PREFIX.length + 1);
18✔
824
          acc[theViewId] = columnName;
18✔
825
          return acc;
18✔
826
        }, {})
22✔
827
      )
828
      .select('__id')
22✔
829
      .whereIn('__id', recordIds)
22✔
830
      .toQuery();
22✔
831
    const indexValues = await this.prismaService
22✔
832
      .txClient()
22✔
833
      .$queryRawUnsafe<Record<string, number>[]>(indexQuery);
22✔
834

835
    const indexMap = indexValues.reduce<Record<string, Record<string, number>>>((map, cur) => {
22✔
836
      const id = cur.__id;
22✔
837
      delete cur.__id;
22✔
838
      map[id] = cur;
22✔
839
      return map;
22✔
840
    }, {});
22✔
841

842
    return recordIds.map((recordId) => indexMap[recordId]);
22✔
843
  }
22✔
844

845
  async updateRecordIndexes(
435✔
846
    tableId: string,
20✔
847
    recordsWithOrder: {
20✔
848
      id: string;
849
      order?: Record<string, number>;
850
    }[]
20✔
851
  ) {
20✔
852
    const dbTableName = await this.getDbTableName(tableId);
20✔
853
    const viewIndexColumns = await this.getViewIndexColumns(dbTableName);
20✔
854
    if (!viewIndexColumns.length) {
20✔
855
      return;
12✔
856
    }
12✔
857

858
    const updateRecordSqls = recordsWithOrder
8✔
859
      .map((record) => {
8✔
860
        const order = record.order;
8✔
861
        const orderFields = viewIndexColumns.reduce<Record<string, number>>((acc, columnName) => {
8✔
862
          const viewId = columnName.substring(ROW_ORDER_FIELD_PREFIX.length + 1);
8✔
863
          const index = order?.[viewId];
8✔
864
          if (index != null) {
8✔
865
            acc[columnName] = index;
8✔
866
          }
8✔
867
          return acc;
8✔
868
        }, {});
8✔
869

870
        if (!order || Object.keys(orderFields).length === 0) {
8✔
871
          return;
×
872
        }
×
873

874
        return this.knex(dbTableName).update(orderFields).where('__id', record.id).toQuery();
8✔
875
      })
8✔
876
      .filter(Boolean) as string[];
8✔
877

878
    for (const sql of updateRecordSqls) {
8✔
879
      await this.prismaService.txClient().$executeRawUnsafe(sql);
8✔
880
    }
8✔
881
  }
8✔
882

883
  @Timing()
435✔
884
  async batchCreateRecords(
1,740✔
885
    tableId: string,
1,740✔
886
    records: IRecordInnerRo[],
1,740✔
887
    fieldKeyType: FieldKeyType,
1,740✔
888
    fieldRaws: IFieldRaws
1,740✔
889
  ) {
1,740✔
890
    const snapshots = await this.createBatch(tableId, records, fieldKeyType, fieldRaws);
1,740✔
891

892
    const dataList = snapshots.map((snapshot) => ({
1,733✔
893
      docId: snapshot.__id,
9,209✔
894
      version: snapshot.__version == null ? 0 : snapshot.__version - 1,
9,209✔
895
    }));
9,209✔
896

897
    await this.batchService.saveRawOps(tableId, RawOpType.Create, IdPrefix.Record, dataList);
1,733✔
898
  }
1,733✔
899

900
  @Timing()
435✔
901
  async createRecordsOnlySql(
6✔
902
    tableId: string,
6✔
903
    records: {
6✔
904
      fields: Record<string, unknown>;
905
    }[]
6✔
906
  ) {
6✔
907
    const userId = this.cls.get('user.id');
6✔
908
    await this.creditCheck(tableId);
6✔
909
    const dbTableName = await this.getDbTableName(tableId);
6✔
910
    const fields = await this.getFieldsByProjection(tableId);
6✔
911
    const fieldInstanceMap = fields.reduce(
6✔
912
      (map, curField) => {
6✔
913
        map[curField.id] = curField;
36✔
914
        return map;
36✔
915
      },
36✔
916
      {} as Record<string, IFieldInstance>
6✔
917
    );
918

919
    const newRecords = records.map((record) => {
6✔
920
      const fieldsValues: Record<string, unknown> = {};
12✔
921
      Object.entries(record.fields).forEach(([fieldId, value]) => {
12✔
922
        const fieldInstance = fieldInstanceMap[fieldId];
60✔
923
        fieldsValues[fieldInstance.dbFieldName] = fieldInstance.convertCellValue2DBValue(value);
60✔
924
      });
60✔
925
      return {
12✔
926
        __id: generateRecordId(),
12✔
927
        __created_by: userId,
12✔
928
        __version: 1,
12✔
929
        ...fieldsValues,
12✔
930
      };
12✔
931
    });
12✔
932
    const sql = this.dbProvider.batchInsertSql(dbTableName, newRecords);
6✔
933
    await this.prismaService.txClient().$executeRawUnsafe(sql);
6✔
934
  }
6✔
935

936
  async creditCheck(tableId: string) {
435✔
937
    if (!this.thresholdConfig.maxFreeRowLimit) {
1,746✔
938
      return;
1,734✔
939
    }
1,734✔
940

941
    const table = await this.prismaService.txClient().tableMeta.findFirstOrThrow({
12✔
942
      where: { id: tableId, deletedTime: null },
12✔
943
      select: { dbTableName: true, base: { select: { space: { select: { credit: true } } } } },
12✔
944
    });
12✔
945

946
    const rowCount = await this.getAllRecordCount(table.dbTableName);
12✔
947

948
    const maxRowCount =
12✔
949
      table.base.space.credit == null
12✔
950
        ? this.thresholdConfig.maxFreeRowLimit
12✔
951
        : table.base.space.credit;
1,746✔
952

953
    if (rowCount >= maxRowCount) {
1,746✔
954
      this.logger.log(`Exceed row count: ${maxRowCount}`, 'creditCheck');
4✔
955
      throw new BadRequestException(
4✔
956
        `Exceed max row limit: ${maxRowCount}, please contact us to increase the limit`
4✔
957
      );
958
    }
4✔
959
  }
1,746✔
960

961
  private async getAllViewIndexesField(dbTableName: string) {
435✔
962
    const query = this.dbProvider.columnInfo(dbTableName);
1,736✔
963
    const columns = await this.prismaService.txClient().$queryRawUnsafe<{ name: string }[]>(query);
1,736✔
964
    return columns
1,736✔
965
      .filter((column) => column.name.startsWith(ROW_ORDER_FIELD_PREFIX))
1,736✔
966
      .map((column) => column.name)
1,736✔
967
      .reduce<{ [viewId: string]: string }>((acc, cur) => {
1,736✔
968
        const viewId = cur.substring(ROW_ORDER_FIELD_PREFIX.length + 1);
24✔
969
        acc[viewId] = cur;
24✔
970
        return acc;
24✔
971
      }, {});
1,736✔
972
  }
1,736✔
973

974
  private async createBatch(
435✔
975
    tableId: string,
1,740✔
976
    records: IRecordInnerRo[],
1,740✔
977
    fieldKeyType: FieldKeyType,
1,740✔
978
    fieldRaws: IFieldRaws
1,740✔
979
  ) {
1,740✔
980
    const userId = this.cls.get('user.id');
1,740✔
981
    await this.creditCheck(tableId);
1,740✔
982

983
    const { dbTableName, name: tableName } = await this.prismaService
1,736✔
984
      .txClient()
1,736✔
985
      .tableMeta.findUniqueOrThrow({
1,736✔
986
        where: { id: tableId },
1,736✔
987
        select: { dbTableName: true, name: true },
1,736✔
988
      })
1,736✔
989
      .catch(() => {
1,736✔
990
        throw new NotFoundException(`Table ${tableId} not found`);
×
991
      });
×
992

993
    const maxRecordOrder = await this.getMaxRecordOrder(dbTableName);
1,736✔
994

995
    const views = await this.prismaService.txClient().view.findMany({
1,736✔
996
      where: { tableId, deletedTime: null },
1,736✔
997
      select: { id: true },
1,736✔
998
    });
1,736✔
999

1000
    const allViewIndexes = await this.getAllViewIndexesField(dbTableName);
1,736✔
1001

1002
    const validationFields = fieldRaws.filter((field) => field.notNull || field.unique);
1,736✔
1003

1004
    const snapshots = records
1,736✔
1005
      .map((record, i) =>
1,736✔
1006
        views.reduce<{ [viewIndexFieldName: string]: number }>((pre, cur) => {
9,212✔
1007
          const viewIndexFieldName = allViewIndexes[cur.id];
9,416✔
1008
          const recordViewIndex = record.order?.[cur.id];
9,416✔
1009
          if (!viewIndexFieldName) {
9,416✔
1010
            return pre;
9,392✔
1011
          }
9,392✔
1012
          if (recordViewIndex) {
24✔
1013
            pre[viewIndexFieldName] = recordViewIndex;
24✔
1014
          } else {
668✔
1015
            pre[viewIndexFieldName] = maxRecordOrder + i;
×
1016
          }
✔
1017
          return pre;
24✔
1018
        }, {})
9,212✔
1019
      )
1020
      .map((order, i) => {
1,736✔
1021
        const snapshot = records[i];
9,212✔
1022
        const fields = snapshot.fields;
9,212✔
1023

1024
        const dbFieldValueMap = validationFields.reduce(
9,212✔
1025
          (map, field) => {
9,212✔
1026
            const dbFieldName = field.dbFieldName;
10✔
1027
            const fieldKey = field[fieldKeyType];
10✔
1028
            const cellValue = fields[fieldKey];
10✔
1029

1030
            map[dbFieldName] = cellValue;
10✔
1031
            return map;
10✔
1032
          },
10✔
1033
          {} as Record<string, unknown>
9,212✔
1034
        );
1035

1036
        return removeUndefined({
9,212✔
1037
          __id: snapshot.id,
9,212✔
1038
          __created_by: snapshot.createdBy || userId,
9,212✔
1039
          __last_modified_by: snapshot.lastModifiedBy || undefined,
9,212✔
1040
          __created_time: snapshot.createdTime || undefined,
9,212✔
1041
          __last_modified_time: snapshot.lastModifiedTime || undefined,
9,212✔
1042
          __auto_number: snapshot.autoNumber == null ? undefined : snapshot.autoNumber,
9,212✔
1043
          __version: 1,
9,212✔
1044
          ...order,
9,212✔
1045
          ...dbFieldValueMap,
9,212✔
1046
        });
9,212✔
1047
      });
9,212✔
1048

1049
    const sql = this.dbProvider.batchInsertSql(
1,736✔
1050
      dbTableName,
1,736✔
1051
      snapshots.map((s) => {
1,736✔
1052
        return Object.entries(s).reduce(
9,212✔
1053
          (acc, [key, value]) => {
9,212✔
1054
            acc[key] = Array.isArray(value) ? JSON.stringify(value) : value;
27,735✔
1055
            return acc;
27,735✔
1056
          },
27,735✔
1057
          {} as Record<string, unknown>
9,212✔
1058
        );
1059
      })
9,212✔
1060
    );
1061

1062
    await handleDBValidationErrors({
1,736✔
1063
      fn: () => this.prismaService.txClient().$executeRawUnsafe(sql),
1,736✔
1064
      handleUniqueError: () => {
1,736✔
1065
        throw new CustomHttpException(
2✔
1066
          `Fields ${validationFields.map((f) => f.id).join(', ')} unique validation failed`,
2✔
1067
          HttpErrorCode.VALIDATION_ERROR,
2✔
1068
          {
2✔
1069
            localization: {
2✔
1070
              i18nKey: 'httpErrors.custom.fieldValueDuplicate',
2✔
1071
              context: {
2✔
1072
                tableName,
2✔
1073
                fieldName: validationFields.map((f) => f.name).join(', '),
2✔
1074
              },
2✔
1075
            },
2✔
1076
          }
2✔
1077
        );
1078
      },
2✔
1079
      handleNotNullError: () => {
1,736✔
1080
        throw new CustomHttpException(
1✔
1081
          `Fields ${validationFields.map((f) => f.id).join(', ')} not null validation failed`,
1✔
1082
          HttpErrorCode.VALIDATION_ERROR,
1✔
1083
          {
1✔
1084
            localization: {
1✔
1085
              i18nKey: 'httpErrors.custom.fieldValueNotNull',
1✔
1086
              context: {
1✔
1087
                tableName,
1✔
1088
                fieldName: validationFields.map((f) => f.name).join(', '),
1✔
1089
              },
1✔
1090
            },
1✔
1091
          }
1✔
1092
        );
1093
      },
1✔
1094
    });
1,736✔
1095

1096
    return snapshots;
1,733✔
1097
  }
1,733✔
1098

1099
  private async batchDel(tableId: string, recordIds: string[]) {
435✔
1100
    const dbTableName = await this.getDbTableName(tableId);
77✔
1101

1102
    const nativeQuery = this.knex(dbTableName).whereIn('__id', recordIds).del().toQuery();
77✔
1103
    await this.prismaService.txClient().$executeRawUnsafe(nativeQuery);
77✔
1104
  }
77✔
1105

1106
  public async getFieldsByProjection(
435✔
1107
    tableId: string,
8,837✔
1108
    projection?: { [fieldNameOrId: string]: boolean },
8,837✔
1109
    fieldKeyType: FieldKeyType = FieldKeyType.Id
8,837✔
1110
  ) {
8,837✔
1111
    const whereParams: Prisma.FieldWhereInput = {};
8,837✔
1112
    if (projection) {
8,837✔
1113
      const projectionFieldKeys = Object.entries(projection)
461✔
1114
        .filter(([, v]) => v)
461✔
1115
        .map(([k]) => k);
461✔
1116
      if (projectionFieldKeys.length) {
461✔
1117
        whereParams[fieldKeyType] = { in: projectionFieldKeys };
453✔
1118
      }
453✔
1119
    }
461✔
1120

1121
    const fields = await this.prismaService.txClient().field.findMany({
8,837✔
1122
      where: { tableId, ...whereParams, deletedTime: null },
8,837✔
1123
    });
8,837✔
1124

1125
    return fields.map((field) => createFieldInstanceByRaw(field));
8,837✔
1126
  }
8,837✔
1127

1128
  private async getCachePreviewUrlTokenMap(
435✔
1129
    records: ISnapshotBase<IRecord>[],
7,798✔
1130
    fields: IFieldInstance[],
7,798✔
1131
    fieldKeyType: FieldKeyType
7,798✔
1132
  ) {
7,798✔
1133
    const previewToken: string[] = [];
7,798✔
1134
    for (const field of fields) {
7,798✔
1135
      if (field.type === FieldType.Attachment) {
41,167✔
1136
        const fieldKey = field[fieldKeyType];
1,156✔
1137
        for (const record of records) {
1,156✔
1138
          const cellValue = record.data.fields[fieldKey];
1,192✔
1139
          if (cellValue == null) continue;
1,192✔
1140
          (cellValue as IAttachmentCellValue).forEach((item) => {
56✔
1141
            if (item.mimetype.startsWith('image/') && item.width && item.height) {
86✔
1142
              const { smThumbnailPath, lgThumbnailPath } = generateTableThumbnailPath(item.path);
6✔
1143
              previewToken.push(getTableThumbnailToken(smThumbnailPath));
6✔
1144
              previewToken.push(getTableThumbnailToken(lgThumbnailPath));
6✔
1145
            }
6✔
1146
            previewToken.push(item.token);
86✔
1147
          });
86✔
1148
        }
56✔
1149
      }
1,156✔
1150
    }
41,167✔
1151
    // limit 1000 one handle
7,798✔
1152
    const tokenMap: Record<string, string> = {};
7,798✔
1153
    for (let i = 0; i < previewToken.length; i += 1000) {
7,798✔
1154
      const tokenBatch = previewToken.slice(i, i + 1000);
42✔
1155
      const previewUrls = await this.cacheService.getMany(
42✔
1156
        tokenBatch.map((token) => `attachment:preview:${token}` as const)
42✔
1157
      );
1158
      previewUrls.forEach((url, index) => {
42✔
1159
        if (url) {
98✔
1160
          tokenMap[previewToken[i + index]] = url.url;
88✔
1161
        }
88✔
1162
      });
98✔
1163
    }
42✔
1164
    return tokenMap;
42✔
1165
  }
42✔
1166

1167
  private async getThumbnailPathTokenMap(
435✔
1168
    records: ISnapshotBase<IRecord>[],
7,798✔
1169
    fields: IFieldInstance[],
7,798✔
1170
    fieldKeyType: FieldKeyType
7,798✔
1171
  ) {
7,798✔
1172
    const thumbnailTokens: string[] = [];
7,798✔
1173
    for (const field of fields) {
7,798✔
1174
      if (field.type === FieldType.Attachment) {
41,167✔
1175
        const fieldKey = field[fieldKeyType];
1,156✔
1176
        for (const record of records) {
1,156✔
1177
          const cellValue = record.data.fields[fieldKey];
1,192✔
1178
          if (cellValue == null) continue;
1,192✔
1179
          (cellValue as IAttachmentCellValue).forEach((item) => {
56✔
1180
            if (item.mimetype.startsWith('image/') && item.width && item.height) {
86✔
1181
              thumbnailTokens.push(getTableThumbnailToken(item.token));
6✔
1182
            }
6✔
1183
          });
86✔
1184
        }
56✔
1185
      }
1,156✔
1186
    }
41,167✔
1187
    const attachments = await this.prismaService.txClient().attachments.findMany({
7,798✔
1188
      where: { token: { in: thumbnailTokens } },
7,798✔
1189
      select: { token: true, thumbnailPath: true },
7,798✔
1190
    });
7,798✔
1191
    return attachments.reduce<
7,798✔
1192
      Record<
1193
        string,
1194
        | {
1195
            sm?: string;
1196
            lg?: string;
1197
          }
1198
        | undefined
1199
      >
1200
    >((acc, cur) => {
7,798✔
1201
      acc[cur.token] = cur.thumbnailPath ? JSON.parse(cur.thumbnailPath) : undefined;
6✔
1202
      return acc;
6✔
1203
    }, {});
7,798✔
1204
  }
7,798✔
1205

1206
  @Timing()
435✔
1207
  private async recordsPresignedUrl(
7,798✔
1208
    records: ISnapshotBase<IRecord>[],
7,798✔
1209
    fields: IFieldInstance[],
7,798✔
1210
    fieldKeyType: FieldKeyType
7,798✔
1211
  ) {
7,798✔
1212
    const cacheTokenUrlMap = await this.getCachePreviewUrlTokenMap(records, fields, fieldKeyType);
7,798✔
1213
    const thumbnailPathTokenMap = await this.getThumbnailPathTokenMap(
7,798✔
1214
      records,
7,798✔
1215
      fields,
7,798✔
1216
      fieldKeyType
7,798✔
1217
    );
1218
    for (const field of fields) {
7,798✔
1219
      if (field.type === FieldType.Attachment) {
41,167✔
1220
        const fieldKey = field[fieldKeyType];
1,156✔
1221
        for (const record of records) {
1,156✔
1222
          const cellValue = record.data.fields[fieldKey];
1,192✔
1223
          const presignedCellValue = await this.getAttachmentPresignedCellValue(
1,192✔
1224
            cellValue as IAttachmentCellValue,
1,192✔
1225
            cacheTokenUrlMap,
1,192✔
1226
            thumbnailPathTokenMap
1,192✔
1227
          );
1228
          if (presignedCellValue == null) continue;
1,192✔
1229

1230
          record.data.fields[fieldKey] = presignedCellValue;
56✔
1231
        }
56✔
1232
      }
1,156✔
1233
    }
41,167✔
1234
    return records;
7,798✔
1235
  }
7,798✔
1236

1237
  async getAttachmentPresignedCellValue(
435✔
1238
    cellValue: IAttachmentCellValue | null,
1,192✔
1239
    cacheTokenUrlMap?: Record<string, string>,
1,192✔
1240
    thumbnailPathTokenMap?: Record<string, { sm?: string; lg?: string } | undefined>
1,192✔
1241
  ) {
1,192✔
1242
    if (cellValue == null) {
1,192✔
1243
      return null;
1,136✔
1244
    }
1,136✔
1245

1246
    return await Promise.all(
56✔
1247
      cellValue.map(async (item) => {
56✔
1248
        const { path, mimetype, token } = item;
86✔
1249
        const presignedUrl =
86✔
1250
          cacheTokenUrlMap?.[token] ??
86✔
1251
          (await this.attachmentStorageService.getPreviewUrlByPath(
×
1252
            StorageAdapter.getBucket(UploadType.Table),
×
1253
            path,
×
1254
            token,
×
1255
            undefined,
×
1256
            {
×
1257
              'Content-Type': mimetype,
×
1258
              'Content-Disposition': `attachment; filename="${item.name}"`,
×
1259
            }
✔
1260
          ));
1261
        let smThumbnailUrl: string | undefined;
×
1262
        let lgThumbnailUrl: string | undefined;
×
1263
        if (thumbnailPathTokenMap && thumbnailPathTokenMap[token]) {
86✔
1264
          const { sm: smThumbnailPath, lg: lgThumbnailPath } = thumbnailPathTokenMap[token]!;
4✔
1265
          if (smThumbnailPath) {
4✔
1266
            smThumbnailUrl =
4✔
1267
              cacheTokenUrlMap?.[getTableThumbnailToken(smThumbnailPath)] ??
4✔
1268
              (await this.attachmentStorageService.getTableThumbnailUrl(smThumbnailPath, mimetype));
2✔
1269
          }
2✔
1270
          if (lgThumbnailPath) {
4✔
1271
            lgThumbnailUrl =
×
1272
              cacheTokenUrlMap?.[getTableThumbnailToken(lgThumbnailPath)] ??
×
1273
              (await this.attachmentStorageService.getTableThumbnailUrl(lgThumbnailPath, mimetype));
×
1274
          }
×
1275
        }
4✔
1276
        const isImage = mimetype.startsWith('image/');
86✔
1277
        return {
86✔
1278
          ...item,
86✔
1279
          presignedUrl,
86✔
1280
          smThumbnailUrl: isImage ? smThumbnailUrl || presignedUrl : undefined,
86✔
1281
          lgThumbnailUrl: isImage ? lgThumbnailUrl || presignedUrl : undefined,
86✔
1282
        };
86✔
1283
      })
86✔
1284
    );
1285
  }
56✔
1286

1287
  private async getSnapshotBulkInner(
435✔
1288
    builder: Knex.QueryBuilder,
7,804✔
1289
    viewQueryDbTableName: string,
7,804✔
1290
    query: {
7,804✔
1291
      tableId: string;
1292
      recordIds: string[];
1293
      projection?: { [fieldNameOrId: string]: boolean };
1294
      fieldKeyType: FieldKeyType;
1295
      cellFormat: CellFormat;
1296
    }
7,804✔
1297
  ): Promise<ISnapshotBase<IRecord>[]> {
7,804✔
1298
    const { tableId, recordIds, projection, fieldKeyType, cellFormat } = query;
7,804✔
1299
    const fields = await this.getFieldsByProjection(tableId, projection, fieldKeyType);
7,804✔
1300
    const fieldNames = fields.map((f) => f.dbFieldName).concat(Array.from(preservedDbFieldNames));
7,804✔
1301
    const nativeQuery = builder
7,804✔
1302
      .from(viewQueryDbTableName)
7,804✔
1303
      .select(fieldNames)
7,804✔
1304
      .whereIn('__id', recordIds)
7,804✔
1305
      .toQuery();
7,804✔
1306

1307
    const result = await this.prismaService
7,804✔
1308
      .txClient()
7,804✔
1309
      .$queryRawUnsafe<
7,804✔
1310
        ({ [fieldName: string]: unknown } & IVisualTableDefaultField)[]
1311
      >(nativeQuery);
7,804✔
1312

1313
    const recordIdsMap = recordIds.reduce(
7,804✔
1314
      (acc, recordId, currentIndex) => {
7,804✔
1315
        acc[recordId] = currentIndex;
25,276✔
1316
        return acc;
25,276✔
1317
      },
25,276✔
1318
      {} as { [recordId: string]: number }
7,804✔
1319
    );
1320

1321
    recordIds.forEach((recordId) => {
7,804✔
1322
      if (!(recordId in recordIdsMap)) {
25,276✔
1323
        throw new NotFoundException(`Record ${recordId} not found`);
×
1324
      }
×
1325
    });
25,276✔
1326

1327
    const primaryFieldRaw = await this.prismaService.txClient().field.findFirstOrThrow({
7,804✔
1328
      where: { tableId, isPrimary: true, deletedTime: null },
7,804✔
1329
    });
7,804✔
1330

1331
    const primaryField = createFieldInstanceByRaw(primaryFieldRaw);
7,804✔
1332
    const snapshots = result
7,804✔
1333
      .sort((a, b) => {
7,804✔
1334
        return recordIdsMap[a.__id] - recordIdsMap[b.__id];
45,667✔
1335
      })
45,667✔
1336
      .map((record) => {
7,804✔
1337
        const recordFields = this.dbRecord2RecordFields(record, fields, fieldKeyType, cellFormat);
25,270✔
1338
        const name = recordFields[primaryField[fieldKeyType]];
25,270✔
1339
        return {
25,270✔
1340
          id: record.__id,
25,270✔
1341
          v: record.__version,
25,270✔
1342
          type: 'json0',
25,270✔
1343
          data: {
25,270✔
1344
            fields: recordFields,
25,270✔
1345
            name:
25,270✔
1346
              cellFormat === CellFormat.Text
25,270✔
1347
                ? (name as string)
12✔
1348
                : primaryField.cellValue2String(name),
25,258✔
1349
            id: record.__id,
25,270✔
1350
            autoNumber: record.__auto_number,
25,270✔
1351
            createdTime: record.__created_time?.toISOString(),
25,270✔
1352
            lastModifiedTime: record.__last_modified_time?.toISOString(),
25,270✔
1353
            createdBy: record.__created_by,
25,270✔
1354
            lastModifiedBy: record.__last_modified_by || undefined,
25,270✔
1355
          },
25,270✔
1356
        };
25,270✔
1357
      });
25,270✔
1358
    if (cellFormat === CellFormat.Json) {
7,804✔
1359
      return await this.recordsPresignedUrl(snapshots, fields, fieldKeyType);
7,798✔
1360
    }
7,798✔
1361
    return snapshots;
6✔
1362
  }
6✔
1363

1364
  async getSnapshotBulkWithPermission(
435✔
1365
    tableId: string,
6,382✔
1366
    recordIds: string[],
6,382✔
1367
    projection?: { [fieldNameOrId: string]: boolean },
6,382✔
1368
    fieldKeyType: FieldKeyType = FieldKeyType.Id, // for convince of collaboration, getSnapshotBulk use id as field key by default.
6,382✔
1369
    cellFormat = CellFormat.Json
6,382✔
1370
  ) {
6,382✔
1371
    const dbTableName = await this.getDbTableName(tableId);
6,382✔
1372
    const { viewCte, builder } = await this.recordPermissionService.wrapView(
6,382✔
1373
      tableId,
6,382✔
1374
      this.knex.queryBuilder(),
6,382✔
1375
      {
6,382✔
1376
        keepPrimaryKey: true,
6,382✔
1377
      }
6,382✔
1378
    );
1379
    const viewQueryDbTableName = viewCte ?? dbTableName;
6,382✔
1380
    return this.getSnapshotBulkInner(builder, viewQueryDbTableName, {
6,382✔
1381
      tableId,
6,382✔
1382
      recordIds,
6,382✔
1383
      projection,
6,382✔
1384
      fieldKeyType,
6,382✔
1385
      cellFormat,
6,382✔
1386
    });
6,382✔
1387
  }
6,382✔
1388

1389
  async getSnapshotBulk(
435✔
1390
    tableId: string,
1,422✔
1391
    recordIds: string[],
1,422✔
1392
    projection?: { [fieldNameOrId: string]: boolean },
1,422✔
1393
    fieldKeyType: FieldKeyType = FieldKeyType.Id, // for convince of collaboration, getSnapshotBulk use id as field key by default.
1,422✔
1394
    cellFormat = CellFormat.Json
1,422✔
1395
  ): Promise<ISnapshotBase<IRecord>[]> {
1,422✔
1396
    const dbTableName = await this.getDbTableName(tableId);
1,422✔
1397
    return this.getSnapshotBulkInner(this.knex.queryBuilder(), dbTableName, {
1,422✔
1398
      tableId,
1,422✔
1399
      recordIds,
1,422✔
1400
      projection,
1,422✔
1401
      fieldKeyType,
1,422✔
1402
      cellFormat,
1,422✔
1403
    });
1,422✔
1404
  }
1,422✔
1405

1406
  async getDocIdsByQuery(
435✔
1407
    tableId: string,
1,405✔
1408
    query: IGetRecordsRo
1,405✔
1409
  ): Promise<{ ids: string[]; extra?: IExtraResult }> {
1,405✔
1410
    const { skip, take = 100, ignoreViewQuery } = query;
1,405✔
1411

1412
    if (identify(tableId) !== IdPrefix.Table) {
1,405✔
1413
      throw new InternalServerErrorException('query collection must be table id');
×
1414
    }
×
1415

1416
    if (take > 1000) {
1,405✔
1417
      throw new BadRequestException(`limit can't be greater than ${take}`);
×
1418
    }
×
1419

1420
    const viewId = ignoreViewQuery ? undefined : query.viewId;
1,405✔
1421
    const {
1,405✔
1422
      groupPoints,
1,405✔
1423
      allGroupHeaderRefs,
1,405✔
1424
      filter: filterWithGroup,
1,405✔
1425
    } = await this.getGroupRelatedData(tableId, {
1,405✔
1426
      ...query,
1,405✔
1427
      viewId,
1,405✔
1428
    });
1,405✔
1429
    const { queryBuilder, dbTableName, viewCte } = await this.buildFilterSortQuery(tableId, {
1,405✔
1430
      ...query,
1,405✔
1431
      filter: filterWithGroup,
1,405✔
1432
    });
1,405✔
1433
    const selectDbTableName = viewCte ?? dbTableName;
1,405✔
1434

1435
    queryBuilder.select(this.knex.ref(`${selectDbTableName}.__id`));
1,405✔
1436

1437
    skip && queryBuilder.offset(skip);
1,405✔
1438
    if (take !== -1) {
1,405✔
1439
      queryBuilder.limit(take);
1,401✔
1440
    }
1,401✔
1441

1442
    this.logger.debug('getRecordsQuery: %s', queryBuilder.toQuery());
1,405✔
1443
    const result = await this.prismaService
1,405✔
1444
      .txClient()
1,405✔
1445
      .$queryRawUnsafe<{ __id: string }[]>(queryBuilder.toQuery());
1,405✔
1446
    const ids = result.map((r) => r.__id);
1,405✔
1447

1448
    const {
1,405✔
1449
      builder: searchWrapBuilder,
1,405✔
1450
      viewCte: searchViewCte,
1,405✔
1451
      enabledFieldIds,
1,405✔
1452
    } = await this.recordPermissionService.wrapView(tableId, this.knex.queryBuilder(), {
1,405✔
1453
      keepPrimaryKey: Boolean(query.filterLinkCellSelected),
1,405✔
1454
      viewId,
1,405✔
1455
    });
1,405✔
1456
    // this search step should not abort the query
1,405✔
1457
    const searchBuilder = searchViewCte
1,405✔
1458
      ? searchWrapBuilder.from(searchViewCte)
×
1459
      : this.knex(dbTableName);
1,405✔
1460
    try {
1,405✔
1461
      const searchHitIndex = await this.getSearchHitIndex(
1,405✔
1462
        tableId,
1,405✔
1463
        {
1,405✔
1464
          ...query,
1,405✔
1465
          projection: query.projection
1,405✔
1466
            ? query.projection.filter((id) => enabledFieldIds?.includes(id))
✔
1467
            : enabledFieldIds,
1,405✔
1468
          viewId,
1,405✔
1469
        },
1,405✔
1470
        searchBuilder.whereIn('__id', ids),
1,405✔
1471
        enabledFieldIds
1,405✔
1472
      );
1473
      return { ids, extra: { groupPoints, searchHitIndex, allGroupHeaderRefs } };
1,405✔
1474
    } catch (e) {
1,405✔
1475
      this.logger.error(`Get search index error: ${(e as Error).message}`, (e as Error)?.stack);
×
1476
    }
×
1477

NEW
1478
    return { ids, extra: { groupPoints, allGroupHeaderRefs } };
×
1479
  }
×
1480

1481
  async getSearchFields(
435✔
1482
    originFieldInstanceMap: Record<string, IFieldInstance>,
430✔
1483
    search?: [string, string?, boolean?],
430✔
1484
    viewId?: string,
430✔
1485
    projection?: string[]
430✔
1486
  ) {
430✔
1487
    const maxSearchFieldCount = process.env.MAX_SEARCH_FIELD_COUNT
430✔
1488
      ? toNumber(process.env.MAX_SEARCH_FIELD_COUNT)
✔
1489
      : 20;
430✔
1490
    let viewColumnMeta: IGridColumnMeta | null = null;
430✔
1491
    const fieldInstanceMap = projection?.length === 0 ? {} : { ...originFieldInstanceMap };
430✔
1492
    if (!search) {
430✔
1493
      return [] as IFieldInstance[];
334✔
1494
    }
334✔
1495

1496
    const isSearchAllFields = !search?.[1];
430✔
1497

1498
    if (viewId) {
430✔
1499
      const { columnMeta: viewColumnRawMeta } =
84✔
1500
        (await this.prismaService.view.findUnique({
84✔
1501
          where: { id: viewId, deletedTime: null },
84✔
1502
          select: { columnMeta: true },
84✔
1503
        })) || {};
84✔
1504

1505
      viewColumnMeta = viewColumnRawMeta ? JSON.parse(viewColumnRawMeta) : null;
84✔
1506

1507
      if (viewColumnMeta) {
84✔
1508
        Object.entries(viewColumnMeta).forEach(([key, value]) => {
84✔
1509
          if (get(value, ['hidden'])) {
888✔
1510
            delete fieldInstanceMap[key];
×
1511
          }
×
1512
        });
888✔
1513
      }
84✔
1514
    }
84✔
1515

1516
    if (projection?.length) {
430✔
1517
      Object.keys(fieldInstanceMap).forEach((fieldId) => {
×
1518
        if (!projection.includes(fieldId)) {
×
1519
          delete fieldInstanceMap[fieldId];
×
1520
        }
×
1521
      });
×
1522
    }
✔
1523

1524
    return uniqBy(
96✔
1525
      orderBy(
96✔
1526
        Object.values(fieldInstanceMap)
96✔
1527
          .map((field) => ({
96✔
1528
            ...field,
1,422✔
1529
            isStructuredCellValue: field.isStructuredCellValue,
1,422✔
1530
          }))
1,422✔
1531
          .filter((field) => {
96✔
1532
            if (!viewColumnMeta) {
1,422✔
1533
              return true;
90✔
1534
            }
90✔
1535
            return !viewColumnMeta?.[field.id]?.hidden;
1,332✔
1536
          })
1,422✔
1537
          .filter((field) => {
96✔
1538
            if (!projection) {
1,422✔
1539
              return true;
1,422✔
1540
            }
1,422✔
1541
            return projection.includes(field.id);
×
1542
          })
×
1543
          .filter((field) => {
96✔
1544
            if (isSearchAllFields) {
1,422✔
1545
              return true;
84✔
1546
            }
84✔
1547

1548
            const searchArr = search?.[1]?.split(',') || [];
1,422✔
1549
            return searchArr.includes(field.id);
1,422✔
1550
          })
1,422✔
1551
          .filter((field) => {
96✔
1552
            if (
216✔
1553
              [CellValueType.Boolean, CellValueType.DateTime].includes(field.cellValueType) &&
216✔
1554
              isSearchAllFields
46✔
1555
            ) {
216✔
1556
              return false;
22✔
1557
            }
22✔
1558
            if (field.cellValueType === CellValueType.Boolean) {
216✔
1559
              return false;
6✔
1560
            }
6✔
1561
            return true;
188✔
1562
          })
188✔
1563
          .map((field) => {
96✔
1564
            return {
188✔
1565
              ...field,
188✔
1566
              order: viewColumnMeta?.[field.id]?.order ?? Number.MIN_SAFE_INTEGER,
188✔
1567
            };
188✔
1568
          }),
188✔
1569
        ['order', 'createTime']
96✔
1570
      ),
1571
      'id'
96✔
1572
    ).slice(0, maxSearchFieldCount) as unknown as IFieldInstance[];
96✔
1573
  }
96✔
1574

1575
  private async getSearchHitIndex(
435✔
1576
    tableId: string,
1,405✔
1577
    query: IGetRecordsRo,
1,405✔
1578
    builder: Knex.QueryBuilder,
1,405✔
1579
    enabledFieldIds?: string[]
1,405✔
1580
  ) {
1,405✔
1581
    const { search, viewId, projection, ignoreViewQuery } = query;
1,405✔
1582

1583
    if (!search) {
1,405✔
1584
      return null;
1,361✔
1585
    }
1,361✔
1586

1587
    const fieldsRaw = await this.prismaService.field.findMany({
44✔
1588
      where: {
44✔
1589
        tableId,
44✔
1590
        deletedTime: null,
44✔
1591
        ...(enabledFieldIds ? { id: { in: enabledFieldIds } } : {}),
1,405✔
1592
      },
1,405✔
1593
    });
1,405✔
1594
    const fieldInstances = fieldsRaw.map((field) => createFieldInstanceByRaw(field));
44✔
1595
    const fieldInstanceMap = fieldInstances.reduce(
44✔
1596
      (map, field) => {
44✔
1597
        map[field.id] = field;
446✔
1598
        return map;
446✔
1599
      },
446✔
1600
      {} as Record<string, IFieldInstance>
44✔
1601
    );
1602
    const searchFields = await this.getSearchFields(
44✔
1603
      fieldInstanceMap,
44✔
1604
      search,
44✔
1605
      ignoreViewQuery ? undefined : viewId,
1,405✔
1606
      projection
1,405✔
1607
    );
1608

1609
    const tableIndex = await this.tableIndexService.getActivatedTableIndexes(tableId);
44✔
1610

1611
    if (searchFields.length === 0) {
50✔
1612
      return null;
2✔
1613
    }
2✔
1614

1615
    const newQuery = this.knex
42✔
1616
      .with('current_page_records', builder)
42✔
1617
      .with('search_index', (qb) => {
42✔
1618
        this.dbProvider.searchIndexQuery(
42✔
1619
          qb,
42✔
1620
          'current_page_records',
42✔
1621
          searchFields,
42✔
1622
          {
42✔
1623
            search,
42✔
1624
          },
42✔
1625
          tableIndex,
42✔
1626
          undefined,
42✔
1627
          undefined,
42✔
1628
          undefined
42✔
1629
        );
1630
      })
42✔
1631
      .from('search_index');
42✔
1632
    const result = await this.prismaService.$queryRawUnsafe<{ __id: string; fieldId: string }[]>(
42✔
1633
      newQuery.toQuery()
42✔
1634
    );
1635

1636
    if (!result.length) {
50✔
1637
      return null;
8✔
1638
    }
8✔
1639

1640
    return result.map((res) => ({
34✔
1641
      fieldId: res.fieldId,
132✔
1642
      recordId: res.__id,
132✔
1643
    }));
132✔
1644
  }
34✔
1645

1646
  async getRecordsFields(
435✔
1647
    tableId: string,
109✔
1648
    query: IGetRecordsRo
109✔
1649
  ): Promise<Pick<IRecord, 'id' | 'fields'>[]> {
109✔
1650
    if (identify(tableId) !== IdPrefix.Table) {
109✔
1651
      throw new InternalServerErrorException('query collection must be table id');
×
1652
    }
×
1653

1654
    const {
109✔
1655
      skip,
109✔
1656
      take,
109✔
1657
      orderBy,
109✔
1658
      search,
109✔
1659
      groupBy,
109✔
1660
      collapsedGroupIds,
109✔
1661
      fieldKeyType,
109✔
1662
      cellFormat,
109✔
1663
      projection,
109✔
1664
      viewId,
109✔
1665
      ignoreViewQuery,
109✔
1666
      filterLinkCellCandidate,
109✔
1667
      filterLinkCellSelected,
109✔
1668
    } = query;
109✔
1669

1670
    const fields = await this.getFieldsByProjection(
109✔
1671
      tableId,
109✔
1672
      this.convertProjection(projection),
109✔
1673
      fieldKeyType
109✔
1674
    );
1675
    const fieldNames = fields.map((f) => f.dbFieldName);
109✔
1676

1677
    const { filter: filterWithGroup } = await this.getGroupRelatedData(tableId, query);
109✔
1678

1679
    const { queryBuilder } = await this.buildFilterSortQuery(tableId, {
109✔
1680
      viewId,
109✔
1681
      ignoreViewQuery,
109✔
1682
      filter: filterWithGroup,
109✔
1683
      orderBy,
109✔
1684
      search,
109✔
1685
      groupBy,
109✔
1686
      collapsedGroupIds,
109✔
1687
      filterLinkCellCandidate,
109✔
1688
      filterLinkCellSelected,
109✔
1689
    });
109✔
1690
    queryBuilder.select(fieldNames.concat('__id'));
109✔
1691
    skip && queryBuilder.offset(skip);
109✔
1692
    take !== -1 && take && queryBuilder.limit(take);
109✔
1693

1694
    const result = await this.prismaService
109✔
1695
      .txClient()
109✔
1696
      .$queryRawUnsafe<
109✔
1697
        (Pick<IRecord, 'fields'> & Pick<IVisualTableDefaultField, '__id'>)[]
1698
      >(queryBuilder.toQuery());
109✔
1699

1700
    return result.map((record) => {
109✔
1701
      return {
20,443✔
1702
        id: record.__id,
20,443✔
1703
        fields: this.dbRecord2RecordFields(record, fields, fieldKeyType, cellFormat),
20,443✔
1704
      };
20,443✔
1705
    });
20,443✔
1706
  }
109✔
1707

1708
  async getRecordsHeadWithTitles(tableId: string, titles: string[]) {
435✔
1709
    const dbTableName = await this.getDbTableName(tableId);
26✔
1710
    const field = await this.prismaService.txClient().field.findFirst({
26✔
1711
      where: { tableId, isPrimary: true, deletedTime: null },
26✔
1712
    });
26✔
1713
    if (!field) {
26✔
1714
      throw new BadRequestException(`Could not find primary index ${tableId}`);
×
1715
    }
×
1716

1717
    // only text field support type cast to title
26✔
1718
    if (field.dbFieldType !== DbFieldType.Text) {
26✔
1719
      return [];
×
1720
    }
×
1721

1722
    const queryBuilder = this.knex(dbTableName)
26✔
1723
      .select({ title: field.dbFieldName, id: '__id' })
26✔
1724
      .whereIn(field.dbFieldName, titles);
26✔
1725

1726
    const querySql = queryBuilder.toQuery();
26✔
1727

1728
    return this.prismaService.txClient().$queryRawUnsafe<{ id: string; title: string }[]>(querySql);
26✔
1729
  }
26✔
1730

1731
  async getRecordsHeadWithIds(tableId: string, recordIds: string[]) {
435✔
1732
    const dbTableName = await this.getDbTableName(tableId);
20✔
1733
    const fieldRaw = await this.prismaService.txClient().field.findFirst({
20✔
1734
      where: { tableId, isPrimary: true, deletedTime: null },
20✔
1735
    });
20✔
1736
    if (!fieldRaw) {
20✔
1737
      throw new BadRequestException(`Could not find primary index ${tableId}`);
×
1738
    }
×
1739

1740
    const field = createFieldInstanceByRaw(fieldRaw);
20✔
1741

1742
    const queryBuilder = this.knex(dbTableName)
20✔
1743
      .select({ title: fieldRaw.dbFieldName, id: '__id' })
20✔
1744
      .whereIn('__id', recordIds);
20✔
1745

1746
    const querySql = queryBuilder.toQuery();
20✔
1747

1748
    const result = await this.prismaService
20✔
1749
      .txClient()
20✔
1750
      .$queryRawUnsafe<{ id: string; title: unknown }[]>(querySql);
20✔
1751

1752
    return result.map((r) => ({
20✔
1753
      id: r.id,
22✔
1754
      title: field.cellValue2String(r.title),
22✔
1755
    }));
22✔
1756
  }
20✔
1757

1758
  async filterRecordIdsByFilter(
435✔
1759
    tableId: string,
×
1760
    recordIds: string[],
×
1761
    filter?: IFilter | null
×
1762
  ): Promise<string[]> {
×
1763
    const { queryBuilder, dbTableName, viewCte } = await this.buildFilterSortQuery(tableId, {
×
1764
      filter,
×
1765
    });
×
1766
    const dbName = viewCte ?? dbTableName;
×
1767
    queryBuilder.whereIn(`${dbName}.__id`, recordIds);
×
1768
    queryBuilder.select(this.knex.ref(`${dbName}.__id`));
×
1769
    const result = await this.prismaService
×
1770
      .txClient()
×
1771
      .$queryRawUnsafe<{ __id: string }[]>(queryBuilder.toQuery());
×
1772
    return result.map((r) => r.__id);
×
1773
  }
×
1774

1775
  async getDiffIdsByIdAndFilter(tableId: string, recordIds: string[], filter?: IFilter | null) {
435✔
1776
    const ids = await this.filterRecordIdsByFilter(tableId, recordIds, filter);
×
1777
    return difference(recordIds, ids);
×
1778
  }
×
1779

1780
  @Timing()
435✔
1781
  // eslint-disable-next-line sonarjs/cognitive-complexity
1782
  private async groupDbCollection2GroupPoints(
48✔
1783
    groupResult: { [key: string]: unknown; __c: number }[],
48✔
1784
    groupFields: IFieldInstance[],
48✔
1785
    collapsedGroupIds: string[] | undefined,
48✔
1786
    rowCount: number
48✔
1787
  ) {
48✔
1788
    const groupPoints: IGroupPoint[] = [];
48✔
1789
    const allGroupHeaderRefs: IGroupHeaderRef[] = [];
48✔
1790
    const collapsedGroupIdsSet = new Set(collapsedGroupIds);
48✔
1791
    let fieldValues: unknown[] = [Symbol(), Symbol(), Symbol()];
48✔
1792
    let curRowCount = 0;
48✔
1793
    let collapsedDepth = Number.MAX_SAFE_INTEGER;
48✔
1794

1795
    for (let i = 0; i < groupResult.length; i++) {
48✔
1796
      const item = groupResult[i];
602✔
1797
      const { __c: count } = item;
602✔
1798

1799
      for (let index = 0; index < groupFields.length; index++) {
602✔
1800
        const field = groupFields[index];
602✔
1801
        const { id, dbFieldName } = field;
602✔
1802
        const fieldValue = convertValueToStringify(item[dbFieldName]);
602✔
1803

1804
        if (fieldValues[index] === fieldValue) continue;
602✔
1805

1806
        const flagString = `${id}_${[...fieldValues.slice(0, index), fieldValue].join('_')}`;
602✔
1807
        const groupId = String(string2Hash(flagString));
602✔
1808

1809
        allGroupHeaderRefs.push({ id: groupId, depth: index });
602✔
1810

1811
        if (index > collapsedDepth) break;
602✔
1812

1813
        // Reset the collapsedDepth when encountering the next peer grouping
602✔
1814
        collapsedDepth = Number.MAX_SAFE_INTEGER;
602✔
1815

1816
        fieldValues[index] = fieldValue;
602✔
1817
        fieldValues = fieldValues.map((value, idx) => (idx > index ? Symbol() : value));
602✔
1818

1819
        const isCollapsedInner = collapsedGroupIdsSet.has(groupId) ?? false;
602✔
1820
        let value = field.convertDBValue2CellValue(fieldValue);
602✔
1821

1822
        if (field.type === FieldType.Attachment) {
602✔
1823
          value = await this.getAttachmentPresignedCellValue(value as IAttachmentCellValue);
×
1824
        }
×
1825

1826
        groupPoints.push({
602✔
1827
          id: groupId,
602✔
1828
          type: GroupPointType.Header,
602✔
1829
          depth: index,
602✔
1830
          value,
602✔
1831
          isCollapsed: isCollapsedInner,
602✔
1832
        });
602✔
1833

1834
        if (isCollapsedInner) {
602✔
1835
          collapsedDepth = index;
2✔
1836
        }
2✔
1837
      }
602✔
1838

1839
      curRowCount += Number(count);
602✔
1840
      if (collapsedDepth !== Number.MAX_SAFE_INTEGER) continue;
602✔
1841
      groupPoints.push({ type: GroupPointType.Row, count: Number(count) });
600✔
1842
    }
600✔
1843

1844
    if (curRowCount < rowCount) {
48✔
1845
      groupPoints.push(
×
1846
        {
×
1847
          id: 'unknown',
×
1848
          type: GroupPointType.Header,
×
1849
          depth: 0,
×
1850
          value: 'Unknown',
×
1851
          isCollapsed: false,
×
1852
        },
×
1853
        { type: GroupPointType.Row, count: rowCount - curRowCount }
×
1854
      );
1855
    }
×
1856

1857
    return {
48✔
1858
      groupPoints,
48✔
1859
      allGroupHeaderRefs,
48✔
1860
    };
48✔
1861
  }
48✔
1862

1863
  private getFilterByCollapsedGroup({
435✔
1864
    groupBy,
48✔
1865
    groupPoints,
48✔
1866
    fieldInstanceMap,
48✔
1867
    collapsedGroupIds,
48✔
1868
  }: {
1869
    groupBy: IGroup;
1870
    groupPoints: IGroupPointsVo;
1871
    fieldInstanceMap: Record<string, IFieldInstance>;
1872
    collapsedGroupIds?: string[];
1873
  }) {
48✔
1874
    if (!groupBy?.length || groupPoints == null || collapsedGroupIds == null) return null;
48✔
1875
    const groupIds: string[] = [];
2✔
1876
    const groupId2DataMap = groupPoints.reduce(
2✔
1877
      (prev, cur) => {
2✔
1878
        if (cur.type !== GroupPointType.Header) {
14✔
1879
          return prev;
6✔
1880
        }
6✔
1881
        const { id, depth } = cur;
8✔
1882

1883
        groupIds[depth] = id;
8✔
1884
        prev[id] = { ...cur, path: groupIds.slice(0, depth + 1) };
8✔
1885
        return prev;
8✔
1886
      },
8✔
1887
      {} as Record<string, IGroupHeaderPoint & { path: string[] }>
2✔
1888
    );
1889

1890
    const filterQuery: IFilter = {
2✔
1891
      conjunction: and.value,
2✔
1892
      filterSet: [],
2✔
1893
    };
2✔
1894

1895
    for (const groupId of collapsedGroupIds) {
2✔
1896
      const groupData = groupId2DataMap[groupId];
2✔
1897

1898
      if (groupData == null) continue;
2✔
1899

1900
      const { path } = groupData;
2✔
1901
      const innerFilterSet: IFilterSet = {
2✔
1902
        conjunction: or.value,
2✔
1903
        filterSet: [],
2✔
1904
      };
2✔
1905

1906
      path.forEach((pathGroupId) => {
2✔
1907
        const pathGroupData = groupId2DataMap[pathGroupId];
2✔
1908

1909
        if (pathGroupData == null) return;
2✔
1910

1911
        const { depth } = pathGroupData;
2✔
1912
        const curGroup = groupBy[depth];
2✔
1913

1914
        if (curGroup == null) return;
2✔
1915

1916
        const { fieldId } = curGroup;
2✔
1917
        const field = fieldInstanceMap[fieldId];
2✔
1918

1919
        if (field == null) return;
2✔
1920

1921
        const filterItem = generateFilterItem(field, pathGroupData.value);
2✔
1922
        innerFilterSet.filterSet.push(filterItem);
2✔
1923
      });
2✔
1924

1925
      filterQuery.filterSet.push(innerFilterSet);
2✔
1926
    }
2✔
1927

1928
    return filterQuery;
2✔
1929
  }
2✔
1930

1931
  async getRowCountByFilter(
435✔
1932
    dbTableName: string,
48✔
1933
    fieldInstanceMap: Record<string, IFieldInstance>,
48✔
1934
    tableId: string,
48✔
1935
    filter?: IFilter,
48✔
1936
    search?: [string, string?, boolean?],
48✔
1937
    viewId?: string
48✔
1938
  ) {
48✔
1939
    const withUserId = this.cls.get('user.id');
48✔
1940
    const queryBuilder = this.knex(dbTableName);
48✔
1941

1942
    if (filter) {
48✔
1943
      this.dbProvider
×
1944
        .filterQuery(queryBuilder, fieldInstanceMap, filter, { withUserId })
×
1945
        .appendQueryBuilder();
×
1946
    }
×
1947

1948
    if (search && search[2]) {
48✔
1949
      const searchFields = await this.getSearchFields(fieldInstanceMap, search, viewId);
×
1950
      const tableIndex = await this.tableIndexService.getActivatedTableIndexes(tableId);
×
1951
      queryBuilder.where((builder) => {
×
1952
        this.dbProvider.searchQuery(builder, searchFields, tableIndex, search);
×
1953
      });
×
1954
    }
×
1955

1956
    const rowCountSql = queryBuilder.count({ count: '*' });
48✔
1957
    const result = await this.prismaService.$queryRawUnsafe<{ count?: number }[]>(
48✔
1958
      rowCountSql.toQuery()
48✔
1959
    );
1960
    return Number(result[0].count);
48✔
1961
  }
48✔
1962

1963
  public async getGroupRelatedData(tableId: string, query?: IGetRecordsRo) {
435✔
1964
    const {
1,524✔
1965
      groupBy: extraGroupBy,
1,524✔
1966
      filter,
1,524✔
1967
      search,
1,524✔
1968
      collapsedGroupIds,
1,524✔
1969
      ignoreViewQuery,
1,524✔
1970
    } = query || {};
1,524✔
1971
    let groupPoints: IGroupPoint[] = [];
1,524✔
1972
    let allGroupHeaderRefs: IGroupHeaderRef[] = [];
1,524✔
1973

1974
    const fullGroupBy = parseGroup(extraGroupBy);
1,524✔
1975

1976
    if (!fullGroupBy?.length) {
1,524✔
1977
      return {
1,476✔
1978
        groupPoints,
1,476✔
1979
        filter,
1,476✔
1980
      };
1,476✔
1981
    }
1,476✔
1982

1983
    const viewId = ignoreViewQuery ? undefined : query?.viewId;
1,524✔
1984
    const viewRaw = await this.getTinyView(tableId, viewId);
1,524✔
1985
    const { viewCte, builder, enabledFieldIds } = await this.recordPermissionService.wrapView(
48✔
1986
      tableId,
48✔
1987
      this.knex.queryBuilder(),
48✔
1988
      {
48✔
1989
        keepPrimaryKey: Boolean(query?.filterLinkCellSelected),
48✔
1990
        viewId,
1,524✔
1991
      }
1,524✔
1992
    );
1993
    const fieldInstanceMap = (await this.getNecessaryFieldMap(
48✔
1994
      tableId,
48✔
1995
      filter,
48✔
1996
      undefined,
48✔
1997
      fullGroupBy,
48✔
1998
      search,
48✔
1999
      enabledFieldIds
48✔
2000
    ))!;
2001
    const groupBy = fullGroupBy.filter((item) => fieldInstanceMap[item.fieldId]);
48✔
2002

2003
    if (!groupBy?.length) {
1,524✔
2004
      return {
×
2005
        groupPoints,
×
2006
        filter,
×
2007
      };
×
2008
    }
✔
2009

2010
    const dbTableName = await this.getDbTableName(tableId);
48✔
2011

2012
    const filterStr = viewRaw?.filter;
54✔
2013
    const mergedFilter = mergeWithDefaultFilter(filterStr, filter);
1,524✔
2014
    const groupFieldIds = groupBy.map((item) => item.fieldId);
1,524✔
2015

2016
    const queryBuilder = builder.from(viewCte ?? dbTableName);
1,524✔
2017

2018
    if (mergedFilter) {
1,524✔
2019
      const withUserId = this.cls.get('user.id');
×
2020
      this.dbProvider
×
2021
        .filterQuery(queryBuilder, fieldInstanceMap, mergedFilter, { withUserId })
×
2022
        .appendQueryBuilder();
×
2023
    }
✔
2024

2025
    if (search && search[2]) {
1,524✔
2026
      const searchFields = await this.getSearchFields(fieldInstanceMap, search, viewId);
×
2027
      const tableIndex = await this.tableIndexService.getActivatedTableIndexes(tableId);
×
2028
      queryBuilder.where((builder) => {
×
2029
        this.dbProvider.searchQuery(builder, searchFields, tableIndex, search);
×
2030
      });
×
2031
    }
✔
2032

2033
    this.dbProvider.sortQuery(queryBuilder, fieldInstanceMap, groupBy).appendSortBuilder();
48✔
2034
    this.dbProvider.groupQuery(queryBuilder, fieldInstanceMap, groupFieldIds).appendGroupBuilder();
48✔
2035

2036
    queryBuilder.count({ __c: '*' }).limit(this.thresholdConfig.maxGroupPoints);
48✔
2037

2038
    const groupSql = queryBuilder.toQuery();
48✔
2039
    const groupFields = groupFieldIds.map((fieldId) => fieldInstanceMap[fieldId]).filter(Boolean);
48✔
2040
    const rowCount = await this.getRowCountByFilter(
48✔
2041
      dbTableName,
48✔
2042
      fieldInstanceMap,
48✔
2043
      tableId,
48✔
2044
      mergedFilter,
48✔
2045
      search,
48✔
2046
      viewId
48✔
2047
    );
2048

2049
    try {
48✔
2050
      const result =
48✔
2051
        await this.prismaService.$queryRawUnsafe<{ [key: string]: unknown; __c: number }[]>(
48✔
2052
          groupSql
48✔
2053
        );
2054
      const pointsResult = await this.groupDbCollection2GroupPoints(
48✔
2055
        result,
48✔
2056
        groupFields,
48✔
2057
        collapsedGroupIds,
48✔
2058
        rowCount
48✔
2059
      );
2060
      groupPoints = pointsResult.groupPoints;
48✔
2061
      allGroupHeaderRefs = pointsResult.allGroupHeaderRefs;
48✔
2062
    } catch (error) {
54✔
2063
      console.log(`Get group points error in table ${tableId}: `, error);
×
2064
    }
✔
2065

2066
    const filterWithCollapsed = this.getFilterByCollapsedGroup({
48✔
2067
      groupBy,
48✔
2068
      groupPoints,
48✔
2069
      fieldInstanceMap,
48✔
2070
      collapsedGroupIds,
48✔
2071
    });
48✔
2072

2073
    return { groupPoints, allGroupHeaderRefs, filter: mergeFilter(filter, filterWithCollapsed) };
48✔
2074
  }
48✔
2075

2076
  async getRecordStatus(
435✔
2077
    tableId: string,
×
2078
    recordId: string,
×
2079
    query: IGetRecordsRo
×
2080
  ): Promise<IRecordStatusVo> {
×
2081
    const dbTableName = await this.getDbTableName(tableId);
×
2082
    const queryBuilder = this.knex(dbTableName).select('__id').where('__id', recordId).limit(1);
×
2083

2084
    const result = await this.prismaService
×
2085
      .txClient()
×
2086
      .$queryRawUnsafe<{ __id: string }[]>(queryBuilder.toQuery());
×
2087

2088
    const isDeleted = result.length === 0;
×
2089

2090
    if (isDeleted) {
×
2091
      return { isDeleted, isVisible: false };
×
2092
    }
×
2093

2094
    const queryResult = await this.getDocIdsByQuery(tableId, {
×
2095
      ignoreViewQuery: query.ignoreViewQuery ?? false,
×
2096
      viewId: query.viewId,
×
2097
      skip: query.skip,
×
2098
      take: query.take,
×
2099
      filter: query.filter,
×
2100
      orderBy: query.orderBy,
×
2101
      search: query.search,
×
2102
      groupBy: query.groupBy,
×
2103
      filterLinkCellCandidate: query.filterLinkCellCandidate,
×
2104
      filterLinkCellSelected: query.filterLinkCellSelected,
×
2105
      selectedRecordIds: query.selectedRecordIds,
×
2106
    });
×
2107
    const isVisible = queryResult.ids.includes(recordId);
×
2108
    return { isDeleted, isVisible };
×
2109
  }
×
2110
}
435✔
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