feat(editor): locate oracle missing objects without stealing focus
This commit is contained in:
1 parent
9177568655
commit
be5f06fd60
13 files changed
+424
-29
No files matched your search
@@ -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"
|
||||
|
||||
+20
@@ -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 });
|
||||
|
||||
@@ -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 [
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
+109
-4
@@ -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();
|
||||
});
|
||||
});
|
||||
+54
-1
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
+32
@@ -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 });
|
||||
});
|
||||
Reference in new issue
Block a user