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

This commit is contained in:
Eddy Lei 2026-08-07 15:18:32 +08:00 committed by GitHub
parent 4c7ddd266b
commit a5e46fd659
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 1401 additions and 51 deletions

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<{

View File

@ -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

View File

@ -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>

View File

@ -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<{

View File

@ -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>

View File

@ -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 });

View File

@ -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);

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",

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",

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",

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: "タイプ",

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: "유형",

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",

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: "类型",

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: "類型",

View File

@ -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";
}

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";

View File

@ -4,7 +4,7 @@ use dbx_core::{
models::connection::{ConnectionConfig, DatabaseType},
production_safety::{is_production_database, targets_production_database},
sql_risk::{classify_sql_risk_for_database, SqlRisk},
types::{ColumnInfo, QueryResult, TableInfo},
types::{ColumnInfo, QueryMessage, QueryResult, TableInfo},
};
use dbx_mcp::{
backend::DocsSnapshotOptions,
@ -742,24 +742,39 @@ fn format_query(connection: &str, result: &QueryResult, format: OutputFormat) ->
.collect();
let row_count = if result.columns.is_empty() { result.affected_rows } else { result.rows.len() as u64 };
match format {
OutputFormat::Json => json_string(
&json!({ "connection": connection, "columns": result.columns, "rows": rows, "row_count": row_count }),
),
OutputFormat::Json => {
let mut output =
json!({ "connection": connection, "columns": result.columns, "rows": rows, "row_count": row_count });
if !result.messages.is_empty() {
output["messages"] = json!(result.messages);
}
json_string(&output)
}
OutputFormat::Csv => Ok(csv_table(&result.columns.iter().map(String::as_str).collect::<Vec<_>>(), &rows)),
OutputFormat::Table if result.columns.is_empty() => {
Ok(format!("Query executed. {row_count} row(s) affected.\n"))
Ok(format!("Query executed. {row_count} row(s) affected.\n{}", format_query_messages(&result.messages)))
}
OutputFormat::Table => Ok(format!(
"{}\n\n{row_count} row(s)\n",
"{}\n\n{row_count} row(s)\n{}",
markdown_table(
&result.columns.iter().map(String::as_str).collect::<Vec<_>>(),
&rows,
&result.columns.iter().map(String::as_str).collect::<Vec<_>>()
)
),
format_query_messages(&result.messages)
)),
}
}
fn format_query_messages(messages: &[QueryMessage]) -> String {
let mut output = String::new();
for message in messages {
output.push_str(&message.format_line());
output.push('\n');
}
output
}
fn format_capabilities(format: OutputFormat) -> Result<String, CliError> {
match format {
OutputFormat::Json => json_string(
@ -997,6 +1012,7 @@ mod tests {
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
})
}
@ -1047,6 +1063,63 @@ mod tests {
assert_eq!(risk, SqlRisk::Ddl);
}
#[test]
fn format_query_renders_server_messages_in_table_output() {
let mut result = QueryResult {
columns: Vec::new(),
column_types: Vec::new(),
column_sortables: Vec::new(),
spatial_columns: vec![],
spatial_values: vec![],
rows: Vec::new(),
affected_rows: 1,
execution_time_ms: 0,
truncated: false,
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: vec![
QueryMessage {
severity: "notice".to_string(),
message: "hello world".to_string(),
code: Some("00000".to_string()),
detail: None,
hint: Some("use a table".to_string()),
},
QueryMessage {
severity: "WARNING".to_string(),
message: "careful".to_string(),
code: None,
detail: None,
hint: None,
},
],
};
let output = format_query("local", &result, OutputFormat::Table).unwrap();
assert_eq!(
output,
"Query executed. 1 row(s) affected.\nNOTICE: hello world (code: 00000, hint: use a table)\nWARNING: careful\n"
);
let output = format_query("local", &result, OutputFormat::Json).unwrap();
let value: Value = serde_json::from_str(&output).unwrap();
assert_eq!(
value["messages"],
json!([
{ "severity": "notice", "message": "hello world", "code": "00000", "hint": "use a table" },
{ "severity": "WARNING", "message": "careful" },
])
);
result.messages = Vec::new();
let output = format_query("local", &result, OutputFormat::Table).unwrap();
assert_eq!(output, "Query executed. 1 row(s) affected.\n");
let output = format_query("local", &result, OutputFormat::Json).unwrap();
let value: Value = serde_json::from_str(&output).unwrap();
assert!(value.get("messages").is_none());
}
#[tokio::test]
async fn routes_legacy_mongo_insert_through_shared_mongo_backend() {
let flags = parse_flags(&args(&[

View File

@ -10,7 +10,7 @@ use crate::query::QueryExecutionOptions;
use crate::query_execution_sql::{build_explain_sql, supports_explain_plan, supports_sql_query, ExplainSqlOptions};
use crate::sql_dialect::{build_table_data_select_sql, TableDataSelectSqlOptions};
use crate::sql_risk::SqlRisk;
use crate::types::QueryResult;
use crate::types::{QueryMessage, QueryResult};
/// Maximum number of tables returned by list_tables tool.
const LIST_TABLES_LIMIT: usize = 200;
@ -734,7 +734,10 @@ fn format_query_result_as_text(result: &QueryResult, limit: usize) -> Result<Str
// A result without columns is a command result, not an empty result set.
// This is how drivers represent DML that does not use RETURNING.
if result.columns.is_empty() {
return Ok(format!("Query executed. {} row(s) affected.", result.affected_rows));
return Ok(append_server_messages(
format!("Query executed. {} row(s) affected.", result.affected_rows),
&result.messages,
));
}
let mut lines = Vec::new();
@ -774,7 +777,20 @@ fn format_query_result_as_text(result: &QueryResult, limit: usize) -> Result<Str
// Stats line
lines.push(format!("({} rows, {}ms)", result.rows.len(), result.execution_time_ms));
Ok(lines.join("\n"))
Ok(append_server_messages(lines.join("\n"), &result.messages))
}
/// Append server messages in the same style as the MCP `format_query_result`
/// renderer: a `Server messages:` section with `- SEVERITY: message` lines.
fn append_server_messages(mut output: String, messages: &[QueryMessage]) -> String {
if messages.is_empty() {
return output;
}
output.push_str("\n\nServer messages:");
for message in messages {
output.push_str(&format!("\n- {}", message.format_line()));
}
output
}
/// Get sample data from a table via the get_sample_data tool.
@ -1363,6 +1379,7 @@ for line in sys.stdin:
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
}
}
@ -1393,6 +1410,44 @@ for line in sys.stdin:
);
}
#[test]
fn query_result_formatter_appends_server_messages() {
let mut dml = query_result(vec![], vec![], 2);
dml.messages = vec![
QueryMessage {
severity: "notice".to_string(),
message: "hello world".to_string(),
code: Some("00000".to_string()),
detail: None,
hint: Some("use a table".to_string()),
},
QueryMessage {
severity: "WARNING".to_string(),
message: "careful".to_string(),
code: None,
detail: None,
hint: None,
},
];
assert_eq!(
format_query_result_as_text(&dml, 50).unwrap(),
"Query executed. 2 row(s) affected.\n\nServer messages:\n- NOTICE: hello world (code: 00000, hint: use a table)\n- WARNING: careful"
);
let mut result = query_result(vec!["id"], vec![vec![serde_json::json!(1)]], 0);
result.messages = vec![QueryMessage {
severity: "INFO".to_string(),
message: "print output".to_string(),
code: None,
detail: None,
hint: None,
}];
assert_eq!(
format_query_result_as_text(&result, 50).unwrap(),
"| id |\n|---|\n| 1 |\n(1 rows, 1ms)\n\nServer messages:\n- INFO: print output"
);
}
#[test]
fn sample_data_sql_uses_database_identifier_and_limit_syntax() {
assert_eq!(

View File

@ -5215,6 +5215,7 @@ mod tests {
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
};
assert_eq!(gaussdb_identifier_quote_from_query_result(&result).as_deref(), Some("`"));

View File

@ -567,6 +567,7 @@ fn limited_query_result(result: ChJsonResult, execution_time_ms: u128, max_rows:
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
}
}
@ -738,6 +739,7 @@ pub async fn execute_query_with_max_rows(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
})
}
}

View File

@ -358,6 +358,7 @@ fn query_result(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
}
}

View File

