feat(editor): add SQL compression
This commit is contained in:
1 parent
ff46b7cdbb
commit
9602cc3329
13 files changed
+487
-3
No files matched your search
@@ -85,6 +85,247 @@ export async function formatSqlText(sql: string, dialect: SqlFormatDialect = "ge
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 压缩 SQL 时使用的方言。不同方言对引号、注释、转义的处理不同:
|
||||
* - `mysql`:保留 MySQL 可执行注释与 optimizer hint;单引号字符串支持反斜杠转义
|
||||
* - `postgres`:支持 dollar-quoted 字符串
|
||||
* - `sqlserver`:支持方括号标识符
|
||||
* - `generic` / 其它:仅处理标准单/双引号与块/行注释
|
||||
*/
|
||||
export type SqlCompressDialect = SqlFormatDialect;
|
||||
|
||||
/**
|
||||
* 将 SQL 压缩成一行可执行文本:折叠所有空白(含换行)为单个空格,
|
||||
* 移除普通行注释(-- ...)与普通块注释(/* ... *\/),
|
||||
* 同时按方言完整保留字符串字面量、引号标识符、可执行注释与 optimizer hint。
|
||||
*
|
||||
* 方言感知说明:
|
||||
* - MySQL:可执行注释作为可执行代码原样保留(仅折叠内部空白);
|
||||
* optimizer hint 原样保留;单引号字符串内反斜杠转义保留
|
||||
* - PostgreSQL:dollar-quoted 字符串原样保留(含标签形式)
|
||||
* - SQL Server:方括号标识符原样保留(双右括号为转义)
|
||||
* - 所有方言:单引号字符串、双引号标识符、反引号标识符均保留
|
||||
*/
|
||||
export function compressSqlText(sql: string, dialect: SqlCompressDialect = "generic"): string {
|
||||
if (!sql.trim()) return sql;
|
||||
|
||||
const len = sql.length;
|
||||
let out = "";
|
||||
let i = 0;
|
||||
|
||||
const isWhitespace = (c: string) => c === " " || c === "\t" || c === "\n" || c === "\r" || c === "\f" || c === "\v";
|
||||
const isIdentifierPart = (c: string | undefined) => c !== undefined && /[A-Za-z0-9_$]/.test(c);
|
||||
const isMysqlDashComment = (c: string | undefined) => c === undefined || c.charCodeAt(0) <= 32 || c.charCodeAt(0) === 127;
|
||||
const supportsNestedBlockComments = dialect === "postgres" || dialect === "sqlserver" || dialect === "clickhouse";
|
||||
|
||||
const dollarQuoteTagAt = (position: number): string | null => {
|
||||
if (sql[position] !== "$" || isIdentifierPart(sql[position - 1])) return null;
|
||||
if (sql[position + 1] === "$") return "$$";
|
||||
if (!/[A-Za-z_]/.test(sql[position + 1] ?? "")) return null;
|
||||
let end = position + 2;
|
||||
while (/[A-Za-z0-9_]/.test(sql[end] ?? "")) end++;
|
||||
return sql[end] === "$" ? sql.slice(position, end + 1) : null;
|
||||
};
|
||||
|
||||
// 折叠一段空白为单个空格(仅在 out 非空且不以空格结尾时追加)
|
||||
const collapseWhitespace = () => {
|
||||
let containsLineBreak = false;
|
||||
while (i < len && isWhitespace(sql[i])) i++;
|
||||
for (let j = i - 1; j >= 0 && isWhitespace(sql[j]); j--) {
|
||||
if (sql[j] === "\n" || sql[j] === "\r") {
|
||||
containsLineBreak = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// PostgreSQL only concatenates adjacent string literals when their separating
|
||||
// whitespace contains a newline, so flattening this case would make valid SQL invalid.
|
||||
if (dialect === "postgres" && containsLineBreak && out.endsWith("'") && sql[i] === "'") {
|
||||
out += "\n";
|
||||
} else if (out && !out.endsWith(" ")) {
|
||||
out += " ";
|
||||
}
|
||||
};
|
||||
|
||||
while (i < len) {
|
||||
const ch = sql[i];
|
||||
const next = sql[i + 1];
|
||||
|
||||
// 块注释 /* ... */ —— 需区分普通块注释、MySQL 可执行注释 /*! */、optimizer hint /*+ */
|
||||
if (ch === "/" && next === "*") {
|
||||
const third = sql[i + 2];
|
||||
const isExecutableMysql = third === "!";
|
||||
const isOptimizerHint = third === "+";
|
||||
|
||||
if (isExecutableMysql || isOptimizerHint) {
|
||||
const contentStart = i + 3;
|
||||
const end = sql.indexOf("*/", contentStart);
|
||||
if (end < 0) {
|
||||
// Keep malformed input malformed instead of silently turning it into executable SQL.
|
||||
out += sql.slice(i);
|
||||
break;
|
||||
}
|
||||
const content = sql.slice(contentStart, end);
|
||||
const leadingSpace = /^\s/.test(content) ? " " : "";
|
||||
const trailingSpace = /\s$/.test(content) ? " " : "";
|
||||
const compressedContent = content.trim() ? compressSqlText(content, dialect) : "";
|
||||
out += `/*${third}${leadingSpace}${compressedContent}${trailingSpace}*/`;
|
||||
i = end + 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 普通块注释 —— 移除
|
||||
const commentStart = i;
|
||||
i += 2;
|
||||
let depth = 1;
|
||||
while (i < len && depth > 0) {
|
||||
if (supportsNestedBlockComments && sql[i] === "/" && sql[i + 1] === "*") {
|
||||
depth++;
|
||||
i += 2;
|
||||
} else if (sql[i] === "*" && sql[i + 1] === "/") {
|
||||
depth--;
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
if (depth > 0) {
|
||||
// Removing an unterminated comment can expose a destructive statement that was invalid before.
|
||||
out += sql.slice(commentStart);
|
||||
break;
|
||||
}
|
||||
if (out && !out.endsWith(" ")) out += " ";
|
||||
continue;
|
||||
}
|
||||
|
||||
// MySQL additionally supports # comments and requires whitespace/control after --.
|
||||
const startsDashComment = ch === "-" && next === "-" && (dialect !== "mysql" || isMysqlDashComment(sql[i + 2]));
|
||||
if (startsDashComment || (dialect === "mysql" && ch === "#")) {
|
||||
i += startsDashComment ? 2 : 1;
|
||||
while (i < len && sql[i] !== "\n" && sql[i] !== "\r") i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// PostgreSQL dollar-quoted 字符串:$$...$$ 或 $tag$...$tag$
|
||||
if (dialect === "postgres" && ch === "$") {
|
||||
const tag = dollarQuoteTagAt(i);
|
||||
if (tag) {
|
||||
out += tag;
|
||||
i += tag.length;
|
||||
const end = sql.indexOf(tag, i);
|
||||
if (end < 0) {
|
||||
out += sql.slice(i);
|
||||
break;
|
||||
}
|
||||
out += sql.slice(i, end + tag.length);
|
||||
i = end + tag.length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 单引号字符串字面量(处理 '' 转义;MySQL 额外处理反斜杠转义)
|
||||
if (ch === "'") {
|
||||
out += "'";
|
||||
i++;
|
||||
const postgresEscapeString = dialect === "postgres" && (sql[i - 2] === "E" || sql[i - 2] === "e") && !isIdentifierPart(sql[i - 3]);
|
||||
while (i < len) {
|
||||
const c = sql[i];
|
||||
// MySQL strings and PostgreSQL E'...' strings use backslash escapes.
|
||||
if ((dialect === "mysql" || postgresEscapeString) && c === "\\" && i + 1 < len) {
|
||||
out += c;
|
||||
out += sql[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
out += c;
|
||||
if (c === "'") {
|
||||
if (sql[i + 1] === "'") {
|
||||
out += sql[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 双引号标识符(处理 "" 转义)
|
||||
if (ch === '"') {
|
||||
out += '"';
|
||||
i++;
|
||||
while (i < len) {
|
||||
if (dialect === "mysql" && sql[i] === "\\" && i + 1 < len) {
|
||||
out += sql[i];
|
||||
out += sql[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
out += sql[i];
|
||||
if (sql[i] === '"') {
|
||||
if (sql[i + 1] === '"') {
|
||||
out += sql[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 反引号标识符(MySQL)
|
||||
if (ch === "`") {
|
||||
out += "`";
|
||||
i++;
|
||||
while (i < len && sql[i] !== "`") {
|
||||
out += sql[i];
|
||||
i++;
|
||||
}
|
||||
if (i < len) {
|
||||
out += "`";
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// SQL Server 方括号标识符 [...](]] 为转义 ])
|
||||
if (dialect === "sqlserver" && ch === "[") {
|
||||
out += "[";
|
||||
i++;
|
||||
while (i < len) {
|
||||
const c = sql[i];
|
||||
out += c;
|
||||
if (c === "]") {
|
||||
if (sql[i + 1] === "]") {
|
||||
out += sql[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 空白 —— 折叠为单个空格
|
||||
if (isWhitespace(ch)) {
|
||||
collapseWhitespace();
|
||||
continue;
|
||||
}
|
||||
|
||||
out += ch;
|
||||
i++;
|
||||
}
|
||||
|
||||
return out.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format SQL for *display* (object source, view/table DDL viewers).
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user