feat(redis): add command-line query result mode

Closes #10099
This commit is contained in:
t8y2
2026-09-27 12:36:33 +00:00
parent add378d643
commit 9c23e3198b
19 changed files with 488 additions and 41 deletions
@@ -96,6 +96,7 @@ function preloadDataGridComponent() {
const QueryEditor = defineAsyncComponent({ loader: () => import("@/components/editor/QueryEditor.vue"), loadingComponent: QueryLoadingState, delay: 0 });
const DataGrid = defineAsyncComponent(loadDataGridComponent);
const RedisKeyBrowser = defineAsyncComponent(() => import("@/components/redis/RedisKeyBrowser.vue"));
const RedisQueryConsoleOutput = defineAsyncComponent(() => import("@/components/redis/RedisQueryConsoleOutput.vue"));
const RedisDashboard = defineAsyncComponent(() => import("@/components/redis/RedisDashboard.vue"));
const EtcdKeyBrowser = defineAsyncComponent(() => import("@/components/etcd/EtcdKeyBrowser.vue"));
const EtcdDashboard = defineAsyncComponent(() => import("@/components/etcd/EtcdDashboard.vue"));
@@ -186,7 +187,7 @@ import { loadObjectDdl } from "@/lib/metadata/objectDdlCache";
import { formatDdlForDisplay } from "@/lib/sql/ddlDisplay";
import { sqlObjectNavigationTypeFromTableType } from "@/lib/sql/sqlNavigation";
import type { CustomSaveHandler } from "@/composables/useDataGridEditor";
import type { QueryMessage, QueryTab, TableInfoTab, TreeNode, VectorCollectionMeta } from "@/types/database";
import type { QueryMessage, QueryTab, RedisResultViewMode, TableInfoTab, TreeNode, VectorCollectionMeta } from "@/types/database";
import type { SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
@@ -613,6 +614,9 @@ const hasTabularResult = computed(() => {
if (props.activeTab.result?.columns.length && props.activeTab.result.server_message !== true) return true;
return visibleResultItems.value.length > 0;
});
const redisConsoleResults = computed(() => (props.activeTab.results?.length ? props.activeTab.results : props.activeTab.result ? [props.activeTab.result] : []));
const canShowRedisConsoleOutput = computed(() => activeEffectiveDatabaseType.value === "redis" && (props.activeTab.isExecuting || redisConsoleResults.value.some((result) => result.execution_error === true || typeof result.redis_console_output === "string")));
const redisResultViewMode = computed<RedisResultViewMode>(() => (activeEffectiveDatabaseType.value === "redis" ? (props.activeTab.uiState?.redisResultViewMode ?? "grid") : "grid"));
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);
// A batch can attach server messages to more than one statement result (for
@@ -626,7 +630,7 @@ const resultMessages = computed<QueryMessage[]>(() => {
});
const resultMessageCount = computed(() => resultMessages.value.length);
const canShowMessagesOutput = computed(() => resultMessageCount.value > 0);
const showStandaloneResultToolbar = computed(() => activeElasticsearchJsonResponse.value || props.activeOutputView !== "result" || !props.activeTab.result || !hasTabularResult.value);
const showStandaloneResultToolbar = computed(() => activeElasticsearchJsonResponse.value || props.activeOutputView !== "result" || (redisResultViewMode.value === "console" && canShowRedisConsoleOutput.value) || !props.activeTab.result || !hasTabularResult.value);
const standaloneResultToolbarCompact = computed(() => isDataGridToolbarCompact(standaloneResultToolbarWidth.value, standaloneResultToolbarViewportWidth.value));
let standaloneResultToolbarResizeObserver: ResizeObserver | undefined;
@@ -635,6 +639,11 @@ function updateStandaloneResultToolbarDimensions() {
standaloneResultToolbarViewportWidth.value = typeof window === "undefined" ? 0 : window.innerWidth;
}
function setRedisResultViewMode(mode: RedisResultViewMode) {
if (activeEffectiveDatabaseType.value !== "redis") return;
queryStore.updateTabUiState(props.activeTab.id, { redisResultViewMode: mode });
}
function observeStandaloneResultToolbar() {
standaloneResultToolbarResizeObserver?.disconnect();
standaloneResultToolbarResizeObserver = undefined;
@@ -1817,7 +1826,7 @@ defineExpose({
<ResultSetNavigator v-if="visibleResultItems.length > 0" :key="`${activeTab.id}:${activeTab.activeResultRunId ?? 'current'}`" :items="visibleResultItems" :active-index="activeTab.activeResultIndex ?? 0" :active="activeOutputView === 'result'" @select="selectResultItem" />
</template>
<div class="ml-auto flex shrink-0 items-center gap-1">
<Popover v-if="activeOutputView === 'result' && activeTab.result && hasTabularResult && !activeElasticsearchJsonResponse" v-model:open="dataGridViewOptionsOpen">
<Popover v-if="activeOutputView === 'result' && redisResultViewMode === 'grid' && activeTab.result && hasTabularResult && !activeElasticsearchJsonResponse" v-model:open="dataGridViewOptionsOpen">
<PopoverTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-7 shrink-0 text-foreground hover:bg-accent" :title="t('grid.viewOptions')" :aria-label="t('grid.viewOptions')">
<Wrench class="h-4 w-4" />
@@ -2096,9 +2105,12 @@ defineExpose({
:can-show-summary="hasExecutionSummary"
:can-show-chart="hasNumericData && !activeElasticsearchJsonResponse"
:can-show-messages="canShowMessagesOutput"
:can-show-redis-console="canShowRedisConsoleOutput"
:result-mode="redisResultViewMode"
:message-count="resultMessageCount"
:compact="standaloneResultToolbarCompact"
@select-view="emit('update:activeOutputView', activeTab.id, $event)"
@select-result-mode="setRedisResultViewMode"
/>
<QueryResultToolbarActions
class="ml-auto"
@@ -2250,6 +2262,8 @@ defineExpose({
</div>
</div>
<RedisQueryConsoleOutput v-else-if="activeOutputView === 'result' && redisResultViewMode === 'console' && canShowRedisConsoleOutput" :result="activeTab.result" :results="activeTab.results" :loading="activeTab.isExecuting" />
<QueryMessagesView v-else-if="activeOutputView === 'messages'" class="flex-1 min-h-0" :messages="resultMessages" />
<template v-else>
@@ -2337,9 +2351,12 @@ defineExpose({
:can-show-summary="hasExecutionSummary"
:can-show-chart="hasNumericData && !activeElasticsearchJsonResponse"
:can-show-messages="canShowMessagesOutput"
:can-show-redis-console="canShowRedisConsoleOutput"
:result-mode="redisResultViewMode"
:message-count="resultMessageCount"
:compact="compact"
@select-view="emit('update:activeOutputView', activeTab.id, $event)"
@select-result-mode="setRedisResultViewMode"
/>
<template v-if="activeElasticsearchRawBody">
<div class="mx-1 h-4 w-px bg-border" />
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { computed } from "vue";
import { BarChart3, ListChecks, MessageSquareText } from "@lucide/vue";
import { BarChart3, ListChecks, MessageSquareText, SquareTerminal } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import type { RedisResultViewMode } from "@/types/database";
type OutputView = "result" | "summary" | "explain" | "chart" | "messages" | "profile";
type PrimaryResultView = Exclude<OutputView, "explain" | "profile">;
@@ -15,32 +16,65 @@ const props = withDefaults(
canShowSummary: boolean;
canShowChart: boolean;
canShowMessages: boolean;
canShowRedisConsole?: boolean;
resultMode?: RedisResultViewMode;
messageCount?: number;
compact?: boolean;
}>(),
{ compact: false, messageCount: 0 },
{ compact: false, messageCount: 0, canShowRedisConsole: false, resultMode: "grid" },
);
const emit = defineEmits<{
selectView: [view: PrimaryResultView];
selectResultMode: [mode: RedisResultViewMode];
}>();
const { t } = useI18n();
const messagesTooltip = computed(() => (props.messageCount > 0 ? `${t("tabs.messages")} (${props.messageCount})` : t("tabs.messages")));
const effectiveResultMode = computed<RedisResultViewMode>(() => (props.canShowRedisConsole ? props.resultMode : "grid"));
function selectView(view: PrimaryResultView) {
if (props.activeView === view) return;
emit("selectView", view);
}
function selectResultMode(mode: RedisResultViewMode) {
if (props.activeView === "result" && effectiveResultMode.value === mode) return;
if (props.canShowRedisConsole && props.resultMode !== mode) emit("selectResultMode", mode);
if (props.activeView !== "result") emit("selectView", "result");
}
</script>
<template>
<div data-query-result-view-switcher class="flex shrink-0 items-center gap-1 px-1">
<Button size="sm" :variant="activeView === 'result' ? 'secondary' : 'ghost'" class="h-5 shrink-0 px-2 text-xs leading-none" :disabled="!canShowResult" :aria-pressed="activeView === 'result'" @click="selectView('result')">
<Button
size="sm"
:variant="activeView === 'result' && effectiveResultMode === 'grid' ? 'secondary' : 'ghost'"
class="h-5 shrink-0 px-2 text-xs leading-none"
:disabled="!canShowResult"
:aria-pressed="activeView === 'result' && effectiveResultMode === 'grid'"
@click="canShowRedisConsole ? selectResultMode('grid') : selectView('result')"
>
<span class="inline-flex h-4 items-center leading-none">{{ t("tabs.tableData") }}</span>
</Button>
<LightTooltip v-if="canShowRedisConsole" :text="t('redis.commandLine')" :disabled="!compact" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button
size="sm"
:variant="activeView === 'result' && effectiveResultMode === 'console' ? 'secondary' : 'ghost'"
class="h-5 shrink-0 text-xs leading-none"
:class="compact ? 'w-6 gap-0 px-0' : 'gap-1 px-2'"
:title="t('redis.commandLine')"
:aria-label="t('redis.commandLine')"
:aria-pressed="activeView === 'result' && effectiveResultMode === 'console'"
@click="selectResultMode('console')"
>
<SquareTerminal class="block h-3.5 w-3.5 self-center" />
<span v-if="!compact" class="inline-flex h-4 items-center leading-none">{{ t("redis.commandLine") }}</span>
</Button>
</LightTooltip>
<LightTooltip :text="t('tabs.executionSummary')" :disabled="!compact" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button
size="sm"
@@ -0,0 +1,81 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, reactive } from "vue";
import { createI18n } from "vue-i18n";
import { afterEach, describe, expect, it } from "vitest";
import QueryResultViewSwitcher from "@/components/layout/QueryResultViewSwitcher.vue";
import type { RedisResultViewMode, TabOutputView } from "@/types/database";
const mounted: Array<{ unmount: () => void; host: HTMLElement }> = [];
afterEach(() => {
for (const { unmount, host } of mounted.splice(0)) {
unmount();
host.remove();
}
});
async function mountSwitcher(canShowRedisConsole: boolean) {
const state = reactive<{ view: TabOutputView; mode?: RedisResultViewMode }>({ view: "result" });
const host = document.createElement("div");
document.body.appendChild(host);
const app = createApp(
defineComponent({
setup: () => () =>
h(QueryResultViewSwitcher, {
activeView: state.view,
canShowResult: true,
canShowSummary: false,
canShowChart: false,
canShowMessages: false,
canShowRedisConsole,
resultMode: state.mode,
onSelectView: (view: TabOutputView) => {
state.view = view;
},
onSelectResultMode: (mode: RedisResultViewMode) => {
state.mode = mode;
},
}),
}),
);
app.use(
createI18n({
legacy: false,
locale: "en",
messages: { en: { tabs: { tableData: "Table Data", executionSummary: "Summary", messages: "Messages" }, chart: { title: "Chart" }, redis: { commandLine: "Command line" } } },
}),
);
app.mount(host);
mounted.push({ unmount: () => app.unmount(), host });
await nextTick();
return { host, state };
}
describe("QueryResultViewSwitcher Redis mode", () => {
it("defaults Redis results to the grid and switches through the existing toolbar contract", async () => {
const { host, state } = await mountSwitcher(true);
const grid = [...host.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.includes("Table Data"));
const console = host.querySelector<HTMLButtonElement>('button[aria-label="Command line"]');
expect(state.mode).toBeUndefined();
expect(grid?.getAttribute("aria-pressed")).toBe("true");
expect(console?.getAttribute("aria-pressed")).toBe("false");
console?.click();
await nextTick();
expect(state.mode).toBe("console");
expect(state.view).toBe("result");
expect(console?.getAttribute("aria-pressed")).toBe("true");
grid?.click();
await nextTick();
expect(state.mode).toBe("grid");
expect(grid?.getAttribute("aria-pressed")).toBe("true");
});
it("does not expose the Redis-only mode on generic result toolbars", async () => {
const { host } = await mountSwitcher(false);
expect(host.querySelector('button[aria-label="Command line"]')).toBeNull();
});
});
@@ -2026,7 +2026,7 @@ async function runRedisCommand(command: string) {
const entryId = appendCommandHistory({ prompt, command, output: "", error: false });
try {
const result = await api.redisExecuteCommand(props.connectionId, commandDb.value, command, !props.blockDangerousRedisCommands);
updateCommandHistory(entryId, { output: formatRedisConsoleValue(result.value), error: false });
updateCommandHistory(entryId, { output: formatRedisConsoleValue(result.value, command), error: false });
// The db this command ran on — capture before nextRedisCommandDb() advances it.
const executedDb = commandDb.value;
commandDb.value = nextRedisCommandDb(commandDb.value, command, result.value);
@@ -0,0 +1,37 @@
// @vitest-environment happy-dom
import { createApp, h, nextTick } from "vue";
import { createI18n } from "vue-i18n";
import { afterEach, describe, expect, it } from "vitest";
import RedisQueryConsoleOutput from "@/components/redis/RedisQueryConsoleOutput.vue";
import { redisCommandResultToQueryResult } from "@/lib/redis/redisQueryResult";
import type { QueryResult } from "@/types/database";
const mounted: Array<{ unmount: () => void; host: HTMLElement }> = [];
afterEach(() => {
for (const { unmount, host } of mounted.splice(0)) {
unmount();
host.remove();
}
});
describe("RedisQueryConsoleOutput", () => {
it("renders every command result and keeps failures visually distinct", async () => {
const results: QueryResult[] = [
{ ...redisCommandResultToQueryResult("PONG", 1, "PING"), sourceStatement: "PING" },
{ ...redisCommandResultToQueryResult(["one", "two"], 1, "LRANGE items 0 -1"), sourceStatement: "LRANGE items 0 -1" },
{ columns: ["Error"], rows: [["ERR unknown command"]], affected_rows: 0, execution_time_ms: 0, execution_error: true, sourceStatement: "NOPE" },
];
const host = document.createElement("div");
document.body.appendChild(host);
const app = createApp({ render: () => h(RedisQueryConsoleOutput, { results }) });
app.use(createI18n({ legacy: false, locale: "en", messages: { en: { redis: { commandLine: "Command line", commandEmptyResult: "No output" } } } }));
app.mount(host);
mounted.push({ unmount: () => app.unmount(), host });
await nextTick();
expect([...host.querySelectorAll("div.flex.min-w-0")].map((node) => node.textContent?.trim())).toEqual(["redis>PING", "redis>LRANGE items 0 -1", "redis>NOPE"]);
expect([...host.querySelectorAll("pre")].map((node) => node.textContent)).toEqual(["PONG", '1) "one"\n2) "two"', "(error) ERR unknown command"]);
expect(host.querySelectorAll("pre")[2]?.className).toContain("text-[#ff6b6b]");
});
});
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { computed } from "vue";
import { Loader2 } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { redisQueryResultsToConsoleEntries } from "@/lib/redis/redisQueryResult";
import type { QueryResult } from "@/types/database";
const props = withDefaults(
defineProps<{
result?: QueryResult;
results?: QueryResult[];
loading?: boolean;
}>(),
{ loading: false },
);
const { t } = useI18n();
const entries = computed(() => redisQueryResultsToConsoleEntries(props.results?.length ? props.results : props.result ? [props.result] : []));
</script>
<template>
<div data-redis-query-console role="region" class="dbx-editor-font-family flex min-h-0 flex-1 flex-col bg-[#171b21] text-[13px] leading-5 text-slate-200" :aria-label="t('redis.commandLine')">
<div class="min-h-0 flex-1 overflow-auto px-4 py-4">
<div v-for="(entry, index) in entries" :key="`${index}:${entry.command}`" class="mb-4 last:mb-0">
<div v-if="entry.command" class="flex min-w-0 items-start gap-2 whitespace-pre-wrap break-words">
<span class="shrink-0 text-[#d7ba7d]">redis&gt;</span>
<span class="min-w-0 text-slate-200">{{ entry.command }}</span>
</div>
<pre data-native-clipboard class="m-0 whitespace-pre-wrap break-words" :class="entry.error ? 'text-[#ff6b6b]' : 'text-slate-300'">{{ entry.output }}</pre>
</div>
<div v-if="loading && entries.length === 0" class="flex h-full items-center justify-center text-slate-400">
<Loader2 class="h-4 w-4 animate-spin" />
</div>
<div v-else-if="entries.length === 0" class="flex h-full items-center justify-center text-slate-400">
{{ t("redis.commandEmptyResult") }}
</div>
</div>
</div>
</template>
@@ -1567,6 +1567,30 @@ SELECT @value AS Message;`;
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { tabId: "tab-1", skipRedisSafetyCheck: false });
});
it("keeps an explicitly selected Redis console visible for multi-command execution", async () => {
const sql = "GET user:1\nDBSIZE";
const activeTab = ref<QueryTab | undefined>({ ...queryTab("0"), sql, uiState: { redisResultViewMode: "console" } });
const activeConnection = ref<ConnectionConfig | undefined>(connection("redis"));
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("summary");
const queryStore = useQueryStore();
useSettingsStore().editorSettings.multiStatementDefaultView = "summary";
vi.spyOn(queryStore, "executeCurrentSql").mockImplementation(async () => {
if (activeTab.value) activeTab.value.result = { columns: ["result"], rows: [["value"]], affected_rows: 0, execution_time_ms: 1, redis_console_output: "value" };
});
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("distinguishes read-only and mutating Meilisearch REST requests", () => {
expect(isDangerousSql("GET /health", "meilisearch")).toBe(false);
expect(isDangerousSql('POST /indexes/movies/documents/fetch\n{"limit":10}', "meilisearch")).toBe(false);
@@ -424,10 +424,11 @@ export function useSqlExecution(deps: {
return;
}
const statementCount = splitSqlStatementRanges(sql, executionDatabaseType, sqlStatementParameterOptionsForCompatibility(executionDatabaseType, executionDatabaseType === "opengauss" ? connectionStore.databaseCompatibilityMode(tab.connectionId, tab.database) : undefined)).length;
const redisConsoleSelected = executionDatabaseType === "redis" && tab.uiState?.redisResultViewMode === "console";
// Output-view switching belongs to the tab the user is looking at — both
// when the query starts and when it finishes.
if (deps.activeTab.value?.id === executionTabId) {
deps.activeOutputView.value = statementCount > 1 ? settingsStore.editorSettings.multiStatementDefaultView : "result";
deps.activeOutputView.value = redisConsoleSelected ? "result" : statementCount > 1 ? settingsStore.editorSettings.multiStatementDefaultView : "result";
}
const connName = executionConnection?.name || "";
const start = Date.now();
@@ -22,9 +22,9 @@ function roundTrip(tabs: QueryTab[]) {
describe("openTabsPersistence originalSql round-trip", () => {
it("preserves per-tab output view state across a round-trip", () => {
const [restored] = roundTrip([queryTab({ uiState: { activeOutputView: "chart", resultPaneOpen: false } })]);
const [restored] = roundTrip([queryTab({ uiState: { activeOutputView: "chart", redisResultViewMode: "console", resultPaneOpen: false } })]);
expect(restored.uiState).toEqual({ activeOutputView: "chart", resultPaneOpen: false });
expect(restored.uiState).toEqual({ activeOutputView: "chart", redisResultViewMode: "console", resultPaneOpen: false });
});
it("preserves namespaced special-page state across a round-trip", () => {
@@ -17,6 +17,19 @@ function backend(name: ResultCacheBackend["name"], overrides: Partial<ResultCach
}
describe("tab result cache statement execution metadata", () => {
it("retains Redis console output through cache restore", () => {
const result = {
columns: ["result"],
rows: [["PONG"]],
affected_rows: 0,
execution_time_ms: 1,
sourceStatement: "PING",
redis_console_output: "PONG",
};
expect(decodeTabResultSnapshot(encodeTabResultSnapshot({ result, cachedAt: 1 }))?.result).toMatchObject({ sourceStatement: "PING", redis_console_output: "PONG" });
});
it("retains timing details for active, array and historical results through cache restore", async () => {
const { buildTabResultSnapshot } = await import("@/lib/tabs/tabResultCache");
const timing = { query_timings_ms: { agent_total: 40, pool_acquire: 20, pool_release: 2 }, client_prepare_ms: 3, client_result_ms: 4, timing_page_count: 2 };
@@ -97,4 +97,10 @@ describe("tabUiState", () => {
page: { query: { Panel: { count: 3 } } },
});
});
it("persists only supported Redis result view modes", () => {
expect(sanitizeTabUiState({ redisResultViewMode: "console" })).toEqual({ redisResultViewMode: "console" });
expect(sanitizeTabUiState({ redisResultViewMode: "grid" })).toEqual({ redisResultViewMode: "grid" });
expect(sanitizeTabUiState({ redisResultViewMode: "invalid" })).toBeUndefined();
});
});
@@ -152,9 +152,11 @@ const TAB_OUTPUT_VIEWS = new Set<TabOutputView>(["result", "summary", "explain",
function restoredTabUiState(tab: SavedOpenTab): QueryTab["uiState"] {
const activeOutputView = tab.uiState?.activeOutputView;
const redisResultViewMode = tab.uiState?.redisResultViewMode;
const resultPaneOpen = tab.uiState?.resultPaneOpen;
const restored: NonNullable<QueryTab["uiState"]> = {};
if (activeOutputView && TAB_OUTPUT_VIEWS.has(activeOutputView)) restored.activeOutputView = activeOutputView;
if (redisResultViewMode === "grid" || redisResultViewMode === "console") restored.redisResultViewMode = redisResultViewMode;
if (typeof resultPaneOpen === "boolean") restored.resultPaneOpen = resultPaneOpen;
if (tab.uiState?.page) {
const sanitized = sanitizeTabUiState({ page: tab.uiState.page });
+65 -1
View File
@@ -1,7 +1,52 @@
import { describe, expect, it } from "vitest";
import { redisCommandResultToQueryResult } from "@/lib/redis/redisQueryResult";
import { redisCommandResultToQueryResult, redisQueryResultsToConsoleEntries } from "@/lib/redis/redisQueryResult";
import { formatRedisConsoleError, formatRedisConsoleValue } from "@/lib/redis/redisValuePresentation";
import type { QueryResult } from "@/types/database";
describe("formatRedisConsoleValue", () => {
it("formats scalar, nil, integer, double, boolean and bulk-string replies", () => {
expect(formatRedisConsoleValue(null)).toBe("(nil)");
expect(formatRedisConsoleValue(42)).toBe("(integer) 42");
expect(formatRedisConsoleValue(1.5)).toBe("(double) 1.5");
expect(formatRedisConsoleValue(true)).toBe("(true)");
expect(formatRedisConsoleValue("hello")).toBe("hello");
expect(formatRedisConsoleValue("")).toBe('""');
});
it("numbers ordinary and nested arrays without collapsing nested values into JSON", () => {
expect(formatRedisConsoleValue(["one", 2, null, ["nested", false]])).toBe(['1) "one"', "2) (integer) 2", "3) (nil)", '4) 1) "nested"', " 2) (false)"].join("\n"));
});
it("renders HGETALL, WITHSCORES and RESP3 map replies as readable pairs", () => {
expect(formatRedisConsoleValue(["name", "alice", "age", "30"], "HGETALL profile")).toBe(['1) "name" => "alice"', '2) "age" => "30"'].join("\n"));
expect(formatRedisConsoleValue(["alice", "1.5", "bob", "2"], "ZRANGE scores 0 -1 WITHSCORES")).toBe(['1) "alice" => "1.5"', '2) "bob" => "2"'].join("\n"));
expect(
formatRedisConsoleValue([
{ key: "name", value: "alice" },
{ key: "visits", value: 3 },
]),
).toBe(['1) "name" => "alice"', '2) "visits" => (integer) 3'].join("\n"));
});
it("keeps INFO text readable and prefixes errors once", () => {
expect(formatRedisConsoleValue("# Server\nredis_version:7.4.2")).toBe("# Server\nredis_version:7.4.2");
expect(formatRedisConsoleError("ERR wrong number of arguments")).toBe("(error) ERR wrong number of arguments");
expect(formatRedisConsoleError("(error) already formatted")).toBe("(error) already formatted");
});
});
describe("redisCommandResultToQueryResult", () => {
it("keeps the existing scalar grid shape as the default while retaining console output", () => {
const result = redisCommandResultToQueryResult(7, 4.6, "DBSIZE");
expect(result).toMatchObject({
columns: ["result"],
rows: [["7"]],
affected_rows: 0,
execution_time_ms: 5,
redis_console_output: "(integer) 7",
});
});
it("pairs member/score rows for ZREVRANGE ... WITHSCORES instead of one row per array element", () => {
const flat = ["carol", "300", "bob", "200", "alice", "100"];
const result = redisCommandResultToQueryResult(flat, 5, "ZREVRANGE issue7229_repro:zset 0 -1 WITHSCORES");
@@ -71,4 +116,23 @@ describe("redisCommandResultToQueryResult", () => {
["bob", "2"],
]);
});
it("builds ordered console entries for multiple commands and errors", () => {
const first = { ...redisCommandResultToQueryResult("PONG", 1, "PING"), sourceStatement: "PING" };
const second = { ...redisCommandResultToQueryResult(["a", "b"], 2, "LRANGE items 0 -1"), sourceStatement: "LRANGE items 0 -1" };
const failed: QueryResult = {
columns: ["Error"],
rows: [["ERR invalid command"]],
affected_rows: 0,
execution_time_ms: 0,
execution_error: true,
sourceStatement: "BROKEN",
};
expect(redisQueryResultsToConsoleEntries([first, second, failed])).toEqual([
{ command: "PING", output: "PONG", error: false },
{ command: "LRANGE items 0 -1", output: ['1) "a"', '2) "b"'].join("\n"), error: false },
{ command: "BROKEN", output: "(error) ERR invalid command", error: true },
]);
});
});
+27 -25
View File
@@ -1,29 +1,26 @@
import type { QueryResult } from "@/types/database";
import { formatRedisCommandResult } from "@/lib/redis/redisValuePresentation";
import { formatRedisCommandResult, formatRedisConsoleError, formatRedisConsoleValue, redisCommandResultPairKind } from "@/lib/redis/redisValuePresentation";
const KEY_VALUE_COMMANDS = new Set(["HGETALL"]);
// Sorted-set commands whose WITHSCORES modifier returns a flat member/score array.
const WITHSCORES_COMMANDS = new Set(["ZRANGE", "ZREVRANGE", "ZRANGEBYSCORE", "ZREVRANGEBYSCORE", "ZRANDMEMBER", "ZDIFF", "ZINTER", "ZUNION"]);
const WITHSCORES_MODIFIER = /\bWITHSCORES\b/i;
function commandHead(command: string): string {
return command.trim().split(/\s+/, 1)[0]?.toUpperCase() ?? "";
export interface RedisQueryConsoleEntry {
command: string;
output: string;
error: boolean;
}
function isKeyValueCommand(command: string): boolean {
return KEY_VALUE_COMMANDS.has(commandHead(command));
}
// ZRANGE/ZREVRANGE/ZRANGEBYSCORE/ZDIFF/ZINTER/ZUNION/... with a WITHSCORES modifier
// return a flat [member1, score1, member2, score2, ...] array — pair it up instead of
// dumping each element as its own row. Gate on the command head so a key or argument
// that merely contains the token WITHSCORES cannot trigger pairing.
function hasWithScoresModifier(command: string): boolean {
return WITHSCORES_COMMANDS.has(commandHead(command)) && WITHSCORES_MODIFIER.test(command);
export function redisQueryResultsToConsoleEntries(results: readonly QueryResult[]): RedisQueryConsoleEntry[] {
return results.flatMap((result) => {
const error = result.execution_error === true;
const output = error ? formatRedisConsoleError(result.rows[0]?.[0]) : result.redis_console_output;
if (output === undefined) return [];
return [{ command: result.sourceStatement?.trim() ?? "", output, error }];
});
}
export function redisCommandResultToQueryResult(value: unknown, elapsedMs: number, command?: string): QueryResult {
if (Array.isArray(value) && command && isKeyValueCommand(command)) {
const execution_time_ms = Math.max(0, Math.round(elapsedMs));
const redis_console_output = formatRedisConsoleValue(value, command);
const pairKind = redisCommandResultPairKind(command);
if (Array.isArray(value) && pairKind === "field-value") {
const rows: (string | number | boolean | null)[][] = [];
for (let i = 0; i + 1 < value.length; i += 2) {
rows.push([formatRedisCommandResult(value[i]), formatRedisCommandResult(value[i + 1])]);
@@ -32,10 +29,11 @@ export function redisCommandResultToQueryResult(value: unknown, elapsedMs: numbe
columns: ["field", "value"],
rows,
affected_rows: value.length / 2,
execution_time_ms: Math.max(0, Math.round(elapsedMs)),
execution_time_ms,
redis_console_output,
};
}
if (Array.isArray(value) && command && hasWithScoresModifier(command)) {
if (Array.isArray(value) && pairKind === "member-score") {
const rows: (string | number | boolean | null)[][] = [];
for (let i = 0; i + 1 < value.length; i += 2) {
rows.push([formatRedisCommandResult(value[i]), formatRedisCommandResult(value[i + 1])]);
@@ -44,7 +42,8 @@ export function redisCommandResultToQueryResult(value: unknown, elapsedMs: numbe
columns: ["member", "score"],
rows,
affected_rows: rows.length,
execution_time_ms: Math.max(0, Math.round(elapsedMs)),
execution_time_ms,
redis_console_output,
};
}
// INFO commands in cluster mode → [[node_addr, info_text], ...] pairs.
@@ -55,7 +54,8 @@ export function redisCommandResultToQueryResult(value: unknown, elapsedMs: numbe
columns: ["(index)", "value"],
rows,
affected_rows: value.length,
execution_time_ms: Math.max(0, Math.round(elapsedMs)),
execution_time_ms,
redis_console_output,
};
}
if (Array.isArray(value)) {
@@ -64,13 +64,15 @@ export function redisCommandResultToQueryResult(value: unknown, elapsedMs: numbe
columns: ["(index)", "value"],
rows,
affected_rows: value.length,
execution_time_ms: Math.max(0, Math.round(elapsedMs)),
execution_time_ms,
redis_console_output,
};
}
return {
columns: ["result"],
rows: [[formatRedisCommandResult(value)]],
affected_rows: 0,
execution_time_ms: Math.max(0, Math.round(elapsedMs)),
execution_time_ms,
redis_console_output,
};
}
@@ -207,6 +207,24 @@ export function formatRedisCommandResult(value: unknown): string {
return JSON.stringify(value, null, 2);
}
export type RedisCommandResultPairKind = "field-value" | "member-score";
const REDIS_KEY_VALUE_COMMANDS = new Set(["HGETALL"]);
// Sorted-set commands whose WITHSCORES modifier returns member/score pairs.
const REDIS_WITHSCORES_COMMANDS = new Set(["ZRANGE", "ZREVRANGE", "ZRANGEBYSCORE", "ZREVRANGEBYSCORE", "ZRANDMEMBER", "ZDIFF", "ZINTER", "ZUNION"]);
const REDIS_WITHSCORES_MODIFIER = /\bWITHSCORES\b/i;
function redisCommandHead(command: string | undefined): string {
return command?.trim().split(/\s+/, 1)[0]?.toUpperCase() ?? "";
}
export function redisCommandResultPairKind(command: string | undefined): RedisCommandResultPairKind | undefined {
const head = redisCommandHead(command);
if (REDIS_KEY_VALUE_COMMANDS.has(head)) return "field-value";
if (REDIS_WITHSCORES_COMMANDS.has(head) && command && REDIS_WITHSCORES_MODIFIER.test(command)) return "member-score";
return undefined;
}
/** RedisJSON source text stays out of JavaScript's numeric representation. */
export function redisJsonValueText(value: { value: string }): string {
return value.value;
@@ -222,6 +240,84 @@ function isRedisClusterInfoValue(value: unknown): value is [string, string][] {
return Array.isArray(value) && value.length > 0 && (value as unknown[]).every((item) => Array.isArray(item) && item.length === 2 && typeof item[0] === "string" && typeof item[1] === "string" && item[1].startsWith("# "));
}
interface RedisMapEntry {
key: unknown;
value: unknown;
}
function isRedisMapEntry(value: unknown): value is RedisMapEntry {
return typeof value === "object" && value !== null && !Array.isArray(value) && Object.prototype.hasOwnProperty.call(value, "key") && Object.prototype.hasOwnProperty.call(value, "value");
}
function isRedisPair(value: unknown): value is [unknown, unknown] {
return Array.isArray(value) && value.length === 2;
}
function quoteRedisString(value: string): string {
return JSON.stringify(formatRedisStringValue(value)) ?? '""';
}
function formatRedisConsoleScalar(value: unknown, nested: boolean): string {
if (value == null) return "(nil)";
if (typeof value === "string") {
const text = formatRedisStringValue(value);
if (nested) return quoteRedisString(value);
return text.length > 0 ? text : '""';
}
if (typeof value === "number") return Number.isInteger(value) ? `(integer) ${value}` : `(double) ${value}`;
if (typeof value === "bigint") return `(integer) ${value}`;
if (typeof value === "boolean") return value ? "(true)" : "(false)";
return String(value);
}
function prefixRedisConsoleLines(prefix: string, lines: string[]): string[] {
if (lines.length === 0) return [prefix.trimEnd()];
const padding = " ".repeat(prefix.length);
return [`${prefix}${lines[0]}`, ...lines.slice(1).map((line) => `${padding}${line}`)];
}
function formatRedisConsoleInline(value: unknown): string {
if (typeof value === "string") return quoteRedisString(value);
if (value == null || typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") return formatRedisConsoleScalar(value, true);
try {
return JSON.stringify(value) ?? String(value);
} catch {
return String(value);
}
}
function redisConsolePairs(value: unknown[], command: string | undefined): Array<[unknown, unknown]> | undefined {
if (value.length > 0 && value.every(isRedisMapEntry)) return (value as RedisMapEntry[]).map((entry): [unknown, unknown] => [entry.key, entry.value]);
if (!redisCommandResultPairKind(command)) return undefined;
if (value.length > 0 && value.every(isRedisPair)) return (value as Array<[unknown, unknown]>).map((entry): [unknown, unknown] => [entry[0], entry[1]]);
if (value.length % 2 !== 0) return undefined;
const pairs: Array<[unknown, unknown]> = [];
for (let index = 0; index < value.length; index += 2) pairs.push([value[index], value[index + 1]]);
return pairs;
}
function formatRedisConsolePairs(pairs: Array<[unknown, unknown]>): string[] {
if (pairs.length === 0) return ["(empty array)"];
return pairs.flatMap(([key, value], index) => prefixRedisConsoleLines(`${index + 1}) ${formatRedisConsoleInline(key)} => `, formatRedisConsoleLines(value, undefined, true)));
}
function formatRedisConsoleLines(value: unknown, command: string | undefined, nested: boolean): string[] {
if (Array.isArray(value)) {
if (value.length === 0) return ["(empty array)"];
const pairs = redisConsolePairs(value, command);
if (pairs) return formatRedisConsolePairs(pairs);
return value.flatMap((entry, index) => prefixRedisConsoleLines(`${index + 1}) `, formatRedisConsoleLines(entry, undefined, true)));
}
if (typeof value === "object" && value !== null) {
try {
return (JSON.stringify(value, null, 2) ?? String(value)).split("\n");
} catch {
return [String(value)];
}
}
return [formatRedisConsoleScalar(value, nested)];
}
/**
* Format a Redis command result for the **command console terminal** (RedisKeyBrowser.vue).
* Unlike `formatRedisCommandResult` (used by the query result table UI), this function
@@ -230,14 +326,21 @@ function isRedisClusterInfoValue(value: unknown): value is [string, string][] {
*
* - Cluster INFO `[[addr, infoText], ...]` → `"{addr}\n{infoText}"` per node, joined by newlines.
* - Plain string → passthrough (handles single-node INFO text correctly).
* - Everything else → JSON.stringify (arrays, objects, etc.).
* - Arrays use redis-cli-style numbered items, including nested arrays.
* - RESP3 maps plus HGETALL/WITHSCORES responses use readable key/value pairs.
* - Integers, doubles, booleans and nil retain visible Redis-style type markers.
*/
export function formatRedisConsoleValue(value: unknown): string {
export function formatRedisConsoleValue(value: unknown, command?: string): string {
if (isRedisClusterInfoValue(value)) {
return value.map(([addr, info]) => `${addr}\n${info}`).join("\n");
}
if (typeof value === "string") return formatRedisStringValue(value);
return JSON.stringify(value, null, 2);
return formatRedisConsoleLines(value, command, false).join("\n");
}
export function formatRedisConsoleError(message: unknown): string {
const text = formatRedisStringValue(message).trim();
if (/^\(error\)(?:\s|$)/i.test(text)) return text;
return `(error) ${text || "Unknown Redis error"}`;
}
export function parseRedisJsonDetail(value: unknown): RedisJsonDetail | null {
@@ -67,6 +67,7 @@ interface ColumnarQueryResult {
rowCount: number;
mongo_documents?: unknown[];
mongo_copy_documents?: unknown[];
redis_console_output?: string;
affected_rows: number;
execution_time_ms: number;
server_execute_time_us?: number;
@@ -361,6 +362,7 @@ function stripSessionIds(result: QueryResult | undefined): QueryResult | undefin
rows: result.rows.map((row) => [...row]),
mongo_documents: result.mongo_documents ? clonePlain(result.mongo_documents) : undefined,
mongo_copy_documents: result.mongo_copy_documents ? clonePlain(result.mongo_copy_documents) : undefined,
redis_console_output: result.redis_console_output,
affected_rows: result.affected_rows,
execution_time_ms: result.execution_time_ms,
server_execute_time_us: result.server_execute_time_us,
@@ -424,6 +426,7 @@ function toColumnarResult(result: QueryResult | undefined): ColumnarQueryResult
rowCount,
mongo_documents: result.mongo_documents ? clonePlain(result.mongo_documents) : undefined,
mongo_copy_documents: result.mongo_copy_documents ? clonePlain(result.mongo_copy_documents) : undefined,
redis_console_output: result.redis_console_output,
affected_rows: result.affected_rows,
execution_time_ms: result.execution_time_ms,
server_execute_time_us: result.server_execute_time_us,
@@ -459,6 +462,7 @@ function fromColumnarResult(result: ColumnarQueryResult | undefined): QueryResul
rows,
mongo_documents: result.mongo_documents ? clonePlain(result.mongo_documents) : undefined,
mongo_copy_documents: result.mongo_copy_documents ? clonePlain(result.mongo_copy_documents) : undefined,
redis_console_output: result.redis_console_output,
affected_rows: result.affected_rows,
execution_time_ms: result.execution_time_ms,
server_execute_time_us: result.server_execute_time_us,
+1
View File
@@ -105,6 +105,7 @@ export function sanitizeTabUiState(value: unknown): TabUiState | undefined {
const state = value as TabUiState;
const normalized: TabUiState = {};
if (state.activeOutputView && ["result", "summary", "explain", "chart", "messages", "profile"].includes(state.activeOutputView)) normalized.activeOutputView = state.activeOutputView;
if (state.redisResultViewMode === "grid" || state.redisResultViewMode === "console") normalized.redisResultViewMode = state.redisResultViewMode;
if (typeof state.resultPaneOpen === "boolean") normalized.resultPaneOpen = state.resultPaneOpen;
const sanitizedPage = sanitizeTabPageUiState(state.page);
if (sanitizedPage) {
+14 -1
View File
@@ -61,6 +61,7 @@ import { refreshLoadedMongoIndexes } from "@/lib/mongo/mongoIndexMetadata";
import { redisCommandResultToQueryResult } from "@/lib/redis/redisQueryResult";
import { nextRedisCommandDb } from "@/lib/redis/redisCommandSession";
import { isRedisMutatingCommand } from "@/lib/redis/redisCommandTable";
import { formatRedisConsoleValue } from "@/lib/redis/redisValuePresentation";
import { usesAgentCursorForQuery } from "@/lib/database/databaseDriverManifest";
import { connectionIsDorisFamilyCatalogCapable, defaultAutoCommitForDbType, supportsClearableQuerySchema, supportsTransaction, usesOracleStickyTransactionState, usesProvenReadOnlyStickyTransactionState } from "@/lib/database/databaseFeatureSupport";
import { canInsertTableRows, canUseKeylessRowPredicate, DBX_ROWID_COLUMN, editablePrimaryKeys, shouldIncludeSyntheticRowId, usesSyntheticRowIdKey } from "@/lib/table/tableEditing";
@@ -388,6 +389,7 @@ function releaseResultObjectPayload(result: QueryResult): void {
result.mongo_copy_documents = undefined;
result.large_value_cells = undefined;
result.elasticsearch_raw_body = undefined;
result.redis_console_output = undefined;
result.messages = undefined;
result.error = undefined;
result.sourceLabel = undefined;
@@ -6884,7 +6886,18 @@ export const useQueryStore = defineStore("query", () => {
(rows) => {
const current = findExecutionTab(id);
if (current?.executionId !== executionId) return;
current.result = markQueryResultRowsRaw(annotateQueryResultSource({ columns: ["MONITOR"], rows: rows.map((message) => [message]), affected_rows: 0, execution_time_ms: performance.now() - startedAt }, "MONITOR"));
current.result = markQueryResultRowsRaw(
annotateQueryResultSource(
{
columns: ["MONITOR"],
rows: rows.map((message) => [message]),
affected_rows: 0,
execution_time_ms: performance.now() - startedAt,
redis_console_output: rows.map((message) => formatRedisConsoleValue(message)).join("\n"),
},
"MONITOR",
),
);
current.results = undefined;
current.activeResultIndex = undefined;
current.queryEditabilityReason = undefined;
+6
View File
@@ -1310,6 +1310,8 @@ export interface QueryResult {
* this carries the raw HTTP response body so the UI can toggle between
* the tabular view and the original JSON. */
elasticsearch_raw_body?: string;
/** Preformatted Redis command output retained alongside the default grid rows. */
redis_console_output?: string;
sourceLabel?: string;
/** 结果集来源的库名 / schema(与 sourceLabel 同时写入),供结果集页签按设置决定是否展示。 */
sourceQualifier?: string;
@@ -1822,11 +1824,15 @@ export interface QueryPageJumpProgress {
export type TabOutputView = "result" | "summary" | "explain" | "chart" | "messages" | "profile";
export type RedisResultViewMode = "grid" | "console";
export type TabPageUiState = Record<string, unknown>;
/** UI-only state that must survive an inactive tab's component being unmounted. */
export interface TabUiState {
activeOutputView?: TabOutputView;
/** Redis query results default to grid; a per-tab override selects command-line output. */
redisResultViewMode?: RedisResultViewMode;
resultPaneOpen?: boolean;
/** Small JSON-compatible snapshots owned by special-page components. */
page?: Record<string, TabPageUiState>;