diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index c62afcb98..a39345739 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -3226,8 +3226,7 @@ function buildPreviewCurrentStatementFrameExtension(viewModule: Pick 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( diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorExecution.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorExecution.spec.ts index b6f5506d5..a973f5d1f 100644 --- a/apps/desktop/src/lib/__tests__/editor/queryEditorExecution.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorExecution.spec.ts @@ -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"); diff --git a/apps/desktop/src/lib/__tests__/sql/currentStatementFrame.spec.ts b/apps/desktop/src/lib/__tests__/sql/currentStatementFrame.spec.ts index a292fc82d..e1667bfce 100644 --- a/apps/desktop/src/lib/__tests__/sql/currentStatementFrame.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/currentStatementFrame.spec.ts @@ -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); }); }); diff --git a/apps/desktop/src/lib/__tests__/sql/executableStatementRangeCache.spec.ts b/apps/desktop/src/lib/__tests__/sql/executableStatementRangeCache.spec.ts index acd2ebb92..1627f5ced 100644 --- a/apps/desktop/src/lib/__tests__/sql/executableStatementRangeCache.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/executableStatementRangeCache.spec.ts @@ -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"); diff --git a/apps/desktop/src/lib/__tests__/sql/sqlExecutionTarget.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlExecutionTarget.spec.ts index 5725a02c3..5544928ff 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlExecutionTarget.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlExecutionTarget.spec.ts @@ -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); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts index 24b31611a..c71564df2 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts @@ -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", () => { diff --git a/apps/desktop/src/lib/sql/currentStatementFrame.ts b/apps/desktop/src/lib/sql/currentStatementFrame.ts index a1ea5021a..a109ea9dc 100644 --- a/apps/desktop/src/lib/sql/currentStatementFrame.ts +++ b/apps/desktop/src/lib/sql/currentStatementFrame.ts @@ -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 { diff --git a/apps/desktop/src/lib/sql/executableStatementRangeCache.ts b/apps/desktop/src/lib/sql/executableStatementRangeCache.ts index 39fe003e9..6cbac7651 100644 --- a/apps/desktop/src/lib/sql/executableStatementRangeCache.ts +++ b/apps/desktop/src/lib/sql/executableStatementRangeCache.ts @@ -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() === ""; -} diff --git a/apps/desktop/src/lib/sql/sqlExecutionTarget.ts b/apps/desktop/src/lib/sql/sqlExecutionTarget.ts index 5d654100d..e7146abb9 100644 --- a/apps/desktop/src/lib/sql/sqlExecutionTarget.ts +++ b/apps/desktop/src/lib/sql/sqlExecutionTarget.ts @@ -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; diff --git a/apps/desktop/src/lib/sql/sqlStatementRanges.ts b/apps/desktop/src/lib/sql/sqlStatementRanges.ts index 2ac14e70a..10d045ad6 100644 --- a/apps/desktop/src/lib/sql/sqlStatementRanges.ts +++ b/apps/desktop/src/lib/sql/sqlStatementRanges.ts @@ -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, diff --git a/apps/desktop/src/lib/sql/statementDelimiter.ts b/apps/desktop/src/lib/sql/statementDelimiter.ts new file mode 100644 index 000000000..01667e2bc --- /dev/null +++ b/apps/desktop/src/lib/sql/statementDelimiter.ts @@ -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); +} diff --git a/crates/dbx-core/src/query.rs b/crates/dbx-core/src/query.rs index b5c7675ff..417d4482c 100644 --- a/crates/dbx-core/src/query.rs +++ b/crates/dbx-core/src/query.rs @@ -1613,15 +1613,22 @@ fn classify_query_error(db_type: Option, 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, 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(