From be5f06fd60e98be40890a404a4642b3bddb83b63 Mon Sep 17 00:00:00 2001 From: efeire Date: Sun, 25 Oct 2026 23:14:21 +0800 Subject: [PATCH] feat(editor): locate oracle missing objects without stealing focus --- .../src/components/editor/QueryEditor.vue | 13 +- .../useQueryEditorDiagnostics.spec.ts | 20 ++++ .../editor/useQueryEditorDiagnostics.ts | 14 +-- .../src/components/layout/ContentArea.vue | 52 +++++++- .../ContentArea.errorReExecute.spec.ts | 113 +++++++++++++++++- .../lib/__tests__/sql/errorPosition.spec.ts | 55 ++++++++- .../editor/queryEditorExecutionViewport.ts | 8 ++ apps/desktop/src/lib/sql/errorPosition.ts | 20 +++- apps/desktop/src/lib/sql/sqlDiagnostics.ts | 47 +++++++- .../queryStore.multiStatementError.spec.ts | 75 +++++++++++- apps/desktop/src/stores/queryStore.ts | 2 + apps/desktop/src/types/database.ts | 2 + packages/app-tests/sqlDiagnostics.test.ts | 32 +++++ 13 files changed, 424 insertions(+), 29 deletions(-) diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index c726e31298..0173a212b9 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -3034,7 +3034,15 @@ function openReplace(): boolean { return searchPanelRef.value?.openReplace() ?? false; } -function scrollCursorIntoView() { +let executionDocument: import("@codemirror/state").Text | undefined; + +function scrollCursorIntoView(errorOffset?: number) { + if (errorOffset !== undefined) { + const canLocate = executionViewportOwnership.consumeErrorCompletion(); + if (canLocate && editorIsActive && view.value?.state.doc === executionDocument) focusErrorPosition(errorOffset); + executionDocument = undefined; + return; + } const preserveViewport = executionViewportOwnership.consumeCompletionPreservation(); const currentView = view.value; if (!currentView || !codeMirrorRuntime.editorViewModule || !editorIsActive || preserveViewport) return; @@ -3057,6 +3065,7 @@ function beginExecutionViewportTracking() { // visible before execution must not be scrolled away by the shrink (#10480). const currentView = view.value; const cursorVisible = currentView ? isQueryEditorPositionVisible(currentView.state.selection.main.head, currentView.visibleRanges, currentView.viewport) : false; + executionDocument = currentView?.state.doc; executionViewportOwnership.beginExecution(cursorVisible); } @@ -3140,6 +3149,8 @@ defineExpose({ :data-large-document-mode="largeDocumentMode || undefined" @wheel="recordExecutionViewportInteraction" @pointerdown="recordExecutionViewportInteraction" + @keydown.capture="recordExecutionViewportInteraction" + @focusout="recordExecutionViewportInteraction" @gesturestart="onEditorGestureStart" @gesturechange="onEditorGestureChange" @gestureend="onEditorGestureEnd" diff --git a/apps/desktop/src/components/editor/__tests__/useQueryEditorDiagnostics.spec.ts b/apps/desktop/src/components/editor/__tests__/useQueryEditorDiagnostics.spec.ts index 1434d54e7d..5de226ed69 100644 --- a/apps/desktop/src/components/editor/__tests__/useQueryEditorDiagnostics.spec.ts +++ b/apps/desktop/src/components/editor/__tests__/useQueryEditorDiagnostics.spec.ts @@ -143,6 +143,26 @@ describe("QueryEditor diagnostic scheduling ownership", () => { expect(diagnostics.sqlErrorDecorationRange(currentView.state)).toEqual([{ from: 7, to: 8, message: "syntax error at line 1, column 8" }]); }); + it("underlines the complete missing Oracle object in a selected DDL statement", () => { + const statement = 'CREATE VIEW V_MISSING AS SELECT * FROM "HR"."EMP_REF"'; + const modelValue = `SELECT 1 FROM DUAL;\n${statement};`; + const sourceFrom = modelValue.indexOf(statement); + const objectFrom = statement.indexOf('"HR"'); + const message = `ORA-00942: table or view does not exist error occur at position: ${objectFrom}`; + const result: QueryResult = { + columns: ["Error"], + rows: [[message]], + affected_rows: 0, + execution_time_ms: 0, + execution_error: true, + sourceStatement: statement, + sourceFrom, + sourceTo: sourceFrom + statement.length, + }; + const { diagnostics, currentView } = createHarness({ modelValue, databaseType: "oracle", dialect: "oracle", executionError: message, executionErrorResult: result }); + expect(diagnostics.sqlErrorDecorationRange(currentView.state)).toEqual([{ from: sourceFrom + objectFrom, to: sourceFrom + statement.length, message }]); + }); + it("retains textual diagnostics for older result envelopes without source metadata", () => { const result: QueryResult = { columns: ["Error"], rows: [["syntax error at line 1, column 8"]], affected_rows: 0, execution_time_ms: 0, execution_error: true }; const { diagnostics, currentView } = createHarness({ executionError: "syntax error at line 1, column 8", executionErrorSql: "SELECT missing;", executionErrorResult: result }); diff --git a/apps/desktop/src/components/editor/useQueryEditorDiagnostics.ts b/apps/desktop/src/components/editor/useQueryEditorDiagnostics.ts index 08d2f07cd4..08ef085653 100644 --- a/apps/desktop/src/components/editor/useQueryEditorDiagnostics.ts +++ b/apps/desktop/src/components/editor/useQueryEditorDiagnostics.ts @@ -15,7 +15,7 @@ import { buildSqlSemanticModel } from "@/lib/sql/semantic/model"; import { mergeSqlSemanticReferenceAnalysis } from "@/lib/sql/semantic/references"; import { sqlServerUseDatabaseBeforeCursor } from "@/lib/sql/sqlCompletionLookupTarget"; import { lineColumnToOffset, sqlErrorDecorationRange as resolveSqlErrorDecorationRange, sqlErrorSqlMatchesEditor } from "@/lib/sql/sqlDiagnostics"; -import { scalarPositionToUtf16Offset, sqlErrorEditorOffset } from "@/lib/sql/errorPosition"; +import { sqlErrorEditorRange } from "@/lib/sql/errorPosition"; import { analyzeMysqlRoutineSyntax, supportsMysqlRoutineSyntaxDiagnostics } from "@/lib/sql/mysqlRoutineSyntaxDiagnostics"; import { buildOracleSyntaxDiagnostics } from "@/lib/sql/oracleSyntaxDiagnostics"; import { buildSqlServerRoutineSyntaxDiagnostics } from "@/lib/sql/sqlServerRoutineSyntaxDiagnostics"; @@ -96,16 +96,12 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions if (!props.executionError) return []; if (props.executionErrorResult?.sourceStatement) { const sql = currentState.doc.toString(); - const mapped = sqlErrorEditorOffset({ editorSql: sql, result: props.executionErrorResult, resultIndex: props.executionErrorResult.statement_index, databaseType: props.databaseType, parameterOptions: sqlStatementParameterOptions() }); - if (!mapped || mapped.offset >= sql.length) return []; - const source = props.executionErrorResult.sourceStatement; - const sourceRange = source ? resolveSqlErrorDecorationRange(source, props.executionError) : null; - const sourceOffset = source ? scalarPositionToUtf16Offset(source, mapped.line, mapped.column) : undefined; - const width = sourceRange && sourceRange.from === sourceOffset ? sourceRange.to - sourceRange.from : (sql.codePointAt(mapped.offset) ?? 0) > 0xffff ? 2 : 1; - return [{ from: mapped.offset, to: Math.min(sql.length, mapped.offset + width), message: props.executionError }]; + const mapped = sqlErrorEditorRange({ editorSql: sql, result: props.executionErrorResult, resultIndex: props.executionErrorResult.statement_index, databaseType: props.databaseType, parameterOptions: sqlStatementParameterOptions() }); + if (!mapped || mapped.from >= sql.length) return []; + return [{ ...mapped, message: props.executionError }]; } if (!props.executionErrorSql || !sqlErrorSqlMatchesEditor(currentState.doc.toString(), props.executionErrorSql)) return []; - const range = resolveSqlErrorDecorationRange(currentState.doc.toString(), props.executionError); + const range = resolveSqlErrorDecorationRange(currentState.doc.toString(), props.executionError, props.databaseType); if (!range) return []; return [ { diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 63aed1f7bd..04b643b89d 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -347,6 +347,7 @@ onMounted(() => { window.addEventListener("resize", updateStandaloneResultToolbarDimensions); window.visualViewport?.addEventListener("resize", updateStandaloneResultToolbarDimensions); window.addEventListener("dbx:ui-scale-applied", updateStandaloneResultToolbarDimensions); + window.addEventListener("dbx:before-tab-switch", revokeExecutionErrorFocus); revealActiveResultRunAfterRender(); }); @@ -1344,20 +1345,62 @@ watch( }, ); +let executionCompletion: { tabId: string; sql: string; editor: typeof queryEditorRef.value; cancelCount: number; executionId?: string; revoked: boolean } | undefined; + +function revokeExecutionErrorFocus(event: Event) { + const detail = (event as CustomEvent<{ fromTabId?: string; tabId?: string }>).detail; + if (executionCompletion && (detail?.fromTabId === executionCompletion.tabId || (!detail?.fromTabId && detail?.tabId !== executionCompletion.tabId))) executionCompletion.revoked = true; +} + +onUnmounted(() => { + executionCompletion = undefined; + window.removeEventListener("dbx:before-tab-switch", revokeExecutionErrorFocus); +}); + watch( - () => props.activeTab.isExecuting, - (isExecuting, wasExecuting) => { + () => [props.activeTab.id, props.activeTab.isExecuting, props.activeTab.executionId, props.activeTab.cancelRequestCount ?? 0, props.activeTab.isCancelling] as const, + ([tabId, isExecuting, executionId, cancelCount, isCancelling], [previousTabId, wasExecuting]) => { + if (tabId !== previousTabId) { + executionCompletion = undefined; + return; + } if (isExecuting && !wasExecuting) { - queryEditorRef.value?.beginExecutionViewportTracking(); + const editor = queryEditorRef.value; + executionCompletion = { tabId, sql: props.activeTab.sql, editor, cancelCount, executionId, revoked: false }; + editor?.beginExecutionViewportTracking(); + } + const completion = executionCompletion; + if (completion) { + if (isCancelling || cancelCount !== completion.cancelCount) completion.revoked = true; + if (isExecuting && executionId) completion.executionId = executionId; } if (!isExecuting && wasExecuting) { + const result = props.activeTab.result; + const error = result && isQueryExecutionErrorResult(result); + // Errors are only located for an execution tracked from its start; a plain + // success still restores the cursor for a completion observed mid-flight. + if (!completion && error) return; nextTick(() => { requestAnimationFrame(() => { - queryEditorRef.value?.scrollCursorIntoView(); + if (props.activeTab.id !== tabId || props.activeTab.isExecuting) return; + if (completion) { + if (executionCompletion !== completion || queryEditorRef.value !== completion.editor) return; + executionCompletion = undefined; + } else if (executionCompletion) { + return; + } + if (error) { + if (!completion || completion.revoked || !completion.executionId || result.sourceExecutionId !== completion.executionId || props.activeTab.sql !== completion.sql || props.activeTab.result !== result) return; + const mapped = sqlErrorEditorOffset(activeResultErrorOffsetOptions()); + if (mapped) completion.editor?.scrollCursorIntoView(mapped.offset); + } else { + (completion?.editor ?? queryEditorRef.value)?.scrollCursorIntoView(); + } }); }); } }, + { flush: "sync" }, ); // Table toolbox handlers @@ -1728,6 +1771,7 @@ async function exportResultArchive() { } async function removeResultRun(runId: string) { + if (executionCompletion) executionCompletion.revoked = true; const removedActiveRun = props.activeTab.activeResultRunId === runId; const removed = await queryStore.removeResultRun(props.activeTab.id, runId); if (!removed) return; diff --git a/apps/desktop/src/components/layout/__tests__/ContentArea.errorReExecute.spec.ts b/apps/desktop/src/components/layout/__tests__/ContentArea.errorReExecute.spec.ts index a66509c0d4..bb0e0b6e4b 100644 --- a/apps/desktop/src/components/layout/__tests__/ContentArea.errorReExecute.spec.ts +++ b/apps/desktop/src/components/layout/__tests__/ContentArea.errorReExecute.spec.ts @@ -5,7 +5,16 @@ import { createI18n } from "vue-i18n"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { QueryResult, QueryTab } from "@/types/database"; -vi.mock("@/components/editor/QueryEditor.vue", () => ({ default: { render: () => null } })); +const editorViewport = vi.hoisted(() => ({ begin: vi.fn(), complete: vi.fn() })); +vi.mock("@/components/editor/QueryEditor.vue", () => ({ + __esModule: true, + default: defineComponent({ + setup(_, { expose }) { + expose({ beginExecutionViewportTracking: editorViewport.begin, scrollCursorIntoView: editorViewport.complete }); + return () => h("div"); + }, + }), +})); vi.mock("@/components/transfer/QueryResultTransferDialog.vue", () => ({ __esModule: true, default: { render: () => null } })); vi.mock("@/components/grid/DataGrid.vue", () => ({ __esModule: true, @@ -23,9 +32,10 @@ afterEach(() => { cleanups.splice(0).forEach((cleanup) => cleanup()); document.body.replaceChildren(); window.localStorage?.clear(); + vi.clearAllMocks(); }); -async function mountContentArea(options: { result?: QueryResult; isExecuting?: boolean; queryExecutionStartedAt?: number }) { +async function mountContentArea(options: { result?: QueryResult; isExecuting?: boolean; queryExecutionStartedAt?: number; resultOnly?: boolean }) { const pinia = createPinia(); setActivePinia(pinia); const connection = { id: "mysql-1", name: "MySQL", db_type: "mysql" as const, host: "localhost", port: 3306, username: "root", password: "" }; @@ -59,7 +69,7 @@ async function mountContentArea(options: { result?: QueryResult; isExecuting?: b compressSqlRequest: null, selectedSql: "", cursorPos: 0, - resultOnly: true, + resultOnly: options.resultOnly ?? true, keywordCaseRequest: null, blockDangerousRedisCommands: false, "onUpdate:activeOutputView": (_tabId: string, view: string) => { @@ -79,7 +89,7 @@ async function mountContentArea(options: { result?: QueryResult; isExecuting?: b toolbar: { stopQuery: "停止执行" }, grid: { cachedResultUnavailable: "结果不可用", reexecuteQuery: "重新执行" }, editor: { pressToExecute: "按快捷键执行", pressToSaveSql: "按快捷键保存" }, - tabs: { resultN: "结果 {n}" }, + tabs: { resultN: "结果 {n}", removeRun: "Close run {n}" }, }, }, missingWarn: false, @@ -90,6 +100,7 @@ async function mountContentArea(options: { result?: QueryResult; isExecuting?: b cleanups.push(() => app.unmount()); await nextTick(); await nextTick(); + if (options.resultOnly === false) await new Promise((resolve) => setTimeout(resolve, 50)); return { host, state, tab }; } @@ -145,4 +156,98 @@ describe("ContentArea query loading state on error re-execute", () => { expect(host.querySelector('[data-test="data-grid"]')).not.toBeNull(); expect(host.textContent).not.toContain("正在执行"); }); + + it.each(["normal", "preparation"])("locates a new error after %s execution completes", async (mode) => { + const { tab } = await mountContentArea({ resultOnly: false }); + tab.sql = "SELECT * FROM missing"; + tab.isExecuting = true; + tab.executionId = mode === "preparation" ? "preparation" : "execution"; + if (mode === "preparation") { + await nextTick(); + tab.executionId = "execution"; + } + tab.result = { ...errorResult, sourceExecutionId: "execution", rows: [["ORA-00942: table or view does not exist error occur at position: 14"]], sourceStatement: tab.sql, sourceFrom: 0, sourceTo: tab.sql.length }; + tab.isExecuting = false; + tab.executionId = undefined; + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(editorViewport.begin).toHaveBeenCalledTimes(1); + expect(editorViewport.complete).toHaveBeenCalledWith(14); + }); + + it("scrolls the cursor into view for a completion observed without a tracked start", async () => { + // The surface mounts while the tab already executes, so no rising edge is + // observed and beginExecutionViewportTracking never runs for this execution. + const { tab } = await mountContentArea({ isExecuting: true, resultOnly: false }); + expect(editorViewport.begin).not.toHaveBeenCalled(); + + tab.result = { columns: ["id"], rows: [[1]], affected_rows: 1, execution_time_ms: 12 }; + tab.isExecuting = false; + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(editorViewport.complete).toHaveBeenCalledTimes(1); + expect(editorViewport.complete).toHaveBeenCalledWith(); + }); + + it.each(["cancel", "edit", "switch", "new-execution"])("does not auto-locate after %s", async (reason) => { + const { tab } = await mountContentArea({ resultOnly: false }); + tab.sql = "SELECT * FROM missing"; + tab.isExecuting = true; + tab.executionId = "execution"; + if (reason === "cancel") { + tab.cancelRequestCount = 1; + tab.isCancelling = true; + } + if (reason === "switch") window.dispatchEvent(new CustomEvent("dbx:before-tab-switch", { detail: { fromTabId: tab.id, tabId: tab.id } })); + tab.result = { ...errorResult, sourceExecutionId: "execution", rows: [["ORA-00942: table or view does not exist error occur at position: 14"]], sourceStatement: tab.sql, sourceFrom: 0, sourceTo: tab.sql.length }; + tab.isExecuting = false; + tab.isCancelling = false; + tab.executionId = undefined; + if (reason === "edit") tab.sql += " "; + if (reason === "new-execution") { + tab.isExecuting = true; + tab.executionId = "next"; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(editorViewport.complete).not.toHaveBeenCalled(); + }); + + it("does not auto-locate an old error restored during failed preparation", async () => { + const { tab } = await mountContentArea({ resultOnly: false }); + tab.sql = "SELECT * FROM missing"; + tab.isExecuting = true; + tab.executionId = "preparation"; + await nextTick(); + tab.result = { ...errorResult, rows: [["ORA-00942 error occur at position: 14"]], sourceStatement: tab.sql, sourceFrom: 0, sourceTo: tab.sql.length }; + tab.isExecuting = false; + tab.executionId = undefined; + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(editorViewport.complete).not.toHaveBeenCalled(); + }); + + it.each(["round-trip", "close"])("keeps automatic focus revoked after a result run %s", async (action) => { + const { tab, host } = await mountContentArea({ resultOnly: false }); + tab.sql = "SELECT * FROM missing"; + tab.resultRuns = [ + { id: "first", title: "First", sequence: 1, createdAt: 1, sql: tab.sql, result: errorResult }, + { id: "second", title: "Second", sequence: 2, createdAt: 2, sql: tab.sql, result: errorResult }, + ]; + tab.activeResultRunId = "first"; + await nextTick(); + tab.isExecuting = true; + tab.executionId = "execution"; + if (action === "round-trip") { + (host.querySelectorAll("[data-result-run-tab]")[1] as HTMLButtonElement).click(); + await vi.waitFor(() => expect(tab.activeResultRunId).toBe("second")); + (host.querySelectorAll("[data-result-run-tab]")[0] as HTMLButtonElement).click(); + await vi.waitFor(() => expect(tab.activeResultRunId).toBe("first")); + } else { + (host.querySelector('[aria-label="Close run 1"]') as HTMLButtonElement).click(); + await vi.waitFor(() => expect(tab.resultRuns?.some((run) => run.id === "first")).toBe(false)); + } + tab.result = { ...errorResult, sourceExecutionId: "execution", rows: [["ORA-00942 error occur at position: 14"]], sourceStatement: tab.sql, sourceFrom: 0, sourceTo: tab.sql.length }; + tab.isExecuting = false; + tab.executionId = undefined; + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(editorViewport.complete).not.toHaveBeenCalled(); + }); }); diff --git a/apps/desktop/src/lib/__tests__/sql/errorPosition.spec.ts b/apps/desktop/src/lib/__tests__/sql/errorPosition.spec.ts index dc712dea70..2056099f0b 100644 --- a/apps/desktop/src/lib/__tests__/sql/errorPosition.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/errorPosition.spec.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "vitest"; import type { BackendError, SqlErrorPosition } from "@/lib/backend/errorUtils"; -import { mapExecutedOffsetToSource, scalarPositionToUtf16Offset, sqlErrorDisplayPosition, sqlErrorEditorOffset, sqlErrorHasMessagePosition } from "@/lib/sql/errorPosition"; +import { mapExecutedOffsetToSource, scalarPositionToUtf16Offset, sqlErrorDisplayPosition, sqlErrorEditorOffset, sqlErrorEditorRange, sqlErrorHasMessagePosition } from "@/lib/sql/errorPosition"; +import { sqlErrorDecorationRange } from "@/lib/sql/sqlDiagnostics"; import type { QueryResult } from "@/types/database"; function backendError(position?: SqlErrorPosition): BackendError { @@ -164,6 +165,58 @@ describe("sqlErrorEditorOffset", () => { }); describe("when the engine reports its position only in the error message", () => { + it("converts Oracle bytes in CRLF text while retaining typed, OB and older-call units", () => { + const sql = "SELECT /* 中文😀 */ 1\r\nFROM missing"; + const offset = sql.indexOf("missing"); + const byteOffset = new TextEncoder().encode(sql.slice(0, offset)).length; + const result = errorResult({ editorStatement: sql, message: `ORA-01722 error occur at position: ${byteOffset}` }); + expect(sqlErrorEditorOffset({ editorSql: sql, result, databaseType: "oracle" })?.offset).toBe(offset); + for (const databaseType of [undefined, "oceanbase-oracle"] as const) { + const legacy = errorResult({ editorStatement: sql, message: `ORA-01722 error occur at position: ${offset}` }); + expect(sqlErrorEditorOffset({ editorSql: sql, result: legacy, databaseType })?.offset).toBe(offset); + } + const typed = errorResult({ editorStatement: sql, position: { line: 2, column: 6, offset: byteOffset } }); + expect(sqlErrorEditorOffset({ editorSql: sql, result: typed, databaseType: "oracle" })?.offset).toBe(offset); + }); + it.each([0, 1, 4, 5, 13, 14])("expands a qualified Oracle object at relative offset %s including EOF", (relative) => { + const name = '"HR"."EMP_REF"'; + const sql = `SELECT * FROM ${name}`; + const from = sql.indexOf(name); + const offset = from + Math.min(relative, name.length); + expect(sqlErrorDecorationRange(sql, `ORA-00942: table or view does not exist error occur at position: ${offset}`, "oracle")).toEqual({ from, to: sql.length }); + }); + + it.each(["'missing'", "q'[missing]'", "nq'[missing]'", "/* missing */", '"unterminated'])("does not expand missing object names inside %s", (fragment) => { + const sql = `SELECT ${fragment}`; + const offset = sql.indexOf("missing") >= 0 ? sql.indexOf("missing") : sql.indexOf('"'); + expect(sqlErrorDecorationRange(sql, `ORA-00942 error occur at position: ${offset}`, "oracle")).toEqual({ from: offset, to: offset + 1 }); + }); + + it("rejects an Oracle offset inside a UTF-8 character or beyond the statement", () => { + const sql = "SELECT /* 中文 */ * FROM missing"; + expect(sqlErrorDecorationRange(sql, "ORA-00942 error occur at position: 11", "oracle")).toBeNull(); + expect(sqlErrorDecorationRange(sql, "ORA-00942 error occur at position: 1000", "oracle")).toBeNull(); + }); + + it("maps both ends of a rewritten object back onto its selected source statement", () => { + const source = 'SELECT * FROM "HR"."EMP_REF"'; + const executed = `SELECT *, "ID" AS "__dbx_hidden_pk" FROM "HR"."EMP_REF" LIMIT 100`; + const editorSql = `SELECT 1 FROM DUAL;\n${source};`; + const from = editorSql.indexOf(source); + const result = errorResult({ editorStatement: source, sourceFrom: from, sourceTo: from + source.length, executedStatement: executed, message: `ORA-00942 error occur at position: ${executed.indexOf('"HR"')}` }); + expect(sqlErrorEditorRange({ editorSql, result, databaseType: "oracle" })).toEqual({ from: editorSql.indexOf('"HR"'), to: from + source.length }); + }); + + it.each([ + { prefix: "ascii", byteOffset: 26, offset: 26 }, + { prefix: "中文", byteOffset: 27, offset: 23 }, + { prefix: "😀", byteOffset: 25, offset: 23 }, + ])("maps the native Oracle Agent UTF-8 offset with $prefix before the object", ({ prefix, byteOffset, offset }) => { + const sql = `SELECT /* ${prefix} */ * FROM DBX6071_MISSING`; + const result = errorResult({ editorStatement: sql, message: `ORA-00942: table or view does not exist error occur at position: ${byteOffset}` }); + expect(sqlErrorEditorOffset({ editorSql: sql, result, databaseType: "oracle" })?.offset).toBe(offset); + }); + it("resolves the Oracle Agent offset and labels it with the caret's row/column", () => { const sql = "SELECT nope_col\nFROM dual"; const result = errorResult({ diff --git a/apps/desktop/src/lib/editor/queryEditorExecutionViewport.ts b/apps/desktop/src/lib/editor/queryEditorExecutionViewport.ts index 423d8ac44e..45c834dcec 100644 --- a/apps/desktop/src/lib/editor/queryEditorExecutionViewport.ts +++ b/apps/desktop/src/lib/editor/queryEditorExecutionViewport.ts @@ -48,6 +48,14 @@ export function createQueryEditorExecutionViewportOwnership() { recordUserInteraction() { if (executionActive) userInteractedDuringExecution = true; }, + consumeErrorCompletion(): boolean { + const canLocate = executionActive && !userInteractedDuringExecution; + acceptedRequestId = undefined; + executionActive = false; + userInteractedDuringExecution = false; + cursorVisibleBeforeExecution = false; + return canLocate; + }, consumeCompletionPreservation(): boolean { const preserveViewport = acceptedRequestId !== undefined || userInteractedDuringExecution || cursorVisibleBeforeExecution; acceptedRequestId = undefined; diff --git a/apps/desktop/src/lib/sql/errorPosition.ts b/apps/desktop/src/lib/sql/errorPosition.ts index d7f264bb53..f2d0f85fbd 100644 --- a/apps/desktop/src/lib/sql/errorPosition.ts +++ b/apps/desktop/src/lib/sql/errorPosition.ts @@ -132,7 +132,13 @@ export function sqlErrorHasMessagePosition(message: string): boolean { * jumping anywhere in that case would be wrong. */ export function sqlErrorEditorOffset(options: SqlErrorOffsetOptions): EditorErrorPosition | undefined { - return resolveSqlErrorOffset(options); + const resolved = resolveSqlErrorOffset(options); + return resolved ? { offset: resolved.offset, line: resolved.line, column: resolved.column } : undefined; +} + +export function sqlErrorEditorRange(options: SqlErrorOffsetOptions): { from: number; to: number } | undefined { + const resolved = resolveSqlErrorOffset(options); + return resolved ? { from: resolved.offset, to: resolved.to } : undefined; } /** @@ -147,7 +153,7 @@ export function sqlErrorDisplayPosition(options: SqlErrorOffsetOptions): SqlErro return resolved ? { line: resolved.line, column: resolved.column } : undefined; } -function resolveSqlErrorOffset(options: SqlErrorOffsetOptions): EditorErrorPosition | undefined { +function resolveSqlErrorOffset(options: SqlErrorOffsetOptions): (EditorErrorPosition & { to: number }) | undefined { const position = options.result?.error?.errorPosition; const messageText = position ? "" : sqlErrorMessageText(options.result); if (!position && !sqlErrorHasMessagePosition(messageText)) { @@ -173,24 +179,29 @@ function resolveSqlErrorOffset(options: SqlErrorOffsetOptions): EditorErrorPosit const positionBasis = executedStatement ?? range.sql; let basisOffset: number; + let basisEnd: number; if (position) { // Walk the line/column in the text the position is relative to (scalar // values, matching PostgreSQL's character-based cursor), then convert to UTF-16. basisOffset = scalarPositionToUtf16Offset(positionBasis, position.line, position.column); + basisEnd = Math.min(positionBasis.length, basisOffset + ((positionBasis.codePointAt(basisOffset) ?? 0) > 0xffff ? 2 : 1)); } else { // Engines without a typed position report it inside the message text // (Oracle's Agent offset, `LINE n:` carets). Parse against the same basis the // typed position would use, and reuse the exact range the editor already // underlines in red so both surfaces agree. - const derived = sqlErrorDecorationRange(positionBasis, messageText); + const derived = sqlErrorDecorationRange(positionBasis, messageText, options.databaseType); if (!derived) { if (isSqlErrorPositionDebugEnabled()) logDiagnostics("unresolved:message-position", options, { hasMessagePosition: true }); return undefined; } basisOffset = Math.min(derived.from, positionBasis.length); + basisEnd = derived.to; } const drifted = Boolean(executedStatement && executedStatement !== range.sql); const sourceOffset = drifted ? mapExecutedOffsetToSource(executedStatement!, range.sql, basisOffset) : basisOffset; + const sourceEnd = drifted ? mapExecutedOffsetToSource(executedStatement!, range.sql, basisEnd) : basisEnd; + const exactRange = sourceEnd > sourceOffset && range.sql.slice(sourceOffset, sourceEnd) === positionBasis.slice(basisOffset, basisEnd); // Clamp inside the resolved statement range so a residual mismatch can never // place the caret outside the statement it belongs to. @@ -213,7 +224,8 @@ function resolveSqlErrorOffset(options: SqlErrorOffsetOptions): EditorErrorPosit display, }); } - return { offset: editorOffset, line: display.line, column: display.column }; + const width = (options.editorSql.codePointAt(editorOffset) ?? 0) > 0xffff ? 2 : 1; + return { offset: editorOffset, to: Math.min(range.to, exactRange ? range.from + sourceEnd : editorOffset + width), line: display.line, column: display.column }; } /** diff --git a/apps/desktop/src/lib/sql/sqlDiagnostics.ts b/apps/desktop/src/lib/sql/sqlDiagnostics.ts index e545e24dd7..14f1344113 100644 --- a/apps/desktop/src/lib/sql/sqlDiagnostics.ts +++ b/apps/desktop/src/lib/sql/sqlDiagnostics.ts @@ -1,3 +1,6 @@ +import type { DatabaseType } from "@/types/database"; +import { tokenizeSqlSemantic } from "@/lib/sql/semantic/tokens"; + export interface SqlErrorLocation { line: number; column: number; @@ -132,7 +135,29 @@ export function sqlErrorMessagePosition(message: string): SqlErrorMessagePositio return { kind: "offset", offset }; } -export function sqlErrorDecorationRange(sql: string, message: string): SqlErrorRange | null { +function oracleMissingObjectRange(sql: string, offset: number): SqlErrorRange | null { + const tokens = tokenizeSqlSemantic(sql, "oracle"); + const identifier = (index: number) => { + const token = tokens[index]; + return token?.kind === "word" || (token?.kind === "quoted_identifier" && token.quote === '"' && token.closed === true); + }; + let index = tokens.findIndex((token) => token.span.start <= offset && offset < token.span.end); + if (index < 0) index = tokens.findIndex((token, candidate) => token.span.end === offset && identifier(candidate)); + if (tokens[index]?.text === "." && identifier(index - 1) && identifier(index + 1)) index -= 1; + if (!identifier(index)) return null; + // An Oracle object reference is a single name or `schema.table`; stop there + // instead of walking a column access (`hr.emp.empno`) or a longer chain. + let first = index; + let last = index; + if (tokens[first - 1]?.text === "." && identifier(first - 2)) { + first -= 2; + } else if (tokens[last + 1]?.text === "." && identifier(last + 2)) { + last += 2; + } + return { from: tokens[first].span.start, to: tokens[last].span.end }; +} + +export function sqlErrorDecorationRange(sql: string, message: string, databaseType?: DatabaseType): SqlErrorRange | null { const position = sqlErrorMessagePosition(message); if (!position) return null; @@ -142,7 +167,25 @@ export function sqlErrorDecorationRange(sql: string, message: string): SqlErrorR return { from: offset, to: offset + 1 }; } - const { offset } = position; + let { offset } = position; + if (databaseType === "oracle") { + let bytes = 0; + let utf16 = 0; + for (const character of sql) { + if (bytes >= offset) break; + const point = character.codePointAt(0)!; + bytes += point <= 0x7f ? 1 : point <= 0x7ff ? 2 : point <= 0xffff ? 3 : 4; + utf16 += character.length; + } + if (bytes !== offset) return null; + offset = utf16; + } + // An Oracle Agent offset is a byte offset only for Oracle itself; other + // engines that happen to emit ORA-00942 keep their own position units. + if (databaseType === "oracle" && /\bORA-00942\b/i.test(message)) { + const object = oracleMissingObjectRange(sql, offset); + if (object) return object; + } if (offset >= sql.length) return null; // For qualified invalid identifiers the offset can point later in the // selector, so prefer the named token. diff --git a/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts index 39f5823e88..efa9910484 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts @@ -1530,13 +1530,13 @@ describe("queryStore multi-statement errors", () => { expect(tab.activeResultRunId).toBeUndefined(); }); - async function prepareDiskBackedAutoKeptResult() { + async function prepareDiskBackedAutoKeptResult(initialError = false) { mocks.defaultAutoKeepResults = true; const { useQueryStore } = await import("@/stores/queryStore"); const cache = await import("@/lib/tabs/tabResultCache"); const store = useQueryStore(); const id = store.createTab("mysql-1", "app", "Restore"); - mocks.executeMulti.mockResolvedValue([{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]); + mocks.executeMulti.mockResolvedValue(initialError ? [{ columns: ["Error"], rows: [["ORA-00942 error occur at position: 14"]], execution_error: true, affected_rows: 0, execution_time_ms: 1 }] : [{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]); await store.executeCurrentSql("SELECT 1"); const tab = store.tabs.find((item) => item.id === id)!; const run = tab.resultRuns![0]!; @@ -1556,6 +1556,7 @@ describe("queryStore multi-statement errors", () => { const execution = store.executeCurrentSql("SELECT 2"); expect(tab.isExecuting).toBe(true); expect(tab.executionId).toBeTruthy(); + const preparationId = tab.executionId; await expect(store.executeCurrentSql("SELECT 2")).resolves.toBe(false); expect(mocks.executeMulti).not.toHaveBeenCalled(); read.resolve(snapshot); @@ -1564,6 +1565,71 @@ describe("queryStore multi-statement errors", () => { expect(tab.resultRuns).toHaveLength(2); expect(tab.isExecuting).toBe(false); expect(tab.executionId).toBeUndefined(); + expect(tab.result?.sourceExecutionId).toBe(mocks.executeMulti.mock.calls[0]?.[4]); + expect(tab.result?.sourceExecutionId).not.toBe(preparationId); + }); + + it("does not relabel an old error restored when connection preparation fails", async () => { + const { store, tab, read, snapshot } = await prepareDiskBackedAutoKeptResult(true); + const oldId = snapshot?.result?.sourceExecutionId; + mocks.ensureConnected.mockRejectedValueOnce(new Error("connection preparation failed")); + const execution = store.executeCurrentSql("SELECT 2"); + const preparationId = tab.executionId; + read.resolve(snapshot); + await expect(execution).resolves.toBe(false); + expect(tab.result?.execution_error).toBe(true); + expect(tab.result?.sourceExecutionId).toBe(oldId); + expect(tab.result?.sourceExecutionId).not.toBe(preparationId); + expect(mocks.executeMulti).not.toHaveBeenCalled(); + }); + + it("binds a new caught SQL error to the execution that produced it", async () => { + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const id = store.createTab("mysql-1", "app", "Error provenance"); + mocks.executeMulti.mockRejectedValueOnce(new Error("ORA-00942 error occur at position: 14")); + await store.executeCurrentSql("SELECT * FROM missing"); + const result = store.tabs.find((tab) => tab.id === id)!.result; + expect(result?.execution_error).toBe(true); + expect(result?.sourceExecutionId).toBe(mocks.executeMulti.mock.calls[0]?.[4]); + expect(result?.sourceExecutionId).toBeTruthy(); + }); + + it("binds returned batch errors without relabeling the replaced result group's other members", async () => { + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const id = store.createTab("mysql-1", "app", "Group provenance"); + mocks.executeMulti.mockResolvedValueOnce([ + { columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }, + { columns: ["Error"], rows: [["ORA-00942 error occur at position: 14"]], execution_error: true, affected_rows: 0, execution_time_ms: 1 }, + ]); + await store.executeTabSql(id, "SELECT 1; SELECT * FROM missing"); + const tab = store.tabs.find((tab) => tab.id === id)!; + const oldId = tab.results?.[0]?.sourceExecutionId; + expect(oldId).toBe(mocks.executeMulti.mock.calls[0]?.[4]); + expect(tab.result?.sourceExecutionId).toBe(oldId); + store.setActiveResultIndex(id, 0); + mocks.executeMulti.mockResolvedValueOnce([{ columns: ["value"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 }]); + await store.executeTabSql(id, "SELECT 2", { replaceActiveResultInGroup: true, preserveResultDuringExecution: true }); + expect(tab.results?.[1]?.sourceExecutionId).toBe(oldId); + expect(tab.result?.sourceExecutionId).toBe(mocks.executeMulti.mock.calls[1]?.[4]); + expect(tab.result?.sourceExecutionId).not.toBe(oldId); + }); + + it("does not relabel the displayed result when only advancing its retained cursor", async () => { + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const id = store.createTab("mysql-1", "app", "Cursor provenance"); + mocks.executeMulti.mockResolvedValueOnce([{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]); + await store.executeTabSql(id, "SELECT 1"); + const tab = store.tabs.find((tab) => tab.id === id)!; + const oldResult = tab.result; + const oldId = oldResult?.sourceExecutionId; + mocks.executeMulti.mockResolvedValueOnce([{ columns: ["value"], rows: [[2]], affected_rows: 0, execution_time_ms: 1, session_id: "cursor" }]); + await store.executeTabSql(id, "SELECT 2", { retainDisplayedResult: true, preserveResultDuringExecution: true }); + expect(tab.result).toBe(oldResult); + expect(tab.result?.sourceExecutionId).toBe(oldId); + expect(tab.result?.sourceExecutionId).not.toBe(mocks.executeMulti.mock.calls[1]?.[4]); }); it("rejects executeCurrentSql when another query is currently executing", async () => { @@ -1591,8 +1657,8 @@ describe("queryStore multi-statement errors", () => { expect(tab.isExecuting).toBe(false); }); - it("cancels result restoration without dispatch and ignores its late payload", async () => { - const { store, id, tab, read, snapshot } = await prepareDiskBackedAutoKeptResult(); + it.each([false, true])("cancels result restoration without dispatch and ignores its late payload (old error: %s)", async (initialError) => { + const { store, id, tab, read, snapshot } = await prepareDiskBackedAutoKeptResult(initialError); const execution = store.executeCurrentSql("SELECT 2"); await expect(store.cancelTabExecution(id)).resolves.toBe(true); expect(tab.isExecuting).toBe(false); @@ -1604,6 +1670,7 @@ describe("queryStore multi-statement errors", () => { await expect(execution).resolves.toBe(false); expect(mocks.executeMulti).toHaveBeenCalledTimes(1); expect(tab.result?.rows).toEqual([[3]]); + expect(tab.result?.sourceExecutionId).toBe(mocks.executeMulti.mock.calls[0]?.[4]); expect(tab.isExecuting).toBe(false); }); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index a95011c728..1884087dab 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -9516,6 +9516,7 @@ export const useQueryStore = defineStore("query", () => { producedResult = true; return producedResult; } + for (const result of results) result.sourceExecutionId = executionId; if (batchResume) { const mergedResults = mergeBatchQueryResults(batchResume.previousResults, results); const preferredResult = results.find((result) => isQueryExecutionErrorResult(result)) ?? results[results.length - 1] ?? mergedResults[mergedResults.length - 1]; @@ -9749,6 +9750,7 @@ export const useQueryStore = defineStore("query", () => { return false; } const errorResult = toErrorResult(e); + errorResult.sourceExecutionId = executionId; annotateSingleStatementErrorResult(errorResult, queryBaseSql, errorLocateContext?.databaseType, errorLocateContext?.sourceOffset, errorLocateContext?.parameterOptions, errorLocateContext?.executedSql); const activeGroupIndex = current.activeResultIndex; const activeGroupResults = current.results; diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 8de1ef0070..786845254f 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -1766,6 +1766,8 @@ export interface QueryResult { appended_from_row_count?: number; /** Set for synthesized query execution failures. */ execution_error?: true; + /** Frontend execution that published this result; absent on older cached results. */ + sourceExecutionId?: string; /** Set only for SQL Server informational messages emitted by the backend. */ server_message?: true; /** Manual-transaction UX marker for sticky proven-read-only dialects (Oracle, diff --git a/packages/app-tests/sqlDiagnostics.test.ts b/packages/app-tests/sqlDiagnostics.test.ts index 0ee6c40f3f..ff07e08b99 100644 --- a/packages/app-tests/sqlDiagnostics.test.ts +++ b/packages/app-tests/sqlDiagnostics.test.ts @@ -86,3 +86,35 @@ test("strips the generated OFFSET ROWS FETCH FIRST suffix fully", () => { assert.equal(sqlErrorSqlMatchesEditor(editorSql, executedSql), true); }); + +test("underlines only the missing schema.table object, not a following column access", () => { + const sql = "SELECT hr.emp.empno FROM hr.emp"; + const message = (offset: number) => `ORA-00942: table or view does not exist error occur at position: ${offset}`; + const objectFrom = sql.indexOf("hr.emp"); + const objectTo = objectFrom + "hr.emp".length; + + assert.deepEqual(sqlErrorDecorationRange(sql, message(objectFrom), "oracle"), { from: objectFrom, to: objectTo }); + // A position inside `emp` still belongs to `hr.emp`, not to `emp.empno`. + assert.deepEqual(sqlErrorDecorationRange(sql, message(objectFrom + 4), "oracle"), { from: objectFrom, to: objectTo }); + + const tableFrom = sql.lastIndexOf("hr.emp"); + assert.deepEqual(sqlErrorDecorationRange(sql, message(tableFrom), "oracle"), { from: tableFrom, to: tableFrom + "hr.emp".length }); +}); + +test("keeps a reported keyword position tight to the keyword", () => { + const sql = "SELECT hr.emp.empno FROM hr.emp"; + const keywordFrom = sql.indexOf("FROM"); + + // The walk must not extend a keyword position into the object that follows it. + assert.deepEqual(sqlErrorDecorationRange(sql, `ORA-00942 error occur at position: ${keywordFrom}`, "oracle"), { from: keywordFrom, to: keywordFrom + "FROM".length }); +}); + +test("does not apply Oracle object expansion to other database types", () => { + const sql = "SELECT /* 中文 */ * FROM missing"; + const utf16Offset = sql.indexOf("missing"); + const byteOffset = new TextEncoder().encode(sql.slice(0, utf16Offset)).length; + const message = `ORA-00942: table or view does not exist error occur at position: ${byteOffset}`; + + assert.deepEqual(sqlErrorDecorationRange(sql, message, "oracle"), { from: utf16Offset, to: utf16Offset + "missing".length }); + assert.deepEqual(sqlErrorDecorationRange(sql, message, "postgres"), { from: byteOffset, to: byteOffset + 1 }); +});