@ -1298,6 +1298,7 @@ fn parse_elasticsearch_response_with_sql_parser(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
})
} else {
Ok(json_response_result(status, &body, start))
@ -1323,6 +1324,7 @@ fn parse_elasticsearch_response_with_sql_parser(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
})
} else {
Ok(json_response_result(status, &body, start))
@ -1494,6 +1496,7 @@ fn raw_json_response_result(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
}
}
@ -1560,6 +1563,7 @@ fn parse_elasticsearch_rest_response_with_sql_parser(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
})
}
@ -1996,6 +2000,7 @@ pub(crate) fn parse_tabular_sql_response(
session_id: body.get("cursor").and_then(|cursor| cursor.as_str()).map(str::to_string),
has_more: body.get("cursor").and_then(|cursor| cursor.as_str()).is_some(),
elasticsearch_raw_body: None,
messages: Vec::new(),
})
}

View File

@ -485,6 +485,7 @@ pub async fn execute_query(client: &InfluxdbClient, database: &str, sql: &str) -
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
}),
None => Ok(QueryResult {
columns: vec![],
@ -499,6 +500,7 @@ pub async fn execute_query(client: &InfluxdbClient, database: &str, sql: &str) -
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
}),
}
}
@ -645,6 +647,7 @@ fn parse_flux_csv(text: &str, start: Instant) -> Result<QueryResult, String> {
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
})
}

View File

