feat(editor): locate Oracle errors by message position with cursor anchor

This commit is contained in:
zipg
2026-09-21 12:59:35 +08:00
committed by GitHub
parent f19d842b8f
commit 0668bdc406
12 changed files with 257 additions and 40 deletions
@@ -139,7 +139,7 @@ import { TABLE_FONT_SIZE_MAX, TABLE_FONT_SIZE_MIN, useSettingsStore, type DataGr
import { useToast } from "@/composables/useToast";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { canCancelQueryExecution, isActiveResultLoading, queryExecutionLabelKey } from "@/lib/sql/queryExecutionState";
import { sqlErrorEditorOffset, logSqlErrorPosition } from "@/lib/sql/errorPosition";
import { sqlErrorDisplayPosition, sqlErrorEditorOffset, logSqlErrorPosition } from "@/lib/sql/errorPosition";
import {
databaseDisplayNameForTab,
executionSummaryItems,
@@ -349,8 +349,14 @@ const activeResultExecutionTarget = computed(() => queryStore.activeResultExecut
const activeResultConnection = computed(() => (activeResultExecutionTarget.value ? connectionStore.getConfig(activeResultExecutionTarget.value.connectionId) : props.activeConnection));
const activeResultConnectionId = computed(() => activeResultExecutionTarget.value?.connectionId ?? props.activeTab.connectionId);
// Row/column locate only makes sense for SQL editor tabs: data/preview tabs have
// no user statement to map the backend position onto.
const activeResultErrorPosition = computed(() => (props.activeTab.mode === "query" ? props.activeTab.result?.error?.errorPosition : undefined));
// no user statement to map the backend position onto. Engines without a typed
// position (Oracle) report it in the error text, so the label falls back to the
// same resolver the jump uses — the button never appears when clicking it could
// not move the caret.
const activeResultErrorPosition = computed(() => {
if (props.activeTab.mode !== "query") return undefined;
return sqlErrorDisplayPosition(activeResultErrorOffsetOptions());
});
const activeResultDatabase = computed(() => activeResultExecutionTarget.value?.database ?? props.activeTab.database);
const activeResultSchema = computed(() => activeResultExecutionTarget.value?.schema ?? props.activeTab.schema);
const activeEffectiveDatabaseType = computed(() => effectiveDatabaseTypeForConnection(activeResultConnection.value));
@@ -1377,6 +1383,17 @@ function focusErrorPosition(offset: number): boolean {
* to a cross-surface event when this surface only renders the shared result pane
* (the editor lives in another group).
*/
function activeResultErrorOffsetOptions() {
const result = props.activeTab.result;
return {
editorSql: props.activeTab.sql,
result,
resultIndex: result?.statement_index ?? props.activeTab.activeResultIndex,
databaseType: activeEffectiveDatabaseType.value,
parameterOptions: activeSqlStatementParameterOptions.value,
};
}
function locateActiveResultError() {
const result = props.activeTab.result;
logSqlErrorPosition("locate:invoke", {
@@ -1390,13 +1407,7 @@ function locateActiveResultError() {
editorLength: props.activeTab.sql.length,
resultIsError: Boolean(result && isQueryExecutionErrorResult(result)),
});
const mapped = sqlErrorEditorOffset({
editorSql: props.activeTab.sql,
result,
resultIndex: result?.statement_index ?? props.activeTab.activeResultIndex,
databaseType: activeEffectiveDatabaseType.value,
parameterOptions: activeSqlStatementParameterOptions.value,
});
const mapped = sqlErrorEditorOffset(activeResultErrorOffsetOptions());
if (!mapped) {
logSqlErrorPosition("locate:unavailable", {
tabId: props.activeTab.id,
+73 -3
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import type { BackendError, SqlErrorPosition } from "@/lib/backend/errorUtils";
import { mapExecutedOffsetToSource, scalarPositionToUtf16Offset, sqlErrorEditorOffset } from "@/lib/sql/errorPosition";
import { mapExecutedOffsetToSource, scalarPositionToUtf16Offset, sqlErrorDisplayPosition, sqlErrorEditorOffset, sqlErrorHasMessagePosition } from "@/lib/sql/errorPosition";
import type { QueryResult } from "@/types/database";
function backendError(position?: SqlErrorPosition): BackendError {
@@ -17,11 +17,11 @@ function backendError(position?: SqlErrorPosition): BackendError {
};
}
function errorResult(options: { editorStatement?: string; sourceFrom?: number; sourceTo?: number; executedStatement?: string; position?: SqlErrorPosition }): QueryResult {
function errorResult(options: { editorStatement?: string; sourceFrom?: number; sourceTo?: number; executedStatement?: string; position?: SqlErrorPosition; message?: string }): QueryResult {
const statement = options.editorStatement ?? "";
return {
columns: ["Error"],
rows: [["ERROR: relation does not exist"]],
rows: [[options.message ?? "ERROR: relation does not exist"]],
affected_rows: 0,
execution_time_ms: 0,
execution_error: true,
@@ -163,6 +163,76 @@ describe("sqlErrorEditorOffset", () => {
});
});
describe("when the engine reports its position only in the error message", () => {
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({
editorStatement: sql,
message: 'ORA-00904: "NOPE_COL": invalid identifier error occur at position: 7',
});
expect(sqlErrorEditorOffset({ editorSql: sql, result })).toEqual({ offset: 7, line: 1, column: 8 });
// The label must describe the caret, not the executed statement it was projected from.
expect(sqlErrorDisplayPosition({ editorSql: sql, result })).toEqual({ line: 1, column: 8 });
});
it("maps an offset on a later line to that line and column", () => {
const sql = "SELECT 1\nFROM dual\nWHERE to_number('abc') = 1";
const offset = sql.indexOf("'abc'");
const result = errorResult({ editorStatement: sql, message: `ORA-01722: invalid number error occur at position: ${offset}` });
const mapped = sqlErrorEditorOffset({ editorSql: sql, result });
// `'abc'` starts at column 17 of "WHERE to_number('abc') = 1".
expect(mapped).toEqual({ offset, line: 3, column: 17 });
expect(sqlErrorDisplayPosition({ editorSql: sql, result })).toEqual({ line: 3, column: 17 });
});
it("projects a message offset back onto the source when DBX rewrote the statement", () => {
const source = "SELECT * FROM users";
const executed = 'SELECT *, "id" AS "__dbx_hidden_pk" FROM users LIMIT 100 OFFSET 0;';
const result = errorResult({
editorStatement: source,
executedStatement: executed,
message: `ORA-00942: table or view does not exist error occur at position: ${executed.indexOf("users")}`,
});
const mapped = sqlErrorEditorOffset({ editorSql: source, result });
expect(mapped?.offset).toBe(source.indexOf("users"));
// The label follows the projected source text, so it stays consistent with the caret.
expect(sqlErrorDisplayPosition({ editorSql: source, result })).toEqual({ line: 1, column: mapped!.offset + 1 });
});
it("uses a textual location when the message carries one", () => {
const sql = "SELECT 1\nFROM missing";
const result = errorResult({ editorStatement: sql, message: "syntax error at line 2, column 6" });
expect(sqlErrorEditorOffset({ editorSql: sql, result })).toEqual({ offset: 14, line: 2, column: 6 });
});
it("returns undefined when neither the envelope nor the message carries a position", () => {
const sql = "SELECT nope_col FROM dual";
const result = errorResult({ editorStatement: sql, message: 'ORA-00904: "NOPE_COL": invalid identifier' });
expect(sqlErrorEditorOffset({ editorSql: sql, result })).toBeUndefined();
expect(sqlErrorDisplayPosition({ editorSql: sql, result })).toBeUndefined();
});
it("still returns undefined when the message offset cannot be found in the editor", () => {
const result = errorResult({ editorStatement: "SELECT * FROM gone", message: "ORA-00942: table or view does not exist error occur at position: 14" });
expect(sqlErrorEditorOffset({ editorSql: "SELECT 1", result })).toBeUndefined();
});
});
describe("sqlErrorHasMessagePosition", () => {
it("detects the positions engines put in the message text", () => {
expect(sqlErrorHasMessagePosition('ORA-00904: "X": invalid identifier error occur at position: 9')).toBe(true);
expect(sqlErrorHasMessagePosition("syntax error at line 2, column 4")).toBe(true);
expect(sqlErrorHasMessagePosition("ERROR 1054 (42S22): Unknown column 'nope_col' in 'field list'")).toBe(false);
expect(sqlErrorHasMessagePosition("")).toBe(false);
});
});
describe("scalarPositionToUtf16Offset", () => {
it("converts scalar line/column to a UTF-16 offset", () => {
expect(scalarPositionToUtf16Offset("SELECT '😀' FROM t", 1, 12)).toBe(12);
+103 -11
View File
@@ -1,5 +1,6 @@
import type { DatabaseType, QueryResult } from "@/types/database";
import type { SqlParameterOptions } from "@/lib/sql/sqlParameters";
import { sqlErrorDecorationRange, sqlErrorMessagePosition } from "@/lib/sql/sqlDiagnostics";
import { splitSqlStatementRanges } from "@/lib/sql/sqlStatementRanges";
import { resultSourceRange } from "@/lib/tabs/tabPresentation";
@@ -12,6 +13,12 @@ export interface EditorErrorPosition {
column: number;
}
/** Row/column shown next to the error; always relative to the editor statement. */
export interface SqlErrorDisplayPosition {
line: number;
column: number;
}
export interface SqlErrorOffsetOptions {
editorSql: string;
result: QueryResult | undefined | null;
@@ -86,6 +93,25 @@ function logDiagnostics(stage: string, options: SqlErrorOffsetOptions, extra: Re
logSqlErrorPosition(stage, details);
}
/**
* The user-facing error text of a result, i.e. the same string the editor
* underlines in red (see `QueryEditor`'s `executionError` prop).
*/
export function sqlErrorMessageText(result: QueryResult | undefined | null): string {
return String(result?.rows?.[0]?.[0] ?? "");
}
/**
* Whether an error message names a position DBX can resolve.
*
* Used to decide whether a synthesized single-statement error result is worth
* annotating with its source range: engines that report no typed position
* (Oracle) still carry a parseable one in the message text.
*/
export function sqlErrorHasMessagePosition(message: string): boolean {
return !!message && sqlErrorMessagePosition(message) !== null;
}
/**
* Translate a backend-reported SQL error position into an absolute offset in the
* current editor document.
@@ -96,15 +122,36 @@ function logDiagnostics(stage: string, options: SqlErrorOffsetOptions, extra: Re
* is first resolved inside `executedStatement` and then projected back onto the
* user's `sourceStatement` before it is placed in the editor.
*
* Returns `undefined` only when the result carries no position, or when
* {@link resultSourceRange} cannot prove the result still maps to the same
* statement text in the editor (stale editor / different statement) — jumping
* anywhere in that case would be wrong.
* Falls back to the position carried by the error text when the backend reports
* no typed one (Oracle: `error occur at position: N`), using the same parser the
* editor's red underline uses, so both surfaces always agree.
*
* Returns `undefined` only when neither the envelope nor the error text carries a
* position, or when {@link resultSourceRange} cannot prove the result still maps
* to the same statement text in the editor (stale editor / different statement) —
* jumping anywhere in that case would be wrong.
*/
export function sqlErrorEditorOffset(options: SqlErrorOffsetOptions): EditorErrorPosition | undefined {
return resolveSqlErrorOffset(options);
}
/**
* The row/column to show next to the error, using the driver-reported position
* when the backend provides one and the position parsed from the error message
* otherwise. Sharing {@link resolveSqlErrorOffset} with the jump means the
* "locate error" button is only shown when clicking it can actually move the
* caret.
*/
export function sqlErrorDisplayPosition(options: SqlErrorOffsetOptions): SqlErrorDisplayPosition | undefined {
const resolved = resolveSqlErrorOffset(options);
return resolved ? { line: resolved.line, column: resolved.column } : undefined;
}
function resolveSqlErrorOffset(options: SqlErrorOffsetOptions): EditorErrorPosition | undefined {
const position = options.result?.error?.errorPosition;
if (!position) {
if (isSqlErrorPositionDebugEnabled()) logDiagnostics("skip:no-error-position", options, {});
const messageText = position ? "" : sqlErrorMessageText(options.result);
if (!position && !sqlErrorHasMessagePosition(messageText)) {
if (isSqlErrorPositionDebugEnabled()) logDiagnostics("skip:no-error-position", options, { hasMessagePosition: false });
return undefined;
}
@@ -125,18 +172,36 @@ export function sqlErrorEditorOffset(options: SqlErrorOffsetOptions): EditorErro
const executedStatement = options.result?.executedStatement;
const positionBasis = executedStatement ?? range.sql;
// 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.
const basisOffset = scalarPositionToUtf16Offset(positionBasis, position.line, position.column);
let basisOffset: 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);
} 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);
if (!derived) {
if (isSqlErrorPositionDebugEnabled()) logDiagnostics("unresolved:message-position", options, { hasMessagePosition: true });
return undefined;
}
basisOffset = Math.min(derived.from, positionBasis.length);
}
const drifted = Boolean(executedStatement && executedStatement !== range.sql);
const sourceOffset = drifted ? mapExecutedOffsetToSource(executedStatement!, range.sql, basisOffset) : basisOffset;
// Clamp inside the resolved statement range so a residual mismatch can never
// place the caret outside the statement it belongs to.
const editorOffset = Math.max(range.from, Math.min(range.from + sourceOffset, range.to));
// Report the row/column of the *source* statement, so the label always points
// at the same character the caret lands on (the position itself is relative to
// the executed statement, which DBX may have rewritten).
const display = utf16OffsetToScalarPosition(range.sql, clamp(sourceOffset, 0, range.sql.length));
if (isSqlErrorPositionDebugEnabled()) {
logDiagnostics("resolved", options, {
position,
position: position ?? { derivedFromMessage: true },
rangeFrom: range.from,
rangeTo: range.to,
rangeSql: previewText(range.sql),
@@ -145,9 +210,36 @@ export function sqlErrorEditorOffset(options: SqlErrorOffsetOptions): EditorErro
basisOffset,
sourceOffset,
editorOffset,
display,
});
}
return { offset: editorOffset, line: position.line, column: position.column };
return { offset: editorOffset, line: display.line, column: display.column };
}
/**
* Inverse of {@link scalarPositionToUtf16Offset}: the 1-based line and column
* (counted in Unicode scalar values) of a UTF-16 offset. Offsets past the end
* clamp to the last character.
*/
export function utf16OffsetToScalarPosition(text: string, offset: number): { line: number; column: number } {
const characters = Array.from(text);
if (characters.length === 0) return { line: 1, column: 1 };
const target = Math.max(0, Math.min(Math.floor(offset), text.length - 1));
let consumed = 0;
let line = 1;
let column = 1;
for (const character of characters) {
const width = character.length;
if (consumed >= target) break;
consumed += width;
if (character === "\n") {
line += 1;
column = 1;
} else {
column += 1;
}
}
return { line, column };
}
/**
+38 -11
View File
@@ -104,20 +104,47 @@ function oracleInvalidIdentifierRange(sql: string, message: string, position: nu
return bestRange;
}
export function sqlErrorDecorationRange(sql: string, message: string): SqlErrorRange | null {
/**
* A position a driver reported in its error text, without needing the SQL it
* refers to. Engines that do not attach a typed position to their error
* envelope (Oracle) still name one in the message.
*/
export type SqlErrorMessagePosition = { kind: "location"; location: SqlErrorLocation } | { kind: "offset"; offset: number };
const ORACLE_AGENT_POSITION_PATTERN = /\berror\s+occur(?:red)?\s+at\s+position\s*:\s*(\d+)\b/i;
/**
* Parse the position carried by a driver error message.
*
* Textual locations (`line N, column M`, `LINE N:` with a `^` caret, and the
* line-only form MySQL emits) win over the Oracle Agent's absolute offset, the
* same precedence {@link sqlErrorDecorationRange} has always used.
*/
export function sqlErrorMessagePosition(message: string): SqlErrorMessagePosition | null {
const location = parseSqlErrorLocation(message);
if (location) {
const offset = lineColumnToOffset(sql, location);
if (location) return { kind: "location", location };
// Oracle Agent reports a zero-based absolute offset into the statement text.
const positionMatch = ORACLE_AGENT_POSITION_PATTERN.exec(message);
if (!positionMatch?.[1]) return null;
const offset = Number.parseInt(positionMatch[1], 10);
if (!Number.isSafeInteger(offset) || offset < 0) return null;
return { kind: "offset", offset };
}
export function sqlErrorDecorationRange(sql: string, message: string): SqlErrorRange | null {
const position = sqlErrorMessagePosition(message);
if (!position) return null;
if (position.kind === "location") {
const offset = lineColumnToOffset(sql, position.location);
if (offset == null || offset >= sql.length) return null;
return { from: offset, to: offset + 1 };
}
// Oracle Agent reports a zero-based absolute offset. For qualified invalid
// identifiers it can point later in the selector, so prefer the named token.
const positionMatch = /\berror\s+occur(?:red)?\s+at\s+position\s*:\s*(\d+)\b/i.exec(message);
if (!positionMatch?.[1]) return null;
const position = Number.parseInt(positionMatch[1], 10);
if (!Number.isSafeInteger(position) || position < 0 || position >= sql.length) return null;
return oracleInvalidIdentifierRange(sql, message, position) ?? { from: position, to: position + 1 };
const { offset } = position;
if (offset >= sql.length) return null;
// For qualified invalid identifiers the offset can point later in the
// selector, so prefer the named token.
return oracleInvalidIdentifierRange(sql, message, offset) ?? { from: offset, to: offset + 1 };
}
+5 -4
View File
@@ -11,7 +11,7 @@ import type { DeletedConnectionTabKeepMode } from "@/lib/tabs/deletedConnectionT
import type { BatchSqlExecution, ConnectionConfig, DatabaseType, IndexInfo, NacosConfigEditorViewport, ObjectBrowserFilter, ObjectBrowserViewport, ObjectSource, ObjectSourceKind, QueryResult, QueryResultSourceColumnRef, QueryTab, TableInfoTab, TableStructureEditorTarget } from "@/types/database";
import { orderPinnedFirst } from "@/lib/app/pinnedItems";
import { canCancelQueryExecution } from "@/lib/sql/queryExecutionState";
import { isSqlErrorPositionDebugEnabled, logSqlErrorPosition } from "@/lib/sql/errorPosition";
import { isSqlErrorPositionDebugEnabled, logSqlErrorPosition, sqlErrorHasMessagePosition, sqlErrorMessageText } from "@/lib/sql/errorPosition";
import { buildExplainSql, parseExplainResult, parseDamengExplainText, parseOracleExplainText, sqlServerExplainResult, type BuildExplainSqlResult, type ExplainPlanDatabaseType } from "@/lib/diagram/explainPlan";
import { mysqlExplainCompatibilityHint } from "@/lib/diagram/mysqlExplainCompatibility";
import { allEditableColumnsWriteable, allPrimaryKeysPresent, analyzeEditableQueryEditability, analyzeSelectStructureForDisplay, resolveMetadataColumnName, resolveSourceColumnsByOrdinal, sourceColumnsForResult, type EditableQueryInfo, type EditableQuerySource } from "@/lib/sql/sqlAnalysis";
@@ -425,11 +425,12 @@ function annotateQueryResultSources(
* The core returns per-statement error results for batches, but a
* single-statement failure aborts the whole execute-multi command, leaving the
* frontend to synthesize the error result here. Only annotated when the error
* actually carries a position and the submission is a single statement (a
* multi-statement thrown error's position cannot be attributed to one statement).
* actually carries a position — either typed (PostgreSQL) or parseable from the
* message text (Oracle's Agent offset) — and the submission is a single statement
* (a multi-statement thrown error's position cannot be attributed to one statement).
*/
function annotateSingleStatementErrorResult(errorResult: QueryResult, sourceSql: string, databaseType: DatabaseType | undefined, sourceOffset: number | undefined, parameterOptions: SqlParameterOptions | undefined, executedSql: string | undefined): void {
if (!errorResult.error?.errorPosition) return;
if (!errorResult.error?.errorPosition && !sqlErrorHasMessagePosition(sqlErrorMessageText(errorResult))) return;
if (splitSqlStatementRanges(sourceSql, databaseType, parameterOptions).length !== 1) return;
annotateQueryResultSources([errorResult], sourceSql, undefined, databaseType, sourceOffset, parameterOptions, executedSql);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

+17 -1
View File
@@ -1,6 +1,6 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { parseSqlErrorLocation, sqlErrorDecorationRange, sqlErrorSqlMatchesEditor } from "../../apps/desktop/src/lib/sql/sqlDiagnostics.ts";
import { parseSqlErrorLocation, sqlErrorDecorationRange, sqlErrorMessagePosition, sqlErrorSqlMatchesEditor } from "../../apps/desktop/src/lib/sql/sqlDiagnostics.ts";
test("locates the Oracle invalid identifier reported by the Agent", () => {
const sql = "select x.* from si_price_adjust_task t;";
@@ -49,6 +49,22 @@ test("keeps existing line-column and PostgreSQL caret parsing", () => {
assert.deepEqual(sqlErrorDecorationRange("SELECT 1\nFROM bad", "syntax error at line 2, column 2"), { from: 10, to: 11 });
});
test("parses the Oracle Agent offset without needing the SQL it refers to", () => {
assert.deepEqual(sqlErrorMessagePosition('ORA-00904: "NOPE_COL": invalid identifier error occur at position: 2270'), { kind: "offset", offset: 2270 });
assert.deepEqual(sqlErrorMessagePosition("error occurred at position: 0"), { kind: "offset", offset: 0 });
});
test("prefers a textual location over the Oracle Agent offset", () => {
assert.deepEqual(sqlErrorMessagePosition("syntax error at line 2, column 4 error occur at position: 9"), { kind: "location", location: { line: 1, column: 3 } });
assert.deepEqual(sqlErrorMessagePosition('ERROR: column "bad" does not exist\nLINE 3: SELECT bad\n ^'), { kind: "location", location: { line: 2, column: 15 } });
});
test("returns null when the message carries no usable position", () => {
assert.equal(sqlErrorMessagePosition("ERROR 1054 (42S22): Unknown column 'nope_col' in 'field list'"), null);
assert.equal(sqlErrorMessagePosition("error occur at position: nope"), null);
assert.equal(sqlErrorMessagePosition('ORA-00904: "X": invalid identifier'), null);
});
test("matches DBX pagination rewrites without accepting unrelated stale SQL", () => {
const editorSql = "SELECT *\nFROM projects\nWHERE ;";
const executedSql = "SELECT *\nFROM projects\nWHERE LIMIT 100;";