fix(editor): preserve current SQL execution boundaries
This commit is contained in:
parent
c9764be9a0
commit
a6f1a9a871
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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() === "";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue