From 41825672c35f7e1bc47d7d4209281c86dd374c85 Mon Sep 17 00:00:00 2001 From: takamasa-like-curry Date: Thu, 1 Oct 2026 20:44:24 +0900 Subject: [PATCH] feat(mysql,mariadb): add per-source readonly_session_sql Add a `readonly_session_sql` source option holding SET SESSION statements that are run right after each read-only execution opens its read-only transaction. This lets a source carry guardrails the server enforces itself, such as MySQL's max_execution_time, and puts them back on every read-only call even if the pooled connection was changed in the meantime. Statements are validated at config load: only one `SET SESSION name = value` assignment per statement, no subqueries, and no setting that would switch off the read-only transaction. Co-Authored-By: Claude Opus 5.5 --- dbhub.toml.example | 9 ++ docs/config/toml.mdx | 27 +++++ src/config/__tests__/toml-loader.test.ts | 55 +++++++++++ src/config/toml-loader.ts | 22 +++++ .../__tests__/mariadb.integration.test.ts | 22 ++++- .../__tests__/mysql.integration.test.ts | 61 +++++++++++- src/connectors/interface.ts | 6 ++ src/connectors/manager.ts | 4 + src/connectors/mariadb/index.ts | 7 +- src/connectors/mysql/index.ts | 7 +- src/types/config.ts | 1 + .../__tests__/readonly-session-sql.test.ts | 70 +++++++++++++ src/utils/readonly-session-sql.ts | 98 +++++++++++++++++++ src/utils/readonly-transaction.ts | 10 +- 14 files changed, 394 insertions(+), 5 deletions(-) create mode 100644 src/utils/__tests__/readonly-session-sql.test.ts create mode 100644 src/utils/readonly-session-sql.ts diff --git a/dbhub.toml.example b/dbhub.toml.example index e8e4b4ae..3ee268b9 100644 --- a/dbhub.toml.example +++ b/dbhub.toml.example @@ -135,6 +135,15 @@ dsn = "postgres://postgres:postgres@localhost:5432/myapp" # charset = "utf8mb4" # Connection character set (uses its default collation) # collation = "utf8mb4_0900_ai_ci" # Connection collation +# MySQL with guardrails re-applied before every read-only execution (MySQL/MariaDB only) +# [[sources]] +# id = "local_mysql_guarded" +# dsn = "mysql://root:mysql@localhost:3306/myapp" +# readonly_session_sql = """ +# SET SESSION max_execution_time = 30000; +# SET SESSION lock_wait_timeout = 5; +# """ + # MySQL on AWS RDS with IAM authentication (no password in config) # [[sources]] # id = "rds_mysql_iam" diff --git a/docs/config/toml.mdx b/docs/config/toml.mdx index 5e251a81..22306705 100644 --- a/docs/config/toml.mdx +++ b/docs/config/toml.mdx @@ -331,6 +331,32 @@ Sources define database connections. Each source represents a database that DBHu +### readonly_session_sql + + + Session-setting statements that DBHub runs at the start of every read-only execution on this source, inside the read-only transaction it already opens. Use it for limits the database enforces itself, such as `max_execution_time` or `lock_wait_timeout`. + + Supported databases: MySQL, MariaDB. + + ```toml + [[sources]] + id = "production" + dsn = "mysql://user:pass@localhost:3306/mydb" + readonly_session_sql = """ + SET SESSION max_execution_time = 30000; + SET SESSION lock_wait_timeout = 5; + """ + ``` + + Only `SET SESSION name = value` statements are accepted, one assignment each. Any other statement fails at startup, as does a setting that controls the transaction they run in (`transaction_read_only`, `tx_read_only`, `autocommit`, `completion_type`). + + The statements run on every read-only execution rather than once at connect time, so a value changed on the pooled connection in the meantime is put back before the next read-only statement. + + + Every read-only execution runs it, including `explain_sql` and read-only custom tools. Writable executions do not. The settings stay on the connection after the execution, so a writable tool sharing the same source may see them. + + + ### lazy @@ -894,6 +920,7 @@ default = 10 | `connection_timeout` | number | ❌ | Connection timeout (seconds) | | `query_timeout` | number | ❌ | Query timeout (seconds) | | `pool_max_connections` | integer | ❌ | PostgreSQL pool size per process and source (default: `10`, range: `1`-`1000`) | +| `readonly_session_sql` | string | ❌ | MySQL/MariaDB: session-setting statements run before every read-only execution | | `sslmode` | string | ❌ | SSL mode: `disable`, `require`, `verify-ca`, `verify-full` | | `sslrootcert` | string | ❌ | CA certificate path (PostgreSQL only, requires `verify-ca` or `verify-full`) | | `sslcert` | string | ❌ | PEM client certificate path for client certificate authentication (PostgreSQL only, requires `sslkey` and TLS enabled) | diff --git a/src/config/__tests__/toml-loader.test.ts b/src/config/__tests__/toml-loader.test.ts index 344bc399..542ba223 100644 --- a/src/config/__tests__/toml-loader.test.ts +++ b/src/config/__tests__/toml-loader.test.ts @@ -1431,6 +1431,61 @@ pool_max_connections = 5 }); }); + describe('readonly_session_sql validation', () => { + it('should accept SET SESSION statements for MySQL', () => { + const tomlContent = ` +[[sources]] +id = "test_db" +dsn = "mysql://user:pass@localhost:3306/testdb" +readonly_session_sql = """ +SET SESSION max_execution_time = 30000; +SET SESSION lock_wait_timeout = 5; +""" +`; + fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent); + + const result = loadTomlConfig(); + + expect(result?.sources[0].readonly_session_sql).toContain('SET SESSION lock_wait_timeout'); + }); + + it('should reject a statement that is not a session setting', () => { + const tomlContent = ` +[[sources]] +id = "test_db" +dsn = "mysql://user:pass@localhost:3306/testdb" +readonly_session_sql = "SET SESSION lock_wait_timeout = 5; DELETE FROM users" +`; + fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent); + + expect(() => loadTomlConfig()).toThrow("source 'test_db' has invalid readonly_session_sql"); + }); + + it('should reject readonly_session_sql for unsupported source types', () => { + const tomlContent = ` +[[sources]] +id = "test_db" +dsn = "postgres://user:pass@localhost:5432/testdb" +readonly_session_sql = "SET LOCAL lock_timeout = '5s'" +`; + fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent); + + expect(() => loadTomlConfig()).toThrow('not supported for postgres'); + }); + + it('should reject a non-string value', () => { + const tomlContent = ` +[[sources]] +id = "test_db" +dsn = "mysql://user:pass@localhost:3306/testdb" +readonly_session_sql = ["SET SESSION lock_wait_timeout = 5"] +`; + fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent); + + expect(() => loadTomlConfig()).toThrow('invalid readonly_session_sql'); + }); + }); + describe('search_path validation', () => { it('should accept search_path for PostgreSQL source', () => { const tomlContent = ` diff --git a/src/config/toml-loader.ts b/src/config/toml-loader.ts index 8d285c49..e55820d3 100644 --- a/src/config/toml-loader.ts +++ b/src/config/toml-loader.ts @@ -6,6 +6,8 @@ import type { SourceConfig, TomlConfig, ToolConfig } from "../types/config.js"; import { parseCommandLineArgs, requireFlagValue } from "./env.js"; import { parseConnectionInfoFromDSN, getDefaultPortForType } from "../utils/dsn-obfuscate.js"; import { SafeURL } from "../utils/safe-url.js"; +import { parseReadonlySessionSQL } from "../utils/readonly-session-sql.js"; +import type { ConnectorType } from "../connectors/interface.js"; import { BUILTIN_TOOL_EXECUTE_SQL, BUILTIN_TOOL_SEARCH_OBJECTS, ALL_BUILTIN_TOOL_NAMES } from "../tools/builtin-tools.js"; /** @@ -719,6 +721,26 @@ function validateSourceConfig(source: SourceConfig, configPath: string): void { } + // Validate readonly_session_sql up front so a bad statement fails at startup rather + // than on the first read-only query. source.type is populated from the DSN + // by processSourceConfigs before validation runs. + if (source.readonly_session_sql !== undefined) { + if (typeof source.readonly_session_sql !== "string") { + throw new Error( + `Configuration file ${configPath}: source '${source.id}' has invalid readonly_session_sql. ` + + `Must be a string of session-setting statements.` + ); + } + try { + parseReadonlySessionSQL(source.readonly_session_sql, source.type as ConnectorType); + } catch (error) { + throw new Error( + `Configuration file ${configPath}: source '${source.id}' has invalid readonly_session_sql: ` + + (error as Error).message + ); + } + } + // Validate timezone (MySQL/MariaDB only) if (source.timezone !== undefined) { if (source.type !== "mysql" && source.type !== "mariadb") { diff --git a/src/connectors/__tests__/mariadb.integration.test.ts b/src/connectors/__tests__/mariadb.integration.test.ts index 669e3d26..c064c445 100644 --- a/src/connectors/__tests__/mariadb.integration.test.ts +++ b/src/connectors/__tests__/mariadb.integration.test.ts @@ -597,4 +597,24 @@ describe('MariaDB Connector Integration Tests', () => { } }); }); -}); \ No newline at end of file + + describe('readonly_session_sql', () => { + it('should apply the settings to read-only executions', async () => { + const connector = new MariaDBConnector(); + try { + await connector.connect(mariadbTest.connectionString, undefined, { + readonlySessionSql: 'SET SESSION lock_wait_timeout = 7', + }); + + const result = await connector.executeSQL( + 'SELECT @@session.lock_wait_timeout AS lwt', + { readonly: true } + ); + + expect(Number(result.resultSets[0].rows[0].lwt)).toBe(7); + } finally { + await connector.disconnect(); + } + }); + }); +}); diff --git a/src/connectors/__tests__/mysql.integration.test.ts b/src/connectors/__tests__/mysql.integration.test.ts index edbe87a4..502d22e7 100644 --- a/src/connectors/__tests__/mysql.integration.test.ts +++ b/src/connectors/__tests__/mysql.integration.test.ts @@ -606,4 +606,63 @@ describe('MySQL Connector Integration Tests', () => { } }); }); -}); \ No newline at end of file + + describe('readonly_session_sql', () => { + it('should apply the settings to read-only executions', async () => { + const connector = new MySQLConnector(); + try { + await connector.connect(mysqlTest.connectionString, undefined, { + readonlySessionSql: 'SET SESSION max_execution_time = 1234', + }); + + const result = await connector.executeSQL( + 'SELECT @@session.max_execution_time AS met', + { readonly: true } + ); + + expect(Number(result.resultSets[0].rows[0].met)).toBe(1234); + } finally { + await connector.disconnect(); + } + }); + + it('should put a value back after it was changed on the pooled connection', async () => { + const connector = new MySQLConnector(); + try { + await connector.connect(mysqlTest.connectionString, undefined, { + readonlySessionSql: 'SET SESSION max_execution_time = 1234', + }); + + await connector.executeSQL('SET SESSION max_execution_time = 0', {}); + const result = await connector.executeSQL( + 'SELECT @@session.max_execution_time AS met', + { readonly: true } + ); + + expect(Number(result.resultSets[0].rows[0].met)).toBe(1234); + } finally { + await connector.disconnect(); + } + }); + + it('should let the server stop a read-only SELECT at max_execution_time', async () => { + const connector = new MySQLConnector(); + try { + await connector.connect(mysqlTest.connectionString, undefined, { + readonlySessionSql: 'SET SESSION max_execution_time = 500', + }); + + const started = Date.now(); + const result = await connector.executeSQL('SELECT SLEEP(5) AS interrupted', { + readonly: true, + }); + + // SLEEP returns 1 when the server interrupts it. + expect(Number(result.resultSets[0].rows[0].interrupted)).toBe(1); + expect(Date.now() - started).toBeLessThan(4000); + } finally { + await connector.disconnect(); + } + }); + }); +}); diff --git a/src/connectors/interface.ts b/src/connectors/interface.ts index ff93cf05..86e84d73 100644 --- a/src/connectors/interface.ts +++ b/src/connectors/interface.ts @@ -177,6 +177,12 @@ export interface ConnectorConfig { * set, the driver falls back to its built-in default (mysql2: `utf8mb4_unicode_ci`). */ collation?: string; + /** + * Session-setting statements re-run at the start of every read-only execution + * (MySQL, MariaDB). Validated by parseReadonlySessionSQL; see + * src/utils/readonly-session-sql.ts for why they run per execution. + */ + readonlySessionSql?: string; } /** diff --git a/src/connectors/manager.ts b/src/connectors/manager.ts index 1b4c309a..b0e2fe3f 100644 --- a/src/connectors/manager.ts +++ b/src/connectors/manager.ts @@ -284,6 +284,10 @@ export class ConnectorManager { if (source.collation) { config.collation = source.collation; } + // Pass readonly_session_sql (MySQL, MariaDB) + if (source.readonly_session_sql) { + config.readonlySessionSql = source.readonly_session_sql; + } // Connect to the database with config and optional init script. If this fails, // close the tunnel established for this attempt: the source may be retried (lazy diff --git a/src/connectors/mariadb/index.ts b/src/connectors/mariadb/index.ts index 63b679dd..f77933c6 100644 --- a/src/connectors/mariadb/index.ts +++ b/src/connectors/mariadb/index.ts @@ -19,6 +19,7 @@ import { requireDatabaseInDSN, MissingDatabaseError } from "../../utils/dsn-data import { SQLRowLimiter } from "../../utils/sql-row-limiter.js"; import { parseQueryResultSets } from "../../utils/multi-statement-result-parser.js"; import { splitSQLStatements } from "../../utils/sql-parser.js"; +import { parseReadonlySessionSQL } from "../../utils/readonly-session-sql.js"; import { withReadOnlyTransaction } from "../../utils/readonly-transaction.js"; import { quoteIdentifier } from "../../utils/identifier-quoter.js"; import { isTiDBVersion } from "../../utils/server-flavor.js"; @@ -147,6 +148,8 @@ export class MariaDBConnector implements Connector { // TiDB speaks the MySQL protocol but rejects `START TRANSACTION READ ONLY` // unless tidb_enable_noop_functions is on. Detected once at connect time. private supportsReadOnlyTransaction: boolean = true; + // Per-source readonly_session_sql, re-run inside every read-only transaction + private sessionStatements: string[] = []; getId(): string { return this.sourceId; @@ -159,6 +162,7 @@ export class MariaDBConnector implements Connector { async connect(dsn: string, initScript?: string, config?: ConnectorConfig): Promise { try { const connectionConfig = await this.dsnParser.parse(dsn, config); + this.sessionStatements = config?.readonlySessionSql ? parseReadonlySessionSQL(config.readonlySessionSql, "mariadb") : []; this.pool = mariadb.createPool(connectionConfig); @@ -703,7 +707,8 @@ export class MariaDBConnector implements Connector { } return { resultSets }; - } + }, + this.sessionStatements ); } finally { // Always release the connection back to the pool diff --git a/src/connectors/mysql/index.ts b/src/connectors/mysql/index.ts index ec8dcd2a..63bd2c52 100644 --- a/src/connectors/mysql/index.ts +++ b/src/connectors/mysql/index.ts @@ -19,6 +19,7 @@ import { requireDatabaseInDSN, MissingDatabaseError } from "../../utils/dsn-data import { SQLRowLimiter } from "../../utils/sql-row-limiter.js"; import { parseQueryResultSets } from "../../utils/multi-statement-result-parser.js"; import { splitSQLStatements } from "../../utils/sql-parser.js"; +import { parseReadonlySessionSQL } from "../../utils/readonly-session-sql.js"; import { withReadOnlyTransaction, isClientSideTimeout } from "../../utils/readonly-transaction.js"; import { quoteIdentifier } from "../../utils/identifier-quoter.js"; import { isTiDBVersion } from "../../utils/server-flavor.js"; @@ -165,6 +166,8 @@ export class MySQLConnector implements Connector { // TiDB speaks the MySQL protocol but rejects `START TRANSACTION READ ONLY` // unless tidb_enable_noop_functions is on. Detected once at connect time. private supportsReadOnlyTransaction: boolean = true; + // Per-source readonly_session_sql, re-run inside every read-only transaction + private sessionStatements: string[] = []; getId(): string { return this.sourceId; @@ -177,6 +180,7 @@ export class MySQLConnector implements Connector { async connect(dsn: string, initScript?: string, config?: ConnectorConfig): Promise { try { const connectionOptions = await this.dsnParser.parse(dsn, config); + this.sessionStatements = config?.readonlySessionSql ? parseReadonlySessionSQL(config.readonlySessionSql, "mysql") : []; this.pool = mysql.createPool(connectionOptions); // Store query timeout for per-query application @@ -723,7 +727,8 @@ export class MySQLConnector implements Connector { } return { resultSets }; - } + }, + this.sessionStatements ); } catch (error) { if (isClientSideTimeout(error)) { diff --git a/src/types/config.ts b/src/types/config.ts index 6f2a3459..9c7bb9c1 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -57,6 +57,7 @@ export interface SourceConfig extends ConnectionParams, SSHConfig { query_timeout?: number; // Query timeout in seconds (PostgreSQL, MySQL, MariaDB, SQL Server) pool_max_connections?: number; // Maximum PostgreSQL connections per source (1-1000) init_script?: string; // Optional SQL script to run on connection (for demo mode or initialization) + readonly_session_sql?: string; // Session-setting statements re-run before every read-only execution (MySQL, MariaDB) lazy?: boolean; // Defer connection until first query (default: false) search_path?: string; // Comma-separated list of schemas for PostgreSQL search_path (e.g., "myschema,public") timezone?: string; // MySQL/MariaDB: how the driver interprets DATETIME values. "Z" (UTC), "local", or "±HH:MM" (e.g., "+09:00") diff --git a/src/utils/__tests__/readonly-session-sql.test.ts b/src/utils/__tests__/readonly-session-sql.test.ts new file mode 100644 index 00000000..660cea9c --- /dev/null +++ b/src/utils/__tests__/readonly-session-sql.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from "vitest"; +import { parseReadonlySessionSQL } from "../readonly-session-sql.js"; + +describe("parseReadonlySessionSQL", () => { + describe("accepted forms", () => { + it.each(["mysql", "mariadb"] as const)("splits SET SESSION statements in order for %s", dialect => { + const sql = ` + SET SESSION max_execution_time = 30000; + SET SESSION lock_wait_timeout = 5; + `; + expect(parseReadonlySessionSQL(sql, dialect)).toEqual([ + "SET SESSION max_execution_time = 30000", + "SET SESSION lock_wait_timeout = 5", + ]); + }); + + it("accepts a comma inside a string value", () => { + expect( + parseReadonlySessionSQL("SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ZERO_DATE'", "mysql") + ).toHaveLength(1); + }); + + it("ignores a leading comment", () => { + expect(parseReadonlySessionSQL("-- guardrails\nSET SESSION lock_wait_timeout = 5", "mysql")).toHaveLength(1); + }); + }); + + describe("rejected statements", () => { + it.each([ + ["a query", "SELECT 1"], + ["a write", "DELETE FROM users"], + ["a SET without SESSION", "SET max_execution_time = 1"], + ["a GLOBAL assignment", "SET GLOBAL max_execution_time = 1"], + ["a subquery value", "SET SESSION max_execution_time = (SELECT 1)"], + ["a second assignment in another scope", "SET SESSION max_execution_time = 1, GLOBAL max_connections = 1"], + ["an executable comment hiding a subquery", "SET SESSION max_execution_time = 1 /*!, b = (SELECT 1) */"], + ["a keyword hidden in a comment", "/* SET SESSION x = 1 */ DELETE FROM users"], + ])("rejects %s", (_label, sql) => { + expect(() => parseReadonlySessionSQL(sql, "mysql")).toThrow("SET SESSION name = value"); + }); + + it("rejects a second statement after a valid one", () => { + expect(() => + parseReadonlySessionSQL("SET SESSION lock_wait_timeout = 5; DROP TABLE users", "mysql") + ).toThrow("got: DROP TABLE users"); + }); + + it.each([ + ["mysql", "SET SESSION transaction_read_only = 0"], + ["mariadb", "SET SESSION tx_read_only = 0"], + ["mysql", "SET SESSION autocommit = 0"], + ["mariadb", "SET SESSION completion_type = 2"], + ] as const)("rejects a transaction-control setting on %s: %s", (dialect, sql) => { + expect(() => parseReadonlySessionSQL(sql, dialect)).toThrow("controls the transaction"); + }); + + it("rejects an empty value", () => { + expect(() => parseReadonlySessionSQL(" ; ", "mysql")).toThrow("empty"); + }); + + it.each(["postgres", "sqlserver", "oracle", "sqlite"] as const)( + "rejects the unsupported dialect %s", + dialect => { + expect(() => parseReadonlySessionSQL("SET SESSION lock_wait_timeout = 5", dialect)).toThrow( + "not supported" + ); + } + ); + }); +}); diff --git a/src/utils/readonly-session-sql.ts b/src/utils/readonly-session-sql.ts new file mode 100644 index 00000000..52155608 --- /dev/null +++ b/src/utils/readonly-session-sql.ts @@ -0,0 +1,98 @@ +import type { ConnectorType } from "../connectors/interface.js"; +import { splitSQLStatements, stripCommentsAndStrings } from "./sql-parser.js"; + +/** + * Per-source `readonly_session_sql`: session-setting statements re-run at the start of + * every read-only execution, inside the read-only transaction the connector + * already opens (START TRANSACTION READ ONLY). + * + * The pooled connection may be shared with writable tools on the same source, + * which can change session settings, so re-running these statements per + * execution puts the configured values back before every read-only statement. + * + * Only the dialect's session-setting form is accepted, so this field cannot + * become a general SQL execution path. The check runs on comment- and + * literal-stripped text: a quoted keyword or a leading comment cannot disguise + * another statement. MySQL/MariaDB executable comments (`/*! ... *\/`) are kept + * by the stripper, so their contents are checked too. + */ + +/** + * Accepted statement shape per dialect. Values are not inspected beyond the + * assignment operator: string literals are blanked by the stripper, and the + * server rejects a malformed value on the first execution. + */ +const sessionStatementPatterns: Partial> = { + // One assignment per statement: a comma list could mix in another scope + // (`SET SESSION a = 1, GLOBAL b = 2`). Commas inside string values are + // blanked by the stripper, so `sql_mode = 'A,B'` still passes. + mysql: /^SET\s+SESSION\s+([a-z_][a-z0-9_]*)\s*=[^,]*$/i, + mariadb: /^SET\s+SESSION\s+([a-z_][a-z0-9_]*)\s*=[^,]*$/i, +}; + +/** Human-readable form of each pattern, for error messages. */ +const sessionStatementForms: Partial> = { + mysql: "SET SESSION name = value", + mariadb: "SET SESSION name = value", +}; + +/** + * Settings that control the transaction the statements run in, rejected so a + * source-level setting cannot undo tool-level `readonly = true` or break the + * pooled connection: the read-only switches, and on MySQL/MariaDB `autocommit` + * and `completion_type`, which change how the closing COMMIT behaves and stay + * on the connection for whichever execution draws it next. + */ +const transactionControlSettings: Partial> = { + mysql: ["transaction_read_only", "tx_read_only", "autocommit", "completion_type"], + mariadb: ["transaction_read_only", "tx_read_only", "autocommit", "completion_type"], +}; + +/** Dialects that support `readonly_session_sql`. */ +export const READONLY_SESSION_SQL_DIALECTS: readonly ConnectorType[] = Object.keys( + sessionStatementPatterns +) as ConnectorType[]; + +/** + * Split and validate a `readonly_session_sql` value. + * + * @returns The statements to run, in order, as written in the config. + * @throws If the dialect is unsupported, or a statement is not the dialect's + * session-setting form or names a transaction-control setting. + */ +export function parseReadonlySessionSQL(sql: string, dialect: ConnectorType): string[] { + const pattern = sessionStatementPatterns[dialect]; + if (!pattern) { + throw new Error( + `readonly_session_sql is not supported for ${dialect} sources ` + + `(supported: ${READONLY_SESSION_SQL_DIALECTS.join(", ")}).` + ); + } + + const statements = splitSQLStatements(sql, dialect); + if (statements.length === 0) { + throw new Error("readonly_session_sql is empty."); + } + + for (const statement of statements) { + const cleaned = stripCommentsAndStrings(statement, dialect).trim(); + const match = pattern.exec(cleaned); + // A SET value can be a subquery on MySQL/MariaDB (`SET SESSION x = (SELECT ...)`); + // reject it so the field stays limited to plain assignments. + if (!match || /\bselect\b/i.test(cleaned)) { + throw new Error( + `readonly_session_sql only accepts statements of the form '${sessionStatementForms[dialect]}' for ${dialect}; ` + + `got: ${statement}` + ); + } + const name = match[1].toLowerCase(); + if (transactionControlSettings[dialect]?.includes(name)) { + throw new Error( + `readonly_session_sql cannot set '${name}': it controls the transaction ` + + `the statements run in.` + ); + } + } + + return statements; +} diff --git a/src/utils/readonly-transaction.ts b/src/utils/readonly-transaction.ts index 1906d2ab..a87f5282 100644 --- a/src/utils/readonly-transaction.ts +++ b/src/utils/readonly-transaction.ts @@ -53,12 +53,17 @@ export function isClientSideTimeout(error: unknown): boolean { * @param readonly Whether read-only enforcement is active for this execution. * @param supportsReadOnlyTransaction False for TiDB, which rejects READ ONLY. * @param execute Driver-specific query + result parsing. + * @param sessionStatements Per-source `readonly_session_sql`, run right after the + * transaction opens (see src/utils/readonly-session-sql.ts). These are SET SESSION + * statements, so they stay on the pooled connection afterwards; that is why + * they are re-run on every read-only execution rather than once. */ export async function withReadOnlyTransaction( conn: ReadOnlyTransactionConnection, readonly: boolean | undefined, supportsReadOnlyTransaction: boolean, - execute: () => Promise + execute: () => Promise, + sessionStatements: readonly string[] = [] ): Promise { if (!readonly) { return execute(); @@ -72,6 +77,9 @@ export async function withReadOnlyTransaction( await conn.query( supportsReadOnlyTransaction ? "START TRANSACTION READ ONLY" : "START TRANSACTION" ); + for (const statement of sessionStatements) { + await conn.query(statement); + } const result = await execute(); await conn.query(supportsReadOnlyTransaction ? "COMMIT" : "ROLLBACK"); return result;