feat(query): jump to PostgreSQL error position from result messages
This commit is contained in:
1 parent
571fc07d85
commit
5a0e74eec6
36 files changed
+2079
-23
No files matched your search
@@ -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: [];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "停止",
|
||||
|
||||
@@ -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: "중지",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "停止",
|
||||
|
||||
@@ -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: "停止",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user