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

teableio / teable / 8389227144

22 Mar 2024 10:56AM UTC coverage: 26.087% (-53.9%) from 79.937%
8389227144

push

github

web-flow
refactor: move zod schema to openapi (#487)

2100 of 3363 branches covered (62.44%)

282 of 757 new or added lines in 74 files covered. (37.25%)

14879 existing lines in 182 files now uncovered.

25574 of 98035 relevant lines covered (26.09%)

5.17 hits per line

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

0.0
/apps/nestjs-backend/src/features/import/open-api/import.class.ts
UNCOV
1
import { BadRequestException } from '@nestjs/common';
×
UNCOV
2
import type { IValidateTypes, IAnalyzeVo } from '@teable/core';
×
UNCOV
3
import { getUniqName, FieldType, SUPPORTEDTYPE, importTypeMap } from '@teable/core';
×
UNCOV
4
import { zip, toString, intersection } from 'lodash';
×
UNCOV
5
import fetch from 'node-fetch';
×
UNCOV
6
import Papa from 'papaparse';
×
UNCOV
7
import * as XLSX from 'xlsx';
×
UNCOV
8
import type { ZodType } from 'zod';
×
UNCOV
9
import z from 'zod';
×
UNCOV
10

×
UNCOV
11
const validateZodSchemaMap: Record<IValidateTypes, ZodType> = {
×
UNCOV
12
  [FieldType.Checkbox]: z.union([z.string(), z.boolean()]).refine((value: unknown) => {
×
UNCOV
13
    if (typeof value === 'boolean') {
×
UNCOV
14
      return true;
×
UNCOV
15
    }
×
UNCOV
16
    if (
×
UNCOV
17
      typeof value === 'string' &&
×
UNCOV
18
      (value.toLowerCase() === 'false' || value.toLowerCase() === 'true')
×
UNCOV
19
    ) {
×
UNCOV
20
      return true;
×
UNCOV
21
    }
×
UNCOV
22
    return false;
×
UNCOV
23
  }),
×
UNCOV
24
  [FieldType.Date]: z.coerce.date(),
×
UNCOV
25
  [FieldType.Number]: z.coerce.number(),
×
UNCOV
26
  [FieldType.LongText]: z
×
UNCOV
27
    .string()
×
UNCOV
28
    .refine((value) => z.string().safeParse(value) && /\n/.test(value)),
×
UNCOV
29
  [FieldType.SingleLineText]: z.string(),
×
UNCOV
30
};
×
UNCOV
31

×
UNCOV
32
interface IImportConstructorParams {
×
UNCOV
33
  url: string;
×
UNCOV
34
  type: SUPPORTEDTYPE;
×
UNCOV
35
}
×
UNCOV
36

×
UNCOV
37
interface IParseResult {
×
UNCOV
38
  [x: string]: unknown[][];
×
UNCOV
39
}
×
UNCOV
40

×
UNCOV
41
export abstract class Importer {
×
UNCOV
42
  public static CHUNK_SIZE = 1024 * 1024 * 1;
×
UNCOV
43

×
UNCOV
44
  public static DEFAULT_COLUMN_TYPE: IValidateTypes = FieldType.SingleLineText;
×
UNCOV
45

×
UNCOV
46
  constructor(public config: IImportConstructorParams) {}
×
UNCOV
47

×
UNCOV
48
  abstract parse(
×
UNCOV
49
    ...args: [options?: unknown, cb?: (chunk: Record<string, unknown[][]>) => Promise<void>]
×
UNCOV
50
  ): Promise<IParseResult>;
×
UNCOV
51

×
UNCOV
52
  abstract getSupportedFieldTypes(): IValidateTypes[];
×
UNCOV
53

×
UNCOV
54
  async getFile() {
×
UNCOV
55
    const { url, type } = this.config;
×
UNCOV
56
    const { body: stream, headers } = await fetch(url);
×
UNCOV
57

×
UNCOV
58
    const supportType = importTypeMap[type].accept.split(',');
×
UNCOV
59

×
UNCOV
60
    const fileFormat = headers
×
UNCOV
61
      .get('content-type')
×
UNCOV
62
      ?.split(';')
×
UNCOV
63
      ?.map((item: string) => item.trim());
×
UNCOV
64

×
UNCOV
65
    // if (!fileFormat?.length) {
×
UNCOV
66
    //   throw new BadRequestException(
×
UNCOV
67
    //     `Input url is not a standard document service without right content-type`
×
UNCOV
68
    //   );
×
UNCOV
69
    // }
×
UNCOV
70

×
UNCOV
71
    if (fileFormat?.length && !intersection(fileFormat, supportType).length) {
×
UNCOV
72
      throw new BadRequestException(
×
UNCOV
73
        `File format is not supported, only ${supportType.join(',')} are supported,`
×
UNCOV
74
      );
×
UNCOV
75
    }
×
UNCOV
76

×
UNCOV
77
    return stream;
×
UNCOV
78
  }
×
UNCOV
79

×
UNCOV
80
  async genColumns() {
×
UNCOV
81
    const supportTypes = this.getSupportedFieldTypes();
×
UNCOV
82
    const parseResult = await this.parse();
×
UNCOV
83
    const result: IAnalyzeVo['worksheets'] = {};
×
UNCOV
84

×
UNCOV
85
    for (const [sheetName, cols] of Object.entries(parseResult)) {
×
UNCOV
86
      const zipColumnInfo = zip(...cols);
×
UNCOV
87
      const existNames: string[] = [];
×
UNCOV
88
      const calculatedColumnHeaders = zipColumnInfo.map((column, index) => {
×
UNCOV
89
        let isColumnEmpty = true;
×
UNCOV
90
        let validatingFieldTypes = [...supportTypes];
×
UNCOV
91
        for (let i = 0; i < column.length; i++) {
×
UNCOV
92
          if (validatingFieldTypes.length <= 1) {
×
UNCOV
93
            break;
×
UNCOV
94
          }
×
UNCOV
95

×
UNCOV
96
          // ignore empty value and first row causing first row as header
×
UNCOV
97
          if (column[i] === '' || column[i] == null || i === 0) {
×
UNCOV
98
            continue;
×
UNCOV
99
          }
×
UNCOV
100

×
UNCOV
101
          // when the whole columns aren't empty should flag
×
UNCOV
102
          isColumnEmpty = false;
×
UNCOV
103

×
UNCOV
104
          // when one of column's value validates long text, then break;
×
UNCOV
105
          if (validateZodSchemaMap[FieldType.LongText].safeParse(column[i]).success) {
×
UNCOV
106
            validatingFieldTypes = [FieldType.LongText];
×
UNCOV
107
            break;
×
UNCOV
108
          }
×
UNCOV
109

×
UNCOV
110
          const matchTypes = validatingFieldTypes.filter((type) => {
×
UNCOV
111
            const schema = validateZodSchemaMap[type];
×
UNCOV
112
            return schema.safeParse(column[i]).success;
×
UNCOV
113
          });
×
UNCOV
114

×
UNCOV
115
          validatingFieldTypes = matchTypes;
×
UNCOV
116
        }
×
UNCOV
117

×
UNCOV
118
        // empty columns should be default type
×
UNCOV
119
        validatingFieldTypes = !isColumnEmpty
×
UNCOV
120
          ? validatingFieldTypes
×
UNCOV
121
          : [Importer.DEFAULT_COLUMN_TYPE];
×
UNCOV
122

×
UNCOV
123
        const name = getUniqName(toString(column?.[0]) ?? `Field ${index}`, existNames);
×
UNCOV
124

×
UNCOV
125
        existNames.push(name);
×
UNCOV
126

×
UNCOV
127
        return {
×
UNCOV
128
          type: validatingFieldTypes[0] || Importer.DEFAULT_COLUMN_TYPE,
×
UNCOV
129
          name: name.toString(),
×
UNCOV
130
        };
×
UNCOV
131
      });
×
UNCOV
132

×
UNCOV
133
      result[sheetName] = {
×
UNCOV
134
        name: sheetName,
×
UNCOV
135
        columns: calculatedColumnHeaders,
×
UNCOV
136
      };
×
UNCOV
137
    }
×
UNCOV
138

×
UNCOV
139
    return {
×
UNCOV
140
      worksheets: result,
×
UNCOV
141
    };
×
UNCOV
142
  }
×
UNCOV
143
}
×
UNCOV
144

×
UNCOV
145
export class CsvImporter extends Importer {
×
UNCOV
146
  public static readonly CHECK_LINES = 5000;
×
UNCOV
147
  public static readonly DEFAULT_SHEETKEY = 'Import Table';
×
UNCOV
148
  // order make sence
×
UNCOV
149
  public static readonly SUPPORTEDTYPE: IValidateTypes[] = [
×
UNCOV
150
    FieldType.Checkbox,
×
UNCOV
151
    FieldType.Number,
×
UNCOV
152
    FieldType.Date,
×
UNCOV
153
    FieldType.LongText,
×
UNCOV
154
    FieldType.SingleLineText,
×
UNCOV
155
  ];
×
UNCOV
156
  getSupportedFieldTypes() {
×
UNCOV
157
    return CsvImporter.SUPPORTEDTYPE;
×
UNCOV
158
  }
×
UNCOV
159

×
UNCOV
160
  parse(): Promise<IParseResult>;
×
UNCOV
161
  parse(
×
UNCOV
162
    options: Papa.ParseConfig & { skipFirstNLines: number; key: string },
×
UNCOV
163
    cb: (chunk: Record<string, unknown[][]>) => Promise<void>
×
UNCOV
164
  ): Promise<void>;
×
UNCOV
165
  async parse(
×
UNCOV
166
    ...args: [
×
UNCOV
167
      options?: Papa.ParseConfig & { skipFirstNLines: number; key: string },
×
UNCOV
168
      cb?: (chunk: Record<string, unknown[][]>) => Promise<void>,
×
UNCOV
169
    ]
×
UNCOV
170
  ): Promise<unknown> {
×
UNCOV
171
    const [options, cb] = args;
×
UNCOV
172
    const stream = await this.getFile();
×
UNCOV
173

×
UNCOV
174
    // chunk parse
×
UNCOV
175
    if (options && cb) {
×
UNCOV
176
      return new Promise((resolve, reject) => {
×
UNCOV
177
        let isFirst = true;
×
UNCOV
178
        Papa.parse(stream, {
×
UNCOV
179
          download: false,
×
UNCOV
180
          dynamicTyping: true,
×
UNCOV
181
          chunkSize: Importer.CHUNK_SIZE,
×
UNCOV
182
          chunk: (chunk, parser) => {
×
UNCOV
183
            (async () => {
×
UNCOV
184
              const newChunk = [...chunk.data] as unknown[][];
×
UNCOV
185
              if (isFirst && options.skipFirstNLines) {
×
UNCOV
186
                newChunk.splice(0, 1);
×
UNCOV
187
                isFirst = false;
×
UNCOV
188
              }
×
UNCOV
189
              parser.pause();
×
UNCOV
190
              await cb({ [CsvImporter.DEFAULT_SHEETKEY]: newChunk });
×
UNCOV
191
              parser.resume();
×
UNCOV
192
            })();
×
UNCOV
193
          },
×
UNCOV
194
          complete: () => {
×
UNCOV
195
            resolve({});
×
UNCOV
196
          },
×
UNCOV
197
          error: (err) => {
×
198
            reject(err);
×
199
          },
×
UNCOV
200
        });
×
UNCOV
201
      });
×
UNCOV
202
    } else {
×
UNCOV
203
      return new Promise((resolve, reject) => {
×
UNCOV
204
        Papa.parse(stream, {
×
UNCOV
205
          download: false,
×
UNCOV
206
          dynamicTyping: true,
×
UNCOV
207
          preview: CsvImporter.CHECK_LINES,
×
UNCOV
208
          complete: (result) => {
×
UNCOV
209
            resolve({
×
UNCOV
210
              [CsvImporter.DEFAULT_SHEETKEY]: result.data,
×
UNCOV
211
            });
×
UNCOV
212
          },
×
UNCOV
213
          error: (err) => {
×
214
            reject(err);
×
215
          },
×
UNCOV
216
        });
×
UNCOV
217
      });
×
UNCOV
218
    }
×
UNCOV
219
  }
×
UNCOV
220
}
×
UNCOV
221

×
UNCOV
222
export class ExcelImporter extends Importer {
×
UNCOV
223
  public static readonly SUPPORTEDTYPE: IValidateTypes[] = [
×
UNCOV
224
    FieldType.Checkbox,
×
UNCOV
225
    FieldType.Number,
×
UNCOV
226
    FieldType.Date,
×
UNCOV
227
    FieldType.SingleLineText,
×
UNCOV
228
    FieldType.LongText,
×
UNCOV
229
  ];
×
UNCOV
230

×
UNCOV
231
  parse(): Promise<IParseResult>;
×
UNCOV
232
  parse(
×
UNCOV
233
    options: { skipFirstNLines: number; key: string },
×
UNCOV
234
    cb: (chunk: Record<string, unknown[][]>) => Promise<void>
×
UNCOV
235
  ): Promise<void>;
×
UNCOV
236

×
UNCOV
237
  async parse(
×
UNCOV
238
    options?: { skipFirstNLines: number; key: string },
×
UNCOV
239
    cb?: (chunk: Record<string, unknown[][]>) => Promise<void>
×
UNCOV
240
  ): Promise<unknown> {
×
UNCOV
241
    const fileSteam = await this.getFile();
×
UNCOV
242

×
UNCOV
243
    const asyncRs = async (stream: NodeJS.ReadableStream): Promise<IParseResult> =>
×
UNCOV
244
      new Promise((res, rej) => {
×
UNCOV
245
        const buffers: Buffer[] = [];
×
UNCOV
246
        stream.on('data', function (data) {
×
UNCOV
247
          buffers.push(data);
×
UNCOV
248
        });
×
UNCOV
249
        stream.on('end', function () {
×
UNCOV
250
          const buf = Buffer.concat(buffers);
×
UNCOV
251
          const workbook = XLSX.read(buf, { dense: true });
×
UNCOV
252
          const result: IParseResult = {};
×
UNCOV
253
          Object.keys(workbook.Sheets).forEach((name) => {
×
UNCOV
254
            result[name] = workbook.Sheets[name]['!data']?.map((item) =>
×
UNCOV
255
              item.map((v) => v.w)
×
UNCOV
256
            ) as unknown[][];
×
UNCOV
257
          });
×
UNCOV
258
          res(result);
×
UNCOV
259
        });
×
UNCOV
260
        stream.on('error', (e) => {
×
261
          rej(e);
×
262
        });
×
UNCOV
263
      });
×
UNCOV
264

×
UNCOV
265
    const parseResult = await asyncRs(fileSteam);
×
UNCOV
266

×
UNCOV
267
    if (options && cb) {
×
UNCOV
268
      const { skipFirstNLines, key } = options;
×
UNCOV
269
      if (skipFirstNLines) {
×
UNCOV
270
        parseResult[key].splice(0, 1);
×
UNCOV
271
      }
×
UNCOV
272
      return await cb(parseResult);
×
UNCOV
273
    }
×
UNCOV
274

×
UNCOV
275
    return parseResult;
×
UNCOV
276
  }
×
UNCOV
277
  getSupportedFieldTypes() {
×
UNCOV
278
    return CsvImporter.SUPPORTEDTYPE;
×
UNCOV
279
  }
×
UNCOV
280
}
×
UNCOV
281

×
UNCOV
282
export const importerFactory = (type: SUPPORTEDTYPE, config: IImportConstructorParams) => {
×
UNCOV
283
  switch (type) {
×
UNCOV
284
    case SUPPORTEDTYPE.CSV:
×
UNCOV
285
      return new CsvImporter(config);
×
UNCOV
286
    case SUPPORTEDTYPE.EXCEL:
×
UNCOV
287
      return new ExcelImporter(config);
×
UNCOV
288
    default:
×
289
      throw new Error('not support');
×
UNCOV
290
  }
×
UNCOV
291
};
×
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