fix(editor): locate SQL errors after pagination rewriting

This commit is contained in:
zipg
2026-09-08 01:43:13 +08:00
committed by GitHub
parent 3ae1548de0
commit 2e72bee9a1
4 changed files with 55 additions and 3 deletions
@@ -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 [
@@ -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;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

+24 -1
View File
@@ -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);
});