Skip to content
Merged
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
7 changes: 7 additions & 0 deletions dbhub.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ dsn = "postgres://postgres:postgres@localhost:5432/myapp"
# password = "p@ss:word/123"
# sslmode = "disable" # Optional: "disable" or "require"

# Local PostgreSQL with custom schema (non-public schema)
# [[sources]]
# id = "local_pg_custom_schema"
# dsn = "postgres://postgres:postgres@localhost:5432/myapp"
# search_path = "myschema,public" # Comma-separated list of schemas; first is the default for discovery

# Development PostgreSQL (shared dev server)
# [[sources]]
# id = "dev_pg"
Expand Down Expand Up @@ -256,6 +262,7 @@ dsn = "postgres://postgres:postgres@localhost:5432/myapp"
# description = "..." # Optional: human-readable description
# dsn = "..." # Connection string (or use individual params below)
# lazy = true # Defer connection until first query (default: false)
# search_path = "schema1,public" # PostgreSQL only: comma-separated schemas
#
# DSN Formats:
# PostgreSQL: postgres://user:pass@host:5432/database?sslmode=require
Expand Down
22 changes: 22 additions & 0 deletions docs/config/toml.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,27 @@ Sources define database connections. Each source represents a database that DBHu
```
</ParamField>

### search_path

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

**PostgreSQL only.**

```toml
[[sources]]
id = "production"
dsn = "postgres://user:pass@localhost:5432/mydb"
search_path = "myschema,public"
```

Without `search_path`, DBHub defaults to the `public` schema for all schema discovery operations.

<Note>
This option is **only available via TOML**. It is not supported as a DSN query parameter.
</Note>
</ParamField>

### SSH Tunnel Options

<ParamField path="ssh_*" type="group">
Expand Down Expand Up @@ -575,6 +596,7 @@ default = 10
| `instanceName` | string | ❌ | SQL Server named instance |
| `authentication` | string | ❌ | SQL Server auth method |
| `domain` | string | ❌ | Windows domain (NTLM) |
| `search_path` | string | ❌ | PostgreSQL schema search path (comma-separated) |
| `ssh_host` | string | ❌ | SSH server hostname |
| `ssh_port` | number | ❌ | SSH server port (default: 22) |
| `ssh_user` | string | ❌ | SSH username |
Expand Down
71 changes: 71 additions & 0 deletions src/config/__tests__/toml-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,77 @@ query_timeout = 120
expect(result?.sources[0].query_timeout).toBe(120);
});
});

describe('search_path validation', () => {
it('should accept search_path for PostgreSQL source', () => {
const tomlContent = `
[[sources]]
id = "test_db"
dsn = "postgres://user:pass@localhost:5432/testdb"
search_path = "myschema,public"
`;
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);

const result = loadTomlConfig();

expect(result).toBeTruthy();
expect(result?.sources[0].search_path).toBe('myschema,public');
});

it('should accept single schema in search_path', () => {
const tomlContent = `
[[sources]]
id = "test_db"
dsn = "postgres://user:pass@localhost:5432/testdb"
search_path = "myschema"
`;
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);

const result = loadTomlConfig();

expect(result).toBeTruthy();
expect(result?.sources[0].search_path).toBe('myschema');
});

it('should throw error when search_path is used with non-PostgreSQL source', () => {
const tomlContent = `
[[sources]]
id = "test_db"
dsn = "mysql://user:pass@localhost:3306/testdb"
search_path = "myschema"
`;
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);

expect(() => loadTomlConfig()).toThrow('only supported for PostgreSQL');
});

it('should throw error when search_path is used with SQLite', () => {
const tomlContent = `
[[sources]]
id = "test_db"
type = "sqlite"
database = "/path/to/database.db"
search_path = "myschema"
`;
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);

expect(() => loadTomlConfig()).toThrow('only supported for PostgreSQL');
});

it('should work without search_path (optional field)', () => {
const tomlContent = `
[[sources]]
id = "test_db"
dsn = "postgres://user:pass@localhost:5432/testdb"
`;
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);

const result = loadTomlConfig();

expect(result).toBeTruthy();
expect(result?.sources[0].search_path).toBeUndefined();
});
});
});

describe('buildDSNFromSource', () => {
Expand Down
16 changes: 16 additions & 0 deletions src/config/toml-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,22 @@ function validateSourceConfig(source: SourceConfig, configPath: string): void {
}
}

// Validate search_path (PostgreSQL only)
if (source.search_path !== undefined) {
if (source.type !== "postgres") {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has 'search_path' but it is only supported for PostgreSQL sources.`
);
}
if (typeof source.search_path !== "string" || source.search_path.trim().length === 0) {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has invalid search_path. ` +
`Must be a non-empty string of comma-separated schema names (e.g., "myschema,public").`
);
}

Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

search_path validation only checks that the string is non-empty after trimming. Inputs like "," or " , , " will pass validation but result in an empty schema list after splitting/trimming in the Postgres connector (silently falling back to public). Consider validating that at least one non-empty schema name exists after splitting on commas.

Suggested change
const searchPathSchemas = source.search_path
.split(",")
.map((schema) => schema.trim())
.filter((schema) => schema.length > 0);
if (searchPathSchemas.length === 0) {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has invalid search_path. ` +
`Must contain at least one non-empty schema name (e.g., "myschema,public").`
);
}

