393 lines
13 KiB
TypeScript
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;
|
|
}
|