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

teableio / teable / 8356130399

20 Mar 2024 08:52AM UTC coverage: 28.231% (+0.06%) from 28.17%
8356130399

push

github

web-flow
refactor: row order (#473)

* refactor: row order

* fix: sqlite test

2122 of 3238 branches covered (65.53%)

194 of 588 new or added lines in 38 files covered. (32.99%)

2 existing lines in 2 files now uncovered.

25811 of 91428 relevant lines covered (28.23%)

5.58 hits per line

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

16.09
/apps/nestjs-backend/src/features/record/record.service.ts
1
import {
1✔
2
  BadRequestException,
1✔
3
  Injectable,
1✔
4
  InternalServerErrorException,
1✔
5
  Logger,
1✔
6
  NotFoundException,
1✔
7
} from '@nestjs/common';
1✔
8
import type {
1✔
9
  IAttachmentCellValue,
1✔
10
  ICreateRecordsRo,
1✔
11
  IExtraResult,
1✔
12
  IFilter,
1✔
13
  IGetRecordQuery,
1✔
14
  IGetRecordsRo,
1✔
15
  IGroup,
1✔
16
  ILinkCellValue,
1✔
17
  IRecord,
1✔
18
  IRecordsVo,
1✔
19
  ISetRecordOpContext,
1✔
20
  IShareViewMeta,
1✔
21
  ISnapshotBase,
1✔
22
  ISortItem,
1✔
23
} from '@teable/core';
1✔
24
import {
1✔
25
  CellFormat,
1✔
26
  FieldKeyType,
1✔
27
  FieldType,
1✔
28
  generateRecordId,
1✔
29
  identify,
1✔
30
  IdPrefix,
1✔
31
  mergeWithDefaultFilter,
1✔
32
  mergeWithDefaultSort,
1✔
33
  OpName,
1✔
34
  parseGroup,
1✔
35
  Relationship,
1✔
36
} from '@teable/core';
1✔
37
import type { Field, Prisma } from '@teable/db-main-prisma';
1✔
38
import { PrismaService } from '@teable/db-main-prisma';
1✔
39
import { UploadType } from '@teable/openapi';
1✔
40
import { Knex } from 'knex';
1✔
41
import { keyBy } from 'lodash';
1✔
42
import { InjectModel } from 'nest-knexjs';
1✔
43
import { ClsService } from 'nestjs-cls';
1✔
44
import { ThresholdConfig, IThresholdConfig } from '../../configs/threshold.config';
1✔
45
import { InjectDbProvider } from '../../db-provider/db.provider';
1✔
46
import { IDbProvider } from '../../db-provider/db.provider.interface';
1✔
47
import type { IAdapterService } from '../../share-db/interface';
1✔
48
import { RawOpType } from '../../share-db/interface';
1✔
49
import type { IClsStore } from '../../types/cls';
1✔
50
import { Timing } from '../../utils/timing';
1✔
51
import { AttachmentsStorageService } from '../attachments/attachments-storage.service';
1✔
52
import StorageAdapter from '../attachments/plugins/adapter';
1✔
53
import { BatchService } from '../calculation/batch.service';
1✔
54
import type { IVisualTableDefaultField } from '../field/constant';
1✔
55
import { preservedDbFieldNames } from '../field/constant';
1✔
56
import type { IFieldInstance } from '../field/model/factory';
1✔
57
import { createFieldInstanceByRaw } from '../field/model/factory';
1✔
58
import { ROW_ORDER_FIELD_PREFIX } from '../view/constant';
1✔
59

1✔
60
type IUserFields = { id: string; dbFieldName: string }[];
1✔
61

1✔
62
@Injectable()
1✔
63
export class RecordService implements IAdapterService {
1✔
64
  private logger = new Logger(RecordService.name);
150✔
65

150✔
66
  constructor(
150✔
67
    private readonly prismaService: PrismaService,
150✔
68
    private readonly batchService: BatchService,
150✔
69
    private readonly attachmentStorageService: AttachmentsStorageService,
150✔
70
    private readonly cls: ClsService<IClsStore>,
150✔
71
    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,
150✔
72
    @InjectDbProvider() private readonly dbProvider: IDbProvider,
150✔
73
    @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig
150✔
74
  ) {}
150✔
75

150✔
76
  private dbRecord2RecordFields(
150✔
77
    record: IRecord['fields'],
×
78
    fields: IFieldInstance[],
×
79
    fieldKeyType?: FieldKeyType,
×
80
    cellFormat: CellFormat = CellFormat.Json
×
81
  ) {
×
82
    return fields.reduce<IRecord['fields']>((acc, field) => {
×
83
      const fieldNameOrId = fieldKeyType === FieldKeyType.Name ? field.name : field.id;
×
84
      const dbCellValue = record[field.dbFieldName];
×
85
      const cellValue = field.convertDBValue2CellValue(dbCellValue);
×
86
      if (cellValue != null) {
×
87
        acc[fieldNameOrId] =
×
88
          cellFormat === CellFormat.Text ? field.cellValue2String(cellValue) : cellValue;
×
89
      }
×
90
      return acc;
×
91
    }, {});
×
92
  }
×
93

150✔
94
  async getAllRecordCount(dbTableName: string) {
150✔
95
    const sqlNative = this.knex(dbTableName).count({ count: '*' }).toSQL().toNative();
×
96

×
97
    const queryResult = await this.prismaService
×
98
      .txClient()
×
99
      .$queryRawUnsafe<{ count?: number }[]>(sqlNative.sql, ...sqlNative.bindings);
×
100
    return Number(queryResult[0]?.count ?? 0);
×
101
  }
×
102

150✔
103
  async getDbValueMatrix(
150✔
104
    dbTableName: string,
×
105
    userFields: IUserFields,
×
106
    rowIndexFieldNames: string[],
×
107
    createRecordsRo: ICreateRecordsRo
×
108
  ) {
×
109
    const rowCount = await this.getAllRecordCount(dbTableName);
×
110
    const dbValueMatrix: unknown[][] = [];
×
111
    for (let i = 0; i < createRecordsRo.records.length; i++) {
×
112
      const recordData = createRecordsRo.records[i].fields;
×
113
      // 1. collect cellValues
×
114
      const recordValues = userFields.map<unknown>((field) => {
×
115
        const cellValue = recordData[field.id];
×
116
        if (cellValue == null) {
×
117
          return null;
×
118
        }
×
119
        return cellValue;
×
120
      });
×
121

×
122
      // 2. generate rowIndexValues
×
123
      const rowIndexValues = rowIndexFieldNames.map(() => rowCount + i);
×
124

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

×
128
      dbValueMatrix.push([...recordValues, ...rowIndexValues, ...systemValues]);
×
129
    }
×
130
    return dbValueMatrix;
×
131
  }
×
132

150✔
133
  async getDbTableName(tableId: string) {
150✔
134
    const tableMeta = await this.prismaService
×
135
      .txClient()
×
136
      .tableMeta.findUniqueOrThrow({
×
137
        where: { id: tableId },
×
138
        select: { dbTableName: true },
×
139
      })
×
140
      .catch(() => {
×
141
        throw new NotFoundException(`Table ${tableId} not found`);
×
142
      });
×
143
    return tableMeta.dbTableName;
×
144
  }
×
145

150✔
146
  private async getLinkCellIds(fieldRaw: Field, recordId: string) {
150✔
147
    const prisma = this.prismaService.txClient();
×
148
    const dbTableName = await prisma.tableMeta.findFirstOrThrow({
×
149
      where: { id: fieldRaw.tableId },
×
150
      select: { dbTableName: true },
×
151
    });
×
152
    const linkCellQuery = this.knex(dbTableName)
×
153
      .select({
×
154
        id: '__id',
×
155
        linkField: fieldRaw.dbFieldName,
×
156
      })
×
157
      .where('__id', recordId)
×
158
      .toQuery();
×
159
    const field = createFieldInstanceByRaw(fieldRaw);
×
160
    const result = await prisma.$queryRawUnsafe<
×
161
      {
×
162
        id: string;
×
163
        linkField: string | null;
×
164
      }[]
×
165
    >(linkCellQuery);
×
166
    return result
×
167
      .map(
×
168
        (item) =>
×
169
          field.convertDBValue2CellValue(item.linkField) as ILinkCellValue | ILinkCellValue[]
×
170
      )
×
171
      .filter(Boolean)
×
172
      .flat()
×
173
      .map((item) => item.id);
×
174
  }
×
175

150✔
176
  async getLinkSelectedRecordIds(
150✔
177
    filterLinkCellSelected: [string, string] | string
×
178
  ): Promise<{ ids: string[] }> {
×
179
    const fieldId = Array.isArray(filterLinkCellSelected)
×
180
      ? filterLinkCellSelected[0]
×
181
      : filterLinkCellSelected;
×
182
    const recordId = Array.isArray(filterLinkCellSelected) ? filterLinkCellSelected[1] : undefined;
×
183

×
184
    if (!fieldId) {
×
185
      throw new BadRequestException(
×
186
        'filterByLinkFieldId is required when filterByLinkRecordId is set'
×
187
      );
×
188
    }
×
189

×
190
    const prisma = this.prismaService.txClient();
×
191
    const fieldRaw = await prisma.field
×
192
      .findFirstOrThrow({
×
193
        where: { id: fieldId, deletedTime: null },
×
194
      })
×
195
      .catch(() => {
×
196
        throw new NotFoundException(`Field ${fieldId} not found`);
×
197
      });
×
198

×
199
    if (fieldRaw.type !== FieldType.Link) {
×
200
      throw new BadRequestException('You can only filter by link field');
×
201
    }
×
202

×
203
    return {
×
204
      ids: recordId ? await this.getLinkCellIds(fieldRaw, recordId) : [],
×
205
    };
×
206
  }
×
207

150✔
208
  private isJunctionTable(dbTableName: string) {
150✔
209
    if (dbTableName.includes('.')) {
×
210
      return dbTableName.split('.')[1].startsWith('junction');
×
211
    }
×
212
    return dbTableName.split('_')[1].startsWith('junction');
×
213
  }
×
214

150✔
215
  async buildLinkCandidateQuery(
150✔
216
    queryBuilder: Knex.QueryBuilder,
×
217
    tableId: string,
×
218
    filterLinkCellCandidate: [string, string] | string
×
219
  ) {
×
220
    const prisma = this.prismaService.txClient();
×
221
    const fieldId = Array.isArray(filterLinkCellCandidate)
×
222
      ? filterLinkCellCandidate[0]
×
223
      : filterLinkCellCandidate;
×
224
    const recordId = Array.isArray(filterLinkCellCandidate)
×
225
      ? filterLinkCellCandidate[1]
×
226
      : undefined;
×
227

×
228
    const fieldRaw = await prisma.field
×
229
      .findFirstOrThrow({
×
230
        where: { id: fieldId, deletedTime: null },
×
231
      })
×
232
      .catch(() => {
×
233
        throw new NotFoundException(`Field ${fieldId} not found`);
×
234
      });
×
235

×
236
    const field = createFieldInstanceByRaw(fieldRaw);
×
237

×
238
    if (field.type !== FieldType.Link) {
×
239
      throw new BadRequestException('You can only filter by link field');
×
240
    }
×
241
    const { foreignTableId, fkHostTableName, selfKeyName, foreignKeyName, relationship } =
×
242
      field.options;
×
243
    if (foreignTableId !== tableId) {
×
244
      throw new BadRequestException('Field is not linked to current table');
×
245
    }
×
246
    if (relationship === Relationship.OneMany) {
×
247
      if (this.isJunctionTable(fkHostTableName)) {
×
248
        queryBuilder.whereNotIn('__id', function () {
×
249
          this.select(foreignKeyName).from(fkHostTableName);
×
250
        });
×
251
      } else {
×
252
        queryBuilder.where(selfKeyName, null);
×
253
      }
×
254
    }
×
255
    if (relationship === Relationship.OneOne) {
×
256
      if (selfKeyName === '__id') {
×
257
        queryBuilder.whereNotIn('__id', function () {
×
258
          this.select(foreignKeyName).from(fkHostTableName).whereNotNull(foreignKeyName);
×
259
        });
×
260
      } else {
×
261
        queryBuilder.where(selfKeyName, null);
×
262
      }
×
263
    }
×
264
    if (recordId) {
×
265
      const linkIds = await this.getLinkCellIds(fieldRaw, recordId);
×
266
      if (linkIds.length) {
×
267
        queryBuilder.whereNotIn('__id', linkIds);
×
268
      }
×
269
    }
×
270
  }
×
271

150✔
272
  private async getNecessaryFieldMap(
150✔
273
    tableId: string,
×
274
    filter?: IFilter,
×
275
    orderBy?: ISortItem[],
×
276
    groupBy?: IGroup
×
277
  ) {
×
278
    if (filter || orderBy?.length || groupBy?.length) {
×
279
      // The field Meta is needed to construct the filter if it exists
×
280
      const fields = await this.getFieldsByProjection(tableId);
×
281
      return fields.reduce(
×
282
        (map, field) => {
×
283
          map[field.id] = field;
×
284
          map[field.name] = field;
×
285
          return map;
×
286
        },
×
287
        {} as Record<string, IFieldInstance>
×
288
      );
×
289
    }
×
290
  }
×
291

150✔
292
  private async getTinyView(tableId: string, viewId?: string) {
150✔
293
    if (!viewId) {
×
294
      return;
×
295
    }
×
296

×
297
    return this.prismaService
×
298
      .txClient()
×
299
      .view.findFirstOrThrow({
×
300
        select: { id: true, type: true, filter: true, sort: true, group: true },
×
301
        where: { tableId, id: viewId, deletedTime: null },
×
302
      })
×
303
      .catch(() => {
×
304
        throw new NotFoundException(`View ${viewId} not found`);
×
305
      });
×
306
  }
×
307

150✔
308
  async prepareQuery(
150✔
309
    tableId: string,
×
310
    query: Pick<IGetRecordsRo, 'viewId' | 'orderBy' | 'groupBy' | 'filter'>
×
311
  ) {
×
312
    const { viewId, orderBy: extraOrderBy, groupBy: extraGroupBy, filter: extraFilter } = query;
×
313

×
314
    const dbTableName = await this.getDbTableName(tableId);
×
315

×
316
    const queryBuilder = this.knex(dbTableName);
×
317

×
318
    const view = await this.getTinyView(tableId, viewId);
×
319

×
320
    const filter = mergeWithDefaultFilter(view?.filter, extraFilter);
×
321
    const orderBy = mergeWithDefaultSort(view?.sort, extraOrderBy);
×
322
    const groupBy = parseGroup(extraGroupBy);
×
323
    const fieldMap = await this.getNecessaryFieldMap(tableId, filter, orderBy, groupBy);
×
324

×
325
    return {
×
326
      queryBuilder,
×
NEW
327
      dbTableName,
×
328
      filter,
×
329
      orderBy,
×
330
      groupBy,
×
331
      fieldMap,
×
332
    };
×
333
  }
×
334

150✔
335
  async getBasicOrderIndexField(dbTableName: string, viewId: string | undefined) {
150✔
NEW
336
    const columnName = `${ROW_ORDER_FIELD_PREFIX}_${viewId}`;
×
NEW
337
    const exists = await this.dbProvider.checkColumnExist(
×
NEW
338
      dbTableName,
×
NEW
339
      columnName,
×
NEW
340
      this.prismaService.txClient()
×
NEW
341
    );
×
NEW
342

×
NEW
343
    if (exists) {
×
NEW
344
      return columnName;
×
NEW
345
    }
×
NEW
346
    return '__auto_number';
×
NEW
347
  }
×
348

150✔
349
  /**
150✔
350
   * Builds a query based on filtering and sorting criteria.
150✔
351
   *
150✔
352
   * This method creates a `Knex` query builder that constructs SQL queries based on the provided
150✔
353
   * filtering and sorting parameters. It also takes into account the context of the current user,
150✔
354
   * which is crucial for ensuring the security and relevance of data access.
150✔
355
   *
150✔
356
   * @param {string} tableId - The unique identifier of the table to determine the target of the query.
150✔
357
   * @param {Pick<IGetRecordsRo, 'viewId' | 'orderBy' | 'filter' | 'filterLinkCellCandidate'>} query - An object of query parameters, including view ID, sorting rules, filtering conditions, etc.
150✔
358
   * @returns {Promise<Knex.QueryBuilder>} Returns an instance of the Knex query builder encapsulating the constructed SQL query.
150✔
359
   */
150✔
360
  async buildFilterSortQuery(
150✔
361
    tableId: string,
×
362
    query: Pick<
×
363
      IGetRecordsRo,
×
364
      'viewId' | 'orderBy' | 'groupBy' | 'filter' | 'filterLinkCellCandidate'
×
365
    >
×
366
  ): Promise<Knex.QueryBuilder> {
×
367
    // Prepare the base query builder, filtering conditions, sorting rules, grouping rules and field mapping
×
NEW
368
    const { dbTableName, queryBuilder, filter, orderBy, groupBy, fieldMap } =
×
NEW
369
      await this.prepareQuery(tableId, query);
×
370

×
371
    // Retrieve the current user's ID to build user-related query conditions
×
372
    const currentUserId = this.cls.get('user.id');
×
373

×
374
    if (query.filterLinkCellCandidate) {
×
375
      await this.buildLinkCandidateQuery(queryBuilder, tableId, query.filterLinkCellCandidate);
×
376
    }
×
377

×
378
    // Add filtering conditions to the query builder
×
379
    this.dbProvider
×
380
      .filterQuery(queryBuilder, fieldMap, filter, { withUserId: currentUserId })
×
381
      .appendQueryBuilder();
×
382

×
383
    // Add sorting rules to the query builder
×
384
    this.dbProvider
×
385
      .sortQuery(queryBuilder, fieldMap, [...(groupBy ?? []), ...orderBy])
×
386
      .appendSortBuilder();
×
387

×
NEW
388
    const basicSortIndex = await this.getBasicOrderIndexField(dbTableName, query.viewId);
×
NEW
389

×
390
    // view sorting added by default
×
NEW
391
    queryBuilder.orderBy(basicSortIndex, 'asc');
×
392

×
393
    this.logger.debug('buildFilterSortQuery: %s', queryBuilder.toQuery());
×
394
    // If you return `queryBuilder` directly and use `await` to receive it,
×
395
    // it will perform a query DB operation, which we obviously don't want to see here
×
396
    return { queryBuilder };
×
397
  }
×
398

150✔
399
  async setRecord(
150✔
400
    version: number,
×
401
    tableId: string,
×
402
    dbTableName: string,
×
403
    recordId: string,
×
404
    contexts: { fieldId: string; newCellValue: unknown }[]
×
405
  ) {
×
406
    const userId = this.cls.get('user.id');
×
407
    const timeStr = this.cls.get('tx.timeStr') ?? new Date().toISOString();
×
408

×
409
    const fieldIds = Array.from(
×
410
      contexts.reduce((acc, cur) => {
×
411
        return acc.add(cur.fieldId);
×
412
      }, new Set<string>())
×
413
    );
×
414

×
415
    const fieldRaws = await this.prismaService.txClient().field.findMany({
×
416
      where: { tableId, id: { in: fieldIds } },
×
417
    });
×
418
    const fieldInstances = fieldRaws.map((field) => createFieldInstanceByRaw(field));
×
419
    const fieldInstanceMap = keyBy(fieldInstances, 'id');
×
420

×
421
    const recordFieldsByDbFieldName = contexts.reduce<{ [dbFieldName: string]: unknown }>(
×
422
      (pre, ctx) => {
×
423
        const fieldInstance = fieldInstanceMap[ctx.fieldId];
×
424
        pre[fieldInstance.dbFieldName] = fieldInstance.convertCellValue2DBValue(ctx.newCellValue);
×
425
        return pre;
×
426
      },
×
427
      {}
×
428
    );
×
429

×
430
    const updateRecordSql = this.knex(dbTableName)
×
431
      .update({
×
432
        ...recordFieldsByDbFieldName,
×
433
        __last_modified_by: userId,
×
434
        __last_modified_time: timeStr,
×
435
        __version: version,
×
436
      })
×
437
      .where({ __id: recordId })
×
438
      .toQuery();
×
439
    return this.prismaService.txClient().$executeRawUnsafe(updateRecordSql);
×
440
  }
×
441

150✔
442
  private convertProjection(fieldKeys?: string[]) {
150✔
443
    return fieldKeys?.reduce<Record<string, boolean>>((acc, cur) => {
×
444
      acc[cur] = true;
×
445
      return acc;
×
446
    }, {});
×
447
  }
×
448

150✔
449
  async getRecords(tableId: string, query: IGetRecordsRo): Promise<IRecordsVo> {
150✔
450
    const queryResult = await this.getDocIdsByQuery(tableId, {
×
451
      viewId: query.viewId,
×
452
      skip: query.skip,
×
453
      take: query.take,
×
454
      filter: query.filter,
×
455
      orderBy: query.orderBy,
×
456
      groupBy: query.groupBy,
×
457
      filterLinkCellCandidate: query.filterLinkCellCandidate,
×
458
      filterLinkCellSelected: query.filterLinkCellSelected,
×
459
    });
×
460

×
461
    const recordSnapshot = await this.getSnapshotBulk(
×
462
      tableId,
×
463
      queryResult.ids,
×
464
      this.convertProjection(query.projection),
×
465
      query.fieldKeyType || FieldKeyType.Name,
×
466
      query.cellFormat
×
467
    );
×
468
    return {
×
469
      records: recordSnapshot.map((r) => r.data),
×
470
    };
×
471
  }
×
472

150✔
473
  async getRecord(tableId: string, recordId: string, query: IGetRecordQuery): Promise<IRecord> {
150✔
474
    const { projection, fieldKeyType = FieldKeyType.Name, cellFormat } = query;
×
475
    const recordSnapshot = await this.getSnapshotBulk(
×
476
      tableId,
×
477
      [recordId],
×
478
      this.convertProjection(projection),
×
479
      fieldKeyType,
×
480
      cellFormat
×
481
    );
×
482

×
483
    if (!recordSnapshot.length) {
×
484
      throw new NotFoundException('Can not get record');
×
485
    }
×
486

×
487
    return recordSnapshot[0].data;
×
488
  }
×
489

150✔
490
  async getCellValue(tableId: string, recordId: string, fieldId: string) {
150✔
491
    const record = await this.getRecord(tableId, recordId, {
×
492
      projection: [fieldId],
×
493
      fieldKeyType: FieldKeyType.Id,
×
494
    });
×
495
    return record.fields[fieldId];
×
496
  }
×
497

150✔
498
  async getMaxRecordOrder(dbTableName: string) {
150✔
499
    const sqlNative = this.knex(dbTableName).max('__auto_number', { as: 'max' }).toSQL().toNative();
×
500

×
501
    const result = await this.prismaService
×
502
      .txClient()
×
503
      .$queryRawUnsafe<{ max?: number }[]>(sqlNative.sql, ...sqlNative.bindings);
×
504

×
505
    return Number(result[0]?.max ?? 0) + 1;
×
506
  }
×
507

150✔
508
  async batchDeleteRecords(tableId: string, recordIds: string[]) {
150✔
509
    const dbTableName = await this.getDbTableName(tableId);
×
510
    // get version by recordIds, __id as id, __version as version
×
511
    const nativeQuery = this.knex(dbTableName)
×
512
      .select('__id as id', '__version as version')
×
513
      .whereIn('__id', recordIds)
×
514
      .toQuery();
×
515
    const recordRaw = await this.prismaService
×
516
      .txClient()
×
517
      .$queryRawUnsafe<{ id: string; version: number }[]>(nativeQuery);
×
518

×
519
    if (recordIds.length !== recordRaw.length) {
×
520
      throw new BadRequestException('delete record not found');
×
521
    }
×
522

×
523
    const recordRawMap = keyBy(recordRaw, 'id');
×
524

×
525
    const dataList = recordIds.map((recordId) => ({
×
526
      docId: recordId,
×
527
      version: recordRawMap[recordId].version,
×
528
    }));
×
529

×
530
    await this.batchService.saveRawOps(tableId, RawOpType.Del, IdPrefix.Record, dataList);
×
531

×
532
    await this.batchDel(tableId, recordIds);
×
533
  }
×
534

150✔
535
  @Timing()
150✔
NEW
536
  async batchCreateRecords(
×
NEW
537
    tableId: string,
×
NEW
538
    records: IRecord[],
×
NEW
539
    orderIndex?: { viewId: string; indexes: number[] }
×
NEW
540
  ) {
×
NEW
541
    const snapshots = await this.createBatch(tableId, records, orderIndex);
×
542

×
543
    const dataList = snapshots.map((snapshot) => ({
×
544
      docId: snapshot.__id,
×
545
      version: 0,
×
546
    }));
×
547

×
548
    await this.batchService.saveRawOps(tableId, RawOpType.Create, IdPrefix.Record, dataList);
×
549
  }
×
550

150✔
551
  async create(tableId: string, snapshot: IRecord) {
150✔
552
    await this.createBatch(tableId, [snapshot]);
×
553
  }
×
554

150✔
555
  async creditCheck(tableId: string) {
150✔
556
    if (!this.thresholdConfig.maxFreeRowLimit) {
×
557
      return;
×
558
    }
×
559

×
560
    const table = await this.prismaService.txClient().tableMeta.findFirstOrThrow({
×
561
      where: { id: tableId, deletedTime: null },
×
562
      select: { dbTableName: true, base: { select: { space: { select: { credit: true } } } } },
×
563
    });
×
564

×
565
    const rowCount = await this.getAllRecordCount(table.dbTableName);
×
566

×
567
    const maxRowCount =
×
568
      table.base.space.credit == null
×
569
        ? this.thresholdConfig.maxFreeRowLimit
×
570
        : table.base.space.credit;
×
571

×
572
    if (rowCount >= maxRowCount) {
×
573
      this.logger.log(`Exceed row count: ${maxRowCount}`, 'creditCheck');
×
574
      throw new BadRequestException(
×
575
        `Exceed max row limit: ${maxRowCount}, please contact us to increase the limit`
×
576
      );
×
577
    }
×
578
  }
×
579

150✔
580
  private async getAllViewIndexesField(dbTableName: string) {
150✔
NEW
581
    const query = this.dbProvider.columnInfo(dbTableName);
×
NEW
582
    const columns = await this.prismaService.txClient().$queryRawUnsafe<{ name: string }[]>(query);
×
NEW
583
    return columns
×
NEW
584
      .filter((column) => column.name.startsWith(ROW_ORDER_FIELD_PREFIX))
×
NEW
585
      .map((column) => column.name)
×
NEW
586
      .reduce<{ [viewId: string]: string }>((acc, cur) => {
×
NEW
587
        const viewId = cur.substring(ROW_ORDER_FIELD_PREFIX.length + 1);
×
NEW
588
        acc[viewId] = cur;
×
NEW
589
        return acc;
×
NEW
590
      }, {});
×
NEW
591
  }
×
592

150✔
593
  private async createBatch(
150✔
NEW
594
    tableId: string,
×
NEW
595
    records: IRecord[],
×
NEW
596
    orderIndex?: { viewId: string; indexes: number[] }
×
NEW
597
  ) {
×
598
    const userId = this.cls.get('user.id');
×
599
    await this.creditCheck(tableId);
×
600
    const dbTableName = await this.getDbTableName(tableId);
×
601

×
602
    const maxRecordOrder = await this.getMaxRecordOrder(dbTableName);
×
603

×
604
    const views = await this.prismaService.txClient().view.findMany({
×
605
      where: { tableId, deletedTime: null },
×
606
      select: { id: true },
×
607
    });
×
608

×
NEW
609
    const allViewIndexes = await this.getAllViewIndexesField(dbTableName);
×
NEW
610

×
611
    const snapshots = records
×
NEW
612
      .map((_, i) =>
×
NEW
613
        views.reduce<{ [viewIndexFieldName: string]: number }>((pre, cur) => {
×
NEW
614
          const viewIndexFieldName = allViewIndexes[cur.id];
×
NEW
615
          if (cur.id === orderIndex?.viewId) {
×
NEW
616
            pre[viewIndexFieldName] = orderIndex.indexes[i];
×
NEW
617
          } else if (viewIndexFieldName) {
×
NEW
618
            pre[viewIndexFieldName] = maxRecordOrder + i;
×
619
          }
×
620
          return pre;
×
621
        }, {})
×
622
      )
×
623
      .map((order, i) => {
×
624
        const snapshot = records[i];
×
625
        return {
×
626
          __id: snapshot.id,
×
627
          __created_by: userId,
×
628
          __version: 1,
×
629
          ...order,
×
630
        };
×
631
      });
×
632

×
633
    const sql = this.dbProvider.batchInsertSql(dbTableName, snapshots);
×
634

×
635
    await this.prismaService.txClient().$executeRawUnsafe(sql);
×
636

×
637
    return snapshots;
×
638
  }
×
639

150✔
640
  private async batchDel(tableId: string, recordIds: string[]) {
150✔
641
    const dbTableName = await this.getDbTableName(tableId);
×
642

×
643
    const nativeQuery = this.knex(dbTableName).whereIn('__id', recordIds).del().toQuery();
×
644

×
645
    await this.prismaService.txClient().$executeRawUnsafe(nativeQuery);
×
646
  }
×
647

150✔
648
  async del(_version: number, tableId: string, recordId: string) {
150✔
649
    await this.batchDel(tableId, [recordId]);
×
650
  }
×
651

150✔
652
  async update(
150✔
653
    version: number,
×
654
    tableId: string,
×
655
    recordId: string,
×
NEW
656
    opContexts: ISetRecordOpContext[]
×
657
  ) {
×
658
    const dbTableName = await this.getDbTableName(tableId);
×
659
    if (opContexts[0].name === OpName.SetRecord) {
×
660
      await this.setRecord(
×
661
        version,
×
662
        tableId,
×
663
        dbTableName,
×
664
        recordId,
×
665
        opContexts as ISetRecordOpContext[]
×
666
      );
×
667
    }
×
668
  }
×
669

150✔
670
  private async getFieldsByProjection(
150✔
671
    tableId: string,
×
672
    projection?: { [fieldNameOrId: string]: boolean },
×
673
    fieldKeyType: FieldKeyType = FieldKeyType.Id
×
674
  ) {
×
675
    const whereParams: Prisma.FieldWhereInput = {};
×
676
    if (projection) {
×
677
      const projectionFieldKeys = Object.entries(projection)
×
678
        .filter(([, v]) => v)
×
679
        .map(([k]) => k);
×
680
      if (projectionFieldKeys.length) {
×
681
        const key = fieldKeyType === FieldKeyType.Id ? 'id' : 'name';
×
682
        whereParams[key] = { in: projectionFieldKeys };
×
683
      }
×
684
    }
×
685

×
686
    const fields = await this.prismaService.txClient().field.findMany({
×
687
      where: { tableId, ...whereParams, deletedTime: null },
×
688
    });
×
689

×
690
    return fields.map((field) => createFieldInstanceByRaw(field));
×
691
  }
×
692

150✔
693
  async projectionFormPermission(
150✔
694
    tableId: string,
×
695
    fieldKeyType: FieldKeyType,
×
696
    projection?: { [fieldNameOrId: string]: boolean }
×
697
  ) {
×
698
    const shareId = this.cls.get('shareViewId');
×
699
    const projectionInner = projection || {};
×
700
    if (shareId) {
×
701
      const rawView = await this.prismaService.txClient().view.findFirst({
×
702
        where: { shareId: shareId, enableShare: true, deletedTime: null },
×
703
        select: { id: true, shareMeta: true, columnMeta: true },
×
704
      });
×
705
      const view = {
×
706
        ...rawView,
×
707
        columnMeta: rawView?.columnMeta ? JSON.parse(rawView.columnMeta) : {},
×
708
      };
×
709
      if (!view) {
×
710
        throw new NotFoundException();
×
711
      }
×
712
      const fieldsPlain = await this.prismaService.txClient().field.findMany({
×
713
        where: { tableId, deletedTime: null },
×
714
        select: {
×
715
          id: true,
×
716
          name: true,
×
717
        },
×
718
      });
×
719

×
720
      const fields = fieldsPlain.map((field) => {
×
721
        return {
×
722
          ...field,
×
723
        };
×
724
      });
×
725

×
726
      if (!(view.shareMeta as IShareViewMeta)?.includeHiddenField) {
×
727
        fields
×
728
          .filter((field) => !view.columnMeta[field.id].hidden)
×
729
          .forEach((field) => (projectionInner[field[fieldKeyType]] = true));
×
730
      }
×
731
    }
×
732
    return Object.keys(projectionInner).length ? projectionInner : undefined;
×
733
  }
×
734

150✔
735
  private async recordsPresignedUrl(
150✔
736
    records: ISnapshotBase<IRecord>[],
×
737
    fields: IFieldInstance[],
×
738
    fieldKeyType: FieldKeyType
×
739
  ) {
×
740
    for (const field of fields) {
×
741
      if (field.type === FieldType.Attachment) {
×
742
        const fieldKey = fieldKeyType === FieldKeyType.Id ? field.id : field.name;
×
743
        for (const record of records) {
×
744
          let cellValue = record.data.fields[fieldKey];
×
745
          if (cellValue == null) {
×
746
            continue;
×
747
          }
×
748
          const attachmentCellValue = cellValue as IAttachmentCellValue;
×
749
          cellValue = await Promise.all(
×
750
            attachmentCellValue.map(async (item) => {
×
751
              const { path, mimetype, token } = item;
×
752
              const presignedUrl = await this.attachmentStorageService.getPreviewUrlByPath(
×
753
                StorageAdapter.getBucket(UploadType.Table),
×
754
                path,
×
755
                token,
×
756
                undefined,
×
757
                {
×
758
                  // eslint-disable-next-line @typescript-eslint/naming-convention
×
759
                  'Content-Type': mimetype,
×
760
                }
×
761
              );
×
762
              return {
×
763
                ...item,
×
764
                presignedUrl,
×
765
              };
×
766
            })
×
767
          );
×
768
          record.data.fields[fieldKey] = cellValue;
×
769
        }
×
770
      }
×
771
    }
×
772
    return records;
×
773
  }
×
774

150✔
775
  async getSnapshotBulk(
150✔
776
    tableId: string,
×
777
    recordIds: string[],
×
778
    projection?: { [fieldNameOrId: string]: boolean },
×
779
    fieldKeyType: FieldKeyType = FieldKeyType.Id, // for convince of collaboration, getSnapshotBulk use id as field key by default.
×
780
    cellFormat = CellFormat.Json
×
781
  ): Promise<ISnapshotBase<IRecord>[]> {
×
782
    const projectionInner = await this.projectionFormPermission(tableId, fieldKeyType, projection);
×
783
    const dbTableName = await this.getDbTableName(tableId);
×
784

×
785
    const fields = await this.getFieldsByProjection(tableId, projectionInner, fieldKeyType);
×
NEW
786
    const fieldNames = fields.map((f) => f.dbFieldName).concat(Array.from(preservedDbFieldNames));
×
787

×
788
    const nativeQuery = this.knex(dbTableName)
×
789
      .select(fieldNames)
×
790
      .whereIn('__id', recordIds)
×
791
      .toQuery();
×
792

×
793
    const result = await this.prismaService
×
794
      .txClient()
×
795
      .$queryRawUnsafe<
×
796
        ({ [fieldName: string]: unknown } & IVisualTableDefaultField)[]
×
797
      >(nativeQuery);
×
798

×
799
    const recordIdsMap = recordIds.reduce(
×
800
      (acc, recordId, currentIndex) => {
×
801
        acc[recordId] = currentIndex;
×
802
        return acc;
×
803
      },
×
804
      {} as { [recordId: string]: number }
×
805
    );
×
806

×
807
    recordIds.forEach((recordId) => {
×
808
      if (!(recordId in recordIdsMap)) {
×
809
        throw new NotFoundException(`Record ${recordId} not found`);
×
810
      }
×
811
    });
×
812

×
813
    const primaryFieldRaw = await this.prismaService.txClient().field.findFirstOrThrow({
×
814
      where: { tableId, isPrimary: true, deletedTime: null },
×
815
    });
×
816

×
817
    const primaryField = createFieldInstanceByRaw(primaryFieldRaw);
×
818

×
819
    const snapshots = result
×
820
      .sort((a, b) => {
×
821
        return recordIdsMap[a.__id] - recordIdsMap[b.__id];
×
822
      })
×
823
      .map((record) => {
×
824
        const recordFields = this.dbRecord2RecordFields(record, fields, fieldKeyType, cellFormat);
×
825
        const name = recordFields[primaryField[fieldKeyType]];
×
826
        return {
×
827
          id: record.__id,
×
828
          v: record.__version,
×
829
          type: 'json0',
×
830
          data: {
×
831
            fields: recordFields,
×
832
            name:
×
833
              cellFormat === CellFormat.Text
×
834
                ? (name as string)
×
835
                : primaryField.cellValue2String(name),
×
836
            id: record.__id,
×
837
            autoNumber: record.__auto_number,
×
838
            createdTime: record.__created_time?.toISOString(),
×
839
            lastModifiedTime: record.__last_modified_time?.toISOString(),
×
840
            createdBy: record.__created_by,
×
841
            lastModifiedBy: record.__last_modified_by || undefined,
×
842
          },
×
843
        };
×
844
      });
×
845
    if (cellFormat === CellFormat.Json) {
×
846
      return await this.recordsPresignedUrl(snapshots, fields, fieldKeyType);
×
847
    }
×
848
    return snapshots;
×
849
  }
×
850

150✔
851
  async shareWithViewId(tableId: string, viewId?: string) {
150✔
852
    const shareId = this.cls.get('shareViewId');
×
853
    if (!shareId) {
×
854
      return viewId;
×
855
    }
×
856
    const view = await this.prismaService.txClient().view.findFirst({
×
857
      select: { id: true },
×
858
      where: {
×
859
        tableId,
×
860
        shareId,
×
861
        ...(viewId ? { id: viewId } : {}),
×
862
        enableShare: true,
×
863
        deletedTime: null,
×
864
      },
×
865
    });
×
866
    if (!view) {
×
867
      throw new BadRequestException('error shareId');
×
868
    }
×
869
    return view.id;
×
870
  }
×
871

150✔
872
  async getDocIdsByQuery(
150✔
873
    tableId: string,
×
874
    query: IGetRecordsRo
×
875
  ): Promise<{ ids: string[]; extra?: IExtraResult }> {
×
876
    const viewId = await this.shareWithViewId(tableId, query.viewId);
×
877

×
878
    const { skip, take = 100 } = query;
×
879
    if (identify(tableId) !== IdPrefix.Table) {
×
880
      throw new InternalServerErrorException('query collection must be table id');
×
881
    }
×
882

×
883
    if (take > 1000) {
×
884
      throw new BadRequestException(`limit can't be greater than ${take}`);
×
885
    }
×
886

×
887
    if (query.filterLinkCellSelected) {
×
888
      return this.getLinkSelectedRecordIds(query.filterLinkCellSelected);
×
889
    }
×
890

×
891
    const { queryBuilder } = await this.buildFilterSortQuery(tableId, {
×
892
      ...query,
×
893
      viewId,
×
894
    });
×
895

×
896
    queryBuilder.select('__id');
×
897

×
898
    queryBuilder.offset(skip);
×
899
    if (take !== -1) {
×
900
      queryBuilder.limit(take);
×
901
    }
×
902

×
903
    const result = await this.prismaService
×
904
      .txClient()
×
905
      .$queryRawUnsafe<{ __id: string }[]>(queryBuilder.toQuery());
×
906
    const ids = result.map((r) => r.__id);
×
907
    return { ids };
×
908
  }
×
909

150✔
910
  async getRecordsFields(
150✔
911
    tableId: string,
×
912
    query: IGetRecordsRo
×
913
  ): Promise<Pick<IRecord, 'id' | 'fields'>[]> {
×
914
    if (identify(tableId) !== IdPrefix.Table) {
×
915
      throw new InternalServerErrorException('query collection must be table id');
×
916
    }
×
917

×
918
    const {
×
919
      skip,
×
920
      take,
×
921
      filter,
×
922
      orderBy,
×
923
      groupBy,
×
924
      fieldKeyType,
×
925
      cellFormat,
×
926
      projection,
×
927
      viewId,
×
928
      filterLinkCellCandidate,
×
929
    } = query;
×
930

×
931
    const fields = await this.getFieldsByProjection(
×
932
      tableId,
×
933
      this.convertProjection(projection),
×
934
      fieldKeyType
×
935
    );
×
936
    const fieldNames = fields.map((f) => f.dbFieldName);
×
937

×
938
    const { queryBuilder } = await this.buildFilterSortQuery(tableId, {
×
939
      viewId,
×
940
      filterLinkCellCandidate,
×
941
      filter,
×
942
      orderBy,
×
943
      groupBy,
×
944
    });
×
945
    queryBuilder.select(fieldNames.concat('__id'));
×
946
    queryBuilder.offset(skip);
×
947
    if (take !== -1) {
×
948
      queryBuilder.limit(take);
×
949
    }
×
950

×
951
    const result = await this.prismaService
×
952
      .txClient()
×
953
      .$queryRawUnsafe<
×
954
        (Pick<IRecord, 'fields'> & Pick<IVisualTableDefaultField, '__id'>)[]
×
955
      >(queryBuilder.toQuery());
×
956

×
957
    return result.map((record) => {
×
958
      return {
×
959
        id: record.__id,
×
960
        fields: this.dbRecord2RecordFields(record, fields, fieldKeyType, cellFormat),
×
961
      };
×
962
    });
×
963
  }
×
964

150✔
965
  async getRecordsWithPrimary(tableId: string, titles: string[]) {
150✔
966
    const dbTableName = await this.getDbTableName(tableId);
×
967
    const field = await this.prismaService.txClient().field.findFirst({
×
968
      where: { tableId, isPrimary: true, deletedTime: null },
×
969
    });
×
970
    if (!field) {
×
971
      throw new BadRequestException(`Could not find primary index ${tableId}`);
×
972
    }
×
973

×
974
    const queryBuilder = this.knex(dbTableName)
×
975
      .select({ title: field.dbFieldName, id: '__id' })
×
976
      .whereIn(field.dbFieldName, titles);
×
977

×
978
    const querySql = queryBuilder.toQuery();
×
979

×
980
    return this.prismaService.txClient().$queryRawUnsafe<{ id: string; title: string }[]>(querySql);
×
981
  }
×
982
}
150✔
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