• 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

18.15
/apps/nestjs-backend/src/features/field/field.service.ts
1
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
1✔
2
import type {
1✔
3
  IFieldVo,
1✔
4
  IGetFieldsQuery,
1✔
5
  ISnapshotBase,
1✔
6
  ISetFieldPropertyOpContext,
1✔
7
  DbFieldType,
1✔
8
  ILookupOptionsVo,
1✔
9
  IOtOperation,
1✔
10
} from '@teable/core';
1✔
11
import { FieldOpBuilder, IdPrefix, OpName } from '@teable/core';
1✔
12
import type { Field as RawField, Prisma } from '@teable/db-main-prisma';
1✔
13
import { PrismaService } from '@teable/db-main-prisma';
1✔
14
import { instanceToPlain } from 'class-transformer';
1✔
15
import { Knex } from 'knex';
1✔
16
import { keyBy, sortBy } from 'lodash';
1✔
17
import { InjectModel } from 'nest-knexjs';
1✔
18
import { ClsService } from 'nestjs-cls';
1✔
19
import { InjectDbProvider } from '../../db-provider/db.provider';
1✔
20
import { IDbProvider } from '../../db-provider/db.provider.interface';
1✔
21
import type { IReadonlyAdapterService } from '../../share-db/interface';
1✔
22
import { RawOpType } from '../../share-db/interface';
1✔
23
import type { IClsStore } from '../../types/cls';
1✔
24
import { convertNameToValidCharacter } from '../../utils/name-conversion';
1✔
25
import { BatchService } from '../calculation/batch.service';
1✔
26
import { createViewVoByRaw } from '../view/model/factory';
1✔
27
import type { IFieldInstance } from './model/factory';
1✔
28
import { createFieldInstanceByVo, rawField2FieldObj } from './model/factory';
1✔
29
import { dbType2knexFormat } from './util';
1✔
30

1✔
31
type IOpContext = ISetFieldPropertyOpContext;
1✔
32

