Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions dbhub.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
27 changes: 27 additions & 0 deletions docs/config/toml.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,32 @@ Sources define database connections. Each source represents a database that DBHu
</Note>
</ParamField>

### readonly_session_sql

<ParamField path="readonly_session_sql" type="string">
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.

<Note>
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.
</Note>
</ParamField>

### lazy

<ParamField path="lazy" type="boolean" default="false">
Expand Down Expand Up @@ -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) |
Expand Down
55 changes: 55 additions & 0 deletions src/config/__tests__/toml-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
Expand Down
22 changes: 22 additions & 0 deletions src/config/toml-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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") {
Expand Down
22 changes: 21 additions & 1 deletion src/connectors/__tests__/mariadb.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -597,4 +597,24 @@ describe('MariaDB Connector Integration Tests', () => {
}
});
});
});

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();
}
});
});
});
61 changes: 60 additions & 1 deletion src/connectors/__tests__/mysql.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -606,4 +606,63 @@ describe('MySQL Connector Integration Tests', () => {
}
});
});
});

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();
}
});
});
});
6 changes: 6 additions & 0 deletions src/connectors/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
4 changes: 4 additions & 0 deletions src/connectors/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion src/connectors/mariadb/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -159,6 +162,7 @@ export class MariaDBConnector implements Connector {
async connect(dsn: string, initScript?: string, config?: ConnectorConfig): Promise<void> {
try {
const connectionConfig = await this.dsnParser.parse(dsn, config);
this.sessionStatements = config?.readonlySessionSql ? parseReadonlySessionSQL(config.readonlySessionSql, "mariadb") : [];

this.pool = mariadb.createPool(connectionConfig);

Expand Down Expand Up @@ -703,7 +707,8 @@ export class MariaDBConnector implements Connector {
}

return { resultSets };
}
},
this.sessionStatements
);
} finally {
// Always release the connection back to the pool
Expand Down
7 changes: 6 additions & 1 deletion src/connectors/mysql/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -177,6 +180,7 @@ export class MySQLConnector implements Connector {
async connect(dsn: string, initScript?: string, config?: ConnectorConfig): Promise<void> {
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
Expand Down Expand Up @@ -723,7 +727,8 @@ export class MySQLConnector implements Connector {
}

return { resultSets };
}
},
this.sessionStatements
);
} catch (error) {
if (isClientSideTimeout(error)) {
Expand Down
1 change: 1 addition & 0 deletions src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading