feat(query): jump to PostgreSQL error position from result messages

This commit is contained in:
amwps290 authored and GitHub committed 2026-09-15 17:39:59 +08:00
1 parent 571fc07d85
commit 5a0e74eec6
36 files changed
+2079 -23

No files matched your search

@@ -214,6 +214,39 @@ describe("queryStore multi-statement errors", () => {
expect(tab.batchSqlExecution?.items[0]?.error).toContain(originalMessage);
});
it("annotates a thrown single-statement error so the locate flow keeps its source", async () => {
const position = { line: 1, column: 16, offset: 15 };
const structuredError = {
...structuredSqlError('ERROR: relation "no_such_table" does not exist'),
errorPosition: position,
};
mocks.prepareQueryPaginationExecutionPlan.mockImplementationOnce(async (options) => ({
sqlToExecute: `${options.sql} LIMIT 100`,
pageSql: undefined,
pageLimit: undefined,
pageOffset: undefined,
countSql: undefined,
useAgentResultSession: false,
}));
mocks.executeMulti.mockRejectedValue(
new BackendErrorException({
backendError: structuredError,
message: 'ERROR: relation "no_such_table" does not exist',
}),
);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("mysql-1", "app", "Query");
await store.executeTabSql(tabId, "SELECT * FROM no_such_table");
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.result?.execution_error).toBe(true);
expect(tab.result?.error?.errorPosition).toEqual(position);
expect(tab.result?.sourceStatement).toBe("SELECT * FROM no_such_table");
expect(tab.result?.executedStatement).toBe("SELECT * FROM no_such_table LIMIT 100");
});
it("updates live per-statement progress before the batch promise resolves", async () => {
const pendingExecution = deferred<any[]>();
let reportProgress!: (progress: any) => void;
+51 -1
View File
@@ -8,6 +8,7 @@ import { sanitizeTabPageUiState } from "@/lib/tabs/tabUiState";
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 { 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";
@@ -345,8 +346,14 @@ function preservedResultIndex(results: QueryResult[], currentIndex: number | und
return currentIndex;
}
function annotateQueryResultSources(results: QueryResult[], sql: string, database: string | undefined, databaseType?: DatabaseType, sourceOffset?: number, parameterOptions?: SqlParameterOptions): { results: QueryResult[]; sqlServerUseDatabase?: string } {
function annotateQueryResultSources(results: QueryResult[], sql: string, database: string | undefined, databaseType?: DatabaseType, sourceOffset?: number, parameterOptions?: SqlParameterOptions, executedSql?: string): { results: QueryResult[]; sqlServerUseDatabase?: string } {
const statements = splitSqlStatementRanges(sql, databaseType, parameterOptions);
// The backend positions errors against the SQL it actually received. When the
// sent SQL was rewritten (pagination wrapper, injected hidden keys…), record
// each statement's executed text so the error mapper can project the position
// back onto `sourceStatement`.
const executedStatements = executedSql && executedSql !== sql ? splitSqlStatementRanges(executedSql, databaseType, parameterOptions) : undefined;
const alignedExecutedStatements = executedStatements && executedStatements.length === statements.length ? executedStatements : undefined;
let statementIndex = 0;
let sourceDatabase = database;
let sqlServerUseDatabase: string | undefined;
@@ -357,6 +364,19 @@ function annotateQueryResultSources(results: QueryResult[], sql: string, databas
const statement = statements[sourceIndex];
if (!statement) continue;
annotateQueryResultSource(result, statement.sql, sourceDatabase, databaseType, sourceOffset === undefined ? undefined : { from: sourceOffset + statement.from, to: sourceOffset + statement.to });
const executedStatement = alignedExecutedStatements?.[sourceIndex]?.sql;
if (executedStatement && executedStatement !== statement.sql) {
result.executedStatement = executedStatement;
if (isSqlErrorPositionDebugEnabled()) {
logSqlErrorPosition("executed-statement-drift", {
statementIndex: sourceIndex,
sourceStatement: statement.sql,
executedStatement,
resultIsError: result.execution_error === true,
errorPosition: result.error?.errorPosition ?? null,
});
}
}
const customName = queryResultNameFromPreamble(sql.slice(statement.hitFrom, statement.from));
if (customName) result.sourceLabel = customName;
const successfulUseDatabase = databaseType === "sqlserver" && result.execution_error !== true ? sqlServerUseDatabaseFromStatement(statement.sql) : undefined;
@@ -368,6 +388,22 @@ function annotateQueryResultSources(results: QueryResult[], sql: string, databas
return { results, sqlServerUseDatabase };
}
/**
* Annotate the synthesized error result of a thrown single-statement execution
* so the row/column locate flow can map the backend position back to the editor.
*
* 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).
*/
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 (splitSqlStatementRanges(sourceSql, databaseType, parameterOptions).length !== 1) return;
annotateQueryResultSources([errorResult], sourceSql, undefined, databaseType, sourceOffset, parameterOptions, executedSql);
}
const NON_STREAMING_BATCH_DATABASE_TYPES = new Set<DatabaseType>(["sqlserver", "turso", "cloudflare-d1"]);
const liveBatchSqlExecutions = new WeakMap<QueryTab, BatchSqlExecution>();
@@ -6175,6 +6211,11 @@ export const useQueryStore = defineStore("query", () => {
let producedResult = false;
const resumedExecutionTarget = batchResume?.batch.executionTarget;
const executionConnectionId = resumedExecutionTarget?.connectionId ?? options?.executionTarget?.connectionId ?? tab.connectionId;
// Captured for the catch below: a single-statement failure aborts the whole
// execute-multi command (the core only returns per-statement error results
// for batches), so the synthesized error result must be annotated here for
// the row/column locate flow. These locals live inside the try block.
let errorLocateContext: { databaseType: DatabaseType | undefined; parameterOptions: SqlParameterOptions | undefined; sourceOffset: number | undefined; executedSql: string | undefined } | undefined;
try {
await waitForTabSessionReset(id);
const connStore = useConnectionStore();
@@ -6880,6 +6921,13 @@ export const useQueryStore = defineStore("query", () => {
useAgentResultSession = conn?.db_type === "sqlserver" && conn?.driver_profile?.trim().toLowerCase() === "sqlserver-legacy";
}
errorLocateContext = {
databaseType: effectiveDbType,
parameterOptions: sqlStatementParameterOptions,
sourceOffset: options?.sourceOffset,
executedSql: sqlToExecute,
};
const executionSchema = connectionQueryExecutionSchema(conn, targetDatabase, targetSchema, tab.mode === "data");
// Jumping to a non-zero offset without a live cursor session: the plan
// could not rewrite the SQL for these engines, so a plain execution
@@ -7051,6 +7099,7 @@ export const useQueryStore = defineStore("query", () => {
effectiveDbType,
options?.sourceOffset,
sqlStatementParameterOptions,
sqlToExecute,
);
const results = offsetBatchQueryResultIndexes(annotatedResults.results, batchResume?.startStatementIndex ?? 0);
reconcileBatchSqlResults(tab, executionId, results);
@@ -7319,6 +7368,7 @@ export const useQueryStore = defineStore("query", () => {
return false;
}
const errorResult = toErrorResult(e);
annotateSingleStatementErrorResult(errorResult, queryBaseSql, errorLocateContext?.databaseType, errorLocateContext?.sourceOffset, errorLocateContext?.parameterOptions, errorLocateContext?.executedSql);
const activeGroupIndex = current.activeResultIndex;
const activeGroupResults = current.results;
const shouldReplaceActiveResultInGroup = options?.replaceActiveResultInGroup === true && Array.isArray(activeGroupResults) && typeof activeGroupIndex === "number" && activeGroupIndex >= 0 && activeGroupIndex < activeGroupResults.length;