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

teableio / teable / 18672249384

21 Oct 2025 03:49AM UTC coverage: 75.076% (-0.01%) from 75.089%
18672249384

Pull #2009

github

web-flow
Merge 5316e7019 into 6b1f5ebfe
Pull Request #2009: fix: fix search index & hint

10040 of 10803 branches covered (92.94%)

38 of 62 new or added lines in 4 files covered. (61.29%)

4 existing lines in 1 file now uncovered.

50156 of 66807 relevant lines covered (75.08%)

4476.37 hits per line

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

88.02
/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,512✔
91
  return Object.fromEntries(Object.entries(obj).filter(([_, v]) => v !== undefined)) as T;
38,512✔
92
}
38,512✔
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;
707,912✔
130
  }
707,912✔
131

132
  private dbRecord2RecordFields(
265✔
133
    record: IRecord['fields'],
119,777✔
134
    fields: IFieldInstance[],
119,777✔
135
    fieldKeyType: FieldKeyType = FieldKeyType.Id,
119,777✔
136
    cellFormat: CellFormat = CellFormat.Json
119,777✔
137
  ) {
119,777✔
138
    return fields.reduce<IRecord['fields']>((acc, field) => {
119,777✔
139
      const fieldNameOrId = field[fieldKeyType];
707,912✔
140
      const queryColumnName = this.getQueryColumnName(field);
707,912✔
141
      const dbCellValue = record[queryColumnName];
707,912✔
142
      const cellValue = field.convertDBValue2CellValue(dbCellValue);
707,912✔
143
      if (cellValue != null) {
707,912✔
144
        acc[fieldNameOrId] =
595,627✔
145
          cellFormat === CellFormat.Text ? field.cellValue2String(cellValue) : cellValue;
595,627✔
146
      }
595,627✔
147
      return acc;
707,912✔
148
    }, {});
119,777✔
149
  }
119,777✔
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,850✔
192
      .txClient()
8,850✔
193
      .tableMeta.findUniqueOrThrow({
8,850✔
194
        where: { id: tableId },
8,850✔
195
        select: { dbTableName: true },
8,850✔
196
      })
8,850✔
197
      .catch(() => {
8,850✔
198
        throw new NotFoundException(`Table ${tableId} not found`);
×
199
      });
×
200
    return tableMeta.dbTableName;
8,850✔
201
  }
8,850✔
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,187✔
389
    filter?: IFilter,
1,187✔
390
    orderBy?: ISortItem[],
1,187✔
391
    groupBy?: IGroup,
1,187✔
392
    search?: [string, string?, boolean?],
1,187✔
393
    projection?: string[]
1,187✔
394
  ) {
1,187✔
395
    if (filter || orderBy?.length || groupBy?.length || search) {
1,187✔
396
      // The field Meta is needed to construct the filter if it exists
604✔
397
      const fields = await this.getFieldsByProjection(tableId, this.convertProjection(projection));
604✔
398
      return fields.reduce(
604✔
399
        (map, field) => {
604✔
400
          map[field.id] = field;
6,710✔
401
          map[field.name] = field;
6,710✔
402
          return map;
6,710✔
403
        },
6,710✔
404
        {} as Record<string, IFieldInstance>
604✔
405
      );
406
    }
604✔
407
  }
1,187✔
408

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

522
    if (exists) {
1,167✔
523
      return columnName;
26✔
524
    }
26✔
525
    return '__auto_number';
101✔
526
  }
101✔
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,162✔
541
    query: Pick<
1,162✔
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,162✔
556
    useQueryModel = false
1,162✔
557
  ) {
1,162✔
558
    // Prepare the base query builder, filtering conditions, sorting rules, grouping rules and field mapping
1,162✔
559
    const { dbTableName, viewCte, filter, search, orderBy, groupBy, fieldMap } =
1,162✔
560
      await this.prepareQuery(tableId, query);
1,162✔
561

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

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

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

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

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

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

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

621
    if (search && search[2] && fieldMap) {
1,162✔
622
      // selectionMap is available later in dbProvider.searchQuery, so include computed fields
25✔
623
      const searchFields = await this.getSearchFields(fieldMap, search, query?.viewId, undefined, {
25✔
624
        allowComputed: true,
25✔
625
      });
25✔
626
      const tableIndex = await this.tableIndexService.getActivatedTableIndexes(tableId);
25✔
627
      qb.where((builder) => {
25✔
628
        this.dbProvider.searchQuery(builder, searchFields, tableIndex, search, { selectionMap });
25✔
629
      });
25✔
630
    }
25✔
631

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

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

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

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

661
    if (fieldKeyType === FieldKeyType.Id) {
×
662
      return this.convertProjection(enabledFieldIds);
×
663
    }
×
664

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

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

676
    return fieldKeys.length ? this.convertProjection(fieldKeys) : undefined;
6,152✔
677
  }
6,152✔
678

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

688
    if (!recordSnapshot.length) {
58✔
689
      throw new NotFoundException('Can not get records');
×
690
    }
×
691

692
    return {
58✔
693
      records: recordSnapshot.map((r) => r.data),
58✔
694
    };
58✔
695
  }
58✔
696

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

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

712
    const columnMeta = JSON.parse(view.columnMeta) as IColumnMeta;
92✔
713

714
    const useVisible = Object.values(columnMeta).some((column) => 'visible' in column);
92✔
715
    const useHidden = Object.values(columnMeta).some((column) => 'hidden' in column);
92✔
716

717
    if (!useVisible && !useHidden) {
1,045✔
718
      return;
89✔
719
    }
89✔
720

721
    const fieldRaws = await this.dataLoaderService.field.load(tableId);
3✔
722

723
    const fieldMap = keyBy(fieldRaws, 'id');
3✔
724

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

730
        const fieldKey = field[fieldKeyType];
27✔
731

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