Copilot uses AI. Check for mistakes.
}

// Reject readonly and max_rows at source level (they should be set on tools instead)
if ((source as any).readonly !== undefined) {
throw new Error(
Expand Down
68 changes: 67 additions & 1 deletion src/connectors/__tests__/postgres.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,12 +142,25 @@ class PostgreSQLIntegrationTest extends IntegrationTestBase<PostgreSQLTestContai
`, {});

await connector.executeSQL(`
INSERT INTO test_schema.products (name, price) VALUES
INSERT INTO test_schema.products (name, price) VALUES
('Widget A', 19.99),
('Widget B', 29.99)
ON CONFLICT DO NOTHING
`, {});

// Create schema with special name (spaces, uppercase) for search_path quoting tests
await connector.executeSQL('CREATE SCHEMA IF NOT EXISTS "My Schema"', {});
await connector.executeSQL(`
CREATE TABLE IF NOT EXISTS "My Schema".items (
id SERIAL PRIMARY KEY,
label VARCHAR(100) NOT NULL
)
`, {});
await connector.executeSQL(`
INSERT INTO "My Schema".items (label) VALUES ('Item A'), ('Item B')
ON CONFLICT DO NOTHING
`, {});

// Create test stored procedures using SQL language to avoid dollar quoting
await connector.executeSQL(`
CREATE OR REPLACE FUNCTION get_user_count()
Expand Down Expand Up @@ -565,4 +578,57 @@ describe('PostgreSQL Connector Integration Tests', () => {
}
});
});

