diff --git a/src/config-parser.ts b/src/config-parser.ts index 70f7fa7..2ba99ae 100644 --- a/src/config-parser.ts +++ b/src/config-parser.ts @@ -5,6 +5,7 @@ import { parseString } from 'xml2js'; import { promisify } from 'util'; import crypto from 'crypto'; import { DatabaseConnection, WorkspaceConfig } from './types.js'; +import { parseConnectionId } from './utils.js'; const parseXML = promisify(parseString); @@ -273,10 +274,23 @@ export class WorkspaceConfigParser { async getConnection(connectionId: string): Promise { try { + const { baseId, databaseOverride } = parseConnectionId(connectionId); const connections = await this.parseConnections(); - return ( - connections.find((conn) => conn.id === connectionId || conn.name === connectionId) || null - ); + const match = connections.find((conn) => conn.id === baseId || conn.name === baseId) || null; + + if (!match || !databaseOverride) { + return match; + } + + // Database-override syntax "/": clone the matched connection + // with the requested database swapped in. The synthetic id ensures pool + // caches keyed on connection.id stay separated per target database. + return { + ...match, + id: `${match.id}/${databaseOverride}`, + database: databaseOverride, + properties: { ...(match.properties || {}), database: databaseOverride }, + }; } catch (error) { if (this.config.debug) { console.error(`Failed to get connection ${connectionId}: ${error}`); diff --git a/src/utils.ts b/src/utils.ts index a020183..6937b61 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -95,15 +95,20 @@ export function enforceReadOnly(query: string): string | null { } /** - * Sanitize connection ID to prevent injection + * Sanitize connection ID to prevent injection. + * + * Allows an optional database-override suffix using the syntax + * `/`. The forward slash is preserved so that + * `parseConnectionId` (and downstream lookup) can split it back out. */ export function sanitizeConnectionId(connectionId: string): string { if (!connectionId || typeof connectionId !== 'string') { throw new Error('Connection ID must be a non-empty string'); } - // Remove potentially dangerous characters - const sanitized = connectionId.replace(/[^a-zA-Z0-9_\-.]/g, ''); + // Remove potentially dangerous characters; '/' is allowed for the database + // override syntax "/" and is split out by parseConnectionId. + const sanitized = connectionId.replace(/[^a-zA-Z0-9_\-./]/g, ''); if (sanitized.length === 0) { throw new Error('Connection ID contains no valid characters'); @@ -112,6 +117,32 @@ export function sanitizeConnectionId(connectionId: string): string { return sanitized; } +/** + * Split an incoming connection identifier into its base ID/name and an optional + * database-override component. Returns `databaseOverride: null` when no + * override is present. + * + * Examples: + * "my-conn" -> { baseId: "my-conn", databaseOverride: null } + * "my-conn/analytics" -> { baseId: "my-conn", databaseOverride: "analytics" } + * "postgres-jdbc-abc/orders" -> { baseId: "postgres-jdbc-abc", databaseOverride: "orders" } + */ +export function parseConnectionId(connectionId: string): { + baseId: string; + databaseOverride: string | null; +} { + const slashIdx = connectionId.indexOf('/'); + if (slashIdx < 0) { + return { baseId: connectionId, databaseOverride: null }; + } + const baseId = connectionId.slice(0, slashIdx); + const databaseOverride = connectionId.slice(slashIdx + 1); + if (!baseId || !databaseOverride) { + return { baseId: connectionId, databaseOverride: null }; + } + return { baseId, databaseOverride }; +} + /** * Sanitize SQL identifier (table name, schema name, column name) * Escapes single quotes and validates the identifier diff --git a/tests/config-parser.test.ts b/tests/config-parser.test.ts index 3b9def4..4f78060 100644 --- a/tests/config-parser.test.ts +++ b/tests/config-parser.test.ts @@ -37,4 +37,72 @@ describe('WorkspaceConfigParser', () => { expect(connections).toEqual([]); }); }); + + describe('getConnection database-override syntax', () => { + const baseConnection = { + id: 'conn-1', + name: 'my-conn', + driver: 'postgres-jdbc', + url: 'jdbc:postgresql://host:5432/postgres', + host: 'host', + port: 5432, + database: 'postgres', + user: 'app', + properties: { user: 'app', host: 'host', database: 'postgres', sslmode: 'require' }, + }; + + it('should look up by base id when no slash is present', async () => { + const { WorkspaceConfigParser } = await import('../src/config-parser.js'); + const parser = new WorkspaceConfigParser({}); + vi.spyOn(parser, 'parseConnections').mockResolvedValue([baseConnection]); + + const result = await parser.getConnection('conn-1'); + expect(result).toEqual(baseConnection); + }); + + it('should look up by name when no slash is present', async () => { + const { WorkspaceConfigParser } = await import('../src/config-parser.js'); + const parser = new WorkspaceConfigParser({}); + vi.spyOn(parser, 'parseConnections').mockResolvedValue([baseConnection]); + + const result = await parser.getConnection('my-conn'); + expect(result).toEqual(baseConnection); + }); + + it('should override database when "/" syntax is used', async () => { + const { WorkspaceConfigParser } = await import('../src/config-parser.js'); + const parser = new WorkspaceConfigParser({}); + vi.spyOn(parser, 'parseConnections').mockResolvedValue([baseConnection]); + + const result = await parser.getConnection('conn-1/analytics'); + expect(result).not.toBeNull(); + expect(result!.id).toBe('conn-1/analytics'); + expect(result!.database).toBe('analytics'); + expect(result!.properties?.database).toBe('analytics'); + // Other fields remain intact + expect(result!.host).toBe('host'); + expect(result!.user).toBe('app'); + expect(result!.properties?.sslmode).toBe('require'); + }); + + it('should not mutate the cached connection when overriding', async () => { + const { WorkspaceConfigParser } = await import('../src/config-parser.js'); + const parser = new WorkspaceConfigParser({}); + vi.spyOn(parser, 'parseConnections').mockResolvedValue([baseConnection]); + + await parser.getConnection('conn-1/analytics'); + expect(baseConnection.id).toBe('conn-1'); + expect(baseConnection.database).toBe('postgres'); + expect(baseConnection.properties.database).toBe('postgres'); + }); + + it('should return null when the base id does not match', async () => { + const { WorkspaceConfigParser } = await import('../src/config-parser.js'); + const parser = new WorkspaceConfigParser({}); + vi.spyOn(parser, 'parseConnections').mockResolvedValue([baseConnection]); + + const result = await parser.getConnection('does-not-exist/analytics'); + expect(result).toBeNull(); + }); + }); }); diff --git a/tests/utils.test.ts b/tests/utils.test.ts index 81c8250..bf32b00 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -5,6 +5,7 @@ import { sanitizeConnectionId, sanitizeIdentifier, getTestQuery, + parseConnectionId, } from '../src/utils.js'; describe('validateQuery', () => { @@ -130,6 +131,45 @@ describe('sanitizeConnectionId', () => { expect(sanitizeConnectionId('conn;DROP TABLE')).toBe('connDROPTABLE'); expect(sanitizeConnectionId('test