744
        return acc;
27✔
745
      },
27✔
746
      {}
3✔
747
    );
748

749
    return Object.keys(projection).length > 0 ? projection : undefined;
1,045✔
750
  }
1,045✔
751

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

775
    const projection = query.projection
1,056✔
776
      ? this.convertProjection(query.projection)
11✔
777
      : await this.getViewProjection(tableId, query);
1,045✔
778

779
    const recordSnapshot = await this.getSnapshotBulkWithPermission(
1,045✔
780
      tableId,
1,045✔
781
      queryResult.ids,
1,045✔
782
      projection,
1,045✔
783
      query.fieldKeyType || FieldKeyType.Name,
1,056✔
784
      query.cellFormat,
1,056✔
785
      useQueryModel
1,056✔
786
    );
787

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

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

813
    if (!recordSnapshot.length) {
715✔
814
      throw new NotFoundException('Can not get record');
4✔
815
    }
4✔
816

817
    return recordSnapshot[0].data;
711✔
818
  }
711✔
819

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

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

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

835
    return Number(result[0]?.max ?? 0) + 1;
2,503✔
836
  }
2,503✔
837

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

849
    if (recordIds.length !== recordRaw.length) {
48✔
850
      throw new BadRequestException('delete record not found');
×
851
    }
×
852

853
    const recordRawMap = keyBy(recordRaw, 'id');
48✔
854

855
    const dataList = recordIds.map((recordId) => ({
48✔
856
      docId: recordId,
8,285✔
857
      version: recordRawMap[recordId].version,
8,285✔
858
    }));
8,285✔
859

860
    await this.batchService.saveRawOps(tableId, RawOpType.Del, IdPrefix.Record, dataList);
48✔
861

862
    await this.batchDel(tableId, recordIds);
48✔
863
  }
48✔
864

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

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

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

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

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

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

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

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

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

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

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

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

970
    const dataList = snapshots.map((snapshot) => ({
2,501✔
971
      docId: snapshot.__id,
38,510✔
972
      version: snapshot.__version == null ? 0 : snapshot.__version - 1,
38,510✔
973
    }));
38,510✔
974

975
    await this.batchService.saveRawOps(tableId, RawOpType.Create, IdPrefix.Record, dataList);
2,501✔
976
  }
2,501✔
977

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

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

1014
  async creditCheck(tableId: string) {
265✔
1015
    if (!this.thresholdConfig.maxFreeRowLimit) {
2,508✔
1016
      return;
2,502✔
1017
    }
2,502✔
1018

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

1024
    const rowCount = await this.getAllRecordCount(table.dbTableName);
6✔
1025

1026
    const maxRowCount =
6✔
1027
      table.base.space.credit == null
6✔
1028
        ? this.thresholdConfig.maxFreeRowLimit
6✔
1029
        : table.base.space.credit;
2,508✔
1030

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

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

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

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

1071
    const maxRecordOrder = await this.getMaxRecordOrder(dbTableName);
2,503✔
1072

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

1078
    const allViewIndexes = await this.getAllViewIndexesField(dbTableName);
2,503✔
1079

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

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

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

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

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

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

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

1177
    return snapshots;
2,501✔
1178
  }
2,501✔
1179

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

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

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

1202
    return fields.map((field) => createFieldInstanceByRaw(field));
8,175✔
1203
  }
8,175✔
1204

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

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

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

1313
          record.data.fields[fieldKey] = presignedCellValue;
48✔
1314
        }
48✔
1315
      }
673✔
1316
    }
6,741✔
1317
    return records;
664✔
1318
  }
664✔
1319

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

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

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

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

1405
    this.logger.debug('getSnapshotBulkInner query %s', nativeQuery);
7,470✔
1406

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

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

1421
    recordIds.forEach((recordId) => {
7,470✔
1422
      if (!(recordId in recordIdsMap)) {
99,441✔
1423
        throw new NotFoundException(`Record ${recordId} not found`);
×
1424
      }
×
1425
    });
99,441✔
1426

1427
    const primaryField = await this.getPrimaryField(tableId);
7,470✔
1428

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

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

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

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

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

1521
    if (take > 1000) {
1,062✔
1522
      throw new BadRequestException(`limit can't be greater than ${take}`);
×
1523
    }
×
1524

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

1547
    // queryBuilder.select(this.knex.ref(`${selectDbTableName}.__id`));
1,062✔
1548

1549
    skip && queryBuilder.offset(skip);
1,062✔
1550
    if (take !== -1) {
1,062✔
1551
      queryBuilder.limit(take);
1,060✔
1552
    }
1,060✔
1553

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

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

UNCOV
1589
    return { ids, extra: { groupPoints, allGroupHeaderRefs } };
×
UNCOV
1590
  }
×
1591

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

1608
    const isSearchAllFields = !search?.[1];
287✔
1609

1610
    if (viewId) {
287✔
1611
      const { columnMeta: viewColumnRawMeta } =
49✔
1612
        (await this.prismaService.view.findUnique({
49✔
1613
          where: { id: viewId, deletedTime: null },
49✔
1614
          select: { columnMeta: true },
49✔
1615
        })) || {};
49✔
1616

1617
      viewColumnMeta = viewColumnRawMeta ? JSON.parse(viewColumnRawMeta) : null;
49✔
1618

1619
      if (viewColumnMeta) {
49✔
1620
        Object.entries(viewColumnMeta).forEach(([key, value]) => {
49✔
1621
          if (get(value, ['hidden'])) {
507✔
1622
            delete fieldInstanceMap[key];
×
1623
          }
×
1624
        });
507✔
1625
      }
49✔
1626
    }
49✔
1627

1628
    if (projection?.length) {
287✔
1629
      Object.keys(fieldInstanceMap).forEach((fieldId) => {
×
1630
        if (!projection.includes(fieldId)) {
×
1631
          delete fieldInstanceMap[fieldId];
×
1632
        }
×
1633
      });
×
1634
    }
