Files
dbx/apps/desktop/src/lib/sqlStatementRanges.ts
T
dac97b83a6 feat(editor): execute single SQL directly without picker
* fix(ui): toast 消息支持换行显示

- 添加 max-w-3xl 限制最大宽度
- 使用 whitespace-pre-wrap 允许自动换行
- 添加 break-words 防止长单词溢出

* feat(editor): 单条 SQL 直接执行

---------

Co-authored-by: t8y2 <1156263951@qq.com>
Co-authored-by: staff <staff@qimaos-MacBook-Pro.local>
2026-06-22 17:14:36 +08:00

867 lines
23 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { SqlExecutionCandidate } from "./sqlExecutionTarget";
import type { DatabaseType } from "@/types/database";
/**
* A contiguous range of SQL text expressed as document offsets plus the
* extracted (original) substring.
*/
export interface SqlTextRange {
from: number;
to: number;
sql: string;
}
const NON_SQL_EXECUTION_TARGET_TYPES: ReadonlySet<DatabaseType> = new Set(["mongodb", "elasticsearch", "qdrant", "milvus", "etcd", "mq", "neo4j"]);
export function supportsExecutionTargetPicker(databaseType?: DatabaseType): boolean {
return !!databaseType && (databaseType === "redis" || !NON_SQL_EXECUTION_TARGET_TYPES.has(databaseType));
}
export function hasMultipleExecutionTargets(sql: string, databaseType?: DatabaseType): boolean {
if (databaseType === "redis") {
return redisExecutableCommandCount(sql) > 1;
}
return splitSqlStatementRanges(sql).length > 1;
}
interface RawStatement {
/** Start offset (inclusive) of whitespace that can still target this statement. */
hitFrom: number;
/** Start offset (inclusive) of the statement's first non-whitespace char. */
from: number;
/** End offset (exclusive) — up to and excluding the terminating semicolon. */
to: number;
/** The statement text, sliced from the source document. */
sql: string;
}
type QuoteState = "none" | "single" | "double" | "backtick" | "bracket" | "dollar";
const COMMON_SOFT_STATEMENT_START_KEYWORDS = [
"SELECT",
"WITH",
"CREATE",
"ALTER",
"DROP",
"INSERT",
"UPDATE",
"DELETE",
"MERGE",
"REPLACE",
"TRUNCATE",
"GRANT",
"REVOKE",
"COMMENT",
"EXPLAIN",
"SHOW",
"DESCRIBE",
"DESC",
"USE",
"SET",
"CALL",
"EXEC",
"EXECUTE",
"BEGIN",
"COMMIT",
"ROLLBACK",
"DECLARE",
"ANALYZE",
"VACUUM",
"PRAGMA",
"REFRESH",
"COPY",
] as const;
const DATABASE_SOFT_STATEMENT_KEYWORDS: Partial<Record<DatabaseType, readonly string[]>> = {
mysql: ["HANDLER", "LOAD", "OPTIMIZE", "REPAIR"],
postgres: ["DO", "LISTEN", "NOTIFY", "UNLISTEN"],
sqlite: ["ATTACH", "DETACH", "REINDEX"],
duckdb: ["ATTACH", "DETACH", "EXPORT", "IMPORT", "INSTALL", "LOAD"],
clickhouse: ["ATTACH", "CHECK", "DETACH", "EXCHANGE", "KILL", "OPTIMIZE", "SYSTEM"],
sqlserver: ["BACKUP", "DBCC", "DENY", "RESTORE"],
oracle: ["FLASHBACK", "LOCK", "PURGE"],
dameng: ["FLASHBACK", "LOCK", "PURGE"],
gaussdb: ["DO", "LOCK"],
"oceanbase-oracle": ["FLASHBACK", "LOCK", "PURGE"],
redis: [],
mongodb: [],
elasticsearch: [],
qdrant: [],
milvus: [],
mq: [],
etcd: [],
};
const WITH_MAIN_STATEMENT_KEYWORDS = new Set(["SELECT", "INSERT", "UPDATE", "DELETE", "MERGE"]);
const EXPLAIN_STATEMENT_KEYWORDS = new Set(["SELECT", "WITH", "INSERT", "UPDATE", "DELETE", "MERGE", "CREATE", "ALTER", "DROP"]);
const CREATE_BODY_KEYWORDS = new Set(["SELECT", "WITH", "BEGIN", "DECLARE"]);
const INSERT_BODY_KEYWORDS = new Set(["SELECT", "WITH"]);
/**
* Parse the SQL document into top-level statement ranges delimited by `;`.
*
* Delimiters inside string literals, double/backtick/bracket quoted
* identifiers, dollar-quoted bodies (Postgres), line comments (`--`, `#`) and
* block comments (`/* *​/`) are ignored, mirroring the backend splitter in
* `dbx-core/src/sql.rs`. Ranges are returned as `[from, to)` offsets covering
* only the statement text (the trailing semicolon and inter-statement
* whitespace are excluded so editor highlights stay tight).
*/
export function splitSqlStatementRanges(sql: string): RawStatement[] {
const statements: RawStatement[] = [];
const len = sql.length;
let statementStart = -1;
let statementEnd = -1;
let statementHitStart = 0;
let state: QuoteState = "none";
let dollarTag = "";
let i = 0;
const isWhitespace = (ch: string) => ch === " " || ch === "\t" || ch === "\r" || ch === "\n";
const markContent = (pos: number) => {
if (statementStart === -1) statementStart = pos;
statementEnd = pos + 1;
};
const flush = () => {
if (statementStart !== -1 && statementEnd !== -1 && statementEnd > statementStart) {
statements.push({ hitFrom: statementHitStart, from: statementStart, to: statementEnd, sql: sql.slice(statementStart, statementEnd) });
}
statementStart = -1;
statementEnd = -1;
};
while (i < len) {
const ch = sql[i];
const next = sql[i + 1] ?? "";
if (state === "dollar") {
// Inside a Postgres dollar-quoted body; look for the closing $tag$.
if (ch === "$") {
const closingTag = `$${dollarTag}$`;
if (sql.startsWith(closingTag, i)) {
markContent(i);
for (let k = 0; k < closingTag.length; k += 1) {
markContent(i + k);
}
i += closingTag.length;
state = "none";
dollarTag = "";
continue;
}
}
markContent(i);
i += 1;
continue;
}
if (state === "single") {
markContent(i);
// Backslash escapes the next char (e.g. PostgreSQL standard_conforming_strings=off style).
if (ch === "\\" && next) {
i += 2;
continue;
}
if (ch === "'") {
// Doubled single quote '' is an escaped quote, not a terminator.
if (next === "'") {
i += 2;
continue;
}
state = "none";
}
i += 1;
continue;
}
if (state === "double") {
markContent(i);
if (ch === '"') {
if (next === '"') {
i += 2;
continue;
}
state = "none";
}
i += 1;
continue;
}
if (state === "backtick") {
markContent(i);
if (ch === "`") {
if (next === "`") {
i += 2;
continue;
}
state = "none";
}
i += 1;
continue;
}
if (state === "bracket") {
markContent(i);
if (ch === "]") {
state = "none";
}
i += 1;
continue;
}
// state === "none"
// Line comments consume up to (and including) the newline.
if (ch === "-" && next === "-") {
const newline = sql.indexOf("\n", i);
i = newline === -1 ? len : newline + 1;
continue;
}
if (ch === "#") {
const newline = sql.indexOf("\n", i);
i = newline === -1 ? len : newline + 1;
continue;
}
// Block comments consume until the closing */.
if (ch === "/" && next === "*") {
const close = sql.indexOf("*/", i + 2);
i = close === -1 ? len : close + 2;
continue;
}
if (ch === "'") {
markContent(i);
state = "single";
i += 1;
continue;
}
if (ch === '"') {
markContent(i);
state = "double";
i += 1;
continue;
}
if (ch === "`") {
markContent(i);
state = "backtick";
i += 1;
continue;
}
if (ch === "[") {
markContent(i);
state = "bracket";
i += 1;
continue;
}
// Postgres dollar quoting: $tag$ ... $tag$ (tag may be empty, i.e. $$)
if (ch === "$") {
const tagMatch = /^\$[A-Za-z_0-9]*\$/.exec(sql.slice(i));
if (tagMatch) {
markContent(i);
dollarTag = tagMatch[0].slice(1, -1);
i += tagMatch[0].length;
state = "dollar";
continue;
}
}
if (ch === ";") {
flush();
statementHitStart = i + 1;
i += 1;
continue;
}
if (!isWhitespace(ch)) {
markContent(i);
}
i += 1;
}
// Flush any trailing statement that lacks a terminating semicolon.
flush();
return statements;
}
/**
* Returns the statement that contains `cursorPos`, or `null` when the cursor
* sits on a blank line or no statement can be resolved.
*
* The returned range covers only the statement's own text (no trailing `;`),
* which lets the editor highlight a tight preview range.
*/
export function statementRangeAtCursor(sql: string, cursorPos: number, databaseType?: DatabaseType): SqlTextRange | null {
const pos = clampCursor(sql, cursorPos);
if (isCursorOnBlankLine(sql, pos)) return null;
const statements = splitSqlStatementRanges(sql);
for (let index = 0; index < statements.length; index += 1) {
const statement = statements[index];
const softRanges = splitStatementRangeAtSoftStarts(sql, statement, databaseType);
// Cursor inside the statement body, including the exact start/end.
if (pos >= statement.from && pos <= statement.to) {
return rangeForCursorInSoftRanges(sql, softRanges, pos) ?? rangeFor(statement, sql);
}
// Cursor in indentation or inter-statement whitespace immediately before
// the statement should still target that statement, while the returned
// execution range remains tight around the SQL text itself.
if (pos >= statement.hitFrom && pos < statement.from && sql.slice(pos, statement.from).trim() === "") {
return rangeForCursorInSoftRanges(sql, softRanges, pos) ?? rangeFor(statement, sql);
}
const next = statements[index + 1];
if (pos > statement.to && (!next || pos < next.hitFrom) && isCursorOnStatementLine(sql, pos, statement)) {
return rangeForCursorInSoftRanges(sql, softRanges, pos) ?? rangeFor(statement, sql);
}
}
return null;
}
function rangeForCursorInSoftRanges(sql: string, ranges: RawStatement[], pos: number): SqlTextRange | null {
for (let index = 0; index < ranges.length; index += 1) {
const range = ranges[index];
if (pos >= range.from && pos <= range.to) {
return rangeFor(range, sql);
}
if (pos >= range.hitFrom && pos < range.from && sql.slice(pos, range.from).trim() === "") {
return rangeFor(range, sql);
}
const next = ranges[index + 1];
if (pos > range.to && (!next || pos < next.hitFrom) && isCursorOnStatementLine(sql, pos, range)) {
return rangeFor(range, sql);
}
}
return null;
}
function splitStatementRangeAtSoftStarts(sql: string, statement: RawStatement, databaseType?: DatabaseType): RawStatement[] {
const lineStarts = topLevelSoftStatementLineStarts(sql, statement, databaseType);
if (lineStarts.length <= 1) return [statement];
const boundaries: Array<{ hitFrom: number; from: number; keyword: string }> = [];
let currentKeyword = softStatementKeywordAt(sql, statement.from, databaseType);
let consumedWithMainStatement = false;
let consumedExplainStatement = false;
boundaries.push({ hitFrom: statement.hitFrom, from: statement.from, keyword: currentKeyword ?? "" });
for (const lineStart of lineStarts) {
if (lineStart.from <= statement.from) continue;
if (currentKeyword === "WITH" && !consumedWithMainStatement && WITH_MAIN_STATEMENT_KEYWORDS.has(lineStart.keyword)) {
consumedWithMainStatement = true;
continue;
}
if (currentKeyword === "EXPLAIN" && !consumedExplainStatement && EXPLAIN_STATEMENT_KEYWORDS.has(lineStart.keyword)) {
consumedExplainStatement = true;
continue;
}
if (currentKeyword === "CREATE" && CREATE_BODY_KEYWORDS.has(lineStart.keyword)) {
continue;
}
if (currentKeyword === "INSERT" && INSERT_BODY_KEYWORDS.has(lineStart.keyword)) {
continue;
}
if (currentKeyword === "UPDATE" && lineStart.keyword === "SET") {
continue;
}
boundaries.push(lineStart);
currentKeyword = lineStart.keyword;
consumedWithMainStatement = false;
consumedExplainStatement = false;
}
if (boundaries.length <= 1) return [statement];
const ranges: RawStatement[] = [];
for (let index = 0; index < boundaries.length; index += 1) {
const boundary = boundaries[index];
const next = boundaries[index + 1];
const to = next ? trimRangeEndBeforeNextBoundary(sql, boundary.from, next.from) : trimRangeEnd(sql, boundary.from, statement.to);
if (to > boundary.from) {
ranges.push({
hitFrom: boundary.hitFrom,
from: boundary.from,
to,
sql: sql.slice(boundary.from, to),
});
}
}
return ranges.length > 0 ? ranges : [statement];
}
function topLevelSoftStatementLineStarts(sql: string, statement: RawStatement, databaseType?: DatabaseType): Array<{ hitFrom: number; from: number; keyword: string }> {
const starts: Array<{ hitFrom: number; from: number; keyword: string }> = [];
const len = statement.to;
let state: QuoteState | "lineComment" | "blockComment" = "none";
let dollarTag = "";
let parenDepth = 0;
let lineStart = statement.from;
let firstNonWhitespaceOnLine = -1;
let i = statement.from;
while (i < len) {
const ch = sql[i];
const next = sql[i + 1] ?? "";
if (state === "none" && firstNonWhitespaceOnLine === -1 && ch !== "\n" && ch !== "\r" && !isSqlWhitespace(ch) && !startsLineComment(sql, i) && !startsBlockComment(sql, i)) {
firstNonWhitespaceOnLine = i;
if (parenDepth === 0) {
const keyword = softStatementKeywordAt(sql, i, databaseType);
if (keyword) {
starts.push({ hitFrom: lineStart, from: i, keyword });
}
}
}
if (ch === "\n") {
if (state === "lineComment") state = "none";
lineStart = i + 1;
firstNonWhitespaceOnLine = -1;
i += 1;
continue;
}
if (state === "lineComment") {
i += 1;
continue;
}
if (state === "blockComment") {
if (ch === "*" && next === "/") {
state = "none";
i += 2;
continue;
}
i += 1;
continue;
}
if (state === "dollar") {
if (ch === "$") {
const closingTag = `$${dollarTag}$`;
if (sql.startsWith(closingTag, i)) {
i += closingTag.length;
state = "none";
dollarTag = "";
continue;
}
}
i += 1;
continue;
}
if (state === "single") {
if (ch === "\\" && next) {
i += 2;
continue;
}
if (ch === "'") {
if (next === "'") {
i += 2;
continue;
}
state = "none";
}
i += 1;
continue;
}
if (state === "double") {
if (ch === '"') {
if (next === '"') {
i += 2;
continue;
}
state = "none";
}
i += 1;
continue;
}
if (state === "backtick") {
if (ch === "`") {
if (next === "`") {
i += 2;
continue;
}
state = "none";
}
i += 1;
continue;
}
if (state === "bracket") {
if (ch === "]") state = "none";
i += 1;
continue;
}
// state === "none"
if (ch === "-" && next === "-") {
state = "lineComment";
i += 2;
continue;
}
if (ch === "#") {
state = "lineComment";
i += 1;
continue;
}
if (ch === "/" && next === "*") {
state = "blockComment";
i += 2;
continue;
}
if (ch === "'") {
state = "single";
i += 1;
continue;
}
if (ch === '"') {
state = "double";
i += 1;
continue;
}
if (ch === "`") {
state = "backtick";
i += 1;
continue;
}
if (ch === "[") {
state = "bracket";
i += 1;
continue;
}
if (ch === "$") {
const tagMatch = /^\$[A-Za-z_0-9]*\$/.exec(sql.slice(i));
if (tagMatch) {
dollarTag = tagMatch[0].slice(1, -1);
i += tagMatch[0].length;
state = "dollar";
continue;
}
}
if (ch === "(") {
parenDepth += 1;
} else if (ch === ")" && parenDepth > 0) {
parenDepth -= 1;
}
i += 1;
}
return starts;
}
function softStatementKeywordAt(sql: string, pos: number, databaseType?: DatabaseType): string | null {
const match = /^[A-Za-z_][\w$]*/.exec(sql.slice(pos));
if (!match) return null;
const keyword = match[0].toUpperCase();
return softStatementStartKeywords(databaseType).has(keyword) ? keyword : null;
}
function softStatementStartKeywords(databaseType?: DatabaseType): Set<string> {
return new Set([...COMMON_SOFT_STATEMENT_START_KEYWORDS, ...(databaseType ? (DATABASE_SOFT_STATEMENT_KEYWORDS[databaseType] ?? []) : [])]);
}
function startsLineComment(sql: string, pos: number): boolean {
return (sql[pos] === "-" && sql[pos + 1] === "-") || sql[pos] === "#";
}
function startsBlockComment(sql: string, pos: number): boolean {
return sql[pos] === "/" && sql[pos + 1] === "*";
}
function trimRangeEnd(sql: string, from: number, to: number): number {
let end = to;
while (end > from && isSqlWhitespace(sql[end - 1])) {
end -= 1;
}
return end;
}
function trimRangeEndBeforeNextBoundary(sql: string, from: number, nextBoundaryFrom: number): number {
let state: QuoteState | "lineComment" | "blockComment" = "none";
let dollarTag = "";
let lastContentEnd = from;
let i = from;
while (i < nextBoundaryFrom) {
const ch = sql[i];
const next = sql[i + 1] ?? "";
if (state === "lineComment") {
if (ch === "\n") state = "none";
i += 1;
continue;
}
if (state === "blockComment") {
if (ch === "*" && next === "/") {
state = "none";
i += 2;
continue;
}
i += 1;
continue;
}
if (state === "dollar") {
lastContentEnd = i + 1;
if (ch === "$") {
const closingTag = `$${dollarTag}$`;
if (sql.startsWith(closingTag, i)) {
i += closingTag.length;
lastContentEnd = i;
state = "none";
dollarTag = "";
continue;
}
}
i += 1;
continue;
}
if (state === "single") {
lastContentEnd = i + 1;
if (ch === "\\" && next) {
i += 2;
lastContentEnd = i;
continue;
}
if (ch === "'") {
if (next === "'") {
i += 2;
lastContentEnd = i;
continue;
}
state = "none";
}
i += 1;
continue;
}
if (state === "double") {
lastContentEnd = i + 1;
if (ch === '"') {
if (next === '"') {
i += 2;
lastContentEnd = i;
continue;
}
state = "none";
}
i += 1;
continue;
}
if (state === "backtick") {
lastContentEnd = i + 1;
if (ch === "`") {
if (next === "`") {
i += 2;
lastContentEnd = i;
continue;
}
state = "none";
}
i += 1;
continue;
}
if (state === "bracket") {
lastContentEnd = i + 1;
if (ch === "]") state = "none";
i += 1;
continue;
}
if (ch === "-" && next === "-") {
state = "lineComment";
i += 2;
continue;
}
if (ch === "#") {
state = "lineComment";
i += 1;
continue;
}
if (ch === "/" && next === "*") {
state = "blockComment";
i += 2;
continue;
}
if (ch === "'") {
state = "single";
lastContentEnd = i + 1;
i += 1;
continue;
}
if (ch === '"') {
state = "double";
lastContentEnd = i + 1;
i += 1;
continue;
}
if (ch === "`") {
state = "backtick";
lastContentEnd = i + 1;
i += 1;
continue;
}
if (ch === "[") {
state = "bracket";
lastContentEnd = i + 1;
i += 1;
continue;
}
if (ch === "$") {
const tagMatch = /^\$[A-Za-z_0-9]*\$/.exec(sql.slice(i));
if (tagMatch) {
state = "dollar";
dollarTag = tagMatch[0].slice(1, -1);
i += tagMatch[0].length;
lastContentEnd = i;
continue;
}
}
if (!isSqlWhitespace(ch)) {
lastContentEnd = i + 1;
}
i += 1;
}
return trimRangeEnd(sql, from, lastContentEnd);
}
function isSqlWhitespace(ch: string): boolean {
return ch === " " || ch === "\t" || ch === "\r" || ch === "\n";
}
function rangeFor(statement: RawStatement, sql: string): SqlTextRange {
return {
from: statement.from,
to: statement.to,
sql: sql.slice(statement.from, statement.to),
};
}
function clampCursor(sql: string, cursorPos: number): number {
if (!Number.isFinite(cursorPos)) return 0;
if (cursorPos < 0) return 0;
if (cursorPos > sql.length) return sql.length;
return cursorPos;
}
function isCursorOnBlankLine(sql: string, pos: number): boolean {
const lineStart = sql.lastIndexOf("\n", pos - 1) + 1;
let lineEnd = sql.indexOf("\n", pos);
if (lineEnd === -1) lineEnd = sql.length;
return sql.slice(lineStart, lineEnd).trim() === "";
}
function isCursorOnStatementLine(sql: string, pos: number, statement: RawStatement): boolean {
const lineStart = sql.lastIndexOf("\n", pos - 1) + 1;
let lineEnd = sql.indexOf("\n", pos);
if (lineEnd === -1) lineEnd = sql.length;
return statement.from >= lineStart && statement.from <= lineEnd;
}
/**
* Returns the full document as a range, or `null` when it is empty/whitespace.
*/
export function fullSqlRange(sql: string): SqlTextRange | null {
const trimmed = sql.trim();
if (!trimmed) return null;
const from = sql.length - sql.trimStart().length;
const to = from + trimmed.length;
return { from, to, sql: sql.slice(from, to) };
}
function normalizeSql(sql: string): string {
return sql.replace(/\s+/g, " ").replace(/;\s*$/, "").trim();
}
/**
* Build the ordered list of execution candidates to show in the picker.
*
* Order is always `[cursor, all]` when both are available, except when the
* cursor statement and the full document are effectively the same SQL — in
* that case only a single candidate is returned to avoid duplicates.
*/
export function buildExecutionCandidates(sql: string, cursorPos: number, databaseType?: DatabaseType): SqlExecutionCandidate[] {
const full = fullSqlRange(sql);
const cursorStatement = databaseType === "redis" ? redisCommandRangeAtCursor(sql, cursorPos) : statementRangeAtCursor(sql, cursorPos, databaseType);
if (!full && !cursorStatement) return [];
if (!full) {
return cursorStatement ? [candidateFromRange(cursorStatement, "cursor", databaseType)] : [];
}
if (!cursorStatement) {
return [candidateFromRange(full, "all", databaseType)];
}
const sameContent = normalizeSql(cursorStatement.sql) === normalizeSql(full.sql);
if (sameContent) {
return [candidateFromRange(full, "all", databaseType)];
}
return [candidateFromRange(cursorStatement, "cursor", databaseType), candidateFromRange(full, "all", databaseType)];
}
function candidateFromRange(range: SqlTextRange, kind: SqlExecutionCandidate["kind"], databaseType?: DatabaseType): SqlExecutionCandidate {
const isRedis = databaseType === "redis";
return {
kind,
label: kind === "cursor" ? (isRedis ? "currentCommand" : "currentStatement") : isRedis ? "allCommands" : "allStatements",
sql: range.sql,
from: range.from,
to: range.to,
};
}
function redisExecutableCommandCount(sql: string): number {
let count = 0;
for (const line of sql.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
count += 1;
if (count > 1) return count;
}
return count;
}
function redisCommandRangeAtCursor(sql: string, cursorPos: number): SqlTextRange | null {
const pos = clampCursor(sql, cursorPos);
if (isCursorOnBlankLine(sql, pos)) return null;
const lineStart = sql.lastIndexOf("\n", pos - 1) + 1;
let lineEnd = sql.indexOf("\n", pos);
if (lineEnd === -1) lineEnd = sql.length;
const rawLine = sql.slice(lineStart, lineEnd);
const leadingWhitespace = rawLine.length - rawLine.trimStart().length;
const trimmedLine = rawLine.trim();
if (!trimmedLine || trimmedLine.startsWith("#")) return null;
const from = lineStart + leadingWhitespace;
const to = lineStart + rawLine.length - (rawLine.length - rawLine.trimEnd().length);
return {
from,
to,
sql: sql.slice(from, to),
};
}