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

teableio / teable / 19063770031

04 Nov 2025 09:20AM UTC coverage: 74.587%. First build
19063770031

Pull #2074

github

web-flow
Merge 6fa499e4c into b793fadbb
Pull Request #2074: fix: improve error handle

10500 of 11312 branches covered (92.82%)

94 of 361 new or added lines in 9 files covered. (26.04%)

51820 of 69476 relevant lines covered (74.59%)

4358.58 hits per line

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

73.5
/apps/nestjs-backend/src/features/base-sql-executor/base-sql-executor.service.ts
1
import { Injectable, Logger } from '@nestjs/common';
1✔
2
import { ConfigService } from '@nestjs/config';
3
import type { IDsn } from '@teable/core';
4
import { DriverClient, HttpErrorCode, parseDsn } from '@teable/core';
5
import { Prisma, PrismaService, PrismaClient } from '@teable/db-main-prisma';
6
import { Knex } from 'knex';
7
import { InjectModel } from 'nest-knexjs';
8
import { CustomHttpException } from '../../custom.exception';
9
import { BASE_READ_ONLY_ROLE_PREFIX, BASE_SCHEMA_TABLE_READ_ONLY_ROLE_NAME } from './const';
10
import { checkTableAccess, validateRoleOperations } from './utils';
11