1✔
33
@Injectable()
1✔
34
export class FieldService implements IReadonlyAdapterService {
1✔
35
  private logger = new Logger(FieldService.name);
130✔
36

130✔
37
  constructor(
130✔
38
    private readonly batchService: BatchService,
130✔
39
    private readonly prismaService: PrismaService,
130✔
40
    private readonly cls: ClsService<IClsStore>,
130✔
41
    @InjectDbProvider() private readonly dbProvider: IDbProvider,
130✔
42
    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex
130✔
43
  ) {}
130✔
44

130✔
45
  async generateDbFieldName(tableId: string, name: string): Promise<string> {
130✔
46
    let dbFieldName = convertNameToValidCharacter(name, 40);
×
47

×
48
    const query = this.dbProvider.columnInfo(await this.getDbTableName(tableId));
×
49
    const columns = await this.prismaService.txClient().$queryRawUnsafe<{ name: string }[]>(query);
×
50
    // fallback logic
×
51
    if (columns.some((column) => column.name === dbFieldName)) {
×
52
      dbFieldName += new Date().getTime();
×
53
    }
×
54
    return dbFieldName;
×
55
  }
×
56

130✔
57
  private async dbCreateField(tableId: string, fieldInstance: IFieldInstance) {
130✔
58
    const userId = this.cls.get('user.id');
×
59
    const {
×
60
      id,
×
61
      name,
×
62
      dbFieldName,
×
63
      description,
×
64
      type,
×
65
      options,
×
66
      lookupOptions,
×
67
      notNull,
×
68
      unique,
×
69
      isPrimary,
×
70
      isComputed,
×
71
      hasError,
×
72
      dbFieldType,
×
73
      cellValueType,
×
74
      isMultipleCellValue,
×
75
      isLookup,
×
76
    } = fieldInstance;
×
77

×
78
    const data: Prisma.FieldCreateInput = {
×
79
      id,
×
80
      table: {
×
81
        connect: {
×
82
          id: tableId,
×
83
        },
×
84
      },
×
85
      name,
×
86
      description,
×
87
      type,
×
88
      options: JSON.stringify(options),
×
89
      notNull,
×
90
      unique,
×
91
      isPrimary,
×
92
      version: 1,
×
93
      isComputed,
×
94
      isLookup,
×
95
      hasError,
×
96
      // add lookupLinkedFieldId for indexing
×
97
      lookupLinkedFieldId: lookupOptions?.linkFieldId,
×
98
      lookupOptions: lookupOptions && JSON.stringify(lookupOptions),
×
99
      dbFieldName,
×
100
      dbFieldType,
×
101
      cellValueType,
×
102
      isMultipleCellValue,
×
103
      createdBy: userId,
×
104
    };
×
105

×
106
    return this.prismaService.txClient().field.create({ data });
×
107
  }
×
108

130✔
109
  async dbCreateMultipleField(tableId: string, fieldInstances: IFieldInstance[]) {
130✔
110
    const multiFieldData: RawField[] = [];
×
111

×
112
    for (let i = 0; i < fieldInstances.length; i++) {
×
113
      const fieldInstance = fieldInstances[i];
×
114
      const fieldData = await this.dbCreateField(tableId, fieldInstance);
×
115

×
116
      multiFieldData.push(fieldData);
×
117
    }
×
118
    return multiFieldData;
×
119
  }
×
120

130✔
121
  private async alterTableAddField(
130✔
122
    dbTableName: string,
×
123
    fieldInstances: { dbFieldType: DbFieldType; dbFieldName: string }[]
×
124
  ) {
×
125
    for (let i = 0; i < fieldInstances.length; i++) {
×
126
      const field = fieldInstances[i];
×
127

×
128
      const alterTableQuery = this.knex.schema
×
129
        .alterTable(dbTableName, (table) => {
×
130
          const typeKey = dbType2knexFormat(this.knex, field.dbFieldType);
×
131
          table[typeKey](field.dbFieldName);
×
132
        })
×
133
        .toQuery();
×
134
      await this.prismaService.txClient().$executeRawUnsafe(alterTableQuery);
×
135
    }
×
136
  }
×
137

130✔
138
  async alterTableDeleteField(dbTableName: string, dbFieldNames: string[]) {
130✔
139
    for (const dbFieldName of dbFieldNames) {
×
140
      const alterTableSql = this.dbProvider.dropColumn(dbTableName, dbFieldName);
×
141

×
142
      for (const alterTableQuery of alterTableSql) {
×
143
        await this.prismaService.txClient().$executeRawUnsafe(alterTableQuery);
×
144
      }
×
145
    }
×
146
  }
×
147

130✔
148
  private async alterTableModifyFieldName(fieldId: string, newDbFieldName: string) {
130✔
149
    const { dbFieldName, table } = await this.prismaService.txClient().field.findFirstOrThrow({
×
150
      where: { id: fieldId, deletedTime: null },
×
151
      select: { dbFieldName: true, table: { select: { id: true, dbTableName: true } } },
×
152
    });
×
153

×
154
    const existingField = await this.prismaService.txClient().field.findFirst({
×
155
      where: { tableId: table.id, dbFieldName: newDbFieldName, deletedTime: null },
×
156
      select: { id: true },
×
157
    });
×
158

×
159
    if (existingField) {
×
160
      throw new BadRequestException(`Db Field name ${newDbFieldName} already exists in this table`);
×
161
    }
×
162

×
NEW
163
    const alterTableSql = this.dbProvider.renameColumn(
×
164
      table.dbTableName,
×
165
      dbFieldName,
×
166
      newDbFieldName
×
167
    );
×
168

×
169
    for (const alterTableQuery of alterTableSql) {
×
170
      await this.prismaService.txClient().$executeRawUnsafe(alterTableQuery);
×
171
    }
×
172
  }
×
173

130✔
174
  private async alterTableModifyFieldType(fieldId: string, newDbFieldType: DbFieldType) {
130✔
175
    const { dbFieldName, table } = await this.prismaService.txClient().field.findFirstOrThrow({
×
176
      where: { id: fieldId, deletedTime: null },
×
177
      select: { dbFieldName: true, table: { select: { dbTableName: true } } },
×
178
    });
×
179

×
180
    const dbTableName = table.dbTableName;
×
181
    const schemaType = dbType2knexFormat(this.knex, newDbFieldType);
×
182

×
183
    const resetFieldQuery = this.knex(dbTableName)
×
184
      .update({ [dbFieldName]: null })
×
185
      .toQuery();
×
186
    await this.prismaService.txClient().$executeRawUnsafe(resetFieldQuery);
×
187

×
188
    const modifyColumnSql = this.dbProvider.modifyColumnSchema(
×
189
      dbTableName,
×
190
      dbFieldName,
×
191
      schemaType
×
192
    );
×
193

×
194
    for (const alterTableQuery of modifyColumnSql) {
×
195
      await this.prismaService.txClient().$executeRawUnsafe(alterTableQuery);
×
196
    }
×
197
  }
×
198

130✔
199
  async getField(tableId: string, fieldId: string): Promise<IFieldVo> {
130✔
200
    const field = await this.prismaService.txClient().field.findFirst({
×
201
      where: { id: fieldId, tableId, deletedTime: null },
×
202
    });
×
203
    if (!field) {
×
204
      throw new NotFoundException(`field ${fieldId} in table ${tableId} not found`);
×
205
    }
×
206
    return rawField2FieldObj(field);
×
207
  }
×
208

130✔
209
  async getFieldsByQuery(tableId: string, query?: IGetFieldsQuery) {
130✔
210
    const fieldsPlain = await this.prismaService.txClient().field.findMany({
×
211
      where: { tableId, deletedTime: null },
×
212
      orderBy: [
×
213
        {
×
214
          isPrimary: {
×
215
            sort: 'asc',
×
216
            nulls: 'last',
×
217
          },
×
218
        },
×
219
        {
×
220
          createdTime: 'asc',
×
221
        },
×
222
      ],
×
223
    });
×
224

×
225
    let result = fieldsPlain.map(rawField2FieldObj);
×
226

×
227
    /**
×
228
     * filter by query
×
229
     * filterHidden depends on viewId so only judge viewId
×
230
     */
×
231
    if (query?.viewId) {
×
232
      const { viewId } = query;
×
233
      const curView = await this.prismaService.txClient().view.findFirst({
×
234
        where: { id: viewId, deletedTime: null },
×
235
        select: { id: true, columnMeta: true },
×
236
      });
×
237
      if (!curView) {
×
238
        throw new NotFoundException('view is not found');
×
239
      }
×
240
      const view = {
×
241
        id: viewId,
×
242
        columnMeta: JSON.parse(curView.columnMeta),
×
243
      };
×
244
      if (query?.filterHidden) {
×
245
        result = result.filter((field) => !view?.columnMeta[field.id].hidden);
×
246
      }
×
247
      result = sortBy(result, (field) => {
×
248
        return view?.columnMeta[field.id].order;
×
249
      });
×
250
    }
×
251

×
252
    return result;
×
253
  }
×
254

130✔
255
  async getFieldInstances(tableId: string, query: IGetFieldsQuery): Promise<IFieldInstance[]> {
130✔
256
    const fields = await this.getFieldsByQuery(tableId, query);
×
257
    return fields.map((field) => createFieldInstanceByVo(field));
×
258
  }
×
259

130✔
260
  async getDbTableName(tableId: string) {
130✔
261
    const tableMeta = await this.prismaService.txClient().tableMeta.findUniqueOrThrow({
×
262
      where: { id: tableId },
×
263
      select: { dbTableName: true },
×
264
    });
×
265
    return tableMeta.dbTableName;
×
266
  }
×
267

130✔
268
  async resolvePending(tableId: string, fieldIds: string[]) {
130✔
269
    await this.batchUpdateFields(
×
270
      tableId,
×
271
      fieldIds.map((fieldId) => ({
×
272
        fieldId,
×
273
        ops: [
×
274
          FieldOpBuilder.editor.setFieldProperty.build({
×
275
            key: 'isPending',
×
276
            newValue: null,
×
277
            oldValue: true,
×
278
          }),
×
279
        ],
×
280
      }))
×
281
    );
×
282
  }
×
283

130✔
284
  async batchUpdateFields(tableId: string, opData: { fieldId: string; ops: IOtOperation[] }[]) {
130✔
285
    if (!opData.length) return;
×
286

×
287
    const fieldRaw = await this.prismaService.txClient().field.findMany({
×
288
      where: { tableId, id: { in: opData.map((data) => data.fieldId) }, deletedTime: null },
×
289
      select: { id: true, version: true },
×
290
    });
×
291

×
292
    const fieldMap = keyBy(fieldRaw, 'id');
×
293

×
294
    // console.log('opData', JSON.stringify(opData, null, 2));
×
295
    for (const { fieldId, ops } of opData) {
×
296
      const opContext = ops.map((op) => {
×
297
        const ctx = FieldOpBuilder.detect(op);
×
298
        if (!ctx) {
×
299
          throw new Error('unknown field editing op');
×
300
        }
×
301
        return ctx as IOpContext;
×
302
      });
×
303

×
304
      await this.update(fieldMap[fieldId].version + 1, tableId, fieldId, opContext);
×
305
    }
×
306

×
307
    const dataList = opData.map((data) => ({
×
308
      docId: data.fieldId,
×
309
      version: fieldMap[data.fieldId].version,
×
310
      data: data.ops,
×
311
    }));
×
312

×
313
    await this.batchService.saveRawOps(tableId, RawOpType.Edit, IdPrefix.Field, dataList);
×
314
  }
×
315

130✔
316
  async batchDeleteFields(tableId: string, fieldIds: string[]) {
130✔
317
    if (!fieldIds.length) return;
×
318

×
319
    const fieldRaw = await this.prismaService.txClient().field.findMany({
×
320
      where: { tableId, id: { in: fieldIds }, deletedTime: null },
×
321
      select: { id: true, version: true },
×
322
    });
×
323

×
324
    if (fieldRaw.length !== fieldIds.length) {
×
325
      throw new BadRequestException('delete field not found');
×
326
    }
×
327

×
328
    const fieldRawMap = keyBy(fieldRaw, 'id');
×
329

×
330
    const dataList = fieldIds.map((fieldId) => ({
×
331
      docId: fieldId,
×
332
      version: fieldRawMap[fieldId].version,
×
333
    }));
×
334

×
335
    await this.batchService.saveRawOps(tableId, RawOpType.Del, IdPrefix.Field, dataList);
×
336

×
337
    await this.deleteMany(
×
338
      tableId,
×
339
      dataList.map((d) => ({ ...d, version: d.version + 1 }))
×
340
    );
×
341
  }
×
342

130✔
343
  async batchCreateFields(tableId: string, dbTableName: string, fields: IFieldInstance[]) {
130✔
344
    if (!fields.length) return;
×
345

×
346
    const dataList = fields.map((field) => {
×
347
      const snapshot = instanceToPlain(field, { excludePrefixes: ['_'] }) as IFieldVo;
×
348
      return {
×
349
        docId: field.id,
×
350
        version: 0,
×
351
        data: snapshot,
×
352
      };
×
353
    });
×
354

×
355
    // 1. save field meta in db
×
356
    await this.dbCreateMultipleField(tableId, fields);
×
357

×
358
    // 2. alter table with real field in visual table
×
359
    await this.alterTableAddField(dbTableName, fields);
×
360

×
361
    await this.batchService.saveRawOps(tableId, RawOpType.Create, IdPrefix.Field, dataList);
×
362
  }
×
363

130✔
364
  async create(tableId: string, snapshot: IFieldVo) {
130✔
365
    const fieldInstance = createFieldInstanceByVo(snapshot);
×
366
    const dbTableName = await this.getDbTableName(tableId);
×
367

×
368
    // 1. save field meta in db
×
369
    await this.dbCreateMultipleField(tableId, [fieldInstance]);
×
370

×
371
    // 2. alter table with real field in visual table
×
372
    await this.alterTableAddField(dbTableName, [fieldInstance]);
×
373
  }
×
374

130✔
375
  private async deleteMany(tableId: string, fieldData: { docId: string; version: number }[]) {
130✔
376
    const userId = this.cls.get('user.id');
×
377

×
378
    for (const data of fieldData) {
×
379
      const { docId: id, version } = data;
×
380
      await this.prismaService.txClient().field.update({
×
381
        where: { id: id },
×
382
        data: { deletedTime: new Date(), lastModifiedBy: userId, version },
×
383
      });
×
384
    }
×
385
    const dbTableName = await this.getDbTableName(tableId);
×
386
    const fieldIds = fieldData.map((data) => data.docId);
×
387
    const fieldsRaw = await this.prismaService.txClient().field.findMany({
×
388
      where: { id: { in: fieldIds } },
×
389
      select: { dbFieldName: true },
×
390
    });
×
391
    await this.alterTableDeleteField(
×
392
      dbTableName,
×
393
      fieldsRaw.map((field) => field.dbFieldName)
×
394
    );
×
395
  }
×
396

130✔
397
  async del(version: number, tableId: string, fieldId: string) {
130✔
398
    await this.deleteMany(tableId, [{ docId: fieldId, version }]);
×
399
  }
×
400

130✔
401
  private async handleFieldProperty(fieldId: string, opContext: IOpContext) {
130✔
402
    const { key, newValue } = opContext as ISetFieldPropertyOpContext;
×
403
    if (key === 'options') {
×
404
      if (!newValue) {
×
405
        throw new Error('field options is required');
×
406
      }
×
407
      return { options: JSON.stringify(newValue) };
×
408
    }
×
409

×
410
    if (key === 'lookupOptions') {
×
411
      return {
×
412
        lookupOptions: newValue ? JSON.stringify(newValue) : null,
×
413
        // update lookupLinkedFieldId for indexing
×
414
        lookupLinkedFieldId: (newValue as ILookupOptionsVo | null)?.linkFieldId || null,
×
415
      };
×
416
    }
×
417

×
418
    if (key === 'dbFieldType') {
×
419
      await this.alterTableModifyFieldType(fieldId, newValue as DbFieldType);
×
420
    }
×
421

×
422
    if (key === 'dbFieldName') {
×
423
      await this.alterTableModifyFieldName(fieldId, newValue as string);
×
424
    }
×
425

×
426
    return { [key]: newValue ?? null };
×
427
  }
×
428

130✔
429
  private async updateStrategies(fieldId: string, opContext: IOpContext) {
130✔
430
    const opHandlers = {
×
431
      [OpName.SetFieldProperty]: this.handleFieldProperty.bind(this),
×
432
    };
×
433

×
434
    const handler = opHandlers[opContext.name];
×
435

×
436
    if (!handler) {
×
437
      throw new Error(`Unknown context ${opContext.name} for field update`);
×
438
    }
×
439

×
440
    return handler.constructor.name === 'AsyncFunction'
×
441
      ? await handler(fieldId, opContext)
×
442
      : handler(fieldId, opContext);
×
443
  }
×
444

130✔
445
  async update(version: number, tableId: string, fieldId: string, opContexts: IOpContext[]) {
130✔
446
    const userId = this.cls.get('user.id');
×
447
    const result: Prisma.FieldUpdateInput = { version, lastModifiedBy: userId };
×
448
    for (const opContext of opContexts) {
×
449
      const updatedResult = await this.updateStrategies(fieldId, opContext);
×
450
      Object.assign(result, updatedResult);
×
451
    }
×
452

×
453
    await this.prismaService.txClient().field.update({
×
454
      where: { id: fieldId, tableId },
×
455
      data: result,
×
456
    });
×
457
  }
×
458

130✔
459
  async getSnapshotBulk(tableId: string, ids: string[]): Promise<ISnapshotBase<IFieldVo>[]> {
130✔
460
    const fieldRaws = await this.prismaService.txClient().field.findMany({
×
461
      where: { tableId, id: { in: ids } },
×
462
    });
×
463
    const fields = fieldRaws.map((field) => rawField2FieldObj(field));
×
464

×
465
    return fieldRaws
×
466
      .map((fieldRaw, i) => {
×
467
        return {
×
468
          id: fieldRaw.id,
×
469
          v: fieldRaw.version,
×
470
          type: 'json0',
×
471
          data: fields[i],
×
472
        };
×
473
      })
×
474
      .sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id));
