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

This commit is contained in:
amwps290
2026-09-15 17:39:59 +08:00
committed by GitHub
parent 571fc07d85
commit 5a0e74eec6
36 changed files with 2079 additions and 23 deletions
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from "vue";
import { Bot, Wrench } from "@lucide/vue";
import { Bot, LocateFixed, Wrench } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import type { BackendError } from "@/lib/backend/errorUtils";
@@ -10,20 +10,28 @@ const props = defineProps<{
errorMessage: string;
backendError?: BackendError;
connectionId?: string;
/** Driver-reported error position, when the backend provides one. */
errorPosition?: { line: number; column: number };
}>();
const emit = defineEmits<{
changeConnectionTimeout: [];
changeQueryTimeout: [];
fixWithAi: [errorMessage: string];
locateError: [];
}>();
const { t } = useI18n();
const showConnectionTimeout = computed(() => !!props.connectionId && isConnectionTimeoutErrorMessage(props.errorMessage, props.backendError));
const showQueryTimeout = computed(() => !!props.connectionId && !showConnectionTimeout.value && isQueryTimeoutErrorMessage(props.errorMessage, props.backendError));
const showLocateError = computed(() => !!props.errorPosition);
</script>
<template>
<Button v-if="showLocateError" variant="outline" size="sm" class="h-7 gap-1.5 px-2.5 text-xs" @click="emit('locateError')">
<LocateFixed class="h-3.5 w-3.5" />
{{ t("editor.locateError", { line: errorPosition!.line, column: errorPosition!.column }) }}
</Button>
<Button v-if="showConnectionTimeout" variant="outline" size="sm" class="h-7 gap-1.5 px-2.5 text-xs" @click="emit('changeConnectionTimeout')">
<Wrench class="h-3.5 w-3.5" />
{{ t("editor.changeConnectionTimeout") }}
@@ -7,17 +7,26 @@ import i18n from "@/i18n";
const mountedApps: App[] = [];
async function mountActions(errorMessage: string, onChangeConnectionTimeout = vi.fn()) {
async function mountActions(errorMessage: string, options: { onChangeConnectionTimeout?: () => void; errorPosition?: { line: number; column: number }; onLocateError?: () => void } = {}) {
const onChangeConnectionTimeout = options.onChangeConnectionTimeout ?? vi.fn();
const onLocateError = options.onLocateError ?? vi.fn();
const host = document.createElement("div");
document.body.appendChild(host);
const app = createApp({
setup: () => () => h(QueryErrorActions, { errorMessage, connectionId: "postgres-1", onChangeConnectionTimeout }),
setup: () => () =>
h(QueryErrorActions, {
errorMessage,
connectionId: "postgres-1",
errorPosition: options.errorPosition,
onChangeConnectionTimeout,
onLocateError,
}),
});
mountedApps.push(app);
app.use(i18n);
app.mount(host);
await nextTick();
return { host, onChangeConnectionTimeout };
return { host, onChangeConnectionTimeout, onLocateError };
}
afterEach(() => {
@@ -35,6 +44,23 @@ describe("QueryErrorActions", () => {
expect(onChangeConnectionTimeout).toHaveBeenCalledOnce();
});
it("offers a locate-error action when the backend reports a position", async () => {
const { host, onLocateError } = await mountActions("ERROR: relation does not exist", {
errorPosition: { line: 2, column: 6 },
});
const locateButton = Array.from(host.querySelectorAll("button")).find((button) => button.textContent?.includes("Locate error"));
expect(locateButton?.textContent).toContain("line 2, col 6");
locateButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onLocateError).toHaveBeenCalledOnce();
});
it("hides the locate-error action without a position", async () => {
const { host } = await mountActions("ERROR: relation does not exist");
expect(host.textContent).not.toContain("Locate error");
});
it("does not offer connection timeout settings for query timeouts", async () => {
const { host } = await mountActions("Query timed out after 30 seconds");
@@ -1118,6 +1118,17 @@ function focusStatementRange(range: { from: number; to: number } | null) {
currentView.focus();
}
function focusErrorPosition(offset: number) {
const currentView = view.value;
if (!currentView || !editorViewModule) return;
const errorPos = Math.max(0, Math.min(offset, currentView.state.doc.length));
currentView.dispatch({
selection: { anchor: errorPos },
effects: [editorViewModule.EditorView.scrollIntoView(errorPos, { y: "center" })],
});
currentView.focus();
}
function onPickerActiveIndexChange(index: number) {
pickerActiveIndex.value = index;
const candidate = pickerCandidates.value[index];
@@ -7553,6 +7564,7 @@ defineExpose({
captureExecutionSnapshot,
pasteClipboardAsSqlInCondition,
focusStatementRange,
focusErrorPosition,
previewStatementRange,
refreshCompletionCache,
});
@@ -45,6 +45,7 @@ import {
RotateCcw,
SkipForward,
ListX,
LocateFixed,
} from "@lucide/vue";
import { Splitpanes, Pane } from "splitpanes";
import { DynamicScroller, DynamicScrollerItem } from "vue-virtual-scroller";
@@ -134,6 +135,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 {
databaseDisplayNameForTab,
executionSummaryItems,
@@ -331,6 +333,9 @@ const activeDataTabTableMeta = computed(() => tableMetaForDataTab(props.activeTa
const activeResultExecutionTarget = computed(() => queryStore.activeResultExecutionTarget(props.activeTab.id));
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));
const activeResultDatabase = computed(() => activeResultExecutionTarget.value?.database ?? props.activeTab.database);
const activeResultSchema = computed(() => activeResultExecutionTarget.value?.schema ?? props.activeTab.schema);
const activeEffectiveDatabaseType = computed(() => effectiveDatabaseTypeForConnection(activeResultConnection.value));
@@ -1296,6 +1301,72 @@ function focusStatementRange(range: { from: number; to: number } | null): boolea
return true;
}
function focusErrorPosition(offset: number): boolean {
if (!queryEditorRef.value) return false;
queryEditorRef.value.focusErrorPosition(offset);
return true;
}
/**
* Jump to the driver-reported row/column of the active result's error. Falls back
* to a cross-surface event when this surface only renders the shared result pane
* (the editor lives in another group).
*/
function locateActiveResultError() {
const result = props.activeTab.result;
logSqlErrorPosition("locate:invoke", {
tabId: props.activeTab.id,
mode: props.activeTab.mode,
hasEditorRef: Boolean(queryEditorRef.value),
activeResultIndex: props.activeTab.activeResultIndex,
statementIndex: result?.statement_index,
hasBackendError: Boolean(result?.error),
errorPosition: result?.error?.errorPosition ?? null,
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,
});
if (!mapped) {
logSqlErrorPosition("locate:unavailable", {
tabId: props.activeTab.id,
reason: "sqlErrorEditorOffset returned undefined (see the preceding 'unresolved:result-source-range' log)",
});
toast(t("editor.errorPositionUnavailable"), 3000);
return;
}
logSqlErrorPosition("locate:focus", { tabId: props.activeTab.id, offset: mapped.offset, line: mapped.line, column: mapped.column });
if (queryEditorRef.value) {
queryEditorRef.value.focusErrorPosition(mapped.offset);
} else {
emit("focusErrorOffset", props.activeTab.id, mapped.offset);
}
}
function locateExecutionSummaryError(item: ExecutionSummaryItem) {
const mapped = item.result
? sqlErrorEditorOffset({
editorSql: props.activeTab.sql,
result: item.result,
resultIndex: item.statementIndex,
databaseType: activeEffectiveDatabaseType.value,
parameterOptions: activeSqlStatementParameterOptions.value,
})
: undefined;
if (mapped) {
if (!focusErrorPosition(mapped.offset)) {
emit("focusErrorOffset", props.activeTab.id, mapped.offset);
}
return;
}
focusExecutionSummaryItem(item);
}
defineExpose({
focusSearch,
openGoToColumn,
@@ -1316,6 +1387,8 @@ defineExpose({
executeRedisCommand,
previewStatementRange,
focusStatementRange,
focusErrorPosition,
locateActiveResultError,
});
</script>
@@ -1867,14 +1940,26 @@ defineExpose({
:title="item.error || item.sql"
:aria-label="item.error || item.sql || t('executionSummary.noSql')"
@click="previewExecutionSummaryItem(item)"
@dblclick="focusExecutionSummaryItem(item)"
@dblclick="locateExecutionSummaryError(item)"
@keydown.enter.prevent="focusExecutionSummaryItem(item)"
/>
<div class="pointer-events-none relative z-[1] font-mono text-muted-foreground">#{{ item.statementIndex + 1 }}</div>
<div class="relative z-[1] min-w-0 cursor-pointer" @click="previewExecutionSummaryItem(item)" @dblclick="focusExecutionSummaryItem(item)">
<div class="relative z-[1] min-w-0 cursor-pointer" @click="previewExecutionSummaryItem(item)" @dblclick="locateExecutionSummaryError(item)">
<div class="truncate font-mono text-[11px] text-foreground">{{ item.sql || t("executionSummary.noSql") }}</div>
<div v-if="item.error" class="mt-0.5 flex min-w-0 items-center gap-1 text-[11px] text-destructive">
<span v-if="item.errorPosition" class="shrink-0 rounded border border-destructive/30 bg-destructive/5 px-1 tabular-nums">{{ t("executionSummary.lineColumn", { line: item.errorPosition.line, column: item.errorPosition.column }) }}</span>
<span data-native-clipboard class="min-w-0 flex-1 cursor-text select-text truncate" :title="item.error" @mousedown.stop @click.stop @dblclick.stop>{{ item.error }}</span>
<LightTooltip v-if="item.errorPosition" :text="t('editor.locateError', { line: item.errorPosition.line, column: item.errorPosition.column })" side="bottom" :delay="0" :close-delay="0" nowrap>
<button
type="button"
class="pointer-events-auto flex h-5 w-5 shrink-0 items-center justify-center rounded text-destructive/70 hover:bg-destructive/10 hover:text-destructive"
:aria-label="t('editor.locateError', { line: item.errorPosition.line, column: item.errorPosition.column })"
@mousedown.stop
@click.stop="locateExecutionSummaryError(item)"
>
<LocateFixed class="h-3 w-3" />
</button>
</LightTooltip>
<LightTooltip :text="t('grid.copy')" side="bottom" :delay="0" :close-delay="0" nowrap>
<button type="button" class="pointer-events-auto flex h-5 w-5 shrink-0 items-center justify-center rounded text-destructive/70 hover:bg-destructive/10 hover:text-destructive" :aria-label="t('grid.copy')" @mousedown.stop @click.stop="copyExecutionSummaryError(item.error)">
<Copy class="h-3 w-3" />
@@ -2034,6 +2119,8 @@ defineExpose({
:error-message="String(errorMessage)"
:backend-error="activeTab.result.error"
:connection-id="activeResultConnectionId"
:error-position="activeResultErrorPosition"
@locate-error="locateActiveResultError"
@change-connection-timeout="activeResultConnectionId && emit('openConnectionSettings', activeResultConnectionId, 'advanced')"
@change-query-timeout="activeResultConnectionId && emit('openConnectionSettings', activeResultConnectionId, 'advanced')"
@fix-with-ai="(message) => emit('fixWithAi', activeTab.id, message)"
@@ -2392,6 +2479,8 @@ defineExpose({
:error-message="String(errorMessage)"
:backend-error="activeTab.result.error"
:connection-id="activeResultConnectionId"
:error-position="activeResultErrorPosition"
@locate-error="locateActiveResultError"
@change-connection-timeout="activeResultConnectionId && emit('openConnectionSettings', activeResultConnectionId, 'advanced')"
@change-query-timeout="activeResultConnectionId && emit('openConnectionSettings', activeResultConnectionId, 'advanced')"
@fix-with-ai="(message) => emit('fixWithAi', activeTab.id, message)"
@@ -89,6 +89,7 @@ defineExpose({
executeRedisCommand: (command: string) => activeSurfaceRef.value?.executeRedisCommand(command) ?? Promise.resolve(false),
previewStatementRange: (tabId: string, range: StatementRange | null) => (activeTab.value?.id === tabId ? (activeSurfaceRef.value?.previewStatementRange(range) ?? false) : false),
focusStatementRange: (tabId: string, range: StatementRange | null) => (activeTab.value?.id === tabId ? (activeSurfaceRef.value?.focusStatementRange(range) ?? false) : false),
focusErrorPosition: (tabId: string, offset: number) => (activeTab.value?.id === tabId ? (activeSurfaceRef.value?.focusErrorPosition(offset) ?? false) : false),
});
const { t } = useI18n();
@@ -41,6 +41,7 @@ defineExpose<QueryEditorSurfaceHandle>({
executeRedisCommand: (command: string) => contentAreaRef.value?.executeRedisCommand(command) ?? Promise.resolve(false),
previewStatementRange: (range: StatementRange | null) => contentAreaRef.value?.previewStatementRange(range) ?? false,
focusStatementRange: (range: StatementRange | null) => contentAreaRef.value?.focusStatementRange(range) ?? false,
focusErrorPosition: (offset: number) => contentAreaRef.value?.focusErrorPosition(offset) ?? false,
});
</script>
@@ -33,6 +33,7 @@ defineExpose<QueryResultSurfaceHandle>({
handleModRTarget: (target: Element) => contentAreaRef.value?.handleModRTarget(target) ?? false,
previewStatementRange: (range: StatementRange | null) => contentAreaRef.value?.previewStatementRange(range) ?? false,
focusStatementRange: (range: StatementRange | null) => contentAreaRef.value?.focusStatementRange(range) ?? false,
focusErrorPosition: (offset: number) => contentAreaRef.value?.focusErrorPosition(offset) ?? false,
});
</script>
@@ -255,6 +255,14 @@ function handleFocusStatement(tabId: string, range: StatementRange | null): bool
}
return false;
}
function handleFocusErrorOffset(tabId: string, offset: number): boolean {
for (const group of groupRefs.values()) {
if (group.focusErrorPosition(tabId, offset)) {
return true;
}
}
return false;
}
</script>
<template>
@@ -348,6 +356,11 @@ function handleFocusStatement(tabId: string, range: StatementRange | null): bool
handleFocusStatement(tabId, range);
}
"
@focus-error-offset="
(tabId: string, offset: number) => {
handleFocusErrorOffset(tabId, offset);
}
"
/>
</div>
</Pane>
@@ -29,6 +29,7 @@ export interface QueryEditorSurfaceHandle {
executeRedisCommand(command: string): Promise<boolean>;
previewStatementRange(range: StatementRange | null): boolean;
focusStatementRange(range: StatementRange | null): boolean;
focusErrorPosition(offset: number): boolean;
}
export interface QueryResultSurfaceHandle {
@@ -38,6 +39,7 @@ export interface QueryResultSurfaceHandle {
handleModRTarget(target: Element): boolean;
previewStatementRange(range: StatementRange | null): boolean;
focusStatementRange(range: StatementRange | null): boolean;
focusErrorPosition(offset: number): boolean;
}
export interface ContentAreaSurfaceProps {
@@ -97,6 +99,7 @@ export interface ContentAreaSurfaceEmits {
structureEditorClose: [tabId: string];
previewStatement: [tabId: string, range: StatementRange | null];
focusStatement: [tabId: string, range: StatementRange | null];
focusErrorOffset: [tabId: string, offset: number];
openSettings: [initialTab?: string, initialSection?: string];
openConnectionSettings: [connectionId: string, initialTab: "advanced"];
toggleZenMode: [];
+52
View File
@@ -230,6 +230,20 @@ describe("backend error translation", () => {
expect(sanitizeBackendErrorMessage(message)).toBe(message);
});
test("strips the SQL error-position transport suffix from raw messages", () => {
const message = 'ERROR: relation "missing" does not exist\nDBX_SQL_ERROR_POSITION:15';
const expected = 'ERROR: relation "missing" does not exist';
expect(sanitizeBackendErrorMessage(message)).toBe(expected);
expect(formatError(new Error(message))).toBe(expected);
});
test("strips the position suffix that precedes appended context text", () => {
const message = "ERROR: boom\nDBX_SQL_ERROR_POSITION:7; cleanup failed";
expect(sanitizeBackendErrorMessage(message)).toBe("ERROR: boom; cleanup failed");
});
test("normalizes Error and structural message objects before translation", () => {
const t = translatorFor("zh-CN");
const message = "file does not exist: /tmp/missing.sqlite";
@@ -327,6 +341,44 @@ describe("backend error translation", () => {
).toBeNull();
});
test("accepts an optional driver-reported error position", () => {
const error = normalizeBackendError({
version: 1,
code: "DBX-JDBC-4001",
messageKey: "backendErrors.jdbc.sqlFailed",
messageParams: { stage: "execute" },
source: "jdbcAgent",
operationOutcome: "unknown",
detail: 'ERROR: relation "missing" does not exist',
errorPosition: { line: 2, column: 6, offset: 12 },
});
expect(error?.errorPosition).toEqual({ line: 2, column: 6, offset: 12 });
const t = translatorFor("zh-CN");
expect(translateBackendError(t, error)).toBe(`${t("backendErrors.jdbc.sqlFailed", { stage: "execute" })}\n\nERROR: relation "missing" does not exist`);
});
test.each([
["zero line", { line: 0, column: 1, offset: 0 }],
["negative column", { line: 1, column: -1, offset: 0 }],
["non-integer offset", { line: 1, column: 1, offset: 1.5 }],
["string line", { line: "1", column: 1, offset: 0 }],
["array position", [1, 1, 0]],
])("rejects a malformed error position with %s", (_name, errorPosition) => {
expect(
normalizeBackendError({
version: 1,
code: "DBX-JDBC-4001",
messageKey: "backendErrors.jdbc.sqlFailed",
messageParams: { stage: "execute" },
source: "jdbcAgent",
operationOutcome: "unknown",
errorPosition,
}),
).toBeNull();
});
test("accepts unknown compatibility sources and extensible origins", () => {
const error = normalizeBackendError({
version: 1,
+3
View File
@@ -1311,6 +1311,8 @@ export default withEnglishFallback({
queryTimeoutError: "Sorğunun vaxt limiti bitdi ({seconds}s). Verilənlər bazası əlaqəsinin işlək olub-olmadığını yoxlayın.",
changeConnectionTimeout: "Əlaqənin vaxt limitini dəyiş",
changeQueryTimeout: "Sorğunun vaxt limitini dəyiş",
locateError: "Xətanı tap ({line}. sətir, {column}. sütun)",
errorPositionUnavailable: "Xəta mövqeyi redaktorun cari məzmununa uyğun gəlmir, ona görə də tapıla bilmir.",
connectionMayBeLost: "Əlaqə kəsilmiş ola bilər. Məlumatları yeniləyin və yenidən cəhd edin.",
showResultsPane: "Nəticələri göstər",
hideResultsPane: "Nəticələri gizlət",
@@ -1655,6 +1657,7 @@ export default withEnglishFallback({
returnedTable: "{count} sütunlu nəticə cədvəli qaytarıldı",
noTable: "Nəticə cədvəli qaytarılmadı",
noSql: "SQL mövcud deyil",
lineColumn: "{line}. sətir, {column}. sütun",
navigationHint: "SQL-ə önbaxış üçün sətrə klikləyin; redaktorda ona keçmək üçün iki dəfə klikləyin.",
recoveryPrompt: "#{statement} əmri uğursuz oldu; {count} əmr işlədilmədi.",
stop: "Dayandır",
+3
View File
@@ -1442,6 +1442,8 @@ export default {
queryTimeoutError: "Query timed out ({seconds}s). Check whether the database connection is healthy.",
changeConnectionTimeout: "Change connection timeout",
changeQueryTimeout: "Change query timeout",
locateError: "Locate error (line {line}, col {column})",
errorPositionUnavailable: "The error position no longer matches the editor content, so it cannot be located.",
connectionMayBeLost: "The connection may have been lost. Refresh data and try again.",
showResultsPane: "Show results",
hideResultsPane: "Hide results",
@@ -1786,6 +1788,7 @@ export default {
returnedTable: "Returned a {count}-column result table",
noTable: "No result table returned",
noSql: "SQL unavailable",
lineColumn: "line {line}, col {column}",
navigationHint: "Click a row to preview its SQL; double-click to focus it in the editor.",
recoveryPrompt: "Statement #{statement} failed; {count} statements were not run.",
stop: "Stop",
+3
View File
@@ -1412,6 +1412,8 @@ export default withEnglishFallback({
queryTimeoutError: "La consulta agotó el tiempo ({seconds}s). Comprueba la conexión a la base de datos.",
changeConnectionTimeout: "Cambiar tiempo de espera de conexión",
changeQueryTimeout: "Cambiar tiempo de espera de consulta",
locateError: "Localizar error (línea {line}, columna {column})",
errorPositionUnavailable: "La posición del error ya no coincide con el contenido del editor, por lo que no se puede localizar.",
connectionMayBeLost: "Es posible que la conexión se haya perdido. Actualiza los datos e inténtalo de nuevo.",
showResultsPane: "Mostrar resultados",
hideResultsPane: "Ocultar resultados",
@@ -1709,6 +1711,7 @@ export default withEnglishFallback({
returnedTable: "Devolvió una tabla de resultados de {count} columnas",
noTable: "No se devolvió ninguna tabla de resultados",
noSql: "SQL no disponible",
lineColumn: "línea {line}, columna {column}",
navigationHint: "Haz clic para previsualizar el SQL; haz doble clic para enfocarlo en el editor.",
recoveryPrompt: "Falló la sentencia #{statement}; no se ejecutaron {count} sentencias.",
stop: "Detener",
+3
View File
@@ -1410,6 +1410,8 @@ export default withEnglishFallback({
queryTimeoutError: "Timeout della query ({seconds}s). Verifica se la connessione al database è integra.",
changeConnectionTimeout: "Modifica timeout connessione",
changeQueryTimeout: "Modifica timeout query",
locateError: "Individua errore (riga {line}, colonna {column})",
errorPositionUnavailable: "La posizione dell'errore non corrisponde più al contenuto dell'editor, quindi non è possibile individuarla.",
connectionMayBeLost: "La connessione potrebbe essere andata perduta. Aggiorna i dati e riprova.",
showResultsPane: "Mostra risultati",
hideResultsPane: "Nascondi risultati",
@@ -1707,6 +1709,7 @@ export default withEnglishFallback({
returnedTable: "Restituita una tabella con {count} colonne",
noTable: "Nessuna tabella restituita",
noSql: "SQL non disponibile",
lineColumn: "riga {line}, colonna {column}",
navigationHint: "Fai clic per visualizzare l'SQL; fai doppio clic per selezionarlo nell'editor.",
recoveryPrompt: "L'istruzione #{statement} non è riuscita; {count} istruzioni non sono state eseguite.",
stop: "Interrompi",
+3
View File
@@ -1431,6 +1431,8 @@ export default withEnglishFallback({
queryTimeoutError: "クエリがタイムアウトしました({seconds}秒)。データベース接続が正常か確認してください。",
changeConnectionTimeout: "接続タイムアウトを変更",
changeQueryTimeout: "クエリタイムアウトを変更",
locateError: "エラー位置へ移動({line} 行目 {column} 桁目)",
errorPositionUnavailable: "エラー位置が現在のエディター内容と一致しないため、位置を特定できません。",
connectionMayBeLost: "接続が失われた可能性があります。データを更新して再試行してください。",
showResultsPane: "結果を表示",
hideResultsPane: "結果を非表示",
@@ -1729,6 +1731,7 @@ export default withEnglishFallback({
returnedTable: "{count}列の結果テーブルを返しました",
noTable: "結果テーブルは返されませんでした",
noSql: "SQLを取得できません",
lineColumn: "{line} 行目 {column} 桁目",
navigationHint: "クリックでSQLをプレビューし、ダブルクリックでエディター内を選択します。",
recoveryPrompt: "ステートメント #{statement} が失敗し、残り {count} 件は未実行です。",
stop: "停止",
+3
View File
@@ -1332,6 +1332,8 @@ export default withEnglishFallback({
queryTimeoutError: "쿼리 시간 초과 ({seconds}초). 데이터베이스 연결이 정상인지 확인하세요.",
changeConnectionTimeout: "연결 제한 시간 변경",
changeQueryTimeout: "쿼리 제한 시간 변경",
locateError: "오류 위치로 이동 ({line}행 {column}열)",
errorPositionUnavailable: "오류 위치가 현재 편집기 내용과 일치하지 않아 위치를 찾을 수 없습니다.",
connectionMayBeLost: "연결이 끊어졌을 수 있습니다. 데이터를 새로고침하고 다시 시도하세요.",
showResultsPane: "결과 표시",
hideResultsPane: "결과 숨기기",
@@ -1658,6 +1660,7 @@ export default withEnglishFallback({
returnedTable: "{count}열 결과 테이블을 반환했습니다",
noTable: "반환된 결과 테이블이 없습니다",
noSql: "SQL을 가져올 수 없습니다",
lineColumn: "{line}행 {column}열",
navigationHint: "행을 클릭하여 SQL을 미리 보고 두 번 클릭하여 편집기에서 선택하세요.",
recoveryPrompt: "구문 #{statement} 실행에 실패하여 나머지 {count}개 구문이 실행되지 않았습니다.",
stop: "중지",
+3
View File
@@ -1411,6 +1411,8 @@ export default withEnglishFallback({
queryTimeoutError: "A consulta expirou ({seconds}s). Verifique se a conexão com o banco de dados está saudável.",
changeConnectionTimeout: "Alterar tempo limite da conexão",
changeQueryTimeout: "Alterar tempo limite da consulta",
locateError: "Localizar erro (linha {line}, coluna {column})",
errorPositionUnavailable: "A posição do erro não corresponde mais ao conteúdo do editor, portanto não é possível localizá-la.",
connectionMayBeLost: "A conexão pode ter sido perdida. Atualize os dados e tente novamente.",
showResultsPane: "Mostrar resultados",
hideResultsPane: "Ocultar resultados",
@@ -1709,6 +1711,7 @@ export default withEnglishFallback({
returnedTable: "Retornou uma tabela de resultado com {count} colunas",
noTable: "Nenhuma tabela de resultado retornada",
noSql: "SQL indisponível",
lineColumn: "linha {line}, coluna {column}",
navigationHint: "Clique para visualizar o SQL; clique duas vezes para selecioná-lo no editor.",
recoveryPrompt: "A instrução #{statement} falhou; {count} instruções não foram executadas.",
stop: "Parar",
+3
View File
@@ -1313,6 +1313,8 @@ export default withEnglishFallback({
queryTimeoutError: "Sorgu zaman aşımına uğradı ({seconds} sn). Veritabanı bağlantısının sağlıklı olup olmadığını denetleyin.",
changeConnectionTimeout: "Bağlantı zaman aşımını değiştir",
changeQueryTimeout: "Sorgu zaman aşımını değiştir",
locateError: "Hatayı bul ({line}. satır, {column}. sütun)",
errorPositionUnavailable: "Hata konumu düzenleyicinin geçerli içeriğiyle eşleşmediği için bulunamıyor.",
connectionMayBeLost: "Bağlantı kopmuş olabilir. Verileri yenileyip tekrar deneyin.",
showResultsPane: "Sonuçları göster",
hideResultsPane: "Sonuçları gizle",
@@ -1656,6 +1658,7 @@ export default withEnglishFallback({
returnedTable: "{count} sütunlu bir sonuç tablosu döndürdü",
noTable: "Sonuç tablosu döndürülmedi",
noSql: "SQL kullanılamıyor",
lineColumn: "{line}. satır, {column}. sütun",
navigationHint: "SQL'ini önizlemek için bir satıra tıklayın; düzenleyicide odaklamak için çift tıklayın.",
recoveryPrompt: "#{statement} numaralı ifade başarısız oldu; {count} ifade çalıştırılmadı.",
stop: "Durdur",
+3
View File
@@ -1357,6 +1357,8 @@ export default withEnglishFallback({
queryTimeoutError: "查询超时 ({seconds}s),请检查数据库连接是否正常",
changeConnectionTimeout: "修改超时时间",
changeQueryTimeout: "修改查询超时时间",
locateError: "定位错误(第 {line} 行,第 {column} 列)",
errorPositionUnavailable: "错误位置与当前编辑器内容不一致,无法定位。",
connectionMayBeLost: "连接可能已断开,请刷新数据重试",
showResultsPane: "显示结果",
hideResultsPane: "收起",
@@ -1701,6 +1703,7 @@ export default withEnglishFallback({
returnedTable: "返回 {count} 列结果表",
noTable: "未返回结果表",
noSql: "无法获取 SQL",
lineColumn: "第 {line} 行,第 {column} 列",
navigationHint: "单击预览对应 SQL,双击在编辑器中聚焦并选中。",
recoveryPrompt: "语句 #{statement} 执行失败,剩余 {count} 条未执行。",
stop: "停止",
+3
View File
@@ -1410,6 +1410,8 @@ export default withEnglishFallback({
queryTimeoutError: "查詢逾時 ({seconds}s),請檢查資料庫連線是否正常",
changeConnectionTimeout: "修改連線逾時時間",
changeQueryTimeout: "修改查詢逾時時間",
locateError: "定位錯誤(第 {line} 行,第 {column} 列)",
errorPositionUnavailable: "錯誤位置與目前編輯器內容不一致,無法定位。",
connectionMayBeLost: "連線可能已斷開,請重新整理資料重試",
showResultsPane: "顯示結果",
hideResultsPane: "收起結果",
@@ -1708,6 +1710,7 @@ export default withEnglishFallback({
returnedTable: "回傳 {count} 欄的結果表",
noTable: "未回傳結果表",
noSql: "無法取得 SQL",
lineColumn: "第 {line} 行,第 {column} 列",
navigationHint: "按一下預覽 SQL,按兩下在編輯器中聚焦並選取。",
recoveryPrompt: "語句 #{statement} 執行失敗,剩餘 {count} 條未執行。",
stop: "停止",
+188
View File
@@ -0,0 +1,188 @@
import { describe, expect, it } from "vitest";
import type { BackendError, SqlErrorPosition } from "@/lib/backend/errorUtils";
import { mapExecutedOffsetToSource, scalarPositionToUtf16Offset, sqlErrorEditorOffset } from "@/lib/sql/errorPosition";
import type { QueryResult } from "@/types/database";
function backendError(position?: SqlErrorPosition): BackendError {
return {
version: 1,
code: "DBX-JDBC-4001",
messageKey: "backendErrors.jdbc.sqlFailed",
messageParams: { stage: "execute" },
source: "jdbcAgent",
operationOutcome: "unknown",
detail: "ERROR: relation does not exist",
...(position ? { errorPosition: position } : {}),
};
}
function errorResult(options: { editorStatement?: string; sourceFrom?: number; sourceTo?: number; executedStatement?: string; position?: SqlErrorPosition }): QueryResult {
const statement = options.editorStatement ?? "";
return {
columns: ["Error"],
rows: [["ERROR: relation does not exist"]],
affected_rows: 0,
execution_time_ms: 0,
execution_error: true,
error: backendError(options.position),
sourceStatement: statement,
sourceFrom: options.sourceFrom ?? 0,
sourceTo: options.sourceTo ?? statement.length,
...(options.executedStatement ? { executedStatement: options.executedStatement } : {}),
};
}
describe("sqlErrorEditorOffset", () => {
it("maps a first-line column to an absolute editor offset", () => {
const sql = "SELECT * FROM no_such_table";
const result = errorResult({
editorStatement: sql,
position: { line: 1, column: 15, offset: 14 },
});
expect(sqlErrorEditorOffset({ editorSql: sql, result })).toEqual({ offset: 14, line: 1, column: 15 });
});
it("maps a later-line column", () => {
const statement = "SELECT *\nFROM missing";
const result = errorResult({
editorStatement: statement,
position: { line: 2, column: 6, offset: 14 },
});
expect(sqlErrorEditorOffset({ editorSql: statement, result })).toEqual({ offset: 14, line: 2, column: 6 });
});
it("adds the statement's absolute start offset in a multi-statement editor", () => {
const statement = "SELECT *\nFROM missing";
const editorSql = "SELECT 1;\n" + statement;
const start = editorSql.indexOf(statement);
const result = errorResult({
editorStatement: statement,
sourceFrom: start,
sourceTo: start + statement.length,
position: { line: 2, column: 6, offset: 14 },
});
expect(sqlErrorEditorOffset({ editorSql, result })).toEqual({ offset: start + 14, line: 2, column: 6 });
});
it("converts scalar-value columns to UTF-16 offsets for astral characters", () => {
const statement = "SELECT '😀' FROM t";
// Code-point column of FROM: S E L E C T space ' 😀 ' space = 11 chars, so FROM is column 12.
const result = errorResult({
editorStatement: statement,
position: { line: 1, column: 12, offset: 11 },
});
// UTF-16 offset: the emoji occupies two code units, so 'FROM' starts at 12.
expect(sqlErrorEditorOffset({ editorSql: statement, result })).toEqual({ offset: 12, line: 1, column: 12 });
});
it("returns undefined when the result carries no position", () => {
const sql = "SELECT 1";
const result = errorResult({ editorStatement: sql });
expect(sqlErrorEditorOffset({ editorSql: sql, result })).toBeUndefined();
});
it("returns undefined when the statement no longer matches the editor", () => {
const result = errorResult({
editorStatement: "SELECT * FROM gone",
position: { line: 1, column: 15, offset: 14 },
});
expect(sqlErrorEditorOffset({ editorSql: "SELECT 1", result })).toBeUndefined();
});
it("clamps instead of failing when the position line is beyond the statement", () => {
const sql = "SELECT 1";
const result = errorResult({
editorStatement: sql,
position: { line: 5, column: 1, offset: 4 },
});
// The position is relative to the statement, so a bad line clamps to its end.
expect(sqlErrorEditorOffset({ editorSql: sql, result })?.offset).toBe(sql.length);
});
describe("when the executed statement differs from the source", () => {
it("projects an appended LIMIT/OFFSET position back onto the source", () => {
const source = "SELECT * FROM t";
const executed = "SELECT * FROM t LIMIT 100 OFFSET 0;";
const tableOffset = executed.indexOf("FROM t") + "FROM ".length;
const result = errorResult({
editorStatement: source,
executedStatement: executed,
position: { line: 1, column: tableOffset + 1, offset: tableOffset },
});
expect(sqlErrorEditorOffset({ editorSql: source, result })?.offset).toBe(source.indexOf("FROM t") + "FROM ".length);
});
it("projects a pagination wrapper position back onto the inner statement", () => {
const source = "SELECT * FROM t";
const executed = "SELECT * FROM (SELECT * FROM t) AS dbx_page LIMIT 100 OFFSET 0;";
const tableOffset = executed.indexOf("FROM t") + "FROM ".length;
const result = errorResult({
editorStatement: source,
executedStatement: executed,
position: { line: 1, column: tableOffset + 1, offset: tableOffset },
});
expect(sqlErrorEditorOffset({ editorSql: source, result })?.offset).toBe(source.indexOf("FROM t") + "FROM ".length);
});
it("projects an injected hidden-key column position back onto the source", () => {
const source = "SELECT * FROM users";
const executed = 'SELECT *, "id" AS "__dbx_hidden_pk" FROM users LIMIT 100 OFFSET 0;';
const tableOffset = executed.indexOf("FROM users") + "FROM ".length;
const result = errorResult({
editorStatement: source,
executedStatement: executed,
position: { line: 1, column: tableOffset + 1, offset: tableOffset },
});
expect(sqlErrorEditorOffset({ editorSql: source, result })?.offset).toBe(source.indexOf("users"));
});
it("keeps a multi-line position aligned when the wrapper is on the first line only", () => {
const source = "SELECT *\nFROM users\nWHERE id = 1";
const executed = `SELECT * FROM (${source}) AS dbx_page LIMIT 100 OFFSET 0;`;
const executedMissing = executed.indexOf("users");
const result = errorResult({
editorStatement: source,
executedStatement: executed,
// `users` is on line 2 of both texts.
position: { line: 2, column: 6, offset: executedMissing + 5 },
});
expect(sqlErrorEditorOffset({ editorSql: source, result })?.offset).toBe(source.indexOf("users"));
});
});
});
describe("scalarPositionToUtf16Offset", () => {
it("converts scalar line/column to a UTF-16 offset", () => {
expect(scalarPositionToUtf16Offset("SELECT '😀' FROM t", 1, 12)).toBe(12);
});
it("clamps a column past the end of its line", () => {
expect(scalarPositionToUtf16Offset("SELECT 1\nFROM t", 1, 999)).toBe("SELECT 1".length);
});
});
describe("mapExecutedOffsetToSource", () => {
it("returns the offset unchanged when both texts are identical", () => {
expect(mapExecutedOffsetToSource("SELECT 1", "SELECT 1", 4)).toBe(4);
});
it("maps an append-only rewrite through the shared prefix", () => {
expect(mapExecutedOffsetToSource("SELECT 1 LIMIT 10", "SELECT 1", 4)).toBe(4);
});
it("clamps offsets in the appended region to the source end", () => {
expect(mapExecutedOffsetToSource("SELECT 1 LIMIT 10", "SELECT 1", 15)).toBe("SELECT 1".length);
});
});
+45 -6
View File
@@ -4,6 +4,19 @@
export type BackendErrorParam = string | number | boolean;
/**
* Driver-reported SQL error position, relative to the statement text that was
* actually sent to the database. `line`/`column` are 1-based and counted in
* Unicode scalar values; `offset` is the 0-based scalar-value index.
*
* Currently only populated by the native PostgreSQL driver.
*/
export interface SqlErrorPosition {
line: number;
column: number;
offset: number;
}
export interface BackendError {
version: 1;
code: string;
@@ -20,6 +33,7 @@ export interface BackendError {
detail?: string;
diagnostics?: Record<string, unknown>;
helpUrl?: string;
errorPosition?: SqlErrorPosition;
}
export const MANUAL_TRANSACTION_SESSION_EXPIRED_CODE = "DBX-TXN-1001";
@@ -27,20 +41,25 @@ export const MANUAL_TRANSACTION_SESSION_EXPIRED_CODE = "DBX-TXN-1001";
const MAX_FALLBACK_CHARS = 64 * 1024;
const MAX_ERROR_PARSE_DEPTH = 16;
const AGENT_RPC_ERROR_DATA_MARKER = "\nDBX_AGENT_ERROR_DATA:";
// Rust-side transport suffix carrying a driver cursor position. It is stripped
// before a structured envelope is built, but metadata/catalog errors can surface
// as raw strings, so strip it here so it never reaches the UI.
const SQL_ERROR_POSITION_MARKER_PATTERN = /\nDBX_SQL_ERROR_POSITION:\d+/g;
export function sanitizeBackendErrorMessage(message: string): string {
const markerIndex = message.lastIndexOf(AGENT_RPC_ERROR_DATA_MARKER);
if (markerIndex < 0) return message;
const withoutPositionMarker = message.replace(SQL_ERROR_POSITION_MARKER_PATTERN, "");
const markerIndex = withoutPositionMarker.lastIndexOf(AGENT_RPC_ERROR_DATA_MARKER);
if (markerIndex < 0) return withoutPositionMarker;
const rawData = message.slice(markerIndex + AGENT_RPC_ERROR_DATA_MARKER.length).trim();
const rawData = withoutPositionMarker.slice(markerIndex + AGENT_RPC_ERROR_DATA_MARKER.length).trim();
try {
const data: unknown = JSON.parse(rawData);
if (!data || typeof data !== "object" || Array.isArray(data)) return message;
if (!data || typeof data !== "object" || Array.isArray(data)) return withoutPositionMarker;
} catch {
return message;
return withoutPositionMarker;
}
return message.slice(0, markerIndex).trimEnd();
return withoutPositionMarker.slice(0, markerIndex).trimEnd();
}
function isBackendError(value: unknown): value is BackendError {
@@ -79,9 +98,29 @@ function isBackendError(value: unknown): value is BackendError {
}
}
if (candidate.detail !== undefined && typeof candidate.detail !== "string") return false;
// Optional driver-reported position. Malformed values are rejected so a
// corrupted envelope never drives a wrong editor jump, while a missing field
// stays valid (all non-PostgreSQL errors and older backends).
if (candidate.errorPosition !== undefined) {
if (!isValidErrorPosition(candidate.errorPosition)) return false;
}
return Object.values(candidate.messageParams).every((param) => typeof param === "string" || typeof param === "boolean" || (typeof param === "number" && Number.isFinite(param)));
}
function isValidErrorPosition(value: unknown): value is SqlErrorPosition {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const { line, column, offset } = value as Record<string, unknown>;
return isPositiveInt(line) && isPositiveInt(column) && isNonNegativeInt(offset);
}
function isPositiveInt(value: unknown): boolean {
return typeof value === "number" && Number.isInteger(value) && value >= 1;
}
function isNonNegativeInt(value: unknown): boolean {
return typeof value === "number" && Number.isInteger(value) && value >= 0;
}
export function normalizeBackendError(error: unknown): BackendError | null {
return normalizeBackendErrorAtDepth(error, new WeakSet<object>(), 0);
}
+245
View File
@@ -0,0 +1,245 @@
import type { DatabaseType, QueryResult } from "@/types/database";
import type { SqlParameterOptions } from "@/lib/sql/sqlParameters";
import { splitSqlStatementRanges } from "@/lib/sql/sqlStatementRanges";
import { resultSourceRange } from "@/lib/tabs/tabPresentation";
export interface EditorErrorPosition {
/** Absolute UTF-16 offset into the current editor document. */
offset: number;
/** 1-based line within the statement, for display. */
line: number;
/** 1-based column within the statement, for display. */
column: number;
}
export interface SqlErrorOffsetOptions {
editorSql: string;
result: QueryResult | undefined | null;
resultIndex?: number;
databaseType?: DatabaseType;
parameterOptions?: SqlParameterOptions;
}
/**
* Diagnostics for the "locate SQL error" flow.
*
* Failures are always logged (they explain the "position unavailable" toast).
* Successful resolutions are verbose-only: run
* `localStorage.setItem("dbx:debug:sql-error-position", "1")` once (then reload)
* to also inspect the drift/offset details.
*/
const LOG_TAG = "[DBX][sql-error-position]";
const DEBUG_FLAG_KEY = "dbx:debug:sql-error-position";
export function isSqlErrorPositionDebugEnabled(): boolean {
try {
return globalThis.localStorage?.getItem(DEBUG_FLAG_KEY) === "1";
} catch {
return false;
}
}
/** Emit a diagnostics record. Failures are always logged; success details are verbose-only. */
export function logSqlErrorPosition(stage: string, payload: Record<string, unknown>): void {
console.info(`${LOG_TAG} ${stage}`, payload);
}
function previewText(value: string | undefined | null, max = 2000): string | undefined {
if (value === undefined || value === null) return undefined;
return value.length > max ? `${value.slice(0, max)}…(len=${value.length})` : value;
}
function logDiagnostics(stage: string, options: SqlErrorOffsetOptions, extra: Record<string, unknown>): void {
const result = options.result ?? undefined;
const details: Record<string, unknown> = {
stage,
resultIndex: options.resultIndex,
editorLength: options.editorSql.length,
result: result
? {
statementIndex: result.statement_index,
executionError: result.execution_error,
hasError: Boolean(result.error),
errorPosition: result.error?.errorPosition ?? null,
sourceStatement: previewText(result.sourceStatement),
executedStatement: previewText(result.executedStatement),
sourceFrom: result.sourceFrom,
sourceTo: result.sourceTo,
}
: null,
editorSliceAtSourceRange: typeof result?.sourceFrom === "number" && typeof result?.sourceTo === "number" ? previewText(options.editorSql.slice(result.sourceFrom, result.sourceTo)) : undefined,
editorContainsSourceStatement: result?.sourceStatement ? options.editorSql.includes(result.sourceStatement) : undefined,
...extra,
};
if (options.editorSql.trim()) {
try {
details.editorStatements = splitSqlStatementRanges(options.editorSql, options.databaseType, options.parameterOptions).map((statement, index) => ({
index,
from: statement.from,
to: statement.to,
sql: previewText(statement.sql, 200),
}));
} catch (error) {
details.editorStatementSplitError = String(error);
}
}
logSqlErrorPosition(stage, details);
}
/**
* Translate a backend-reported SQL error position into an absolute offset in the
* current editor document.
*
* The backend reports the position against the statement text it actually sent.
* DBX frequently rewrites the user's statement before execution (pagination
* wrappers, appended LIMIT/OFFSET, injected hidden key columns), so the position
* 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.
*/
export function sqlErrorEditorOffset(options: SqlErrorOffsetOptions): EditorErrorPosition | undefined {
const position = options.result?.error?.errorPosition;
if (!position) {
if (isSqlErrorPositionDebugEnabled()) logDiagnostics("skip:no-error-position", options, {});
return undefined;
}
const range = resultSourceRange(options.editorSql, options.result ?? undefined, options.resultIndex, options.databaseType, options.parameterOptions);
if (!range) {
const reason = "resultSourceRange returned undefined: the result's statement text is not found (or no longer unique) in the editor";
if (isSqlErrorPositionDebugEnabled()) {
logDiagnostics("unresolved:result-source-range", options, { reason });
} else {
// Redacted failure record: SQL previews (user data) go to the debug-gated diagnostics only.
logSqlErrorPosition("unresolved:result-source-range", { reason, resultIndex: options.resultIndex, statementIndex: options.result?.statement_index ?? null });
}
return undefined;
}
// The position is relative to the executed statement when we recorded one;
// older/other results only have the source statement to fall back on.
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);
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));
if (isSqlErrorPositionDebugEnabled()) {
logDiagnostics("resolved", options, {
position,
rangeFrom: range.from,
rangeTo: range.to,
rangeSql: previewText(range.sql),
drifted,
positionBasis: previewText(positionBasis),
basisOffset,
sourceOffset,
editorOffset,
});
}
return { offset: editorOffset, line: position.line, column: position.column };
}
/**
* Convert a 1-based line/column counted in Unicode scalar values into a UTF-16
* offset. Lines beyond the text are clamped to its end (the position is always
* relative to `text`, so this only guards malformed/edge input).
*/
export function scalarPositionToUtf16Offset(text: string, line: number, column: number): number {
const characters = Array.from(text);
if (characters.length === 0) return 0;
const targetLine = Math.max(1, line);
const targetColumn = Math.max(1, column);
let currentLine = 1;
let lineStart = 0;
let index = 0;
while (index < characters.length && currentLine < targetLine) {
if (characters[index] === "\n") {
currentLine += 1;
lineStart = index + 1;
}
index += 1;
}
if (currentLine < targetLine) return text.length;
let lineEnd = characters.length;
for (let cursor = lineStart; cursor < characters.length; cursor += 1) {
if (characters[cursor] === "\n") {
lineEnd = cursor;
break;
}
}
const target = Math.min(lineStart + (targetColumn - 1), lineEnd);
return characters.slice(0, target).join("").length;
}
/**
* Project a UTF-16 offset in the executed statement onto the original source
* statement.
*
* 1. Pagination wraps (`SELECT * FROM (<sql>) …`) and appended clauses keep the
* original statement contiguous, so a direct substring search resolves them
* exactly.
* 2. Injected columns (hidden keys) break contiguity: align the common prefix,
* then let the source resume where its remaining text next appears in the
* executed statement.
* 3. Otherwise fall back to a single changed-region alignment between the first
* and last difference.
*/
export function mapExecutedOffsetToSource(executed: string, source: string, executedOffset: number): number {
if (executed === source) return clamp(executedOffset, 0, source.length);
const clampedExecuted = clamp(executedOffset, 0, executed.length);
const direct = executed.indexOf(source);
if (direct >= 0) return clamp(clampedExecuted - direct, 0, source.length);
const prefix = commonPrefixLength(executed, source);
const remainder = source.slice(prefix);
if (remainder.length >= 2) {
const resume = executed.indexOf(remainder, prefix);
if (resume >= 0) {
if (clampedExecuted < resume) return Math.min(clampedExecuted, prefix);
return clamp(clampedExecuted - (resume - prefix), prefix, source.length);
}
}
const suffix = commonSuffixLength(executed, source, prefix);
const executedChangeEnd = executed.length - suffix;
const sourceChangeEnd = source.length - suffix;
if (clampedExecuted <= prefix) return clampedExecuted;
if (clampedExecuted >= executedChangeEnd) {
return clamp(clampedExecuted - (executedChangeEnd - sourceChangeEnd), prefix, sourceChangeEnd);
}
return prefix;
}
function commonPrefixLength(a: string, b: string): number {
let index = 0;
const max = Math.min(a.length, b.length);
while (index < max && a[index] === b[index]) index += 1;
return index;
}
function commonSuffixLength(a: string, b: string, prefix: number): number {
let index = 0;
const max = Math.min(a.length, b.length) - prefix;
while (index < max && a[a.length - 1 - index] === b[b.length - 1 - index]) index += 1;
return index;
}
function clamp(value: number, min: number, max: number): number {
if (max < min) return min;
return Math.max(min, Math.min(value, max));
}
@@ -43,6 +43,7 @@ export const contentSurfaceEventNames = [
"structureEditorClose",
"previewStatement",
"focusStatement",
"focusErrorOffset",
"openSettings",
"openConnectionSettings",
"toggleZenMode",
@@ -8,6 +8,7 @@ import { executableStatementRanges, splitSqlStatementRanges, sqlStatementParamet
import type { SqlParameterOptions } from "@/lib/sql/sqlParameters";
import { sqlTextFingerprint } from "@/lib/sql/sqlTextFingerprint";
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
import type { SqlErrorPosition } from "@/lib/backend/errorUtils";
import type { BatchSqlExecution, ConnectionConfig, DatabaseType, QueryResult, QueryTab } from "@/types/database";
type Translate = (key: string, params?: Record<string, unknown>) => string;
@@ -416,6 +417,8 @@ export interface ExecutionSummaryItem {
sourceTo?: number;
status: "pending" | "running" | "success" | "error" | "skipped" | "cancelled";
error?: string;
/** Backend-reported error row/column, when the driver provides one. */
errorPosition?: SqlErrorPosition;
returnedColumns: number;
returnedRows: number;
affectedRows: number;
@@ -446,6 +449,7 @@ export function executionSummaryItems(tab: Pick<QueryTab, "result" | "results" |
sourceTo: item.to,
status: item.status,
error: item.error,
errorPosition: item.errorDetails?.errorPosition ?? result?.error?.errorPosition,
returnedColumns: result?.columns.length ?? 0,
returnedRows,
affectedRows,
@@ -467,6 +471,7 @@ export function executionSummaryItems(tab: Pick<QueryTab, "result" | "results" |
sourceTo: result.sourceTo,
status: isError ? "error" : "success",
error: isError ? String(result.rows[0]?.[0] ?? "") : undefined,
errorPosition: result.error?.errorPosition,
returnedColumns: result.columns.length,
returnedRows: result.rows.length,
affectedRows: result.affected_rows,
@@ -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;
+7
View File
@@ -1083,6 +1083,13 @@ export interface QueryResult {
/** Absolute offsets in the editor document at execution time. */
sourceFrom?: number;
sourceTo?: number;
/**
* Frontend-internal: the statement text actually sent for this result when it
* differs from `sourceStatement` (pagination wrapping, hidden-key rewrites…).
* Backend SQL error positions are relative to this text, so it is needed to
* map an error row/column back onto the user's original statement.
*/
executedStatement?: string;
/** Database server messages (notices, warnings) emitted while producing this result. Omitted when empty. */
messages?: QueryMessage[];
}
+52 -1
View File
@@ -125,6 +125,11 @@ pub struct BackendError {
diagnostics: Option<BackendErrorDiagnostics>,
#[serde(skip_serializing_if = "Option::is_none")]
help_url: Option<String>,
/// Driver-reported SQL error position, when the adapter provides one
/// (currently native PostgreSQL). Optional field, added without bumping the
/// envelope version; clients that do not understand it simply ignore it.
#[serde(skip_serializing_if = "Option::is_none")]
error_position: Option<crate::sql_error_position::SqlErrorPosition>,
}
impl BackendError {
@@ -270,6 +275,14 @@ impl BackendError {
)
}
/// Create a SQL failure envelope while retaining bounded native driver detail
/// and a driver-reported error position.
pub fn from_sql_detail_with_position(message: &str, position: crate::sql_error_position::SqlErrorPosition) -> Self {
let mut error = Self::from_sql_detail(message);
error.error_position = Some(position);
error
}
/// Adapt a DuckDB worker error while retaining both the native detail and
/// the worker protocol code for diagnostics at the public boundary.
pub fn from_duckdb_worker_error(code: &str, message: &str) -> Self {
@@ -350,6 +363,10 @@ impl BackendError {
self.diagnostics.as_ref()
}
pub fn error_position(&self) -> Option<crate::sql_error_position::SqlErrorPosition> {
self.error_position
}
fn new(
entry: &'static CatalogEntry,
source: BackendErrorSource,
@@ -372,6 +389,7 @@ impl BackendError {
detail,
diagnostics,
help_url: entry.help_url.map(str::to_string),
error_position: None,
}
}
}
@@ -673,6 +691,8 @@ fn bounded_text(value: &str, max_bytes: usize) -> String {
}
fn bounded_detail(message: &str) -> Option<String> {
let stripped = crate::sql_error_position::strip_marker(message);
let message = stripped.as_str();
if message.trim().is_empty() {
return None;
}
@@ -684,7 +704,10 @@ fn bounded_detail(message: &str) -> Option<String> {
fn bounded_native_detail(message: &str) -> Option<String> {
// This path is only for typed native SQL failures. Do not infer or rewrite
// SQL content here; preserving the driver's diagnostic is the contract.
let trimmed = message.trim();
// The position transport suffix is stripped because it is surfaced through
// the structured `errorPosition` field, not as message text.
let stripped = crate::sql_error_position::strip_marker(message);
let trimmed = stripped.trim();
if trimmed.is_empty() {
return None;
}
@@ -1141,6 +1164,34 @@ mod tests {
assert_eq!(payload["origin"]["adapter"], "native");
}
#[test]
fn sql_error_with_position_exposes_typed_error_position_and_keeps_v1_detail() {
let position = crate::sql_error_position::SqlErrorPosition { line: 2, column: 6, offset: 12 };
let payload = serde_json::to_value(BackendError::from_sql_detail_with_position(
"ERROR: relation \"missing\" does not exist",
position,
))
.unwrap();
assert_eq!(payload["code"], "DBX-JDBC-4001");
assert_eq!(payload["detail"], "ERROR: relation \"missing\" does not exist");
assert_eq!(payload["errorPosition"]["line"], 2);
assert_eq!(payload["errorPosition"]["column"], 6);
assert_eq!(payload["errorPosition"]["offset"], 12);
assert_eq!(payload["origin"]["adapter"], "native");
// SQL errors without a position must not emit the optional field.
let plain = serde_json::to_value(BackendError::from_sql_detail("ERROR: nope")).unwrap();
assert!(plain.get("errorPosition").is_none());
}
#[test]
fn detail_sanitizers_strip_the_position_transport_marker() {
let message = format!("ERROR: relation does not exist{}", crate::sql_error_position::encode_marker(15));
assert_eq!(bounded_native_detail(&message).as_deref(), Some("ERROR: relation does not exist"));
assert_eq!(bounded_detail(&message).as_deref(), Some("ERROR: relation does not exist"));
}
#[test]
fn duckdb_worker_sql_error_preserves_worker_code_detail_and_driver_origin() {
let payload = serde_json::to_value(BackendError::from_duckdb_worker_error(
+24 -2
View File
@@ -1480,6 +1480,25 @@ fn escape_tsvector_lexeme(value: &str) -> String {
}
fn pg_error_to_string(err: tokio_postgres::Error) -> String {
let Some(db_error) = err.as_db_error() else {
return err.to_string();
};
let mut message = db_error.to_string();
// Carry the server-reported cursor position across the `db` layer's
// `Result<_, String>` boundary; `query.rs` resolves it against the executed
// statement and strips the suffix before the message reaches any client.
if let Some(tokio_postgres::error::ErrorPosition::Original(cursor)) = db_error.position() {
message.push_str(&crate::sql_error_position::encode_marker(*cursor));
}
message
}
/// Same as [`pg_error_to_string`] but never carries a cursor position.
///
/// Used for infrastructure/setup statements (search_path, BEGIN/ROLLBACK, …)
/// whose SQL is not the statement the user is editing: a marker from those would
/// be resolved against the user's SQL and point at the wrong place.
fn pg_error_to_string_plain(err: tokio_postgres::Error) -> String {
err.as_db_error().map(ToString::to_string).unwrap_or_else(|| err.to_string())
}
@@ -2822,7 +2841,10 @@ async fn set_automatic_postgres_timezone(client: &deadpool_postgres::Client, tim
}
}
Err(error) => {
return Err(format!("PostgreSQL SET timezone failed after connecting: {}", pg_error_to_string(error)));
return Err(format!(
"PostgreSQL SET timezone failed after connecting: {}",
pg_error_to_string_plain(error)
));
}
}
}
@@ -8025,7 +8047,7 @@ pub(crate) async fn execute_postgres_infra_statement(
tokio::time::timeout(timeout_duration, client.execute_typed(sql, &[]))
.await
.map_err(|_| format!("PostgreSQL {stage} timed out after {} seconds", timeout_duration.as_secs()))?
.map_err(pg_error_to_string)
.map_err(pg_error_to_string_plain)
}
pub(crate) async fn wait_postgres_operation<T, F>(
+1
View File
@@ -93,6 +93,7 @@ pub mod sql_analysis;
pub mod sql_diagnostics;
pub mod sql_dialect;
pub mod sql_editability;
pub mod sql_error_position;
pub mod sql_file_import;
pub mod sql_parser;
pub mod sql_risk;
+98 -6
View File
@@ -68,23 +68,43 @@ pub enum PoolErrorAction {
#[derive(Debug, Clone)]
pub enum QueryExecutionError {
Agent(AgentCallError),
DuckDb { code: String, message: String },
Canceled { stage: AgentErrorStage, operation_outcome: AgentOperationOutcome },
DuckDb {
code: String,
message: String,
},
Canceled {
stage: AgentErrorStage,
operation_outcome: AgentOperationOutcome,
},
Timeout(String),
Sql(String),
/// Native PostgreSQL SQL failure whose driver-reported cursor position was
/// resolved against the executed statement text. Kept distinct from
/// [`Self::Sql`] so the position survives `classify_query_error` and reaches
/// [`Self::into_backend_error`] as a typed field instead of being parsed back
/// out of a string.
SqlWithPosition {
message: String,
position: crate::sql_error_position::SqlErrorPosition,
},
Legacy(String),
}
impl QueryExecutionError {
pub fn into_legacy_string(self) -> String {
match self {
let mut message = match self {
Self::Agent(error) => error.into_legacy_string(),
Self::DuckDb { message, .. } => message,
Self::Canceled { .. } => canceled_error(),
Self::Timeout(error) => error,
Self::Sql(error) => error,
Self::SqlWithPosition { message, .. } => message,
Self::Legacy(error) => error,
}
};
// Defensive: any remaining transport marker (e.g. from a driver error
// that never passed through the resolve step) must not reach clients.
while crate::sql_error_position::take_marker(&mut message).is_some() {}
message
}
pub fn into_backend_error(self) -> crate::backend_error::BackendError {
@@ -98,6 +118,9 @@ impl QueryExecutionError {
}
Self::Timeout(error) => crate::backend_error::BackendError::from_timeout_detail(&error),
Self::Sql(error) => crate::backend_error::BackendError::from_sql_detail(&error),
Self::SqlWithPosition { message, position } => {
crate::backend_error::BackendError::from_sql_detail_with_position(&message, position)
}
Self::Legacy(error) => crate::backend_error::BackendError::from_legacy_string(&error),
}
}
@@ -111,6 +134,9 @@ impl QueryExecutionError {
canceled @ Self::Canceled { .. } => canceled,
Self::Timeout(error) => Self::Timeout(query_error_with_omitted_sql_context(&error, sql)),
Self::Sql(error) => Self::Sql(append_typed_sql_error_context(&error, sql)),
Self::SqlWithPosition { message, position } => {
Self::SqlWithPosition { message: append_typed_sql_error_context(&message, sql), position }
}
Self::Legacy(error) => Self::Legacy(query_error_with_omitted_sql_context(&error, sql)),
}
}
@@ -122,6 +148,9 @@ impl QueryExecutionError {
canceled @ Self::Canceled { .. } => canceled,
Self::Timeout(error) => Self::Timeout(format!("{error}; {context}")),
Self::Sql(error) => Self::Sql(format!("{error}; {context}")),
Self::SqlWithPosition { message, position } => {
Self::SqlWithPosition { message: format!("{message}; {context}"), position }
}
Self::Legacy(error) => Self::Legacy(format!("{error}; {context}")),
}
}
@@ -129,7 +158,12 @@ impl QueryExecutionError {
fn as_agent_error(&self) -> Option<&AgentCallError> {
match self {
Self::Agent(error) => Some(error),
Self::DuckDb { .. } | Self::Canceled { .. } | Self::Timeout(_) | Self::Sql(_) | Self::Legacy(_) => None,
Self::DuckDb { .. }
| Self::Canceled { .. }
| Self::Timeout(_)
| Self::Sql(_)
| Self::SqlWithPosition { .. }
| Self::Legacy(_) => None,
}
}
}
@@ -142,6 +176,7 @@ impl std::fmt::Display for QueryExecutionError {
Self::Canceled { .. } => formatter.write_str(QUERY_CANCELED),
Self::Timeout(error) => formatter.write_str(error),
Self::Sql(error) => formatter.write_str(error),
Self::SqlWithPosition { message, .. } => formatter.write_str(message),
Self::Legacy(error) => formatter.write_str(error),
}
}
@@ -1490,6 +1525,7 @@ fn query_execution_error_action(
QueryExecutionError::DuckDb { message, .. } => query_pool_error_action(db_type, sql, message),
QueryExecutionError::Timeout(message)
| QueryExecutionError::Sql(message)
| QueryExecutionError::SqlWithPosition { message, .. }
| QueryExecutionError::Legacy(message) => query_pool_error_action(db_type, sql, message),
QueryExecutionError::Agent(_) => unreachable!("Agent errors return above"),
}
@@ -2345,7 +2381,23 @@ async fn do_execute_typed(
if let Some(duckdb_error) = typed_duckdb_error {
return QueryExecutionError::DuckDb { code: duckdb_error.code, message: duckdb_error.message };
}
typed_agent_error.map_or_else(|| QueryExecutionError::Legacy(error), QueryExecutionError::Agent)
if let Some(agent_error) = typed_agent_error {
return QueryExecutionError::Agent(agent_error);
}
// PostgreSQL reports a cursor position as a marker suffix on the
// driver message. Resolve it here, while the executed statement text
// is still available, into a typed field. The marker is stripped even
// when it cannot be resolved, so it can never reach a user-facing
// message. The PostgreSQL driver also backs Redshift/GaussDB/Kwdb/
// QuestDB/openGauss, so this is not gated on `DatabaseType::Postgres`.
if error.contains(crate::sql_error_position::SQL_ERROR_POSITION_MARKER) {
let (message, position) = crate::sql_error_position::take_message_position(&error, sql);
return match position {
Some(position) => QueryExecutionError::SqlWithPosition { message, position },
None => QueryExecutionError::Legacy(message),
};
}
QueryExecutionError::Legacy(error)
})
.map_err(|error| classify_query_error(pool_db_type, error))
}
@@ -8749,6 +8801,46 @@ for line in sys.stdin:
assert_eq!(error.into_backend_error().code(), "DBX-JDBC-1002");
}
#[test]
fn postgres_sql_error_with_position_survives_classification_and_legacy_rendering() {
let sql = "SELECT *\nFROM no_such_table";
let cursor = sql.find("no_such_table").unwrap() as u32 + 1;
let raw = format!(
"ERROR: relation \"no_such_table\" does not exist{}",
crate::sql_error_position::encode_marker(cursor)
);
let (message, position) = crate::sql_error_position::take_message_position(&raw, sql);
let error = QueryExecutionError::SqlWithPosition { message, position: position.unwrap() };
// The transport marker must never reach the user-facing message.
let legacy = error.clone().into_legacy_string();
assert!(!legacy.contains(crate::sql_error_position::SQL_ERROR_POSITION_MARKER));
assert_eq!(legacy, "ERROR: relation \"no_such_table\" does not exist");
// Classification must not downgrade the typed variant.
let classified = classify_query_error(Some(DatabaseType::Postgres), error.clone());
assert!(matches!(classified, QueryExecutionError::SqlWithPosition { .. }));
let backend_error = classified.into_backend_error();
assert_eq!(backend_error.code(), "DBX-JDBC-4001");
let position = backend_error.error_position().expect("position must survive into the envelope");
assert_eq!((position.line, position.column), (2, 6));
}
#[test]
fn legacy_rendering_strips_a_leftover_transport_marker() {
// Guards the driver-error path that never went through the resolve step
// (e.g. a PostgreSQL-family driver error shown as a legacy string).
let error = QueryExecutionError::Legacy(format!(
"ERROR: boom{}{}",
crate::sql_error_position::encode_marker(9),
crate::sql_error_position::encode_marker(2)
));
let rendered = error.into_legacy_string();
assert_eq!(rendered, "ERROR: boom");
assert!(!rendered.contains(crate::sql_error_position::SQL_ERROR_POSITION_MARKER));
}
#[test]
fn duckdb_worker_error_preserves_catalog_identity_and_detail() {
let error = QueryExecutionError::DuckDb {
+255
View File
@@ -0,0 +1,255 @@
//! Driver-reported SQL error positions (currently PostgreSQL only).
//!
//! PostgreSQL reports a 1-based *character* cursor position for many SQL
//! errors ([`tokio_postgres::error::ErrorPosition::Original`]). The native
//! driver error path in `db::postgres` only exposes a `String` to the query
//! layer, so the raw cursor is carried across that `String` ABI as a small
//! machine-readable suffix and resolved back into a typed [`SqlErrorPosition`]
//! as soon as the executed statement text is available.
//!
//! The suffix is a position carrier, not an error classifier: nothing about the
//! error category, retryability or recovery is inferred from it, and
//! [`strip_marker`] guarantees it can never surface in a user-facing message.
use serde::{Deserialize, Serialize};
/// Suffix appended to a driver error message to carry a raw PostgreSQL cursor
/// position across the `Result<_, String>` boundary of the `db` layer.
pub const SQL_ERROR_POSITION_MARKER: &str = "\nDBX_SQL_ERROR_POSITION:";
/// A SQL error position relative to the statement text that was actually sent
/// to the database.
///
/// `line`/`column` are 1-based and counted in Unicode scalar values (matching
/// PostgreSQL's character-based cursor positions); `offset` is the 0-based
/// scalar-value index into the statement text.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SqlErrorPosition {
pub line: u32,
pub column: u32,
pub offset: u32,
}
impl SqlErrorPosition {
/// Convert a PostgreSQL 1-based character cursor into a line/column/offset.
///
/// Returns `None` when the cursor is unusable (zero, non-positive, or the
/// statement is empty). A cursor past the end (`position == len + 1`, e.g.
/// "unexpected end of input") is clamped to the last character.
pub fn from_pg_cursor(statement_sql: &str, cursor: u32) -> Option<Self> {
if cursor == 0 {
return None;
}
let total = statement_sql.chars().count() as u32;
if total == 0 {
return None;
}
let target = (cursor - 1).min(total - 1);
let mut line = 1u32;
let mut column = 1u32;
for (index, ch) in statement_sql.chars().enumerate() {
if index as u32 == target {
return Some(Self { line, column, offset: target });
}
if ch == '\n' {
line += 1;
column = 1;
} else {
column += 1;
}
}
None
}
}
/// Encode a raw cursor as the transport suffix.
pub fn encode_marker(cursor: u32) -> String {
format!("{SQL_ERROR_POSITION_MARKER}{cursor}")
}
/// Remove every marker from `message` and return the cursors they carried.
///
/// Markers are removed even when followed by more text (the read-only
/// transaction path can append a cleanup error after one, e.g.
/// `...<marker>15; rollback failed`), so the trailing text is preserved. A
/// malformed or absent suffix leaves `message` untouched and is not collected.
fn take_all_markers(message: &mut String) -> Vec<u32> {
let mut cursors = Vec::new();
while let Some(cursor) = take_marker(message) {
cursors.push(cursor);
}
cursors
}
/// Remove a marker from `message` and return the raw cursor it carried.
///
/// See [`take_all_markers`] for the multi-marker behaviour.
pub fn take_marker(message: &mut String) -> Option<u32> {
let index = message.rfind(SQL_ERROR_POSITION_MARKER)?;
let digits_start = index + SQL_ERROR_POSITION_MARKER.len();
let digits_end = message[digits_start..]
.find(|ch: char| !ch.is_ascii_digit())
.map_or(message.len(), |offset| digits_start + offset);
let cursor = message[digits_start..digits_end].parse::<u32>().ok()?;
message.replace_range(index..digits_end, "");
Some(cursor)
}
/// Return `message` with every transport suffix stripped.
pub fn strip_marker(message: &str) -> String {
let mut owned = message.to_string();
let _ = take_all_markers(&mut owned);
owned
}
/// Strip all transport suffixes from `message` and, when possible, resolve the
/// carried cursor against the executed statement text into a typed position.
///
/// The returned message is always marker-free. The position is only resolved
/// when exactly one marker was present: a message carrying several markers (e.g.
/// a user-statement error merged with an infrastructure error) cannot be
/// attributed to a single statement, and guessing would point at the wrong spot.
pub fn take_message_position(message: &str, statement_sql: &str) -> (String, Option<SqlErrorPosition>) {
let mut cleaned = message.to_string();
let cursors = take_all_markers(&mut cleaned);
if cursors.len() != 1 {
return (cleaned, None);
}
let position = SqlErrorPosition::from_pg_cursor(statement_sql, cursors[0]);
(cleaned, position)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cursor_maps_to_line_and_column() {
let sql = "SELECT *\nFROM no_such_table";
// 1-based cursor of the 'n' in no_such_table (second line, column 6).
let cursor = sql.find("no_such_table").unwrap() as u32 + 1;
let position = SqlErrorPosition::from_pg_cursor(sql, cursor).unwrap();
assert_eq!(position.line, 2);
assert_eq!(position.column, 6);
assert_eq!(position.offset, sql[..sql.find("no_such_table").unwrap()].chars().count() as u32);
}
#[test]
fn first_character_is_line_one_column_one() {
let position = SqlErrorPosition::from_pg_cursor("SELECT 1", 1).unwrap();
assert_eq!((position.line, position.column, position.offset), (1, 1, 0));
}
#[test]
fn crlf_treats_carriage_return_as_a_column() {
let sql = "SELECT 1\r\nFROM t";
let cursor = sql.find("FROM").unwrap() as u32 + 1;
let position = SqlErrorPosition::from_pg_cursor(sql, cursor).unwrap();
assert_eq!(position.line, 2);
assert_eq!(position.column, 1);
}
#[test]
fn multi_byte_characters_count_as_one_column() {
// '表' is 3 UTF-8 bytes; cursor positions are character based.
let sql = "SELECT '表' FROM t";
let from_byte = sql.find("FROM").unwrap();
let cursor = sql[..from_byte].chars().count() as u32 + 1;
let position = SqlErrorPosition::from_pg_cursor(sql, cursor).unwrap();
assert_eq!(position.line, 1);
assert_eq!(position.column, 12);
assert_eq!(position.offset, 11);
}
#[test]
fn cursor_past_end_is_clamped() {
let sql = "SELECT";
let position = SqlErrorPosition::from_pg_cursor(sql, 999).unwrap();
assert_eq!(position.offset, 5);
assert_eq!(position.column, 6);
}
#[test]
fn zero_cursor_and_empty_sql_are_rejected() {
assert!(SqlErrorPosition::from_pg_cursor("SELECT 1", 0).is_none());
assert!(SqlErrorPosition::from_pg_cursor("", 1).is_none());
}
#[test]
fn marker_round_trips_and_strips() {
let mut message = format!("ERROR: relation does not exist{}", encode_marker(15));
assert_eq!(take_marker(&mut message), Some(15));
assert_eq!(message, "ERROR: relation does not exist");
let mut without_marker = "ERROR: nope".to_string();
assert_eq!(take_marker(&mut without_marker), None);
assert_eq!(without_marker, "ERROR: nope");
}
#[test]
fn malformed_marker_is_left_untouched() {
let text = format!("{SQL_ERROR_POSITION_MARKER}not-a-number");
let mut message = text.clone();
assert_eq!(take_marker(&mut message), None);
assert_eq!(message, text);
}
#[test]
fn marker_followed_by_text_is_stripped_and_preserves_the_tail() {
let mut message = format!("ERROR: boom{}; rollback failed", encode_marker(7));
assert_eq!(take_marker(&mut message), Some(7));
assert_eq!(message, "ERROR: boom; rollback failed");
}
#[test]
fn take_message_position_strips_marker_and_resolves_position() {
let sql = "SELECT 1\nFROM missing";
let cursor = sql.find("missing").unwrap() as u32 + 1;
let raw = format!("ERROR: relation \"missing\" does not exist{}", encode_marker(cursor));
let (message, position) = take_message_position(&raw, sql);
assert_eq!(message, "ERROR: relation \"missing\" does not exist");
let position = position.unwrap();
assert_eq!(position.line, 2);
assert_eq!(position.column, 6);
}
#[test]
fn take_message_position_still_strips_when_the_cursor_is_unusable() {
// cursor 0 cannot be resolved, but the marker must not leak.
let raw = format!("ERROR: boom{}", encode_marker(0));
let (message, position) = take_message_position(&raw, "SELECT 1");
assert_eq!(message, "ERROR: boom");
assert!(position.is_none());
// Empty statement: nothing to map onto.
let raw = format!("ERROR: boom{}", encode_marker(5));
let (message, position) = take_message_position(&raw, "");
assert_eq!(message, "ERROR: boom");
assert!(position.is_none());
}
#[test]
fn take_message_position_without_marker_is_identity() {
let (message, position) = take_message_position("ERROR: x", "SELECT 1");
assert_eq!(message, "ERROR: x");
assert!(position.is_none());
}
#[test]
fn multiple_markers_are_all_stripped_and_never_resolved() {
// A user-statement error merged with an infrastructure error carries two
// cursors; the position cannot be attributed to a single statement.
let raw =
format!("ERROR: relation missing does not exist{}; cleanup failed{}", encode_marker(15), encode_marker(3));
let (message, position) = take_message_position(&raw, "SELECT * FROM missing");
assert_eq!(message, "ERROR: relation missing does not exist; cleanup failed");
assert!(position.is_none());
assert_eq!(strip_marker(&raw), "ERROR: relation missing does not exist; cleanup failed");
}
#[test]
fn strip_marker_without_marker_is_identity() {
assert_eq!(strip_marker("ERROR: x"), "ERROR: x");
}
}
@@ -0,0 +1,233 @@
#![recursion_limit = "256"]
//! Live PostgreSQL verification of the SQL error-position plumbing.
//!
//! Requires a connectable PostgreSQL-family server pointed at by
//! `DBX_LIVE_POSTGRES_*` (same variables as the other `live_postgres_*` tests).
//! `DBX_LIVE_PG_FAMILY=opengauss` switches the full-funnel test to an openGauss
//! connection config, to prove the position is not gated on `DatabaseType::Postgres`.
//!
//! ```text
//! DBX_LIVE_POSTGRES_HOST=... DBX_LIVE_POSTGRES_PORT=... \
//! DBX_LIVE_POSTGRES_USER=... DBX_LIVE_POSTGRES_PASSWORD=... \
//! DBX_LIVE_POSTGRES_DATABASE=... \
//! cargo test -p dbx-core --no-default-features --test live_postgres_error_position -- --ignored
//! ```
use std::time::Duration;
use dbx_core::connection::AppState;
use dbx_core::db::postgres;
use dbx_core::models::connection::{ConnectionConfig, DatabaseType};
use dbx_core::query::{execute_multi_core_with_options_for_client_and_progress_typed, QueryExecutionOptions};
use dbx_core::sql_error_position::{encode_marker, take_message_position, SqlErrorPosition, SQL_ERROR_POSITION_MARKER};
use dbx_core::storage::Storage;
fn live_env() -> (String, u16, String, String, String) {
let host = std::env::var("DBX_LIVE_POSTGRES_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port = std::env::var("DBX_LIVE_POSTGRES_PORT").ok().and_then(|value| value.parse().ok()).unwrap_or(5432);
let user = std::env::var("DBX_LIVE_POSTGRES_USER").unwrap_or_else(|_| "postgres".to_string());
let password = std::env::var("DBX_LIVE_POSTGRES_PASSWORD").unwrap_or_default();
let database = std::env::var("DBX_LIVE_POSTGRES_DATABASE").unwrap_or_else(|_| "postgres".to_string());
(host, port, user, password, database)
}
fn postgres_url() -> String {
let (host, port, user, password, database) = live_env();
format!("postgres://{}:{}@{host}:{port}/{database}", encode_userinfo(&user), encode_userinfo(&password))
}
/// Percent-encode the URL userinfo so secrets containing `@`/`:` survive parsing.
fn encode_userinfo(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for character in value.chars() {
match character {
'%' => encoded.push_str("%25"),
'@' => encoded.push_str("%40"),
':' => encoded.push_str("%3A"),
'/' => encoded.push_str("%2F"),
other => encoded.push(other),
}
}
encoded
}
fn live_db_type() -> DatabaseType {
if std::env::var("DBX_LIVE_PG_FAMILY").is_ok_and(|value| value.eq_ignore_ascii_case("opengauss")) {
DatabaseType::OpenGauss
} else {
DatabaseType::Postgres
}
}
fn expected_position(sql: &str, offending: &str) -> SqlErrorPosition {
let byte_index = sql.find(offending).unwrap();
let offset = sql[..byte_index].chars().count() as u32;
let line = sql[..byte_index].matches('\n').count() as u32 + 1;
let line_start = sql[..byte_index].rfind('\n').map_or(0, |index| index + 1);
let column = sql[line_start..byte_index].chars().count() as u32 + 1;
SqlErrorPosition { line, column, offset }
}
#[tokio::test]
#[ignore = "requires DBX_LIVE_POSTGRES_* pointing at a PostgreSQL-family database"]
async fn live_pg_error_positions_resolve_against_the_executed_statement() {
let pool = postgres::connect(&postgres_url(), Duration::from_secs(10)).await.expect("connect");
// (sql, offending token). The first case is the exact shape DBX sends for the
// user's `select * from no_such_table` after pagination appends LIMIT.
let cases = [
("select * from no_such_table LIMIT 100", "no_such_table"),
("SELECT *\nFROM no_such_table", "no_such_table"),
("SELECT no_such_col FROM (SELECT 1 AS x) AS t", "no_such_col"),
("INSERT INTO no_such_table VALUES (1)", "no_such_table"),
];
for (sql, offending) in cases {
let message = postgres::execute_query(&pool, sql).await.expect_err("statement must fail");
assert!(message.contains(SQL_ERROR_POSITION_MARKER), "driver error must carry the marker: {message}");
let (cleaned, position) = take_message_position(&message, sql);
assert!(!cleaned.contains(SQL_ERROR_POSITION_MARKER), "marker must be stripped: {cleaned}");
assert_eq!(position, Some(expected_position(sql, offending)), "position for `{sql}`");
}
let syntax_sql = "SELECT * FROMM no_such_table";
let message = postgres::execute_query(&pool, syntax_sql).await.expect_err("syntax error");
let (_, position) = take_message_position(&message, syntax_sql);
assert_eq!(position, Some(expected_position(syntax_sql, "FROMM")), "syntax error should point at FROMM");
pool.close();
}
/// Full backend funnel: AppState + connection config → `execute_multi_core` →
/// `BackendError`. This is where the old `pool_db_type == Postgres` gate lived, so
/// running it with `DBX_LIVE_PG_FAMILY=opengauss` guards the regression.
#[tokio::test]
#[ignore = "requires DBX_LIVE_POSTGRES_* pointing at a PostgreSQL-family database"]
async fn live_pg_multi_core_reports_error_position_in_the_backend_envelope() {
let (host, port, user, password, database) = live_env();
let db_type = live_db_type();
let dir = std::env::temp_dir().join(format!("dbx-live-pg-position-{}", uuid::Uuid::new_v4().simple()));
std::fs::create_dir_all(&dir).unwrap();
let storage = Storage::open(&dir.join("storage.db")).await.unwrap();
let state = AppState::new(storage);
let connection_id = "live-pg-position";
state.configs.write().await.insert(
connection_id.to_string(),
live_connection_config(connection_id, db_type, &host, port, &user, &password, &database),
);
let sql = "select * from no_such_table LIMIT 100";
let result = execute_multi_core_with_options_for_client_and_progress_typed(
&state,
connection_id,
&database,
sql,
None,
None,
QueryExecutionOptions { timeout_secs: Some(30), ..Default::default() },
None,
)
.await;
let backend_error = result.expect_err("single-statement failure must surface as an error").into_backend_error();
assert_eq!(backend_error.code(), "DBX-JDBC-4001");
assert_eq!(
backend_error.error_position(),
Some(expected_position(sql, "no_such_table")),
"db_type={db_type:?} detail={:?}",
backend_error.detail()
);
let detail = backend_error.detail().unwrap_or_default();
assert!(!detail.contains(SQL_ERROR_POSITION_MARKER), "detail must not leak the marker: {detail}");
assert!(detail.contains("no_such_table"), "detail should keep the native message: {detail}");
let _ = std::fs::remove_dir_all(&dir);
}
/// A second case proving the multi-marker guard: a marker-containing error that
/// gets another marker merged in must not resolve to a wrong position.
#[test]
fn marker_guard_rejects_ambiguous_merged_errors() {
let sql = "SELECT 1";
let raw = format!("ERROR: boom{}{}", encode_marker(1), encode_marker(50));
let (cleaned, position) = take_message_position(&raw, sql);
assert!(position.is_none());
assert_eq!(cleaned, "ERROR: boom");
}
fn live_connection_config(
id: &str,
db_type: DatabaseType,
host: &str,
port: u16,
user: &str,
password: &str,
database: &str,
) -> ConnectionConfig {
ConnectionConfig {
docs_notes_path: None,
id: id.to_string(),
name: id.to_string(),
note: String::new(),
db_type,
driver_profile: None,
driver_label: None,
url_params: None,
agent_java_options: Vec::new(),
host: host.to_string(),
port,
username: user.to_string(),
password: password.to_string(),
database: Some(database.to_string()),
default_schema: None,
visible_databases: None,
visible_database_patterns: None,
visible_schemas: None,
attached_databases: Vec::new(),
init_script: None,
color: None,
transport_layers: Vec::new(),
connect_timeout_secs: 10,
query_timeout_secs: 30,
idle_timeout_secs: 60,
keepalive_interval_secs: 0,
ssl: false,
ca_cert_path: String::new(),
client_cert_path: String::new(),
client_key_path: String::new(),
sysdba: false,
oracle_connection_type: None,
connection_string: None,
redis_connection_mode: None,
redis_sentinel_master: String::new(),
redis_sentinel_nodes: String::new(),
redis_sentinel_username: String::new(),
redis_sentinel_password: String::new(),
redis_sentinel_tls: false,
redis_cluster_nodes: String::new(),
redis_key_separator: dbx_core::models::connection::default_redis_key_separator(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
redis_key_grouping: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
external_config: None,
plugin_id: None,
plugin_connection_provider: None,
plugin_connection_type: None,
connection_secrets: Default::default(),
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
save_password: true,
read_only: false,
is_production: false,
production_databases: vec![],
show_system_schemas: false,
database_info: None,
}
}
+3 -1
View File
@@ -44,6 +44,7 @@ Rust `BackendError` 的字段由 catalog 构造,字段定义如下(JSON 使
"origin": { "subsystem": "database", "adapter": "native" },
"operationOutcome": "unknown",
"detail": "relation missing_table does not exist",
"errorPosition": { "line": 1, "column": 15, "offset": 14 },
"diagnostics": {
"category": "sql",
"stage": "execute",
@@ -61,6 +62,7 @@ Rust `BackendError` 的字段由 catalog 构造,字段定义如下(JSON 使
- `source` 是 v1 兼容字段,表示旧的错误来源;新代码使用 `origin.subsystem` 和 `origin.adapter` 描述数据库、隧道、插件、AI、消息队列等子系统。客户端不能因为未知的 source/origin 值而丢弃整个 envelope。
- `origin` 是可扩展元数据,至少包含 `subsystem` 和 `adapter`,可选 `driver`;它不参与错误分类、恢复或重试决策。
- `diagnostics.adapterCode` 是适配器协议提供的可选错误码(例如 DuckDB worker 的 `duckdb_execute_failed`),仅用于诊断展示,不替代稳定的 DBX `code`。
- `errorPosition` 是适配器提供的可选出错位置,只有能给出真实游标的驱动才会填充(目前为原生 PostgreSQL)。`line`/`column` 为 1-based、按 Unicode 码点计数,`offset` 为语句内 0-based 码点下标,均相对**实际下发的语句文本**;它不参与错误分类、重试或恢复。客户端只应在能证明该语句仍映射回当前编辑器内容时用于定位,否则忽略该字段。
- `operationOutcome` 只能是 `not_started` 或 `unknown`。结果未知时不能自动重放用户操作。
- `messageParams` 只能包含 catalog 声明的 string、number、boolean 标量,不得携带 SQL、URL、凭据或任意对象。
- Rust 字段保持私有,新增错误必须通过 catalog 构造,避免 code、key 和参数声明漂移。
@@ -99,7 +101,7 @@ Rust `BackendError` 的字段由 catalog 构造,字段定义如下(JSON 使
- DBX 不解析、抽取或改写 SQL payload,也不会主动把执行 SQL 追加到错误;因此 SQL 方言、嵌套括号、引号和业务字面量不会被错误的通用字符串规则破坏。需要内部诊断时应单独记录原始请求,不得把内部日志对象直接复用为公共 envelope。
- `AgentErrorContext` 中的 `agentSessionId`、重试标记和内部恢复字段不会作为结构化字段进入公共 envelope;`connection` 等非 SQL 类别的驱动错误正文如果包含 Session 或凭据文本,公共 detail 仍会脱敏。
- Rust 查询执行器生成的查询超时会使用 `DBX-JDBC-2002`(阶段 `execute`)摘要,同时保留超时诊断 detail;它不会作为 `DBX-LEGACY-0001` 展示。
- PostgreSQL native driver 返回的标准服务端 `ERROR:` 诊断会使用 `DBX-JDBC-4001`(阶段 `execute`)摘要并保留原始 detail;连接、超时、取消和清理错误不使用该分类。
- PostgreSQL native driver 返回的标准服务端 `ERROR:` 诊断会使用 `DBX-JDBC-4001`(阶段 `execute`)摘要并保留原始 detail;当驱动给出游标位置时,会同时填充结构化的 `errorPosition`(用于前端“点击定位到出错行列”)。位置以可解析后缀在 `db` 层内部传递,并在 `query.rs` 还原为类型化字段;该后缀不会出现在 `detail` 或任何用户可见文本中。连接、超时、取消和清理错误不使用该分类。
- DuckDB worker 返回的 `Parser Error`、`Catalog Error` 等厂商正文会保留在 `detail`;worker code 会放入 `diagnostics.adapterCode`,并在前端详情前显示。
- 超时和取消没有服务端 detail 时只返回摘要;`without_detail()` 只有在调用方明确要求隐藏 detail 时才会移除原文。
+596
View File
@@ -0,0 +1,596 @@
# PostgreSQL 执行错误「点击定位到行列」开发方案
> 目标:在 PG 连接下执行 SQL 失败后,错误信息区域出现「定位错误」入口,点击后把编辑器光标定位到出错的行/列(并高亮该位置)。
> 本文先给出代码现状分析,再给出可执行的改造方案与分阶段任务。
---
## 1. 现状分析
### 1.1 错误从 PG 到界面的完整链路
```
tokio_postgres::Error
└─ DbError.position() = ErrorPosition::Original(cursorpos) ← 位置信息在这里,当前被丢弃
│
│ crates/dbx-core/src/db/postgres.rs::pg_error_to_string()
│ (只取 err.as_db_error().map(ToString::to_string)) ← 只保留文本,未保留 cursorpos
▼
Result<db::QueryResult, String> ← db 层统一用 String 承载错误
│
│ crates/dbx-core/src/query.rs::do_execute_typed() 尾部 .map_err(...)
▼
QueryExecutionError::{Legacy|Sql}(String) ← 仍是纯文本
│
│ QueryExecutionError::into_backend_error()
▼
BackendError { code, messageKey, detail, diagnostics, ... } ← 结构化,但无位置
│
├─ 单语句/事务:tauri command 返回 Err(BackendError)
└─ 多语句:ExecuteMultiResult { execution_error: true, error: BackendError, statement_index }
▼
前端 queryStore:
- 多语句:annotateQueryResultSources() 给每个 result 写 sourceStatement / sourceFrom / sourceTo / statement_index
- 单语句异常:toErrorResult() 合成 { columns:["Error"], execution_error:true, error:BackendError }
▼
DataGrid ErrorBanner(apps/desktop/src/components/grid/DataGrid.vue:11835)
└─ #error-actions slot → ContentArea.vue:2032 → QueryErrorActions.vue
```
### 1.2 关键结论
1. **位置信息在驱动层就已存在**:`tokio_postgres::error::DbError::position()` 返回 `ErrorPosition::Original(u32)`,语义是 **1-based 的字符下标**(不是字节,PG 文档明确规定)。
2. **当前被丢弃**:`postgres.rs:1482` 的 `pg_error_to_string` 只用 `Display`,而 `DbError` 的 `Display` 实现**不包含** position 字段(见 `tokio-postgres/src/error/mod.rs` 的 `impl fmt::Display for DbError`)。所以前端完全拿不到。
3. **中间层是 String ABI**:`db::postgres` 到 `query.rs` 之间所有用户查询函数都返回 `Result<_, String>`(`execute_query_with_max_rows_and_cancel` / `execute_postgres_user_query(_with_mode)` / `execute_query_with_max_rows_inner` 等),错误在多个 `?`/`map_err(pg_error_to_string)` 中被反复包装、拼接(如 `merge_postgres_operation_and_rollback_result`)。直接引入类型化错误会牵动整个 `postgres.rs`。
4. **前端已经具备映射到编辑器位置的能力**:`apps/desktop/src/lib/tabs/tabPresentation.ts:244` 的 `resultSourceRange(editorSql, result, resultIndex, dbType, params)` 能把「某个 result 对应的语句」映射回当前编辑器文档的 `{from,to,sql}`,并且带一致性校验(`editorSql.slice(from,to) === sourceStatement`),stale 时返回 `undefined`。这就是把「语句内行列」换算成「编辑器绝对 offset」的现成入口。
5. **编辑器已有定位能力**:`QueryEditor.vue` 通过 `defineExpose` 暴露 `focusStatementRange({from,to})`(1105 行)与 `previewStatementRange`,内部用 CodeMirror 的 `EditorView.scrollIntoView` + selection 实现。新增一个「定位到点」的方法即可复用同一套机制。
6. **UI 入口已经存在**:错误横幅的 action 插槽(`ContentArea.vue:2032` / `:2390`)已经挂了 `QueryErrorActions`;执行摘要(`executionSummaryItems` + `ContentArea.vue:1869`)也已经有「单击预览语句 / 双击聚焦语句」的交互。这两个地方都是本次新增「定位错误」的天然落点。
7. **后端契约允许加可选字段**:`docs/backend-error-handling.md` 明确「新增可选字段可以保持 v1」,并要求错误对象由 catalog 构造、前后端不因未知可选字段丢弃 envelope。因此给 `BackendError` 增加可选 `errorPosition` 是向后兼容的。
8. **契约同时要求**:「不得先降级为字符串再重建 envelope」——这条针对的是**错误分类**。位置是驱动事实、不是分类依据,但方案会尽量把它做成类型化字段,字符串只作为跨 `String` ABI 的临时载体,并在第一层知道 SQL 文本的地方立刻还原为类型化字段。
### 1.3 需要动到的文件清单
| 层 | 文件 | 作用 |
| --- | --- | --- |
| 驱动 | `crates/dbx-core/src/db/postgres.rs` | 从 `DbError` 提取 cursorpos |
| 新模块 | `crates/dbx-core/src/sql_error_position.rs`(新增) | 位置类型、行列换算、marker 编解码 |
| 查询层 | `crates/dbx-core/src/query.rs` | 还原位置、挂到 `QueryExecutionError` |
| 契约 | `crates/dbx-core/src/backend_error.rs` | `BackendError.errorPosition` 字段与构造器 |
| 契约文档 | `docs/backend-error-handling.md` | 记录新可选字段 |
| 前端类型 | `apps/desktop/src/lib/backend/errorUtils.ts` | TS `BackendError.errorPosition` + 校验 |
| 前端映射 | `apps/desktop/src/lib/sql/errorPosition.ts`(新增) | 语句内行列 → 编辑器 offset |
| 前端状态 | `apps/desktop/src/stores/queryStore.ts` | 错误结果附带位置(必要时) |
| 前端 UI | `apps/desktop/src/components/common/QueryErrorActions.vue`、`ContentArea.vue`、`editor/QueryEditor.vue`、`grid/DataGrid.vue`(如做内联标记) | 按钮 + 定位 + 行列展示 |
| i18n | `apps/desktop/src/i18n/locales/*.ts` | 新增文案 |
| 测试 | 各处 `#[cfg(test)]` / `apps/desktop/src/**/__tests__` | 单测覆盖 |
---
## 2. 方案总览
```
PG server cursorpos (字符下标)
│ ① 提取
▼
pg_error_to_string() 追加临时 marker: "\nDBX_SQL_ERROR_POSITION:<cursor>"
│ ② 沿既有 String ABI 冒泡(不改各函数签名)
▼
query.rs::do_execute_typed() 尾部 .map_err:
PG 分支解析 marker + 用已执行 SQL 文本换算 line/column/offset
→ QueryExecutionError::SqlWithPosition { message, position }
│ ③ 类型化
▼
QueryExecutionError::into_backend_error()
→ BackendError { ..., errorPosition: { line, column, offset } }
│ ④ 随 envelope 到前端(单语句 Err / 多语句 result.error 两条路都覆盖)
▼
前端 QueryErrorActions / ExecutionSummary 显示「行 L 列 C」+「定位」按钮
│ ⑤ 点击
▼
sqlErrorEditorOffset(): resultSourceRange() 得到语句范围 → 语句内行列换算为编辑器 UTF-16 offset
▼
QueryEditor.focusErrorPosition(offset):selection + scrollIntoView + 高亮
```
设计取舍:
- **位置换算放在后端**:后端在 `do_execute_typed` 处同时掌握「原始 SQL 文本」和「cursorpos」,直接算出行列,前端只做「语句范围 → 文档 offset」的平移,避免前端再次处理字符/字节/码点差异。
- **跨层用 marker**:`db::postgres` 全链路是 `Result<_, String>`,引入类型化错误代价大且风险高。用与仓库既有 `DBX_AGENT_ERROR_DATA`(`agent_driver.rs:385`)同款「结构化后缀 marker」把类型化事实无损穿过 String 边界,并在第一层知道 SQL 的 `query.rs` 立即还原并剥离。marker 是**位置载体**而非分类依据,符合契约边界。
- **两级安全网**:`BackendError` 的 `detail` 清洗(`bounded_detail` / `bounded_native_detail`)里也剥离 marker,保证元数据/连接等旁路错误即使没走到 PG 分支也不会把 marker 泄漏到界面。
> 备选方案(更「类型化」但改动大):把 `db::postgres` 用户查询链路(`execute_query_with_max_rows_and_cancel` → `execute_postgres_user_query(_with_mode)` → `execute_query_with_max_rows_inner` 及其等待包装)的返回错误从 `String` 改为 `PostgresQueryError { message, position }`,并给 `String` 提供 `From` 以便内部 `?` 继续工作。优点是彻底无 marker;缺点是触及 `postgres.rs` 多处签名与等待辅助函数。若团队不接受 marker,可切换到该方案,`query.rs` 以上的设计不变。
---
## 3. 后端改造
### 3.1 新模块 `crates/dbx-core/src/sql_error_position.rs`
```rust
use serde::{Deserialize, Serialize};
/// 跨 String 边界携带 PG cursorpos 的临时后缀。
pub const SQL_ERROR_POSITION_MARKER: &str = "\nDBX_SQL_ERROR_POSITION:";
/// 相对「实际下发的那条语句文本」的出错位置。
/// line/column 为 1-based、按 Unicode 码点计数;offset 为 0-based 码点下标。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SqlErrorPosition {
pub line: u32,
pub column: u32,
pub offset: u32,
}
impl SqlErrorPosition {
/// `cursor` 为 PostgreSQL 的 1-based 字符下标。
pub fn from_pg_cursor(sql: &str, cursor: u32) -> Option<Self> {
if cursor == 0 {
return None;
}
let total = sql.chars().count() as u32;
// 越界时钳到末尾("unexpected end of input" 这类错误常见 position == len+1)
let target = (cursor - 1).min(total.saturating_sub(1));
if total == 0 {
return None;
}
let mut line = 1u32;
let mut column = 1u32;
for (index, ch) in sql.chars().enumerate() {
if index as u32 == target {
return Some(Self { line, column, offset: target });
}
if ch == '\n' {
line += 1;
column = 1;
} else {
column += 1;
}
}
None
}
}
pub fn encode_marker(cursor: u32) -> String {
format!("{SQL_ERROR_POSITION_MARKER}{cursor}")
}
/// 从字符串尾部取出 marker 并剥离。返回原始 cursor。
pub fn take_marker(message: &mut String) -> Option<u32> {
let index = message.rfind(SQL_ERROR_POSITION_MARKER)?;
let cursor = message[index + SQL_ERROR_POSITION_MARKER.len()..].trim().parse::<u32>().ok()?;
message.truncate(index);
Some(cursor)
}
pub fn strip_marker(message: &str) -> String {
let mut owned = message.to_string();
let _ = take_marker(&mut owned);
owned
}
/// 把一条错误文本(可能带 marker)解析为「清理后的文本 + 已换算位置」。
pub fn resolve_message(message: &str, executed_sql: &str) -> Option<(String, SqlErrorPosition)> {
let mut owned = message.to_string();
let cursor = take_marker(&mut owned)?;
let position = SqlErrorPosition::from_pg_cursor(executed_sql, cursor)?;
Some((owned, position))
}
```
> 在 `crates/dbx-core/src/lib.rs` 注册 `pub mod sql_error_position;`(仓库若已有模块清单,按现有顺序追加)。
### 3.2 `db/postgres.rs`:提取 cursorpos
改 `pg_error_to_string`(1482 行)——这是把 tokio 错误变文本的唯一汇聚点:
```rust
fn pg_error_to_string(err: tokio_postgres::Error) -> String {
let Some(db_error) = err.as_db_error() else {
return err.to_string();
};
let mut message = db_error.to_string();
if let Some(tokio_postgres::error::ErrorPosition::Original(cursor)) = db_error.position() {
message.push_str(&crate::sql_error_position::encode_marker(*cursor));
}
message
}
```
注意:
- `ErrorPosition::Internal { .. }` 表示位置在服务端内部生成的语句里,**不映射**,保持无位置。
- `pg_db_error_to_string`(1542 行,给 `pg_error_from_sources`/池错误用)**不加 marker**,因为那条路径没有用户 SQL 上下文。
- marker 只对 PG 生效,其它驱动零影响。
### 3.3 `query.rs`:还原为类型化位置
1) 扩展 `QueryExecutionError`(`query.rs:69`)新增变体:
```rust
pub enum QueryExecutionError {
Agent(AgentCallError),
DuckDb { code: String, message: String },
Canceled { stage: AgentErrorStage, operation_outcome: AgentOperationOutcome },
Timeout(String),
Sql(String),
/// PG 原生驱动:携带已换算好的出错行列。
SqlWithPosition { message: String, position: crate::sql_error_position::SqlErrorPosition },
Legacy(String),
}
```
2) 补齐所有 match 分支(按现有 `Self::Sql(..)` 处理方式):
- `into_legacy_string()`(85 行附近):`Self::SqlWithPosition { message, .. } => message`
- `into_backend_error()`(90 行附近):
```rust
Self::SqlWithPosition { message, position } =>
crate::backend_error::BackendError::from_sql_detail_with_position(&message, position),
```
- `with_omitted_sql_context()`:保留 position,仅对 message 追加 `SQL_OMITTED_ERROR_CONTEXT`
- `with_context()`:同上,仅改 message
- `as_agent_error()`:归入 `None`
- `Display`:输出 message
- `query_execution_error_action()` 的 `Sql(message) | Legacy(message)` 分支(1497 行)加 `SqlWithPosition { message, .. }`
- `classify_query_error()`(2358 行)保持 `other => other` 即可透传
3) 在 `do_execute_typed` 的最终 `.map_err`(2350~2357 行)解析 marker:
```rust
.map_err(|error| {
#[cfg(feature = "duckdb-sidecar")]
if let Some(duckdb_error) = typed_duckdb_error {
return QueryExecutionError::DuckDb { code: duckdb_error.code, message: duckdb_error.message };
}
if let Some(agent_error) = typed_agent_error {
return QueryExecutionError::Agent(agent_error);
}
if pool_db_type == Some(DatabaseType::Postgres) {
if let Some((message, position)) = crate::sql_error_position::resolve_message(&error, sql) {
return QueryExecutionError::SqlWithPosition { message, position };
}
}
QueryExecutionError::Legacy(error)
})
```
此处 `sql: &str` 就是该语句实际下发的文本,`pool_db_type` 已在前文计算。**注意**:`execute_sql_statement_with_options_typed(_inner)` 里 `with_omitted_sql_context` 之后还会走 reconnect 重试等分支,位置只在真正执行、且 SQL 未再改写的那次错误上正确;重试后再次执行会重新产生新的 marker,逻辑自洽。
4) `error_query_result(error.clone().into_legacy_string())`(`query.rs:3345` / `3833`)自动拿到清理后的文本,无需改动。
### 3.4 `backend_error.rs`:新增可选字段
```rust
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BackendError {
// ...既有字段...
#[serde(skip_serializing_if = "Option::is_none")]
error_position: Option<crate::sql_error_position::SqlErrorPosition>,
}
impl BackendError {
/// 在既有 SQL 失败 envelope 上附加驱动给出的出错位置(保持 v1)。
pub fn from_sql_detail_with_position(
message: &str,
position: crate::sql_error_position::SqlErrorPosition,
) -> Self {
let mut error = Self::from_sql_detail(message);
error.error_position = Some(position);
error
}
pub fn error_position(&self) -> Option<crate::sql_error_position::SqlErrorPosition> {
self.error_position
}
fn new(/* 既有参数 */) -> Self {
Self { /* ... */ error_position: None }
}
}
```
安全网(防止 marker 从旁路泄漏到 detail):
```rust
fn bounded_detail(message: &str) -> Option<String> {
let message = crate::sql_error_position::strip_marker(message);
// ...对 message 走原有逻辑
}
fn bounded_native_detail(message: &str) -> Option<String> {
let message = crate::sql_error_position::strip_marker(message);
// ...原有逻辑
}
```
(注意现有函数签名与所有权,改成接收 `&str` 后用局部 `String` 即可。)
### 3.5 契约文档
在 `docs/backend-error-handling.md` 的「公共错误对象」示例与「协议演进」段落补充:新增可选字段 `errorPosition: { line, column, offset }`,仅 PG 原生驱动 SQL 错误存在,为 v1 向后兼容可选字段;`offset` 为语句内 0-based 码点下标,`line/column` 为 1-based 码点计数。
---
## 4. 前端改造
### 4.1 类型与校验(`lib/backend/errorUtils.ts`)
```ts
export interface SqlErrorPosition {
line: number;
column: number;
offset: number;
}
export interface BackendError {
// ...既有字段...
/** PostgreSQL 原生驱动给出的出错位置(相对该语句文本)。 */
errorPosition?: SqlErrorPosition;
}
```
在 `isBackendError` 中对 `errorPosition` 做形状校验(三个字段均为有限非负整数,`line/column >= 1`),不合法时忽略整个字段但不丢弃 envelope(与既有「未知可选字段」策略一致)。
### 4.2 行列 → 编辑器 offset(新增 `lib/sql/errorPosition.ts`)
```ts
import { resultSourceRange } from "@/lib/tabs/tabPresentation";
import type { QueryResult, DatabaseType } from "@/types/database";
import type { SqlParameterOptions } from "@/lib/sql/sqlParameters";
export interface EditorErrorPosition {
offset: number;
line: number;
column: number;
}
/**
* 把 result.error.errorPosition(语句内行列)换算为当前编辑器文档的 UTF-16 offset。
* 只有在 resultSourceRange 能证明该 result 仍对应编辑器里同一段语句时才返回。
*/
export function sqlErrorEditorOffset(options: {
editorSql: string;
result: QueryResult | undefined;
resultIndex?: number;
databaseType?: DatabaseType;
parameterOptions?: SqlParameterOptions;
}): EditorErrorPosition | undefined {
const position = options.result?.error?.errorPosition;
if (!position) return undefined;
const range = resultSourceRange(
options.editorSql,
options.result,
options.resultIndex,
options.databaseType,
options.parameterOptions,
);
if (!range) return undefined;
// 目标:range.sql 内第 position.line 行、第 position.column 列(均 1-based,按码点)
const chars = Array.from(range.sql);
let line = 1;
let lineStart = 0;
let index = 0;
while (index < chars.length && line < position.line) {
if (chars[index] === "\n") {
line += 1;
lineStart = index + 1;
}
index += 1;
}
if (line !== position.line) return undefined; // 行号超出语句
// 该行内前进 column-1 个码点,遇到行尾或换行则钳制
let columnOffset = 0;
while (columnOffset < position.column - 1 && lineStart + columnOffset < chars.length) {
if (chars[lineStart + columnOffset] === "\n") break;
columnOffset += 1;
}
const charIndex = lineStart + columnOffset;
// 码点下标 → range.sql 内 UTF-16 下标
const utf16Offset = chars.slice(0, charIndex).join("").length;
return { offset: range.from + utf16Offset, line: position.line, column: position.column };
}
```
> 优先用 `line/column` 而非 `offset`,因为二者都是「码点计数」,语义一致;`offset` 作为兜底/测试信号。
### 4.3 编辑器定位能力(`components/editor/QueryEditor.vue`)
在 `focusStatementRange`(1105 行)旁新增并加入 `defineExpose`(7542 行):
```ts
function focusErrorPosition(offset: number) {
const currentView = view.value;
if (!currentView || !editorViewModule) return;
const pos = Math.max(0, Math.min(offset, currentView.state.doc.length));
currentView.dispatch({
selection: { anchor: pos },
effects: [editorViewModule.EditorView.scrollIntoView(pos, { y: "center" })],
});
currentView.focus();
}
```
可选增强(二选一或都做):
- 用一次性 decoration 在 `pos` 处画一个短暂的错误下划线/闪烁光标(复用已有 `cm-sql-error` 样式与 `Decoration` 机制,5897 行附近)。
- 在 `pos` 处选中一个「词」(向两侧扫描标识符边界),让出错 token 更醒目。
### 4.4 UI 入口
**A. 错误横幅(主入口,符合「点击错误信息定位」)**
`QueryErrorActions.vue` 增加 props/emit 与按钮:
```ts
const props = defineProps<{ errorMessage: string; backendError?: BackendError; connectionId?: string; errorPosition?: { line: number; column: number } }>();
const emit = defineEmits<{
/* 既有 */
locateError: [];
}>();
const showLocate = computed(() => !!props.errorPosition);
```
```vue
<Button v-if="showLocate" variant="outline" size="sm" class="h-7 gap-1.5 px-2.5 text-xs" @click="emit('locateError')">
<LocateFixed class="h-3.5 w-3.5" />
{{ t("editor.locateError", { line: errorPosition!.line, column: errorPosition!.column }) }}
</Button>
```
`ContentArea.vue` 两处 `#error-actions`(2032 / 2390 行)传入 `:error-position="activeTab.result?.error?.errorPosition"` 并绑定 `@locate-error="locateActiveResultError"`:
```ts
function locateActiveResultError() {
const result = props.activeTab.result;
const mapped = sqlErrorEditorOffset({
editorSql: props.activeTab.sql,
result,
resultIndex: props.activeTab.activeResultIndex,
databaseType: activeEffectiveDatabaseType.value,
parameterOptions: activeSqlStatementParameterOptions.value,
});
if (!mapped) {
toast(t("editor.errorPositionUnavailable"), 3000);
return;
}
if (queryEditorRef.value) queryEditorRef.value.focusErrorPosition(mapped.offset);
else emit("focusErrorPosition", props.activeTab.id, mapped.offset); // 多窗口/分离 tab 兼容
}
```
**B. 执行摘要(多语句场景体验更好)**
- `executionSummaryItems`(`tabPresentation.ts:419`)的 `ExecutionSummaryItem` 增加 `errorPosition?`,多语句分支直接从 `result.error?.errorPosition` 取。
- `ContentArea.vue` 摘要行的错误文本后追加「行 L 列 C」小标签;`focusExecutionSummaryItem` 改为优先使用 `sqlErrorEditorOffset`(有位置就精确定位,否则退回整条语句 `focusStatementRange`)。
**C. DataGrid 内联提示(可选)**
如需「点击错误信息本体」而非按钮:在 `DataGrid.vue:11835` 的 `ErrorBanner` 上对 message 绑定点击,emit 一个新事件透传到 `ContentArea` 的同一处理函数。建议保留按钮作为主要交互,避免与文本选择冲突。
### 4.5 i18n
在 `i18n/locales/en.ts` 与 `zh-CN.ts`(其余 locale 走 fallback)新增:
```ts
editor: {
locateError: "Locate error (line {line}, col {column})", // zh: 定位错误(第 {line} 行,第 {column} 列)
errorPositionUnavailable: "The error position no longer matches the editor content", // zh: 错误位置与当前编辑器内容不一致,无法定位
}
executionSummary: {
lineColumn: "line {line}, col {column}", // zh: 第 {line} 行,第 {column} 列
}
```
---
## 5. 分阶段任务清单
### Phase 1 — 后端位置提取与透传(不影响 UI)
- [ ] 新增 `crates/dbx-core/src/sql_error_position.rs` 并在 `lib.rs` 注册;写 `from_pg_cursor` / marker 的单测(多行、`\r\n`、含 emoji、越界钳制)。
- [ ] `postgres.rs::pg_error_to_string` 追加 marker(仅 `ErrorPosition::Original`)。
- [ ] `backend_error.rs`:加 `error_position` 字段、`from_sql_detail_with_position`、`error_position()`,`new()` 补默认;在 `bounded_detail`/`bounded_native_detail` 内 `strip_marker`。
- [ ] `query.rs`:新增 `QueryExecutionError::SqlWithPosition`,补齐所有 match 分支,在 `do_execute_typed` 尾部 PG 分支解析。
- [ ] Rust 测试:序列化 `errorPosition`(camelCase);`into_backend_error()` 产出正确 position;`into_legacy_string()` 不含 marker;`SqlWithPosition` 不被 `classify_query_error` 降级。
### Phase 2 — 前端类型与映射
- [ ] `errorUtils.ts` 增加 `SqlErrorPosition` 与 `errorPosition?`,扩展 `isBackendError` 校验。
- [ ] 新增 `lib/sql/errorPosition.ts` + vitest:用带 `sourceFrom/sourceTo/sourceStatement` 的假 result 验证多行/非 BMP 字符/行超界/stale 返回 `undefined`。
- [ ] 同步 `lib/backend/http.ts`、`lib/backend/tauri.ts` 的错误规范化路径(复用 `normalizeBackendError` 应无需改,确认即可)。
### Phase 3 — UI 与编辑器定位
- [ ] `QueryErrorActions.vue` 增加 `errorPosition` prop、`locateError` emit 与按钮;`__tests__/QueryErrorActions.spec.ts` 补用例。
- [ ] `QueryEditor.vue` 增加 `focusErrorPosition(offset)` 并 `defineExpose`;`ContentArea.vue` 接线 + toast 兜底。
- [ ] `tabPresentation.ts` 的 `ExecutionSummaryItem` 增加 `errorPosition`;`ExecutionSummary` 行显示行列并支持精确定位。
- [ ] i18n 文案(en / zh-CN)。
- [ ] 手工验证:`SELECT * FROM no_such_table;`(表不存在)、`SELECT FROMM t;`(语法错误)、多语句中第 2 条报错、光标定位与高亮。
### Phase 4 — 文档与收尾
- [ ] 更新 `docs/backend-error-handling.md`。
- [ ] 跑提交前检查(见第 7 节)。
---
## 6. 边界、风险与对策
| 风险 | 说明 | 对策 |
| --- | --- | --- |
| **SQL 被前端改写** | 分页包装、排序、只读事务、schema 限定等会在下发前改写 SQL,PG 的 cursorpos 相对改写后的文本,映射到用户原文会偏移 | 位置只在「result 仍能通过 `resultSourceRange` 证明对应当前编辑器同一段语句」时启用;仍可能因包装漂移时,回退为仅展示行列、禁用定位(Phase 3 的 `undefined` 分支)。后续可在前端记录 `sqlToExecute === queryBaseSql` 标志,仅在该成立时启用 |
| **前后端语句切分差异** | 后端 `split_sql_statements` 与前端 `splitSqlStatementRanges` 对前导注释/空白/分号处理可能不同 | 加对照测试(同一批脚本);前端已有 `submittedStatement.sql === sourceStatement` 校验,边界不符时不定位 |
| **PG cursorpos 语义** | 1-based、按字符(码点)而非字节 | 全部换算按 `chars()`/`Array.from` 码点进行;单测覆盖多字节与 emoji |
| **agent/JDBC PG 无位置** | JDBC 路径错误来自 `AgentCallError`,结构化上下文里没有 position | 明确 Phase 1 只覆盖 tokio-postgres 原生驱动;JDBC 需在 Agent Protocol 扩展字段,另开阶段 |
| **字符串 marker 泄漏到 UI** | 旁路错误(元数据/连接)可能带 marker | `bounded_detail`/`bounded_native_detail` 统一剥离 + `into_legacy_string` 返回已清理文本;补「不含 marker」单测 |
| **契约兼容** | `BackendError` 新增字段 | 可选字段保持 v1;前端 `isBackendError` 对未知/非法可选字段宽容处理 |
| **大小写/换行** | `\r\n` 的行列计算 | `from_pg_cursor` 只以 `\n` 分行,`\r` 计入列;前端按同一规则处理,保持一致 |
---
## 7. 测试与验收
**Rust 单测**
```text
cargo test -j 1 -p dbx-core --no-default-features --lib sql_error_position::tests
cargo test -j 1 -p dbx-core --no-default-features --lib backend_error::tests
cargo test -j 1 -p dbx-core --no-default-features --lib query::tests # QueryExecutionError 分支
```
**前端**
```text
pnpm vitest run apps/desktop/src/lib/__tests__/sql/errorPosition.spec.ts
pnpm vitest run apps/desktop/src/components/common/__tests__/QueryErrorActions.spec.ts
pnpm vitest run apps/desktop/src/i18n/__tests__/backendErrors.spec.ts
pnpm typecheck
```
**手工验收(PG 连接)**
1. `SELECT * FROM no_such_table;` → 错误横幅出现「定位错误(第 1 行,第 15 列)」→ 点击后光标落在 `no_such_table` 起首。
2. 多行脚本第 3 条报错 → 执行摘要该行显示行列,点击精确跳转;错误横幅定位到同一条语句。
3. 执行后手动改动编辑器使语句不再匹配 → 点击定位给出「位置不可用」提示,不误跳。
4. 非 PG 连接(MySQL/SQLite)错误 → 无定位入口,行为不变。
**完成标准**
- PG 原生驱动的 SQL 错误(语法错误、对象不存在等)在单语句与多语句两条路径都能拿到 `errorPosition` 并正确定位。
- 其它驱动与既有错误展示零回归(现有 `BackendError` / `translateBackendError` 测试全绿)。
- 契约文档更新;`docs/backend-error-handling.md` 的提交前检查全过。
---
## 8. 工作量估算
| 阶段 | 内容 | 估算 |
| --- | --- | --- |
| Phase 1 | 后端位置提取 + 类型化 + 透传 + 单测 | 0.5–1 天 |
| Phase 2 | 前端类型 + 映射函数 + 单测 | 0.5 天 |
| Phase 3 | UI 按钮 + 编辑器定位 + 摘要 + i18n + 手工验证 | 1 天 |
| Phase 4 | 文档、回归、提测 | 0.5 天 |
| 合计 | | **约 2.5–3 天** |
---
## 9. 一句话总结
PG 的出错行列一直存在于 `DbError::position()`,只是被 `pg_error_to_string` 丢弃;本方案在驱动层提取 cursorpos,用轻量 marker 穿过既有 `String` 错误 ABI,在 `query.rs` 还原为类型化的 `BackendError.errorPosition`,前端借助已有的 `resultSourceRange` 把「语句内行列」换算成编辑器 offset,并在错误横幅/执行摘要上提供一键定位。整体对现有架构侵入小、对非 PG 驱动零影响。
## 10. 实现补充:下发语句漂移的处理(已落地)
「位置相对实际下发语句」在本方案实现后暴露出一个高频问题:DBX 常在下发前改写语句(追加 `LIMIT/OFFSET`、用 `SELECT * FROM (…)` 包裹分页、为可编辑查询注入隐藏主键列),导致后端位置与用户原文对不上,定位会失败或偏移。已在前端增加一层投影,无需改动后端协议:
1. `annotateQueryResultSources` 额外接收本次实际下发的 SQL(`sqlToExecute`),当某个 result 的语句文本与 `sourceStatement` 不同时,把实际下发的语句文本记录到 `QueryResult.executedStatement`(仅在前端内部使用)。
2. `sqlErrorEditorOffset` 先把后端 `line/column` 解析到 `executedStatement`(位置本就相对它),再用 `mapExecutedOffsetToSource` 投影回 `sourceStatement`:
- 直接子串匹配 → 精确处理「追加子句」与「子查询包裹」;
- 前缀对齐 + 剩余文本重定位 → 处理「在投影中注入隐藏列」;
- 最后退回「首个/末个差异之间的单一变更区间」对齐。
3. 行/列越界时钳制而非报「不可用」,因此只要还存在该语句的源码范围,点击定位总能落到句式内的合理位置;只有编辑器内容与结果语句确实不一致(stale)时才提示无法定位。
新增单测覆盖:追加 `LIMIT`、分页包裹、注入隐藏列、多行语句、软字符(emoji)列宽换算与越界钳制。