feat(query): show database server messages in query results

This commit is contained in:
Eddy Lei authored and GitHub committed 2026-08-07 15:18:32 +08:00
1 parent 4c7ddd266b
commit a5e46fd659
45 files changed
+1401 -51

No files matched your search

+1 -1
View File
@@ -239,7 +239,7 @@ const selectedSql = ref("");
const cursorPos = ref(0);
const formatSqlRequest = ref<{ id: number; tabId: string } | null>(null);
const compressSqlRequest = ref<{ id: number; tabId: string } | null>(null);
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
const activeOutputView = ref<"result" | "summary" | "explain" | "chart" | "messages">("result");
const newQueryContextSource = ref<"tab" | "sidebar">("tab");
const queryEditorDdlTarget = ref<{ connectionId: string; database: string; catalog?: string; schema?: string; tableName: string; objectType?: ObjectSourceKind } | null>(null);
const queryEditorObjectSourceTarget = ref<{
@@ -3,6 +3,7 @@ import { computed, ref, defineAsyncComponent, watch, nextTick, onMounted, onUnmo
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog";
import { canReloadUnavailableDataTab } from "@/lib/table/tableDataRefresh";
import { defaultViewForResult } from "@/lib/query/queryResultDefaultView";
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
import type { CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
@@ -18,6 +19,7 @@ import QueryEditor from "@/components/editor/QueryEditor.vue";
import ColumnInfoPanel from "@/components/editor/ColumnInfoPanel.vue";
import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
import QueryErrorActions from "@/components/common/QueryErrorActions.vue";
import QueryMessagesView from "@/components/layout/QueryMessagesView.vue";
import QueryResultToolbarActions from "@/components/layout/QueryResultToolbarActions.vue";
import QueryResultViewSwitcher from "@/components/layout/QueryResultViewSwitcher.vue";
import DataGridCopyFormatControl from "@/components/grid/DataGridCopyFormatControl.vue";
@@ -138,7 +140,7 @@ const props = defineProps<{
activeTab: QueryTab;
activeConnection?: ConnectionConfig;
executableSql: string;
activeOutputView: "result" | "summary" | "explain" | "chart";
activeOutputView: "result" | "summary" | "explain" | "chart" | "messages";
formatSqlRequest: { id: number; tabId: string } | null;
compressSqlRequest: { id: number; tabId: string } | null;
selectedSql: string;
@@ -147,7 +149,7 @@ const props = defineProps<{
}>();
const emit = defineEmits<{
"update:activeOutputView": [value: "result" | "summary" | "explain" | "chart"];
"update:activeOutputView": [value: "result" | "summary" | "explain" | "chart" | "messages"];
fixWithAi: [errorMessage: string];
sendSelectionToAi: [sql: string];
execute: [sqlOverride?: SqlExecutionOverride];
@@ -427,6 +429,8 @@ const hasTabularResult = computed(() => {
});
const canShowResultOutput = computed(() => hasTabularResult.value || props.activeTab.isExecuting);
const canShowExplainOutput = computed(() => !!props.activeTab.explainPlan || !!props.activeTab.explainError || !!props.activeTab.explainTableResult || !!props.activeTab.explainTableError || props.activeTab.isExplaining === true);
const resultMessageCount = computed(() => props.activeTab.result?.messages?.length ?? 0);
const canShowMessagesOutput = computed(() => resultMessageCount.value > 0);
const showStandaloneResultToolbar = computed(() => activeElasticsearchJsonResponse.value || props.activeOutputView !== "result" || !props.activeTab.result || !hasTabularResult.value);
const standaloneResultToolbarCompact = computed(() => isDataGridToolbarCompact(standaloneResultToolbarWidth.value, standaloneResultToolbarViewportWidth.value));
let standaloneResultToolbarResizeObserver: ResizeObserver | undefined;
@@ -599,7 +603,8 @@ watch(
() => {
if (props.activeTab.isExecuting) return;
if (hasExecutionSummary.value && !hasTabularResult.value && props.activeOutputView === "result") {
emit("update:activeOutputView", "summary");
const result = props.activeTab.result;
emit("update:activeOutputView", result ? defaultViewForResult(result) : "summary");
}
},
{ immediate: true },
@@ -1319,6 +1324,8 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
:can-show-result="canShowResultOutput"
:can-show-summary="hasExecutionSummary"
:can-show-chart="hasNumericData && !activeElasticsearchJsonResponse"
:can-show-messages="canShowMessagesOutput"
:message-count="resultMessageCount"
:compact="standaloneResultToolbarCompact"
@select-view="emit('update:activeOutputView', $event)"
/>
@@ -1414,6 +1421,8 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
</div>
</div>
<QueryMessagesView v-else-if="activeOutputView === 'messages'" class="flex-1 min-h-0" :messages="activeTab.result?.messages ?? []" />
<template v-else>
<ElasticsearchJsonResponsePanel v-if="activeElasticsearchJsonResponse" ref="elasticsearchJsonResponsePanelRef" class="flex-1 min-h-0" :status="activeElasticsearchJsonResponse.status" :body="activeElasticsearchJsonResponse.body" />
<ElasticsearchJsonResponsePanel v-else-if="showElasticsearchRawJson && activeElasticsearchRawBody" ref="elasticsearchJsonResponsePanelRef" class="flex-1 min-h-0" :status="200" :body="activeElasticsearchRawBody" can-show-table @show-table="showElasticsearchRawJson = false" />
@@ -1467,7 +1476,16 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
@sort="(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string, mode?: DataGridSortMode) => emit('sort', column, columnIndex, direction, whereInput, mode)"
>
<template #result-toolbar-leading="{ compact }">
<QueryResultViewSwitcher :active-view="activeOutputView" :can-show-result="canShowResultOutput" :can-show-summary="hasExecutionSummary" :can-show-chart="hasNumericData && !activeElasticsearchJsonResponse" :compact="compact" @select-view="emit('update:activeOutputView', $event)" />
<QueryResultViewSwitcher
:active-view="activeOutputView"
:can-show-result="canShowResultOutput"
:can-show-summary="hasExecutionSummary"
:can-show-chart="hasNumericData && !activeElasticsearchJsonResponse"
:can-show-messages="canShowMessagesOutput"
:message-count="resultMessageCount"
:compact="compact"
@select-view="emit('update:activeOutputView', $event)"
/>
<template v-if="activeElasticsearchRawBody">
<div class="mx-1 h-4 w-px bg-border" />
<button
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { Badge } from "@/components/ui/badge";
import type { QueryMessage } from "@/types/database";
defineProps<{ messages: QueryMessage[] }>();
const { t } = useI18n();
type SeverityTone = "muted" | "warning" | "error";
function severityTone(severity: string): SeverityTone {
const normalized = severity.toLowerCase();
if (normalized === "error" || normalized === "fatal" || normalized === "panic") return "error";
if (normalized.includes("warn")) return "warning";
return "muted";
}
const severityBadgeClasses: Record<SeverityTone, string> = {
muted: "border-border bg-muted/40 text-muted-foreground",
warning: "border-amber-500/35 bg-amber-500/10 text-amber-700 dark:text-amber-300",
error: "border-destructive/40 bg-destructive/10 text-destructive",
};
</script>
<template>
<div class="h-full overflow-auto bg-background">
<div v-if="messages.length === 0" class="flex h-full items-center justify-center text-sm text-muted-foreground">
{{ t("queryMessages.empty") }}
</div>
<div v-else class="overflow-hidden">
<div v-for="(message, index) in messages" :key="index" class="flex items-start gap-2 border-b px-3 py-2 text-xs last:border-b-0">
<Badge variant="secondary" class="mt-px shrink-0 font-mono text-[10px] uppercase" :class="severityBadgeClasses[severityTone(message.severity)]">
{{ message.severity }}
</Badge>
<div class="min-w-0 flex-1">
<div class="font-mono text-[11px] whitespace-pre-wrap break-words text-foreground">{{ message.message }}</div>
<div v-if="message.detail" class="mt-0.5 font-mono text-[11px] whitespace-pre-wrap break-words text-muted-foreground">{{ message.detail }}</div>
<div v-if="message.hint" class="mt-0.5 font-mono text-[11px] whitespace-pre-wrap break-words text-muted-foreground">{{ message.hint }}</div>
<div v-if="message.code" class="mt-0.5 font-mono text-[10px] text-muted-foreground">{{ t("queryMessages.code", { code: message.code }) }}</div>
</div>
</div>
</div>
</div>
</template>
@@ -4,7 +4,7 @@ import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import LightTooltip from "@/components/ui/LightTooltip.vue";
type OutputView = "result" | "summary" | "explain" | "chart";
type OutputView = "result" | "summary" | "explain" | "chart" | "messages";
withDefaults(
defineProps<{
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { BarChart3, ListChecks } from "@lucide/vue";
import { computed } from "vue";
import { BarChart3, ListChecks, MessageSquareText } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import LightTooltip from "@/components/ui/LightTooltip.vue";
type OutputView = "result" | "summary" | "explain" | "chart";
type OutputView = "result" | "summary" | "explain" | "chart" | "messages";
type PrimaryResultView = Exclude<OutputView, "explain">;
const props = withDefaults(
@@ -13,9 +14,11 @@ const props = withDefaults(
canShowResult: boolean;
canShowSummary: boolean;
canShowChart: boolean;
canShowMessages: boolean;
messageCount?: number;
compact?: boolean;
}>(),
{ compact: false },
{ compact: false, messageCount: 0 },
);
const emit = defineEmits<{
@@ -24,6 +27,8 @@ const emit = defineEmits<{
const { t } = useI18n();
const messagesTooltip = computed(() => (props.messageCount > 0 ? `${t("tabs.messages")} (${props.messageCount})` : t("tabs.messages")));
function selectView(view: PrimaryResultView) {
if (props.activeView === view) return;
emit("selectView", view);
@@ -69,5 +74,23 @@ function selectView(view: PrimaryResultView) {
<span v-if="!compact" class="inline-flex h-4 items-center leading-none">{{ t("chart.title") }}</span>
</Button>
</LightTooltip>
<LightTooltip :text="messagesTooltip" :disabled="!compact" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button
size="sm"
:variant="activeView === 'messages' ? 'secondary' : 'ghost'"
class="h-5 shrink-0 text-xs leading-none"
:class="compact ? 'w-6 gap-0 px-0' : 'gap-1 px-2'"
:title="messagesTooltip"
:aria-label="messagesTooltip"
:aria-pressed="activeView === 'messages'"
:disabled="!canShowMessages"
@click="selectView('messages')"
>
<MessageSquareText class="block h-3.5 w-3.5 self-center" />
<span v-if="!compact" class="inline-flex h-4 items-center leading-none">{{ t("tabs.messages") }}</span>
<span v-if="!compact && messageCount > 0" class="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-muted px-1 text-[10px] leading-none tabular-nums text-muted-foreground">{{ messageCount }}</span>
</Button>
</LightTooltip>
</div>
</template>
@@ -264,6 +264,108 @@ SELECT @value AS Message;`;
expect(activeTab.value?.result?.rows).toEqual([["x"]]);
});
it("opens the messages view for a message-only result", async () => {
const sql = "DO $$ BEGIN RAISE NOTICE 'hello'; END $$;";
const activeTab = ref<QueryTab | undefined>({ ...queryTab("app"), sql });
const activeConnection = ref<ConnectionConfig | undefined>(connection("postgres"));
const activeOutputView = ref<"result" | "summary" | "explain" | "chart" | "messages">("result");
const queryStore = useQueryStore();
vi.spyOn(queryStore, "executeCurrentSql").mockImplementation(async () => {
if (activeTab.value) activeTab.value.result = { columns: [], rows: [], affected_rows: 0, execution_time_ms: 1, messages: [{ severity: "NOTICE", message: "hello", code: "00000" }] };
});
vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
const execution = useSqlExecution({
activeTab: computed(() => activeTab.value),
activeConnection: computed(() => activeConnection.value),
executableSql: computed(() => sql),
activeOutputView,
});
await execution.tryExecute();
expect(activeOutputView.value).toBe("messages");
});
it("keeps the summary view for a MySQL INSERT that carries an INFO message", async () => {
const sql = "INSERT INTO users (name) VALUES ('a'), ('b')";
const activeTab = ref<QueryTab | undefined>({ ...queryTab("app"), sql });
const activeConnection = ref<ConnectionConfig | undefined>(connection("mysql"));
const activeOutputView = ref<"result" | "summary" | "explain" | "chart" | "messages">("result");
const queryStore = useQueryStore();
vi.spyOn(queryStore, "executeCurrentSql").mockImplementation(async () => {
if (activeTab.value) activeTab.value.result = { columns: [], rows: [], affected_rows: 2, execution_time_ms: 1, messages: [{ severity: "Note", message: "Records: 2 Duplicates: 0 Warnings: 0" }] };
});
vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
const execution = useSqlExecution({
activeTab: computed(() => activeTab.value),
activeConnection: computed(() => activeConnection.value),
executableSql: computed(() => sql),
activeOutputView,
});
await execution.tryExecute();
expect(activeOutputView.value).toBe("summary");
});
it("keeps the summary view for a batch whose statements emit messages", async () => {
const sql = "DO $$ BEGIN RAISE NOTICE 'one'; END $$;\nDO $$ BEGIN RAISE NOTICE 'two'; END $$;";
const activeTab = ref<QueryTab | undefined>({ ...queryTab("app"), sql });
const activeConnection = ref<ConnectionConfig | undefined>(connection("postgres"));
const activeOutputView = ref<"result" | "summary" | "explain" | "chart" | "messages">("result");
const queryStore = useQueryStore();
vi.spyOn(queryStore, "executeCurrentSql").mockImplementation(async () => {
if (activeTab.value)
activeTab.value.result = {
columns: [],
rows: [],
affected_rows: 0,
execution_time_ms: 1,
messages: [
{ severity: "NOTICE", message: "one" },
{ severity: "NOTICE", message: "two" },
],
};
});
vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
const execution = useSqlExecution({
activeTab: computed(() => activeTab.value),
activeConnection: computed(() => activeConnection.value),
executableSql: computed(() => sql),
activeOutputView,
});
await execution.tryExecute();
expect(activeOutputView.value).toBe("summary");
});
it("keeps the result view when messages accompany a tabular result", async () => {
const sql = "SELECT 1";
const activeTab = ref<QueryTab | undefined>({ ...queryTab("app"), sql });
const activeConnection = ref<ConnectionConfig | undefined>(connection("postgres"));
const activeOutputView = ref<"result" | "summary" | "explain" | "chart" | "messages">("result");
const queryStore = useQueryStore();
vi.spyOn(queryStore, "executeCurrentSql").mockImplementation(async () => {
if (activeTab.value) activeTab.value.result = { columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1, messages: [{ severity: "NOTICE", message: "hello" }] };
});
vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
const execution = useSqlExecution({
activeTab: computed(() => activeTab.value),
activeConnection: computed(() => activeConnection.value),
executableSql: computed(() => sql),
activeOutputView,
});
await execution.tryExecute();
expect(activeOutputView.value).toBe("result");
});
it("forwards execute-in-new-result-tab intent to the query store", async () => {
const sql = "SELECT * FROM users";
const activeTab = ref<QueryTab | undefined>({ ...queryTab("app"), sql });
@@ -9,6 +9,7 @@ import { isSingleDatabase, usesTreeSchemaMode } from "@/lib/database/databaseCap
import { supportsConnectionLevelSqlExecution } from "@/lib/connection/connectionLevelDatabaseBootstrap";
import { classifySqlActivityKind } from "@/lib/history/historyActivityKind";
import { sqlMetadataRefreshTarget } from "@/lib/sql/sqlMetadataRefresh";
import { defaultViewForResult } from "@/lib/query/queryResultDefaultView";
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
import { classifyRedisCommandSafety } from "@/lib/redis/redisCommandSafety";
import { isSqlExecutionSnapshot, resolveExecutableSql, type SqlExecutionOverride, type SqlExecutionSnapshot } from "@/lib/sql/sqlExecutionTarget";
@@ -76,7 +77,7 @@ export function useSqlExecution(deps: {
activeConnection: ComputedRef<ConnectionConfig | undefined>;
executableSql: ComputedRef<string>;
resolveExecutableSql?: (snapshot?: SqlExecutionSnapshot) => Promise<string>;
activeOutputView: Ref<"result" | "summary" | "explain" | "chart">;
activeOutputView: Ref<"result" | "summary" | "explain" | "chart" | "messages">;
blockDangerousRedisCommands?: Ref<boolean>;
onMissingDatabase?: () => void;
}) {
@@ -235,7 +236,7 @@ export function useSqlExecution(deps: {
} else if (executionDatabaseType === "sqlserver" && tab.result?.server_message === true) {
deps.activeOutputView.value = "result";
} else if (tab.result && !tab.result.columns.length && !tab.results?.some((result) => result.columns.length > 0)) {
deps.activeOutputView.value = "summary";
deps.activeOutputView.value = statementCount === 1 ? defaultViewForResult(tab.result) : "summary";
}
const elapsed = Date.now() - start;
const failure = firstQueryExecutionError(tab);
+5
View File
@@ -1047,6 +1047,7 @@ export default {
objects: "Objects",
users: "Users & Privileges",
executionSummary: "Summary",
messages: "Messages",
tooltipTitle: "Title:",
tooltipFilePath: "File Path:",
tooltipFileStatus: "File Status:",
@@ -1145,6 +1146,10 @@ export default {
cancelled: "Cancelled",
},
},
queryMessages: {
empty: "No messages",
code: "Code: {code}",
},
chart: {
title: "Chart",
type: "Type",
+5
View File
@@ -1023,6 +1023,7 @@ export default withEnglishFallback({
vector: "Vector",
users: "Usuarios y Privilegios",
executionSummary: "Resumen",
messages: "Mensajes",
mongo: "Mongo",
objects: "Objetos",
tooltipTitle: "Título:",
@@ -1092,6 +1093,10 @@ export default withEnglishFallback({
cancelled: "Cancelada",
},
},
queryMessages: {
empty: "Sin mensajes",
code: "Código: {code}",
},
chart: {
title: "Gráfico",
type: "Tipo",
+5
View File
@@ -1023,6 +1023,7 @@ export default withEnglishFallback({
objects: "Oggetti",
users: "Utenti e Privilegi",
executionSummary: "Riepilogo",
messages: "Messaggi",
tooltipTitle: "Titolo:",
tooltipFileStatus: "Stato file:",
externalFileMissing: "Eliminato o spostato fuori da DBX",
@@ -1090,6 +1091,10 @@ export default withEnglishFallback({
cancelled: "Annullata",
},
},
queryMessages: {
empty: "Nessun messaggio",
code: "Codice: {code}",
},
chart: {
title: "Grafico",
type: "Tipo",
+5
View File
@@ -1042,6 +1042,7 @@ export default withEnglishFallback({
objects: "オブジェクト",
users: "ユーザーと権限",
executionSummary: "サマリー",
messages: "メッセージ",
tooltipTitle: "タイトル:",
tooltipFilePath: "ファイルパス:",
tooltipFileStatus: "ファイルの状態:",
@@ -1112,6 +1113,10 @@ export default withEnglishFallback({
cancelled: "キャンセル済み",
},
},
queryMessages: {
empty: "メッセージなし",
code: "コード: {code}",
},
chart: {
title: "グラフ",
type: "タイプ",
+5
View File
@@ -956,6 +956,7 @@ export default withEnglishFallback({
objects: "객체",
users: "사용자 및 권한",
executionSummary: "요약",
messages: "메시지",
tooltipTitle: "제목:",
tooltipFilePath: "파일 경로:",
tooltipFileStatus: "파일 상태:",
@@ -1054,6 +1055,10 @@ export default withEnglishFallback({
cancelled: "취소됨",
},
},
queryMessages: {
empty: "메시지 없음",
code: "코드: {code}",
},
chart: {
title: "차트",
type: "유형",
+5
View File
@@ -1024,6 +1024,7 @@ export default withEnglishFallback({
objects: "Objetos",
users: "Usuários e Privilégios",
executionSummary: "Resumo",
messages: "Mensagens",
tooltipTitle: "Título:",
tooltipFilePath: "Caminho do arquivo:",
tooltipFileStatus: "Status do arquivo:",
@@ -1092,6 +1093,10 @@ export default withEnglishFallback({
cancelled: "Cancelada",
},
},
queryMessages: {
empty: "Nenhuma mensagem",
code: "Código: {code}",
},
chart: {
title: "Gráfico",
type: "Tipo",
+5
View File
@@ -1047,6 +1047,7 @@ export default withEnglishFallback({
objects: "对象",
users: "用户与权限",
executionSummary: "摘要",
messages: "消息",
tooltipTitle: "标题:",
tooltipFilePath: "文件路径:",
tooltipFileStatus: "文件状态:",
@@ -1145,6 +1146,10 @@ export default withEnglishFallback({
cancelled: "已取消",
},
},
queryMessages: {
empty: "没有消息",
code: "代码:{code}",
},
chart: {
title: "图表",
type: "类型",
+5
View File
@@ -1023,6 +1023,7 @@ export default withEnglishFallback({
objects: "物件",
users: "使用者與權限",
executionSummary: "摘要",
messages: "訊息",
tooltipTitle: "標題:",
tooltipFilePath: "檔案路徑:",
tooltipFileStatus: "檔案狀態:",
@@ -1091,6 +1092,10 @@ export default withEnglishFallback({
cancelled: "已取消",
},
},
queryMessages: {
empty: "沒有訊息",
code: "代碼:{code}",
},
chart: {
title: "圖表",
type: "類型",
@@ -0,0 +1,12 @@
import type { QueryResult } from "@/types/database";
/**
* Picks the default output view for a result that has no result set.
* Message-only results (e.g. PostgreSQL `DO $$ RAISE NOTICE $$`) open the
* messages view; routine DML like a MySQL INSERT also carries an INFO message
* ("Records: N ...") but keeps the established summary view.
*/
export function defaultViewForResult(result: Pick<QueryResult, "columns" | "rows" | "affected_rows" | "messages">): "messages" | "summary" {
const messageOnly = result.columns.length === 0 && result.rows.length === 0 && result.affected_rows === 0 && (result.messages?.length ?? 0) > 0;
return messageOnly ? "messages" : "summary";
}
+11
View File
@@ -573,6 +573,15 @@ export interface OwnerInfo {
owner: string;
}
/** A database server message carried on a query result (e.g. PostgreSQL RAISE NOTICE, MySQL warnings). */
export interface QueryMessage {
severity: string;
message: string;
code?: string;
detail?: string;
hint?: string;
}
export interface QueryResult {
columns: string[];
/** One SRID per geometry/geography column (first non-null observed). */
@@ -634,6 +643,8 @@ export interface QueryResult {
/** Absolute offsets in the editor document at execution time. */
sourceFrom?: number;
sourceTo?: number;
/** Database server messages (notices, warnings) emitted while producing this result. Omitted when empty. */
messages?: QueryMessage[];
}
export type BatchStatementExecutionStatus = "pending" | "running" | "success" | "error" | "skipped" | "cancelled";