×
475
  }
×
476

130✔
477
  async viewQueryWidthShare(tableId: string, query: IGetFieldsQuery): Promise<IGetFieldsQuery> {
130✔
478
    const shareId = this.cls.get('shareViewId');
×
479
    if (!shareId) {
×
480
      return query;
×
481
    }
×
482
    const { viewId } = query;
×
483
    const view = await this.prismaService.txClient().view.findFirst({
×
484
      where: {
×
485
        tableId,
×
486
        shareId,
×
487
        ...(viewId ? { id: viewId } : {}),
×
488
        enableShare: true,
×
489
        deletedTime: null,
×
490
      },
×
491
    });
×
492
    if (!view) {
×
493
      throw new BadRequestException('error shareId');
×
494
    }
×
495
    const filterHidden = !createViewVoByRaw(view).shareMeta?.includeHiddenField;
×
496
    return { viewId: view.id, filterHidden };
×
497
  }
×
498

130✔
499
  async getDocIdsByQuery(tableId: string, query: IGetFieldsQuery) {
130✔
500
    const { viewId, filterHidden } = await this.viewQueryWidthShare(tableId, query);
×
501
    const result = await this.getFieldsByQuery(tableId, { viewId, filterHidden });
×
502

×
503
    return {
×
504
      ids: result.map((field) => field.id),
×
505
    };
×
506
  }
×
507
}
130✔
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