✔
1635

1636
    const allowComputed = options?.allowComputed === true;
287✔
1637

1638
    return uniqBy(
287✔
1639
      orderBy(
287✔
1640
        Object.values(fieldInstanceMap)
287✔
1641
          .map((field) => ({
287✔
1642
            ...field,
816✔
1643
            isStructuredCellValue: field.isStructuredCellValue,
816✔
1644
          }))
816✔
1645
          // Exclude fields that don't have a physical column on the table
287✔
1646
          // Link and Rollup fields (and lookup variants) are computed via CTEs and
287✔
1647
          // are not selectable in search-index queries built directly from the base table.
287✔
1648
          .filter((field) => {
287✔
1649
            if (allowComputed) {
816✔
1650
              // In contexts where selectionMap is available (e.g., record-query-builder),
506✔
1651
              // we can safely include computed fields like Link/Rollup/Lookup.
506✔
1652
              return true;
506✔
1653
            }
506✔
1654
            if (field.type === FieldType.Link) return false;
816✔
1655
            if (field.type === FieldType.Rollup) return false;
816✔
1656
            if (field.type === FieldType.ConditionalRollup) return false;
816✔
1657
            if (field.isLookup) return false;
816✔
1658
            return true;
224✔
1659
          })
224✔
1660
          .filter((field) => {
287✔
1661
            if (!viewColumnMeta) {
730✔
1662
              return true;
52✔
1663
            }
52✔
1664
            return !viewColumnMeta?.[field.id]?.hidden;
678✔
1665
          })
730✔
1666
          .filter((field) => {
287✔
1667
            if (!projection) {
730✔
1668
              return true;
730✔
1669
            }
730✔
1670
            return projection.includes(field.id);
×
1671
          })
×
1672
          .filter((field) => {
287✔
1673
            if (isSearchAllFields) {
730✔
1674
              return true;
46✔
1675
            }
46✔
1676

1677
            const searchArr = search?.[1]?.split(',') || [];
730✔
1678
            return searchArr.includes(field.id);
730✔
1679
          })
730✔
1680
          .filter((field) => {
287✔
1681
            if (
115✔
1682
              [CellValueType.Boolean, CellValueType.DateTime].includes(field.cellValueType) &&
115✔
1683
              isSearchAllFields
26✔
1684
            ) {
115✔
1685
              return false;
15✔
1686
            }
15✔
1687
            if (field.cellValueType === CellValueType.Boolean) {
115✔
1688
              return false;
3✔
1689
            }
3✔
1690
            return true;
97✔
1691
          })
97✔
1692
          .filter((field) => {
287✔
1693
            if (field.type === FieldType.Button) {
97✔
1694
              return false;
×
1695
            }
×
1696
            return true;
97✔
1697
          })
97✔
1698
          .map((field) => {
287✔
1699
            return {
97✔
1700
              ...field,
97✔
1701
              order: viewColumnMeta?.[field.id]?.order ?? Number.MIN_SAFE_INTEGER,
97✔
1702
            };
97✔
1703
          }),
97✔
1704
        ['order', 'createTime']
287✔
1705
      ),
1706
      'id'
287✔
1707
    ).slice(0, maxSearchFieldCount) as unknown as IFieldInstance[];
287✔
1708
  }
287✔
1709

1710
  private async getSearchHitIndex(
265✔
1711
    tableId: string,
1,062✔
1712
    query: IGetRecordsRo,
1,062✔
1713
    builder: Knex.QueryBuilder,
1,062✔
1714
    enabledFieldIds?: string[]
1,062✔
1715
  ) {
1,062✔
1716
    const { search, viewId, projection, ignoreViewQuery } = query;
1,062✔
1717

1718
    if (!search) {
1,062✔
1719
      return null;
1,037✔
1720
    }
1,037✔
1721

1722
    const fieldsRaw = await this.dataLoaderService.field.load(tableId, {
25✔
1723
      id: enabledFieldIds,
25✔
1724
    });
25✔
1725

1726
    const fieldInstances = fieldsRaw.map((field) => createFieldInstanceByRaw(field));
25✔
1727
    const fieldInstanceMap = fieldInstances.reduce(
25✔
1728
      (map, field) => {
25✔
1729
        map[field.id] = field;
253✔
1730
        return map;
253✔
1731
      },
253✔
1732
      {} as Record<string, IFieldInstance>
25✔
1733
    );
1734
    const searchFields = await this.getSearchFields(
25✔
1735
      fieldInstanceMap,
25✔
1736
      search,
25✔
1737
      ignoreViewQuery ? undefined : viewId,
1,062✔
1738
      projection
1,062✔
1739
    );
1740

1741
    const tableIndex = await this.tableIndexService.getActivatedTableIndexes(tableId);
25✔
1742

1743
    if (searchFields.length === 0) {
28✔
1744
      return null;
9✔
1745
    }
9✔
1746

1747
    const newQuery = this.knex
16✔
1748
      .with('current_page_records', builder)
16✔
1749
      .with('search_index', (qb) => {
16✔
1750
        this.dbProvider.searchIndexQuery(
16✔
1751
          qb,
16✔
1752
          'current_page_records',
16✔
1753
          searchFields,
16✔
1754
          {
16✔
1755
            search,
16✔
1756
          },
16✔
1757
          tableIndex,
16✔
1758
          undefined,
16✔
1759
          undefined,
16✔
1760
          undefined
16✔
1761
        );
1762
      })
16✔
1763
      .from('search_index');
16✔
1764

1765
    const searchQuery = newQuery.toQuery();
16✔
1766

1767
    this.logger.debug('getSearchHitIndex query: %s', searchQuery);
16✔
1768

1769
    const result =
16✔
1770
      await this.prismaService.$queryRawUnsafe<{ __id: string; fieldId: string }[]>(searchQuery);
16✔
1771

1772
    if (!result.length) {
28✔
1773
      return null;
3✔
1774
    }
3✔
1775

1776
    return result.map((res) => ({
13✔
1777
      fieldId: res.fieldId,
41✔
1778
      recordId: res.__id,
41✔
1779
    }));
41✔
1780
  }
