import { format as sqlFormat } from "sql-formatter"; import { isDangerousRedis, splitRedisCommands } from "./redis"; /** * 危险语句 UX 预检(免一次往返)。 * 注意:服务端 CheckDangerousSQL 为权威判定(逐语句/词边界/注释处理), * 此处只做宽松预提示,判定冲突时以服务端为准(needConfirm 二段流)。 */ export function isDangerousSQL(sql: string, driver?: string): boolean { if ((driver || "").toLowerCase() === "redis") return isDangerousRedis(sql); const s = stripLead(sql); if (!s) return false; const up = s.toUpperCase(); if (/^(DROP|TRUNCATE|ALTER|GRANT|REVOKE)\b/.test(up)) return true; if (/^DELETE\b/.test(up) && !/\bWHERE\b/.test(up)) return true; if (/^UPDATE\b/.test(up) && !/\bWHERE\b/.test(up)) return true; return false; } function stripLead(sql: string): string { let s = sql.trim(); while (true) { if (s.startsWith("--")) { const i = s.indexOf("\n"); if (i < 0) return ""; s = s.slice(i + 1).trim(); continue; } if (s.startsWith("/*")) { const i = s.indexOf("*/"); if (i < 0) return ""; s = s.slice(i + 2).trim(); continue; } break; } return s; } /** 选区优先;否则取光标所在语句。Redis 按行,SQL 按分号。 */ export function statementAt(sql: string, cursor: number, selection?: string, driver?: string): string { const sel = selection?.trim(); if (sel) return sel; const ranges = splitRunnable(sql, driver); const pos = Math.max(0, Math.min(cursor, sql.length)); const hit = ranges.find((r) => pos >= r.start && pos <= r.end) ?? ranges.find((r) => pos < r.start); return (hit?.text || sql).trim(); } /** Redis 按行;其它引擎按 SQL 分号。 */ export function splitRunnable(sql: string, driver?: string): { start: number; end: number; text: string }[] { if ((driver || "").toLowerCase() === "redis") return splitRedisCommands(sql); return splitStatements(sql); } export function splitStatements(sql: string): { start: number; end: number; text: string }[] { const out: { start: number; end: number; text: string }[] = []; let start = 0; let i = 0; let inS = false; let inD = false; let inLine = false; let inBlock = false; while (i < sql.length) { const ch = sql[i]; const nxt = sql[i + 1]; if (inLine) { if (ch === "\n") inLine = false; i++; continue; } if (inBlock) { if (ch === "*" && nxt === "/") { inBlock = false; i += 2; continue; } i++; continue; } if (!inS && !inD && ch === "-" && nxt === "-") { inLine = true; i += 2; continue; } if (!inS && !inD && ch === "/" && nxt === "*") { inBlock = true; i += 2; continue; } if (ch === "'" && !inD) { inS = !inS; i++; continue; } if (ch === '"' && !inS) { inD = !inD; i++; continue; } if (!inS && !inD && ch === ";") { const text = sql.slice(start, i); if (text.trim()) out.push({ start, end: i, text }); start = i + 1; } i++; } const tail = sql.slice(start); if (tail.trim()) out.push({ start, end: sql.length, text: tail }); return out.length ? out : [{ start: 0, end: sql.length, text: sql }]; } /** limit:false 时不拼行数上限(树预览用,翻页由请求层 offset/limit 承担) */ function buildSelectSql(driver: string | undefined, db: string, table: string, limit: number | false) { const d = (driver || "mysql").toLowerCase(); if (d === "redis") return `GET ${table}`; if (d === "postgresql" || d === "postgres" || d === "gaussdb" || d === "kingbase" || d === "opengauss") { const ident = table.includes(".") ? table : `"${table}"`; return `SELECT * FROM ${ident}${limit === false ? "" : ` LIMIT ${limit}`};`; } if (d === "sqlserver" || d === "mssql") { return `SELECT ${limit === false ? "" : `TOP ${limit} `}* FROM ${table};`; } if (d === "oracle") { return `SELECT * FROM ${table}${limit === false ? "" : ` FETCH FIRST ${limit} ROWS ONLY`}`; } if (d === "sqlite") { return `SELECT * FROM "${table}"${limit === false ? "" : ` LIMIT ${limit}`};`; } const tbl = table.includes(".") ? table : `\`${table}\``; const lim = limit === false ? "" : ` LIMIT ${limit}`; if (db) return `SELECT * FROM \`${db}\`.${tbl}${lim};`; return `SELECT * FROM ${tbl}${lim};`; } export function sampleSelectSql(driver: string | undefined, db: string, table: string) { return buildSelectSql(driver, db, table, 100); } /** 树表预览 SQL:不带行数上限,翻页由请求层 offset/limit 完成(否则第 2 页起恒为空) */ export function previewSelectSql(driver: string | undefined, db: string, table: string) { return buildSelectSql(driver, db, table, false); } export function rowsToTSV(columns: string[], rows: Record[], headers = true): string { const esc = (v: unknown) => { if (v == null) return ""; return String(v).replace(/\t/g, " ").replace(/\r?\n/g, " "); }; const lines = rows.map((r) => columns.map((c) => esc(r[c])).join("\t")); if (headers) lines.unshift(columns.join("\t")); return lines.join("\n"); } export function rowsToCSV(columns: string[], rows: Record[]): string { const esc = (v: unknown) => { if (v == null) return ""; const s = String(v); if (/[",\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`; return s; }; return [columns.map(esc).join(","), ...rows.map((r) => columns.map((c) => esc(r[c])).join(","))].join("\n"); } /** R2-F2:表名/列名按方言 quoteIdent(修 01-P1-1 恒反引号);库.表 形式逐段加引号 */ export function rowsToInsert( driver: string | undefined, table: string, columns: string[], rows: Record[], ): string { const ident = table ? table.split(".").map((p) => quoteIdent(driver, p)).join(".") : "t"; const cols = columns.map((c) => quoteIdent(driver, c)).join(", "); const lit = (v: unknown) => { if (v == null) return "NULL"; if (typeof v === "number" && Number.isFinite(v)) return String(v); return `'${String(v).replace(/'/g, "''")}'`; }; return rows .map((r) => `INSERT INTO ${ident} (${cols}) VALUES (${columns.map((c) => lit(r[c])).join(", ")});`) .join("\n"); } export function isWritableDriver(driver: string | undefined): boolean { const d = (driver || "").toLowerCase(); return [ "mysql", "mariadb", "tidb", "postgresql", "postgres", "sqlite", "sqlserver", "mssql", "oracle", "oceanbase", "polardb", "tdsql", "gaussdb", "kingbase", "opengauss", ].includes(d); } export function quoteIdent(driver: string | undefined, name: string): string { const d = (driver || "mysql").toLowerCase(); if (d === "postgresql" || d === "postgres" || d === "gaussdb" || d === "kingbase" || d === "opengauss" || d === "sqlite") { return `"${name.replace(/"/g, '""')}"`; } if (d === "sqlserver" || d === "mssql") return `[${name.replace(/]/g, "]]")}]`; if (d === "oracle") return name; // often unquoted uppercase return `\`${name.replace(/`/g, "``")}\``; } export function sqlLiteral(v: unknown): string { if (v == null || v === "") return "NULL"; if (typeof v === "number" && Number.isFinite(v)) return String(v); if (typeof v === "boolean") return v ? "TRUE" : "FALSE"; return `'${String(v).replace(/'/g, "''")}'`; } /** Build UPDATE … SET col=? WHERE pk… from edited cell. */ export function buildUpdateSql( driver: string | undefined, database: string | undefined, table: string, pkCols: string[], row: Record, col: string, newVal: unknown, ): string | null { if (!pkCols.length) return null; const d = (driver || "mysql").toLowerCase(); const t = table.includes(".") ? table : database && !["postgresql", "postgres", "sqlite", "gaussdb", "kingbase", "opengauss"].includes(d) ? `${quoteIdent(driver, database)}.${quoteIdent(driver, table)}` : quoteIdent(driver, table); const set = `${quoteIdent(driver, col)} = ${sqlLiteral(newVal === "" ? null : newVal)}`; const where = pkCols .map((k) => { const v = row[k]; if (v == null) return `${quoteIdent(driver, k)} IS NULL`; return `${quoteIdent(driver, k)} = ${sqlLiteral(v)}`; }) .join(" AND "); return `UPDATE ${t} SET ${set} WHERE ${where}`; } export function supportsExplain(driver: string | undefined): boolean { const d = (driver || "").toLowerCase(); return [ "mysql", "mariadb", "tidb", "postgresql", "postgres", "sqlite", "sqlserver", "mssql", "oracle", "oceanbase", "polardb", "tdsql", "gaussdb", "kingbase", "opengauss", ].includes(d); } // C3:方言 EXPLAIN 包装已下线 —— 改由服务端 POST /api/db/explain 按驱动完成 // (MSSQL SHOWPLAN_XML 需钉单连接,Oracle 需查 DBMS_XPLAN,前端拼 SQL 拿不到)。 /** Map OpsxDB driver id → sql-formatter language. */ export function sqlFormatterLanguage(driver?: string): string { const d = (driver || "mysql").toLowerCase(); if (d === "postgresql" || d === "postgres" || d === "gaussdb" || d === "kingbase" || d === "opengauss") { return "postgresql"; } if (d === "sqlite") return "sqlite"; if (d === "sqlserver" || d === "mssql") return "tsql"; if (d === "oracle") return "plsql"; if (d === "mariadb") return "mariadb"; // mysql / tidb / oceanbase / polardb / tdsql / redis fallback return "mysql"; } /** * Pretty-print SQL via sql-formatter. * On formatter parse errors, falls back to a minimal indent printer. */ export function formatSqlText(sql: string, driver?: string): string { const input = sql.replace(/\s+$/, ""); if (!input.trim()) return sql; try { return sqlFormat(input, { // dialect heuristic — cast: sql-formatter language union is wide language: sqlFormatterLanguage(driver) as "mysql", tabWidth: 2, keywordCase: "upper", linesBetweenQueries: 1, }); } catch { return minimalPrettySql(input); } } /** Minimal pretty-printer when sql-formatter is not installed yet. */ function minimalPrettySql(sql: string): string { const s = sql.trim(); // Insert newline before major keywords (best-effort, ignores strings — fine as fallback) return s .replace(/\s+/g, " ") .replace( /\b(SELECT|FROM|WHERE|AND|OR|JOIN|LEFT JOIN|RIGHT JOIN|INNER JOIN|OUTER JOIN|GROUP BY|ORDER BY|LIMIT|OFFSET|HAVING|UNION|INSERT INTO|VALUES|UPDATE|SET|DELETE FROM)\b/gi, (m) => `\n${m.toUpperCase()}`, ) .replace(/^\n/, "") .trim(); } /** Build DELETE FROM … WHERE pk… for a row. */ export function buildDeleteSql( driver: string | undefined, database: string | undefined, table: string, pkCols: string[], row: Record, ): string | null { if (!pkCols.length) return null; const d = (driver || "mysql").toLowerCase(); const t = table.includes(".") ? table : database && !["postgresql", "postgres", "sqlite", "gaussdb", "kingbase", "opengauss"].includes(d) ? `${quoteIdent(driver, database)}.${quoteIdent(driver, table)}` : quoteIdent(driver, table); const where = pkCols .map((k) => { const v = row[k]; if (v == null) return `${quoteIdent(driver, k)} IS NULL`; return `${quoteIdent(driver, k)} = ${sqlLiteral(v)}`; }) .join(" AND "); return `DELETE FROM ${t} WHERE ${where}`; } /** * Parse clipboard TSV/CSV into a grid. * - Split lines on newlines (handles CRLF); cells on tabs. * - If a line has no tabs but looks like simple CSV (has commas), split on commas. * - Empty cell → ""; literal "NULL" (any case) → null. */ export function parseClipboardGrid(text: string): (string | null)[][] { const raw = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); // Drop a single trailing newline so Excel paste does not invent an empty row const trimmed = raw.endsWith("\n") ? raw.slice(0, -1) : raw; if (!trimmed) return []; const lines = trimmed.split("\n"); return lines.map((line) => { let cells: string[]; if (line.includes("\t")) { cells = line.split("\t"); } else if (line.includes(",") && looksLikeSimpleCsv(line)) { cells = splitSimpleCsv(line); } else { cells = [line]; } return cells.map((c) => { if (c === "") return ""; if (/^null$/i.test(c)) return null; return c; }); }); } function looksLikeSimpleCsv(line: string): boolean { // Prefer CSV when commas separate simple unquoted or simply-quoted fields if (!line.includes(",")) return false; // Reject if it looks like a single free-text phrase with incidental commas and spaces only mid-word — still allow return true; } /** Minimal CSV split: handles commas and ""-quoted fields; no escaped newlines. */ function splitSimpleCsv(line: string): string[] { const out: string[] = []; let cur = ""; let inQ = false; for (let i = 0; i < line.length; i++) { const ch = line[i]; if (inQ) { if (ch === '"') { if (line[i + 1] === '"') { cur += '"'; i++; } else { inQ = false; } } else { cur += ch; } } else if (ch === '"') { inQ = true; } else if (ch === ",") { out.push(cur); cur = ""; } else { cur += ch; } } out.push(cur); return out; }