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

teableio / teable / 18705228740

22 Oct 2025 04:19AM UTC coverage: 75.17% (+0.09%) from 75.082%
18705228740

Pull #2004

github

web-flow
Merge f8333616c into 27929a692
Pull Request #2004: fix: search high light

10081 of 10840 branches covered (93.0%)

60 of 63 new or added lines in 5 files covered. (95.24%)

245 existing lines in 4 files now uncovered.

50309 of 66927 relevant lines covered (75.17%)

4491.05 hits per line

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

88.18
/apps/nestjs-backend/src/features/record/record.service.ts
1
/* eslint-disable @typescript-eslint/naming-convention */
2✔
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
  ISelectFieldOptions,
20
  ISnapshotBase,
21
  ISortItem,
22
} from '@teable/core';
23
import {
24
  and,
25
  CellFormat,
26
  CellValueType,
27
  DbFieldType,
28
  FieldKeyType,
29
  FieldType,
30
  generateRecordId,
31
  HttpErrorCode,
32
  identify,
33
  IdPrefix,
34
  mergeFilter,
35
  mergeWithDefaultFilter,
36
  mergeWithDefaultSort,
37
  or,
38
  parseGroup,
39
  Relationship,
40
  SortFunc,
41
  StatisticsFunc,
42
} from '@teable/core';
43
import { PrismaService } from '@teable/db-main-prisma';
44
import type {
45
  ICreateRecordsRo,
46
  IGetRecordQuery,
47
  IGetRecordsRo,
48
  IGroupHeaderPoint,
49
  IGroupHeaderRef,
50
  IGroupPoint,
51
  IGroupPointsVo,
52
  IRecordStatusVo,
53
  IRecordsVo,
54
} from '@teable/openapi';
55
import { DEFAULT_MAX_SEARCH_FIELD_COUNT, GroupPointType, UploadType } from '@teable/openapi';
56
import { Knex } from 'knex';
57
import { get, difference, keyBy, orderBy, uniqBy, toNumber } from 'lodash';
58
import { InjectModel } from 'nest-knexjs';
59
import { ClsService } from 'nestjs-cls';
60
import { CacheService } from '../../cache/cache.service';
61
import { ThresholdConfig, IThresholdConfig } from '../../configs/threshold.config';
62
import { CustomHttpException } from '../../custom.exception';
63
import { InjectDbProvider } from '../../db-provider/db.provider';
64
import { IDbProvider } from '../../db-provider/db.provider.interface';
65
import { RawOpType } from '../../share-db/interface';
66
import type { IClsStore } from '../../types/cls';
67
import { convertValueToStringify, string2Hash } from '../../utils';
68
import { handleDBValidationErrors } from '../../utils/db-validation-error';
69
import { generateFilterItem } from '../../utils/filter';
70
import {
71
  generateTableThumbnailPath,
72
  getTableThumbnailToken,
73
} from '../../utils/generate-thumbnail-path';
74
import { Timing } from '../../utils/timing';
75
import { AttachmentsStorageService } from '../attachments/attachments-storage.service';
76
import StorageAdapter from '../attachments/plugins/adapter';
77
import { BatchService } from '../calculation/batch.service';
78
import { DataLoaderService } from '../data-loader/data-loader.service';
79
import type { IVisualTableDefaultField } from '../field/constant';
80
import type { IFieldInstance } from '../field/model/factory';
81
import { createFieldInstanceByRaw } from '../field/model/factory';
82
import { TableIndexService } from '../table/table-index.service';
83
import { ROW_ORDER_FIELD_PREFIX } from '../view/constant';
84
import { InjectRecordQueryBuilder, IRecordQueryBuilder } from './query-builder';
85
import { RecordPermissionService } from './record-permission.service';
86
import { IFieldRaws } from './type';
87

88
type IUserFields = { id: string; dbFieldName: string }[];
89

90
function removeUndefined<T extends Record<string, unknown>>(obj: T) {
38,766✔
91
  return Object.fromEntries(Object.entries(obj).filter(([_, v]) => v !== undefined)) as T;
38,766✔
92
}
38,766✔
93

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