describe('Search Path Configuration Tests', () => {
it('should use first schema in search_path as default for discovery', async () => {
const connector = new PostgresConnector();

try {
await connector.connect(postgresTest.connectionString, undefined, {
searchPath: 'test_schema,public',
});

// Session search_path should be set
const result = await connector.executeSQL('SHOW search_path', {});
expect(result.rows[0].search_path).toContain('test_schema');

// Discovery defaults to test_schema (first in search_path)
const tables = await connector.getTables();
expect(tables).toContain('products');
expect(tables).not.toContain('users');

// Explicit schema override still works
const publicTables = await connector.getTables('public');
expect(publicTables).toContain('users');

// SQL resolves unqualified names via search_path
const sqlResult = await connector.executeSQL('SELECT * FROM products', {});
expect(sqlResult.rows.length).toBeGreaterThan(0);
} finally {
await connector.disconnect();
}
});

it('should handle schema names with spaces and special characters', async () => {
const connector = new PostgresConnector();

try {
await connector.connect(postgresTest.connectionString, undefined, {
searchPath: 'My Schema,public',
});

// Discovery defaults to "My Schema"
const tables = await connector.getTables();
expect(tables).toContain('items');
expect(tables).not.toContain('users');

// SQL resolves unqualified names via quoted search_path
const result = await connector.executeSQL('SELECT * FROM items', {});
expect(result.rows.length).toBeGreaterThan(0);
expect(result.rows[0]).toHaveProperty('label');
} finally {
await connector.disconnect();
}
});
});
});
7 changes: 6 additions & 1 deletion src/connectors/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,12 @@ export interface ConnectorConfig {
* Note: Application-level validation is done via ExecuteOptions.readonly
*/
readonly?: boolean;
// Future database-specific options can be added here as optional fields
/**
* PostgreSQL search_path setting.
* Comma-separated list of schema names (e.g., "myschema,public").
* Sets the session search_path and uses the first schema as default for discovery methods.
*/
searchPath?: 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 @@ -219,6 +219,10 @@ export class ConnectorManager {
if (source.readonly !== undefined) {
config.readonly = source.readonly;
}
// Pass search_path for PostgreSQL
if (source.search_path) {
config.searchPath = source.search_path;
}

// Connect to the database with config and optional init script
await connector.connect(actualDSN, source.init_script, config);
Expand Down
50 changes: 33 additions & 17 deletions src/connectors/postgres/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { SafeURL } from "../../utils/safe-url.js";
import { obfuscateDSNPassword } from "../../utils/dsn-obfuscate.js";
import { SQLRowLimiter } from "../../utils/sql-row-limiter.js";
import { quoteIdentifier } from "../../utils/identifier-quoter.js";

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

// Default schema for discovery methods (first entry from search_path, or "public")
private defaultSchema: string = "public";

getId(): string {
return this.sourceId;
}
Expand All @@ -119,6 +123,9 @@ export class PostgresConnector implements Connector {
}

async connect(dsn: string, initScript?: string, config?: ConnectorConfig): Promise<void> {
// Reset default schema in case this connector instance is re-used across connect() calls
this.defaultSchema = "public";

try {
const poolConfig = await this.dsnParser.parse(dsn, config);

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

// Set search_path if configured
if (config?.searchPath) {
const schemas = config.searchPath.split(',').map(s => s.trim()).filter(s => s.length > 0);
if (schemas.length > 0) {
this.defaultSchema = schemas[0];
const quotedSchemas = schemas.map(s => quoteIdentifier(s, 'postgres'));
// Escape backslashes then spaces for PostgreSQL options string parser
const optionsValue = quotedSchemas.join(',').replace(/\\/g, '\\\\').replace(/ /g, '\\ ');
Comment on lines +142 to +144
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In connect(), the searchPath schemas are run through quoteIdentifier() unconditionally. For PostgreSQL this always adds double quotes, which changes identifier semantics (e.g., MySchema becomes case-sensitive and no longer resolves to myschema, and special tokens like $user become the literal schema name "$user"). Consider only quoting when necessary (or validating against a safe unquoted identifier regex and leaving it unquoted), and explicitly allow $user to pass through without quoting.

Suggested change
const quotedSchemas = schemas.map(s => quoteIdentifier(s, 'postgres'));
// Escape backslashes then spaces for PostgreSQL options string parser
const optionsValue = quotedSchemas.join(',').replace(/\\/g, '\\\\').replace(/ /g, '\\ ');
// For PostgreSQL, avoid unconditionally quoting identifiers:
// - Allow special token $user to pass through unquoted.
// - Leave simple safe identifiers unquoted.
// - Quote everything else to preserve safety.
const safeUnquotedIdentifier = /^[a-zA-Z_][a-zA-Z0-9_$]*$/;
const formattedSchemas = schemas.map((s) => {
if (s === "$user") {
return s;
}
if (safeUnquotedIdentifier.test(s)) {
return s;
}
return quoteIdentifier(s, "postgres");
});
// Escape backslashes then spaces for PostgreSQL options string parser
const optionsValue = formattedSchemas.join(',').replace(/\\/g, '\\\\').replace(/ /g, '\\ ');

Copilot uses AI. Check for mistakes.
poolConfig.options = (poolConfig.options || '') + ` -c search_path=${optionsValue}`;
}
}

this.pool = new Pool(poolConfig);

// Test the connection
Expand Down Expand Up @@ -172,9 +191,8 @@ export class PostgresConnector implements Connector {

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

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

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

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

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

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

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

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

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

const result = await client.query(
`
Expand Down Expand Up @@ -346,8 +362,8 @@ export class PostgresConnector implements Connector {

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

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

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

// Get stored procedure details from PostgreSQL
const result = await client.query(
Expand Down
Loading