fix(editor): preserve current SQL execution boundaries

This commit is contained in:
zipg 2026-08-04 00:37:42 +08:00 committed by GitHub
parent c9764be9a0
commit a6f1a9a871
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 232 additions and 44 deletions

View File

@ -3226,8 +3226,7 @@ function buildPreviewCurrentStatementFrameExtension(viewModule: Pick<typeof impo
}
function previewCurrentStatementFrameTo(view: import("@codemirror/view").EditorView, range: SqlTextRange): number {
const nextChar = range.to < view.state.doc.length ? view.state.doc.sliceString(range.to, range.to + 1) : "";
return currentStatementFrameRangeTo(nextChar, range);
return currentStatementFrameRangeTo(view.state.doc, range);
}
watch(

View File

@ -11,7 +11,7 @@ import SqlExecutionTargetPicker from "./SqlExecutionTargetPicker.vue";
import DelimitedListDialog from "./DelimitedListDialog.vue";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import { copyToClipboard, readTextFromClipboard } from "@/lib/common/clipboard";
import { resolveExecutableSql, type SqlExecutionSnapshot, type SqlExecutionOverride, type SqlExecutionCandidate } from "@/lib/sql/sqlExecutionTarget";
import { executionCandidateForMode, resolveExecutableSql, type SqlExecutionSnapshot, type SqlExecutionOverride, type SqlExecutionCandidate } from "@/lib/sql/sqlExecutionTarget";
import { buildExecutionCandidates, hasMultipleExecutionTargets, supportsExecutionTargetPicker, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
import { executableStatementRangeAtCursor, executableStatementRangeCacheForDoc, executableStatementRangeStartingAt as executableStatementRangeStartingAtLine, type ExecutableStatementRangeCache } from "@/lib/sql/executableStatementRangeCache";
import { currentStatementFrameRangeTo, shouldRebuildCurrentStatementFrame, visualSqlColumnsWithInlineHints } from "@/lib/sql/currentStatementFrame";
@ -644,12 +644,12 @@ function requestExecuteFromView(currentView: EditorViewType, cursorPos: number,
const doc = currentView.state.doc.toString();
const parameterOptions = sqlStatementParameterOptions();
const candidates = buildExecutionCandidates(doc, cursorPos, props.databaseType, parameterOptions);
if (candidates.length === 0) return false;
if (candidates.length === 0) return true;
// The execution shortcut keeps executing the configured target (cursor/all) directly:
// it stays keyboard-driven and never pops the picker, which is reserved for click entry points.
if (options.bypassPicker || !settingsStore.editorSettings.showExecutionTargetPicker || !hasMultipleExecutionTargets(doc, props.databaseType, parameterOptions)) {
const preferredKind = settingsStore.editorSettings.executeMode === "current" ? "cursor" : "all";
const candidate = candidates.find((item) => item.kind === preferredKind) ?? candidates[0];
const candidate = executionCandidateForMode(candidates, settingsStore.editorSettings.executeMode);
if (!candidate) return true;
emitExecutionRequest(sqlExecutionSnapshotForRange(currentView, candidate), options.openInNewResultTab);
return true;
}
@ -4011,8 +4011,7 @@ onMounted(async () => {
);
function currentStatementFrameTo(view: import("@codemirror/view").EditorView, range: SqlTextRange): number {
const nextChar = range.to < view.state.doc.length ? view.state.doc.sliceString(range.to, range.to + 1) : "";
return currentStatementFrameRangeTo(nextChar, range);
return currentStatementFrameRangeTo(view.state.doc, range);
}
const activeLineHighlighter = ViewPlugin.fromClass(

View File

@ -21,12 +21,22 @@ describe("QueryEditor execution routing", () => {
it("keeps selection priority and the configured current/all target choice", () => {
const selectionBranch = queryEditorSource.indexOf("if (!options.ignoreSelection && !selection.empty)");
const executeModeBranch = queryEditorSource.indexOf('settingsStore.editorSettings.executeMode === "current" ? "cursor" : "all"');
const executeModeBranch = queryEditorSource.indexOf("executionCandidateForMode(candidates, settingsStore.editorSettings.executeMode)");
expect(selectionBranch).toBeGreaterThan(-1);
expect(executeModeBranch).toBeGreaterThan(selectionBranch);
});
it("does not fall back to all SQL when current mode has no statement at the cursor", () => {
expect(queryEditorSource).toContain("const candidate = executionCandidateForMode(candidates, settingsStore.editorSettings.executeMode)");
expect(queryEditorSource).toContain("if (!candidate) return true");
expect(queryEditorSource).not.toContain("?? candidates[0]");
});
it("consumes the execution shortcut when the editor has no executable target", () => {
expect(queryEditorSource).toContain("if (candidates.length === 0) return true");
});
it("preserves the source range when executing a current/all candidate without a manual selection", () => {
expect(queryEditorSource).toContain("emitExecutionRequest(sqlExecutionSnapshotForRange(currentView, candidate), options.openInNewResultTab)");
expect(queryEditorSource).toContain("currentView ? sqlExecutionSnapshotForRange(currentView, candidate) : candidate.sql");

View File

@ -2,15 +2,42 @@ import { describe, expect, it } from "vitest";
import { currentStatementFrameRangeTo, estimateInlineHintVisualColumns, isWideSqlChar, shouldRebuildCurrentStatementFrame, visualSqlColumns, visualSqlColumnsWithInlineHints } from "@/lib/sql/currentStatementFrame";
import type { SqlTextRange } from "@/lib/sql/sqlStatementRanges";
function frameDocument(sql: string) {
return {
length: sql.length,
sliceString: (from: number, to: number) => sql.slice(from, to),
};
}
describe("currentStatementFrameRangeTo", () => {
it("includes a directly adjacent trailing semicolon in frame width calculations", () => {
const sql = "SELECT 1;";
const range: SqlTextRange = { from: 0, to: "SELECT 1".length, sql: "SELECT 1" };
expect(currentStatementFrameRangeTo(";", range)).toBe(range.to + 1);
expect(currentStatementFrameRangeTo(frameDocument(sql), range)).toBe(sql.length);
});
it("does not extend the frame when the next character is not a semicolon", () => {
it("includes a trailing semicolon on its own line", () => {
const sql = "SELECT 1\n;\n\nSELECT 2;";
const range: SqlTextRange = { from: 0, to: "SELECT 1".length, sql: "SELECT 1" };
expect(currentStatementFrameRangeTo("\n", range)).toBe(range.to);
expect(currentStatementFrameRangeTo(frameDocument(sql), range)).toBe(sql.indexOf(";") + 1);
});
it("does not extend the frame across a comment before a later semicolon", () => {
const sql = "SELECT 1\n-- comment\n;";
const range: SqlTextRange = { from: 0, to: "SELECT 1".length, sql: "SELECT 1" };
expect(currentStatementFrameRangeTo(frameDocument(sql), range)).toBe(range.to);
});
it("does not extend the frame across a blank line before a later semicolon", () => {
const sql = "SELECT 1\n\n;";
const range: SqlTextRange = { from: 0, to: "SELECT 1".length, sql: "SELECT 1" };
expect(currentStatementFrameRangeTo(frameDocument(sql), range)).toBe(range.to);
});
it("does not extend the frame when the next non-whitespace character is not a semicolon", () => {
const sql = "SELECT 1\n\nSELECT 2";
const range: SqlTextRange = { from: 0, to: "SELECT 1".length, sql: "SELECT 1" };
expect(currentStatementFrameRangeTo(frameDocument(sql), range)).toBe(range.to);
});
});

View File

@ -93,6 +93,24 @@ describe("executableStatementRangeCacheForDoc", () => {
expect(executableStatementRangeAtCursor(cache, semicolonGapCursor)?.sql).toBe("SELECT 1");
});
it("keeps a standalone next-line semicolon attached to the current statement", () => {
const sql = "SELECT *\nFROM users\n;\n\nSELECT * FROM audit;";
const doc = Text.of(sql.split("\n"));
const cache = executableStatementRangeCacheForDoc(null, doc, "mysql");
const delimiterCursor = sql.indexOf(";");
expect(executableStatementRangeAtCursor(cache, delimiterCursor)?.sql).toBe("SELECT *\nFROM users");
expect(executableStatementRangeAtCursor(cache, delimiterCursor + 1)?.sql).toBe("SELECT *\nFROM users");
});
it("does not attach a semicolon after a blank line to the previous statement", () => {
const sql = "SELECT 1\n\n;";
const doc = Text.of(sql.split("\n"));
const cache = executableStatementRangeCacheForDoc(null, doc, "mysql");
expect(executableStatementRangeAtCursor(cache, sql.indexOf(";"))).toBeNull();
});
it("returns null for blank and pure comment cursor lines", () => {
const doc = Text.of(["SELECT 1;", "-- comment", "/* block comment */", "", "SELECT 2;"]);
const cache = executableStatementRangeCacheForDoc(null, doc, "mysql");

View File

@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { resolveExecutableSql } from "@/lib/sql/sqlExecutionTarget";
import { executionCandidateForMode, resolveExecutableSql, type SqlExecutionCandidate } from "@/lib/sql/sqlExecutionTarget";
function candidate(kind: SqlExecutionCandidate["kind"], supportedKinds: SqlExecutionCandidate["supportedKinds"]): SqlExecutionCandidate {
return { kind, supportedKinds, label: kind, sql: "SELECT 1", from: 0, to: 8 };
}
describe("resolveExecutableSql", () => {
it("uses selected SQL before cursor-mode resolution", () => {
@ -10,3 +14,27 @@ describe("resolveExecutableSql", () => {
expect(resolveExecutableSql(sql, selectedSql, { mode: "current", cursorPos: cursorAfterFirstSemicolon })).toBe("select 2;");
});
});
describe("executionCandidateForMode", () => {
it("does not fall back to all SQL when current mode has no cursor statement", () => {
const all = candidate("all", ["all"]);
expect(executionCandidateForMode([all], "current")).toBeNull();
expect(executionCandidateForMode([all], "all")).toBe(all);
});
it("uses the deduplicated candidate when one statement is both current and all", () => {
const currentAndAll = candidate("all", ["cursor", "all"]);
expect(executionCandidateForMode([currentAndAll], "current")).toBe(currentAndAll);
expect(executionCandidateForMode([currentAndAll], "all")).toBe(currentAndAll);
});
it("selects the exact target when current and all candidates are distinct", () => {
const current = candidate("cursor", ["cursor"]);
const all = candidate("all", ["all"]);
expect(executionCandidateForMode([current, all], "current")).toBe(current);
expect(executionCandidateForMode([current, all], "all")).toBe(all);
});
});

View File

@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { executionCandidateForMode } from "@/lib/sql/sqlExecutionTarget";
import { buildExecutionCandidates, currentExecutableStatementRange, executableStatementRanges, fullSqlRange, hasMultipleExecutionTargets, splitSqlStatementRanges, statementRangeAtCursor, supportsExecutionTargetPicker } from "@/lib/sql/sqlStatementRanges";
function indexOf(sql: string, needle: string, occurrence = 1): number {
@ -531,6 +532,22 @@ GET /_cat/indices`;
expect(range?.sql.trim()).toBe("SELECT *\nFROM system_dept");
});
it("keeps a standalone next-line semicolon cursor on the current multi-line statement", () => {
const sql = "SELECT *\nFROM system_dept\n;\n\nSELECT * FROM sys;";
const delimiterPos = sql.indexOf(";");
expect(statementRangeAtCursor(sql, delimiterPos)?.sql.trim()).toBe("SELECT *\nFROM system_dept");
expect(statementRangeAtCursor(sql, delimiterPos + 1)?.sql.trim()).toBe("SELECT *\nFROM system_dept");
});
it("assigns a standalone trailing semicolon to the final soft statement", () => {
const sql = "SELECT * FROM `t_0001`\nSELECT * FROM `t_0001` LIMIT 1\n;";
const delimiterPos = sql.lastIndexOf(";");
expect(statementRangeAtCursor(sql, delimiterPos, "mysql")?.sql).toBe("SELECT * FROM `t_0001` LIMIT 1");
expect(statementRangeAtCursor(sql, delimiterPos + 1, "mysql")?.sql).toBe("SELECT * FROM `t_0001` LIMIT 1");
});
it("returns the next same-line statement when the cursor is inside it", () => {
const sql = "SELECT 1; SELECT 2;";
const pos = indexOf(sql, "SELECT 2") + 1;
@ -1113,6 +1130,20 @@ describe("buildExecutionCandidates", () => {
expect(candidateLabels(candidates)).toEqual(["currentStatement", "allStatements"]);
});
it("keeps a MySQL FORCE INDEX query current when its semicolon is on the next line", () => {
const firstStatement = `SELECT count(*)
FROM cus_loan_status_copy1 t2
LEFT JOIN case_allocation_details_copy1 t4 FORCE INDEX(idx_debt_case_number)
ON t2.caseno = t4.debt_case_number
WHERE t2.product_name = '12345'
;`;
const sql = `${firstStatement}\n\nSELECT count(*) FROM cus_loan_status_copy1;`;
const candidates = buildExecutionCandidates(sql, indexOf(sql, "12345"), "mysql");
expect(candidateKinds(candidates)).toEqual(["cursor", "all"]);
expect(executionCandidateForMode(candidates, "current")?.sql.trim()).toBe(firstStatement.replace(/\n;$/, ""));
});
it("uses the current statement when the cursor is immediately after its semicolon before a blank line", () => {
const sql = "select 1;\n\nselect 2;";
const cursorAfterFirstSemicolon = sql.indexOf(";") + 1;
@ -1126,6 +1157,13 @@ describe("buildExecutionCandidates", () => {
expect(candidateSummaries(candidates)).toEqual(["cursor:SELECT 2", "all:SELECT 1\nSELECT 2"]);
});
it("uses the final soft statement when the cursor is on a standalone trailing semicolon", () => {
const sql = "SELECT * FROM `t_0001`\nSELECT * FROM `t_0001` LIMIT 1\n;";
const candidates = buildExecutionCandidates(sql, sql.lastIndexOf(";"), "mysql");
expect(candidateSummaries(candidates)).toEqual(["cursor:SELECT * FROM `t_0001` LIMIT 1", "all:SELECT * FROM `t_0001`\nSELECT * FROM `t_0001` LIMIT 1\n;"]);
});
it("dedupes when the cursor statement equals the full document", () => {
const sql = "SELECT 1;";
const candidates = buildExecutionCandidates(sql, indexOf(sql, "1"));
@ -1137,6 +1175,16 @@ describe("buildExecutionCandidates", () => {
const sql = "SELECT 1;\n\nSELECT 2;";
const candidates = buildExecutionCandidates(sql, sql.indexOf("\n") + 1);
expect(candidateKinds(candidates)).toEqual(["all"]);
expect(candidates[0].supportedKinds).toEqual(["all"]);
expect(executionCandidateForMode(candidates, "current")).toBeNull();
expect(executionCandidateForMode(candidates, "all")).toBe(candidates[0]);
});
it("marks a deduplicated single-statement candidate as both current and all", () => {
const sql = "SELECT 1;";
const candidates = buildExecutionCandidates(sql, indexOf(sql, "1"));
expect(candidates[0].supportedKinds).toEqual(["cursor", "all"]);
});
it("returns no candidates for an empty document", () => {

View File

@ -1,7 +1,9 @@
import type { SqlTextRange } from "@/lib/sql/sqlStatementRanges";
import { trailingStatementDelimiterPosition, type StatementDelimiterDocument } from "@/lib/sql/statementDelimiter";
export function currentStatementFrameRangeTo(nextChar: string, range: SqlTextRange): number {
return nextChar === ";" ? range.to + 1 : range.to;
export function currentStatementFrameRangeTo(doc: StatementDelimiterDocument, range: SqlTextRange): number {
const delimiterPos = trailingStatementDelimiterPosition(doc, range.to);
return delimiterPos === null ? range.to : delimiterPos + 1;
}
export function shouldRebuildCurrentStatementFrame(update: { docChanged: boolean; selectionSet: boolean; configurationChanged: boolean }): boolean {

View File

@ -2,6 +2,7 @@ import type { Text } from "@codemirror/state";
import type { DatabaseType } from "@/types/database";
import { readSqlBracedParameterAt, type SqlParameterOptions } from "@/lib/sql/sqlParameters";
import { executableStatementRanges, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
import { cursorBelongsToTrailingStatementDelimiter } from "@/lib/sql/statementDelimiter";
export interface ExecutableStatementRangeCache {
doc: Text;
@ -61,7 +62,7 @@ export function executableStatementRangeAtCursor(cache: ExecutableStatementRange
}
const next = cache.ranges[index + 1];
if (pos > range.to && (!next || pos < next.from) && range.to >= line.from && range.to <= line.to && cursorRemainsOnRangeLine(cache.doc, range.to, pos)) {
if (pos > range.to && (!next || pos < next.from) && cursorBelongsToTrailingStatementDelimiter(cache.doc, range.to, pos)) {
return range;
}
}
@ -80,14 +81,3 @@ function isCursorOnLeadingBlockComment(lineText: string, lineOffset: number): bo
return lineOffset <= commentEnd + 2;
}
function cursorRemainsOnRangeLine(doc: Text, rangeTo: number, cursorPos: number): boolean {
const between = doc.sliceString(rangeTo, cursorPos);
if (between.includes("\n")) return false;
const delimiterIndex = between.lastIndexOf(";");
if (delimiterIndex === -1) return between.trim() === "";
const beforeDelimiter = between.slice(0, delimiterIndex);
const afterDelimiter = between.slice(delimiterIndex + 1);
return beforeDelimiter.trim() === "" && afterDelimiter.trim() === "";
}

View File

@ -23,6 +23,7 @@ export type SqlExecutionTargetKind = "cursor" | "all";
*/
export interface SqlExecutionCandidate {
kind: SqlExecutionTargetKind;
supportedKinds: SqlExecutionTargetKind[];
label: string;
sql: string;
from: number;
@ -40,6 +41,11 @@ export function isSqlExecutionSnapshot(value: SqlExecutionOverride | undefined):
return typeof value === "object" && value !== null && typeof value.fullSql === "string" && typeof value.selectedSql === "string" && typeof value.cursorPos === "number" && typeof value.selectionFrom === "number" && typeof value.selectionTo === "number";
}
export function executionCandidateForMode(candidates: SqlExecutionCandidate[], mode: ExecuteMode): SqlExecutionCandidate | null {
const targetKind: SqlExecutionTargetKind = mode === "current" ? "cursor" : "all";
return candidates.find((candidate) => candidate.supportedKinds.includes(targetKind)) ?? null;
}
export function resolveExecutableSql(fullSql: string, selectedSql: string, options?: { mode?: ExecuteMode; cursorPos?: number }): string {
const trimmedSelection = selectedSql.trim();
if (trimmedSelection) return trimmedSelection;

View File

@ -1,4 +1,5 @@
import type { SqlExecutionCandidate } from "@/lib/sql/sqlExecutionTarget";
import { cursorBelongsToTrailingStatementDelimiter } from "@/lib/sql/statementDelimiter";
import { splitMongoCommandRanges } from "@/lib/mongo/mongoShellCommand";
import { readSqlBracedParameterAt, type SqlParameterOptions } from "@/lib/sql/sqlParameters";
import { isElasticsearchCompatibleDatabaseType, type DatabaseType } from "@/types/database";
@ -576,8 +577,8 @@ export function statementRangeAtCursor(sql: string, cursorPos: number, databaseT
const next = statements[index + 1];
// A caret after a statement's semicolon still belongs to that statement
// until the next statement's text begins.
if (pos > statement.to && (!next || pos < next.from) && isCursorInSameLineDelimiterGap(sql, statement.to, pos)) {
return rangeForCursorInSoftRanges(sql, softRanges, pos) ?? rangeFor(statement, sql);
if (pos > statement.to && (!next || pos < next.from) && isCursorInTrailingDelimiterGap(sql, statement.to, pos)) {
return rangeForCursorInSoftRanges(sql, softRanges, pos) ?? rangeFor(softRanges[softRanges.length - 1] ?? statement, sql);
}
// Cursor in indentation or inter-statement whitespace immediately before
@ -585,9 +586,9 @@ export function statementRangeAtCursor(sql: string, cursorPos: number, databaseT
// execution range remains tight around the SQL text itself.
if (pos >= statement.hitFrom && pos < statement.from && (sql.slice(pos, statement.from).trim() === "" || (isElasticsearchCompatibleDatabaseType(databaseType) && isElasticsearchRequestPreamble(sql.slice(statement.hitFrom, statement.from))))) {
const previous = statements[index - 1];
if (previous && isCursorInSameLineDelimiterGap(sql, previous.to, pos)) {
if (previous && isCursorInTrailingDelimiterGap(sql, previous.to, pos)) {
const previousSoftRanges = splitStatementRangeAtSoftStarts(sql, previous, databaseType, parameterOptions);
return rangeForCursorInSoftRanges(sql, previousSoftRanges, pos) ?? rangeFor(previous, sql);
return rangeForCursorInSoftRanges(sql, previousSoftRanges, pos) ?? rangeFor(previousSoftRanges[previousSoftRanges.length - 1] ?? previous, sql);
}
return rangeForCursorInSoftRanges(sql, softRanges, pos) ?? rangeFor(statement, sql);
}
@ -614,7 +615,7 @@ export function mongoCommandRangeAtCursor(sql: string, cursorPos: number): SqlTe
if (pos >= command.from && pos <= command.to) return range;
const next = commands[index + 1];
if (pos > command.to && (!next || pos < next.from) && isCursorInSameLineDelimiterGap(sql, command.to, pos)) return range;
if (pos > command.to && (!next || pos < next.from) && isCursorInTrailingDelimiterGap(sql, command.to, pos)) return range;
if (pos < command.from && sql.slice(pos, command.from).trim() === "" && isCursorOnStatementLine(sql, pos, command)) return range;
}
@ -622,13 +623,8 @@ export function mongoCommandRangeAtCursor(sql: string, cursorPos: number): SqlTe
return null;
}
function isCursorInSameLineDelimiterGap(sql: string, previousStatementEnd: number, cursorPos: number): boolean {
if (cursorPos <= previousStatementEnd) return false;
const between = sql.slice(previousStatementEnd, cursorPos);
const delimiterIndex = between.lastIndexOf(";");
if (delimiterIndex === -1) return false;
const afterDelimiter = between.slice(delimiterIndex + 1);
return !afterDelimiter.includes("\n") && between.slice(0, delimiterIndex).trim() === "" && afterDelimiter.trim() === "";
function isCursorInTrailingDelimiterGap(sql: string, previousStatementEnd: number, cursorPos: number): boolean {
return cursorBelongsToTrailingStatementDelimiter(sql, previousStatementEnd, cursorPos);
}
function rangeForCursorInSoftRanges(sql: string, ranges: RawStatement[], pos: number): SqlTextRange | null {
@ -2043,16 +2039,17 @@ export function buildExecutionCandidates(sql: string, cursorPos: number, databas
const sameContent = normalizeSql(cursorStatement.sql) === normalizeSql(full.sql);
if (sameContent) {
return [candidateFromRange(full, "all", databaseType)];
return [candidateFromRange(full, "all", databaseType, ["cursor", "all"])];
}
return [candidateFromRange(cursorStatement, "cursor", databaseType), candidateFromRange(full, "all", databaseType)];
}
function candidateFromRange(range: SqlTextRange, kind: SqlExecutionCandidate["kind"], databaseType?: DatabaseType): SqlExecutionCandidate {
function candidateFromRange(range: SqlTextRange, kind: SqlExecutionCandidate["kind"], databaseType?: DatabaseType, supportedKinds: SqlExecutionCandidate["supportedKinds"] = [kind]): SqlExecutionCandidate {
const isRedis = databaseType === "redis";
return {
kind,
supportedKinds,
label: kind === "cursor" ? (isRedis ? "currentCommand" : "currentStatement") : isRedis ? "allCommands" : "allStatements",
sql: range.sql,
from: range.from,

View File

@ -0,0 +1,32 @@
export interface StatementDelimiterDocument {
readonly length: number;
sliceString(from: number, to: number): string;
}
type StatementDelimiterSource = string | StatementDelimiterDocument;
export function trailingStatementDelimiterPosition(source: StatementDelimiterSource, rangeTo: number): number | null {
let delimiterPos = rangeTo;
let lineBreakCount = 0;
while (delimiterPos < source.length) {
const char = sliceSource(source, delimiterPos, delimiterPos + 1);
if (!/\s/u.test(char)) break;
if (char === "\n" && ++lineBreakCount > 1) return null;
delimiterPos += 1;
}
return sliceSource(source, delimiterPos, delimiterPos + 1) === ";" ? delimiterPos : null;
}
export function cursorBelongsToTrailingStatementDelimiter(source: StatementDelimiterSource, rangeTo: number, cursorPos: number): boolean {
if (cursorPos < rangeTo) return false;
const delimiterPos = trailingStatementDelimiterPosition(source, rangeTo);
if (delimiterPos === null) return false;
if (cursorPos <= delimiterPos + 1) return true;
const afterDelimiter = sliceSource(source, delimiterPos + 1, cursorPos);
return !afterDelimiter.includes("\n") && afterDelimiter.trim() === "";
}
function sliceSource(source: StatementDelimiterSource, from: number, to: number): string {
return typeof source === "string" ? source.slice(from, to) : source.sliceString(from, to);
}

View File

@ -1613,15 +1613,22 @@ fn classify_query_error(db_type: Option<DatabaseType>, error: QueryExecutionErro
QueryExecutionError::Legacy(message) if is_dbx_query_timeout_error(&message.to_ascii_lowercase()) => {
QueryExecutionError::Timeout(message)
}
QueryExecutionError::Legacy(message)
if db_type == Some(DatabaseType::Postgres) && message.trim_start().starts_with("ERROR:") =>
{
QueryExecutionError::Legacy(message) if is_native_sql_server_error(db_type, &message) => {
QueryExecutionError::Sql(message)
}
other => other,
}
}
fn is_native_sql_server_error(db_type: Option<DatabaseType>, message: &str) -> bool {
let message = message.trim_start();
match db_type {
Some(DatabaseType::Postgres) => message.starts_with("ERROR:"),
Some(DatabaseType::Mysql) => message.starts_with("Server error: `ERROR "),
_ => false,
}
}
pub async fn do_execute(
state: &AppState,
pool_key: &str,
@ -5043,6 +5050,31 @@ for line in sys.stdin:
);
}
#[test]
fn mysql_server_error_preserves_sql_catalog_identity_and_detail() {
let error = classify_query_error(
Some(DatabaseType::Mysql),
QueryExecutionError::Legacy(
"Server error: `ERROR 1064 (42000): You have an error in your SQL syntax`".to_string(),
),
)
.with_omitted_sql_context("SELECT 111 AS first_value FROM DUAL");
let backend_error = error.into_backend_error();
assert_eq!(backend_error.code(), "DBX-JDBC-4001");
assert_eq!(backend_error.message_key(), "backendErrors.jdbc.sqlFailed");
assert_eq!(
backend_error.message_params().get("stage"),
Some(&crate::backend_error::BackendMessageParam::String("execute".to_string()))
);
assert_eq!(
backend_error.detail(),
Some(
"Server error: `ERROR 1064 (42000): You have an error in your SQL syntax` SQL text omitted from user-facing error; enable debug SQL diagnostics for a redacted statement."
)
);
}
#[test]
fn single_statement_multi_result_preserves_sql_error_type() {
let error = classify_query_error(