13✔
1781

1782
  async getRecordsFields(
265✔
1783
    tableId: string,
96✔
1784
    query: IGetRecordsRo
96✔
1785
  ): Promise<Pick<IRecord, 'id' | 'fields'>[]> {
96✔
1786
    if (identify(tableId) !== IdPrefix.Table) {
96✔
1787
      throw new InternalServerErrorException('query collection must be table id');
×
1788
    }
×
1789

1790
    const {
96✔
1791
      skip,
96✔
1792
      take,
96✔
1793
      orderBy,
96✔
1794
      search,
96✔
1795
      groupBy,
96✔
1796
      collapsedGroupIds,
96✔
1797
      fieldKeyType,
96✔
1798
      cellFormat,
96✔
1799
      projection,
96✔
1800
      viewId,
96✔
1801
      ignoreViewQuery,
96✔
1802
      filterLinkCellCandidate,
96✔
1803
      filterLinkCellSelected,
96✔
1804
    } = query;
96✔
1805

1806
    const fields = await this.getFieldsByProjection(
96✔
1807
      tableId,
96✔
1808
      this.convertProjection(projection),
96✔
1809
      fieldKeyType
96✔
1810
    );
1811

1812
    const { filter: filterWithGroup } = await this.getGroupRelatedData(tableId, query);
96✔
1813

1814
    const { queryBuilder } = await this.buildFilterSortQuery(tableId, {
96✔
1815
      viewId,
96✔
1816
      ignoreViewQuery,
96✔
1817
      filter: filterWithGroup,
96✔
1818
      orderBy,
96✔
1819
      search,
96✔
1820
      groupBy,
96✔
1821
      collapsedGroupIds,
96✔
1822
      filterLinkCellCandidate,
96✔
1823
      filterLinkCellSelected,
96✔
1824
      skip,
96✔
1825
      take,
96✔
1826
    });
96✔
1827
    skip && queryBuilder.offset(skip);
96✔
1828
    take !== -1 && take && queryBuilder.limit(take);
96✔
1829
    const sql = queryBuilder.toQuery();
96✔
1830

1831
    this.logger.debug('getRecordsFields query: %s', sql);
96✔
1832

1833
    const result = await this.prismaService
96✔
1834
      .txClient()
96✔
1835
      .$queryRawUnsafe<(Pick<IRecord, 'fields'> & Pick<IVisualTableDefaultField, '__id'>)[]>(sql);
96✔
1836

1837
    return result.map((record) => {
96✔
1838
      return {
20,351✔
1839
        id: record.__id,
20,351✔
1840
        fields: this.dbRecord2RecordFields(record, fields, fieldKeyType, cellFormat),
20,351✔
1841
      };
20,351✔
1842
    });
20,351✔
1843
  }
96✔
1844

1845
  private async getPrimaryField(tableId: string) {
265✔
1846
    const field = await this.dataLoaderService.field.load(tableId, {
7,494✔
1847
      isPrimary: [true],
7,494✔
1848
    });
7,494✔
1849
    if (!field.length) {
7,494✔
1850
      throw new BadRequestException(`Could not find primary index ${tableId}`);
×
1851
    }
×
1852
    return createFieldInstanceByRaw(field[0]);
7,494✔
1853
  }
7,494✔
1854

1855
  async getRecordsHeadWithTitles(tableId: string, titles: string[]) {
265✔
1856
    const dbTableName = await this.getDbTableName(tableId);
14✔
1857
    const field = await this.getPrimaryField(tableId);
14✔
1858

1859
    // only text field support type cast to title
14✔
1860
    if (field.dbFieldType !== DbFieldType.Text) {
14✔
1861
      return [];
×
1862
    }
×
1863

1864
    const queryBuilder = this.knex(dbTableName)
14✔
1865
      .select({ title: field.dbFieldName, id: '__id' })
14✔
1866
      .whereIn(field.dbFieldName, titles);
14✔
1867

1868
    const querySql = queryBuilder.toQuery();
14✔
1869

1870
    return this.prismaService.txClient().$queryRawUnsafe<{ id: string; title: string }[]>(querySql);
14✔
1871
  }
14✔
1872

1873
  async getRecordsHeadWithIds(tableId: string, recordIds: string[]) {
265✔
1874
    const dbTableName = await this.getDbTableName(tableId);
10✔
1875
    const field = await this.getPrimaryField(tableId);
10✔
1876

1877
    const queryBuilder = this.knex(dbTableName)
10✔
1878
      .select({ title: field.dbFieldName, id: '__id' })
10✔
1879
      .whereIn('__id', recordIds);
10✔
1880

1881
    const querySql = queryBuilder.toQuery();
10✔
1882

1883
    const result = await this.prismaService
10✔
1884
      .txClient()
10✔
1885
      .$queryRawUnsafe<{ id: string; title: unknown }[]>(querySql);
10✔
1886

1887
    return result.map((r) => ({
10✔
1888
      id: r.id,
11✔
1889
      title: field.cellValue2String(r.title),
11✔
1890
    }));
11✔
1891
  }
10✔
1892

