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

teableio / teable / 11928446030

20 Nov 2024 07:09AM UTC coverage: 84.229%. First build
11928446030

Pull #1095

github

web-flow
Merge 3d24e8adc into 05349ef33
Pull Request #1095: feat: search result highlight

5981 of 6286 branches covered (95.15%)

172 of 183 new or added lines in 2 files covered. (93.99%)

39152 of 46483 relevant lines covered (84.23%)

1755.01 hits per line

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

95.05
/apps/nestjs-backend/src/features/aggregation/aggregation.service.ts
1
import { Injectable, Logger, BadRequestException, BadGatewayException } from '@nestjs/common';
4✔
2
import type { IGridColumnMeta, IFilter, IGroup } from '@teable/core';
4✔
3
import {
4✔
4
  FieldType,
4✔
5
  mergeWithDefaultFilter,
4✔
6
  nullsToUndefined,
4✔
7
  StatisticsFunc,
4✔
8
  ViewType,
4✔
9
} from '@teable/core';
4✔
10
import type { Prisma } from '@teable/db-main-prisma';
4✔
11
import { PrismaService } from '@teable/db-main-prisma';
4✔
12
import type {
4✔
13
  IAggregationField,
4✔
14
  IGetRecordsRo,
4✔
15
  IQueryBaseRo,
4✔
16
  IRawAggregations,
4✔
17
  IRawAggregationValue,
4✔
18
  IRawRowCountValue,
4✔
19
  IGroupPointsRo,
4✔
20
  ISearchIndexByQueryRo,
4✔
21
  ISearchCountRo,
4✔
22
} from '@teable/openapi';
4✔
23
import dayjs from 'dayjs';
4✔
24
import { Knex } from 'knex';
4✔
25
import { get, groupBy, isDate, isEmpty, keyBy, orderBy } from 'lodash';
4✔
26
import { InjectModel } from 'nest-knexjs';
4✔
27
import { ClsService } from 'nestjs-cls';
4✔
28
import { IThresholdConfig, ThresholdConfig } from '../../configs/threshold.config';
4✔
29
import { InjectDbProvider } from '../../db-provider/db.provider';
4✔
30
import { IDbProvider } from '../../db-provider/db.provider.interface';
4✔
31
import type { IClsStore } from '../../types/cls';
4✔
32
import { convertValueToStringify, string2Hash } from '../../utils';
4✔
33
import type { IFieldInstance } from '../field/model/factory';
4✔
34
import { createFieldInstanceByRaw } from '../field/model/factory';
4✔
35
import { RecordService } from '../record/record.service';
4✔
36

4✔
37
export type IWithView = {
4✔
38
  viewId?: string;
4✔
39
  groupBy?: IGroup;
4✔
40
  customFilter?: IFilter;
4✔
41
  customFieldStats?: ICustomFieldStats[];
4✔
42
};
4✔
43

4✔
44
type ICustomFieldStats = {
4✔
45
  fieldId: string;
4✔
46
  statisticFunc?: StatisticsFunc;
4✔
47
};
4✔
48

4✔
49
type IStatisticsData = {
4✔
50
  viewId?: string;
4✔
51
  filter?: IFilter;
4✔
52
  statisticFields?: IAggregationField[];
4✔
53
};
4✔
54