105
@Injectable()
106
export class RecordService {
2✔
107
  private logger = new Logger(RecordService.name);
265✔
108

109
  constructor(
265✔
110
    private readonly prismaService: PrismaService,
265✔
111
    private readonly batchService: BatchService,
265✔
112
    private readonly cls: ClsService<IClsStore>,
265✔
113
    private readonly cacheService: CacheService,
265✔
114
    private readonly attachmentStorageService: AttachmentsStorageService,
265✔
115
    private readonly recordPermissionService: RecordPermissionService,
265✔
116
    private readonly tableIndexService: TableIndexService,
265✔
117
    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,
265✔
118
    @InjectDbProvider() private readonly dbProvider: IDbProvider,
265✔
119
    @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig,
265✔
120
    private readonly dataLoaderService: DataLoaderService,
265✔
121
    @InjectRecordQueryBuilder() private readonly recordQueryBuilder: IRecordQueryBuilder
265✔
122
  ) {}
265✔
123

124
  /**
265✔
125
   * Get the database column name to query for a field
126
   * For lookup formula fields, use the standard field name
127
   */
265✔
128
  private getQueryColumnName(field: IFieldInstance): string {
265✔
129
    return field.dbFieldName;
710,327✔
130
  }
710,327✔
131

132
  private dbRecord2RecordFields(
265✔
133
    record: IRecord['fields'],
120,108✔
134
    fields: IFieldInstance[],
120,108✔
135
    fieldKeyType: FieldKeyType = FieldKeyType.Id,
120,108✔
136
    cellFormat: CellFormat = CellFormat.Json
120,108✔
137
  ) {
120,108✔
138
    return fields.reduce<IRecord['fields']>((acc, field) => {
120,108✔
139
      const fieldNameOrId = field[fieldKeyType];
710,327✔
140
      const queryColumnName = this.getQueryColumnName(field);
710,327✔
141
      const dbCellValue = record[queryColumnName];
710,327✔
142
      const cellValue = field.convertDBValue2CellValue(dbCellValue);
710,327✔
143
      if (cellValue != null) {
710,327✔
144
        acc[fieldNameOrId] =
596,947✔
145
          cellFormat === CellFormat.Text ? field.cellValue2String(cellValue) : cellValue;
596,947✔
146
      }
596,947✔
147
      return acc;
710,327✔
148
    }, {});
120,108✔
149
  }
120,108✔
150

151
  async getAllRecordCount(dbTableName: string) {
265✔
152
    const sqlNative = this.knex(dbTableName).count({ count: '*' }).toSQL().toNative();
6✔
153

154
    const queryResult = await this.prismaService
6✔
155
      .txClient()
6✔
156
      .$queryRawUnsafe<{ count?: number }[]>(sqlNative.sql, ...sqlNative.bindings);
6✔
157
    return Number(queryResult[0]?.count ?? 0);
6✔
158
  }
6✔
159

160
  async getDbValueMatrix(
265✔
161
    dbTableName: string,
×
162
    userFields: IUserFields,
×
163
    rowIndexFieldNames: string[],
×
164
    createRecordsRo: ICreateRecordsRo
×
165
  ) {
×
166
    const rowCount = await this.getAllRecordCount(dbTableName);
×
167
    const dbValueMatrix: unknown[][] = [];
×
168
    for (let i = 0; i < createRecordsRo.records.length; i++) {
×
169
      const recordData = createRecordsRo.records[i].fields;
×
170
      // 1. collect cellValues
×
171
      const recordValues = userFields.map<unknown>((field) => {
×
172
        const cellValue = recordData[field.id];
×
173
        if (cellValue == null) {
×
174
          return null;
×
175
        }
×
176
        return cellValue;
×
177
      });
×
178

179
      // 2. generate rowIndexValues
×
180
      const rowIndexValues = rowIndexFieldNames.map(() => rowCount + i);
×
181

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

185
      dbValueMatrix.push([...recordValues, ...rowIndexValues, ...systemValues]);
×
186
    }
×
187
    return dbValueMatrix;
×
188
  }
×
189

190
  async getDbTableName(tableId: string) {
265✔
191
    const tableMeta = await this.prismaService
8,951✔
192
      .txClient()
8,951✔
193
      .tableMeta.findUniqueOrThrow({
8,951✔
194
        where: { id: tableId },
8,951✔
195
        select: { dbTableName: true },
8,951✔
196
      })
8,951✔
197
      .catch(() => {
8,951✔
198
        throw new NotFoundException(`Table ${tableId} not found`);
×
199
      });
×
200
    return tableMeta.dbTableName;
8,951✔
201
  }
8,951✔
202

203
  private async getLinkCellIds(tableId: string, field: IFieldInstance, recordId: string) {
265✔
204
    const prisma = this.prismaService.txClient();
54✔
205
    const { dbTableName } = await prisma.tableMeta.findFirstOrThrow({
54✔
206
      where: { id: tableId },
54✔
207
      select: { dbTableName: true },
54✔
208
    });
54✔
209

210
    const { qb: queryBuilder } = await this.recordQueryBuilder.createRecordQueryBuilder(
54✔
211
      dbTableName,
54✔
212
      {
54✔
213
        tableIdOrDbTableName: tableId,
54✔
214
        viewId: undefined,
54✔
215
      }
54✔
216
    );
217
    const sql = queryBuilder.where('__id', recordId).toQuery();
54✔
218

219
    const result = await prisma.$queryRawUnsafe<{ id: string; [key: string]: unknown }[]>(sql);
54✔
220
    return result
54✔
221
      .map((item) => {
54✔
222
        return field.convertDBValue2CellValue(item[field.dbFieldName]) as
54✔
223
          | ILinkCellValue
224
          | ILinkCellValue[];
225
      })
54✔
226
      .filter(Boolean)
54✔
227
      .flat()
54✔
228
      .map((item) => item.id);
54✔
229
  }
54✔
230

231
  private async buildLinkSelectedSort(
265✔
232
    queryBuilder: Knex.QueryBuilder,
26✔
233
    dbTableName: string,
26✔
234
    filterLinkCellSelected: [string, string]
26✔
235
  ) {
26✔
236
    const prisma = this.prismaService.txClient();
26✔
237
    const [fieldId, recordId] = filterLinkCellSelected;
26✔
238
    const fieldRaw = await prisma.field
26✔
239
      .findFirstOrThrow({
26✔
240
        where: { id: fieldId, deletedTime: null },
26✔
241
      })
26✔
242
      .catch(() => {
26✔
243
        throw new NotFoundException(`Field ${fieldId} not found`);
×
244
      });
×
245
    const field = createFieldInstanceByRaw(fieldRaw);
26✔
246
    if (!field.isMultipleCellValue) {
26✔
247
      return;
12✔
248
    }
12✔
249

250
    const ids = await this.getLinkCellIds(fieldRaw.tableId, field, recordId);
14✔
251
    if (!ids.length) {
26✔
252
      return;
6✔
253
    }
6✔
254

255
    // sql capable for sqlite
8✔
256
    const valuesQuery = ids
8✔
257
      .map((id, index) => `SELECT ${index + 1} AS sort_order, '${id}' AS id`)
8✔
258
      .join(' UNION ALL ');
8✔
259

260
    queryBuilder
8✔
261
      .with('ordered_ids', this.knex.raw(`${valuesQuery}`))
8✔
262
      .leftJoin('ordered_ids', function () {
8✔
263
        this.on(`${dbTableName}.__id`, '=', 'ordered_ids.id');
8✔
264
      })
8✔
265
      .orderBy('ordered_ids.sort_order');
8✔
266
  }
8✔
267

268
  private isJunctionTable(dbTableName: string) {
265✔
269
    if (dbTableName.includes('.')) {
12✔
270
      return dbTableName.split('.')[1].startsWith('junction');
12✔
271
    }
12✔
272
    return dbTableName.split('_')[1].startsWith('junction');
×
273
  }
×
274

275
  // eslint-disable-next-line sonarjs/cognitive-complexity
265✔
276
  async buildLinkSelectedQuery(
265✔
277
    queryBuilder: Knex.QueryBuilder,
52✔
278
    tableId: string,
52✔
279
    dbTableName: string,
52✔
280
    alias: string,
52✔
281
    filterLinkCellSelected: [string, string] | string
52✔
282
  ) {
52✔
283
    const prisma = this.prismaService.txClient();
52✔
284
    const fieldId = Array.isArray(filterLinkCellSelected)
52✔
285
      ? filterLinkCellSelected[0]
38✔
286
      : filterLinkCellSelected;
14✔
287
    const recordId = Array.isArray(filterLinkCellSelected) ? filterLinkCellSelected[1] : undefined;
52✔
288

289
    const fieldRaw = await prisma.field
52✔
290
      .findFirstOrThrow({
52✔
291
        where: { id: fieldId, deletedTime: null },
52✔
292
      })
52✔
293
      .catch(() => {
52✔
294
        throw new NotFoundException(`Field ${fieldId} not found`);
×
295
      });
×
296

297
    const field = createFieldInstanceByRaw(fieldRaw);
52✔
298

299
    if (field.type !== FieldType.Link) {
52✔
300
      throw new BadRequestException('You can only filter by link field');
×
301
    }
×
302
    const { foreignTableId, fkHostTableName, selfKeyName, foreignKeyName } = field.options;
52✔
303
    if (foreignTableId !== tableId) {
52✔
304
      throw new BadRequestException('Field is not linked to current table');
×
305
    }
×
306

307
    if (fkHostTableName !== dbTableName) {
52✔
308
      queryBuilder.leftJoin(
42✔
309
        `${fkHostTableName}`,
42✔
310
        `${alias}.__id`,
42✔
311
        '=',
42✔
312
        `${fkHostTableName}.${foreignKeyName}`
42✔
313
      );
314
      if (recordId) {
42✔
315
        queryBuilder.where(`${fkHostTableName}.${selfKeyName}`, recordId);
30✔
316
        return;
30✔
317
      }
30✔
318
      queryBuilder.whereNotNull(`${fkHostTableName}.${foreignKeyName}`);
12✔
319
      return;
12✔
320
    }
12✔
321

322
    if (recordId) {
50✔
323
      queryBuilder.where(`${alias}.${selfKeyName}`, recordId);
8✔
324
      return;
8✔
325
    }
8✔
326
    queryBuilder.whereNotNull(`${alias}.${selfKeyName}`);
2✔
327
  }
2✔
328

329
  async buildLinkCandidateQuery(
265✔
330
    queryBuilder: Knex.QueryBuilder,
53✔
331
    tableId: string,
53✔
332
    filterLinkCellCandidate: [string, string] | string
53✔
333
  ) {
53✔
334
    const prisma = this.prismaService.txClient();
53✔
335
    const fieldId = Array.isArray(filterLinkCellCandidate)
53✔
336
      ? filterLinkCellCandidate[0]
40✔
337
      : filterLinkCellCandidate;
13✔
338
    const recordId = Array.isArray(filterLinkCellCandidate)
53✔
339
      ? filterLinkCellCandidate[1]
40✔
340
      : undefined;
13✔
341

342
    const fieldRaw = await prisma.field
53✔
343
      .findFirstOrThrow({
53✔
344
        where: { id: fieldId, deletedTime: null },
53✔
345
      })
53✔
346
      .catch(() => {
53✔
347
        throw new NotFoundException(`Field ${fieldId} not found`);
×
348
      });
×
349

350
    const field = createFieldInstanceByRaw(fieldRaw);
53✔
351

352
    if (field.type !== FieldType.Link) {
53✔
353
      throw new BadRequestException('You can only filter by link field');
×
354
    }
×
355
    const { foreignTableId, fkHostTableName, selfKeyName, foreignKeyName, relationship } =
53✔
356
      field.options;
53✔
357
    if (foreignTableId !== tableId) {
53✔
358
      throw new BadRequestException('Field is not linked to current table');
×
359
    }
×
360
    if (relationship === Relationship.OneMany) {
53✔
361
      if (this.isJunctionTable(fkHostTableName)) {
12✔
362
        queryBuilder.whereNotIn('__id', function () {
4✔
363
          this.select(foreignKeyName).from(fkHostTableName);
4✔
364
        });
4✔
365
      } else {
12✔
366
        queryBuilder.where(selfKeyName, null);
8✔
367
      }
8✔
368
    }
12✔
369
    if (relationship === Relationship.OneOne) {
53✔
370
      if (selfKeyName === '__id') {
16✔
371
        queryBuilder.whereNotIn('__id', function () {
12✔
372
          this.select(foreignKeyName).from(fkHostTableName).whereNotNull(foreignKeyName);
12✔
373
        });
12✔
374
      } else {
16✔
375
        queryBuilder.where(selfKeyName, null);
4✔
376
      }
4✔
377
    }
16✔
378

379
    if (recordId) {
53✔
380
      const linkIds = await this.getLinkCellIds(fieldRaw.tableId, field, recordId);
40✔
381
      if (linkIds.length) {
40✔
382
        queryBuilder.whereNotIn('__id', linkIds);
15✔
383
      }
15✔
384
    }
40✔
385
  }
53✔
386

387
  private async getNecessaryFieldMap(
265✔
388
    tableId: string,
1,213✔
389
    filter?: IFilter,
1,213✔
390
    orderBy?: ISortItem[],
1,213✔
391
    groupBy?: IGroup,
1,213✔
392
    search?: [string, string?, boolean?],
1,213✔
393
    projection?: string[]
1,213✔
394
  ) {
1,213✔
395
    if (filter || orderBy?.length || groupBy?.length || search) {
1,213✔
396
      // The field Meta is needed to construct the filter if it exists
630✔
397
      const fields = await this.getFieldsByProjection(tableId, this.convertProjection(projection));
630✔
398
      return fields.reduce(
630✔
399
        (map, field) => {
630✔
400
          map[field.id] = field;
6,842✔
401
          map[field.name] = field;
6,842✔
402
          return map;
6,842✔
403
        },
6,842✔
404
        {} as Record<string, IFieldInstance>
630✔
405
      );
406
    }
630✔
407
  }
1,213✔
408

409
  private async getTinyView(tableId: string, viewId?: string) {
265✔
410
    if (!viewId) {
1,213✔
411
      return;
1,064✔
412
    }
1,064✔
413

414
    return this.prismaService
149✔
415
      .txClient()
149✔
416
      .view.findFirstOrThrow({
149✔
417
        select: { id: true, type: true, filter: true, sort: true, group: true, columnMeta: true },
149✔
418
        where: { tableId, id: viewId, deletedTime: null },
149✔
419
      })
149✔
420
      .catch(() => {
149✔
421
        throw new NotFoundException(`View ${viewId} not found`);
×
422
      });
×
423
  }
149✔
424

425
  public parseSearch(
265✔
426
    search: [string, string?, boolean?],
55✔
427
    fieldMap?: Record<string, IFieldInstance>
55✔
428
  ): [string, string?, boolean?] {
55✔
429
    const [searchValue, fieldId, hideNotMatchRow] = search;
55✔
430

431
    if (!fieldMap) {
55✔
432
      throw new Error('fieldMap is required when search is set');
×
433
    }
×
434

435
    if (!fieldId) {
55✔
436
      return [searchValue, fieldId, hideNotMatchRow];
17✔
437
    }
17✔
438

439
    const fieldIds = fieldId?.split(',');
55✔
440

441
    fieldIds.forEach((id) => {
55✔
442
      const field = fieldMap[id];
38✔
443
      if (!field) {
38✔
444
        throw new NotFoundException(`Field ${id} not found`);
×
445
      }
×
446
    });
38✔
447

448
    return [searchValue, fieldId, hideNotMatchRow];
55✔
449
  }
55✔
450

451
  async prepareQuery(
265✔
452
    tableId: string,
1,188✔
453
    query: Pick<
1,188✔
454
      IGetRecordsRo,
455
      | 'viewId'
456
      | 'orderBy'
457
      | 'groupBy'
458
      | 'filter'
459
      | 'search'
460
      | 'filterLinkCellSelected'
461
      | 'ignoreViewQuery'
462
    >
1,188✔
463
  ) {
1,188✔
464
    const viewId = query.ignoreViewQuery ? undefined : query.viewId;
1,188✔
465
    const {
1,188✔
466
      orderBy: extraOrderBy,
1,188✔
467
      groupBy: extraGroupBy,
1,188✔
468
      filter: extraFilter,
1,188✔
469
      search: originSearch,
1,188✔
470
    } = query;
1,188✔
471
    const dbTableName = await this.getDbTableName(tableId);
1,188✔
472
    const { viewCte, builder, enabledFieldIds } = await this.recordPermissionService.wrapView(
1,188✔
473
      tableId,
1,188✔
474
      this.knex.queryBuilder(),
1,188✔
475
      {
1,188✔
476
        viewId: query.viewId,
1,188✔
477
        keepPrimaryKey: Boolean(query.filterLinkCellSelected),
1,188✔
478
      }
1,188✔
479
    );
480

481
    const queryBuilder = builder.from(viewCte ?? dbTableName);
1,188✔
482

483
    const view = await this.getTinyView(tableId, viewId);
1,188✔
484

485
    const filter = mergeWithDefaultFilter(view?.filter, extraFilter);
1,188✔
486
    const orderBy = mergeWithDefaultSort(view?.sort, extraOrderBy);
1,188✔
487
    const groupBy = parseGroup(extraGroupBy);
1,188✔
488
    const fieldMap = await this.getNecessaryFieldMap(
1,188✔
489
      tableId,
1,188✔
490
      filter,
1,188✔
491
      orderBy,
1,188✔
492
      groupBy,
1,188✔
493
      originSearch,
1,188✔
494
      enabledFieldIds
1,188✔
495
    );
496

497
    const search = originSearch ? this.parseSearch(originSearch, fieldMap) : undefined;
1,188✔
498

499
    return {
1,188✔
500
      queryBuilder,
1,188✔
501
      dbTableName,
1,188✔
502
      viewCte,
1,188✔
503
      filter,
1,188✔
504
      search,
1,188✔
505
      orderBy,
1,188✔
506
      groupBy,
1,188✔
507
      fieldMap,
1,188✔
508
    };
1,188✔
509
  }
1,188✔
510

511
  async getBasicOrderIndexField(dbTableName: string, viewId: string | undefined) {
265✔
512
    if (!viewId) {
1,209✔
513
      return '__auto_number';
1,041✔
514
    }
1,041✔
515
    const columnName = `${ROW_ORDER_FIELD_PREFIX}_${viewId}`;
168✔
516
    const exists = await this.dbProvider.checkColumnExist(
168✔
517
      dbTableName,
168✔
518
      columnName,
168✔
519
      this.prismaService.txClient()
168✔
520
    );
521

522
    if (exists) {
1,209✔
523
      return columnName;
26✔
524
    }
26✔
525
    return '__auto_number';
142✔
526
  }
142✔
527

528
  /**
265✔
529
   * Builds a query based on filtering and sorting criteria.
530
   *
531
   * This method creates a `Knex` query builder that constructs SQL queries based on the provided
532
   * filtering and sorting parameters. It also takes into account the context of the current user,
533
   * which is crucial for ensuring the security and relevance of data access.
534
   *
535
   * @param {string} tableId - The unique identifier of the table to determine the target of the query.
536
   * @param {Pick<IGetRecordsRo, 'viewId' | 'orderBy' | 'filter' | 'filterLinkCellCandidate'>} query - An object of query parameters, including view ID, sorting rules, filtering conditions, etc.
537
   */
265✔
538
  // eslint-disable-next-line sonarjs/cognitive-complexity
265✔
539
  async buildFilterSortQuery(
265✔
540
    tableId: string,
1,188✔
541
    query: Pick<
1,188✔
542
      IGetRecordsRo,
543
      | 'viewId'
544
      | 'ignoreViewQuery'
545
      | 'orderBy'
546
      | 'groupBy'
547
      | 'filter'
548
      | 'search'
549
      | 'filterLinkCellCandidate'
550
      | 'filterLinkCellSelected'
551
      | 'collapsedGroupIds'
552
      | 'selectedRecordIds'
553
      | 'skip'
554
      | 'take'
555
    >,
1,188✔
556
    useQueryModel = false
1,188✔
557
  ) {
1,188✔
558
    // Prepare the base query builder, filtering conditions, sorting rules, grouping rules and field mapping
1,188✔
559
    const { dbTableName, viewCte, filter, search, orderBy, groupBy, fieldMap } =
1,188✔
560
      await this.prepareQuery(tableId, query);
1,188✔
561

562
    const basicSortIndex = await this.getBasicOrderIndexField(dbTableName, query.viewId);
1,188✔
563

564
    // Retrieve the current user's ID to build user-related query conditions
1,188✔
565
    const currentUserId = this.cls.get('user.id');
1,188✔
566
    const { qb, alias, selectionMap } = await this.recordQueryBuilder.createRecordQueryBuilder(
1,188✔
567
      viewCte ?? dbTableName,
1,188✔
568
      {
1,188✔
569
        tableIdOrDbTableName: tableId,
1,188✔
570
        viewId: query.viewId,
1,188✔
571
        filter,
1,188✔
572
        currentUserId,
1,188✔
573
        sort: [...(groupBy ?? []), ...(orderBy ?? [])],
1,188✔
574
        // Only select fields required by filter/order/search to avoid touching unrelated columns
1,188✔
575
        projection: fieldMap ? Object.values(fieldMap).map((f) => f.id) : [],
1,188✔
576
        useQueryModel,
1,188✔
577
        limit: query.take,
1,188✔
578
        offset: query.skip,
1,188✔
579
        hasSearch: Boolean(search?.[2]),
1,188✔
580
        defaultOrderField: basicSortIndex,
1,188✔
581
      }
1,188✔
582
    );
583

584
    // Ensure permission CTE is attached to the final query builder when referencing it via FROM.
1,188✔
585
    // The initial wrapView done in prepareQuery computed viewCte and enabledFieldIds for fieldMap,
1,188✔
586
    // but the actual builder used below is created anew by recordQueryBuilder. Attach the CTE here
1,188✔
587
    // so that `FROM view_cte_tmp` resolves correctly in the generated SQL.
1,188✔
588
    const docIdWrap = await this.recordPermissionService.wrapView(tableId, qb, {
1,188✔
589
      viewId: query.viewId,
1,188✔
590
      keepPrimaryKey: Boolean(query.filterLinkCellSelected),
1,188✔
591
    });
1,188✔
592
    if (docIdWrap.viewCte) {
1,188✔
593
      qb.from({ [alias]: docIdWrap.viewCte });
×
UNCOV
594
    }
×
595

596
    if (query.filterLinkCellSelected && query.filterLinkCellCandidate) {
1,188✔
597
      throw new BadRequestException(
×
UNCOV
598
        'filterLinkCellSelected and filterLinkCellCandidate can not be set at the same time'
×
599
      );
UNCOV
600
    }
×
601

602
    if (query.selectedRecordIds) {
1,188✔
603
      query.filterLinkCellCandidate
2✔
604
        ? qb.whereNotIn(`${alias}.__id`, query.selectedRecordIds)
1✔
605
        : qb.whereIn(`${alias}.__id`, query.selectedRecordIds);
1✔
606
    }
2✔
607

608
    if (query.filterLinkCellCandidate) {
1,188✔
609
      await this.buildLinkCandidateQuery(qb, tableId, query.filterLinkCellCandidate);
40✔
610
    }
40✔
611

612
    if (query.filterLinkCellSelected) {
1,188✔
613
      await this.buildLinkSelectedQuery(
39✔
614
        qb,
39✔
615
        tableId,
39✔
616
        dbTableName,
39✔
617
        alias,
39✔
618
        query.filterLinkCellSelected
39✔
619
      );
620
    }
39✔
621

622
    if (search && search[2] && fieldMap) {
1,188✔
623
      const searchFields = await this.getSearchFields(fieldMap, search, query?.viewId);
34✔
624
      const tableIndex = await this.tableIndexService.getActivatedTableIndexes(tableId);
34✔
625
      qb.where((builder) => {
34✔
626
        this.dbProvider.searchQuery(builder, searchFields, tableIndex, search, { selectionMap });
34✔
627
      });
34✔
628
    }
34✔
629

630
    // ignore sorting when filterLinkCellSelected is set
1,188✔
631
    if (query.filterLinkCellSelected && Array.isArray(query.filterLinkCellSelected)) {
1,188✔
632
      await this.buildLinkSelectedSort(qb, alias, query.filterLinkCellSelected);
26✔
633
    } else {
1,188✔
634
      // view sorting added by default
1,162✔
635
      qb.orderBy(`${alias}.${basicSortIndex}`, 'asc');
1,162✔
636
    }
1,162✔
637

638
    // If you return `queryBuilder` directly and use `await` to receive it,
1,188✔
639
    // it will perform a query DB operation, which we obviously don't want to see here
1,188✔
640
    return { queryBuilder: qb, dbTableName, viewCte, alias };
1,188✔
641
  }
1,188✔
642

643
  convertProjection(fieldKeys?: string[]) {
265✔
644
    return fieldKeys?.reduce<Record<string, boolean>>((acc, cur) => {
3,976✔
645
      acc[cur] = true;
305✔
646
      return acc;
305✔
647
    }, {});
3,976✔
648
  }
3,976✔
649

650
  private async convertEnabledFieldIdsToProjection(
265✔
651
    tableId: string,
6,216✔
652
    enabledFieldIds?: string[],
6,216✔
653
    fieldKeyType: FieldKeyType = FieldKeyType.Id
6,216✔
654
  ) {
6,216✔
655
    if (!enabledFieldIds?.length) {
6,216✔
656
      return undefined;
6,216✔
657
    }
6,216✔
658

UNCOV
659
    if (fieldKeyType === FieldKeyType.Id) {
×
UNCOV
660
      return this.convertProjection(enabledFieldIds);
×
661
    }
×
662

663
    const fields = await this.dataLoaderService.field.load(tableId, {
×
UNCOV
664
      id: enabledFieldIds,
×
665
    });
×
666
    if (!fields.length) {
×
667
      return undefined;
×
668
    }
×
669

670
    const fieldKeys = fields
×
UNCOV
671
      .map((field) => field[fieldKeyType] as string | undefined)
×
672
      .filter((key): key is string => Boolean(key));
×
673

674
    return fieldKeys.length ? this.convertProjection(fieldKeys) : undefined;
6,216✔
675
  }
6,216✔
676

677
  async getRecordsById(
265✔
678
    tableId: string,
59✔
679
    recordIds: string[],
59✔
680
    withPermission = true
59✔
681
  ): Promise<IRecordsVo> {
59✔
682
    const recordSnapshot = await this[
59✔
683
      withPermission ? 'getSnapshotBulkWithPermission' : 'getSnapshotBulk'
59✔
684
    ](tableId, recordIds, undefined, FieldKeyType.Id);
59✔
685

686
    if (!recordSnapshot.length) {
59✔
UNCOV
687
      throw new NotFoundException('Can not get records');
×
UNCOV
688
    }
×
689

690
    return {
59✔
691
      records: recordSnapshot.map((r) => r.data),
59✔
692
    };
59✔
693
  }
59✔
694

695
  private async getViewProjection(
265✔
696
    tableId: string,
1,062✔
697
    query: IGetRecordsRo
1,062✔
698
  ): Promise<Record<string, boolean> | undefined> {
1,062✔
699
    const viewId = query.viewId;
1,062✔
700
    if (!viewId) {
1,062✔
701
      return;
954✔
702
    }
954✔
703

704
    const fieldKeyType = query.fieldKeyType || FieldKeyType.Name;
1,062✔
705
    const view = await this.prismaService.txClient().view.findFirstOrThrow({
1,062✔
706
      where: { id: viewId, deletedTime: null },
1,062✔
707
      select: { id: true, columnMeta: true },
1,062✔
708
    });
1,062✔
709

710
    const columnMeta = JSON.parse(view.columnMeta) as IColumnMeta;
108✔
711

712
    const useVisible = Object.values(columnMeta).some((column) => 'visible' in column);
108✔
713
    const useHidden = Object.values(columnMeta).some((column) => 'hidden' in column);
108✔
714

715
    if (!useVisible && !useHidden) {
1,062✔
716
      return;
105✔
717
    }
105✔
718

719
    const fieldRaws = await this.dataLoaderService.field.load(tableId);
3✔
720

721
    const fieldMap = keyBy(fieldRaws, 'id');
3✔
722

723
    const projection = Object.entries(columnMeta).reduce<Record<string, boolean>>(
3✔
724
      (acc, [fieldId, column]) => {
3✔
725
        const field = fieldMap[fieldId];
27✔
726
        if (!field) return acc;
27✔
727

728
        const fieldKey = field[fieldKeyType];
27✔
729

730
        if (useVisible) {
27✔
UNCOV
731
          if ('visible' in column && column.visible) {
×
UNCOV
732
            acc[fieldKey] = true;
×
733
          }
×
734
        } else if (useHidden) {
27✔
735
          if (!('hidden' in column) || !column.hidden) {
27✔
736
            acc[fieldKey] = true;
24✔
737
          }
24✔
738
        } else {
27✔
UNCOV
739
          acc[fieldKey] = true;
×
UNCOV
740
        }
×
741

742
        return acc;
27✔
743
      },
27✔
744
      {}
3✔
745
    );
746

747
    return Object.keys(projection).length > 0 ? projection : undefined;
1,062✔
748
  }
1,062✔
749

750
  async getRecords(
265✔
751
    tableId: string,
1,073✔
752
    query: IGetRecordsRo,
1,073✔
753
    useQueryModel = false
1,073✔
754
  ): Promise<IRecordsVo> {
1,073✔
755
    const queryResult = await this.getDocIdsByQuery(
1,073✔
756
      tableId,
1,073✔
757
      {
1,073✔
758
        ignoreViewQuery: query.ignoreViewQuery ?? false,
1,073✔
759
        viewId: query.viewId,
1,073✔
760
        skip: query.skip,
1,073✔
761
        take: query.take,
1,073✔
762
        filter: query.filter,
1,073✔
763
        orderBy: query.orderBy,
1,073✔
764
        search: query.search,
1,073✔
765
        groupBy: query.groupBy,
1,073✔
766
        filterLinkCellCandidate: query.filterLinkCellCandidate,
1,073✔
767
        filterLinkCellSelected: query.filterLinkCellSelected,
1,073✔
768
        selectedRecordIds: query.selectedRecordIds,
1,073✔
769
      },
1,073✔
770
      useQueryModel
1,073✔
771
    );
772

773
    const projection = query.projection
1,073✔
774
      ? this.convertProjection(query.projection)
11✔
775
      : await this.getViewProjection(tableId, query);
1,062✔
776

777
    const recordSnapshot = await this.getSnapshotBulkWithPermission(
1,062✔
778
      tableId,
1,062✔
779
      queryResult.ids,
1,062✔
780
      projection,
1,062✔
781
      query.fieldKeyType || FieldKeyType.Name,
1,073✔
782
      query.cellFormat,
1,073✔
783
      useQueryModel
1,073✔
784
    );
785

786
    return {
1,073✔
787
      records: recordSnapshot.map((r) => r.data),
1,073✔
788
      extra: queryResult.extra,
1,073✔
789
    };
1,073✔
790
  }
1,073✔
791

792
  async getRecord(
265✔
793
    tableId: string,
717✔
794
    recordId: string,
717✔
795
    query: IGetRecordQuery,
717✔
796
    withPermission = true,
717✔
797
    useQueryModel = false
717✔
798
  ): Promise<IRecord> {
717✔
799
    const { projection, fieldKeyType = FieldKeyType.Name, cellFormat } = query;
717✔
800
    const recordSnapshot = await this[
717✔
801
      withPermission ? 'getSnapshotBulkWithPermission' : 'getSnapshotBulk'
717✔
802
    ](
717✔
803
      tableId,
717✔
804
      [recordId],
717✔
805
      this.convertProjection(projection),
717✔
806
      fieldKeyType,
717✔
807
      cellFormat,
717✔
808
      useQueryModel
717✔
809
    );
810

811
    if (!recordSnapshot.length) {
717✔
812
      throw new NotFoundException('Can not get record');
4✔
813
    }
4✔
814

815
    return recordSnapshot[0].data;
713✔
816
  }
713✔
817

818
  async getCellValue(tableId: string, recordId: string, fieldId: string) {
265✔
819
    const record = await this.getRecord(tableId, recordId, {
165✔
820
      projection: [fieldId],
165✔
821
      fieldKeyType: FieldKeyType.Id,
165✔
822
    });
165✔
823
    return record.fields[fieldId];
165✔
824
  }
165✔
825

826
  async getMaxRecordOrder(dbTableName: string) {
265✔
827
    const sqlNative = this.knex(dbTableName).max('__auto_number', { as: 'max' }).toSQL().toNative();
2,532✔
828

829
    const result = await this.prismaService
2,532✔
830
      .txClient()
2,532✔
831
      .$queryRawUnsafe<{ max?: number }[]>(sqlNative.sql, ...sqlNative.bindings);
2,532✔
832

833
    return Number(result[0]?.max ?? 0) + 1;
2,532✔
834
  }
2,532✔
835

836
  async batchDeleteRecords(tableId: string, recordIds: string[]) {
265✔
837
    const dbTableName = await this.getDbTableName(tableId);
49✔
838
    // get version by recordIds, __id as id, __version as version
49✔
839
    const nativeQuery = this.knex(dbTableName)
49✔
840
      .select('__id as id', '__version as version')
49✔
841
      .whereIn('__id', recordIds)
49✔
842
      .toQuery();
49✔
843
    const recordRaw = await this.prismaService
49✔
844
      .txClient()
49✔
845
      .$queryRawUnsafe<{ id: string; version: number }[]>(nativeQuery);
49✔
846

847
    if (recordIds.length !== recordRaw.length) {
49✔
UNCOV
848
      throw new BadRequestException('delete record not found');
×
UNCOV
849
    }
×
850

851
    const recordRawMap = keyBy(recordRaw, 'id');
49✔
852

853
    const dataList = recordIds.map((recordId) => ({
49✔
854
      docId: recordId,
8,287✔
855
      version: recordRawMap[recordId].version,
8,287✔
856
    }));
8,287✔
857

858
    await this.batchService.saveRawOps(tableId, RawOpType.Del, IdPrefix.Record, dataList);
49✔
859

860
    await this.batchDel(tableId, recordIds);
49✔
861
  }
49✔
862

863
  private async getViewIndexColumns(dbTableName: string) {
265✔
864
    const columnInfoQuery = this.dbProvider.columnInfo(dbTableName);
51✔
865
    const columns = await this.prismaService
51✔
866
      .txClient()
51✔
867
      .$queryRawUnsafe<{ name: string }[]>(columnInfoQuery);
51✔
868
    return columns
51✔
869
      .filter((column) => column.name.startsWith(ROW_ORDER_FIELD_PREFIX))
51✔
870
      .map((column) => column.name);
51✔
871
  }
51✔
872

873
  async getRecordIndexes(
265✔
874
    tableId: string,
41✔
875
    recordIds: string[],
41✔
876
    viewId?: string
41✔
877
  ): Promise<Record<string, number>[] | undefined> {
41✔
878
    const dbTableName = await this.getDbTableName(tableId);
41✔
879
    const allViewIndexColumns = await this.getViewIndexColumns(dbTableName);
41✔
880
    const viewIndexColumns = viewId
41✔
881
      ? (() => {
4✔
882
          const viewIndexColumns = allViewIndexColumns.filter((column) => column.endsWith(viewId));
4✔
883
          return viewIndexColumns.length === 0 ? ['__auto_number'] : viewIndexColumns;
4✔
884
        })()
4✔
885
      : allViewIndexColumns;
37✔
886

887
    if (!viewIndexColumns.length) {
41✔
888
      return;
30✔
889
    }
30✔
890

891
    // get all viewIndexColumns value for __id in recordIds
11✔
892
    const indexQuery = this.knex(dbTableName)
11✔
893
      .select(
11✔
894
        viewIndexColumns.reduce<Record<string, string>>((acc, columnName) => {
11✔
895
          if (columnName === '__auto_number') {
11✔
896
            acc[viewId as string] = '__auto_number';
2✔
897
            return acc;
2✔
898
          }
2✔
899
          const theViewId = columnName.substring(ROW_ORDER_FIELD_PREFIX.length + 1);
9✔
900
          acc[theViewId] = columnName;
9✔
901
          return acc;
9✔
902
        }, {})
11✔
903
      )
904
      .select('__id')
11✔
905
      .whereIn('__id', recordIds)
11✔
906
      .toQuery();
11✔
907
    const indexValues = await this.prismaService
11✔
908
      .txClient()
11✔
909
      .$queryRawUnsafe<Record<string, number>[]>(indexQuery);
11✔
910

911
    const indexMap = indexValues.reduce<Record<string, Record<string, number>>>((map, cur) => {
11✔
912
      const id = cur.__id;
11✔
913
      delete cur.__id;
11✔
914
      map[id] = cur;
11✔
915
      return map;
11✔
916
    }, {});
11✔
917

918
    return recordIds.map((recordId) => indexMap[recordId]);
11✔
919
  }
11✔
920

921
  async updateRecordIndexes(
265✔
922
    tableId: string,
10✔
923
    recordsWithOrder: {
10✔
924
      id: string;
925
      order?: Record<string, number>;
926
    }[]
10✔
927
  ) {
10✔
928
    const dbTableName = await this.getDbTableName(tableId);
10✔
929
    const viewIndexColumns = await this.getViewIndexColumns(dbTableName);
10✔
930
    if (!viewIndexColumns.length) {
10✔
931
      return;
6✔
932
    }
6✔
933

934
    const updateRecordSqls = recordsWithOrder
4✔
935
      .map((record) => {
4✔
936
        const order = record.order;
4✔
937
        const orderFields = viewIndexColumns.reduce<Record<string, number>>((acc, columnName) => {
4✔
938
          const viewId = columnName.substring(ROW_ORDER_FIELD_PREFIX.length + 1);
4✔
939
          const index = order?.[viewId];
4✔
940
          if (index != null) {
4✔
941
            acc[columnName] = index;
4✔
942
          }
4✔
943
          return acc;
4✔
944
        }, {});
4✔
945

946
        if (!order || Object.keys(orderFields).length === 0) {
4✔
UNCOV
947
          return;
×
UNCOV
948
        }
×
949

950
        return this.knex(dbTableName).update(orderFields).where('__id', record.id).toQuery();
4✔
951
      })
4✔
952
      .filter(Boolean) as string[];
4✔
953

954
    for (const sql of updateRecordSqls) {
4✔
955
      await this.prismaService.txClient().$executeRawUnsafe(sql);
4✔
956
    }
4✔
957
  }
4✔
958

959
  @Timing()
265✔
960
  async batchCreateRecords(
2,534✔
961
    tableId: string,
2,534✔
962
    records: IRecordInnerRo[],
2,534✔
963
    fieldKeyType: FieldKeyType,
2,534✔
964
    fieldRaws: IFieldRaws
2,534✔
965
  ) {
2,534✔
966
    const snapshots = await this.createBatch(tableId, records, fieldKeyType, fieldRaws);
2,534✔
967

968
    const dataList = snapshots.map((snapshot) => ({
2,530✔
969
      docId: snapshot.__id,
38,764✔
970
      version: snapshot.__version == null ? 0 : snapshot.__version - 1,
38,764✔
971
    }));
38,764✔
972

973
    await this.batchService.saveRawOps(tableId, RawOpType.Create, IdPrefix.Record, dataList);
2,530✔
974
  }
2,530✔
975

976
  @Timing()
265✔
977
  async createRecordsOnlySql(
3✔
978
    tableId: string,
3✔
979
    records: {
3✔
980
      fields: Record<string, unknown>;
981
    }[]
3✔
982
  ) {
3✔
983
    const userId = this.cls.get('user.id');
3✔
984
    await this.creditCheck(tableId);
3✔
985
    const dbTableName = await this.getDbTableName(tableId);
3✔
986
    const fields = await this.getFieldsByProjection(tableId);
3✔
987
    const fieldInstanceMap = fields.reduce(
3✔
988
      (map, curField) => {
3✔
989
        map[curField.id] = curField;
18✔
990
        return map;
18✔
991
      },
18✔
992
      {} as Record<string, IFieldInstance>
3✔
993
    );
994

995
    const newRecords = records.map((record) => {
3✔
996
      const fieldsValues: Record<string, unknown> = {};
6✔
997
      Object.entries(record.fields).forEach(([fieldId, value]) => {
6✔
998
        const fieldInstance = fieldInstanceMap[fieldId];
36✔
999
        fieldsValues[fieldInstance.dbFieldName] = fieldInstance.convertCellValue2DBValue(value);
36✔
1000
      });
36✔
1001
      return {
6✔
1002
        __id: generateRecordId(),
6✔
1003
        __created_by: userId,
6✔
1004
        __version: 1,
6✔
1005
        ...fieldsValues,
6✔
1006
      };
6✔
1007
    });
6✔
1008
    const sql = this.dbProvider.batchInsertSql(dbTableName, newRecords);
3✔
1009
    await this.prismaService.txClient().$executeRawUnsafe(sql);
3✔
1010
  }
3✔
1011

1012
  async creditCheck(tableId: string) {
265✔
1013
    if (!this.thresholdConfig.maxFreeRowLimit) {
2,537✔
1014
      return;
2,531✔
1015
    }
2,531✔
1016

1017
    const table = await this.prismaService.txClient().tableMeta.findFirstOrThrow({
6✔
1018
      where: { id: tableId, deletedTime: null },
6✔
1019
      select: { dbTableName: true, base: { select: { space: { select: { credit: true } } } } },
6✔
1020
    });
6✔
1021

1022
    const rowCount = await this.getAllRecordCount(table.dbTableName);
6✔
1023

1024
    const maxRowCount =
6✔
1025
      table.base.space.credit == null
6✔
1026
        ? this.thresholdConfig.maxFreeRowLimit
6✔
1027
        : table.base.space.credit;
2,537✔
1028

1029
    if (rowCount >= maxRowCount) {
2,537✔
1030
      this.logger.log(`Exceed row count: ${maxRowCount}`, 'creditCheck');
2✔
1031
      throw new BadRequestException(
2✔
1032
        `Exceed max row limit: ${maxRowCount}, please contact us to increase the limit`
2✔
1033
      );
1034
    }
2✔
1035
  }
2,537✔
1036

1037
  private async getAllViewIndexesField(dbTableName: string) {
265✔
1038
    const query = this.dbProvider.columnInfo(dbTableName);
2,532✔
1039
    const columns = await this.prismaService.txClient().$queryRawUnsafe<{ name: string }[]>(query);
2,532✔
1040
    return columns
2,532✔
1041
      .filter((column) => column.name.startsWith(ROW_ORDER_FIELD_PREFIX))
2,532✔
1042
      .map((column) => column.name)
2,532✔
1043
      .reduce<{ [viewId: string]: string }>((acc, cur) => {
2,532✔
1044
        const viewId = cur.substring(ROW_ORDER_FIELD_PREFIX.length + 1);
12✔
1045
        acc[viewId] = cur;
12✔
1046
        return acc;
12✔
1047
      }, {});
2,532✔
1048
  }
2,532✔
1049

1050
  private async createBatch(
265✔
1051
    tableId: string,
2,534✔
1052
    records: IRecordInnerRo[],
2,534✔
1053
    fieldKeyType: FieldKeyType,
2,534✔
1054
    fieldRaws: IFieldRaws
2,534✔
1055
  ) {
2,534✔
1056
    const userId = this.cls.get('user.id');
2,534✔
1057
    await this.creditCheck(tableId);
2,534✔
1058

1059
    const { dbTableName, name: tableName } = await this.prismaService
2,532✔
1060
      .txClient()
2,532✔
1061
      .tableMeta.findUniqueOrThrow({
2,532✔
1062
        where: { id: tableId },
2,532✔
1063
        select: { dbTableName: true, name: true },
2,532✔
1064
      })
2,532✔
1065
      .catch(() => {
2,532✔
UNCOV
1066
        throw new NotFoundException(`Table ${tableId} not found`);
×
UNCOV
1067
      });
×
1068

1069
    const maxRecordOrder = await this.getMaxRecordOrder(dbTableName);
2,532✔
1070

1071
    const views = await this.prismaService.txClient().view.findMany({
2,532✔
1072
      where: { tableId, deletedTime: null },
2,532✔
1073
      select: { id: true },
2,532✔
1074
    });
2,532✔
1075

1076
    const allViewIndexes = await this.getAllViewIndexesField(dbTableName);
2,532✔
1077

1078
    const validationFields = fieldRaws
2,532✔
1079
      .filter((f) => !f.isComputed)
2,532✔
1080
      .filter((f) => f.type !== FieldType.Link)
2,532✔
1081
      .filter((field) => field.notNull || field.unique);
2,532✔
1082

1083
    const snapshots = records
2,532✔
1084
      .map((record, i) =>
2,532✔
1085
        views.reduce<{ [viewIndexFieldName: string]: number }>((pre, cur) => {
38,766✔
1086
          const viewIndexFieldName = allViewIndexes[cur.id];
38,890✔
1087
          const recordViewIndex = record.order?.[cur.id];
38,890✔
1088
          if (!viewIndexFieldName) {
38,890✔
1089
            return pre;
38,878✔
1090
          }
38,878✔
1091
          if (recordViewIndex) {
12✔
1092
            pre[viewIndexFieldName] = recordViewIndex;
12✔
1093
          } else {
427✔
UNCOV
1094
            pre[viewIndexFieldName] = maxRecordOrder + i;
×
UNCOV
1095
          }
✔
1096
          return pre;
12✔
1097
        }, {})
38,766✔
1098
      )
1099
      .map((order, i) => {
2,532✔
1100
        const snapshot = records[i];
38,766✔
1101
        const fields = snapshot.fields;
38,766✔
1102

1103
        const dbFieldValueMap = validationFields.reduce(
38,766✔
1104
          (map, field) => {
38,766✔
1105
            const dbFieldName = field.dbFieldName;
7✔
1106
            const fieldKey = field[fieldKeyType];
7✔
1107
            const cellValue = fields[fieldKey];
7✔
1108

1109
            map[dbFieldName] = cellValue;
7✔
1110
            return map;
7✔
1111
          },
7✔
1112
          {} as Record<string, unknown>
38,766✔
1113
        );
1114

1115
        return removeUndefined({
38,766✔
1116
          __id: snapshot.id,
38,766✔
1117
          __created_by: snapshot.createdBy || userId,
38,766✔
1118
          __last_modified_by: snapshot.lastModifiedBy || undefined,
38,766✔
1119
          __created_time: snapshot.createdTime || undefined,
38,766✔
1120
          __last_modified_time: snapshot.lastModifiedTime || undefined,
38,766✔
1121
          __auto_number: snapshot.autoNumber == null ? undefined : snapshot.autoNumber,
38,766✔
1122
          __version: 1,
38,766✔
1123
          ...order,
38,766✔
1124
          ...dbFieldValueMap,
38,766✔
1125
        });
38,766✔
1126
      });
38,766✔
1127

1128
    const sql = this.dbProvider.batchInsertSql(
2,532✔
1129
      dbTableName,
2,532✔
1130
      snapshots.map((s) => {
2,532✔
1131
        return Object.entries(s).reduce(
38,766✔
1132
          (acc, [key, value]) => {
38,766✔
1133
            acc[key] = Array.isArray(value) ? JSON.stringify(value) : value;
116,348✔
1134
            return acc;
116,348✔
1135
          },
116,348✔
1136
          {} as Record<string, unknown>
38,766✔
1137
        );
1138
      })
38,766✔
1139
    );
1140

1141
    await handleDBValidationErrors({
2,532✔
1142
      fn: () => this.prismaService.txClient().$executeRawUnsafe(sql),
2,532✔
1143
      handleUniqueError: () => {
2,532✔
1144
        throw new CustomHttpException(
1✔
1145
          `Fields ${validationFields.map((f) => f.id).join(', ')} unique validation failed`,
1✔
1146
          HttpErrorCode.VALIDATION_ERROR,
1✔
1147
          {
1✔
1148
            localization: {
1✔
1149
              i18nKey: 'httpErrors.custom.fieldValueDuplicate',
1✔
1150
              context: {
1✔
1151
                tableName,
1✔
1152
                fieldName: validationFields.map((f) => f.name).join(', '),
1✔
1153
              },
1✔
1154
            },
1✔
1155
          }
1✔
1156
        );
1157
      },
1✔
1158
      handleNotNullError: () => {
2,532✔
1159
        throw new CustomHttpException(
1✔
1160
          `Fields ${validationFields.map((f) => f.id).join(', ')} not null validation failed`,
1✔
1161
          HttpErrorCode.VALIDATION_ERROR,
1✔
1162
          {
1✔
1163
            localization: {
1✔
1164
              i18nKey: 'httpErrors.custom.fieldValueNotNull',
1✔
1165
              context: {
1✔
1166
                tableName,
1✔
1167
                fieldName: validationFields.map((f) => f.name).join(', '),
1✔
1168
              },
1✔
1169
            },
1✔
1170
          }
1✔
1171
        );
1172
      },
1✔
1173
    });
2,532✔
1174

1175
    return snapshots;
2,530✔
1176
  }
2,530✔
1177

1178
  private async batchDel(tableId: string, recordIds: string[]) {
265✔
1179
    const dbTableName = await this.getDbTableName(tableId);
49✔
1180

1181
    const nativeQuery = this.knex(dbTableName).whereIn('__id', recordIds).del().toQuery();
49✔
1182
    await this.prismaService.txClient().$executeRawUnsafe(nativeQuery);
49✔
1183
  }
49✔
1184

1185
  public async getFieldsByProjection(
265✔
1186
    tableId: string,
8,275✔
1187
    projection?: { [fieldNameOrId: string]: boolean },
8,275✔
1188
    fieldKeyType: FieldKeyType = FieldKeyType.Id
8,275✔
1189
  ) {
8,275✔
1190
    let fields = await this.dataLoaderService.field.load(tableId);
8,275✔
1191
    if (projection) {
8,275✔
1192
      const projectionFieldKeys = Object.entries(projection)
276✔
1193
        .filter(([, v]) => v)
276✔
1194
        .map(([k]) => k);
276✔
1195
      if (projectionFieldKeys.length) {
276✔
1196
        fields = fields.filter((field) => projectionFieldKeys.includes(field[fieldKeyType]));
264✔
1197
      }
264✔
1198
    }
276✔
1199

1200
    return fields.map((field) => createFieldInstanceByRaw(field));
8,275✔
1201
  }
8,275✔
1202

1203
  private async getCachePreviewUrlTokenMap(
265✔
1204
    records: ISnapshotBase<IRecord>[],
682✔
1205
    fields: IFieldInstance[],
682✔
1206
    fieldKeyType: FieldKeyType
682✔
1207
  ) {
682✔
1208
    const previewToken: string[] = [];
682✔
1209
    for (const field of fields) {
682✔
1210
      if (field.type === FieldType.Attachment) {
6,921✔
1211
        const fieldKey = field[fieldKeyType];
691✔
1212
        for (const record of records) {
691✔
1213
          const cellValue = record.data.fields[fieldKey];
728✔
1214
          if (cellValue == null) continue;
728✔
1215
          (cellValue as IAttachmentCellValue).forEach((item) => {
48✔
1216
            if (item.mimetype.startsWith('image/') && item.width && item.height) {
63✔
1217
              const { smThumbnailPath, lgThumbnailPath } = generateTableThumbnailPath(item.path);
3✔
1218
              previewToken.push(getTableThumbnailToken(smThumbnailPath));
3✔
1219
              previewToken.push(getTableThumbnailToken(lgThumbnailPath));
3✔
1220
            }
3✔
1221
            previewToken.push(item.token);
63✔
1222
          });
63✔
1223
        }
48✔
1224
      }
691✔
1225
    }
6,921✔
1226
    // limit 1000 one handle
682✔
1227
    const tokenMap: Record<string, string> = {};
682✔
1228
    for (let i = 0; i < previewToken.length; i += 1000) {
682✔
1229
      const tokenBatch = previewToken.slice(i, i + 1000);
40✔
1230
      const previewUrls = await this.cacheService.getMany(
40✔
1231
        tokenBatch.map((token) => `attachment:preview:${token}` as const)
40✔
1232
      );
1233
      previewUrls.forEach((url, index) => {
40✔
1234
        if (url) {
69✔
1235
          tokenMap[previewToken[i + index]] = url.url;
65✔
1236
        }
65✔
1237
      });
69✔
1238
    }
40✔
1239
    return tokenMap;
40✔
1240
  }
40✔
1241

1242
  private async getThumbnailPathTokenMap(
265✔
1243
    records: ISnapshotBase<IRecord>[],
682✔
1244
    fields: IFieldInstance[],
682✔
1245
    fieldKeyType: FieldKeyType
682✔
1246
  ) {
682✔
1247
    const thumbnailTokens: string[] = [];
682✔
1248
    for (const field of fields) {
682✔
1249
      if (field.type === FieldType.Attachment) {
6,921✔
1250
        const fieldKey = field[fieldKeyType];
691✔
1251
        for (const record of records) {
691✔
1252
          const cellValue = record.data.fields[fieldKey];
728✔
1253
          if (cellValue == null) continue;
728✔
1254
          (cellValue as IAttachmentCellValue).forEach((item) => {
48✔
1255
            if (item.mimetype.startsWith('image/') && item.width && item.height) {
63✔
1256
              thumbnailTokens.push(getTableThumbnailToken(item.token));
3✔
1257
            }
3✔
1258
          });
63✔
1259
        }
48✔
1260
      }
691✔
1261
    }
6,921✔
1262
    if (thumbnailTokens.length === 0) {
682✔
1263
      return {};
679✔
1264
    }
679✔
1265
    const attachments = await this.prismaService.txClient().attachments.findMany({
3✔
1266
      where: { token: { in: thumbnailTokens } },
3✔
1267
      select: { token: true, thumbnailPath: true },
3✔
1268
    });
3✔
1269
    return attachments.reduce<
3✔
1270
      Record<
1271
        string,
1272
        | {
1273
            sm?: string;
1274
            lg?: string;
1275
          }
1276
        | undefined
1277
      >
1278
    >((acc, cur) => {
3✔
1279
      acc[cur.token] = cur.thumbnailPath ? JSON.parse(cur.thumbnailPath) : undefined;
3✔
1280
      return acc;
3✔
1281
    }, {});
3✔
1282
  }
3✔
1283

1284
  @Timing()
265✔
1285
  private async recordsPresignedUrl(
7,538✔
1286
    records: ISnapshotBase<IRecord>[],
7,538✔
1287
    fields: IFieldInstance[],
7,538✔
1288
    fieldKeyType: FieldKeyType
7,538✔
1289
  ) {
7,538✔
1290
    if (records.length === 0 || fields.findIndex((f) => f.type === FieldType.Attachment) === -1) {
7,538✔
1291
      return records;
6,856✔
1292
    }
6,856✔
1293
    const cacheTokenUrlMap = await this.getCachePreviewUrlTokenMap(records, fields, fieldKeyType);
682✔
1294
    const thumbnailPathTokenMap = await this.getThumbnailPathTokenMap(
682✔
1295
      records,
682✔
1296
      fields,
682✔
1297
      fieldKeyType
682✔
1298
    );
1299
    for (const field of fields) {
7,538✔
1300
      if (field.type === FieldType.Attachment) {
6,921✔
1301
        const fieldKey = field[fieldKeyType];
691✔
1302
        for (const record of records) {
691✔
1303
          const cellValue = record.data.fields[fieldKey];
728✔
1304
          const presignedCellValue = await this.getAttachmentPresignedCellValue(
728✔
1305
            cellValue as IAttachmentCellValue,
728✔
1306
            cacheTokenUrlMap,
728✔
1307
            thumbnailPathTokenMap
728✔
1308
          );
1309
          if (presignedCellValue == null) continue;
728✔
1310

1311
          record.data.fields[fieldKey] = presignedCellValue;
48✔
1312
        }
48✔
1313
      }
691✔
1314
    }
6,921✔
1315
    return records;
682✔
1316
  }
682✔
1317

1318
  async getAttachmentPresignedCellValue(
265✔
1319
    cellValue: IAttachmentCellValue | null,
728✔
1320
    cacheTokenUrlMap?: Record<string, string>,
728✔
1321
    thumbnailPathTokenMap?: Record<string, { sm?: string; lg?: string } | undefined>
728✔
1322
  ) {
728✔
1323
    if (cellValue == null) {
728✔
1324
      return null;
680✔
1325
    }
680✔
1326

1327
    return await Promise.all(
48✔
1328
      cellValue.map(async (item) => {
48✔
1329
        const { path, mimetype, token } = item;
63✔
1330
        const presignedUrl =
63✔
1331
          cacheTokenUrlMap?.[token] ??
63✔
1332
          (await this.attachmentStorageService.getPreviewUrlByPath(
1✔
1333
            StorageAdapter.getBucket(UploadType.Table),
1✔
1334
            path,
1✔
1335
            token,
1✔
1336
            undefined,
1✔
1337
            {
1✔
1338
              'Content-Type': mimetype,
1✔
1339
              'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(item.name)}`,
1✔
1340
            }
1✔
1341
          ));
1342
        let smThumbnailUrl: string | undefined;
1✔
1343
        let lgThumbnailUrl: string | undefined;
1✔
1344
        if (thumbnailPathTokenMap && thumbnailPathTokenMap[token]) {
63✔
1345
          const { sm: smThumbnailPath, lg: lgThumbnailPath } = thumbnailPathTokenMap[token]!;
3✔
1346
          if (smThumbnailPath) {
3✔
1347
            smThumbnailUrl =
3✔
1348
              cacheTokenUrlMap?.[getTableThumbnailToken(smThumbnailPath)] ??
3✔
UNCOV
1349
              (await this.attachmentStorageService.getTableThumbnailUrl(smThumbnailPath, mimetype));
✔
UNCOV
1350
          }
×
1351
          if (lgThumbnailPath) {
3✔
1352
            lgThumbnailUrl =
×
UNCOV
1353
              cacheTokenUrlMap?.[getTableThumbnailToken(lgThumbnailPath)] ??
×
1354
              (await this.attachmentStorageService.getTableThumbnailUrl(lgThumbnailPath, mimetype));
×
1355
          }
×
1356
        }
3✔
1357
        const isImage = mimetype.startsWith('image/');
63✔
1358
        return {
63✔
1359
          ...item,
63✔
1360
          presignedUrl,
63✔
1361
          smThumbnailUrl: isImage ? smThumbnailUrl || presignedUrl : undefined,
63✔
1362
          lgThumbnailUrl: isImage ? lgThumbnailUrl || presignedUrl : undefined,
63✔
1363
        };
63✔
1364
      })
63✔
1365
    );
1366
  }
48✔
1367

1368
  private async getSnapshotBulkInner(
265✔
1369
    builder: Knex.QueryBuilder,
7,543✔
1370
    viewQueryDbTableName: string,
7,543✔
1371
    query: {
7,543✔
1372
      tableId: string;
1373
      recordIds: string[];
1374
      projection?: { [fieldNameOrId: string]: boolean };
1375
      fieldKeyType: FieldKeyType;
1376
      cellFormat: CellFormat;
1377
      useQueryModel: boolean;
1378
    }
7,543✔
1379
  ): Promise<ISnapshotBase<IRecord>[]> {
7,543✔
1380
    const { tableId, recordIds, projection, fieldKeyType, cellFormat } = query;
7,543✔
1381
    const fields = await this.getFieldsByProjection(tableId, projection, fieldKeyType);
7,543✔
1382
    const fieldIds = fields.map((f) => f.id);
7,543✔
1383
    const { qb: queryBuilder, alias } = await this.recordQueryBuilder.createRecordQueryBuilder(
7,543✔
1384
      viewQueryDbTableName,
7,543✔
1385
      {
7,543✔
1386
        tableIdOrDbTableName: tableId,
7,543✔
1387
        viewId: undefined,
7,543✔
1388
        useQueryModel: query.useQueryModel,
7,543✔
1389
        projection: fieldIds,
7,543✔
1390
      }
7,543✔
1391
    );
1392

1393
    // Attach permission CTE and switch FROM to the CTE if available so masking applies.
7,543✔
1394
    const wrap = await this.recordPermissionService.wrapView(tableId, queryBuilder, {
7,543✔
1395
      keepPrimaryKey: true,
7,543✔
1396
    });
7,543✔
1397
    if (wrap.viewCte) {
7,543✔
UNCOV
1398
      // Preserve the alias used by the query builder to keep selected columns valid.
×
UNCOV
1399
      queryBuilder.from({ [alias]: wrap.viewCte });
×
1400
    }
×
1401
    const nativeQuery = queryBuilder.whereIn('__id', recordIds).toQuery();
7,543✔
1402

1403
    this.logger.debug('getSnapshotBulkInner query %s', nativeQuery);
7,543✔
1404

1405
    const result = await this.prismaService
7,543✔
1406
      .txClient()
7,543✔
1407
      .$queryRawUnsafe<
7,543✔
1408
        ({ [fieldName: string]: unknown } & IVisualTableDefaultField)[]
1409
      >(nativeQuery);
7,543✔
1410

1411
    const recordIdsMap = recordIds.reduce(
7,543✔
1412
      (acc, recordId, currentIndex) => {
7,543✔
1413
        acc[recordId] = currentIndex;
99,770✔
1414
        return acc;
99,770✔
1415
      },
99,770✔
1416
      {} as { [recordId: string]: number }
7,543✔
1417
    );
1418

1419
    recordIds.forEach((recordId) => {
7,543✔
1420
      if (!(recordId in recordIdsMap)) {
99,770✔
UNCOV
1421
        throw new NotFoundException(`Record ${recordId} not found`);
×
UNCOV
1422
      }
×
1423
    });
99,770✔
1424

1425
    const primaryField = await this.getPrimaryField(tableId);
7,543✔
1426

1427
    const snapshots = result
7,543✔
1428
      .sort((a, b) => {
7,543✔
1429
        return recordIdsMap[a.__id] - recordIdsMap[b.__id];
141,199✔
1430
      })
141,199✔
1431
      .map((record) => {
7,543✔
1432
        const recordFields = this.dbRecord2RecordFields(record, fields, fieldKeyType, cellFormat);
99,755✔
1433
        const name = recordFields[primaryField[fieldKeyType]];
99,755✔
1434
        return {
99,755✔
1435
          id: record.__id,
99,755✔
1436
          v: record.__version,
99,755✔
1437
          type: 'json0',
99,755✔
1438
          data: {
99,755✔
1439
            fields: recordFields,
99,755✔
1440
            name:
99,755✔
1441
              cellFormat === CellFormat.Text
99,755✔
1442
                ? (name as string)
11✔
1443
                : primaryField.cellValue2String(name),
99,744✔
1444
            id: record.__id,
99,755✔
1445
            autoNumber: record.__auto_number,
99,755✔
1446
            createdTime: record.__created_time?.toISOString(),
99,755✔
1447
            lastModifiedTime: record.__last_modified_time?.toISOString(),
99,755✔
1448
            createdBy: record.__created_by,
99,755✔
1449
            lastModifiedBy: record.__last_modified_by || undefined,
99,755✔
1450
          },
99,755✔
1451
        };
99,755✔
1452
      });
99,755✔
1453
    if (cellFormat === CellFormat.Json) {
7,543✔
1454
      return await this.recordsPresignedUrl(snapshots, fields, fieldKeyType);
7,538✔
1455
    }
7,538✔
1456
    return snapshots;
5✔
1457
  }
5✔
1458

1459
  async getSnapshotBulkWithPermission(
265✔
1460
    tableId: string,
6,395✔
1461
    recordIds: string[],
6,395✔
1462
    projection?: { [fieldNameOrId: string]: boolean },
6,395✔
1463
    fieldKeyType: FieldKeyType = FieldKeyType.Id, // for convince of collaboration, getSnapshotBulk use id as field key by default.
6,395✔
1464
    cellFormat = CellFormat.Json,
6,395✔
1465
    useQueryModel = false
6,395✔
1466
  ) {
6,395✔
1467
    const dbTableName = await this.getDbTableName(tableId);
6,395✔
1468
    const { viewCte, builder, enabledFieldIds } = await this.recordPermissionService.wrapView(
6,395✔
1469
      tableId,
6,395✔
1470
      this.knex.queryBuilder(),
6,395✔
1471
      {
6,395✔
1472
        keepPrimaryKey: true,
6,395✔
1473
      }
6,395✔
1474
    );
1475
    const viewQueryDbTableName = viewCte ?? dbTableName;
6,395✔
1476
    const finalProjection =
6,395✔
1477
      projection ??
6,395✔
1478
      (await this.convertEnabledFieldIdsToProjection(tableId, enabledFieldIds, fieldKeyType));
6,216✔
1479
    return this.getSnapshotBulkInner(builder, viewQueryDbTableName, {
6,216✔
1480
      tableId,
6,216✔
1481
      recordIds,
6,216✔
1482
      projection: finalProjection,
6,216✔
1483
      fieldKeyType,
6,216✔
1484
      cellFormat,
6,216✔
1485
      useQueryModel,
6,216✔
1486
    });
6,216✔
1487
  }
6,216✔
1488

1489
  async getSnapshotBulk(
265✔
1490
    tableId: string,
1,148✔
1491
    recordIds: string[],
1,148✔
1492
    projection?: { [fieldNameOrId: string]: boolean },
1,148✔
1493
    fieldKeyType: FieldKeyType = FieldKeyType.Id, // for convince of collaboration, getSnapshotBulk use id as field key by default.
1,148✔
1494
    cellFormat = CellFormat.Json,
1,148✔
1495
    useQueryModel = false
1,148✔
1496
  ): Promise<ISnapshotBase<IRecord>[]> {
1,148✔
1497
    const dbTableName = await this.getDbTableName(tableId);
1,148✔
1498
    return this.getSnapshotBulkInner(this.knex.queryBuilder(), dbTableName, {
1,148✔
1499
      tableId,
1,148✔
1500
      recordIds,
1,148✔
1501
      projection,
1,148✔
1502
      fieldKeyType,
1,148✔
1503
      cellFormat,
1,148✔
1504
      useQueryModel,
1,148✔
1505
    });
1,148✔
1506
  }
1,148✔
1507

1508
  async getDocIdsByQuery(
265✔
1509
    tableId: string,
1,079✔
1510
    query: IGetRecordsRo,
1,079✔
1511
    useQueryModel = false
1,079✔
1512
  ): Promise<{ ids: string[]; extra?: IExtraResult }> {
1,079✔
1513
    const { skip, take = 100, ignoreViewQuery } = query;
1,079✔
1514

1515
    if (identify(tableId) !== IdPrefix.Table) {
1,079✔
UNCOV
1516
      throw new InternalServerErrorException('query collection must be table id');
×
UNCOV
1517
    }
×
1518

1519
    if (take > 1000) {
1,079✔
UNCOV
1520
      throw new BadRequestException(`limit can't be greater than ${take}`);
×
UNCOV
1521
    }
×
1522

1523
    const viewId = ignoreViewQuery ? undefined : query.viewId;
1,079✔
1524
    const {
1,079✔
1525
      groupPoints,
1,079✔
1526
      allGroupHeaderRefs,
1,079✔
1527
      filter: filterWithGroup,
1,079✔
1528
    } = await this.getGroupRelatedData(
1,079✔
1529
      tableId,
1,079✔
1530
      {
1,079✔
1531
        ...query,
1,079✔
1532
        viewId,
1,079✔
1533
      },
1,079✔
1534
      useQueryModel
1,079✔
1535
    );
1536
    const { queryBuilder, dbTableName } = await this.buildFilterSortQuery(
1,079✔
1537
      tableId,
1,079✔
1538
      {
1,079✔
1539
        ...query,
1,079✔
1540
        filter: filterWithGroup,
1,079✔
1541
      },
1,079✔
1542
      useQueryModel
1,079✔
1543
    );
1544

1545
    // queryBuilder.select(this.knex.ref(`${selectDbTableName}.__id`));
1,079✔
1546

1547
    skip && queryBuilder.offset(skip);
1,079✔
1548
    if (take !== -1) {
1,079✔
1549
      queryBuilder.limit(take);
1,077✔
1550
    }
1,077✔
1551

1552
    const sql = queryBuilder.toQuery();
1,079✔
1553
    this.logger.debug('getRecordsQuery: %s', sql);
1,079✔
1554
    const result = await this.prismaService.txClient().$queryRawUnsafe<{ __id: string }[]>(sql);
1,079✔
1555
    const ids = result.map((r) => r.__id);
1,079✔
1556

1557
    const {
1,079✔
1558
      builder: searchWrapBuilder,
1,079✔
1559
      viewCte: searchViewCte,
1,079✔
1560
      enabledFieldIds,
1,079✔
1561
    } = await this.recordPermissionService.wrapView(tableId, queryBuilder, {
1,079✔
1562
      keepPrimaryKey: Boolean(query.filterLinkCellSelected),
1,079✔
1563
      viewId,
1,079✔
1564
    });
1,079✔
1565
    // this search step should not abort the query
1,079✔
1566
    const searchBuilder = searchViewCte
1,079✔
UNCOV
1567
      ? searchWrapBuilder.from(searchViewCte)
×
1568
      : this.knex(dbTableName);
1,079✔
1569
    try {
1,079✔
1570
      const searchHitIndex = await this.getSearchHitIndex(
1,079✔
1571
        tableId,
1,079✔
1572
        {
1,079✔
1573
          ...query,
1,079✔
1574
          projection: query.projection
1,079✔
UNCOV
1575
            ? query.projection.filter((id) => enabledFieldIds?.includes(id))
✔
1576
            : enabledFieldIds,
1,079✔
1577
          viewId,
1,079✔
1578
        },
1,079✔
1579
        searchBuilder.whereIn('__id', ids),
1,079✔
1580
        enabledFieldIds
1,079✔
1581
      );
1582
      return { ids, extra: { groupPoints, searchHitIndex, allGroupHeaderRefs } };
1,079✔
1583
    } catch (e) {
1,079✔
UNCOV
1584
      this.logger.error(`Get search index error: ${(e as Error).message}`, (e as Error)?.stack);
×
UNCOV
1585
    }
×
1586

1587
    return { ids, extra: { groupPoints, allGroupHeaderRefs } };
×
UNCOV
1588
  }
×
1589

1590
  async getSearchFields(
265✔
1591
    originFieldInstanceMap: Record<string, IFieldInstance>,
332✔
1592
    search?: [string, string?, boolean?],
332✔
1593
    viewId?: string,
332✔
1594
    projection?: string[]
332✔
1595
  ) {
332✔
1596
    const maxSearchFieldCount = process.env.MAX_SEARCH_FIELD_COUNT
332✔
UNCOV
1597
      ? toNumber(process.env.MAX_SEARCH_FIELD_COUNT)
✔
1598
      : DEFAULT_MAX_SEARCH_FIELD_COUNT;
332✔
1599
    let viewColumnMeta: IGridColumnMeta | null = null;
332✔
1600
    const fieldInstanceMap = projection?.length === 0 ? {} : { ...originFieldInstanceMap };
332✔
1601
    if (!search) {
332✔
1602
      return [] as IFieldInstance[];
234✔
1603
    }
234✔
1604

1605
    const isSearchAllFields = !search?.[1];
332✔
1606

1607
    if (viewId) {
332✔
1608
      const { columnMeta: viewColumnRawMeta } =
90✔
1609
        (await this.prismaService.view.findUnique({
90✔
1610
          where: { id: viewId, deletedTime: null },
90✔
1611
          select: { columnMeta: true },
90✔
1612
        })) || {};
90✔
1613

1614
      viewColumnMeta = viewColumnRawMeta ? JSON.parse(viewColumnRawMeta) : null;
90✔
1615

1616
      if (viewColumnMeta) {
90✔
1617
        Object.entries(viewColumnMeta).forEach(([key, value]) => {
90✔
1618
          if (get(value, ['hidden'])) {
708✔
UNCOV
1619
            delete fieldInstanceMap[key];
×
UNCOV
1620
          }
×
1621
        });
708✔
1622
      }
90✔
1623
    }
90✔
1624

1625
    if (projection?.length) {
332✔
UNCOV
1626
      Object.keys(fieldInstanceMap).forEach((fieldId) => {
×
UNCOV
1627
        if (!projection.includes(fieldId)) {
×
UNCOV
1628
          delete fieldInstanceMap[fieldId];
×
1629
        }
×
1630
      });
×
1631
    }
✔
1632

1633
    return uniqBy(
98✔
1634
      orderBy(
98✔
1635
        Object.values(fieldInstanceMap)
98✔
1636
          .map((field) => ({
98✔
1637
            ...field,
1,069✔
1638
            isStructuredCellValue: field.isStructuredCellValue,
1,069✔
1639
          }))
1,069✔
1640
          .filter((field) => {
98✔
1641
            if (!viewColumnMeta) {
1,069✔
1642
              return true;
68✔
1643
            }
68✔
1644
            return !viewColumnMeta?.[field.id]?.hidden;
1,001✔
1645
          })
1,069✔
1646
          .filter((field) => {
98✔
1647
            if (!projection) {
1,069✔
1648
              return true;
1,069✔
1649
            }
1,069✔
UNCOV
1650
            return projection.includes(field.id);
×
UNCOV
1651
          })
×
1652
          .filter((field) => {
98✔
1653
            if (isSearchAllFields) {
1,069✔
1654
              return true;
180✔
1655
            }
180✔
1656

1657
            const searchArr = search?.[1]?.split(',') || [];
1,069✔
1658
            return searchArr.includes(field.id);
1,069✔
1659
          })
1,069✔
1660
          .filter((field) => {
98✔
1661
            if (
283✔
1662
              [CellValueType.Boolean, CellValueType.DateTime].includes(field.cellValueType) &&
283✔
1663
              isSearchAllFields
30✔
1664
            ) {
283✔
1665
              return false;
18✔
1666
            }
18✔
1667
            if (field.cellValueType === CellValueType.Boolean) {
283✔
1668
              return false;
3✔
1669
            }
3✔
1670
            return true;
262✔
1671
          })
262✔
1672
          .filter((field) => {
98✔
1673
            if (field.type === FieldType.Button) {
262✔
UNCOV
1674
              return false;
×
UNCOV
1675
            }
×
1676
            return true;
262✔
1677
          })
262✔
1678
          .map((field) => {
98✔
1679
            return {
262✔
1680
              ...field,
262✔
1681
              order: viewColumnMeta?.[field.id]?.order ?? Number.MIN_SAFE_INTEGER,
262✔
1682
            };
262✔
1683
          }),
262✔
1684
        ['order', 'createTime']
98✔
1685
      ),
1686
      'id'
98✔
1687
    ).slice(0, maxSearchFieldCount) as unknown as IFieldInstance[];
98✔
1688
  }
98✔
1689

1690
  private async getSearchHitIndex(
265✔
1691
    tableId: string,
1,079✔
1692
    query: IGetRecordsRo,
1,079✔
1693
    builder: Knex.QueryBuilder,
1,079✔
1694
    enabledFieldIds?: string[]
1,079✔
1695
  ) {
1,079✔
1696
    const { search, viewId, projection, ignoreViewQuery } = query;
1,079✔
1697

1698
    if (!search) {
1,079✔
1699
      return null;
1,037✔
1700
    }
1,037✔
1701

1702
    const fieldsRaw = await this.dataLoaderService.field.load(tableId, {
42✔
1703
      id: enabledFieldIds,
42✔
1704
    });
42✔
1705

1706
    const fieldInstances = fieldsRaw.map((field) => createFieldInstanceByRaw(field));
42✔
1707
    const fieldInstanceMap = fieldInstances.reduce(
42✔
1708
      (map, field) => {
42✔
1709
        map[field.id] = field;
344✔
1710
        return map;
344✔
1711
      },
344✔
1712
      {} as Record<string, IFieldInstance>
42✔
1713
    );
1714
    const searchFields = await this.getSearchFields(
42✔
1715
      fieldInstanceMap,
42✔
1716
      search,
42✔
1717
      ignoreViewQuery ? undefined : viewId,
1,079✔
1718
      projection
1,079✔
1719
    );
1720

1721
    const tableIndex = await this.tableIndexService.getActivatedTableIndexes(tableId);
42✔
1722

1723
    if (searchFields.length === 0) {
45✔
1724
      return null;
1✔
1725
    }
1✔
1726

1727
    const newQuery = this.knex
41✔
1728
      .with('current_page_records', builder)
41✔
1729
      .with('search_index', (qb) => {
41✔
1730
        this.dbProvider.searchIndexQuery(
41✔
1731
          qb,
41✔
1732
          'current_page_records',
41✔
1733
          searchFields,
41✔
1734
          {
41✔
1735
            search,
41✔
1736
          },
41✔
1737
          tableIndex,
41✔
1738
          undefined,
41✔
1739
          undefined,
41✔
1740
          undefined
41✔
1741
        );
1742
      })
41✔
1743
      .from('search_index');
41✔
1744

1745
    const searchQuery = newQuery.toQuery();
41✔
1746

1747
    this.logger.debug('getSearchHitIndex query: %s', searchQuery);
41✔
1748

1749
    const result =
41✔
1750
      await this.prismaService.$queryRawUnsafe<{ __id: string; fieldId: string }[]>(searchQuery);
41✔
1751

1752
    if (!result.length) {
45✔
1753
      return null;
4✔
1754
    }
4✔
1755

1756
    return result.map((res) => ({
37✔
1757
      fieldId: res.fieldId,
93✔
1758
      recordId: res.__id,
93✔
1759
    }));
93✔
1760
  }
37✔
1761

1762
  async getRecordsFields(
265✔
1763
    tableId: string,
97✔
1764
    query: IGetRecordsRo
97✔
1765
  ): Promise<Pick<IRecord, 'id' | 'fields'>[]> {
97✔
1766
    if (identify(tableId) !== IdPrefix.Table) {
97✔
UNCOV
1767
      throw new InternalServerErrorException('query collection must be table id');
×
UNCOV
1768
    }
×
1769

1770
    const {
97✔
1771
      skip,
97✔
1772
      take,
97✔
1773
      orderBy,
97✔
1774
      search,
97✔
1775
      groupBy,
97✔
1776
      collapsedGroupIds,
97✔
1777
      fieldKeyType,
97✔
1778
      cellFormat,
97✔
1779
      projection,
97✔
1780
      viewId,
97✔
1781
      ignoreViewQuery,
97✔
1782
      filterLinkCellCandidate,
97✔
1783
      filterLinkCellSelected,
97✔
1784
    } = query;
97✔
1785

1786
    const fields = await this.getFieldsByProjection(
97✔
1787
      tableId,
97✔
1788
      this.convertProjection(projection),
97✔
1789
      fieldKeyType
97✔
1790
    );
1791

1792
    const { filter: filterWithGroup } = await this.getGroupRelatedData(tableId, query);
97✔
1793

1794
    const { queryBuilder } = await this.buildFilterSortQuery(tableId, {
97✔
1795
      viewId,
97✔
1796
      ignoreViewQuery,
97✔
1797
      filter: filterWithGroup,
97✔
1798
      orderBy,
97✔
1799
      search,
97✔
1800
      groupBy,
97✔
1801
      collapsedGroupIds,
97✔
1802
      filterLinkCellCandidate,
97✔
1803
      filterLinkCellSelected,
97✔
1804
      skip,
97✔
1805
      take,
97✔
1806
    });
97✔
1807
    skip && queryBuilder.offset(skip);
97✔
1808
    take !== -1 && take && queryBuilder.limit(take);
97✔
1809
    const sql = queryBuilder.toQuery();
97✔
1810

1811
    this.logger.debug('getRecordsFields query: %s', sql);
97✔
1812

1813
    const result = await this.prismaService
97✔
1814
      .txClient()
97✔
1815
      .$queryRawUnsafe<(Pick<IRecord, 'fields'> & Pick<IVisualTableDefaultField, '__id'>)[]>(sql);
97✔
1816

1817
    return result.map((record) => {
97✔
1818
      return {
20,353✔
1819
        id: record.__id,
20,353✔
1820
        fields: this.dbRecord2RecordFields(record, fields, fieldKeyType, cellFormat),
20,353✔
1821
      };
20,353✔
1822
    });
20,353✔
1823
  }
97✔
1824

1825
  private async getPrimaryField(tableId: string) {
265✔
1826
    const field = await this.dataLoaderService.field.load(tableId, {
7,567✔
1827
      isPrimary: [true],
7,567✔
1828
    });
7,567✔
1829
    if (!field.length) {
7,567✔
UNCOV
1830
      throw new BadRequestException(`Could not find primary index ${tableId}`);
×
UNCOV
1831
    }
×
1832
    return createFieldInstanceByRaw(field[0]);
7,567✔
1833
  }
7,567✔
1834

1835
  async getRecordsHeadWithTitles(tableId: string, titles: string[]) {
265✔
1836
    const dbTableName = await this.getDbTableName(tableId);
14✔
1837
    const field = await this.getPrimaryField(tableId);
14✔
1838

1839
    // only text field support type cast to title
14✔
1840
    if (field.dbFieldType !== DbFieldType.Text) {
14✔
UNCOV
1841
      return [];
×
UNCOV
1842
    }
×
1843

1844
    const queryBuilder = this.knex(dbTableName)
14✔
1845
      .select({ title: field.dbFieldName, id: '__id' })
14✔
1846
      .whereIn(field.dbFieldName, titles);
14✔
1847

1848
    const querySql = queryBuilder.toQuery();
14✔
1849

1850
    return this.prismaService.txClient().$queryRawUnsafe<{ id: string; title: string }[]>(querySql);
14✔
1851
  }
14✔
1852

1853
  async getRecordsHeadWithIds(tableId: string, recordIds: string[]) {
265✔
1854
    const dbTableName = await this.getDbTableName(tableId);
10✔
1855
    const field = await this.getPrimaryField(tableId);
10✔
1856

1857
    const queryBuilder = this.knex(dbTableName)
10✔
1858
      .select({ title: field.dbFieldName, id: '__id' })
10✔
1859
      .whereIn('__id', recordIds);
10✔
1860

1861
    const querySql = queryBuilder.toQuery();
10✔
1862

1863
    const result = await this.prismaService
10✔
1864
      .txClient()
10✔
1865
      .$queryRawUnsafe<{ id: string; title: unknown }[]>(querySql);
10✔
1866

1867
    return result.map((r) => ({
10✔
1868
      id: r.id,
11✔
1869
      title: field.cellValue2String(r.title),
11✔
1870
    }));
11✔
1871
  }
10✔
1872

1873
  async filterRecordIdsByFilter(
265✔
UNCOV
1874
    tableId: string,
×
UNCOV
1875
    recordIds: string[],
×
UNCOV
1876
    filter?: IFilter | null
×
UNCOV
1877
  ): Promise<string[]> {
×
UNCOV
1878
    const { queryBuilder, alias } = await this.buildFilterSortQuery(tableId, {
×
UNCOV
1879
      filter,
×
UNCOV
1880
    });
×
UNCOV
1881
    queryBuilder.whereIn(`${alias}.__id`, recordIds);
×
UNCOV
1882
    const result = await this.prismaService
×
UNCOV
1883
      .txClient()
×
UNCOV
1884
      .$queryRawUnsafe<{ __id: string }[]>(queryBuilder.toQuery());
×
UNCOV
1885
    return result.map((r) => r.__id);
×
UNCOV
1886
  }
×
1887

1888
  async getDiffIdsByIdAndFilter(tableId: string, recordIds: string[], filter?: IFilter | null) {
265✔
UNCOV
1889
    const ids = await this.filterRecordIdsByFilter(tableId, recordIds, filter);
×
UNCOV
1890
    return difference(recordIds, ids);
×
UNCOV
1891
  }
×
1892

1893
  private sortGroupRawResult(
265✔
1894
    groupResult: { [key: string]: unknown; __c: number }[],
25✔
1895
    groupFields: IFieldInstance[],
25✔
1896
    groupBy?: IGroup
25✔
1897
  ) {
25✔
1898
    if (!groupResult.length || !groupBy?.length) {
25✔
1899
      return groupResult;
×
1900
    }
×
1901

1902
    const comparators = groupBy
25✔
1903
      .map((groupItem, index) => {
25✔
1904
        const field = groupFields[index];
25✔
1905

1906
        if (!field) {
25✔
UNCOV
1907
          return undefined;
×
UNCOV
1908
        }
×
1909

1910
        const { dbFieldName } = field;
25✔
1911
        const order = groupItem.order ?? SortFunc.Asc;
25✔
1912
        const selectOrderMap =
25✔
1913
          field.type === FieldType.SingleSelect
25✔
1914
            ? new Map(
5✔
1915
                ((field.options as ISelectFieldOptions | undefined)?.choices ?? []).map(
5✔
1916
                  (choice, idx) => [choice.name, idx]
5✔
1917
                )
1918
              )
1919
            : null;
20✔
1920

1921
        return (
25✔
1922
          left: { [key: string]: unknown; __c: number },
896✔
1923
          right: { [key: string]: unknown; __c: number }
896✔
1924
        ) => {
1925
          const leftValue = convertValueToStringify(left[dbFieldName]);
896✔
1926
          const rightValue = convertValueToStringify(right[dbFieldName]);
896✔
1927

1928
          if (selectOrderMap) {
896✔
1929
            return this.compareSelectGroupValues(selectOrderMap, leftValue, rightValue, order);
30✔
1930
          }
30✔
1931

1932
          return this.compareGroupValues(leftValue, rightValue, order);
866✔
1933
        };
866✔
1934
      })
25✔
1935
      .filter(Boolean) as ((
25✔
1936
      left: { [key: string]: unknown; __c: number },
1937
      right: { [key: string]: unknown; __c: number }
1938
    ) => number)[];
1939

1940
    if (!comparators.length) {
25✔
UNCOV
1941
      return groupResult;
×
UNCOV
1942
    }
×
1943

1944
    return [...groupResult].sort((left, right) => {
25✔
1945
      for (const comparator of comparators) {
896✔
1946
        const result = comparator(left, right);
896✔
1947
        if (result !== 0) {
896✔
1948
          return result;
896✔
1949
        }
896✔
1950
      }
896✔
UNCOV
1951
      return 0;
×
UNCOV
1952
    });
×
1953
  }
25✔
1954

1955
  // eslint-disable-next-line sonarjs/cognitive-complexity
265✔
1956
  private compareGroupValues(
265✔
1957
    left: number | string | null,
876✔
1958
    right: number | string | null,
876✔
1959
    order: SortFunc
876✔
1960
  ): number {
876✔
1961
    if (left === right) {
876✔
1962
      return 0;
×
UNCOV
1963
    }
×
1964

1965
    const isDesc = order === SortFunc.Desc;
876✔
1966
    const leftIsNull = left == null;
876✔
1967
    const rightIsNull = right == null;
876✔
1968

1969
    if (leftIsNull || rightIsNull) {
876✔
1970
      if (leftIsNull && rightIsNull) {
70✔
1971
        return 0;
×
1972
      }
×
1973

1974
      if (leftIsNull) {
70✔
1975
        return isDesc ? 1 : -1;
15✔
1976
      }
15✔
1977

1978
      return isDesc ? -1 : 1;
70✔
1979
    }
70✔
1980

1981
    if (typeof left === 'number' && typeof right === 'number') {
876✔
1982
      const diff = left - right;
282✔
1983
      if (diff === 0) {
282✔
UNCOV
1984
        return 0;
×
UNCOV
1985
      }
×
1986
      return isDesc ? -diff : diff;
282✔
1987
    }
282✔
1988

1989
    const leftString = String(left);
524✔
1990
    const rightString = String(right);
524✔
1991

1992
    if (leftString === rightString) {
876✔
UNCOV
1993
      return 0;
×
UNCOV
1994
    }
✔
1995

1996
    if (leftString < rightString) {
876✔
1997
      return isDesc ? 1 : -1;
215✔
1998
    }
215✔
1999

2000
    return isDesc ? -1 : 1;
876✔
2001
  }
876✔
2002

2003
  private compareSelectGroupValues(
265✔
2004
    orderMap: Map<string, number>,
30✔
2005
    left: number | string | null,
30✔
2006
    right: number | string | null,
30✔
2007
    order: SortFunc
30✔
2008
  ): number {
30✔
2009
    if (left === right) {
30✔
UNCOV
2010
      return 0;
×
UNCOV
2011
    }
×
2012

2013
    if (left == null || right == null) {
30✔
2014
      return this.compareGroupValues(left, right, order);
10✔
2015
    }
10✔
2016

2017
    const getRank = (value: number | string): number => {
20✔
2018
      if (typeof value === 'string') {
40✔
2019
        const index = orderMap.get(value);
40✔
2020
        if (index != null) {
40✔
2021
          return index + 1;
40✔
2022
        }
40✔
2023
      }
40✔
UNCOV
2024
      return -1;
×
UNCOV
2025
    };
×
2026

2027
    const leftRank = getRank(left);
20✔
2028
    const rightRank = getRank(right);
20✔
2029

2030
    if (leftRank === rightRank) {
30✔
2031
      return this.compareGroupValues(left, right, order);
×
UNCOV
2032
    }
✔
2033

2034
    const diff = leftRank - rightRank;
20✔
2035
    return order === SortFunc.Desc ? -diff : diff;
30✔
2036
  }
30✔
2037

2038
  @Timing()
265✔
2039
  // eslint-disable-next-line sonarjs/cognitive-complexity
2040
  private async groupDbCollection2GroupPoints(
25✔
2041
    groupResult: { [key: string]: unknown; __c: number }[],
25✔
2042
    groupFields: IFieldInstance[],
25✔
2043
    groupBy: IGroup | undefined,
25✔
2044
    collapsedGroupIds: string[] | undefined,
25✔
2045
    rowCount: number
25✔
2046
  ) {
25✔
2047
    const groupPoints: IGroupPoint[] = [];
25✔
2048
    const allGroupHeaderRefs: IGroupHeaderRef[] = [];
25✔
2049
    const collapsedGroupIdsSet = new Set(collapsedGroupIds);
25✔
2050
    let fieldValues: unknown[] = [Symbol(), Symbol(), Symbol()];
25✔
2051
    let curRowCount = 0;
25✔
2052
    let collapsedDepth = Number.MAX_SAFE_INTEGER;
25✔
2053

2054
    const sortedGroupResult = this.sortGroupRawResult(groupResult, groupFields, groupBy);
25✔
2055

2056
    for (let i = 0; i < sortedGroupResult.length; i++) {
25✔
2057
      const item = sortedGroupResult[i];
305✔
2058
      const { __c: count } = item;
305✔
2059

2060
      for (let index = 0; index < groupFields.length; index++) {
305✔
2061
        const field = groupFields[index];
305✔
2062
        const { id, dbFieldName } = field;
305✔
2063
        const fieldValue = convertValueToStringify(item[dbFieldName]);
305✔
2064

2065
        if (fieldValues[index] === fieldValue) continue;
305✔
2066

2067
        const flagString = `${id}_${[...fieldValues.slice(0, index), fieldValue].join('_')}`;
305✔
2068
        const groupId = String(string2Hash(flagString));
305✔
2069

2070
        allGroupHeaderRefs.push({ id: groupId, depth: index });
305✔
2071

2072
        if (index > collapsedDepth) break;
305✔
2073

2074
        // Reset the collapsedDepth when encountering the next peer grouping
305✔
2075
        collapsedDepth = Number.MAX_SAFE_INTEGER;
305✔
2076

2077
        fieldValues[index] = fieldValue;
305✔
2078
        fieldValues = fieldValues.map((value, idx) => (idx > index ? Symbol() : value));
305✔
2079

2080
        const isCollapsedInner = collapsedGroupIdsSet.has(groupId) ?? false;
305✔
2081
        let value = field.convertDBValue2CellValue(fieldValue);
305✔
2082

2083
        if (field.type === FieldType.Attachment) {
305✔
UNCOV
2084
          value = await this.getAttachmentPresignedCellValue(value as IAttachmentCellValue);
×
UNCOV
2085
        }
×
2086

2087
        groupPoints.push({
305✔
2088
          id: groupId,
305✔
2089
          type: GroupPointType.Header,
305✔
2090
          depth: index,
305✔
2091
          value,
305✔
2092
          isCollapsed: isCollapsedInner,
305✔
2093
        });
305✔
2094

2095
        if (isCollapsedInner) {
305✔
2096
          collapsedDepth = index;
1✔
2097
        }
1✔
2098
      }
305✔
2099

2100
      curRowCount += Number(count);
305✔
2101
      if (collapsedDepth !== Number.MAX_SAFE_INTEGER) continue;
305✔
2102
      groupPoints.push({ type: GroupPointType.Row, count: Number(count) });
304✔
2103
    }
304✔
2104

2105
    if (curRowCount < rowCount) {
25✔
UNCOV
2106
      groupPoints.push(
×
UNCOV
2107
        {
×
UNCOV
2108
          id: 'unknown',
×
UNCOV
2109
          type: GroupPointType.Header,
×
UNCOV
2110
          depth: 0,
×
UNCOV
2111
          value: 'Unknown',
×
UNCOV
2112
          isCollapsed: false,
×
UNCOV
2113
        },
×
UNCOV
2114
        { type: GroupPointType.Row, count: rowCount - curRowCount }
×
2115
      );
UNCOV
2116
    }
×
2117

2118
    return {
25✔
2119
      groupPoints,
25✔
2120
      allGroupHeaderRefs,
25✔
2121
    };
25✔
2122
  }
25✔
2123

2124
  private getFilterByCollapsedGroup({
265✔
2125
    groupBy,
25✔
2126
    groupPoints,
25✔
2127
    fieldInstanceMap,
25✔
2128
    collapsedGroupIds,
25✔
2129
  }: {
2130
    groupBy: IGroup;
2131
    groupPoints: IGroupPointsVo;
2132
    fieldInstanceMap: Record<string, IFieldInstance>;
2133
    collapsedGroupIds?: string[];
2134
  }) {
25✔
2135
    if (!groupBy?.length || groupPoints == null || collapsedGroupIds == null) return null;
25✔
2136
    const groupIds: string[] = [];
1✔
2137
    const groupId2DataMap = groupPoints.reduce(
1✔
2138
      (prev, cur) => {
1✔
2139
        if (cur.type !== GroupPointType.Header) {
7✔
2140
          return prev;
3✔
2141
        }
3✔
2142
        const { id, depth } = cur;
4✔
2143

2144
        groupIds[depth] = id;
4✔
2145
        prev[id] = { ...cur, path: groupIds.slice(0, depth + 1) };
4✔
2146
        return prev;
4✔
2147
      },
4✔
2148
      {} as Record<string, IGroupHeaderPoint & { path: string[] }>
1✔
2149
    );
2150

2151
    const filterQuery: IFilter = {
1✔
2152
      conjunction: and.value,
1✔
2153
      filterSet: [],
1✔
2154
    };
1✔
2155

2156
    for (const groupId of collapsedGroupIds) {
1✔
2157
      const groupData = groupId2DataMap[groupId];
1✔
2158

2159
      if (groupData == null) continue;
1✔
2160

2161
      const { path } = groupData;
1✔
2162
      const innerFilterSet: IFilterSet = {
1✔
2163
        conjunction: or.value,
1✔
2164
        filterSet: [],
1✔
2165
      };
1✔
2166

2167
      path.forEach((pathGroupId) => {
1✔
2168
        const pathGroupData = groupId2DataMap[pathGroupId];
1✔
2169

2170
        if (pathGroupData == null) return;
1✔
2171

2172
        const { depth } = pathGroupData;
1✔
2173
        const curGroup = groupBy[depth];
1✔
2174

2175
        if (curGroup == null) return;
1✔
2176

2177
        const { fieldId } = curGroup;
1✔
2178
        const field = fieldInstanceMap[fieldId];
1✔
2179

2180
        if (field == null) return;
1✔
2181

2182
        const filterItem = generateFilterItem(field, pathGroupData.value);
1✔
2183
        innerFilterSet.filterSet.push(filterItem);
1✔
2184
      });
1✔
2185

2186
      filterQuery.filterSet.push(innerFilterSet);
1✔
2187
    }
1✔
2188

2189
    return filterQuery;
1✔
2190
  }
1✔
2191

2192
  async getRowCountByFilter(
265✔
2193
    dbTableName: string,
25✔
2194
    fieldInstanceMap: Record<string, IFieldInstance>,
25✔
2195
    tableId: string,
25✔
2196
    filter?: IFilter,
25✔
2197
    search?: [string, string?, boolean?],
25✔
2198
    viewId?: string,
25✔
2199
    useQueryModel = false
25✔
2200
  ) {
25✔
2201
    const withUserId = this.cls.get('user.id');
25✔
2202

2203
    const { qb, selectionMap } = await this.recordQueryBuilder.createRecordAggregateBuilder(
25✔
2204
      dbTableName,
25✔
2205
      {
25✔
2206
        tableIdOrDbTableName: tableId,
25✔
2207
        aggregationFields: [],
25✔
2208
        viewId,
25✔
2209
        filter,
25✔
2210
        currentUserId: withUserId,
25✔
2211
        useQueryModel,
25✔
2212
      }
25✔
2213
    );
2214

2215
    if (search && search[2]) {
25✔
UNCOV
2216
      const searchFields = await this.getSearchFields(fieldInstanceMap, search, viewId);
×
UNCOV
2217
      const tableIndex = await this.tableIndexService.getActivatedTableIndexes(tableId);
×
UNCOV
2218
      qb.where((builder) => {
×
UNCOV
2219
        this.dbProvider.searchQuery(builder, searchFields, tableIndex, search, { selectionMap });
×
UNCOV
2220
      });
×
UNCOV
2221
    }
×
2222

2223
    const rowCountSql = qb.count({ count: '*' });
25✔
2224
    const sql = rowCountSql.toQuery();
25✔
2225
    this.logger.debug('getRowCountSql: %s', sql);
25✔
2226
    const result = await this.prismaService.$queryRawUnsafe<{ count?: number }[]>(sql);
25✔
2227
    return Number(result[0].count);
25✔
2228
  }
25✔
2229

2230
  public async getGroupRelatedData(tableId: string, query?: IGetRecordsRo, useQueryModel = false) {
265✔
2231
    const { groupBy: extraGroupBy, filter, search, ignoreViewQuery, queryId } = query || {};
1,182✔
2232
    let groupPoints: IGroupPoint[] = [];
1,182✔
2233
    let allGroupHeaderRefs: IGroupHeaderRef[] = [];
1,182✔
2234
    let collapsedGroupIds = query?.collapsedGroupIds;
1,182✔
2235

2236
    if (queryId) {
1,182✔
2237
      const cacheKey = `query-params:${queryId}` as const;
×
2238
      const cache = await this.cacheService.get(cacheKey);
×
2239
      if (cache) {
×
2240
        collapsedGroupIds = (cache.queryParams as IGetRecordsRo)?.collapsedGroupIds;
×
2241
      }
×
2242
    }
×
2243

2244
    const fullGroupBy = parseGroup(extraGroupBy);
1,182✔
2245

2246
    if (!fullGroupBy?.length) {
1,182✔
2247
      return {
1,157✔
2248
        groupPoints,
1,157✔
2249
        filter,
1,157✔
2250
      };
1,157✔
2251
    }
1,157✔
2252

2253
    const viewId = ignoreViewQuery ? undefined : query?.viewId;
1,182✔
2254
    const viewRaw = await this.getTinyView(tableId, viewId);
1,182✔
2255
    const { viewCte, builder, enabledFieldIds } = await this.recordPermissionService.wrapView(
25✔
2256
      tableId,
25✔
2257
      this.knex.queryBuilder(),
25✔
2258
      {
25✔
2259
        keepPrimaryKey: Boolean(query?.filterLinkCellSelected),
25✔
2260
        viewId,
1,182✔
2261
      }
1,182✔
2262
    );
2263
    const fieldInstanceMap = (await this.getNecessaryFieldMap(
25✔
2264
      tableId,
25✔
2265
      filter,
25✔
2266
      undefined,
25✔
2267
      fullGroupBy,
25✔
2268
      search,
25✔
2269
      enabledFieldIds
25✔
2270
    ))!;
2271
    const groupBy = fullGroupBy.filter((item) => fieldInstanceMap[item.fieldId]);
25✔
2272

2273
    if (!groupBy?.length) {
1,182✔
UNCOV
2274
      return {
×
UNCOV
2275
        groupPoints,
×
UNCOV
2276
        filter,
×
UNCOV
2277
      };
×
UNCOV
2278
    }
✔
2279

2280
    const dbTableName = await this.getDbTableName(tableId);
25✔
2281

2282
    const filterStr = viewRaw?.filter;
28✔
2283
    const mergedFilter = mergeWithDefaultFilter(filterStr, filter);
1,182✔
2284
    const groupFieldIds = groupBy.map((item) => item.fieldId);
1,182✔
2285

2286
    const withUserId = this.cls.get('user.id');
1,182✔
2287
    const shouldUseQueryModel = useQueryModel && !viewCte;
1,182✔
2288
    const { qb: queryBuilder, selectionMap } =
1,182✔
2289
      await this.recordQueryBuilder.createRecordAggregateBuilder(viewCte ?? dbTableName, {
1,182✔
2290
        tableIdOrDbTableName: tableId,
1,182✔
2291
        viewId,
1,182✔
2292
        filter: mergedFilter,
1,182✔
2293
        aggregationFields: [
1,182✔
2294
          {
1,182✔
2295
            fieldId: '*',
1,182✔
2296
            statisticFunc: StatisticsFunc.Count,
1,182✔
2297
            alias: '__c',
1,182✔
2298
          },
1,182✔
2299
        ],
1,182✔
2300
        groupBy,
1,182✔
2301
        currentUserId: withUserId,
1,182✔
2302
        useQueryModel: shouldUseQueryModel,
1,182✔
2303
      });
1,182✔
2304

2305
    // Attach permission CTE to the aggregate query when using the permission view.
25✔
2306
    await this.recordPermissionService.wrapView(tableId, queryBuilder, {
25✔
2307
      viewId,
25✔
2308
      keepPrimaryKey: Boolean(query?.filterLinkCellSelected),
25✔
2309
    });
1,182✔
2310

2311
    if (search && search[2]) {
1,182✔
UNCOV
2312
      const searchFields = await this.getSearchFields(fieldInstanceMap, search, viewId);
×
UNCOV
2313
      const tableIndex = await this.tableIndexService.getActivatedTableIndexes(tableId);
×
UNCOV
2314
      queryBuilder.where((builder) => {
×
UNCOV
2315
        this.dbProvider.searchQuery(builder, searchFields, tableIndex, search, { selectionMap });
×
UNCOV
2316
      });
×
UNCOV
2317
    }
✔
2318

2319
    queryBuilder.limit(this.thresholdConfig.maxGroupPoints);
25✔
2320

2321
    const groupSql = queryBuilder.toQuery();
25✔
2322
    this.logger.debug('groupSql: %s', groupSql);
25✔
2323
    const groupFields = groupFieldIds.map((fieldId) => fieldInstanceMap[fieldId]).filter(Boolean);
25✔
2324
    const rowCount = await this.getRowCountByFilter(
25✔
2325
      dbTableName,
25✔
2326
      fieldInstanceMap,
25✔
2327
      tableId,
25✔
2328
      mergedFilter,
25✔
2329
      search,
25✔
2330
      viewId,
25✔
2331
      useQueryModel
25✔
2332
    );
2333

2334
    try {
25✔
2335
      const result =
25✔
2336
        await this.prismaService.$queryRawUnsafe<{ [key: string]: unknown; __c: number }[]>(
25✔
2337
          groupSql
25✔
2338
        );
2339
      const pointsResult = await this.groupDbCollection2GroupPoints(
25✔
2340
        result,
25✔
2341
        groupFields,
25✔
2342
        groupBy,
25✔
2343
        collapsedGroupIds,
25✔
2344
        rowCount
25✔
2345
      );
2346
      groupPoints = pointsResult.groupPoints;
25✔
2347
      allGroupHeaderRefs = pointsResult.allGroupHeaderRefs;
25✔
2348
    } catch (error) {
28✔
UNCOV
2349
      this.logger.error(`Get group points error in table ${tableId}: `, error);
×
UNCOV
2350
    }
✔
2351

2352
    const filterWithCollapsed = this.getFilterByCollapsedGroup({
25✔
2353
      groupBy,
25✔
2354
      groupPoints,
25✔
2355
      fieldInstanceMap,
25✔
2356
      collapsedGroupIds,
25✔
2357
    });
25✔
2358

2359
    return { groupPoints, allGroupHeaderRefs, filter: mergeFilter(filter, filterWithCollapsed) };
25✔
2360
  }
25✔
2361

2362
  async getRecordStatus(
265✔
UNCOV
2363
    tableId: string,
×
UNCOV
2364
    recordId: string,
×
UNCOV
2365
    query: IGetRecordsRo
×
UNCOV
2366
  ): Promise<IRecordStatusVo> {
×
UNCOV
2367
    const dbTableName = await this.getDbTableName(tableId);
×
UNCOV
2368
    const queryBuilder = this.knex(dbTableName).select('__id').where('__id', recordId).limit(1);
×
2369

UNCOV
2370
    const result = await this.prismaService
×
UNCOV
2371
      .txClient()
×
UNCOV
2372
      .$queryRawUnsafe<{ __id: string }[]>(queryBuilder.toQuery());
×
2373

UNCOV
2374
    const isDeleted = result.length === 0;
×
2375

2376
    if (isDeleted) {
×
UNCOV
2377
      return { isDeleted, isVisible: false };
×
UNCOV
2378
    }
×
2379

UNCOV
2380
    const queryResult = await this.getDocIdsByQuery(tableId, {
×
UNCOV
2381
      ignoreViewQuery: query.ignoreViewQuery ?? false,
×
UNCOV
2382
      viewId: query.viewId,
×
UNCOV
2383
      skip: query.skip,
×
UNCOV
2384
      take: query.take,
×
UNCOV
2385
      filter: query.filter,
×
UNCOV
2386
      orderBy: query.orderBy,
×
UNCOV
2387
      search: query.search,
×
UNCOV
2388
      groupBy: query.groupBy,
×
2389
      filterLinkCellCandidate: query.filterLinkCellCandidate,
×
2390
      filterLinkCellSelected: query.filterLinkCellSelected,
×
2391
      selectedRecordIds: query.selectedRecordIds,
×
2392
    });
×
2393
    const isVisible = queryResult.ids.includes(recordId);
×
2394
    return { isDeleted, isVisible };
×
UNCOV
2395
  }
×
2396
}
265✔
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