@ -21,7 +21,8 @@ use crate::sql::{starts_with_executable_sql_keyword, starts_with_executable_sql_
use crate::types::{
ColumnInfo, CompletionAssistantCandidate, CompletionAssistantCandidateKind, CompletionAssistantMatchMode,
CompletionAssistantObjectKind, CompletionAssistantRequest, CompletionAssistantResponse, DatabaseInfo,
ForeignKeyInfo, IndexInfo, ObjectInfo, ObjectStatistics, QueryResult, SpatialColumnBuilder, TableInfo, TriggerInfo,
ForeignKeyInfo, IndexInfo, ObjectInfo, ObjectStatistics, QueryMessage, QueryResult, SpatialColumnBuilder,
TableInfo, TriggerInfo,
};
use super::file_validator::validate_file_path;
@ -3772,6 +3773,80 @@ async fn ping_conn_with_timeout_and_cancel(
}
}
/// Maps `SHOW WARNINGS` rows (`Level`, `Code`, `Message`) to query messages.
fn mysql_warning_rows_to_messages(rows: Vec<(String, u16, String)>) -> Vec<QueryMessage> {
rows.into_iter()
.map(|(level, code, message)| QueryMessage {
severity: level,
code: Some(code.to_string()),
message,
detail: None,
hint: None,
})
.collect()
}
/// Maps a non-empty OK-packet info string (e.g. `Records: 3 Duplicates: 0
/// Warnings: 1`) to an INFO query message.
fn mysql_info_message(info: &str) -> Option<QueryMessage> {
let info = info.trim();
if info.is_empty() {
return None;
}
Some(QueryMessage { severity: "INFO".to_string(), code: None, message: info.to_string(), detail: None, hint: None })
}
fn mysql_warnings_fallback_message(warnings: u16) -> QueryMessage {
QueryMessage {
severity: "Warning".to_string(),
code: None,
message: format!("{warnings} warning(s)"),
detail: None,
hint: None,
}
}
/// Builds the message list from an OK-packet info string plus the outcome of a
/// `SHOW WARNINGS` query: `None` when the query failed (MySQL-compatible
/// proxies such as Doris/StarRocks may not support it), `Some(rows)` with its
/// rows otherwise. An empty successful result still falls back to the
/// count-only message so a nonzero warning count is never silently dropped.
fn mysql_server_messages_from_warnings(
info: &str,
warnings: u16,
warning_rows: Option<Vec<(String, u16, String)>>,
) -> Vec<QueryMessage> {
let mut messages: Vec<QueryMessage> = mysql_info_message(info).into_iter().collect();
if warnings == 0 {
return messages;
}
match warning_rows {
Some(rows) if !rows.is_empty() => messages.extend(mysql_warning_rows_to_messages(rows)),
_ => messages.push(mysql_warnings_fallback_message(warnings)),
}
messages
}
/// Best-effort collection of server messages for a finished statement: the
/// OK-packet info string plus `SHOW WARNINGS` output when the server reported
/// warnings. `SHOW WARNINGS` runs on the same connection; all errors are
/// swallowed (MySQL-compatible proxies such as Doris/StarRocks may not support
/// it) and fall back to a count-only message.
async fn collect_mysql_server_messages(conn: &mut mysql_async::Conn, warnings: u16, info: &str) -> Vec<QueryMessage> {
let warning_rows = if warnings == 0 {
None
} else {
match conn.query_iter("SHOW WARNINGS").await {
Ok(result) => match result.try_collect_and_drop::<(String, u16, String)>().await {
Ok(rows) => rows.into_iter().collect::<Result<Vec<_>, _>>().ok(),
Err(_) => None,
},
Err(_) => None,
}
};
mysql_server_messages_from_warnings(info, warnings, warning_rows)
}
async fn execute_result_set_with_text_protocol_on_conn(
conn: &mut mysql_async::Conn,
sql: &str,
@ -3781,6 +3856,11 @@ async fn execute_result_set_with_text_protocol_on_conn(
) -> Result<QueryResult, String> {
let mut result = conn.query_iter(sql).await.map_err(|e| e.to_string())?;
if !advance_to_result_set_with_columns(&mut result).await? {
let affected_rows = result.affected_rows();
let warnings = result.warnings();
let info = result.info().into_owned();
drop(result);
let messages = collect_mysql_server_messages(conn, warnings, &info).await;
return Ok(QueryResult {
columns: vec![],
column_types: Vec::new(),
@ -3788,12 +3868,13 @@ async fn execute_result_set_with_text_protocol_on_conn(
spatial_columns: vec![],
spatial_values: vec![],
rows: vec![],
affected_rows: result.affected_rows(),
affected_rows,
execution_time_ms: start.elapsed().as_millis(),
truncated: false,
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages,
});
}
let columns: Vec<String> = result.columns_ref().iter().map(|c| c.name_str().to_string()).collect();
@ -3802,6 +3883,11 @@ async fn execute_result_set_with_text_protocol_on_conn(
if should_collect_text_result_set(sql, row_limit, max_rows) {
let rows: Vec<mysql_async::Row> = result.collect_and_drop().await.map_err(|e| e.to_string())?;
// collect_and_drop consumed the result; warnings/info now reflect the
// trailing EOF/OK packet of the finished result set.
let warnings = conn.get_warnings();
let info = conn.info().into_owned();
let messages = collect_mysql_server_messages(conn, warnings, &info).await;
let truncated = rows.len() > row_limit;
let mut spatial_values = Vec::new();
let result_rows = rows
@ -3827,6 +3913,7 @@ async fn execute_result_set_with_text_protocol_on_conn(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages,
});
}
@ -3849,6 +3936,21 @@ async fn execute_result_set_with_text_protocol_on_conn(
result_rows.push(values);
spatial_values.push(srids);
}
drop(stream);
// A truncated stream broke out of the row loop before this result set's
// terminator packet arrived, so `result.warnings()`/`result.info()` still
// hold the previous statement's values; skip capture instead of reporting
// stale messages.
let messages = if truncated {
drop(result);
Vec::new()
} else {
let warnings = result.warnings();
let info = result.info().into_owned();
drop(result);
collect_mysql_server_messages(conn, warnings, &info).await
};
Ok(QueryResult {
columns,
@ -3863,6 +3965,7 @@ async fn execute_result_set_with_text_protocol_on_conn(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages,
})
}
@ -3873,8 +3976,20 @@ async fn execute_result_sets_with_text_protocol_on_conn(
max_rows: Option<usize>,
start: Instant,
) -> Result<Vec<QueryResult>, String> {
// Per-set warnings/info read after each `collect()` are only accurate when
// the connection negotiated CLIENT_DEPRECATE_EOF: with legacy EOF packets
// the next result set's column-definition EOF clobbers the previous OK
// state, and a following column-less statement's OK packet would be
// misattributed to the wrong set. mysql_async does not expose the
// negotiated capability publicly, so gate on the client-side
// `deprecate_eof` option requested when the pool was built (DBX disables
// it automatically when a proxy only speaks legacy EOF). When disabled,
// per-set messages stay empty and only the final SHOW WARNINGS attachment
// on the last result set reports warnings.
let capture_per_set_messages = conn.opts().deprecate_eof();
let mut result = conn.query_iter(sql).await.map_err(|e| e.to_string())?;
let mut results = Vec::new();
let mut results: Vec<QueryResult> = Vec::new();
let mut result_set_warnings: Vec<u16> = Vec::new();
while advance_to_result_set_with_columns(&mut result).await? {
let columns: Vec<String> = result.columns_ref().iter().map(|c| c.name_str().to_string()).collect();
@ -3915,6 +4030,13 @@ async fn execute_result_sets_with_text_protocol_on_conn(
rows
};
let warnings = if capture_per_set_messages { result.warnings() } else { 0 };
let messages: Vec<QueryMessage> = if capture_per_set_messages {
mysql_info_message(&result.info()).into_iter().collect()
} else {
Vec::new()
};
result_set_warnings.push(warnings);
results.push(QueryResult {
columns,
column_types,
@ -3928,10 +4050,16 @@ async fn execute_result_sets_with_text_protocol_on_conn(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages,
});
}
if results.is_empty() {
let affected_rows = result.affected_rows();
let warnings = result.warnings();
let info = result.info().into_owned();
drop(result);
let messages = collect_mysql_server_messages(conn, warnings, &info).await;
results.push(QueryResult {
columns: vec![],
column_types: Vec::new(),
@ -3939,13 +4067,40 @@ async fn execute_result_sets_with_text_protocol_on_conn(
spatial_columns: vec![],
spatial_values: vec![],
rows: vec![],
affected_rows: result.affected_rows(),
affected_rows,
execution_time_ms: start.elapsed().as_millis(),
truncated: false,
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages,
});
return Ok(results);
}
// Without per-set capture (no CLIENT_DEPRECATE_EOF) the per-iteration
// counts were skipped above; the connection state after the loop still
// reflects the last statement's terminator, so use its warnings count for
// the final SHOW WARNINGS attachment on the last result set.
if !capture_per_set_messages {
let last_index = result_set_warnings.len() - 1;
result_set_warnings[last_index] = result.warnings();
}
drop(result);
// SHOW WARNINGS only reports the last executed statement, so detailed
// warning rows can only be attached to the last result set; earlier sets
// with warnings get a count-only fallback message.
let last_index = results.len() - 1;
for (index, warnings) in result_set_warnings.iter().copied().enumerate() {
if warnings == 0 {
continue;
}
if index == last_index {
let mut messages = collect_mysql_server_messages(conn, warnings, "").await;
results[index].messages.append(&mut messages);
} else {
results[index].messages.push(mysql_warnings_fallback_message(warnings));
}
}
Ok(results)
@ -3993,6 +4148,21 @@ async fn execute_result_set_with_prepared_protocol_on_conn(
result_rows.push(values);
spatial_values.push(srids);
}
drop(stream);
// A truncated stream broke out of the row loop before this result set's
// terminator packet arrived, so `result.warnings()`/`result.info()` still
// hold the previous statement's values; skip capture instead of reporting
// stale messages.
let messages = if truncated {
drop(result);
Vec::new()
} else {
let warnings = result.warnings();
let info = result.info().into_owned();
drop(result);
collect_mysql_server_messages(conn, warnings, &info).await
};
Ok(QueryResult {
columns,
@ -4007,6 +4177,7 @@ async fn execute_result_set_with_prepared_protocol_on_conn(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages,
})
}
@ -4258,7 +4429,13 @@ pub async fn execute_query_on_conn_with_max_rows(
}
};
let affected_rows = result.affected_rows();
let warnings = result.warnings();
let info = result.info().into_owned();
let drop_result = result.drop_result().await;
// Collect server messages before restoring the timestamp defaults: the
// restore issues `SET SESSION explicit_defaults_for_timestamp`, which
// clears the diagnostics area and would leave SHOW WARNINGS empty.
let messages = collect_mysql_server_messages(conn, warnings, &info).await;
restore_explicit_timestamp_defaults_for_query(conn, previous_explicit_timestamp_defaults).await;
drop_result.map_err(|e| e.to_string())?;
@ -4275,6 +4452,7 @@ pub async fn execute_query_on_conn_with_max_rows(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages,
})
}
}
@ -5011,6 +5189,83 @@ pub async fn list_triggers(pool: &MySqlPool, database: &str, table: &str) -> Res
mod tests {
use super::*;
#[test]
fn mysql_info_message_maps_non_empty_info_strings() {
let message = mysql_info_message("Records: 3 Duplicates: 0 Warnings: 1").unwrap();
assert_eq!(message.severity, "INFO");
assert_eq!(message.message, "Records: 3 Duplicates: 0 Warnings: 1");
assert_eq!(message.code, None);
assert_eq!(message.detail, None);
assert_eq!(message.hint, None);
assert!(mysql_info_message("").is_none());
assert!(mysql_info_message(" ").is_none());
}
#[test]
fn mysql_warning_rows_to_messages_maps_levels_and_codes() {
let messages = mysql_warning_rows_to_messages(vec![
("Warning".to_string(), 1265, "Data truncated for column 'a' at row 1".to_string()),
("Note".to_string(), 1051, "Unknown table 't'".to_string()),
]);
assert_eq!(messages.len(), 2);
assert_eq!(messages[0].severity, "Warning");
assert_eq!(messages[0].code.as_deref(), Some("1265"));
assert_eq!(messages[0].message, "Data truncated for column 'a' at row 1");
assert_eq!(messages[0].detail, None);
assert_eq!(messages[0].hint, None);
assert_eq!(messages[1].severity, "Note");
assert_eq!(messages[1].code.as_deref(), Some("1051"));
assert!(mysql_warning_rows_to_messages(Vec::new()).is_empty());
}
#[test]
fn mysql_warnings_fallback_message_reports_count() {
let message = mysql_warnings_fallback_message(3);
assert_eq!(message.severity, "Warning");
assert_eq!(message.message, "3 warning(s)");
assert_eq!(message.code, None);
}
#[test]
fn mysql_server_messages_from_warnings_maps_info_and_warning_rows() {
let rows = vec![("Warning".to_string(), 1265, "Data truncated for column 'a' at row 1".to_string())];
let messages = mysql_server_messages_from_warnings("Records: 1 Duplicates: 0 Warnings: 1", 1, Some(rows));
assert_eq!(messages.len(), 2);
assert_eq!(messages[0].severity, "INFO");
assert_eq!(messages[1].severity, "Warning");
assert_eq!(messages[1].code.as_deref(), Some("1265"));
}
#[test]
fn mysql_server_messages_from_warnings_falls_back_when_show_warnings_returns_no_rows() {
let messages = mysql_server_messages_from_warnings("", 2, Some(Vec::new()));
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].severity, "Warning");
assert_eq!(messages[0].message, "2 warning(s)");
}
#[test]
fn mysql_server_messages_from_warnings_falls_back_when_show_warnings_fails() {
let messages = mysql_server_messages_from_warnings("", 2, None);
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].message, "2 warning(s)");
}
#[test]
fn mysql_server_messages_from_warnings_skips_warnings_when_count_is_zero() {
let messages = mysql_server_messages_from_warnings("Query OK", 0, None);
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].severity, "INFO");
assert_eq!(messages[0].message, "Query OK");
}
#[test]
fn mysql_sql_statement_limit_reserves_packet_headroom() {
let packet_bytes = 64 * 1024 * 1024;

View File

@ -15,13 +15,16 @@ use std::collections::{BTreeSet, HashMap};
use std::fs::File;
use std::future::Future;
use std::io::BufReader;
use std::pin::Pin;
use std::str::FromStr;
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex, OnceLock, Weak};
use std::time::{Duration, Instant};
use tokio::task::JoinHandle;
use tokio_postgres::config::SslMode;
use tokio_postgres::tls::{MakeTlsConnect, TlsConnect};
use tokio_postgres::types::{FromSql, Kind, Type};
use tokio_postgres::{NoTls, Row, SimpleQueryMessage};
use tokio_postgres::{AsyncMessage, NoTls, Row, SimpleQueryMessage, Socket};
use tokio_util::sync::CancellationToken;
use super::file_validator::validate_file_path;
@ -31,7 +34,8 @@ use crate::types::{
ColumnInfo, CompletionAssistantCandidate, CompletionAssistantCandidateKind, CompletionAssistantMatchMode,
CompletionAssistantObjectKind, CompletionAssistantRequest, CompletionAssistantResponse, DatabaseInfo,
DatabaseStorageInfo, ExtensionInfo, ForeignKeyInfo, FunctionInfo, IndexInfo, ObjectInfo, ObjectStatistics,
OwnerInfo, QueryResult, RuleInfo, SchemaInfo, SequenceInfo, SpatialColumnBuilder, TableInfo, TriggerInfo,
OwnerInfo, QueryMessage, QueryResult, RuleInfo, SchemaInfo, SequenceInfo, SpatialColumnBuilder, TableInfo,
TriggerInfo,
};
pub(crate) const GAUSSDB_COMPATIBILITY_SQL: &str =
@ -1056,6 +1060,7 @@ async fn execute_select_prepared(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
}))
}
@ -1150,6 +1155,7 @@ async fn execute_select_text(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
})
}
@ -1484,6 +1490,188 @@ async fn connect_with_local_timezone(url: &str, fallback_timeout: Duration, time
connect_with_optional_local_timezone(url, fallback_timeout, Some(timezone)).await
}
/// Identity of one physical backend connection for notice attribution:
/// (server address, server port, backend PID). The PID alone is not unique
/// across different servers, so the server's own address/port disambiguate
/// (`inet_server_addr()` is NULL for Unix sockets, hence the fallback).
type PostgresConnectionKey = (String, i32, i32);
const POSTGRES_CONNECTION_IDENTITY_SQL: &str = "SELECT pg_backend_pid(), \
COALESCE(host(inet_server_addr()), 'unix'), \
COALESCE(inet_server_port(), current_setting('port')::integer)";
/// Notice buffers for live connections, keyed by connection identity. Entries
/// are weak so they disappear once the pooled connection (and its driver
/// task) is dropped.
fn postgres_notice_buffers() -> &'static Mutex<HashMap<PostgresConnectionKey, Weak<Mutex<Vec<QueryMessage>>>>> {
static BUFFERS: OnceLock<Mutex<HashMap<PostgresConnectionKey, Weak<Mutex<Vec<QueryMessage>>>>>> = OnceLock::new();
BUFFERS.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Connection identity cache, keyed by the per-physical-connection statement
/// cache pointer (same identity pattern as `postgres_single_schema_clients`).
/// The `Weak` guards against pointer reuse: a new connection whose
/// `StatementCache` lands on a freed entry's address fails the `ptr_eq` check
/// and is treated as a miss. The value is `None` when the identity query
/// failed, so it is issued at most once per physical connection — it must
/// never be retried from `drain_postgres_notices`, which can run inside the
/// read-only transaction used for EXPLAIN, where a failing query would abort
/// the user's statement.
fn postgres_client_keys(
) -> &'static Mutex<HashMap<usize, (Weak<deadpool_postgres::StatementCache>, Option<PostgresConnectionKey>)>> {
static KEYS: OnceLock<
Mutex<HashMap<usize, (Weak<deadpool_postgres::StatementCache>, Option<PostgresConnectionKey>)>>,
> = OnceLock::new();
KEYS.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Establishes connections like deadpool's `ConfigConnectImpl`, but drives
/// each connection with a task that captures `NoticeResponse` messages
/// (`RAISE NOTICE`/`WARNING`, etc.) into a per-backend buffer instead of
/// discarding them. Query execution drains the buffer so notices are
/// attached to the `QueryResult` of the statement that raised them.
struct NoticeCapturingConnect<T>
where
T: MakeTlsConnect<Socket> + Clone + Sync + Send + 'static,
T::Stream: Sync + Send,
T::TlsConnect: Sync + Send,
<T::TlsConnect as TlsConnect<Socket>>::Future: Send,
{
tls: T,
}
impl<T> deadpool_postgres::Connect for NoticeCapturingConnect<T>
where
T: MakeTlsConnect<Socket> + Clone + Sync + Send + 'static,
T::Stream: Sync + Send,
T::TlsConnect: Sync + Send,
<T::TlsConnect as TlsConnect<Socket>>::Future: Send,
{
fn connect(
&self,
pg_config: &tokio_postgres::Config,
) -> Pin<
Box<dyn Future<Output = Result<(tokio_postgres::Client, JoinHandle<()>), tokio_postgres::Error>> + Send + '_>,
> {
let tls = self.tls.clone();
let pg_config = pg_config.clone();
Box::pin(async move {
let (client, mut connection) = pg_config.connect(tls).await?;
// No query can complete before the connection is being driven, so
// the notice buffer is handed to the driver task through a slot
// that is filled once the backend PID is known.
let notice_buffer = Arc::new(Mutex::new(None::<Arc<Mutex<Vec<QueryMessage>>>>));
let task_buffer = Arc::clone(&notice_buffer);
let conn_task = tokio::spawn(async move {
loop {
match std::future::poll_fn(|cx| connection.poll_message(cx)).await {
Some(Ok(AsyncMessage::Notice(error))) => {
let message = QueryMessage {
severity: error.severity().to_string(),
message: error.message().to_string(),
code: Some(error.code().code().to_string()),
detail: error.detail().map(str::to_string),
hint: error.hint().map(str::to_string),
};
let buffer = task_buffer.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
match buffer {
Some(buffer) => {
buffer.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).push(message);
}
None => {
log::info!("[postgres][notice] {}: {}", message.severity, message.message);
}
}
}
// LISTEN/NOTIFY messages are not surfaced anywhere.
Some(Ok(_)) => {}
Some(Err(err)) => {
log::warn!("[postgres] connection driver error: {err}");
break;
}
None => break,
}
}
});
// Best-effort: without the connection identity, notices cannot be
// attributed to query results on this connection and are logged
// by the driver task instead. Never fail the connection over this.
if let Ok(row) = client.query_one(POSTGRES_CONNECTION_IDENTITY_SQL, &[]).await {
let key: PostgresConnectionKey = (row.get(1), row.get(2), row.get(0));
let buffer = Arc::new(Mutex::new(Vec::new()));
let mut buffers = postgres_notice_buffers().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
buffers.retain(|_, weak| weak.strong_count() > 0);
buffers.insert(key, Arc::downgrade(&buffer));
drop(buffers);
*notice_buffer.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(buffer);
}
Ok((client, conn_task))
})
}
}
/// Cached identity lookup. `Some(Some(key))` = resolved, `Some(None)` =
/// identity query failed before (do not retry), `None` = never seen (or the
/// entry belonged to a dropped connection whose address was reused).
fn cached_postgres_client_key(client: &deadpool_postgres::Client) -> Option<Option<PostgresConnectionKey>> {
let cache_key = Arc::as_ptr(&client.statement_cache) as usize;
let mut keys = postgres_client_keys().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
match keys.get(&cache_key) {
Some((cached, key)) if cached.upgrade().is_some_and(|cached| Arc::ptr_eq(&cached, &client.statement_cache)) => {
Some(key.clone())
}
Some(_) => {
keys.remove(&cache_key);
None
}
None => None,
}
}
/// Best-effort identity resolution. Failures are cached as `None` so the
/// identity query runs at most once per physical connection.
async fn resolve_postgres_client_key(client: &deadpool_postgres::Client) -> Option<PostgresConnectionKey> {
if let Some(key) = cached_postgres_client_key(client) {
return key;
}
let key: Option<PostgresConnectionKey> = client
.query_one(POSTGRES_CONNECTION_IDENTITY_SQL, &[])
.await
.ok()
.map(|row| (row.get(1), row.get(2), row.get(0)));
let mut keys = postgres_client_keys().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
keys.retain(|_, (cached, _)| cached.strong_count() > 0);
let cache_key = Arc::as_ptr(&client.statement_cache) as usize;
keys.insert(cache_key, (Arc::downgrade(&client.statement_cache), key.clone()));
key
}
async fn postgres_client_key(client: &deadpool_postgres::Client) -> Option<PostgresConnectionKey> {
resolve_postgres_client_key(client).await
}
fn take_notices_for_key(key: &PostgresConnectionKey) -> Vec<QueryMessage> {
let buffer = {
let mut buffers = postgres_notice_buffers().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
buffers.retain(|_, weak| weak.strong_count() > 0);
buffers.get(key).and_then(Weak::upgrade)
};
let Some(buffer) = buffer else {
return Vec::new();
};
let notices = std::mem::take(&mut *buffer.lock().unwrap_or_else(|poisoned| poisoned.into_inner()));
notices
}
async fn drain_postgres_notices(client: &deadpool_postgres::Client) -> Vec<QueryMessage> {
match postgres_client_key(client).await {
Some(key) => take_notices_for_key(&key),
None => Vec::new(),
}
}
async fn connect_with_optional_local_timezone(
url: &str,
fallback_timeout: Duration,
@ -1512,9 +1700,9 @@ async fn connect_with_optional_local_timezone(
postgres_url.accepts_invalid_certs,
postgres_url.verifies_hostname,
)?;
let mgr = deadpool_postgres::Manager::from_config(
let mgr = deadpool_postgres::Manager::from_connect(
pg_config.clone(),
tokio_postgres_rustls::MakeRustlsConnect::new(tls_config),
NoticeCapturingConnect { tls: tokio_postgres_rustls::MakeRustlsConnect::new(tls_config) },
mgr_config,
);
let pool = Pool::builder(mgr)
@ -3341,6 +3529,7 @@ pub async fn execute_query_with_max_rows(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
})
}
}
@ -3890,12 +4079,18 @@ pub async fn checkout_postgres_client(
},
None => get_future.await,
};
if result.is_ok() {
if let Ok(client) = &result {
log::debug!(
"[db:pool.checkout:done] elapsed_ms={} timeout_ms={}",
start.elapsed().as_millis(),
checkout_timeout.as_millis()
);
// Resolve the notice-attribution identity here, outside of any
// transaction: a lazy first lookup from `drain_postgres_notices`
// could run inside the read-only EXPLAIN transaction, where a
// failing identity query would abort the user's statement. Cached
// after the first checkout of each physical connection.
let _ = resolve_postgres_client_key(client).await;
}
result
}
@ -3960,16 +4155,19 @@ async fn execute_query_with_max_rows_inner(
let start = Instant::now();
let row_limit = query_result_row_limit(max_rows);
if postgres_statement_returns_rows(sql) {
// Discard stale notices from infrastructure statements (e.g. the timezone
// SET issued at connect time) so only messages raised by this statement
// are attached to its result.
let _ = drain_postgres_notices(client).await;
let result = if postgres_statement_returns_rows(sql) {
if prefer_text_protocol {
execute_select_text(client, sql, start, row_limit, None, progress_clock.as_deref()).await
} else {
execute_select_query_with_progress(client, sql, start, row_limit, progress_clock.as_deref()).await
}
} else {
let affected = client.execute(sql, &[]).await.map_err(pg_error_to_string)?;
Ok(QueryResult {
client.execute(sql, &[]).await.map_err(pg_error_to_string).map(|affected| QueryResult {
columns: vec![],
column_types: Vec::new(),
column_sortables: Vec::new(),
@ -3982,7 +4180,21 @@ async fn execute_query_with_max_rows_inner(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
})
};
match result {
Ok(mut result) => {
result.messages = drain_postgres_notices(client).await;
Ok(result)
}
Err(error) => {
// Drop notices so an errored statement's messages cannot leak
// into the next query on this pooled connection.
let _ = drain_postgres_notices(client).await;
Err(error)
}
}
}
@ -4734,6 +4946,87 @@ mod tests {
assert_eq!(gaussdb_identifier_quote_for_compatibility_mode(""), None);
}
fn test_query_message(message: &str) -> QueryMessage {
QueryMessage {
severity: "NOTICE".to_string(),
message: message.to_string(),
code: Some("00000".to_string()),
detail: None,
hint: None,
}
}
#[test]
fn take_notices_for_key_returns_buffered_notices_and_empties_buffer() {
let key = ("test-host".to_string(), 9_000_001, 9_000_001);
let buffer = Arc::new(Mutex::new(vec![test_query_message("first"), test_query_message("second")]));
postgres_notice_buffers()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.insert(key.clone(), Arc::downgrade(&buffer));
let notices = take_notices_for_key(&key);
assert_eq!(notices.len(), 2);
assert_eq!(notices[0].message, "first");
assert_eq!(notices[1].message, "second");
assert_eq!(notices[0].severity, "NOTICE");
assert_eq!(notices[0].code.as_deref(), Some("00000"));
// The buffer was drained but stays registered while the connection lives.
assert!(take_notices_for_key(&key).is_empty());
buffer.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).push(test_query_message("third"));
let notices = take_notices_for_key(&key);
assert_eq!(notices.len(), 1);
assert_eq!(notices[0].message, "third");
}
#[test]
fn take_notices_for_key_prunes_dead_buffers_and_misses_return_empty() {
let live_key = ("test-host".to_string(), 9_000_002, 9_000_002);
let dead_key = ("test-host".to_string(), 9_000_003, 9_000_003);
let live = Arc::new(Mutex::new(vec![test_query_message("live")]));
let dead = Arc::new(Mutex::new(vec![test_query_message("dead")]));
{
let mut buffers = postgres_notice_buffers().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
buffers.insert(live_key.clone(), Arc::downgrade(&live));
buffers.insert(dead_key.clone(), Arc::downgrade(&dead));
}
drop(dead);
assert!(take_notices_for_key(&("test-host".to_string(), 9_000_004, 9_000_004)).is_empty());
assert!(take_notices_for_key(&dead_key).is_empty());
let buffers = postgres_notice_buffers().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
assert!(!buffers.contains_key(&dead_key));
assert!(buffers.contains_key(&live_key));
drop(buffers);
let notices = take_notices_for_key(&live_key);
assert_eq!(notices.len(), 1);
assert_eq!(notices[0].message, "live");
}
#[test]
fn take_notices_for_key_distinguishes_same_pid_on_different_servers() {
// Backend PIDs collide across servers; the (address, port, pid) key
// keeps notice attribution separate.
let key_a = ("server-a".to_string(), 5432, 42);
let key_b = ("server-b".to_string(), 5432, 42);
let buffer_a = Arc::new(Mutex::new(vec![test_query_message("from-a")]));
let buffer_b = Arc::new(Mutex::new(vec![test_query_message("from-b")]));
{
let mut buffers = postgres_notice_buffers().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
buffers.insert(key_a.clone(), Arc::downgrade(&buffer_a));
buffers.insert(key_b.clone(), Arc::downgrade(&buffer_b));
}
let notices_a = take_notices_for_key(&key_a);
let notices_b = take_notices_for_key(&key_b);
assert_eq!(notices_a.len(), 1);
assert_eq!(notices_a[0].message, "from-a");
assert_eq!(notices_b.len(), 1);
assert_eq!(notices_b[0].message, "from-b");
}
#[test]
fn postgres_json_arrays_decode_elements_without_jsonb_version_bytes() {
let json_raw = pg_array_binary(
@ -6586,6 +6879,7 @@ mod tests {
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
};
let columns = redshift_columns_from_query_result(result);

View File

@ -306,6 +306,7 @@ pub async fn execute_query_with_max_rows(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
})
}
}
@ -358,6 +359,7 @@ fn query_result_from_rqlite_result(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
}
}

View File

@ -2530,6 +2530,7 @@ fn execute_query_blocking(pool: &SqliteHandle, sql: &str, max_rows: Option<usize
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
})
} else {
conn.execute_batch(sql).map_err(|e| e.to_string())?;
@ -2546,6 +2547,7 @@ fn execute_query_blocking(pool: &SqliteHandle, sql: &str, max_rows: Option<usize
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
})
}
})

