diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 2f23c7f5d4..b6003e060c 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -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, diff --git a/apps/desktop/src/lib/__tests__/sql/errorPosition.spec.ts b/apps/desktop/src/lib/__tests__/sql/errorPosition.spec.ts index 552076a2b4..dc712dea70 100644 --- a/apps/desktop/src/lib/__tests__/sql/errorPosition.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/errorPosition.spec.ts @@ -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); diff --git a/apps/desktop/src/lib/sql/errorPosition.ts b/apps/desktop/src/lib/sql/errorPosition.ts index aaffb90580..d7f264bb53 100644 --- a/apps/desktop/src/lib/sql/errorPosition.ts +++ b/apps/desktop/src/lib/sql/errorPosition.ts @@ -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 }; } /** diff --git a/apps/desktop/src/lib/sql/sqlDiagnostics.ts b/apps/desktop/src/lib/sql/sqlDiagnostics.ts index db52bce566..e545e24dd7 100644 --- a/apps/desktop/src/lib/sql/sqlDiagnostics.ts +++ b/apps/desktop/src/lib/sql/sqlDiagnostics.ts @@ -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 }; } diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 4e10d06a57..b068c910db 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -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); } diff --git a/docs/screenshots/issue-5581-oracle-caret-after.png b/docs/screenshots/issue-5581-oracle-caret-after.png new file mode 100644 index 0000000000..af065053f4 Binary files /dev/null and b/docs/screenshots/issue-5581-oracle-caret-after.png differ diff --git a/docs/screenshots/issue-5581-oracle-caret-before.png b/docs/screenshots/issue-5581-oracle-caret-before.png new file mode 100644 index 0000000000..bb72502087 Binary files /dev/null and b/docs/screenshots/issue-5581-oracle-caret-before.png differ diff --git a/docs/screenshots/issue-5581-oracle-error-position-button.png b/docs/screenshots/issue-5581-oracle-error-position-button.png new file mode 100644 index 0000000000..77b0869d8f Binary files /dev/null and b/docs/screenshots/issue-5581-oracle-error-position-button.png differ diff --git a/docs/screenshots/issue-5581-oracle-error-position-multiline.png b/docs/screenshots/issue-5581-oracle-error-position-multiline.png new file mode 100644 index 0000000000..e1f1bdb89e Binary files /dev/null and b/docs/screenshots/issue-5581-oracle-error-position-multiline.png differ diff --git a/docs/screenshots/issue-5581-oracle-multiline-after.png b/docs/screenshots/issue-5581-oracle-multiline-after.png new file mode 100644 index 0000000000..bf66afc425 Binary files /dev/null and b/docs/screenshots/issue-5581-oracle-multiline-after.png differ diff --git a/docs/screenshots/issue-5581-oracle-multiline-before.png b/docs/screenshots/issue-5581-oracle-multiline-before.png new file mode 100644 index 0000000000..8b4e98190e Binary files /dev/null and b/docs/screenshots/issue-5581-oracle-multiline-before.png differ diff --git a/packages/app-tests/sqlDiagnostics.test.ts b/packages/app-tests/sqlDiagnostics.test.ts index 8bb5056f3e..0ee6c40f3f 100644 --- a/packages/app-tests/sqlDiagnostics.test.ts +++ b/packages/app-tests/sqlDiagnostics.test.ts @@ -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;";