Skip to content
Draft
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
11 changes: 9 additions & 2 deletions apps/cli/src/commands/drivers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,18 @@ export async function runDriversInstall(name: string): Promise<void> {
const cwd = webWorkspaceRoot();

// Prefer installing into the @foxschema/web package directory.
// ibm_db must run install scripts (--foreground-scripts) or clidriver never
// downloads and Windows connects fail with SQL1042C / missing native binding.
const npmArgs =
entry.pkg === 'ibm_db'
? ['install', 'ibm_db@4.0.1', '--foreground-scripts', '--prefix', cwd]
: ['install', entry.pkg, '--foreground-scripts', '--prefix', cwd];

await new Promise<void>((resolve, reject) => {
const child = spawn('npm', ['install', entry.pkg, '--prefix', cwd], {
const child = spawn('npm', npmArgs, {
stdio: 'inherit',
shell: process.platform === 'win32',
env: process.env,
env: { ...process.env, npm_config_ignore_scripts: '' },
});
child.on('error', reject);
child.on('exit', (code) => {
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/backend/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
DriverDetector,
buildConnectionString,
normalizeTableSchemas,
getProviderSettings,
type MigrationStep,
type ConnectionOptions,
type DbObjectType,
Expand Down Expand Up @@ -358,6 +359,13 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt
const { scope, ...ref } = req.body as ConnectionRef & { scope: DbObjectType[] };
try {
const { dialect, option, schema } = await resolveRef((req as AuthedRequest).userId, ref);
const settings = getProviderSettings(dialect);
if (settings.schemaRequired && !schema?.trim()) {
res.status(400).json({
error: `${settings.label} requires a schema. Load schemas for the connection, then pick one before browsing or editing tables.`,
});
return;
}
const { tables, warnings } = await loadScopedTables(dialect, option, schema, scope);
res.json(warnings.length ? { tables, warnings } : { tables });
} catch (error: unknown) {
Expand Down
11 changes: 5 additions & 6 deletions apps/web/src/frontend/lib/provider-settings.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { buildConnectionString as coreBuildConnectionString } from '@foxschema/core';

export type Dialect = 'postgres' | 'mysql' | 'mariadb' | 'db2' | 'sqlserver' | 'oracle' | 'sqlite' | 'redshift' | 'clickhouse' | 'azuresql' | 'cockroachdb' | 'yugabytedb' | 'tidb' | 'duckdb';

export interface ConnectionOptions {
Expand Down Expand Up @@ -87,13 +89,10 @@ const db2Settings: ProviderSettings = {
defaultPort: 50000,
defaultSchema: '',
schemaRequired: true,
// Delegate to core so pasted strings always get Authentication=SERVER /
// CurrentSchema (verbatim early-return caused SQL1042C / empty browse on Windows).
buildConnectionString(o) {
if (o.connectionString?.trim()) return o.connectionString.trim();
const host = o.host || 'localhost';
const port = o.port || this.defaultPort;
let cs = `DATABASE=${o.database || ''};HOSTNAME=${host};PORT=${port};PROTOCOL=TCPIP;UID=${o.username || ''};PWD=${o.password || ''};Authentication=SERVER;`;
if (o.schema) cs += `CurrentSchema=${o.schema.toUpperCase()};`;
return cs;
return coreBuildConnectionString('db2', o);
},
};

Expand Down
14 changes: 12 additions & 2 deletions packages/core/src/providers/db2/db2.adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createRequire } from 'node:module';
import { ConnectionOptions, DriverAdapter } from '../../interfaces/schema-provider.interface';
import { assertSafeIdentifier } from '../../cores/sql-identifier';
import { BoundedPoolCache, disposePoolEndOrClose } from '../../cores/pool-cache';
import { setupDb2ClientEnv } from './db2.env';
import { setupDb2ClientEnv, hasDb2Clidriver } from './db2.env';

const nodeRequire = createRequire(import.meta.url);

Expand All @@ -20,12 +20,22 @@ class Db2Adapter implements DriverAdapter {
private load(): any {
if (this.driver) return this.driver;
setupDb2ClientEnv(); // point at the bundled clidriver before native load
if (!hasDb2Clidriver()) {
throw new Error(
'ibm_db is installed but its CLI driver (clidriver) is missing. ' +
'On Windows this usually means scripts were skipped — reinstall with: ' +
'npm install ibm_db@4.0.1 --foreground-scripts (or: foxschema drivers install db2)'
);
}
try {
const mod = nodeRequire(this.packageName);
this.driver = mod.default ?? mod;
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e);
throw new Error(`Database driver "${this.packageName}" is not installed for db2. Install it with: npm install ${this.packageName} — ${message}`);
throw new Error(
`Database driver "${this.packageName}" is not installed for db2. ` +
`Install it with: npm install ${this.packageName} --foreground-scripts — ${message}`
);
}
return this.driver;
}
Expand Down
71 changes: 71 additions & 0 deletions packages/core/src/providers/db2/db2.connection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import {
buildDb2ConnectionString,
odbcEscape,
parseDb2SemicolonMap,
} from './db2.connection';

describe('buildDb2ConnectionString', () => {
it('always injects Authentication=SERVER', () => {
const cs = buildDb2ConnectionString({
host: 'db.example',
port: 50000,
database: 'SAMPLE',
username: 'db2inst1',
password: 'secret',
});
expect(cs).toContain('Authentication=SERVER');
expect(cs).toContain('DATABASE=SAMPLE');
expect(cs).toContain('HOSTNAME=db.example');
expect(cs).toContain('UID=db2inst1');
expect(cs).toContain('PWD=secret');
});

it('normalizes a pasted semicolon string and still forces Authentication=SERVER', () => {
const cs = buildDb2ConnectionString({
connectionString:
'DATABASE=SAMPLE;HOSTNAME=h;PORT=50000;PROTOCOL=TCPIP;UID=u;PWD=p;',
});
expect(cs).toMatch(/Authentication=SERVER/);
expect(cs).toContain('DATABASE=SAMPLE');
expect(cs).toContain('UID=u');
});

it('brace-escapes passwords that contain semicolons so they round-trip', () => {
const password = 'a;b}c';
const cs = buildDb2ConnectionString({
host: 'h',
database: 'D',
username: 'u',
password,
});
expect(cs).toContain(`PWD=${odbcEscape(password)}`);
const map = parseDb2SemicolonMap(cs);
expect(map.get('PWD')).toBe(password);
});

it('parses braced PWD from a pasted string', () => {
const cs = buildDb2ConnectionString({
connectionString: 'DATABASE=D;HOSTNAME=h;PORT=50000;UID=u;PWD={p;a;s;s};',
});
expect(parseDb2SemicolonMap(cs).get('PWD')).toBe('p;a;s;s');
});

it('adds CurrentSchema when schema is provided', () => {
const cs = buildDb2ConnectionString(
{ host: 'h', database: 'D', username: 'u', password: 'p', schema: 'myschema' },
'myschema'
);
expect(cs).toContain('CurrentSchema=MYSCHEMA');
});
});

describe('odbcEscape', () => {
it('leaves plain values alone', () => {
expect(odbcEscape('plain')).toBe('plain');
});

it('escapes braces inside braced values', () => {
expect(odbcEscape('a}b')).toBe('{a}}b}');
});
});
81 changes: 66 additions & 15 deletions packages/core/src/providers/db2/db2.connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,21 @@ import type { ConnectionOptions } from '../../interfaces/schema-provider.interfa

/**
* Build a DB2 CLI connection string (semicolon-delimited key=value pairs).
* ibm_db2 does not accept db2:// URLs — those must be converted first.
* ibm_db does not accept db2:// URLs — those must be converted first.
*
* Authentication=SERVER is required by IBM to avoid SQL1042C on many setups.
* Authentication=SERVER is required by IBM to avoid SQL1042C on many setups
* (especially Windows when GSKit / system CLI libraries pollute PATH).
*/
export function buildDb2ConnectionString(options: ConnectionOptions, schema?: string): string {
const parsed = parseDb2ConnectionInput(options.connectionString, options);

const parts: string[] = [
`DATABASE=${parsed.database}`,
`HOSTNAME=${parsed.host}`,
`DATABASE=${odbcEscape(parsed.database)}`,
`HOSTNAME=${odbcEscape(parsed.host)}`,
`PORT=${parsed.port}`,
'PROTOCOL=TCPIP',
`UID=${parsed.username}`,
`PWD=${parsed.password}`,
`UID=${odbcEscape(parsed.username)}`,
`PWD=${odbcEscape(parsed.password)}`,
'Authentication=SERVER',
];

Expand All @@ -25,12 +26,18 @@ export function buildDb2ConnectionString(options: ConnectionOptions, schema?: st

const schemaName = schema?.trim() || options.schema?.trim();
if (schemaName) {
parts.push(`CurrentSchema=${schemaName.toUpperCase()}`);
parts.push(`CurrentSchema=${odbcEscape(schemaName.toUpperCase())}`);
}

return parts.join(';') + ';';
}

/** ODBC brace-escape so values containing `;` or `}` do not truncate the string. */
export function odbcEscape(value: string): string {
if (!/[;{}]/.test(value) && value === value.trim()) return value;
return `{${value.replace(/}/g, '}}')}}`;
}

function parseDb2ConnectionInput(
connectionString: string | undefined,
options: ConnectionOptions
Expand Down Expand Up @@ -81,6 +88,57 @@ function parseDb2Url(url: string): {
};
}

/**
* Parse `KEY=value;KEY2={value;with;semicolons};` with ODBC brace rules.
* A plain `split(';')` would truncate passwords that contain `;`.
*/
export function parseDb2SemicolonMap(connStr: string): Map<string, string> {
const map = new Map<string, string>();
let i = 0;
const s = connStr;

while (i < s.length) {
while (i < s.length && (s[i] === ';' || /\s/.test(s[i]!))) i++;
if (i >= s.length) break;

const eq = s.indexOf('=', i);
if (eq === -1) break;
const key = s.slice(i, eq).trim().toUpperCase();
let j = eq + 1;
while (j < s.length && s[j] === ' ') j++;

let value: string;
if (s[j] === '{') {
j++;
let out = '';
while (j < s.length) {
if (s[j] === '}' && s[j + 1] === '}') {
out += '}';
j += 2;
continue;
}
if (s[j] === '}') {
j++;
break;
}
out += s[j]!;
j++;
}
value = out;
while (j < s.length && s[j] !== ';') j++;
} else {
const semi = s.indexOf(';', j);
value = (semi === -1 ? s.slice(j) : s.slice(j, semi)).trim();
j = semi === -1 ? s.length : semi;
}

if (key) map.set(key, value);
i = j + 1;
}

return map;
}

function parseDb2Semicolon(
connStr: string,
fallback: ConnectionOptions
Expand All @@ -91,14 +149,7 @@ function parseDb2Semicolon(
username: string;
password: string;
} {
const map = new Map<string, string>();
for (const segment of connStr.split(';')) {
const eq = segment.indexOf('=');
if (eq === -1) continue;
const key = segment.slice(0, eq).trim().toUpperCase();
const value = segment.slice(eq + 1).trim();
if (key) map.set(key, value);
}
const map = parseDb2SemicolonMap(connStr);

return {
database: map.get('DATABASE') ?? fallback.database ?? '',
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/providers/db2/db2.env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { stripIbmGskPath } from './db2.env';

describe('stripIbmGskPath', () => {
it('removes IBM GSKit directories that conflict with bundled clidriver', () => {
const sep = process.platform === 'win32' ? ';' : ':';
const input = ['C:\\Tools', 'C:\\Program Files\\IBM\\gsk8\\lib', 'C:\\clidriver\\bin'].join(
sep
);
const out = stripIbmGskPath(input) ?? '';
expect(out.toLowerCase()).not.toContain('gsk8');
expect(out).toContain('Tools');
expect(out).toContain('clidriver');
});

it('returns undefined for undefined input', () => {
expect(stripIbmGskPath(undefined)).toBeUndefined();
});
});
27 changes: 26 additions & 1 deletion packages/core/src/providers/db2/db2.env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ let configured = false;
/**
* Point the process at ibm_db's bundled clidriver before loading native bindings.
* Helps avoid SQL1042C caused by missing/wrong CLI library paths.
*
* On Windows, IBM GSKit (`…\IBM\gsk8\…`) on PATH is a known conflict with
* ibm_db's bundled CLI — strip those entries when we install our clidriver PATH.
*/
export function setupDb2ClientEnv(): void {
if (configured) return;
Expand All @@ -20,6 +23,8 @@ export function setupDb2ClientEnv(): void {
const binDir = path.join(clidriverDir, 'bin');

if (!fs.existsSync(clidriverDir)) {
// Leave configured=false so a later successful `npm install ibm_db
// --foreground-scripts` can retry setup on the next connect.
return;
}

Expand All @@ -32,7 +37,7 @@ export function setupDb2ClientEnv(): void {
process.env.LD_LIBRARY_PATH = prependPath(process.env.LD_LIBRARY_PATH, libDir);
process.env.PATH = prependPath(process.env.PATH, binDir);
} else if (process.platform === 'win32') {
process.env.PATH = prependPath(process.env.PATH, binDir, libDir);
process.env.PATH = prependPath(stripIbmGskPath(process.env.PATH), binDir, libDir);
process.env.LIB = prependPath(process.env.LIB, libDir, binDir);
}

Expand All @@ -42,6 +47,26 @@ export function setupDb2ClientEnv(): void {
}
}

/** True when ibm_db's installer/clidriver directory is present on disk. */
export function hasDb2Clidriver(): boolean {
try {
const pkgPath = nodeRequire.resolve('ibm_db/package.json');
const clidriverDir = path.join(path.dirname(pkgPath), 'installer', 'clidriver');
return fs.existsSync(clidriverDir);
} catch {
return false;
}
}

/** Exported for tests — remove IBM GSKit dirs that conflict with bundled CLI. */
export function stripIbmGskPath(current: string | undefined): string | undefined {
if (!current) return current;
const kept = current
.split(path.delimiter)
.filter((p) => p && !/IBM[/\\]gsk8/i.test(p));
return kept.join(path.delimiter);
}

function prependPath(current: string | undefined, ...dirs: string[]): string {
const existing = current?.split(path.delimiter).filter(Boolean) ?? [];
const merged = [...dirs, ...existing.filter((d) => !dirs.includes(d))];
Expand Down
Loading