1893
  async filterRecordIdsByFilter(
265✔
1894
    tableId: string,
×
1895
    recordIds: string[],
×
1896
    filter?: IFilter | null
×
1897
  ): Promise<string[]> {
×
1898
    const { queryBuilder, alias } = await this.buildFilterSortQuery(tableId, {
×
1899
      filter,
×
1900
    });
×
1901
    queryBuilder.whereIn(`${alias}.__id`, recordIds);
×
1902
    const result = await this.prismaService
×
1903
      .txClient()
×
1904
      .$queryRawUnsafe<{ __id: string }[]>(queryBuilder.toQuery());
×
1905
    return result.map((r) => r.__id);
×
1906
  }
×
1907

1908
  async getDiffIdsByIdAndFilter(tableId: string, recordIds: string[], filter?: IFilter | null) {
265✔
1909
    const ids = await this.filterRecordIdsByFilter(tableId, recordIds, filter);
×
1910
    return difference(recordIds, ids);
×
1911
  }
×
1912

1913
  private sortGroupRawResult(
265✔
1914
    groupResult: { [key: string]: unknown; __c: number }[],
25✔
1915
    groupFields: IFieldInstance[],
25✔
1916
    groupBy?: IGroup
25✔
1917
  ) {
25✔
1918
    if (!groupResult.length || !groupBy?.length) {
25✔
1919
      return groupResult;
×
1920
    }
×
1921

1922
    const comparators = groupBy
25✔
1923
      .map((groupItem, index) => {
25✔
1924
        const field = groupFields[index];
25✔
1925

1926
        if (!field) {
25✔
1927
          return undefined;
×
1928
        }
×
1929

1930
        const { dbFieldName } = field;
25✔
1931
        const order = groupItem.order ?? SortFunc.Asc;
25✔
1932
        const selectOrderMap =
25✔
1933
          field.type === FieldType.SingleSelect
25✔
1934
            ? new Map(
5✔
1935
                ((field.options as ISelectFieldOptions | undefined)?.choices ?? []).map(
5✔
1936
                  (choice, idx) => [choice.name, idx]
5✔
1937
                )
1938
              )
1939
            : null;
20✔
1940

1941
        return (
25✔
1942
          left: { [key: string]: unknown; __c: number },
898✔
1943
          right: { [key: string]: unknown; __c: number }
898✔
1944
        ) => {
1945
          const leftValue = convertValueToStringify(left[dbFieldName]);
898✔
1946
          const rightValue = convertValueToStringify(right[dbFieldName]);
898✔
1947

1948
          if (selectOrderMap) {
898✔
1949
            return this.compareSelectGroupValues(selectOrderMap, leftValue, rightValue, order);
30✔
1950
          }
30✔
1951

1952
          return this.compareGroupValues(leftValue, rightValue, order);
868✔
1953
        };
868✔
1954
      })
25✔
1955
      .filter(Boolean) as ((
25✔
1956
      left: { [key: string]: unknown; __c: number },
1957
      right: { [key: string]: unknown; __c: number }
1958
    ) => number)[];
1959

1960
    if (!comparators.length) {
25✔
1961
      return groupResult;
×
1962
    }
×
1963

1964
    return [...groupResult].sort((left, right) => {
25✔
1965
      for (const comparator of comparators) {
898✔
1966
        const result = comparator(left, right);
898✔
1967
        if (result !== 0) {
898✔
1968
          return result;
898✔
1969
        }
898✔
1970
      }
898✔
1971
      return 0;
×
1972
    });
×
1973
  }
25✔
1974

1975
  // eslint-disable-next-line sonarjs/cognitive-complexity
265✔
1976
  private compareGroupValues(
265✔
1977
    left: number | string | null,
878✔
1978
    right: number | string | null,
878✔
1979
    order: SortFunc
878✔
1980
  ): number {
878✔
1981
    if (left === right) {
878✔
1982
      return 0;
×
1983
    }
×
1984

1985
    const isDesc = order === SortFunc.Desc;
878✔
1986
    const leftIsNull = left == null;
878✔
1987
    const rightIsNull = right == null;
878✔
1988

1989
    if (leftIsNull || rightIsNull) {
878✔
1990
      if (leftIsNull && rightIsNull) {
74✔
1991
        return 0;
×
1992
      }
×
1993

1994
      if (leftIsNull) {
74✔
1995
        return isDesc ? 1 : -1;
15✔
1996
      }
15✔
1997

1998
      return isDesc ? -1 : 1;
74✔
1999
    }
74✔
2000

2001
    if (typeof left === 'number' && typeof right === 'number') {
878✔
2002
      const diff = left - right;
282✔
2003
      if (diff === 0) {
282✔
2004
        return 0;
×
2005
      }
×
2006
      return isDesc ? -diff : diff;
282✔
2007
    }
282✔
2008

2009
    const leftString = String(left);
522✔
2010
    const rightString = String(right);
522✔
2011

2012
    if (leftString === rightString) {
878✔
2013
      return 0;
×
2014
    }
✔
2015

2016
    if (leftString < rightString) {
878✔
2017
      return isDesc ? 1 : -1;
217✔
2018
    }
217✔
2019

2020
    return isDesc ? -1 : 1;
878✔
2021
  }
878✔
2022

2023
  private compareSelectGroupValues(
265✔
2024
    orderMap: Map<string, number>,
30✔
2025
    left: number | string | null,
30✔
2026
    right: number | string | null,
30✔
2027
    order: SortFunc
30✔
2028
  ): number {
30✔
2029
    if (left === right) {
30✔
2030
      return 0;
×
2031
    }
×
2032

2033
    if (left == null || right == null) {
30✔
2034
      return this.compareGroupValues(left, right, order);
10✔
2035
    }
10✔
2036

2037
    const getRank = (value: number | string): number => {
20✔
2038
      if (typeof value === 'string') {
40✔
2039
        const index = orderMap.get(value);
40✔
2040
        if (index != null) {
40✔
2041
          return index + 1;
40✔
2042
        }
40✔
2043
      }
40✔
2044
      return -1;
×
2045
    };
×
2046

2047
    const leftRank = getRank(left);
20✔
2048
    const rightRank = getRank(right);
20✔
2049

2050
    if (leftRank === rightRank) {
30✔
2051
      return this.compareGroupValues(left, right, order);
×
2052
    }
✔
2053

2054
    const diff = leftRank - rightRank;
20✔
2055
    return order === SortFunc.Desc ? -diff : diff;
30✔
2056
  }
