From a1dfc81d4cf6a453f553831100f9eb4447a878ef Mon Sep 17 00:00:00 2001 From: zipg Date: Thu, 6 Aug 2026 23:16:35 +0800 Subject: [PATCH] fix(oracle): highlight execution error position in editor --- .../src/components/editor/QueryEditor.vue | 11 ++-- apps/desktop/src/lib/sql/sqlDiagnostics.ts | 50 +++++++++++++++++++ packages/app-tests/sqlDiagnostics.test.ts | 49 ++++++++++++++++++ 3 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 packages/app-tests/sqlDiagnostics.test.ts diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index d5322df2e0..04aa7e410a 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -79,7 +79,7 @@ import { type SqlObjectNavigationTarget, } from "@/lib/sql/sqlNavigation"; import { buildHoverTableSql, ddlForHoverPreview, hoverTableMatchesScope, quoteQualifiedName, reformatHoverDdl, scopeHoverTables, type HoverTableScope } from "@/lib/editor/hoverTableSql"; -import { lineColumnToOffset, parseSqlErrorLocation } from "@/lib/sql/sqlDiagnostics"; +import { lineColumnToOffset, sqlErrorDecorationRange as resolveSqlErrorDecorationRange } from "@/lib/sql/sqlDiagnostics"; import { DBX_TABLE_REFERENCE_MIME, DBX_TABLE_REFERENCE_DROP_EVENT, @@ -2208,14 +2208,11 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number) function sqlErrorDecorationRange(currentState: import("@codemirror/state").EditorState) { if (!props.executionError) return []; if (!props.executionErrorSql || props.executionErrorSql !== currentState.doc.toString()) return []; - const location = parseSqlErrorLocation(props.executionError); - if (!location) return []; - const offset = lineColumnToOffset(currentState.doc.toString(), location); - if (offset == null) return []; + const range = resolveSqlErrorDecorationRange(currentState.doc.toString(), props.executionError); + if (!range) return []; return [ { - from: offset, - to: Math.min(offset + 1, currentState.doc.length), + ...range, message: props.executionError, }, ]; diff --git a/apps/desktop/src/lib/sql/sqlDiagnostics.ts b/apps/desktop/src/lib/sql/sqlDiagnostics.ts index ab56e98448..1e4bc1239b 100644 --- a/apps/desktop/src/lib/sql/sqlDiagnostics.ts +++ b/apps/desktop/src/lib/sql/sqlDiagnostics.ts @@ -3,6 +3,11 @@ export interface SqlErrorLocation { column: number; } +export interface SqlErrorRange { + from: number; + to: number; +} + function toZeroBased(value: string | undefined): number | null { if (!value) return null; const parsed = Number.parseInt(value, 10); @@ -42,3 +47,48 @@ export function lineColumnToOffset(sql: string, location: SqlErrorLocation): num return Math.min(offset + location.column, offset + lines[location.line].length); } + +function oracleInvalidIdentifierRange(sql: string, message: string, position: number): SqlErrorRange | null { + const identifierMatch = /\bORA-00904:\s*"((?:""|[^"])*)"\s*:\s*invalid identifier\b/i.exec(message); + if (!identifierMatch?.[1]) return null; + + const reportedIdentifier = identifierMatch[1].replace(/""/g, '"'); + const identifierPattern = /"(?:""|[^"])*"|[\p{L}_][\p{L}\p{N}_$#]*/gu; + let bestRange: SqlErrorRange | null = null; + let bestDistance = Number.POSITIVE_INFINITY; + + for (const match of sql.matchAll(identifierPattern)) { + const token = match[0]; + const tokenStart = match.index; + const quoted = token.startsWith('"'); + const identifier = quoted ? token.slice(1, -1).replace(/""/g, '"') : token; + if (quoted ? identifier !== reportedIdentifier : identifier.toUpperCase() !== reportedIdentifier.toUpperCase()) continue; + + const from = tokenStart + (quoted ? 1 : 0); + const to = tokenStart + token.length - (quoted ? 1 : 0); + const distance = position < from ? from - position : position > to ? position - to : 0; + if (distance >= bestDistance) continue; + bestDistance = distance; + bestRange = { from, to }; + } + + return bestRange; +} + +export function sqlErrorDecorationRange(sql: string, message: string): SqlErrorRange | null { + const location = parseSqlErrorLocation(message); + if (location) { + const offset = lineColumnToOffset(sql, 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 }; +} diff --git a/packages/app-tests/sqlDiagnostics.test.ts b/packages/app-tests/sqlDiagnostics.test.ts new file mode 100644 index 0000000000..642dd45c1d --- /dev/null +++ b/packages/app-tests/sqlDiagnostics.test.ts @@ -0,0 +1,49 @@ +import { strict as assert } from "node:assert"; +import { test } from "vitest"; +import { parseSqlErrorLocation, sqlErrorDecorationRange } 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;"; + const message = 'ORA-00904: "X": invalid identifier error occur at position: 9'; + + assert.deepEqual(sqlErrorDecorationRange(sql, message), { from: 7, to: 8 }); + assert.equal(sql.slice(7, 8), "x"); +}); + +test("locates a multi-character qualified Oracle identifier instead of a later selector character", () => { + const sql = "SELECT bad.* FROM dual t"; + const message = 'ORA-00904: "BAD": invalid identifier error occur at position: 11'; + + assert.deepEqual(sqlErrorDecorationRange(sql, message), { from: 7, to: 10 }); +}); + +test("preserves Oracle quoted identifier casing", () => { + const sql = 'SELECT "X".*, "x".* FROM dual t'; + const message = 'ORA-00904: "x": invalid identifier error occur at position: 18'; + + assert.deepEqual(sqlErrorDecorationRange(sql, message), { from: 15, to: 16 }); +}); + +test("uses Oracle Agent absolute positions across lines", () => { + const sql = "SELECT 1,\n missing_col\nFROM dual"; + const message = 'ORA-00904: "MISSING_COL": invalid identifier error occur at position: 17'; + + assert.deepEqual(sqlErrorDecorationRange(sql, message), { from: 17, to: 28 }); +}); + +test("accepts zero as an Oracle Agent absolute position", () => { + const sql = "SELEC 1 FROM dual"; + + assert.deepEqual(sqlErrorDecorationRange(sql, "ORA-00900: invalid SQL statement error occur at position: 0"), { from: 0, to: 1 }); +}); + +test("rejects malformed or out-of-range Oracle Agent positions", () => { + assert.equal(sqlErrorDecorationRange("SELECT 1", "error occur at position: nope"), null); + assert.equal(sqlErrorDecorationRange("SELECT 1", "error occur at position: 99"), null); +}); + +test("keeps existing line-column and PostgreSQL caret parsing", () => { + assert.deepEqual(parseSqlErrorLocation("syntax error at line 2, column 4"), { line: 1, column: 3 }); + assert.deepEqual(parseSqlErrorLocation('ERROR: column "bad" does not exist\nLINE 3: SELECT bad\n ^'), { line: 2, column: 15 }); + assert.deepEqual(sqlErrorDecorationRange("SELECT 1\nFROM bad", "syntax error at line 2, column 2"), { from: 10, to: 11 }); +});