Skip to content

Commit 8a44ac4

Browse files
tianzhouclaude
andauthored
Add search_path support in TOML config for PostgreSQL non-public schemas (#250)
Closes #243 Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a30c3b1 commit 8a44ac4

File tree

9 files changed

+227
-19
lines changed

9 files changed

+227
-19
lines changed

dbhub.toml.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ dsn = "postgres://postgres:postgres@localhost:5432/myapp"
3232
# password = "p@ss:word/123"
3333
# sslmode = "disable" # Optional: "disable" or "require"
3434

35+
# Local PostgreSQL with custom schema (non-public schema)
36+
# [[sources]]
37+
# id = "local_pg_custom_schema"
38+
# dsn = "postgres://postgres:postgres@localhost:5432/myapp"
39+
# search_path = "myschema,public" # Comma-separated list of schemas; first is the default for discovery
40+
3541
# Development PostgreSQL (shared dev server)
3642
# [[sources]]
3743
# id = "dev_pg"
@@ -256,6 +262,7 @@ dsn = "postgres://postgres:postgres@localhost:5432/myapp"
256262
# description = "..." # Optional: human-readable description
257263
# dsn = "..." # Connection string (or use individual params below)
258264
# lazy = true # Defer connection until first query (default: false)
265+
# search_path = "schema1,public" # PostgreSQL only: comma-separated schemas
259266
#
260267
# DSN Formats:
261268
# PostgreSQL: postgres://user:pass@host:5432/database?sslmode=require

docs/config/toml.mdx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,27 @@ Sources define database connections. Each source represents a database that DBHu
281281
```
282282
</ParamField>
283283

284+
### search_path
285+
286+
<ParamField path="search_path" type="string">
287+
Comma-separated list of PostgreSQL schema names to set as the session `search_path`. The first schema in the list becomes the default schema for all discovery methods (`getTables`, `getTableSchema`, etc.).
288+
289+
**PostgreSQL only.**
290+
291+
```toml
292+
[[sources]]
293+
id = "production"
294+
dsn = "postgres://user:pass@localhost:5432/mydb"
295+
search_path = "myschema,public"
296+
```
297+
298+
Without `search_path`, DBHub defaults to the `public` schema for all schema discovery operations.
299+
300+
<Note>
301+
This option is **only available via TOML**. It is not supported as a DSN query parameter.
302+
</Note>
303+
</ParamField>
304+
284305
### SSH Tunnel Options
285306

286307
<ParamField path="ssh_*" type="group">
@@ -575,6 +596,7 @@ default = 10
575596
| `instanceName` | string || SQL Server named instance |
576597
| `authentication` | string || SQL Server auth method |
577598
| `domain` | string || Windows domain (NTLM) |
599+
| `search_path` | string || PostgreSQL schema search path (comma-separated) |
578600
| `ssh_host` | string || SSH server hostname |
579601
| `ssh_port` | number || SSH server port (default: 22) |
580602
| `ssh_user` | string || SSH username |

src/config/__tests__/toml-loader.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -737,6 +737,77 @@ query_timeout = 120
737737
expect(result?.sources[0].query_timeout).toBe(120);
738738
});
739739
});
740+
741+
describe('search_path validation', () => {
742+
it('should accept search_path for PostgreSQL source', () => {
743+
const tomlContent = `
744+
[[sources]]
745+
id = "test_db"
746+
dsn = "postgres://user:pass@localhost:5432/testdb"
747+
search_path = "myschema,public"
748+
`;
749+
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);
750+
751+
const result = loadTomlConfig();
752+
753+
expect(result).toBeTruthy();
754+
expect(result?.sources[0].search_path).toBe('myschema,public');
755+
});
756+
757+
it('should accept single schema in search_path', () => {
758+
const tomlContent = `
759+
[[sources]]
760+
id = "test_db"
761+
dsn = "postgres://user:pass@localhost:5432/testdb"
762+
search_path = "myschema"
763+
`;
764+
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);
765+
766+
const result = loadTomlConfig();
767+
768+
expect(result).toBeTruthy();
769+
expect(result?.sources[0].search_path).toBe('myschema');
770+
});
771+
772+
it('should throw error when search_path is used with non-PostgreSQL source', () => {
773+
const tomlContent = `
774+
[[sources]]
775+
id = "test_db"
776+
dsn = "mysql://user:pass@localhost:3306/testdb"
777+
search_path = "myschema"
778+
`;
779+
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);
780+
781+
expect(() => loadTomlConfig()).toThrow('only supported for PostgreSQL');
782+
});
783+
784+
it('should throw error when search_path is used with SQLite', () => {
785+
const tomlContent = `
786+
[[sources]]
787+
id = "test_db"
788+
type = "sqlite"
789+
database = "/path/to/database.db"
790+
search_path = "myschema"
791+
`;
792+
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);
793+
794+
expect(() => loadTomlConfig()).toThrow('only supported for PostgreSQL');
795+
});
796+
797+
it('should work without search_path (optional field)', () => {
798+
const tomlContent = `
799+
[[sources]]
800+
id = "test_db"
801+
dsn = "postgres://user:pass@localhost:5432/testdb"
802+
`;
803+
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);
804+
805+
const result = loadTomlConfig();
806+
807+
expect(result).toBeTruthy();
808+
expect(result?.sources[0].search_path).toBeUndefined();
809+
});
810+
});
740811
});
741812

742813
describe('buildDSNFromSource', () => {

src/config/toml-loader.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,22 @@ function validateSourceConfig(source: SourceConfig, configPath: string): void {
349349
}
350350
}
351351

352+
// Validate search_path (PostgreSQL only)
353+
if (source.search_path !== undefined) {
354+
if (source.type !== "postgres") {
355+
throw new Error(
356+
`Configuration file ${configPath}: source '${source.id}' has 'search_path' but it is only supported for PostgreSQL sources.`
357+
);
358+
}
359+
if (typeof source.search_path !== "string" || source.search_path.trim().length === 0) {
360+
throw new Error(
361+
`Configuration file ${configPath}: source '${source.id}' has invalid search_path. ` +
362+
`Must be a non-empty string of comma-separated schema names (e.g., "myschema,public").`
363+
);
364+
}
365+
366+
}
367+
352368
// Reject readonly and max_rows at source level (they should be set on tools instead)
353369
if ((source as any).readonly !== undefined) {
354370
throw new Error(

src/connectors/__tests__/postgres.integration.test.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,12 +142,25 @@ class PostgreSQLIntegrationTest extends IntegrationTestBase<PostgreSQLTestContai
142142
`, {});
143143