30✔
2057

2058
  @Timing()
265✔
2059
  // eslint-disable-next-line sonarjs/cognitive-complexity
2060
  private async groupDbCollection2GroupPoints(
25✔
2061
    groupResult: { [key: string]: unknown; __c: number }[],
25✔
2062
    groupFields: IFieldInstance[],
25✔
2063
    groupBy: IGroup | undefined,
25✔
2064
    collapsedGroupIds: string[] | undefined,
25✔
2065
    rowCount: number
25✔
2066
  ) {
25✔
2067
    const groupPoints: IGroupPoint[] = [];
25✔
2068
    const allGroupHeaderRefs: IGroupHeaderRef[] = [];
25✔
2069
    const collapsedGroupIdsSet = new Set(collapsedGroupIds);
25✔
2070
    let fieldValues: unknown[] = [Symbol(), Symbol(), Symbol()];
25✔
2071
    let curRowCount = 0;
25✔
2072
    let collapsedDepth = Number.MAX_SAFE_INTEGER;
25✔
2073

2074
    const sortedGroupResult = this.sortGroupRawResult(groupResult, groupFields, groupBy);
25✔
2075

2076
    for (let i = 0; i < sortedGroupResult.length; i++) {
25✔
2077
      const item = sortedGroupResult[i];
305✔
2078
      const { __c: count } = item;
305✔
2079

2080
      for (let index = 0; index < groupFields.length; index++) {
305✔
2081
        const field = groupFields[index];
305✔
2082
        const { id, dbFieldName } = field;
305✔
2083
        const fieldValue = convertValueToStringify(item[dbFieldName]);
305✔
2084

2085
        if (fieldValues[index] === fieldValue) continue;
305✔
2086

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

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

2092
        if (index > collapsedDepth) break;
305✔
2093

2094
        // Reset the collapsedDepth when encountering the next peer grouping
305✔
2095
        collapsedDepth = Number.MAX_SAFE_INTEGER;
305✔
2096

2097
        fieldValues[index] = fieldValue;
305✔
2098
        fieldValues = fieldValues.map((value, idx) => (idx > index ? Symbol() : value));
305✔
2099

2100
        const isCollapsedInner = collapsedGroupIdsSet.has(groupId) ?? false;
305✔
2101
        let value = field.convertDBValue2CellValue(fieldValue);
305✔
2102

2103
        if (field.type === FieldType.Attachment) {
305✔
2104
          value = await this.getAttachmentPresignedCellValue(value as IAttachmentCellValue);
×
2105
        }
×
2106

2107
        groupPoints.push({
305✔
2108
          id: groupId,
305✔
2109
          type: GroupPointType.Header,
305✔
2110
          depth: index,
305✔
2111
          value,
305✔
2112
          isCollapsed: isCollapsedInner,
305✔
2113
        });
305✔
2114

2115
        if (isCollapsedInner) {
305✔
2116
          collapsedDepth = index;
1✔
2117
        }
1✔
2118
      }
305✔
2119

2120
      curRowCount += Number(count);
305✔
2121
      if (collapsedDepth !== Number.MAX_SAFE_INTEGER) continue;
305✔
2122
      groupPoints.push({ type: GroupPointType.Row, count: Number(count) });
304✔
2123
    }
304✔
2124

2125
    if (curRowCount < rowCount) {
25✔
2126
      groupPoints.push(
×
2127
        {
×
2128
          id: 'unknown',
×
2129
          type: GroupPointType.Header,
×
2130
          depth: 0,
×
2131
          value: 'Unknown',
×
2132
          isCollapsed: false,
×
2133
        },
×
2134
        { type: GroupPointType.Row, count: rowCount - curRowCount }
×
2135
      );
2136
    }
×
2137

2138
    return {
25✔
2139
      groupPoints,
25✔
2140
      allGroupHeaderRefs,
25✔
2141
    };
25✔
2142
  }
25✔
2143

2144
  private getFilterByCollapsedGroup({
265✔
2145
    groupBy,
25✔
2146
    groupPoints,
25✔
2147
    fieldInstanceMap,
25✔
2148
    collapsedGroupIds,
25✔
2149
  }: {
2150
    groupBy: IGroup;
2151
    groupPoints: IGroupPointsVo;
2152
    fieldInstanceMap: Record<string, IFieldInstance>;
2153
    collapsedGroupIds?: string[];
2154
  }) {
25✔
2155
    if (!groupBy?.length || groupPoints == null || collapsedGroupIds == null) return null;
25✔
2156
    const groupIds: string[] = [];
1✔
2157
    const groupId2DataMap = groupPoints.reduce(
1✔
2158
      (prev, cur) => {
1✔
2159
        if (cur.type !== GroupPointType.Header) {
7✔
2160
          return prev;
3✔
2161
        }
3✔
2162
        const { id, depth } = cur;
4✔
2163

2164
        groupIds[depth] = id;
4✔
2165
        prev[id] = { ...cur, path: groupIds.slice(0, depth + 1) };
4✔
2166
        return prev;
4✔
2167
      },
4✔
2168
      {} as Record<string, IGroupHeaderPoint & { path: string[] }>
1✔
2169
    );
2170

2171
    const filterQuery: IFilter = {
1✔
2172
      conjunction: and.value,
1✔
2173
      filterSet: [],
1✔
2174
    };
1✔
2175

2176
    for (const groupId of collapsedGroupIds) {
1✔
2177
      const groupData = groupId2DataMap[groupId];
1✔
2178

2179
      if (groupData == null) continue;
1✔
2180

2181
      const { path } = groupData;
1✔
2182
      const innerFilterSet: IFilterSet = {
1✔
2183
        conjunction: or.value,
1✔
2184
        filterSet: [],
1✔
2185
      };
1✔
2186

2187
      path.forEach((pathGroupId) => {
1✔
2188
        const pathGroupData = groupId2DataMap[pathGroupId];
1✔
2189

2190
        if (pathGroupData == null) return;
1✔
2191

2192
        const { depth } = pathGroupData;
1✔
2193
        const curGroup = groupBy[depth];
1✔
2194

2195
        if (curGroup == null) return;
1✔
2196

2197
        const { fieldId } = curGroup;
1✔
2198
        const field = fieldInstanceMap[fieldId];
1✔
2199

2200
        if (field == null) return;
1✔
2201

2202
        const filterItem = generateFilterItem(field, pathGroupData.value);
1✔
2203
        innerFilterSet.filterSet.push(filterItem);
1✔
2204
      });
1✔
2205

2206
      filterQuery.filterSet.push(innerFilterSet);
1✔
2207
    }
1✔
2208

2209
    return filterQuery;
1✔
2210
  }