View File

@ -1,7 +1,7 @@
use crate::query::MAX_ROWS;
use crate::sql::starts_with_executable_sql_keyword;
use crate::types::{
ColumnInfo, DatabaseInfo, ForeignKeyInfo, IndexInfo, LinkedServerInfo, ObjectStatistics, QueryResult,
ColumnInfo, DatabaseInfo, ForeignKeyInfo, IndexInfo, LinkedServerInfo, ObjectStatistics, QueryMessage, QueryResult,
SpatialColumnBuilder, TableInfo, TriggerInfo,
};
use futures::{FutureExt, TryStreamExt};
@ -486,15 +486,41 @@ where
(output, messages)
}
/// Map captured tiberius INFO-token texts to generic query messages. Tiberius
/// exposes only the message text in its tracing event, so severity is always
/// `INFO` and no code/detail/hint is available.
fn sqlserver_query_messages(messages: &[String]) -> Vec<QueryMessage> {
messages
.iter()
.map(|message| QueryMessage {
severity: "INFO".to_string(),
message: message.clone(),
code: None,
detail: None,
hint: None,
})
.collect()
}
fn query_result_with_server_messages(result: QueryResult, messages: Vec<String>) -> QueryResult {
query_result_with_server_messages_metadata(result, messages).result
}
fn query_result_with_server_messages_metadata(mut result: QueryResult, messages: Vec<String>) -> SqlServerBatchResult {
if messages.is_empty() || !result.columns.is_empty() || !result.rows.is_empty() {
if messages.is_empty() {
return SqlServerBatchResult { result, server_message: false };
}
if !result.columns.is_empty() || !result.rows.is_empty() {
// Tabular results keep their shape and additionally carry the
// messages; only the empty-result synthesis below leaves
// `messages` empty so consumers do not render the same text twice.
result.messages = sqlserver_query_messages(&messages);
return SqlServerBatchResult { result, server_message: false };
}
// Empty results synthesize the legacy single-"Message"-column grid; the
// text lives only there, so `messages` stays empty to avoid consumers
// rendering the same text twice.
result.columns = vec![SQLSERVER_MESSAGE_COLUMN.to_string()];
result.column_types = vec!["nvarchar".to_string()];
result.rows = messages.into_iter().map(|message| vec![serde_json::Value::String(message)]).collect();
@ -520,6 +546,7 @@ fn server_messages_query_result(messages: Vec<String>, start: Instant) -> Option
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
},
messages,
))
@ -585,6 +612,7 @@ async fn collect_first_result_limited(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
})
}
@ -1399,6 +1427,7 @@ fn push_sqlserver_result_set(results: &mut Vec<QueryResult>, result: Option<SqlS
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
});
}
}
@ -1456,6 +1485,7 @@ fn push_sqlserver_ordered_events(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
},
server_message: false,
});
@ -2785,6 +2815,7 @@ pub async fn execute_query_with_max_rows(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
},
messages,
))
@ -2829,6 +2860,7 @@ pub(crate) async fn execute_batch_with_max_rows_metadata(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
},
messages,
)]);
@ -2913,6 +2945,7 @@ pub(crate) async fn execute_simple_batch_with_max_rows_metadata(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
},
server_message: false,
});
@ -3182,12 +3215,12 @@ mod tests {
is_sqlserver_legacy_duplicate_probe_error, is_sqlserver_spatial_column, is_sqlserver_variant_column,
push_sqlserver_ordered_events, query_result_with_server_messages, query_result_with_server_messages_metadata,
requires_simple_query_batch, restore_sqlserver_legacy_probe_output_names,
restore_sqlserver_spatial_column_types, sqlserver_batch_can_use_execute, sqlserver_bulk_token_row,
sqlserver_cell_to_json, sqlserver_columns_sql, sqlserver_completion_assistant_sql,
restore_sqlserver_spatial_column_types, server_messages_query_result, sqlserver_batch_can_use_execute,
sqlserver_bulk_token_row, sqlserver_cell_to_json, sqlserver_columns_sql, sqlserver_completion_assistant_sql,
sqlserver_dml_output_returns_rows, sqlserver_done_trace_event, sqlserver_filter_definition_error,
sqlserver_hidden_schema_names, sqlserver_indexes_sql, sqlserver_legacy_indexes_sql, sqlserver_legacy_probe,
sqlserver_legacy_probe_with_nonce, sqlserver_legacy_wildcard_metadata_query, sqlserver_list_objects_sql,
sqlserver_list_schemas_sql, sqlserver_list_tables_sql, sqlserver_probe_explicit_alias,
sqlserver_list_schemas_sql, sqlserver_list_tables_sql, sqlserver_probe_explicit_alias, sqlserver_query_messages,
sqlserver_schema_name_predicate, sqlserver_spatial_marker, sqlserver_supports_session_database_switch,
sqlserver_table_comment_sql, sqlserver_triggers_sql, sqlserver_visible_object_predicate,
strip_dbx_sqlserver_row_number_column, SqlServerDescribedColumn, SqlServerProbeOutputNameOverride,
@ -3237,10 +3270,15 @@ mod tests {
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
};
let result = query_result_with_server_messages(empty, vec!["DBCC execution completed".to_string()]);
assert_eq!(result.columns, vec!["Message"]);
assert_eq!(result.column_types, vec!["nvarchar"]);
assert_eq!(result.rows, vec![vec![serde_json::json!("DBCC execution completed")]]);
// The synthesized grid already carries the text; `messages` stays
// empty so consumers do not render the same text twice.
assert!(result.messages.is_empty());
let message = query_result_with_server_messages_metadata(
QueryResult {
@ -3256,6 +3294,7 @@ mod tests {
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
},
vec!["PRINT output".to_string()],
);
@ -3274,11 +3313,16 @@ mod tests {
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
};
let result = query_result_with_server_messages_metadata(select, vec!["informational".to_string()]);
assert!(!result.server_message);
assert_eq!(result.result.columns, vec!["id"]);
assert_eq!(result.result.rows, vec![vec![serde_json::json!(1)]]);
// Non-empty results keep their shape but still carry the server messages.
assert_eq!(result.result.messages.len(), 1);
assert_eq!(result.result.messages[0].severity, "INFO");
assert_eq!(result.result.messages[0].message, "informational");
}
#[test]
@ -3421,6 +3465,36 @@ mod tests {
assert_eq!(results[1].result.affected_rows, 2);
}
#[test]
fn sqlserver_query_messages_map_info_severity() {
let messages = sqlserver_query_messages(&["first".to_string(), "second".to_string()]);
assert_eq!(messages.len(), 2);
for (message, expected) in messages.iter().zip(["first", "second"]) {
assert_eq!(message.severity, "INFO");
assert_eq!(message.message, expected);
assert!(message.code.is_none());
assert!(message.detail.is_none());
assert!(message.hint.is_none());
}
assert!(sqlserver_query_messages(&[]).is_empty());
}
#[test]
fn sqlserver_server_messages_query_result_synthesizes_grid() {
assert!(server_messages_query_result(vec![], Instant::now()).is_none());
let result = server_messages_query_result(vec!["print output".to_string()], Instant::now())
.expect("non-empty messages yield a result");
assert!(result.server_message);
assert_eq!(result.result.columns, vec!["Message"]);
assert_eq!(result.result.column_types, vec!["nvarchar"]);
assert_eq!(result.result.rows, vec![vec![serde_json::json!("print output")]]);
// The synthesized grid already carries the text; `messages` stays
// empty so consumers do not render the same text twice.
assert!(result.result.messages.is_empty());
}
#[test]
fn sqlserver_endpoint_splits_named_instance_hosts() {
assert_eq!(
@ -3939,6 +4013,7 @@ mod tests {
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
})
.unwrap();
@ -4281,6 +4356,7 @@ mod tests {
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
};
strip_dbx_sqlserver_row_number_column(&mut result, sql);

View File

@ -355,6 +355,7 @@ pub async fn execute_query_with_max_rows(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
});
}
@ -376,6 +377,7 @@ pub async fn execute_query_with_max_rows(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
})
} else {
// Batch multiple statements into a single pipeline for transactional integrity
@ -394,6 +396,7 @@ pub async fn execute_query_with_max_rows(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
})
}
}
@ -574,6 +577,7 @@ fn query_result_from_turso_result(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
}
}

