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

teableio / teable / 20502768075

25 Dec 2025 09:24AM UTC coverage: 71.835% (-0.009%) from 71.844%
20502768075

push

github

web-flow
fix: template publish app fail (#2341)

* perf: unify template card display

relative issueid: T1429

* feat: add quick entry for template preview

24164 of 27026 branches covered (89.41%)

59778 of 83216 relevant lines covered (71.83%)

4185.08 hits per line

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

70.18
/apps/nestjs-backend/src/features/base-sql-executor/base-sql-executor.service.ts
1
import { Injectable, Logger } from '@nestjs/common';
4✔
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 {
4✔
14
  private db?: PrismaClient;
126✔
15
  private readonly dsn: IDsn;
126✔
16
  readonly driver: DriverClient;
126✔
17
  private hasPgReadAllDataRole?: boolean;
126✔
18
  private readonly logger = new Logger(BaseSqlExecutorService.name);
126✔
19

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

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

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

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

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

104
  async onModuleDestroy() {
126✔
105
    await this.db?.$disconnect();
124✔
106
  }
124✔
107

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

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

147
  private getReadOnlyRoleName(baseId: string) {
126✔
148
    return `${BASE_READ_ONLY_ROLE_PREFIX}${baseId}`;
6✔
149
  }
6✔
150

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

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

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

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

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

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

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

264
  private async readonlyExecuteSql(sql: string) {
126✔
265
    return this.db?.$queryRawUnsafe(sql);
2✔
266
  }
2✔
267

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

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