1✔
2211

2212
  async getRowCountByFilter(
265✔
2213
    dbTableName: string,
25✔
2214
    fieldInstanceMap: Record<string, IFieldInstance>,
25✔
2215
    tableId: string,
25✔
2216
    filter?: IFilter,
25✔
2217
    search?: [string, string?, boolean?],
25✔
2218
    viewId?: string,
25✔
2219
    useQueryModel = false
25✔
2220
  ) {
25✔
2221
    const withUserId = this.cls.get('user.id');
25✔
2222

2223
    const { qb, selectionMap } = await this.recordQueryBuilder.createRecordAggregateBuilder(
25✔
2224
      dbTableName,
25✔
2225
      {
25✔
2226
        tableIdOrDbTableName: tableId,
25✔
2227
        aggregationFields: [],
25✔
2228
        viewId,
25✔
2229
        filter,
25✔
2230
        currentUserId: withUserId,
25✔
2231
        useQueryModel,
25✔
2232
      }
25✔
2233
    );
2234

2235
    if (search && search[2]) {
25✔
2236
      // selectionMap is available, so allow computed fields
×
2237
      const searchFields = await this.getSearchFields(fieldInstanceMap, search, viewId, undefined, {
×
2238
        allowComputed: true,
×
2239
      });
×
2240
      const tableIndex = await this.tableIndexService.getActivatedTableIndexes(tableId);
×
2241
      qb.where((builder) => {
×
2242
        this.dbProvider.searchQuery(builder, searchFields, tableIndex, search, { selectionMap });
×
2243
      });
×
2244
    }
×
2245

2246
    const rowCountSql = qb.count({ count: '*' });
25✔
2247
    const sql = rowCountSql.toQuery();
25✔
2248
    this.logger.debug('getRowCountSql: %s', sql);
25✔
2249
    const result = await this.prismaService.$queryRawUnsafe<{ count?: number }[]>(sql);
25✔
2250
    return Number(result[0].count);
25✔
2251
  }
25✔
2252