144144
await connector.executeSQL(`
145-
INSERT INTO test_schema.products (name, price) VALUES
145+
INSERT INTO test_schema.products (name, price) VALUES
146146
('Widget A', 19.99),
147147
('Widget B', 29.99)
148148
ON CONFLICT DO NOTHING
149149
`, {});
150150

151+
// Create schema with special name (spaces, uppercase) for search_path quoting tests
152+
await connector.executeSQL('CREATE SCHEMA IF NOT EXISTS "My Schema"', {});
153+
await connector.executeSQL(`
154+
CREATE TABLE IF NOT EXISTS "My Schema".items (
155+
id SERIAL PRIMARY KEY,
156+
label VARCHAR(100) NOT NULL
157+
)
158+
`, {});
159+
await connector.executeSQL(`
160+
INSERT INTO "My Schema".items (label) VALUES ('Item A'), ('Item B')
161+
ON CONFLICT DO NOTHING
162+
`, {});
163+
151164
// Create test stored procedures using SQL language to avoid dollar quoting
152165
await connector.executeSQL(`
153166
CREATE OR REPLACE FUNCTION get_user_count()
@@ -565,4 +578,57 @@ describe('PostgreSQL Connector Integration Tests', () => {
565578
}
566579
});
567580
});
581+
582+
describe('Search Path Configuration Tests', () => {
583+
it('should use first schema in search_path as default for discovery', async () => {
584+
const connector = new PostgresConnector();
585+
586+
try {
587+
await connector.connect(postgresTest.connectionString, undefined, {
588+
searchPath: 'test_schema,public',
589+
});
590+
591+
// Session search_path should be set
592+
const result = await connector.executeSQL('SHOW search_path', {});
593+
expect(result.rows[0].search_path).toContain('test_schema');
594+
595+
// Discovery defaults to test_schema (first in search_path)
596+
const tables = await connector.getTables();
597+
expect(tables).toContain('products');
598+
expect(tables).not.toContain('users');
599+
600+
// Explicit schema override still works
601+
const publicTables = await connector.getTables('public');
602+
expect(publicTables).toContain('users');
603+
604+
// SQL resolves unqualified names via search_path
605+
const sqlResult = await connector.executeSQL('SELECT * FROM products', {});
606+
expect(sqlResult.rows.length).toBeGreaterThan(0);
607+
} finally {
608+
await connector.disconnect();
609+
}
610+
});
611+
612+
it('should handle schema names with spaces and special characters', async () => {
613+
const connector = new PostgresConnector();
614+
615+
try {
616+
await connector.connect(postgresTest.connectionString, undefined, {
617+
searchPath: 'My Schema,public',
618+
});
619+
620+
// Discovery defaults to "My Schema"
621+
const tables = await connector.getTables();
622+
expect(tables).toContain('items');
623+
expect(tables).not.toContain('users');
624+
625+
// SQL resolves unqualified names via quoted search_path
626+
const result = await connector.executeSQL('SELECT * FROM items', {});
627+
expect(result.rows.length).toBeGreaterThan(0);
628+
expect(result.rows[0]).toHaveProperty('label');
629+
} finally {
630+
await connector.disconnect();
631+
}
632+
});
633+
});
568634
});

src/connectors/interface.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,12 @@ export interface ConnectorConfig {
6666
* Note: Application-level validation is done via ExecuteOptions.readonly
6767
*/
6868
readonly?: boolean;
69-
// Future database-specific options can be added here as optional fields
69+
/**
70+
* PostgreSQL search_path setting.
71+
* Comma-separated list of schema names (e.g., "myschema,public").
72+
* Sets the session search_path and uses the first schema as default for discovery methods.
73+
*/
74+
searchPath?: string;
7075
}
7176

7277
/**

src/connectors/manager.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,10 @@ export class ConnectorManager {
219219
if (source.readonly !== undefined) {
220220
config.readonly = source.readonly;
221221
}
222+
// Pass search_path for PostgreSQL
223+
if (source.search_path) {
224+
config.searchPath = source.search_path;
225+
}
222226

223227
// Connect to the database with config and optional init script
224228
await connector.connect(actualDSN, source.init_script, config);

src/connectors/postgres/index.ts

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
import { SafeURL } from "../../utils/safe-url.js";
1616
import { obfuscateDSNPassword } from "../../utils/dsn-obfuscate.js";
1717
import { SQLRowLimiter } from "../../utils/sql-row-limiter.js";
18+
import { quoteIdentifier } from "../../utils/identifier-quoter.js";
1819

1920
/**
2021
* PostgreSQL DSN Parser
@@ -110,6 +111,9 @@ export class PostgresConnector implements Connector {
110111
// Source ID is set by ConnectorManager after cloning
111112
private sourceId: string = "default";
112113

114+
// Default schema for discovery methods (first entry from search_path, or "public")
115+
private defaultSchema: string = "public";
116+
113117
getId(): string {
114118
return this.sourceId;
115119
}
@@ -119,6 +123,9 @@ export class PostgresConnector implements Connector {
119123
}
120124

121125
async connect(dsn: string, initScript?: string, config?: ConnectorConfig): Promise<void> {
126+
// Reset default schema in case this connector instance is re-used across connect() calls
127+
this.defaultSchema = "public";
128+
122129
try {
123130
const poolConfig = await this.dsnParser.parse(dsn, config);
124131

@@ -127,6 +134,18 @@ export class PostgresConnector implements Connector {
127134
poolConfig.options = (poolConfig.options || '') + ' -c default_transaction_read_only=on';
128135
}
129136

137+
// Set search_path if configured
138+
if (config?.searchPath) {
139+
const schemas = config.searchPath.split(',').map(s => s.trim()).filter(s => s.length > 0);
140+
if (schemas.length > 0) {
141+
this.defaultSchema = schemas[0];
142+
const quotedSchemas = schemas.map(s => quoteIdentifier(s, 'postgres'));
143+
// Escape backslashes then spaces for PostgreSQL options string parser
144+
const optionsValue = quotedSchemas.join(',').replace(/\\/g, '\\\\').replace(/ /g, '\\ ');
145+
poolConfig.options = (poolConfig.options || '') + ` -c search_path=${optionsValue}`;
146+
}
147+
}
148+
130149
this.pool = new Pool(poolConfig);
131150

132151
// Test the connection
@@ -172,9 +191,8 @@ export class PostgresConnector implements Connector {
172191

173192
const client = await this.pool.connect();
174193
try {
175-
// In PostgreSQL, use 'public' as the default schema if none specified
176-
// 'public' is the standard default schema in PostgreSQL databases
177-
const schemaToUse = schema || "public";
194+
// Use the configured default schema (from search_path config, defaults to 'public')
195+
const schemaToUse = schema || this.defaultSchema;
178196

179197
const result = await client.query(
180198
`
@@ -199,8 +217,8 @@ export class PostgresConnector implements Connector {
199217

200218
const client = await this.pool.connect();
201219
try {
202-
// In PostgreSQL, use 'public' as the default schema if none specified
203-
const schemaToUse = schema || "public";
220+
// Use the configured default schema (from search_path config, defaults to 'public')
221+
const schemaToUse = schema || this.defaultSchema;
204222

205223
const result = await client.query(
206224
`
@@ -226,8 +244,8 @@ export class PostgresConnector implements Connector {
226244

227245
const client = await this.pool.connect();
228246
try {
229-
// In PostgreSQL, use 'public' as the default schema if none specified
230-
const schemaToUse = schema || "public";
247+
// Use the configured default schema (from search_path config, defaults to 'public')
248+
const schemaToUse = schema || this.defaultSchema;
231249

232250
// Query to get all indexes for the table
233251
const result = await client.query(
@@ -280,9 +298,8 @@ export class PostgresConnector implements Connector {
280298

281299
const client = await this.pool.connect();
282300
try {
283-
// In PostgreSQL, use 'public' as the default schema if none specified
284-
// Tables are created in the 'public' schema by default unless otherwise specified
285-
const schemaToUse = schema || "public";
301+
// Use the configured default schema (from search_path config, defaults to 'public')
302+
const schemaToUse = schema || this.defaultSchema;
286303

287304
// Get table columns
288305
const result = await client.query(
@@ -313,9 +330,8 @@ export class PostgresConnector implements Connector {
313330

314331
const client = await this.pool.connect();
315332
try {
316-
// In PostgreSQL, use 'public' as the default schema if none specified
317-
// Tables are created in the 'public' schema by default unless otherwise specified
318-
const schemaToUse = schema || "public";
333+
// Use the configured default schema (from search_path config, defaults to 'public')
334+
const schemaToUse = schema || this.defaultSchema;
319335

320336
const result = await client.query(
321337
`
@@ -346,8 +362,8 @@ export class PostgresConnector implements Connector {
346362

347363
const client = await this.pool.connect();
348364
try {
349-
// In PostgreSQL, use 'public' as the default schema if none specified
350-
const schemaToUse = schema || "public";
365+
// Use the configured default schema (from search_path config, defaults to 'public')
366+
const schemaToUse = schema || this.defaultSchema;
351367

352368
// Get stored procedures and functions from PostgreSQL
353369
const result = await client.query(
@@ -374,8 +390,8 @@ export class PostgresConnector implements Connector {
374390

375391
const client = await this.pool.connect();
376392
try {
377-
// In PostgreSQL, use 'public' as the default schema if none specified
378-
const schemaToUse = schema || "public";
393+
// Use the configured default schema (from search_path config, defaults to 'public')
394+
const schemaToUse = schema || this.defaultSchema;
379395

380396
// Get stored procedure details from PostgreSQL
381397
const result = await client.query(

0 commit comments

Comments
 (0)