diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index d0363ae9e9..d8099499c9 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -96,7 +96,7 @@ import { import { buildHoverTableSql, ddlForHoverPreview, hoverTableMatchesScope, normalizeAlignedSqlWhitespace, quoteIdentifier, quoteQualifiedName, reformatHoverDdl, scopeHoverTables, type HoverTableScope } from "@/lib/editor/hoverTableSql"; import { constrainSqlHoverLayout } from "@/lib/editor/sqlHoverLayout"; import { createHoverSearch, type HoverSearchController } from "@/lib/editor/sqlHoverSearch"; -import { lineColumnToOffset, sqlErrorDecorationRange as resolveSqlErrorDecorationRange } from "@/lib/sql/sqlDiagnostics"; +import { lineColumnToOffset, sqlErrorDecorationRange as resolveSqlErrorDecorationRange, sqlErrorSqlMatchesEditor } from "@/lib/sql/sqlDiagnostics"; import { analyzeMysqlRoutineSyntax, supportsMysqlRoutineSyntaxDiagnostics } from "@/lib/sql/mysqlRoutineSyntaxDiagnostics"; import { buildOracleSyntaxDiagnostics } from "@/lib/sql/oracleSyntaxDiagnostics"; import { @@ -3077,7 +3077,7 @@ 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 []; + if (!props.executionErrorSql || !sqlErrorSqlMatchesEditor(currentState.doc.toString(), props.executionErrorSql)) return []; const range = resolveSqlErrorDecorationRange(currentState.doc.toString(), props.executionError); if (!range) return []; return [ diff --git a/apps/desktop/src/lib/sql/sqlDiagnostics.ts b/apps/desktop/src/lib/sql/sqlDiagnostics.ts index 1e4bc1239b..db52bce566 100644 --- a/apps/desktop/src/lib/sql/sqlDiagnostics.ts +++ b/apps/desktop/src/lib/sql/sqlDiagnostics.ts @@ -8,6 +8,27 @@ export interface SqlErrorRange { to: number; } +function normalizeSqlForComparison(sql: string): string { + return sql.trim().replace(/;\s*$/u, "").replace(/\s+/gu, " ").trim().toLowerCase(); +} + +function removeDbxPagination(sql: string): string { + return sql.replace(/\s+(?:limit\s+\d+(?:\s+offset\s+\d+)?|offset\s+\d+\s+rows?(?:\s+fetch\s+(?:next|first)\s+\d+\s+rows?(?:\s+only)?)?|fetch\s+(?:first|next)\s+\d+\s+rows?\s+only)\s*;?\s*$/iu, "").trim(); +} + +/** + * Pagination is appended to the SQL sent to the database, so an execution + * error can refer to a SQL string that differs from the editor by only that + * generated suffix. Keep error highlighting disabled for unrelated stale + * errors while allowing this known DBX rewrite. + */ +export function sqlErrorSqlMatchesEditor(editorSql: string, executedSql: string): boolean { + if (editorSql === executedSql) return true; + const norm = (sql: string) => normalizeSqlForComparison(sql); + if (norm(executedSql) === norm(editorSql)) return true; + return norm(removeDbxPagination(executedSql)) === norm(removeDbxPagination(editorSql)); +} + function toZeroBased(value: string | undefined): number | null { if (!value) return null; const parsed = Number.parseInt(value, 10); @@ -33,6 +54,14 @@ export function parseSqlErrorLocation(message: string): SqlErrorLocation | null if (line != null && caretIndex >= 0) return { line, column: caretIndex }; } + // MySQL commonly reports only "at line N" for syntax errors. With no + // column information available, point at the beginning of that line. + const lineOnly = /\bline\s+(\d+)\b/i.exec(message); + if (lineOnly) { + const line = toZeroBased(lineOnly[1]); + if (line != null) return { line, column: 0 }; + } + return null; } diff --git a/docs/screenshots/issue-8210-mysql-error-location.png b/docs/screenshots/issue-8210-mysql-error-location.png new file mode 100644 index 0000000000..7159a83e04 Binary files /dev/null and b/docs/screenshots/issue-8210-mysql-error-location.png differ diff --git a/packages/app-tests/sqlDiagnostics.test.ts b/packages/app-tests/sqlDiagnostics.test.ts index 642dd45c1d..8bb5056f3e 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 } from "../../apps/desktop/src/lib/sql/sqlDiagnostics.ts"; +import { parseSqlErrorLocation, sqlErrorDecorationRange, 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;"; @@ -44,6 +44,29 @@ test("rejects malformed or out-of-range Oracle Agent positions", () => { 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 1064: syntax error near 'LIMIT 100' at line 3"), { line: 2, column: 0 }); 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 }); }); + +test("matches DBX pagination rewrites without accepting unrelated stale SQL", () => { + const editorSql = "SELECT *\nFROM projects\nWHERE ;"; + const executedSql = "SELECT *\nFROM projects\nWHERE LIMIT 100;"; + + assert.equal(sqlErrorSqlMatchesEditor(editorSql, executedSql), true); + assert.equal(sqlErrorSqlMatchesEditor(editorSql, "SELECT *\nFROM other_table\nWHERE LIMIT 100;"), false); +}); + +test("matches the user's own LIMIT when the SQL differs only by a semicolon", () => { + const editorSql = "SELECT *\nFROM projects\nLIMIT 10"; + const executedSql = "SELECT *\nFROM projects\nLIMIT 10;"; + + assert.equal(sqlErrorSqlMatchesEditor(editorSql, executedSql), true); +}); + +test("strips the generated OFFSET ROWS FETCH FIRST suffix fully", () => { + const editorSql = "SELECT *\nFROM projects"; + const executedSql = "SELECT *\nFROM projects OFFSET 10 ROWS FETCH FIRST 5 ROWS ONLY;"; + + assert.equal(sqlErrorSqlMatchesEditor(editorSql, executedSql), true); +});