View File

@ -832,6 +832,7 @@ fn json_to_query_result(status: u16, body: Value, start: Instant) -> QueryResult
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
}
}
@ -865,6 +866,7 @@ fn values_to_query_result(items: Vec<Value>, start: Instant) -> QueryResult {
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
}
}

View File

@ -339,6 +339,7 @@ fn series_result_to_query_result(series: Vec<SeriesResult>, start: Instant) -> Q
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
}
}
@ -375,6 +376,7 @@ fn simple_result(rows: Vec<Vec<Value>>, value_type: &str, start: Instant) -> Que
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
}
}

View File

@ -889,6 +889,7 @@ fn query_result(columns: Vec<String>, rows: Vec<Vec<serde_json::Value>>, affecte
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
}
}

View File

@ -2484,6 +2484,7 @@ fn error_query_result(message: String) -> db::QueryResult {
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
}
}
@ -2501,6 +2502,7 @@ fn empty_query_result(execution_time_ms: u128) -> db::QueryResult {
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
}
}
@ -2731,6 +2733,7 @@ pub async fn execute_statements(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
})
}
@ -3232,6 +3235,7 @@ async fn exec_tx_pg_inner(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
}),
(Err(e), Ok(_)) => Err(e),
(Ok(_), Err(reset_err)) => Err(reset_err),
@ -3324,6 +3328,7 @@ async fn exec_tx_mysql_inner(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
})
}
@ -3390,6 +3395,7 @@ async fn exec_tx_sqlite_inner(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
})
})
})
@ -3464,6 +3470,7 @@ async fn exec_tx_explicit_inner(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
})
}
@ -3531,6 +3538,7 @@ async fn exec_tx_none_inner(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
})
}
@ -4061,6 +4069,7 @@ async fn execute_manual_txn_postgres_statement(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
})
}
}
@ -4103,6 +4112,7 @@ async fn execute_manual_txn_mysql_statement(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
})
} else {
let result = conn.query_iter(sql).await.map_err(|e| format!("Query failed: {e}"))?;
@ -4121,6 +4131,7 @@ async fn execute_manual_txn_mysql_statement(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
})
}
}
@ -4156,6 +4167,7 @@ pub async fn commit_manual_transaction(state: &AppState, txn_session_id: &str) -
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
})
}
@ -4183,6 +4195,7 @@ pub async fn rollback_manual_transaction(state: &AppState, txn_session_id: &str)
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
})
}
@ -4972,6 +4985,7 @@ for line in sys.stdin:
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
};
let mut executor = FakeMysqlBatchExecutor {
outcomes: std::collections::VecDeque::from([
@ -5499,6 +5513,7 @@ for line in sys.stdin:
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
})
})
.await;
@ -5523,6 +5538,7 @@ for line in sys.stdin:
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
})
})
.await;
@ -6215,6 +6231,7 @@ for line in sys.stdin:
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
};
let normalized = normalize_query_result_for_js(result);