12
@Injectable()
13
export class BaseSqlExecutorService {
1✔
14
  private db?: PrismaClient;
87✔
15
  private readonly dsn: IDsn;
87✔
16
  readonly driver: DriverClient;
87✔
17
  private hasPgReadAllDataRole?: boolean;
87✔
18
  private readonly logger = new Logger(BaseSqlExecutorService.name);
87✔
19

20
  constructor(
87✔
21
    private readonly prismaService: PrismaService,
87✔
22
    private readonly configService: ConfigService,
87✔
23
    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex
87✔
24
  ) {
87✔
25
    this.dsn = parseDsn(this.getDatabaseUrl());
87✔
26
    this.driver = this.dsn.driver as DriverClient;
87✔
27
  }
87✔
28

29
  private getDatabaseUrl() {
87✔
30
    return (
87✔
31
      this.configService.get<string>('PRISMA_DATABASE_URL_FOR_SQL_EXECUTOR') ||
87✔
32
      this.configService.getOrThrow<string>('PRISMA_DATABASE_URL')
87✔
33
    );
34
  }
87✔
35

36
  private getDisablePreSqlExecutorCheck() {
87✔
37
    return this.configService.get<string>('DISABLE_PRE_SQL_EXECUTOR_CHECK') === 'true';
87✔
38
  }
87✔
39

40
  private async getReadOnlyDatabaseConnectionConfig(): Promise<string | undefined> {
87✔
41
    if (this.driver === DriverClient.Sqlite) {
87!
42
      return;
×
43
    }
×
44
    if (!this.hasPgReadAllDataRole) {
87!
45
      return;
×
46
    }
×
47
    const isExistReadOnlyRole = await this.roleExits(BASE_SCHEMA_TABLE_READ_ONLY_ROLE_NAME);
87✔
48
    if (!isExistReadOnlyRole) {
87✔
49
      await this.prismaService.$tx(async (prisma) => {
3✔
50
        try {
3✔
51
          await prisma.$executeRawUnsafe(
3✔
52
            this.knex
3✔
53
              .raw(
3✔
54
                `CREATE ROLE ?? WITH LOGIN PASSWORD ? NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION`,
3✔
55
                [BASE_SCHEMA_TABLE_READ_ONLY_ROLE_NAME, this.dsn.pass]
3✔
56
              )
57
              .toQuery()
3✔
58
          );
59
          await prisma.$executeRawUnsafe(
1✔
60
            this.knex
1✔
61
              .raw(`GRANT pg_read_all_data TO ??`, [BASE_SCHEMA_TABLE_READ_ONLY_ROLE_NAME])
1✔
62
              .toQuery()
1✔
63
          );
64
        } catch (error) {
2✔
65
          if (
2✔
66
            error instanceof Prisma.PrismaClientKnownRequestError &&
2✔
67
            (error?.meta?.code === '42710' || error?.meta?.code === '23505')
2✔
68
          ) {
2✔
69
            this.logger.warn(
2✔
70
              `read only role ${BASE_SCHEMA_TABLE_READ_ONLY_ROLE_NAME} already exists, error code: ${error?.meta?.code}`
2✔
71
            );
72
            return;
2✔
73
          }
2!
74
          throw error;
×
75
        }
×
76
      });
3✔
77
    }
3✔
78
    return `postgresql://${BASE_SCHEMA_TABLE_READ_ONLY_ROLE_NAME}:${this.dsn.pass}@${this.dsn.host}:${this.dsn.port}/${this.dsn.db}${
87✔
79
      this.dsn.params
87✔
80
        ? `?${Object.entries(this.dsn.params)
87✔
81
            .map(([key, value]) => `${key}=${value}`)
87✔
82
            .join('&')}`
87✔
83
        : ''
×
84
    }`;
87✔
85
  }
87✔
86

87
  async onModuleInit() {
87✔
88
    if (this.driver !== DriverClient.Pg) {
87!
89
      return;
×
90
    }
×
91
    if (this.getDisablePreSqlExecutorCheck()) {
87!
92
      return;
×
93
    }
×
94
    // if pg_read_all_data role not exist, no need to create read only role
87✔
95
    this.hasPgReadAllDataRole = await this.roleExits('pg_read_all_data');
87✔
96
    if (!this.hasPgReadAllDataRole) {
87!
97
      return;
×
98
    }
×
99
    this.db = await this.createConnection();
87✔
100
  }
87✔
101

102
  async onModuleDestroy() {
87✔
103
    await this.db?.$disconnect();
86✔
104
  }
86✔
105

106
  private async createConnection(): Promise<PrismaClient | undefined> {
87✔
107
    if (this.db) {
87!
108
      return this.db;
×
109
    }
×
110
    const connectionConfig = await this.getReadOnlyDatabaseConnectionConfig();
87✔
111
    if (!connectionConfig) {
87!
112
      return;
×
113
    }
×
114
    const connection = new PrismaClient({
87✔
115
      datasources: {
87✔
116
        db: {
87✔
117
          url: connectionConfig,
87✔
118
        },
87✔
119
      },
87✔
120
    });
87✔
121
    await connection.$connect();
87✔
122

123
    // validate connection
87✔
124
    try {
87✔
125
      await connection.$queryRawUnsafe('SELECT 1');
87✔
126
      return connection;
87✔
127
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
87✔
128
    } catch (error: any) {
87!
129
      await connection.$disconnect();
×
NEW
130
      throw new CustomHttpException(
×
NEW
131
        `database connection failed: ${error.message}`,
×
NEW
132
        HttpErrorCode.VALIDATION_ERROR,
×
NEW
133
        {
×
NEW
134
          localization: {
×
NEW
135
            i18nKey: 'httpErrors.baseSqlExecutor.databaseConnectionFailed',
×
NEW
136
            context: {
×
NEW
137
              message: error.message,
×
NEW
138
            },
×
NEW
139
          },
×
NEW
140
        }
×
141
      );
142
    }
×
143
  }
87✔
144

145
  private getReadOnlyRoleName(baseId: string) {
87✔
146
    return `${BASE_READ_ONLY_ROLE_PREFIX}${baseId}`;
6✔
147
  }
6✔
148

149
  async createReadOnlyRole(baseId: string) {
87✔
150
    const roleName = this.getReadOnlyRoleName(baseId);
2✔
151
    await this.prismaService
2✔
152
      .txClient()
2✔
153
      .$executeRawUnsafe(
2✔
154
        this.knex
2✔
155
          .raw(
2✔
156
            `CREATE ROLE ?? WITH NOLOGIN NOSUPERUSER NOINHERIT NOCREATEDB NOCREATEROLE NOREPLICATION`,
2✔
157
            [roleName]
2✔
158
          )
159
          .toQuery()
2✔
160
      );
161
    await this.prismaService
2✔
162
      .txClient()
2✔
163
      .$executeRawUnsafe(
2✔
164
        this.knex.raw(`GRANT USAGE ON SCHEMA ?? TO ??`, [baseId, roleName]).toQuery()
2✔
165
      );
166
    await this.prismaService
2✔
167
      .txClient()
2✔
168
      .$executeRawUnsafe(
2✔
169
        this.knex.raw(`GRANT SELECT ON ALL TABLES IN SCHEMA ?? TO ??`, [baseId, roleName]).toQuery()
2✔
170
      );
171
    await this.prismaService
2✔
172
      .txClient()
2✔
173
      .$executeRawUnsafe(
2✔
174
        this.knex
2✔
175
          .raw(`ALTER DEFAULT PRIVILEGES IN SCHEMA ?? GRANT SELECT ON TABLES TO ??`, [
2✔
176
            baseId,
2✔
177
            roleName,
2✔
178
          ])
2✔
179
          .toQuery()
2✔
180
      );
181
  }
2✔
182

183
  async dropReadOnlyRole(baseId: string) {
87✔
184
    const roleName = this.getReadOnlyRoleName(baseId);
×
185
    await this.prismaService
×
186
      .txClient()
×
187
      .$executeRawUnsafe(
×
188
        this.knex.raw(`REVOKE USAGE ON SCHEMA ?? FROM ??`, [baseId, roleName]).toQuery()
×
189
      );
190
    await this.prismaService
×
191
      .txClient()
×
192
      .$executeRawUnsafe(
×
193
        this.knex
×
194
          .raw(`REVOKE SELECT ON ALL TABLES IN SCHEMA ?? FROM ??`, [baseId, roleName])
×
195
          .toQuery()
×
196
      );
197
    await this.prismaService
×
198
      .txClient()
×
199
      .$executeRawUnsafe(
×
200
        this.knex
×
201
          .raw(`ALTER DEFAULT PRIVILEGES IN SCHEMA ?? REVOKE ALL ON TABLES FROM ??`, [
×
202
            baseId,
×
203
            roleName,
×
204
          ])
×
205
          .toQuery()
×
206
      );
207
    await this.prismaService
×
208
      .txClient()
×
209
      .$executeRawUnsafe(this.knex.raw(`DROP ROLE IF EXISTS ??`, [roleName]).toQuery());
×
210
  }
×
211

212
  async grantReadOnlyRole(baseId: string) {
87✔
213
    const roleName = this.getReadOnlyRoleName(baseId);
×
214
    await this.prismaService
×
215
      .txClient()
×
216
      .$executeRawUnsafe(
×
217
        this.knex.raw(`GRANT USAGE ON SCHEMA ?? TO ??`, [baseId, roleName]).toQuery()
×
218
      );
219
    await this.prismaService
×
220
      .txClient()
×
221
      .$executeRawUnsafe(
×
222
        this.knex.raw(`GRANT SELECT ON ALL TABLES IN SCHEMA ?? TO ??`, [baseId, roleName]).toQuery()
×
223
      );
224
    await this.prismaService
×
225
      .txClient()
×
226
      .$executeRawUnsafe(
×
227
        this.knex
×
228
          .raw(`ALTER DEFAULT PRIVILEGES IN SCHEMA ?? GRANT SELECT ON TABLES TO ??`, [
×
229
            baseId,
×
230
            roleName,
×
231
          ])
×
232
          .toQuery()
×
233
      );
234
  }
×
235

236
  private async roleExits(role: string): Promise<boolean> {
87✔
237
    const roleExists = await this.prismaService.$queryRaw<
176✔
238
      { count: bigint }[]
239
    >`SELECT count(*) FROM pg_roles WHERE rolname=${role}`;
176✔
240
    return Boolean(roleExists[0].count);
176✔
241
  }
176✔
242

243
  private async roleCheckAndCreate(baseId: string) {
87✔
244
    if (this.driver !== DriverClient.Pg) {
2!
245
      return;
×
246
    }
×
247
    const roleName = this.getReadOnlyRoleName(baseId);
2✔
248
    if (!(await this.roleExits(roleName))) {
2✔
249
      await this.createReadOnlyRole(baseId);
2✔
250
    }
2✔
251
  }
2✔
252

253
  private async setRole(prisma: Prisma.TransactionClient, baseId: string) {
87✔
254
    const roleName = this.getReadOnlyRoleName(baseId);
2✔
255
    await prisma.$executeRawUnsafe(this.knex.raw(`SET ROLE ??`, [roleName]).toQuery());
2✔
256
  }
2✔
257

258
  private async resetRole(prisma: Prisma.TransactionClient) {
87✔
259
    await prisma.$executeRawUnsafe(this.knex.raw(`RESET ROLE`).toQuery());
2✔
260
  }
1✔
261

262
  private async readonlyExecuteSql(sql: string) {
87✔
263
    return this.db?.$queryRawUnsafe(sql);
2✔
264
  }
2✔
265

266
  /**
87✔
267
   * check sql is safe
268
   * 1. role operations validation
269
   * 2. parse sql to valid table names
270
   * 3. read only role check table access
271
   */
87✔
272
  private async safeCheckSql(
87✔
273
    baseId: string,
2✔
274
    sql: string,
2✔
275
    opts?: { projectionTableDbNames?: string[]; projectionTableIds?: string[] }
2✔
276
  ) {
2✔
277
    const { projectionTableDbNames = [] } = opts ?? {};
2✔
278
    // 1. role operations keywords validation, only pg support
2✔
279
    if (this.driver == DriverClient.Pg) {
2✔
280
      validateRoleOperations(sql);
2✔
281
    }
2✔
282
    let tableNames = projectionTableDbNames;
2✔
283
    if (!projectionTableDbNames.length) {
2✔
284
      const tables = await this.prismaService.tableMeta.findMany({
1✔
285
        where: {
1✔
286
          baseId,
1✔
287
        },
1✔
288
        select: {
1✔
289
          dbTableName: true,
1✔
290
        },
1✔
291
      });
1✔
292
      tableNames = tables.map((table) => table.dbTableName);
1✔
293
    }
1✔
294
    // 2. parse sql to valid table names
2✔
295
    checkTableAccess(sql, {
2✔
296
      tableNames,
2✔
297
      database: this.driver,
2✔
298
    });
2✔
299
    // 3. read only role check table access, only pg and pg version > 14 support
2✔
300
    await this.readonlyExecuteSql(sql);
2✔
301
  }
2✔
302

303
  async executeQuerySql<T = unknown>(
87✔
304
    baseId: string,
2✔
305
    sql: string,
2✔
306
    opts?: {
2✔
307
      projectionTableDbNames?: string[];
308
      projectionTableIds?: string[];
309
    }
2✔
310
  ) {
2✔
311
    await this.safeCheckSql(baseId, sql, opts);
2✔
312
    await this.roleCheckAndCreate(baseId);
2✔
313
    return this.prismaService.$tx(async (prisma) => {
2✔
314
      try {
2✔
315
        await this.setRole(prisma, baseId);
2✔
316
        return await prisma.$queryRawUnsafe<T>(sql);
2✔
317
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
2✔
318
      } catch (error: any) {
2✔
319
        throw new CustomHttpException(
1✔
320
          `execute query sql failed: ${error?.meta?.message || error?.message}`,
1!
321
          HttpErrorCode.VALIDATION_ERROR,
1✔
322
          {
1✔
323
            localization: {
1✔
324
              i18nKey: 'httpErrors.baseSqlExecutor.executeQuerySqlFailed',
1✔
325
              context: {
1✔
326
                message: error?.meta?.message || error?.message,
1!
327
              },
1✔
328
            },
1✔
329
          }
1✔
330
        );
331
      } finally {
2!
332
        await this.resetRole(prisma).catch((error) => {
2✔
333
          console.log('resetRole error', error);
1✔
334
        });
1✔
335
      }
2✔
336
    });
2✔
337
  }
2✔
338
}
87✔
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

© 2026 Coveralls, Inc