Files
opsxdb/src/sql.ts
T

393 lines
13 KiB
TypeScript

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<string, unknown>[], 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, unknown>[]): 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, unknown>[],
): 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<string, unknown>,
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, 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 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;
}