View File

@ -2848,6 +2848,7 @@ mod tests {
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
};
assert_eq!(mysql_external_driver_ddl_from_query_result(result).unwrap(), "CREATE TABLE `users` (`id` bigint);");
@ -2868,6 +2869,7 @@ mod tests {
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
};
assert_eq!(
@ -3572,6 +3574,7 @@ for line in sys.stdin:
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
};
let tables = presto_like_tables_from_query_result(&result);
@ -3619,6 +3622,7 @@ for line in sys.stdin:
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
};
let columns = presto_like_columns_from_query_result(&result);
@ -3790,6 +3794,7 @@ for line in sys.stdin:
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
};
assert_eq!(oracle_table_comment_from_query_result(result).unwrap().as_deref(), Some("Customer table"));
@ -3807,6 +3812,7 @@ for line in sys.stdin:
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
};
assert_eq!(oracle_table_comment_from_query_result(empty).unwrap(), None);
@ -3842,6 +3848,7 @@ for line in sys.stdin:
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
};
let comments = table_comments_from_query_result(result);
@ -3964,6 +3971,7 @@ for line in sys.stdin:
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
};
let columns = oracle_columns_from_query_result(result);
@ -4068,6 +4076,7 @@ for line in sys.stdin:
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
};
let stats = oracle_object_statistics_from_query_result(result);