2253
  public async getGroupRelatedData(tableId: string, query?: IGetRecordsRo, useQueryModel = false) {
265✔
2254
    const { groupBy: extraGroupBy, filter, search, ignoreViewQuery, queryId } = query || {};
1,164✔
2255
    let groupPoints: IGroupPoint[] = [];
1,164✔
2256
    let allGroupHeaderRefs: IGroupHeaderRef[] = [];
1,164✔
2257
    let collapsedGroupIds = query?.collapsedGroupIds;
1,164✔
2258

2259
    if (queryId) {
1,164✔
2260
      const cacheKey = `query-params:${queryId}` as const;
×
2261
      const cache = await this.cacheService.get(cacheKey);
×
2262
      if (cache) {
×
2263
        collapsedGroupIds = (cache.queryParams as IGetRecordsRo)?.collapsedGroupIds;
×
2264
      }
×
2265
    }
×
2266

2267
    const fullGroupBy = parseGroup(extraGroupBy);
1,164✔
2268

2269
    if (!fullGroupBy?.length) {
1,164✔
2270
      return {
1,139✔
2271
        groupPoints,
1,139✔
2272
        filter,
1,139✔
2273
      };
1,139✔
2274
    }
1,139✔
2275

2276
    const viewId = ignoreViewQuery ? undefined : query?.viewId;
1,164✔
2277
    const viewRaw = await this.getTinyView(tableId, viewId);
1,164✔
2278
    const { viewCte, builder, enabledFieldIds } = await this.recordPermissionService.wrapView(
25✔
2279
      tableId,
25✔
2280
      this.knex.queryBuilder(),
25✔
2281
      {
25✔
2282
        keepPrimaryKey: Boolean(query?.filterLinkCellSelected),
25✔
2283
        viewId,
1,164✔
2284
      }
1,164✔
2285
    );
2286
    const fieldInstanceMap = (await this.getNecessaryFieldMap(
25✔
2287
      tableId,
25✔
2288
      filter,
25✔
2289
      undefined,
25✔
2290
      fullGroupBy,
25✔
2291
      search,
25✔
2292
      enabledFieldIds
25✔
2293
    ))!;
2294
    const groupBy = fullGroupBy.filter((item) => fieldInstanceMap[item.fieldId]);
25✔
2295

2296
    if (!groupBy?.length) {
1,164✔
2297
      return {
×
2298
        groupPoints,
×
2299
        filter,
×
2300
      };
×
2301
    }
✔
2302

2303
    const dbTableName = await this.getDbTableName(tableId);
25✔
2304

2305
    const filterStr = viewRaw?.filter;
28✔
2306
    const mergedFilter = mergeWithDefaultFilter(filterStr, filter);
1,164✔
2307
    const groupFieldIds = groupBy.map((item) => item.fieldId);
1,164✔
2308

2309
    const withUserId = this.cls.get('user.id');
1,164✔
2310
    const shouldUseQueryModel = useQueryModel && !viewCte;
1,164✔
2311
    const { qb: queryBuilder, selectionMap } =
1,164✔
2312
      await this.recordQueryBuilder.createRecordAggregateBuilder(viewCte ?? dbTableName, {
1,164✔
2313
        tableIdOrDbTableName: tableId,
1,164✔
2314
        viewId,
1,164✔
2315
        filter: mergedFilter,
1,164✔
2316
        aggregationFields: [
1,164✔
2317
          {
1,164✔
2318
            fieldId: '*',
1,164✔
2319
            statisticFunc: StatisticsFunc.Count,
1,164✔
2320
            alias: '__c',
1,164✔
2321
          },
1,164✔
2322
        ],
1,164✔
2323
        groupBy,
1,164✔
2324
        currentUserId: withUserId,
1,164✔
2325
        useQueryModel: shouldUseQueryModel,
1,164✔
2326
      });
1,164✔
2327

2328
    // Attach permission CTE to the aggregate query when using the permission view.
25✔
2329
    await this.recordPermissionService.wrapView(tableId, queryBuilder, {
25✔
2330
      viewId,
25✔
2331
      keepPrimaryKey: Boolean(query?.filterLinkCellSelected),
25✔
2332
    });
1,164✔
2333

2334
    if (search && search[2]) {
1,164✔
2335
      // selectionMap is available, so allow computed fields
×
2336
      const searchFields = await this.getSearchFields(fieldInstanceMap, search, viewId, undefined, {
×
2337
        allowComputed: true,
×
2338
      });
×
2339
      const tableIndex = await this.tableIndexService.getActivatedTableIndexes(tableId);
×
2340
      queryBuilder.where((builder) => {
×
2341
        this.dbProvider.searchQuery(builder, searchFields, tableIndex, search, { selectionMap });
×
2342
      });
×
2343
    }
✔
2344

2345
    queryBuilder.limit(this.thresholdConfig.maxGroupPoints);
25✔
2346

2347
    const groupSql = queryBuilder.toQuery();
25✔
2348
    this.logger.debug('groupSql: %s', groupSql);
25✔
2349
    const groupFields = groupFieldIds.map((fieldId) => fieldInstanceMap[fieldId]).filter(Boolean);
25✔
2350
    const rowCount = await this.getRowCountByFilter(
25✔
2351
      dbTableName,
25✔
2352
      fieldInstanceMap,
25✔
2353
      tableId,
25✔
2354
      mergedFilter,
25✔
2355
      search,
25✔
2356
      viewId,
25✔
2357
      useQueryModel
25✔
2358
    );
2359

2360
    try {
25✔
2361
      const result =
25✔
2362
        await this.prismaService.$queryRawUnsafe<{ [key: string]: unknown; __c: number }[]>(
25✔
2363
          groupSql
25✔
2364
        );
2365
      const pointsResult = await this.groupDbCollection2GroupPoints(
25✔
2366
        result,
25✔
2367
        groupFields,
25✔
2368
        groupBy,
25✔
2369
        collapsedGroupIds,
25✔
2370
        rowCount
25✔
2371
      );
2372
      groupPoints = pointsResult.groupPoints;
25✔
2373
      allGroupHeaderRefs = pointsResult.allGroupHeaderRefs;
25✔
2374
    } catch (error) {
28✔
2375
      this.logger.error(`Get group points error in table ${tableId}: `, error);
×
2376
    }
✔
2377

2378
    const filterWithCollapsed = this.getFilterByCollapsedGroup({
25✔
2379
      groupBy,
25✔
2380
      groupPoints,
25✔
2381
      fieldInstanceMap,
25✔
2382
      collapsedGroupIds,
25✔
2383
    });
25✔
2384

2385
    return { groupPoints, allGroupHeaderRefs, filter: mergeFilter(filter, filterWithCollapsed) };
25✔
2386
  }
25✔
2387

2388
  async getRecordStatus(
265✔
2389
    tableId: string,
×
2390
    recordId: string,
×
2391
    query: IGetRecordsRo
×
2392
  ): Promise<IRecordStatusVo> {
×
2393
    const dbTableName = await this.getDbTableName(tableId);
×
2394
    const queryBuilder = this.knex(dbTableName).select('__id').where('__id', recordId).limit(1);
×
2395

2396
    const result = await this.prismaService
×
2397
      .txClient()
×
2398
      .$queryRawUnsafe<{ __id: string }[]>(queryBuilder.toQuery());
×
2399

2400
    const isDeleted = result.length === 0;
×
2401

2402
    if (isDeleted) {
×
2403
      return { isDeleted, isVisible: false };
×
2404
    }
×
2405

2406
    const queryResult = await this.getDocIdsByQuery(tableId, {
×
2407
      ignoreViewQuery: query.ignoreViewQuery ?? false,
×
2408
      viewId: query.viewId,
×
2409
      skip: query.skip,
×
2410
      take: query.take,
×
2411
      filter: query.filter,
×
2412
      orderBy: query.orderBy,
×
2413
      search: query.search,
×
2414
      groupBy: query.groupBy,
×
2415
      filterLinkCellCandidate: query.filterLinkCellCandidate,
×
2416
      filterLinkCellSelected: query.filterLinkCellSelected,
×
2417
      selectedRecordIds: query.selectedRecordIds,
×
2418
    });
×
2419
    const isVisible = queryResult.ids.includes(recordId);
×
2420
    return { isDeleted, isVisible };
×
2421
  }
×
2422
}
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