4✔
55
@Injectable()
4✔
56
export class AggregationService {
4✔
57
  private logger = new Logger(AggregationService.name);
161✔
58

161✔
59
  constructor(
161✔
60
    private readonly recordService: RecordService,
161✔
61
    private readonly prisma: PrismaService,
161✔
62
    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,
161✔
63
    @InjectDbProvider() private readonly dbProvider: IDbProvider,
161✔
64
    private readonly cls: ClsService<IClsStore>,
161✔
65
    @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig
161✔
66
  ) {}
161✔
67

161✔
68
  async performAggregation(params: {
161✔
69
    tableId: string;
222✔
70
    withFieldIds?: string[];
222✔
71
    withView?: IWithView;
222✔
72
    search?: [string, string?, boolean?];
222✔
73
  }): Promise<IRawAggregationValue> {
222✔
74
    const { tableId, withFieldIds, withView, search } = params;
222✔
75
    // Retrieve the current user's ID to build user-related query conditions
222✔
76
    const currentUserId = this.cls.get('user.id');
222✔
77

222✔
78
    const { statisticsData, fieldInstanceMap, fieldInstanceMapWithoutHiddenFields } =
222✔
79
      await this.fetchStatisticsParams({
222✔
80
        tableId,
222✔
81
        withView,
222✔
82
        withFieldIds,
222✔
83
      });
222✔
84

222✔
85
    const dbTableName = await this.getDbTableName(this.prisma, tableId);
222✔
86

222✔
87
    const { filter, statisticFields } = statisticsData;
222✔
88
    const groupBy = withView?.groupBy;
222✔
89

222✔
90
    const rawAggregationData = await this.handleAggregation({
222✔
91
      dbTableName,
222✔
92
      fieldInstanceMap,
222✔
93
      fieldInstanceMapWithoutHiddenFields,
222✔
94
      filter,
222✔
95
      search,
222✔
96
      statisticFields,
222✔
97
      withUserId: currentUserId,
222✔
98
    });
222✔
99

222✔
100
    const aggregationResult = rawAggregationData && rawAggregationData[0];
222✔
101

222✔
102
    const aggregations: IRawAggregations = [];
222✔
103
    if (aggregationResult) {
222✔
104
      for (const [key, value] of Object.entries(aggregationResult)) {
222✔
105
        const [fieldId, aggFunc] = key.split('_') as [string, StatisticsFunc | undefined];
230✔
106

230✔
107
        const convertValue = this.formatConvertValue(value, aggFunc);
230✔
108

230✔
109
        if (fieldId) {
230✔
110
          aggregations.push({
230✔
111
            fieldId,
230✔
112
            total: aggFunc ? { value: convertValue, aggFunc: aggFunc } : null,
230✔
113
          });
230✔
114
        }
230✔
115
      }
230✔
116
    }
222✔
117

222✔
118
    const aggregationsWithGroup = await this.performGroupedAggregation({
222✔
119
      aggregations,
222✔
120
      statisticFields,
222✔
121
      filter,
222✔
122
      search,
222✔
123
      groupBy,
222✔
124
      dbTableName,
222✔
125
      fieldInstanceMap,
222✔
126
      fieldInstanceMapWithoutHiddenFields,
222✔
127
    });
222✔
128

222✔
129
    return { aggregations: aggregationsWithGroup };
222✔
130
  }
222✔
131

161✔
132
  async performGroupedAggregation(params: {
161✔
133
    aggregations: IRawAggregations;
222✔
134
    statisticFields: IAggregationField[] | undefined;
222✔
135
    filter?: IFilter;
222✔
136
    search?: [string, string?, boolean?];
222✔
137
    groupBy?: IGroup;
222✔
138
    dbTableName: string;
222✔
139
    fieldInstanceMap: Record<string, IFieldInstance>;
222✔
140
    fieldInstanceMapWithoutHiddenFields: Record<string, IFieldInstance>;
222✔
141
  }) {
222✔
142
    const {
222✔
143
      dbTableName,
222✔
144
      aggregations,
222✔
145
      statisticFields,
222✔
146
      filter,
222✔
147
      groupBy,
222✔
148
      search,
222✔
149
      fieldInstanceMap,
222✔
150
      fieldInstanceMapWithoutHiddenFields,
222✔
151
    } = params;
222✔
152

222✔
153
    if (!groupBy || !statisticFields) return aggregations;
222✔
154

110✔
155
    const currentUserId = this.cls.get('user.id');
110✔
156
    const aggregationByFieldId = keyBy(aggregations, 'fieldId');
110✔
157

110✔
158
    const groupByFields = groupBy.map(({ fieldId }) => {
110✔
159
      return {
110✔
160
        fieldId,
110✔
161
        dbFieldName: fieldInstanceMap[fieldId].dbFieldName,
110✔
162
      };
110✔
163
    });
110✔
164

110✔
165
    for (let i = 0; i < groupBy.length; i++) {
110✔
166
      const rawGroupedAggregationData = (await this.handleAggregation({
110✔
167
        dbTableName,
110✔
168
        fieldInstanceMap,
110✔
169
        fieldInstanceMapWithoutHiddenFields,
110✔
170
        filter,
110✔
171
        groupBy: groupBy.slice(0, i + 1),
110✔
172
        search,
110✔
173
        statisticFields,
110✔
174
        withUserId: currentUserId,
110✔
175
      }))!;
110✔
176

110✔
177
      const currentGroupFieldId = groupByFields[i].fieldId;
110✔
178

110✔
179
      for (const groupedAggregation of rawGroupedAggregationData) {
110✔
180
        const groupByValueString = groupByFields
1,404✔
181
          .slice(0, i + 1)
1,404✔
182
          .map(({ dbFieldName }) => {
1,404✔
183
            const groupByValue = groupedAggregation[dbFieldName];
1,404✔
184
            return convertValueToStringify(groupByValue);
1,404✔
185
          })
1,404✔
186
          .join('_');
1,404✔
187
        const flagString = `${currentGroupFieldId}_${groupByValueString}`;
1,404✔
188
        const groupId = String(string2Hash(flagString));
1,404✔
189

1,404✔
190
        for (const statisticField of statisticFields) {
1,404✔
191
          const { fieldId, statisticFunc } = statisticField;
1,404✔
192
          const aggKey = `${fieldId}_${statisticFunc}`;
1,404✔
193
          const curFieldAggregation = aggregationByFieldId[fieldId]!;
1,404✔
194
          const convertValue = this.formatConvertValue(groupedAggregation[aggKey], statisticFunc);
1,404✔
195

1,404✔
196
          if (!curFieldAggregation.group) {
1,404✔
197
            aggregationByFieldId[fieldId].group = {
110✔
198
              [groupId]: { value: convertValue, aggFunc: statisticFunc },
110✔
199
            };
110✔
200
          } else {
1,404✔
201
            aggregationByFieldId[fieldId]!.group![groupId] = {
1,294✔
202
              value: convertValue,
1,294✔
203
              aggFunc: statisticFunc,
1,294✔
204
            };
1,294✔
205
          }
1,294✔
206
        }
1,404✔
207
      }
1,404✔
208
    }
110✔
209

110✔
210
    return Object.values(aggregationByFieldId);
110✔
211
  }
110✔
212

161✔
213
  async performRowCount(tableId: string, queryRo: IQueryBaseRo): Promise<IRawRowCountValue> {
161✔
214
    const { filterLinkCellCandidate, filterLinkCellSelected, selectedRecordIds } = queryRo;
64✔
215
    // Retrieve the current user's ID to build user-related query conditions
64✔
216
    const currentUserId = this.cls.get('user.id');
64✔
217

64✔
218
    const { statisticsData, fieldInstanceMap, fieldInstanceMapWithoutHiddenFields } =
64✔
219
      await this.fetchStatisticsParams({
64✔
220
        tableId,
64✔
221
        withView: {
64✔
222
          viewId: queryRo.viewId,
64✔
223
          customFilter: queryRo.filter,
64✔
224
        },
64✔
225
      });
64✔
226

64✔
227
    const dbTableName = await this.getDbTableName(this.prisma, tableId);
64✔
228

64✔
229
    const { filter } = statisticsData;
64✔
230

64✔
231
    const rawRowCountData = await this.handleRowCount({
64✔
232
      tableId,
64✔
233
      dbTableName,
64✔
234
      fieldInstanceMap,
64✔
235
      fieldInstanceMapWithoutHiddenFields,
64✔
236
      filter,
64✔
237
      filterLinkCellCandidate,
64✔
238
      filterLinkCellSelected,
64✔
239
      selectedRecordIds,
64✔
240
      search: queryRo.search,
64✔
241
      withUserId: currentUserId,
64✔
242
    });
64✔
243

64✔
244
    return {
64✔
245
      rowCount: Number(rawRowCountData[0]?.count ?? 0),
64✔
246
    };
64✔
247
  }
64✔
248

161✔
249
  private async fetchStatisticsParams(params: {
161✔
250
    tableId: string;
286✔
251
    withView?: IWithView;
286✔
252
    withFieldIds?: string[];
286✔
253
  }): Promise<{
286✔
254
    statisticsData: IStatisticsData;
286✔
255
    fieldInstanceMap: Record<string, IFieldInstance>;
286✔
256
    fieldInstanceMapWithoutHiddenFields: Record<string, IFieldInstance>;
286✔
257
  }> {
286✔
258
    const { tableId, withView, withFieldIds } = params;
286✔
259

286✔
260
    const viewRaw = await this.findView(tableId, withView);
286✔
261

286✔
262
    const { fieldInstances, fieldInstanceMap } = await this.getFieldsData(tableId);
286✔
263
    const filteredFieldInstances = this.filterFieldInstances(
286✔
264
      fieldInstances,
286✔
265
      withView,
286✔
266
      withFieldIds
286✔
267
    );
286✔
268

286✔
269
    const statisticsData = this.buildStatisticsData(filteredFieldInstances, viewRaw, withView);
286✔
270

286✔
271
    const fieldInstanceMapWithoutHiddenFields = { ...fieldInstanceMap };
286✔
272

286✔
273
    if (viewRaw?.columnMeta) {
286✔
274
      const columnMeta = JSON.parse(viewRaw?.columnMeta);
234✔
275
      Object.entries(columnMeta).forEach(([key, value]) => {
234✔
276
        if (get(value, ['hidden'])) {
2,048✔
277
          delete fieldInstanceMapWithoutHiddenFields[key];
×
278
        }
×
279
      });
2,048✔
280
    }
234✔
281
    return { statisticsData, fieldInstanceMap, fieldInstanceMapWithoutHiddenFields };
286✔
282
  }
286✔
283

161✔
284
  private async findView(tableId: string, withView?: IWithView) {
161✔
285
    if (!withView?.viewId) {
286✔
286
      return undefined;
52✔
287
    }
52✔
288

234✔
289
    return nullsToUndefined(
234✔
290
      await this.prisma.view.findFirst({
234✔
291
        select: { id: true, columnMeta: true, filter: true, group: true },
234✔
292
        where: {
234✔
293
          tableId,
234✔
294
          ...(withView?.viewId ? { id: withView.viewId } : {}),
286✔
295
          type: { in: [ViewType.Grid, ViewType.Gantt, ViewType.Kanban, ViewType.Gallery] },
286✔
296
          deletedTime: null,
286✔
297
        },
286✔
298
      })
286✔
299
    );
234✔
300
  }
234✔
301

161✔
302
  private filterFieldInstances(
161✔
303
    fieldInstances: IFieldInstance[],
286✔
304
    withView?: IWithView,
286✔
305
    withFieldIds?: string[]
286✔
306
  ) {
286✔
307
    const targetFieldIds =
286✔
308
      withView?.customFieldStats?.map((field) => field.fieldId) ?? withFieldIds;
286✔
309

286✔
310
    return targetFieldIds?.length
286✔
311
      ? fieldInstances.filter((instance) => targetFieldIds.includes(instance.id))
222✔
312
      : fieldInstances;
64✔
313
  }
286✔
314

161✔
315
  private buildStatisticsData(
161✔
316
    filteredFieldInstances: IFieldInstance[],
286✔
317
    viewRaw:
286✔
318
      | {
286✔
319
          id: string | undefined;
286✔
320
          columnMeta: string | undefined;
286✔
321
          filter: string | undefined;
286✔
322
          group: string | undefined;
286✔
323
        }
286✔
324
      | undefined,
286✔
325
    withView?: IWithView
286✔
326
  ) {
286✔
327
    let statisticsData: IStatisticsData = {
286✔
328
      viewId: viewRaw?.id,
286✔
329
    };
286✔
330

286✔
331
    if (viewRaw?.filter || withView?.customFilter) {
286✔
332
      const filter = mergeWithDefaultFilter(viewRaw?.filter, withView?.customFilter);
2✔
333
      statisticsData = { ...statisticsData, filter };
2✔
334
    }
2✔
335

286✔
336
    if (viewRaw?.id || withView?.customFieldStats) {
286✔
337
      const statisticFields = this.getStatisticFields(
234✔
338
        filteredFieldInstances,
234✔
339
        viewRaw?.columnMeta && JSON.parse(viewRaw.columnMeta),
234✔
340
        withView?.customFieldStats
234✔
341
      );
234✔
342
      statisticsData = { ...statisticsData, statisticFields };
234✔
343
    }
234✔
344
    return statisticsData;
286✔
345
  }
286✔
346

161✔
347
  async getFieldsData(tableId: string, fieldIds?: string[], withName?: boolean) {
161✔
348
    const fieldsRaw = await this.prisma.field.findMany({
524✔
349
      where: { tableId, ...(fieldIds ? { id: { in: fieldIds } } : {}), deletedTime: null },
524✔
350
    });
524✔
351

524✔
352
    const fieldInstances = fieldsRaw.map((field) => createFieldInstanceByRaw(field));
524✔
353
    const fieldInstanceMap = fieldInstances.reduce(
524✔
354
      (map, field) => {
524✔
355
        map[field.id] = field;
2,588✔
356
        if (withName || withName === undefined) {
2,588✔
357
          map[field.name] = field;
2,428✔
358
        }
2,428✔
359
        return map;
2,588✔
360
      },
2,588✔
361
      {} as Record<string, IFieldInstance>
524✔
362
    );
524✔
363
    return { fieldInstances, fieldInstanceMap };
524✔
364
  }
524✔
365

161✔
366
  private getStatisticFields(
161✔
367
    fieldInstances: IFieldInstance[],
234✔
368
    columnMeta?: IGridColumnMeta,
234✔
369
    customFieldStats?: ICustomFieldStats[]
234✔
370
  ) {
234✔
371
    let calculatedStatisticFields: IAggregationField[] | undefined;
234✔
372
    const customFieldStatsGrouped = groupBy(customFieldStats, 'fieldId');
234✔
373

234✔
374
    fieldInstances.forEach((fieldInstance) => {
234✔
375
      const { id: fieldId } = fieldInstance;
274✔
376
      const viewColumnMeta = columnMeta ? columnMeta[fieldId] : undefined;
274✔
377
      const customFieldStats = customFieldStatsGrouped[fieldId];
274✔
378

274✔
379
      if (viewColumnMeta || customFieldStats) {
274✔
380
        const { hidden, statisticFunc } = viewColumnMeta || {};
274✔
381
        const statisticFuncList = customFieldStats
274✔
382
          ?.filter((item) => item.statisticFunc)
274✔
383
          ?.map((item) => item.statisticFunc) as StatisticsFunc[];
274✔
384

274✔
385
        const funcList = !isEmpty(statisticFuncList)
274✔
386
          ? statisticFuncList
224✔
387
          : statisticFunc && [statisticFunc];
50✔
388

274✔
389
        if (hidden !== true && funcList && funcList.length) {
274✔
390
          const statisticFieldList = funcList.map((item) => {
224✔
391
            return {
230✔
392
              fieldId,
230✔
393
              statisticFunc: item,
230✔
394
            };
230✔
395
          });
230✔
396
          (calculatedStatisticFields = calculatedStatisticFields ?? []).push(...statisticFieldList);
224✔
397
        }
224✔
398
      }
274✔
399
    });
274✔
400
    return calculatedStatisticFields;
234✔
401
  }
234✔
402

161✔
403
  private async handleAggregation(params: {
161✔
404
    dbTableName: string;
332✔
405
    fieldInstanceMap: Record<string, IFieldInstance>;
332✔
406
    fieldInstanceMapWithoutHiddenFields: Record<string, IFieldInstance>;
332✔
407
    filter?: IFilter;
332✔
408
    groupBy?: IGroup;
332✔
409
    search?: [string, string?, boolean?];
332✔
410
    statisticFields?: IAggregationField[];
332✔
411
    withUserId?: string;
332✔
412
  }) {
332✔
413
    const { dbTableName, fieldInstanceMap, filter, search, statisticFields, withUserId, groupBy } =
332✔
414
      params;
332✔
415

332✔
416
    if (!statisticFields?.length) {
332✔
417
      return;
×
418
    }
×
419

332✔
420
    const tableAlias = 'main_table';
332✔
421
    const queryBuilder = this.knex
332✔
422
      .with(tableAlias, (qb) => {
332✔
423
        qb.select('*').from(dbTableName);
332✔
424
        if (filter) {
332✔
425
          this.dbProvider
2✔
426
            .filterQuery(qb, fieldInstanceMap, filter, { withUserId })
2✔
427
            .appendQueryBuilder();
2✔
428
        }
2✔
429
        if (search && search[2]) {
332✔
430
          qb.where((builder) => {
×
431
            this.dbProvider.searchQuery(builder, fieldInstanceMap, search);
×
432
          });
×
433
        }
×
434
      })
332✔
435
      .from(tableAlias);
332✔
436

332✔
437
    const qb = this.dbProvider
332✔
438
      .aggregationQuery(queryBuilder, tableAlias, fieldInstanceMap, statisticFields)
332✔
439
      .appendBuilder();
332✔
440

332✔
441
    if (groupBy) {
332✔
442
      this.dbProvider
110✔
443
        .groupQuery(
110✔
444
          qb,
110✔
445
          fieldInstanceMap,
110✔
446
          groupBy.map((item) => item.fieldId)
110✔
447
        )
110✔
448
        .appendGroupBuilder();
110✔
449
    }
110✔
450
    const aggSql = qb.toQuery();
332✔
451
    return this.prisma.$queryRawUnsafe<{ [field: string]: unknown }[]>(aggSql);
332✔
452
  }
332✔
453

161✔
454
  private async handleRowCount(params: {
161✔
455
    tableId: string;
64✔
456
    dbTableName: string;
64✔
457
    fieldInstanceMap: Record<string, IFieldInstance>;
64✔
458
    fieldInstanceMapWithoutHiddenFields: Record<string, IFieldInstance>;
64✔
459
    filter?: IFilter;
64✔
460
    filterLinkCellCandidate?: IGetRecordsRo['filterLinkCellCandidate'];
64✔
461
    filterLinkCellSelected?: IGetRecordsRo['filterLinkCellSelected'];
64✔
462
    selectedRecordIds?: IGetRecordsRo['selectedRecordIds'];
64✔
463
    search?: [string, string?, boolean?];
64✔
464
    withUserId?: string;
64✔
465
  }) {
64✔
466
    const {
64✔
467
      tableId,
64✔
468
      dbTableName,
64✔
469
      fieldInstanceMap,
64✔
470
      fieldInstanceMapWithoutHiddenFields,
64✔
471
      filter,
64✔
472
      filterLinkCellCandidate,
64✔
473
      filterLinkCellSelected,
64✔
474
      selectedRecordIds,
64✔
475
      search,
64✔
476
      withUserId,
64✔
477
    } = params;
64✔
478

64✔
479
    const queryBuilder = this.knex(dbTableName);
64✔
480

64✔
481
    if (filter) {
64✔
482
      this.dbProvider
×
483
        .filterQuery(queryBuilder, fieldInstanceMap, filter, { withUserId })
×
484
        .appendQueryBuilder();
×
485
    }
×
486

64✔
487
    if (search && search[2]) {
64✔
488
      queryBuilder.where((builder) => {
×
489
        this.dbProvider.searchQuery(builder, fieldInstanceMapWithoutHiddenFields, search);
×
490
      });
×
491
    }
×
492

64✔
493
    if (selectedRecordIds) {
64✔
494
      filterLinkCellCandidate
4✔
495
        ? queryBuilder.whereNotIn(`${dbTableName}.__id`, selectedRecordIds)
2✔
496
        : queryBuilder.whereIn(`${dbTableName}.__id`, selectedRecordIds);
2✔
497
    }
4✔
498

64✔
499
    if (filterLinkCellCandidate) {
64✔
500
      await this.recordService.buildLinkCandidateQuery(
26✔
501
        queryBuilder,
26✔
502
        tableId,
26✔
503
        dbTableName,
26✔
504
        filterLinkCellCandidate
26✔
505
      );
26✔
506
    }
26✔
507

64✔
508
    if (filterLinkCellSelected) {
64✔
509
      await this.recordService.buildLinkSelectedQuery(
24✔
510
        queryBuilder,
24✔
511
        tableId,
24✔
512
        dbTableName,
24✔
513
        filterLinkCellSelected
24✔
514
      );
24✔
515
    }
24✔
516

64✔
517
    return this.getRowCount(this.prisma, queryBuilder);
64✔
518
  }
64✔
519

161✔
520
  private convertValueToNumberOrString(currentValue: unknown): number | string | null {
161✔
521
    if (typeof currentValue === 'bigint' || typeof currentValue === 'number') {
1,634✔
522
      return Number(currentValue);
1,227✔
523
    }
1,227✔
524
    if (isDate(currentValue)) {
1,634✔
525
      return currentValue.toISOString();
38✔
526
    }
38✔
527
    return currentValue?.toString() ?? null;
1,634✔
528
  }
1,634✔
529

161✔
530
  private calculateDateRangeOfMonths(currentValue: string): number {
161✔
531
    const [maxTime, minTime] = currentValue.split(',');
39✔
532
    return maxTime && minTime ? dayjs(maxTime).diff(minTime, 'month') : 0;
39✔
533
  }
39✔
534

161✔
535
  private formatConvertValue = (currentValue: unknown, aggFunc?: StatisticsFunc) => {
161✔
536
    let convertValue = this.convertValueToNumberOrString(currentValue);
1,634✔
537

1,634✔
538
    if (!aggFunc) {
1,634✔
539
      return convertValue;
×
540
    }
×
541

1,634✔
542
    if (aggFunc === StatisticsFunc.DateRangeOfMonths && typeof currentValue === 'string') {
1,634✔
543
      convertValue = this.calculateDateRangeOfMonths(currentValue);
39✔
544
    }
39✔
545

1,634✔
546
    const defaultToZero = [
1,634✔
547
      StatisticsFunc.PercentEmpty,
1,634✔
548
      StatisticsFunc.PercentFilled,
1,634✔
549
      StatisticsFunc.PercentUnique,
1,634✔
550
      StatisticsFunc.PercentChecked,
1,634✔
551
      StatisticsFunc.PercentUnChecked,
1,634✔
552
    ];
1,634✔
553

1,634✔
554
    if (defaultToZero.includes(aggFunc)) {
1,634✔
555
      convertValue = convertValue ?? 0;
554✔
556
    }
554✔
557
    return convertValue;
1,634✔
558
  };
1,634✔
559

161✔
560
  private async getDbTableName(prisma: Prisma.TransactionClient, tableId: string) {
161✔
561
    const tableMeta = await prisma.tableMeta.findUniqueOrThrow({
302✔
562
      where: { id: tableId },
302✔
563
      select: { dbTableName: true },
302✔
564
    });
302✔
565
    return tableMeta.dbTableName;
302✔
566
  }
302✔
567

161✔
568
  private async getRowCount(prisma: Prisma.TransactionClient, queryBuilder: Knex.QueryBuilder) {
161✔
569
    queryBuilder
64✔
570
      .clearSelect()
64✔
571
      .clearCounters()
64✔
572
      .clearGroup()
64✔
573
      .clearHaving()
64✔
574
      .clearOrder()
64✔
575
      .clear('limit')
64✔
576
      .clear('offset');
64✔
577
    const rowCountSql = queryBuilder.count({ count: '*' });
64✔
578

64✔
579
    return prisma.$queryRawUnsafe<{ count?: number }[]>(rowCountSql.toQuery());
64✔
580
  }
64✔
581

161✔
582
  public async getGroupPoints(tableId: string, query?: IGroupPointsRo) {
161✔
583
    const { groupPoints } = await this.recordService.getGroupRelatedData(tableId, query);
8✔
584
    return groupPoints;
8✔
585
  }
8✔
586

161✔
587
  public async getSearchCount(tableId: string, queryRo: ISearchCountRo, projection?: string[]) {
161✔
588
    const { search, viewId } = queryRo;
8✔
589
    const dbFieldName = await this.getDbTableName(this.prisma, tableId);
8✔
590
    const { fieldInstanceMap } = await this.getFieldsData(tableId, undefined, false);
8✔
591

8✔
592
    if (!search) {
8✔
NEW
593
      throw new BadRequestException('Search query is required');
×
594
    }
×
595

8✔
596
    const searchFields = await this.recordService.getSearchFields(
8✔
597
      fieldInstanceMap,
8✔
598
      search,
8✔
599
      viewId,
8✔
600
      projection
8✔
601
    );
8✔
602

8✔
603
    const queryBuilder = this.knex(dbFieldName);
8✔
604
    this.dbProvider.searchCountQuery(queryBuilder, searchFields, search[0]);
8✔
605
    this.dbProvider
8✔
606
      .filterQuery(queryBuilder, fieldInstanceMap, queryRo?.filter, {
8✔
607
        withUserId: this.cls.get('user.id'),
8✔
608
      })
8✔
609
      .appendQueryBuilder();
8✔
610

8✔
611
    const sql = queryBuilder.toQuery();
8✔
612

8✔
613
    const result = await this.prisma.$queryRawUnsafe<{ count: number }[] | null>(sql);
8✔
614

8✔
615
    return {
8✔
616
      count: result ? Number(result[0]?.count) : 0,
8✔
617
    };
8✔
618
  }
8✔
619

161✔
620
  public async getRecordIndexBySearchOrder(
161✔
621
    tableId: string,
8✔
622
    queryRo: ISearchIndexByQueryRo,
8✔
623
    projection?: string[]
8✔
624
  ) {
8✔
625
    const { search, take, skip, orderBy, filter, viewId, groupBy } = queryRo;
8✔
626
    const dbTableName = await this.getDbTableName(this.prisma, tableId);
8✔
627
    const { fieldInstanceMap } = await this.getFieldsData(tableId, undefined, false);
8✔
628

8✔
629
    if (take > 1000) {
8✔
630
      throw new BadGatewayException('The maximum search index result is 1000');
2✔
631
    }
2✔
632

6✔
633
    if (!search) {
8✔
NEW
634
      throw new BadRequestException('Search query is required');
×
635
    }
✔
636

6✔
637
    const searchFields = await this.recordService.getSearchFields(
6✔
638
      fieldInstanceMap,
6✔
639
      search,
6✔
640
      viewId,
6✔
641
      projection
6✔
642
    );
6✔
643

6✔
644
    const { queryBuilder: viewRecordsQB } = await this.recordService.buildFilterSortQuery(
6✔
645
      tableId,
6✔
646
      queryRo
6✔
647
    );
6✔
648

6✔
649
    const basicSortIndex = await this.recordService.getBasicOrderIndexField(dbTableName, viewId);
6✔
650

6✔
651
    const queryBuilder = this.knex.queryBuilder();
6✔
652

6✔
653
    this.dbProvider.searchIndexQuery(queryBuilder, searchFields, search?.[0], dbTableName);
8✔
654

8✔
655
    if (orderBy?.length || groupBy?.length) {
8✔
656
      this.dbProvider
×
657
        .sortQuery(queryBuilder, fieldInstanceMap, [...(groupBy ?? []), ...(orderBy ?? [])])
×
658
        .appendSortBuilder();
×
659
    }
✔
660

6✔
661
    if (filter) {
8✔
662
      this.dbProvider
×
663
        .filterQuery(queryBuilder, fieldInstanceMap, filter, {
×
664
          withUserId: this.cls.get('user.id'),
×
665
        })
×
666
        .appendQueryBuilder();
×
667
    }
✔
668

6✔
669
    queryBuilder.orderBy(basicSortIndex, 'asc');
6✔
670
    const cases = searchFields.map((field, index) => {
6✔
671
      return this.knex.raw(`CASE WHEN ?? = ? THEN ? END`, [
54✔
672
        'matched_column',
54✔
673
        field.dbFieldName,
54✔
674
        index + 1,
54✔
675
      ]);
54✔
676
    });
54✔
677
    cases.length && queryBuilder.orderByRaw(cases.join(','));
6✔
678

8✔
679
    queryBuilder.limit(take);
8✔
680
    skip && queryBuilder.offset(skip);
8✔
681

8✔
682
    const sql = queryBuilder.toQuery();
8✔
683

8✔
684
    const result = await this.prisma.$queryRawUnsafe<{ __id: string; fieldId: string }[]>(sql);
8✔
685

6✔
686
    // no result found
6✔
687
    if (result?.length === 0) {
8✔
688
      return null;
2✔
689
    }
2✔
690

4✔
691
    const recordIds = result;
4✔
692

4✔
693
    // step 2. find the index in current view
4✔
694
    const indexQueryBuilder = this.knex
4✔
695
      .select('row_num')
4✔
696
      .select('__id')
4✔
697
      .from((qb: Knex.QueryBuilder) => {
4✔
698
        qb.select('__id')
4✔
699
          .select(this.knex.client.raw('ROW_NUMBER() OVER () as row_num'))
4✔
700
          .from(viewRecordsQB.as('t'))
4✔
701
          .as('t1');
4✔
702
      })
4✔
703
      .whereIn(
4✔
704
        '__id',
4✔
705
        recordIds.map((record) => record.__id)
4✔
706
      );
4✔
707

4✔
708
    // eslint-disable-next-line
4✔
709
    const indexResult = await this.prisma.$queryRawUnsafe<{ row_num: number; __id: string }[]>(
4✔
710
      indexQueryBuilder.toQuery()
4✔
711
    );
4✔
712

4✔
713
    if (indexResult?.length === 0) {
8✔
714
      return null;
×
715
    }
✔
716

4✔
717
    return result.map((item) => {
4✔
718
      const index = Number(indexResult.find((indexItem) => indexItem.__id === item.__id)?.row_num);
40✔
719
      if (isNaN(index)) {
40✔
NEW
720
        throw new Error('Index not found');
×
721
      }
×
722
      return {
40✔
723
        index,
40✔
724
        fieldId: item.fieldId,
40✔
725
      };
40✔
726
    });
40✔
727
  }
4✔
728
}
161✔
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