/** * sql.ts — SQL Server introspection via `sqlcmd`. No node SQL driver: we shell out * to sqlcmd (which handles Windows/LocalDB trusted auth natively) and read the * result of each `FOR JSON PATH` query. * * sqlcmd gotchas handled here: * -y 0 → no truncation of nvarchar(max) (default is 256 chars → broken JSON). * -b → non-zero exit on SQL error so execFileSync throws. * FOR JSON splits its output across ~2033-char rows → we concatenate every line. */ import { execFileSync } from 'node:child_process' import type { ParsedConnection } from '../../../lib/appsettings.js' import type { DbColumn, DbFk, DbTable } from './classify.js' /** Only allow safe identifier chars in schema names injected into the IN (...) list. */ function sanitizeIdent(name: string): string { return name.replace(/[^A-Za-z0-9_]/g, '') } const qColumns = (schemas: string[]): string => { const list = schemas.map((s) => `'${sanitizeIdent(s)}'`).join(',') || `''` return `SET NOCOUNT ON; SELECT s.name AS [schema], t.name AS [table], c.name AS [column], ty.name AS [dataType] FROM sys.columns c JOIN sys.tables t ON t.object_id = c.object_id JOIN sys.schemas s ON s.schema_id = t.schema_id JOIN sys.types ty ON ty.user_type_id = c.user_type_id WHERE s.name IN (${list}) AND c.name LIKE '%Id' AND c.name <> 'Id' ORDER BY s.name, t.name, c.name FOR JSON PATH;` } const Q_FKS = `SET NOCOUNT ON; SELECT ps.name AS [schema], pt.name AS [table], pc.name AS [column] FROM sys.foreign_key_columns fkc JOIN sys.tables pt ON pt.object_id = fkc.parent_object_id JOIN sys.schemas ps ON ps.schema_id = pt.schema_id JOIN sys.columns pc ON pc.object_id = fkc.parent_object_id AND pc.column_id = fkc.parent_column_id FOR JSON PATH;` const Q_TABLES = `SET NOCOUNT ON; SELECT s.name AS [schema], t.name AS [table] FROM sys.tables t JOIN sys.schemas s ON s.schema_id = t.schema_id FOR JSON PATH;` function runSqlcmd(conn: ParsedConnection, sqlcmdPath: string, query: string): string { const auth = conn.useWindowsAuth || !conn.user ? ['-E'] : ['-U', conn.user, '-P', conn.password ?? ''] const args = ['-S', conn.server, '-d', conn.database, ...auth, '-b', '-h', '-1', '-W', '-y', '0', '-C', '-Q', query] try { return execFileSync(sqlcmdPath, args, { encoding: 'utf-8', maxBuffer: 128 * 1024 * 1024 }) } catch (e: any) { if (e?.code === 'ENOENT') { throw new Error(`'${sqlcmdPath}' not found on PATH. Install the SQL Server command-line tools (sqlcmd) or pass sqlcmdPath.`) } const detail = [e?.stderr, e?.stdout].filter(Boolean).join(' ').trim() || e?.message || String(e) throw new Error(`sqlcmd failed (server="${conn.server}", db="${conn.database}"): ${detail}`) } } /** Reassemble a `FOR JSON` result that sqlcmd split across rows. */ function parseForJson(raw: string): any[] { const joined = raw .split(/\r?\n/) .map((l) => l.trim()) .filter(Boolean) .join('') if (!joined || joined.toLowerCase() === 'null') return [] try { const parsed = JSON.parse(joined) return Array.isArray(parsed) ? parsed : [] } catch (e) { throw new Error(`Could not parse sqlcmd JSON output: ${(e as Error).message}. First 200 chars: ${joined.slice(0, 200)}`) } } export interface SchemaSnapshot { columns: DbColumn[] fks: DbFk[] tables: DbTable[] } /** Introspect the live database: candidate `*Id` columns, existing FKs, and all tables. */ export function fetchSchema(conn: ParsedConnection, schemas: string[], sqlcmdPath: string): SchemaSnapshot { return { columns: parseForJson(runSqlcmd(conn, sqlcmdPath, qColumns(schemas))) as DbColumn[], fks: parseForJson(runSqlcmd(conn, sqlcmdPath, Q_FKS)) as DbFk[], tables: parseForJson(runSqlcmd(conn, sqlcmdPath, Q_TABLES)) as DbTable[], } }