View File

@ -209,6 +209,7 @@ mod tests {
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
};
let extensions = extension_infos_from_query_result(result, true);

View File

@ -530,6 +530,7 @@ async fn fetch_table_export_batch(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
});
}

View File

@ -688,6 +688,7 @@ fn execute_change_transaction(
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
}),
}
}

View File

@ -6772,6 +6772,7 @@ mod tests {
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
}
}

View File

@ -285,6 +285,42 @@ impl SpatialColumnBuilder {
}
}
/// A message emitted by the database server while executing a statement:
/// PostgreSQL `RAISE NOTICE`/`WARNING`, MySQL warnings and OK-packet info
/// strings, SQL Server `PRINT`/`RAISERROR` info messages, and similar.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueryMessage {
/// Severity/level as reported by the server (e.g. `NOTICE`, `WARNING`,
/// `INFO`, `ERROR`, MySQL's `Note`/`Warning`).
pub severity: String,
pub message: String,
/// Server error/condition code (PostgreSQL SQLSTATE, MySQL error code).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hint: Option<String>,
}
impl QueryMessage {
/// One-line text rendering shared by the CLI, MCP, and agent tool output:
/// `SEVERITY: message` plus inline `(code: …, detail: …, hint: …)` extras.
pub fn format_line(&self) -> String {
let mut line = format!("{}: {}", self.severity.to_uppercase(), self.message);
let extras = [
self.code.as_ref().map(|value| format!("code: {value}")),
self.detail.as_ref().map(|value| format!("detail: {value}")),
self.hint.as_ref().map(|value| format!("hint: {value}")),
];
let extras: Vec<_> = extras.into_iter().flatten().collect();
if !extras.is_empty() {
line.push_str(&format!(" ({})", extras.join(", ")));
}
line
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueryResult {
pub columns: Vec<String>,
@ -321,6 +357,11 @@ pub struct QueryResult {
/// between the tabular view and the original JSON.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub elasticsearch_raw_body: Option<String>,
/// Messages emitted by the database server while executing the statement
/// (notices, warnings, info messages). Empty for drivers that do not
/// capture server messages.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub messages: Vec<QueryMessage>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -453,7 +494,87 @@ pub struct OwnerInfo {
#[cfg(test)]
mod tests {
use super::{ObjectInfo, ObjectSourceKind, SpatialColumn, SpatialColumnBuilder};
use super::{ObjectInfo, ObjectSourceKind, QueryMessage, SpatialColumn, SpatialColumnBuilder};
#[test]
fn query_message_format_line_uppercases_severity() {
let message = QueryMessage {
severity: "notice".to_string(),
message: "hello world".to_string(),
code: None,
detail: None,
hint: None,
};
assert_eq!(message.format_line(), "NOTICE: hello world");
}
#[test]
fn query_message_format_line_appends_code_detail_hint_extras() {
let message = QueryMessage {
severity: "WARNING".to_string(),
message: "careful".to_string(),
code: Some("01000".to_string()),
detail: Some("column truncated".to_string()),
hint: Some("widen the column".to_string()),
};
assert_eq!(
message.format_line(),
"WARNING: careful (code: 01000, detail: column truncated, hint: widen the column)"
);
}
#[test]
fn query_message_format_line_skips_missing_extras() {
let message = QueryMessage {
severity: "INFO".to_string(),
message: "Records: 3".to_string(),
code: None,
detail: None,
hint: Some("use a table".to_string()),
};
assert_eq!(message.format_line(), "INFO: Records: 3 (hint: use a table)");
}
#[test]
fn query_message_omits_empty_optional_fields_in_json() {
let minimal = QueryMessage {
severity: "NOTICE".to_string(),
message: "hello".to_string(),
code: None,
detail: None,
hint: None,
};
assert_eq!(
serde_json::to_value(&minimal).unwrap(),
serde_json::json!({ "severity": "NOTICE", "message": "hello" })
);
let full = QueryMessage {
severity: "NOTICE".to_string(),
message: "hello".to_string(),
code: Some("00000".to_string()),
detail: Some("d".to_string()),
hint: Some("h".to_string()),
};
assert_eq!(
serde_json::to_value(&full).unwrap(),
serde_json::json!({ "severity": "NOTICE", "message": "hello", "code": "00000", "detail": "d", "hint": "h" })
);
}
#[test]
fn query_message_deserializes_without_optional_fields() {
let message: QueryMessage = serde_json::from_str(r#"{"severity":"Note","message":"Records: 1"}"#).unwrap();
assert_eq!(message.severity, "Note");
assert_eq!(message.message, "Records: 1");
assert_eq!(message.code, None);
assert_eq!(message.detail, None);
assert_eq!(message.hint, None);
}
#[test]
fn list_objects_payload_preserves_optional_validity() {

View File

@ -1216,17 +1216,25 @@ fn url_encode(value: &str) -> String {
}
pub(crate) fn format_query_result(result: &dbx_core::db::QueryResult, max_rows: usize) -> String {
if result.columns.is_empty() {
return format!("Query executed. {} row(s) affected.", result.affected_rows);
let mut output = if result.columns.is_empty() {
format!("Query executed. {} row(s) affected.", result.affected_rows)
} else {
let rows = result
.rows
.iter()
.take(max_rows)
.map(|row| row.iter().map(format_query_cell).collect::<Vec<_>>())
.collect::<Vec<_>>();
let mut output = markdown_table(&result.columns, &rows);
output.push_str(&format!("\n\n{} row(s)", rows.len()));
output
};
if !result.messages.is_empty() {
output.push_str("\n\nServer messages:");
for message in &result.messages {
output.push_str(&format!("\n- {}", message.format_line()));
}
}
let rows = result
.rows
.iter()
.take(max_rows)
.map(|row| row.iter().map(format_query_cell).collect::<Vec<_>>())
.collect::<Vec<_>>();
let mut output = markdown_table(&result.columns, &rows);
output.push_str(&format!("\n\n{} row(s)", rows.len()));
output
}
@ -1244,6 +1252,7 @@ fn query_result(columns: Vec<String>, rows: Vec<Vec<Value>>, affected_rows: u64)
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
}
}
@ -1450,6 +1459,33 @@ mod tests {
assert!(parse_database_type("unknown").is_err());
}
#[test]
fn format_query_result_appends_server_messages() {
let mut result = query_result(Vec::new(), Vec::new(), 3);
assert_eq!(format_query_result(&result, 100), "Query executed. 3 row(s) affected.");
result.messages = vec![
dbx_core::db::QueryMessage {
severity: "notice".to_string(),
message: "hello world".to_string(),
code: Some("00000".to_string()),
detail: None,
hint: Some("use a table".to_string()),
},
dbx_core::db::QueryMessage {
severity: "WARNING".to_string(),
message: "careful".to_string(),
code: None,
detail: None,
hint: None,
},
];
assert_eq!(
format_query_result(&result, 100),
"Query executed. 3 row(s) affected.\n\nServer messages:\n- NOTICE: hello world (code: 00000, hint: use a table)\n- WARNING: careful"
);
}
#[test]
fn mongo_drop_indexes_query_result_preserves_partial_failures() {
let result = mongo_drop_indexes_query_result(

View File

@ -1027,6 +1027,7 @@ mod tests {
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
},
execution_error: true,
statement_index: Some(1),

View File

@ -0,0 +1,90 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "vitest";
import { compileScript, compileTemplate, parse } from "vue/compiler-sfc";
import en from "../../apps/desktop/src/i18n/locales/en.ts";
import es from "../../apps/desktop/src/i18n/locales/es.ts";
import it from "../../apps/desktop/src/i18n/locales/it.ts";
import ja from "../../apps/desktop/src/i18n/locales/ja.ts";
import ko from "../../apps/desktop/src/i18n/locales/ko.ts";
import ptBR from "../../apps/desktop/src/i18n/locales/pt-BR.ts";
import zhCN from "../../apps/desktop/src/i18n/locales/zh-CN.ts";
import zhTW from "../../apps/desktop/src/i18n/locales/zh-TW.ts";
const messagesViewPath = "apps/desktop/src/components/layout/QueryMessagesView.vue";
const viewSwitcherPath = "apps/desktop/src/components/layout/QueryResultViewSwitcher.vue";
const contentAreaPath = "apps/desktop/src/components/layout/ContentArea.vue";
function source(path: string): string {
return readFileSync(path, "utf8");
}
test("QueryMessagesView SFC compiles", () => {
const { descriptor, errors } = parse(source(messagesViewPath), { filename: messagesViewPath });
assert.deepEqual(errors, [], `${messagesViewPath} should parse without SFC errors`);
assert.ok(descriptor.scriptSetup, `${messagesViewPath} should have a script setup block`);
compileScript(descriptor, { id: messagesViewPath });
assert.ok(descriptor.template);
const result = compileTemplate({ id: messagesViewPath, filename: messagesViewPath, source: descriptor.template!.content });
assert.deepEqual(result.errors, [], `${messagesViewPath} template should compile`);
});
test("QueryMessagesView maps severities to badge tones", () => {
const view = source(messagesViewPath);
// error/fatal/panic are errors, anything containing "warn" is a warning,
// everything else (NOTICE, INFO, Note, ...) stays muted.
assert.match(view, /normalized === "error" \|\| normalized === "fatal" \|\| normalized === "panic"\) return "error"/);
assert.match(view, /normalized\.includes\("warn"\)\) return "warning"/);
assert.match(view, /return "muted"/);
for (const tone of ["muted", "warning", "error"] as const) {
assert.match(view, new RegExp(`${tone}: "`));
}
});
test("QueryMessagesView renders message text, extras, and the empty state", () => {
const view = source(messagesViewPath);
assert.match(view, /t\("queryMessages\.empty"\)/);
assert.match(view, /\{\{ message\.severity \}\}/);
assert.match(view, /\{\{ message\.message \}\}/);
assert.match(view, /v-if="message\.detail"/);
assert.match(view, /v-if="message\.hint"/);
assert.match(view, /v-if="message\.code"[\s\S]*t\("queryMessages\.code", \{ code: message\.code \}\)/);
});
test("view switcher exposes a messages button with a count badge", () => {
const switcher = source(viewSwitcherPath);
assert.match(switcher, /canShowMessages: boolean/);
assert.match(switcher, /messageCount\?: number/);
assert.match(switcher, /<MessageSquareText class="block h-3\.5 w-3\.5 self-center" \/>/);
assert.match(switcher, /:disabled="!canShowMessages"/);
assert.match(switcher, /@click="selectView\('messages'\)"/);
assert.match(switcher, /v-if="!compact && messageCount > 0"[\s\S]*?\{\{ messageCount \}\}/);
// The tooltip/aria label carries the count when messages exist.
assert.match(switcher, /messagesTooltip = computed\(\(\) => \(props\.messageCount > 0/);
});
test("ContentArea wires server messages into the switcher and the messages view", () => {
const contentArea = source(contentAreaPath);
assert.match(contentArea, /resultMessageCount = computed\(\(\) => props\.activeTab\.result\?\.messages\?\.length \?\? 0\)/);
assert.match(contentArea, /canShowMessagesOutput = computed\(\(\) => resultMessageCount\.value > 0\)/);
assert.equal((contentArea.match(/:can-show-messages="canShowMessagesOutput"/g) ?? []).length, 2);
assert.equal((contentArea.match(/:message-count="resultMessageCount"/g) ?? []).length, 2);
assert.match(contentArea, /<QueryMessagesView v-else-if="activeOutputView === 'messages'"[\s\S]*:messages="activeTab\.result\?\.messages \?\? \[\]"/);
// Results with no result set auto-switch via the shared default-view helper.
assert.match(contentArea, /import \{ defaultViewForResult \} from "@\/lib\/query\/queryResultDefaultView"/);
assert.match(contentArea, /emit\("update:activeOutputView", result \? defaultViewForResult\(result\) : "summary"\)/);
});
test("every locale defines the query message strings", () => {
const locales = { en, es, it, ja, ko, "pt-BR": ptBR, "zh-CN": zhCN, "zh-TW": zhTW };
for (const [name, locale] of Object.entries(locales)) {
assert.ok(locale.queryMessages.empty.length > 0, `${name}: queryMessages.empty`);
assert.ok(locale.queryMessages.code.includes("{code}"), `${name}: queryMessages.code keeps the {code} placeholder`);
assert.ok(locale.tabs.messages.length > 0, `${name}: tabs.messages`);
}
});

View File

@ -0,0 +1,42 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { defaultViewForResult } from "../../apps/desktop/src/lib/query/queryResultDefaultView.ts";
import type { QueryMessage } from "../../apps/desktop/src/types/database.ts";
const notice: QueryMessage = { severity: "NOTICE", message: "hello", code: "00000" };
test("message-only results default to the messages view", () => {
// e.g. PostgreSQL `DO $$ BEGIN RAISE NOTICE 'hello'; END $$;`
const view = defaultViewForResult({ columns: [], rows: [], affected_rows: 0, messages: [notice] });
assert.equal(view, "messages");
});
test("results without messages default to the summary view", () => {
assert.equal(defaultViewForResult({ columns: [], rows: [], affected_rows: 0, messages: [] }), "summary");
assert.equal(defaultViewForResult({ columns: [], rows: [], affected_rows: 0 }), "summary");
});
test("DML with affected rows keeps the summary view even with INFO messages", () => {
// e.g. a MySQL INSERT whose OK packet carries "Records: 2 Duplicates: 0 Warnings: 0"
const view = defaultViewForResult({
columns: [],
rows: [],
affected_rows: 2,
messages: [{ severity: "Note", message: "Records: 2 Duplicates: 0 Warnings: 0" }],
});
assert.equal(view, "summary");
});
test("tabular results keep the summary view even with messages", () => {
const view = defaultViewForResult({ columns: ["value"], rows: [[1]], affected_rows: 0, messages: [notice] });
assert.equal(view, "summary");
});
test("rows alone (without column metadata) keep the summary view", () => {
const view = defaultViewForResult({ columns: [], rows: [["x"]], affected_rows: 0, messages: [notice] });
assert.equal(view, "summary");
});

View File

@ -35,7 +35,7 @@ test("query result toolbar reuses the production icon contract", () => {
assert.match(contentArea, /<Pin class="h-3\.5 w-3\.5"/);
assert.match(contentArea, /<Wrench class="h-4 w-4"/);
assert.match(contentArea, /<ChevronDown class="h-3\.5 w-3\.5"/);
assert.match(viewSwitcher, /import \{ BarChart3, ListChecks \} from "@lucide\/vue"/);
assert.match(viewSwitcher, /import \{ BarChart3, ListChecks, MessageSquareText \} from "@lucide\/vue"/);
assert.match(toolbarActions, /import \{ GitBranch, Loader2, Upload \} from "@lucide\/vue"/);
assert.match(viewSwitcher, /inline-flex h-4 items-center leading-none/);
assert.match(toolbarActions, /block h-3\.5 w-3\.5 self-center/);