feat(elasticsearch): render REST JSON responses
This commit is contained in:
parent
512f46a835
commit
24c63b6012
|
|
@ -0,0 +1,120 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Code2, Copy } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { parseJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat";
|
||||
import { createShikiJsonHighlighter, type JsonHighlighter } from "@/lib/common/shikiJsonHighlighter";
|
||||
import JsonTree from "./JsonTree.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
status: number;
|
||||
body: string;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const { isDark } = useTheme();
|
||||
const responseView = ref<"raw" | "json">("json");
|
||||
const jsonTreeRef = ref<{ refresh: () => void }>();
|
||||
const jsonHighlighter = ref<JsonHighlighter>();
|
||||
|
||||
const parsedBody = computed(() => {
|
||||
try {
|
||||
return { valid: true, value: parseJsonPreservingLargeNumbers(props.body) };
|
||||
} catch {
|
||||
return { valid: false, value: null };
|
||||
}
|
||||
});
|
||||
|
||||
const statusClass = computed(() => {
|
||||
if (props.status >= 500) return "border-destructive/40 bg-destructive/10 text-destructive";
|
||||
if (props.status >= 400) return "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300";
|
||||
if (props.status >= 300) return "border-sky-500/40 bg-sky-500/10 text-sky-700 dark:text-sky-300";
|
||||
return "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
|
||||
});
|
||||
|
||||
const statusLabel = computed(() => `HTTP ${props.status}`);
|
||||
const jsonAppearance = computed(() => (isDark.value ? "dark" : "light"));
|
||||
|
||||
watch(
|
||||
() => props.body,
|
||||
() => {
|
||||
responseView.value = parsedBody.value.valid ? "json" : "raw";
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(responseView, (view) => {
|
||||
if (view === "json") void nextTick(() => jsonTreeRef.value?.refresh());
|
||||
});
|
||||
|
||||
async function copyResponse() {
|
||||
try {
|
||||
await copyToClipboard(props.body);
|
||||
toast(t("grid.copied"), 2000);
|
||||
} catch (error: any) {
|
||||
toast(t("grid.copyFailed", { message: error?.message || String(error) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function highlightJson(value: string): string {
|
||||
return jsonHighlighter.value?.(value, jsonAppearance.value) ?? escapeHtml(value);
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void createShikiJsonHighlighter({ appearance: () => jsonAppearance.value })
|
||||
.then((highlight) => {
|
||||
jsonHighlighter.value = highlight;
|
||||
})
|
||||
.catch(() => {
|
||||
jsonHighlighter.value = undefined;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section data-elasticsearch-json-response-root class="flex h-full min-h-0 flex-col bg-background" :aria-label="t('redis.jsonView')">
|
||||
<header class="flex min-h-11 shrink-0 items-center gap-2 border-b bg-muted/25 px-3 py-1.5 text-xs">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border bg-background text-muted-foreground shadow-sm" aria-hidden="true">
|
||||
<Code2 class="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<div class="inline-flex h-7 items-center rounded-md border bg-muted/45 p-0.5">
|
||||
<button type="button" class="h-6 rounded-[4px] px-2 text-xs transition-colors" :class="responseView === 'raw' ? 'bg-background font-medium text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'" :aria-pressed="responseView === 'raw'" @click="responseView = 'raw'">
|
||||
{{ t("redis.rawContent") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-6 rounded-[4px] px-2 text-xs transition-colors"
|
||||
:class="responseView === 'json' ? 'bg-background font-medium text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||||
:aria-pressed="responseView === 'json'"
|
||||
:disabled="!parsedBody.valid"
|
||||
@click="responseView = 'json'"
|
||||
>
|
||||
{{ t("redis.jsonView") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span class="shrink-0 rounded-full border px-2 py-0.5 font-mono text-[11px] font-medium tabular-nums" :class="statusClass" role="status" :aria-label="statusLabel">
|
||||
{{ statusLabel }}
|
||||
</span>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" :title="t('grid.copyJson')" :aria-label="t('grid.copyJson')" @click="copyResponse">
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</header>
|
||||
<div class="min-h-0 flex-1 overflow-hidden bg-background p-4">
|
||||
<pre v-show="responseView === 'raw' || !parsedBody.valid" class="m-0 h-full overflow-auto bg-transparent p-0 font-mono text-sm leading-6 whitespace-pre-wrap break-words">{{ body }}</pre>
|
||||
<div v-if="parsedBody.valid" v-show="responseView === 'json'" class="h-full min-h-0">
|
||||
<JsonTree ref="jsonTreeRef" :value="parsedBody.value" :highlight-json="highlightJson" :virtualized="true" class="dbx-editor-font-family text-sm leading-6" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,317 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, defineComponent, h, nextTick, ref, shallowRef, watch, type VNodeChild } from "vue";
|
||||
import { DynamicScroller, DynamicScrollerItem } from "vue-virtual-scroller";
|
||||
import { ChevronDown, ChevronRight } from "@lucide/vue";
|
||||
import { createJsonTreeRoot, getJsonTreeChildren, getVisibleJsonTreeNodes, isJsonTreeContainer, isJsonTreeInitiallyExpanded, jsonTreeContainerKind, jsonTreeContainerSummary, type JsonTreeNode } from "@/lib/common/jsonTree";
|
||||
import { isLosslessJsonNumber } from "@/lib/common/safeJsonFormat";
|
||||
|
||||
defineOptions({ name: "JsonTree" });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
value: unknown;
|
||||
wordWrap?: boolean;
|
||||
/** Optional Shiki-style inline highlighter retained for Redis value views. */
|
||||
highlightJson?: (json: string) => string;
|
||||
/** Number of expanded container levels, with the root counted as level one. */
|
||||
initialExpandedDepth?: number;
|
||||
/** Render only viewport rows for large, scrollable JSON responses. */
|
||||
virtualized?: boolean;
|
||||
}>(),
|
||||
{
|
||||
wordWrap: true,
|
||||
highlightJson: undefined,
|
||||
initialExpandedDepth: Number.POSITIVE_INFINITY,
|
||||
virtualized: false,
|
||||
},
|
||||
);
|
||||
|
||||
type ExpansionMode = "default" | "all" | "none";
|
||||
type DynamicScrollerHandle = {
|
||||
forceUpdate: (clear?: boolean) => void;
|
||||
scrollToPosition?: (position: number) => void;
|
||||
};
|
||||
|
||||
const expansionMode = ref<ExpansionMode>("default");
|
||||
const expansionOverrides = shallowRef(new Map<string, boolean>());
|
||||
const virtualScroller = ref<DynamicScrollerHandle>();
|
||||
|
||||
const rootNode = computed(() => createJsonTreeRoot(props.value));
|
||||
|
||||
function resetExpansion() {
|
||||
expansionMode.value = "default";
|
||||
expansionOverrides.value = new Map();
|
||||
}
|
||||
|
||||
function expandAll() {
|
||||
expansionMode.value = "all";
|
||||
expansionOverrides.value = new Map();
|
||||
}
|
||||
|
||||
function collapseAll() {
|
||||
expansionMode.value = "none";
|
||||
expansionOverrides.value = new Map();
|
||||
}
|
||||
|
||||
function isNodeExpanded(node: JsonTreeNode): boolean {
|
||||
const override = expansionOverrides.value.get(node.path);
|
||||
if (override !== undefined) return override;
|
||||
|
||||
if (expansionMode.value === "all") return true;
|
||||
if (expansionMode.value === "none") return false;
|
||||
return isJsonTreeInitiallyExpanded(node.depth, props.initialExpandedDepth);
|
||||
}
|
||||
|
||||
function setNodeExpanded(path: string, expanded: boolean) {
|
||||
const next = new Map(expansionOverrides.value);
|
||||
next.set(path, expanded);
|
||||
expansionOverrides.value = next;
|
||||
}
|
||||
|
||||
function toggleNode(node: JsonTreeNode) {
|
||||
setNodeExpanded(node.path, !isNodeExpanded(node));
|
||||
}
|
||||
|
||||
function scalarClass(value: unknown): string {
|
||||
if (isLosslessJsonNumber(value) || typeof value === "number") return "json-tree-number";
|
||||
if (typeof value === "string") return "json-tree-string";
|
||||
if (typeof value === "boolean") return "json-tree-boolean";
|
||||
if (value === null) return "json-tree-null";
|
||||
return "json-tree-string";
|
||||
}
|
||||
|
||||
function scalarText(value: unknown): string {
|
||||
if (isLosslessJsonNumber(value)) return value.raw;
|
||||
if (typeof value === "string") return JSON.stringify(value);
|
||||
if (value === null) return "null";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function highlightedJson(value: string): string {
|
||||
return props.highlightJson?.(value) ?? escapeHtml(value);
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function nodeAccessibleLabel(node: JsonTreeNode): string {
|
||||
// JSON Pointer paths are language-neutral and uniquely identify each toggle.
|
||||
return node.path || "$";
|
||||
}
|
||||
|
||||
function highlightedJsonSpan(className: string, json: string): VNodeChild {
|
||||
if (!props.highlightJson) return h("span", { class: className }, json);
|
||||
return h("span", { class: className, innerHTML: props.highlightJson(json) });
|
||||
}
|
||||
|
||||
function renderJsonNode(node: JsonTreeNode): VNodeChild {
|
||||
const containerValue = isJsonTreeContainer(node.value) ? node.value : undefined;
|
||||
const expanded = containerValue !== undefined && isNodeExpanded(node);
|
||||
const indent = `${node.depth * 16}px`;
|
||||
const rowChildren: VNodeChild[] = [];
|
||||
|
||||
if (containerValue !== undefined) {
|
||||
const accessibleLabel = nodeAccessibleLabel(node);
|
||||
rowChildren.push(
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
class: "json-tree-toggle",
|
||||
"aria-expanded": expanded,
|
||||
"aria-label": accessibleLabel,
|
||||
title: accessibleLabel,
|
||||
onClick: () => toggleNode(node),
|
||||
},
|
||||
[expanded ? h(ChevronDown, { class: "h-3.5 w-3.5", "aria-hidden": "true" }) : h(ChevronRight, { class: "h-3.5 w-3.5", "aria-hidden": "true" })],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
rowChildren.push(h("span", { class: "json-tree-spacer", "aria-hidden": "true" }));
|
||||
}
|
||||
|
||||
if (node.parentKind !== "root") {
|
||||
rowChildren.push(node.parentKind === "array" ? h("span", { class: "json-tree-index" }, `[${node.label}]`) : highlightedJsonSpan("json-tree-key", JSON.stringify(node.label)), h("span", { class: "json-tree-punctuation" }, ":"));
|
||||
}
|
||||
|
||||
if (containerValue !== undefined) {
|
||||
const kind = jsonTreeContainerKind(containerValue);
|
||||
rowChildren.push(h("span", { class: `json-tree-bracket is-${kind}` }, kind === "array" ? "[" : "{"), h("span", { class: "json-tree-summary" }, jsonTreeContainerSummary(containerValue, expanded)), h("span", { class: `json-tree-bracket is-${kind}` }, kind === "array" ? "]" : "}"));
|
||||
} else {
|
||||
rowChildren.push(highlightedJsonSpan(scalarClass(node.value), scalarText(node.value)));
|
||||
}
|
||||
|
||||
const children = containerValue !== undefined && expanded ? h("div", { class: "json-tree-children" }, getJsonTreeChildren(node).map(renderJsonNode)) : null;
|
||||
|
||||
return h("div", { key: node.path, class: "json-tree-node", "data-json-path": node.path }, [h("div", { class: "json-tree-row", style: { paddingInlineStart: indent } }, rowChildren), children]);
|
||||
}
|
||||
|
||||
const JsonTreeRoot = defineComponent({
|
||||
name: "JsonTreeRoot",
|
||||
setup() {
|
||||
return () => renderJsonNode(rootNode.value);
|
||||
},
|
||||
});
|
||||
|
||||
const visibleNodes = computed(() => getVisibleJsonTreeNodes(rootNode.value, isNodeExpanded));
|
||||
|
||||
function refresh(resetScroll = false) {
|
||||
if (!props.virtualized) return;
|
||||
void nextTick(() => {
|
||||
virtualScroller.value?.forceUpdate(true);
|
||||
if (resetScroll) virtualScroller.value?.scrollToPosition?.(0);
|
||||
});
|
||||
}
|
||||
|
||||
watch([() => props.value, () => props.initialExpandedDepth], () => {
|
||||
resetExpansion();
|
||||
refresh(true);
|
||||
});
|
||||
|
||||
defineExpose({ expandAll, collapseAll, resetExpansion, refresh });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="json-tree" :class="{ 'is-nowrap': !wordWrap, 'is-virtualized': virtualized }">
|
||||
<!-- Virtual rows keep a fully expanded response responsive even when it has thousands of nodes. -->
|
||||
<DynamicScroller v-if="virtualized" ref="virtualScroller" :items="visibleNodes" :min-item-size="24" :buffer="600" key-field="path" class="json-tree-scroller">
|
||||
<template #default="{ item: node, active, index }">
|
||||
<DynamicScrollerItem :item="node" :active="active" :size-dependencies="[wordWrap, node.path, node.label, scalarText(node.value), isNodeExpanded(node)]" :data-index="index">
|
||||
<div class="json-tree-node" :data-json-path="node.path">
|
||||
<div class="json-tree-row" :style="{ paddingInlineStart: `${node.depth * 16}px` }">
|
||||
<button v-if="isJsonTreeContainer(node.value)" type="button" class="json-tree-toggle" :aria-expanded="isNodeExpanded(node)" :aria-label="nodeAccessibleLabel(node)" :title="nodeAccessibleLabel(node)" @click="toggleNode(node)">
|
||||
<ChevronDown v-if="isNodeExpanded(node)" class="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<ChevronRight v-else class="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
<span v-else class="json-tree-spacer" aria-hidden="true" />
|
||||
|
||||
<template v-if="node.parentKind !== 'root'">
|
||||
<span v-if="node.parentKind === 'array'" class="json-tree-index">[{{ node.label }}]</span>
|
||||
<span v-else class="json-tree-key" v-html="highlightedJson(JSON.stringify(node.label))" />
|
||||
<span class="json-tree-punctuation">:</span>
|
||||
</template>
|
||||
|
||||
<template v-if="isJsonTreeContainer(node.value)">
|
||||
<span class="json-tree-bracket" :class="`is-${jsonTreeContainerKind(node.value)}`">{{ jsonTreeContainerKind(node.value) === "array" ? "[" : "{" }}</span>
|
||||
<span class="json-tree-summary">{{ jsonTreeContainerSummary(node.value, isNodeExpanded(node)) }}</span>
|
||||
<span class="json-tree-bracket" :class="`is-${jsonTreeContainerKind(node.value)}`">{{ jsonTreeContainerKind(node.value) === "array" ? "]" : "}" }}</span>
|
||||
</template>
|
||||
<span v-else :class="scalarClass(node.value)" v-html="highlightedJson(scalarText(node.value))" />
|
||||
</div>
|
||||
</div>
|
||||
</DynamicScrollerItem>
|
||||
</template>
|
||||
</DynamicScroller>
|
||||
<JsonTreeRoot v-else />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.json-tree {
|
||||
--json-tree-key: #1d4ed8;
|
||||
--json-tree-string: #15803d;
|
||||
--json-tree-number: #b45309;
|
||||
--json-tree-boolean: #7c3aed;
|
||||
--json-tree-null: #64748b;
|
||||
|
||||
color: hsl(var(--foreground));
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.json-tree.is-virtualized {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.json-tree-scroller {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.json-tree.is-nowrap {
|
||||
white-space: pre;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
|
||||
.json-tree-row {
|
||||
display: flex;
|
||||
min-height: 24px;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.json-tree-row:hover {
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
}
|
||||
|
||||
.json-tree-toggle {
|
||||
margin-top: 1px;
|
||||
display: inline-flex;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 3px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.json-tree-toggle:hover {
|
||||
background: hsl(var(--accent));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.json-tree-toggle:focus-visible {
|
||||
outline: 2px solid hsl(var(--ring));
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.json-tree-spacer {
|
||||
width: 18px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.json-tree-key {
|
||||
color: var(--json-tree-key);
|
||||
}
|
||||
|
||||
.json-tree-string {
|
||||
color: var(--json-tree-string);
|
||||
}
|
||||
|
||||
.json-tree-number {
|
||||
color: var(--json-tree-number);
|
||||
}
|
||||
|
||||
.json-tree-boolean {
|
||||
color: var(--json-tree-boolean);
|
||||
}
|
||||
|
||||
.json-tree-null {
|
||||
color: var(--json-tree-null);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.json-tree-index,
|
||||
.json-tree-punctuation,
|
||||
.json-tree-summary {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.json-tree-bracket {
|
||||
color: hsl(var(--foreground));
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.dark .json-tree {
|
||||
--json-tree-key: #93c5fd;
|
||||
--json-tree-string: #86efac;
|
||||
--json-tree-number: #fbbf24;
|
||||
--json-tree-boolean: #c4b5fd;
|
||||
--json-tree-null: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -48,6 +48,7 @@ const DocumentBrowser = defineAsyncComponent(() => import("@/components/document
|
|||
const MongoGridFsBrowser = defineAsyncComponent(() => import("@/components/document/MongoGridFsBrowser.vue"));
|
||||
const MongoBucketBrowser = defineAsyncComponent(() => import("@/components/document/MongoBucketBrowser.vue"));
|
||||
const VectorBrowser = defineAsyncComponent(() => import("@/components/vector/VectorBrowser.vue"));
|
||||
const ElasticsearchJsonResponsePanel = defineAsyncComponent(() => import("@/components/common/ElasticsearchJsonResponsePanel.vue"));
|
||||
const MqAdminConsole = defineAsyncComponent(() => import("@/components/mq/MqAdminConsole.vue"));
|
||||
const NacosAdminConsole = defineAsyncComponent(() => import("@/components/nacos/NacosAdminConsole.vue"));
|
||||
const ObjectBrowser = defineAsyncComponent(() => import("@/components/objects/ObjectBrowser.vue"));
|
||||
|
|
@ -70,6 +71,7 @@ import { dataTabExecutionDatabase } from "@/lib/table/dataTabExecutionDatabase";
|
|||
import { formatShortcut } from "@/lib/editor/shortcutRegistry";
|
||||
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import { chartableColumnIndexes } from "@/lib/dataGrid/chartData";
|
||||
import { elasticsearchJsonResponseForResult } from "@/lib/elasticsearch/elasticsearchJsonResponse";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { applyMongoGridChangesToDocument, buildMongoUpdateDocument, formatMongoShellLiteral, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues";
|
||||
import type { SqlExecutionOverride } from "@/lib/sql/sqlExecutionTarget";
|
||||
|
|
@ -321,6 +323,7 @@ const resultRuns = computed(() => resultRunItems(props.activeTab));
|
|||
const activeResultRunItem = computed(() => resultRuns.value.find((run) => run.active));
|
||||
const activeResultGridCacheKey = computed(() => resultGridCacheKey(props.activeTab));
|
||||
const activeResultSql = computed(() => resultSqlForGrid(props.activeTab));
|
||||
const activeElasticsearchJsonResponse = computed(() => elasticsearchJsonResponseForResult(activeEffectiveDatabaseType.value, activeResultSql.value, props.activeTab.result));
|
||||
const resultArchiveExporting = ref(false);
|
||||
const canExportResultArchive = computed(() => props.activeTab.mode === "query" && (!!props.activeTab.result || !!props.activeTab.results?.length || !!props.activeTab.resultRuns?.length));
|
||||
const resultAutoSave = computed(() => props.activeTab.resultAutoSave === true);
|
||||
|
|
@ -339,7 +342,7 @@ 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 showStandaloneResultToolbar = computed(() => props.activeOutputView !== "result" || !props.activeTab.result || !hasTabularResult.value);
|
||||
const showStandaloneResultToolbar = computed(() => activeElasticsearchJsonResponse.value || props.activeOutputView !== "result" || !props.activeTab.result || !hasTabularResult.value);
|
||||
const standaloneResultToolbarCompact = computed(() => standaloneResultToolbarWidth.value > 0 && standaloneResultToolbarWidth.value < DATA_GRID_COMPACT_TOPBAR_WIDTH);
|
||||
let standaloneResultToolbarResizeObserver: ResizeObserver | undefined;
|
||||
|
||||
|
|
@ -672,6 +675,12 @@ function refreshData(): boolean {
|
|||
emit("reload");
|
||||
return true;
|
||||
}
|
||||
if (activeElasticsearchJsonResponse.value) {
|
||||
// Match DataGrid's toolbar refresh intent so multi-result runs are
|
||||
// refreshed as a group instead of replacing them with the active result.
|
||||
emit("reload", activeResultSql.value, undefined, undefined, undefined, undefined, undefined, "refresh");
|
||||
return true;
|
||||
}
|
||||
if (!dataGridRef.value) return false;
|
||||
void dataGridRef.value.onToolbarRefresh();
|
||||
return true;
|
||||
|
|
@ -723,7 +732,7 @@ function toggleResultAutoSave() {
|
|||
function handleModRTarget(target: Element): boolean {
|
||||
if (target.closest("[data-query-editor-root]")) return queryEditorRef.value?.openReplace() ?? false;
|
||||
if (target.closest("[data-cell-detail-editor-root]")) return dataGridRef.value?.openCellDetailSearch() ?? false;
|
||||
if (target.closest("[data-grid-root]")) return refreshData();
|
||||
if (target.closest("[data-grid-root], [data-elasticsearch-json-response-root]")) return refreshData();
|
||||
if (canReloadUnavailableDataTab(props.activeTab)) return refreshData();
|
||||
return false;
|
||||
}
|
||||
|
|
@ -860,7 +869,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
|
|||
</div>
|
||||
</template>
|
||||
<div class="ml-auto flex shrink-0 items-center gap-1">
|
||||
<Popover v-if="activeOutputView === 'result' && activeTab.result && hasTabularResult">
|
||||
<Popover v-if="activeOutputView === 'result' && activeTab.result && hasTabularResult && !activeElasticsearchJsonResponse">
|
||||
<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" />
|
||||
|
|
@ -1012,7 +1021,14 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
|
|||
</div>
|
||||
|
||||
<div v-if="hasQueryOutput && showStandaloneResultToolbar" ref="standaloneResultToolbarRef" class="flex min-h-7 shrink-0 items-center border-b bg-muted/20">
|
||||
<QueryResultViewSwitcher :active-view="activeOutputView" :can-show-result="canShowResultOutput" :can-show-summary="hasExecutionSummary" :can-show-chart="hasNumericData" :compact="standaloneResultToolbarCompact" @select-view="emit('update:activeOutputView', $event)" />
|
||||
<QueryResultViewSwitcher
|
||||
:active-view="activeOutputView"
|
||||
:can-show-result="canShowResultOutput"
|
||||
:can-show-summary="hasExecutionSummary"
|
||||
:can-show-chart="hasNumericData && !activeElasticsearchJsonResponse"
|
||||
:compact="standaloneResultToolbarCompact"
|
||||
@select-view="emit('update:activeOutputView', $event)"
|
||||
/>
|
||||
<QueryResultToolbarActions
|
||||
class="ml-auto"
|
||||
:active-view="activeOutputView"
|
||||
|
|
@ -1037,7 +1053,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
|
|||
:table-error="activeTab.explainTableError"
|
||||
/>
|
||||
|
||||
<QueryChart v-else-if="activeOutputView === 'chart' && activeTab.result" class="flex-1 min-h-0" :result="activeTab.result" />
|
||||
<QueryChart v-else-if="activeOutputView === 'chart' && activeTab.result && !activeElasticsearchJsonResponse" class="flex-1 min-h-0" :result="activeTab.result" />
|
||||
|
||||
<div v-else-if="activeOutputView === 'summary'" class="flex-1 min-h-0 overflow-auto bg-background">
|
||||
<div v-if="activeTab.isExecuting" class="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
|
|
@ -1075,8 +1091,9 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
|
|||
</div>
|
||||
|
||||
<template v-else>
|
||||
<ElasticsearchJsonResponsePanel v-if="activeElasticsearchJsonResponse" class="flex-1 min-h-0" :status="activeElasticsearchJsonResponse.status" :body="activeElasticsearchJsonResponse.body" />
|
||||
<DataGrid
|
||||
v-if="activeTab.result && hasTabularResult"
|
||||
v-else-if="activeTab.result && hasTabularResult"
|
||||
ref="dataGridRef"
|
||||
:key="activeResultGridCacheKey"
|
||||
:cache-key="activeResultGridCacheKey"
|
||||
|
|
@ -1118,7 +1135,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
|
|||
@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" :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" :compact="compact" @select-view="emit('update:activeOutputView', $event)" />
|
||||
</template>
|
||||
<template #result-toolbar-actions="{ compact }">
|
||||
<QueryResultToolbarActions
|
||||
|
|
|
|||
|
|
@ -1,247 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, defineComponent, h, ref, type VNodeChild } from "vue";
|
||||
import { ChevronDown, ChevronRight } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { isLosslessJsonNumber } from "@/lib/common/safeJsonFormat";
|
||||
|
||||
defineOptions({ name: "RedisJsonTree" });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
value: unknown;
|
||||
wordWrap?: boolean;
|
||||
highlightJson?: (json: string) => string;
|
||||
}>(),
|
||||
{
|
||||
wordWrap: true,
|
||||
highlightJson: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const collapsedPaths = ref(new Set<string>());
|
||||
|
||||
type JsonNode = {
|
||||
key: string;
|
||||
label: string;
|
||||
value: unknown;
|
||||
path: string;
|
||||
depth: number;
|
||||
parentKind: "object" | "array" | "root";
|
||||
};
|
||||
|
||||
const rootNode = computed<JsonNode>(() => ({
|
||||
key: "$",
|
||||
label: "$",
|
||||
value: props.value,
|
||||
path: "$",
|
||||
depth: 0,
|
||||
parentKind: "root",
|
||||
}));
|
||||
|
||||
function isContainer(value: unknown): value is Record<string, unknown> | unknown[] {
|
||||
return value !== null && typeof value === "object" && !isLosslessJsonNumber(value);
|
||||
}
|
||||
|
||||
function containerKind(value: unknown): "array" | "object" {
|
||||
return Array.isArray(value) ? "array" : "object";
|
||||
}
|
||||
|
||||
function childNodes(node: JsonNode): JsonNode[] {
|
||||
if (!isContainer(node.value)) return [];
|
||||
if (Array.isArray(node.value)) {
|
||||
return node.value.map((value, index) => ({
|
||||
key: String(index),
|
||||
label: String(index),
|
||||
value,
|
||||
path: `${node.path}[${index}]`,
|
||||
depth: node.depth + 1,
|
||||
parentKind: "array",
|
||||
}));
|
||||
}
|
||||
return Object.entries(node.value).map(([key, value]) => ({
|
||||
key,
|
||||
label: key,
|
||||
value,
|
||||
path: `${node.path}.${key}`,
|
||||
depth: node.depth + 1,
|
||||
parentKind: "object",
|
||||
}));
|
||||
}
|
||||
|
||||
function nodeSummary(value: unknown): string {
|
||||
if (Array.isArray(value)) return `Array(${value.length})`;
|
||||
if (isContainer(value)) return `Object(${Object.keys(value).length})`;
|
||||
return "";
|
||||
}
|
||||
|
||||
function scalarClass(value: unknown): string {
|
||||
if (isLosslessJsonNumber(value)) return "json-tree-number";
|
||||
if (typeof value === "string") return "json-tree-string";
|
||||
if (typeof value === "number") return "json-tree-number";
|
||||
if (typeof value === "boolean") return "json-tree-boolean";
|
||||
if (value === null) return "json-tree-null";
|
||||
return "json-tree-string";
|
||||
}
|
||||
|
||||
function scalarText(value: unknown): string {
|
||||
if (isLosslessJsonNumber(value)) return value.raw;
|
||||
if (typeof value === "string") return JSON.stringify(value);
|
||||
if (value === null) return "null";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function highlightedJsonSpan(className: string, json: string): VNodeChild {
|
||||
if (!props.highlightJson) return h("span", { class: className }, json);
|
||||
return h("span", { class: className, innerHTML: props.highlightJson(json) });
|
||||
}
|
||||
|
||||
function isCollapsed(path: string): boolean {
|
||||
return collapsedPaths.value.has(path);
|
||||
}
|
||||
|
||||
function toggleCollapsed(path: string) {
|
||||
const next = new Set(collapsedPaths.value);
|
||||
if (next.has(path)) next.delete(path);
|
||||
else next.add(path);
|
||||
collapsedPaths.value = next;
|
||||
}
|
||||
|
||||
function renderJsonNode(node: JsonNode): VNodeChild {
|
||||
const children = childNodes(node);
|
||||
const container = isContainer(node.value);
|
||||
const collapsed = isCollapsed(node.path);
|
||||
const indent = `${node.depth * 16}px`;
|
||||
const rowChildren: VNodeChild[] = [];
|
||||
|
||||
if (container) {
|
||||
rowChildren.push(
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: "ghost",
|
||||
size: "icon",
|
||||
class: "redis-json-toggle",
|
||||
onClick: () => toggleCollapsed(node.path),
|
||||
},
|
||||
() => (collapsed ? h(ChevronRight, { class: "h-3.5 w-3.5" }) : h(ChevronDown, { class: "h-3.5 w-3.5" })),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
rowChildren.push(h("span", { class: "redis-json-spacer" }));
|
||||
}
|
||||
|
||||
if (node.parentKind !== "root") {
|
||||
rowChildren.push(node.parentKind === "array" ? h("span", { class: "redis-json-index" }, `[${node.label}]`) : highlightedJsonSpan("redis-json-key", JSON.stringify(node.label)), h("span", { class: "redis-json-colon" }, ":"));
|
||||
}
|
||||
|
||||
if (container) {
|
||||
rowChildren.push(
|
||||
h("span", { class: `redis-json-bracket is-${containerKind(node.value)}` }, Array.isArray(node.value) ? "[" : "{"),
|
||||
h("span", { class: "redis-json-summary" }, nodeSummary(node.value)),
|
||||
h("span", { class: `redis-json-bracket is-${containerKind(node.value)}` }, Array.isArray(node.value) ? "]" : "}"),
|
||||
);
|
||||
} else {
|
||||
rowChildren.push(highlightedJsonSpan(scalarClass(node.value), scalarText(node.value)));
|
||||
}
|
||||
|
||||
return h("div", { class: "redis-json-node" }, [h("div", { class: "redis-json-row", style: { paddingLeft: indent } }, rowChildren), container && !collapsed ? h("div", { class: "redis-json-children" }, children.map(renderJsonNode)) : null]);
|
||||
}
|
||||
|
||||
const JsonTreeNode = defineComponent({
|
||||
name: "JsonTreeNode",
|
||||
setup() {
|
||||
return () => renderJsonNode(rootNode.value);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="redis-json-tree" :class="{ 'is-nowrap': !wordWrap }">
|
||||
<JsonTreeNode />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.redis-json-tree {
|
||||
color: hsl(var(--foreground));
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.redis-json-tree.is-nowrap {
|
||||
white-space: pre;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
|
||||
.redis-json-row {
|
||||
display: flex;
|
||||
min-height: 24px;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.redis-json-toggle {
|
||||
margin-top: 1px;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
flex: 0 0 auto;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.redis-json-spacer {
|
||||
width: 18px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.redis-json-key {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.redis-json-index,
|
||||
.redis-json-colon,
|
||||
.redis-json-summary {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.redis-json-string {
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.redis-json-number {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.redis-json-boolean {
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.redis-json-null {
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.redis-json-bracket {
|
||||
color: hsl(var(--foreground));
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
:global(.dark) .redis-json-key {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
:global(.dark) .redis-json-string {
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
:global(.dark) .redis-json-number {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
:global(.dark) .redis-json-boolean {
|
||||
color: #c4b5fd;
|
||||
}
|
||||
|
||||
:global(.dark) .redis-json-null {
|
||||
color: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -10,13 +10,13 @@ import { Badge } from "@/components/ui/badge";
|
|||
import { Switch } from "@/components/ui/switch";
|
||||
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import RedisJsonTree from "./RedisJsonTree.vue";
|
||||
import JsonTree from "@/components/common/JsonTree.vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { RedisBlob, RedisHashItem, RedisKeyInfo, RedisListItem, RedisSetItem, RedisStreamEntry, RedisValue, RedisZsetItem } from "@/lib/backend/api";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useEditorFontFamilyStyle } from "@/composables/useEditorFontFamilyStyle";
|
||||
import { createRedisShikiJsonHighlighter, type RedisJsonHighlighter } from "@/lib/redis/redisJsonHighlighter";
|
||||
import { createShikiJsonHighlighter, type JsonHighlighter } from "@/lib/common/shikiJsonHighlighter";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { formatTtl } from "@/lib/common/ttlFormat";
|
||||
import { computeAutoRefreshTick, computeDisplayTtl, shouldStopAutoRefresh } from "@/lib/redis/redisAutoRefresh";
|
||||
|
|
@ -99,7 +99,7 @@ const stringValueView = ref<RedisValueFormat>(readPreferredRedisValueFormat());
|
|||
const memberValueView = ref<RedisValueFormat>(readPreferredRedisValueFormat());
|
||||
const redisJsonView = ref<"raw" | "tree">("raw");
|
||||
const redisJsonWordWrap = ref(readRedisJsonWordWrap());
|
||||
const redisJsonHighlighter = ref<RedisJsonHighlighter>();
|
||||
const redisJsonHighlighter = ref<JsonHighlighter>();
|
||||
|
||||
// Auto-refresh
|
||||
const autoRefreshEnabled = ref(true);
|
||||
|
|
@ -1118,7 +1118,7 @@ function formatValue(val: unknown): string {
|
|||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
void createRedisShikiJsonHighlighter({
|
||||
void createShikiJsonHighlighter({
|
||||
appearance: () => redisJsonAppearance.value,
|
||||
})
|
||||
.then((highlight) => {
|
||||
|
|
@ -1196,10 +1196,10 @@ onBeforeUnmount(() => {
|
|||
</label>
|
||||
</div>
|
||||
<div v-if="stringValueView === 'json' && stringValueDetail.json" class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background p-4 text-sm leading-6">
|
||||
<RedisJsonTree :value="stringValueDetail.json.value" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
|
||||
<JsonTree :value="stringValueDetail.json.value" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
|
||||
</div>
|
||||
<div v-else-if="stringValueView === 'javaserialize' && stringValueDetail.javaSerialized" class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background p-4 text-sm leading-6">
|
||||
<RedisJsonTree :value="stringValueDetail.javaSerialized.value" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
|
||||
<JsonTree :value="stringValueDetail.javaSerialized.value" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
|
||||
</div>
|
||||
<div v-else-if="stringValueView === 'hex'" class="min-h-0 flex-1 overflow-auto bg-background p-4 text-xs leading-5">
|
||||
<div class="mb-3 flex items-center justify-between text-muted-foreground">
|
||||
|
|
@ -1255,7 +1255,7 @@ onBeforeUnmount(() => {
|
|||
</label>
|
||||
</div>
|
||||
<div v-if="redisJsonView === 'tree'" class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background p-4 text-sm leading-6">
|
||||
<RedisJsonTree :value="redisJsonValue" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
|
||||
<JsonTree :value="redisJsonValue" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
|
||||
</div>
|
||||
<textarea v-else v-model="editValue" class="dbx-editor-font-family flex-1 resize-none bg-background p-4 text-sm outline-none" :class="redisJsonWordWrap ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'" spellcheck="false" @input="handleJsonInput" />
|
||||
<div v-if="isEditing" class="px-4 py-2 border-t flex justify-end gap-2 shrink-0">
|
||||
|
|
@ -1574,10 +1574,10 @@ onBeforeUnmount(() => {
|
|||
</label>
|
||||
</div>
|
||||
<div v-if="memberValueView === 'json' && selectedMemberDetail.json" class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background p-5 text-[13px] leading-6">
|
||||
<RedisJsonTree :value="selectedMemberDetail.json.value" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
|
||||
<JsonTree :value="selectedMemberDetail.json.value" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
|
||||
</div>
|
||||
<div v-else-if="memberValueView === 'javaserialize' && selectedMemberDetail.javaSerialized" class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background p-5 text-[13px] leading-6">
|
||||
<RedisJsonTree :value="selectedMemberDetail.javaSerialized.value" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
|
||||
<JsonTree :value="selectedMemberDetail.javaSerialized.value" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
|
||||
</div>
|
||||
<div v-else-if="memberValueView === 'hex'" class="min-h-0 flex-1 overflow-auto bg-background p-5 text-xs leading-5">
|
||||
<div class="mb-3 flex items-center justify-between text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { appendJsonPointer, createJsonTreeRoot, getJsonTreeChildren, getVisibleJsonTreeNodes, isJsonTreeContainer, isJsonTreeInitiallyExpanded } from "../jsonTree";
|
||||
import { parseJsonPreservingLargeNumbers } from "../safeJsonFormat";
|
||||
|
||||
describe("jsonTree", () => {
|
||||
it("uses RFC 6901 paths for object keys and array positions", () => {
|
||||
const root = createJsonTreeRoot({ "a/b~c": ["value"] });
|
||||
const objectChild = getJsonTreeChildren(root)[0];
|
||||
const arrayChild = getJsonTreeChildren(objectChild)[0];
|
||||
|
||||
expect(root.path).toBe("");
|
||||
expect(objectChild.path).toBe("/a~1b~0c");
|
||||
expect(arrayChild.path).toBe("/a~1b~0c/0");
|
||||
expect(appendJsonPointer("/parent", "a/b~c")).toBe("/parent/a~1b~0c");
|
||||
});
|
||||
|
||||
it("keeps lossless JSON numbers as scalar nodes", () => {
|
||||
const value = parseJsonPreservingLargeNumbers('{"id":518400931654815740}') as Record<string, unknown>;
|
||||
const child = getJsonTreeChildren(createJsonTreeRoot(value))[0];
|
||||
|
||||
expect(isJsonTreeContainer(child.value)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats initial depth as expanded container levels from the root", () => {
|
||||
expect(isJsonTreeInitiallyExpanded(0, 2)).toBe(true);
|
||||
expect(isJsonTreeInitiallyExpanded(1, 2)).toBe(true);
|
||||
expect(isJsonTreeInitiallyExpanded(2, 2)).toBe(false);
|
||||
expect(isJsonTreeInitiallyExpanded(99, Number.POSITIVE_INFINITY)).toBe(true);
|
||||
});
|
||||
|
||||
it("flattens expanded branches iteratively for virtual rendering", () => {
|
||||
const root = createJsonTreeRoot({ first: { nested: true }, second: ["kept"] });
|
||||
const nodes = getVisibleJsonTreeNodes(root, (node) => node.path !== "/first");
|
||||
|
||||
expect(nodes.map((node) => node.path)).toEqual(["", "/first", "/second", "/second/0"]);
|
||||
});
|
||||
|
||||
it("handles deeply nested expanded JSON without recursive traversal", () => {
|
||||
let value: unknown = true;
|
||||
for (let depth = 0; depth < 2_000; depth += 1) value = { child: value };
|
||||
|
||||
const nodes = getVisibleJsonTreeNodes(createJsonTreeRoot(value), () => true);
|
||||
|
||||
expect(nodes).toHaveLength(2_001);
|
||||
});
|
||||
});
|
||||
|
|
@ -73,4 +73,16 @@ describe("safeJsonFormat", () => {
|
|||
expect(isLosslessJsonNumber(parsed.companyId) ? parsed.companyId.raw : null).toBe("518400931654815740");
|
||||
expect(parsed.safe).toBe(42);
|
||||
});
|
||||
|
||||
it("preserves fractional and exponent literals that Number would round or overflow", () => {
|
||||
const input = '{"fraction":0.123456789012345678901234,"scientific":1.234567890123456789e20,"overflow":1e999}';
|
||||
const parsed = parseJsonPreservingLargeNumbers(input) as Record<string, unknown>;
|
||||
|
||||
expect(isLosslessJsonNumber(parsed.fraction) ? parsed.fraction.raw : null).toBe("0.123456789012345678901234");
|
||||
expect(isLosslessJsonNumber(parsed.scientific) ? parsed.scientific.raw : null).toBe("1.234567890123456789e20");
|
||||
expect(isLosslessJsonNumber(parsed.overflow) ? parsed.overflow.raw : null).toBe("1e999");
|
||||
expect(safeJsonFormat(input, 2)).toContain("0.123456789012345678901234");
|
||||
expect(safeJsonFormat(input, 2)).toContain("1.234567890123456789e20");
|
||||
expect(safeJsonFormat(input, 2)).toContain("1e999");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
import { isLosslessJsonNumber } from "./safeJsonFormat";
|
||||
|
||||
export type JsonTreeContainerKind = "array" | "object";
|
||||
export type JsonTreeParentKind = JsonTreeContainerKind | "root";
|
||||
|
||||
export interface JsonTreeNode {
|
||||
key: string;
|
||||
label: string;
|
||||
value: unknown;
|
||||
/** RFC 6901 JSON Pointer. The root value is represented by an empty string. */
|
||||
path: string;
|
||||
/** Zero-based structural depth; the root value is at depth zero. */
|
||||
depth: number;
|
||||
parentKind: JsonTreeParentKind;
|
||||
}
|
||||
|
||||
export type JsonTreeContainer = Record<string, unknown> | unknown[];
|
||||
|
||||
export function createJsonTreeRoot(value: unknown): JsonTreeNode {
|
||||
return {
|
||||
key: "$",
|
||||
label: "$",
|
||||
value,
|
||||
path: "",
|
||||
depth: 0,
|
||||
parentKind: "root",
|
||||
};
|
||||
}
|
||||
|
||||
export function isJsonTreeContainer(value: unknown): value is JsonTreeContainer {
|
||||
// LosslessJsonNumber is an object wrapper, but represents a scalar JSON number.
|
||||
return value !== null && typeof value === "object" && !isLosslessJsonNumber(value);
|
||||
}
|
||||
|
||||
export function jsonTreeContainerKind(value: JsonTreeContainer): JsonTreeContainerKind {
|
||||
return Array.isArray(value) ? "array" : "object";
|
||||
}
|
||||
|
||||
export function jsonTreeContainerSummary(value: JsonTreeContainer, includeObjectLength = true): string {
|
||||
if (Array.isArray(value)) return `Array(${value.length})`;
|
||||
return includeObjectLength ? `Object(${Object.keys(value).length})` : "Object";
|
||||
}
|
||||
|
||||
/** Escape a reference token according to RFC 6901. */
|
||||
export function escapeJsonPointerSegment(segment: string): string {
|
||||
return segment.replaceAll("~", "~0").replaceAll("/", "~1");
|
||||
}
|
||||
|
||||
export function appendJsonPointer(path: string, segment: string | number): string {
|
||||
return `${path}/${escapeJsonPointerSegment(String(segment))}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create child nodes only for an already-expanded parent. Callers should not
|
||||
* invoke this while a container is collapsed so large JSON payloads stay lazy.
|
||||
*/
|
||||
export function getJsonTreeChildren(node: JsonTreeNode): JsonTreeNode[] {
|
||||
if (!isJsonTreeContainer(node.value)) return [];
|
||||
|
||||
if (Array.isArray(node.value)) {
|
||||
return node.value.map((value, index) => ({
|
||||
key: String(index),
|
||||
label: String(index),
|
||||
value,
|
||||
path: appendJsonPointer(node.path, index),
|
||||
depth: node.depth + 1,
|
||||
parentKind: "array",
|
||||
}));
|
||||
}
|
||||
|
||||
return Object.entries(node.value).map(([key, value]) => ({
|
||||
key,
|
||||
label: key,
|
||||
value,
|
||||
path: appendJsonPointer(node.path, key),
|
||||
depth: node.depth + 1,
|
||||
parentKind: "object",
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten the currently expanded tree with an iterative traversal. This lets
|
||||
* virtual renderers keep every node logically expanded without recursive DOM
|
||||
* creation or deep-call-stack failures.
|
||||
*/
|
||||
export function getVisibleJsonTreeNodes(root: JsonTreeNode, isExpanded: (node: JsonTreeNode) => boolean): JsonTreeNode[] {
|
||||
const nodes: JsonTreeNode[] = [];
|
||||
const pending = [root];
|
||||
|
||||
while (pending.length > 0) {
|
||||
const node = pending.pop();
|
||||
if (!node) continue;
|
||||
nodes.push(node);
|
||||
|
||||
if (!isJsonTreeContainer(node.value) || !isExpanded(node)) continue;
|
||||
const children = getJsonTreeChildren(node);
|
||||
for (let index = children.length - 1; index >= 0; index -= 1) pending.push(children[index]);
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* `initialExpandedDepth` counts container levels from the root. For example,
|
||||
* a value of 2 expands the root and its direct container children.
|
||||
*/
|
||||
export function isJsonTreeInitiallyExpanded(depth: number, initialExpandedDepth: number): boolean {
|
||||
if (initialExpandedDepth === Number.POSITIVE_INFINITY) return true;
|
||||
if (!Number.isFinite(initialExpandedDepth)) return false;
|
||||
return depth < Math.max(0, Math.floor(initialExpandedDepth));
|
||||
}
|
||||
|
|
@ -18,7 +18,7 @@ export function isLosslessJsonNumber(value: unknown): value is LosslessJsonNumbe
|
|||
|
||||
/**
|
||||
* Parses JSON while retaining numeric literals that JavaScript cannot safely
|
||||
* represent as numbers. Callers can render these values without adding quotes.
|
||||
* represent exactly. Callers can render these values without adding quotes.
|
||||
*/
|
||||
export function parseJsonPreservingLargeNumbers(text: string): unknown {
|
||||
const protectedJson = protectLargeJsonNumbers(text);
|
||||
|
|
@ -28,7 +28,8 @@ export function parseJsonPreservingLargeNumbers(text: string): unknown {
|
|||
|
||||
/**
|
||||
* Parse and re-stringify JSON while preserving numeric literals whose integer
|
||||
* parts exceed Number.MAX_SAFE_INTEGER (2^53 - 1).
|
||||
* parts exceed Number.MAX_SAFE_INTEGER (2^53 - 1), plus decimal and exponent
|
||||
* forms that JavaScript may round or turn into Infinity.
|
||||
*/
|
||||
export function safeJsonFormat(text: string, indent?: number): string {
|
||||
const protectedJson = protectLargeJsonNumbers(text);
|
||||
|
|
@ -62,7 +63,7 @@ function protectLargeJsonNumbers(text: string): ProtectedJsonNumbers {
|
|||
const numberMatch = text.slice(index).match(/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/);
|
||||
if (numberMatch) {
|
||||
const raw = numberMatch[0];
|
||||
if (hasUnsafeIntegerPart(raw)) {
|
||||
if (shouldPreserveJsonNumber(raw)) {
|
||||
// A quoted placeholder lets the native parser validate the rest of the JSON.
|
||||
const placeholder = `${placeholderPrefix}${numbers.size}__`;
|
||||
numbers.set(placeholder, raw);
|
||||
|
|
@ -96,7 +97,12 @@ function findJsonStringEnd(text: string, start: number): number {
|
|||
return text.length;
|
||||
}
|
||||
|
||||
function hasUnsafeIntegerPart(raw: string): boolean {
|
||||
function shouldPreserveJsonNumber(raw: string): boolean {
|
||||
// Keep fractional/exponent forms verbatim. Even when a particular value is
|
||||
// representable today, parsing it through Number can change its precision or
|
||||
// spelling before the JSON viewer renders it.
|
||||
if (raw.includes(".") || raw.includes("e") || raw.includes("E") || raw === "-0") return true;
|
||||
|
||||
const unsigned = raw.startsWith("-") ? raw.slice(1) : raw;
|
||||
const integerPart = unsigned.split(/[.eE]/, 1)[0];
|
||||
const normalized = integerPart.replace(/^0+(?=\d)/, "");
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import type { AppThemeAppearance } from "@/lib/app/appTheme";
|
||||
|
||||
export type RedisJsonHighlighter = (content: string, appearance?: AppThemeAppearance) => string;
|
||||
export type JsonHighlighter = (content: string, appearance?: AppThemeAppearance) => string;
|
||||
|
||||
interface RedisShikiJsonHighlighterOptions {
|
||||
interface ShikiJsonHighlighterOptions {
|
||||
appearance: () => AppThemeAppearance;
|
||||
}
|
||||
|
||||
|
|
@ -15,8 +15,8 @@ type ShikiHighlighter = Awaited<ReturnType<typeof import("shiki/core").createHig
|
|||
|
||||
let highlighterPromise: Promise<ShikiHighlighter> | undefined;
|
||||
|
||||
export async function createRedisShikiJsonHighlighter(options: RedisShikiJsonHighlighterOptions): Promise<RedisJsonHighlighter> {
|
||||
const highlighter = await getRedisShikiHighlighter();
|
||||
export async function createShikiJsonHighlighter(options: ShikiJsonHighlighterOptions): Promise<JsonHighlighter> {
|
||||
const highlighter = await getShikiJsonHighlighter();
|
||||
return (content, appearance = options.appearance()) =>
|
||||
highlighter.codeToHtml(content, {
|
||||
lang: "json",
|
||||
|
|
@ -25,12 +25,12 @@ export async function createRedisShikiJsonHighlighter(options: RedisShikiJsonHig
|
|||
});
|
||||
}
|
||||
|
||||
function getRedisShikiHighlighter(): Promise<ShikiHighlighter> {
|
||||
highlighterPromise ??= loadRedisShikiHighlighter();
|
||||
function getShikiJsonHighlighter(): Promise<ShikiHighlighter> {
|
||||
highlighterPromise ??= loadShikiJsonHighlighter();
|
||||
return highlighterPromise;
|
||||
}
|
||||
|
||||
async function loadRedisShikiHighlighter(): Promise<ShikiHighlighter> {
|
||||
async function loadShikiJsonHighlighter(): Promise<ShikiHighlighter> {
|
||||
const [{ createHighlighterCore }, { createJavaScriptRegexEngine }, githubDark, githubLight, json] = await Promise.all([import("shiki/core"), import("shiki/engine/javascript"), import("shiki/themes/github-dark.mjs"), import("shiki/themes/github-light.mjs"), import("shiki/langs/json.mjs")]);
|
||||
|
||||
return createHighlighterCore({
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import type { DatabaseType, QueryResult } from "@/types/database";
|
||||
|
||||
export interface ElasticsearchJsonResponse {
|
||||
status: number;
|
||||
body: string;
|
||||
}
|
||||
|
||||
const ELASTICSEARCH_REST_STATEMENT = /^(?:GET|POST|PUT|DELETE)\s+\S+/i;
|
||||
|
||||
/**
|
||||
* Detect the result shape emitted for a JSON response to an explicit
|
||||
* Elasticsearch REST request. SQL and text (such as CAT) results keep using
|
||||
* the normal data-grid path.
|
||||
*/
|
||||
export function elasticsearchJsonResponseForResult(databaseType: DatabaseType | undefined, sourceStatement: string | undefined, result: QueryResult | undefined): ElasticsearchJsonResponse | undefined {
|
||||
if (databaseType !== "elasticsearch" || !result || typeof sourceStatement !== "string") return undefined;
|
||||
if (!ELASTICSEARCH_REST_STATEMENT.test(sourceStatement.trim())) return undefined;
|
||||
if (result.columns.length !== 2 || result.columns[0] !== "status" || result.columns[1] !== "response" || result.rows.length !== 1) return undefined;
|
||||
|
||||
const row = result.rows[0];
|
||||
if (!row || row.length !== 2) return undefined;
|
||||
|
||||
const [status, body] = row;
|
||||
if (typeof status !== "number" || !Number.isInteger(status) || status < 100 || status > 599 || typeof body !== "string") return undefined;
|
||||
return { status, body };
|
||||
}
|
||||
|
|
@ -39,7 +39,7 @@ sqlite-sqlcipher = ["rusqlite/bundled-sqlcipher-vendored-openssl"]
|
|||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = { version = "1.0", features = ["preserve_order"] }
|
||||
serde_json = { version = "1.0", features = ["arbitrary_precision", "preserve_order"] }
|
||||
regex = "1"
|
||||
rayon = "1"
|
||||
percent-encoding = "2"
|
||||
|
|
|
|||
|
|
@ -790,7 +790,15 @@ pub async fn execute_rest_query(client: &EsClient, input: &str) -> Result<crate:
|
|||
req.send().await
|
||||
}
|
||||
}
|
||||
"DELETE" => client.delete(&path).send().await,
|
||||
"DELETE" => {
|
||||
let req = client.delete(&path);
|
||||
if let Some(b) = body {
|
||||
let json: serde_json::Value = serde_json::from_str(b).map_err(|e| format!("Invalid JSON body: {e}"))?;
|
||||
req.json(&json).send().await
|
||||
} else {
|
||||
req.send().await
|
||||
}
|
||||
}
|
||||
_ => return Err(format!("Unsupported HTTP method: {method}. Use GET, POST, PUT, or DELETE.")),
|
||||
}
|
||||
.map_err(|e| format!("Elasticsearch request failed: {e}"))?;
|
||||
|
|
@ -865,18 +873,7 @@ fn parse_elasticsearch_response(
|
|||
has_more: false,
|
||||
})
|
||||
} else {
|
||||
let pretty = serde_json::to_string_pretty(&body).unwrap_or_else(|_| body.to_string());
|
||||
Ok(crate::types::QueryResult {
|
||||
columns: vec!["status".to_string(), "response".to_string()],
|
||||
column_types: Vec::new(),
|
||||
column_sortables: vec![],
|
||||
rows: vec![vec![serde_json::Value::Number(status.into()), serde_json::Value::String(pretty)]],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: start.elapsed().as_millis(),
|
||||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
})
|
||||
Ok(json_response_result(status, &body, start))
|
||||
}
|
||||
} else if let Some(hits) = body.pointer("/hits/hits").and_then(|v| v.as_array()) {
|
||||
// Treat any `_search`-shaped body as the hits result, even when empty —
|
||||
|
|
@ -941,18 +938,30 @@ fn parse_elasticsearch_response(
|
|||
has_more: false,
|
||||
})
|
||||
} else {
|
||||
let pretty = serde_json::to_string_pretty(&body).unwrap_or_else(|_| body.to_string());
|
||||
Ok(crate::types::QueryResult {
|
||||
columns: vec!["status".to_string(), "response".to_string()],
|
||||
column_types: Vec::new(),
|
||||
column_sortables: vec![],
|
||||
rows: vec![vec![serde_json::Value::Number(status.into()), serde_json::Value::String(pretty)]],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: start.elapsed().as_millis(),
|
||||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
})
|
||||
Ok(json_response_result(status, &body, start))
|
||||
}
|
||||
}
|
||||
|
||||
fn json_response_result(status: u16, body: &serde_json::Value, start: std::time::Instant) -> crate::types::QueryResult {
|
||||
let body_text = serde_json::to_string_pretty(body).unwrap_or_else(|_| body.to_string());
|
||||
raw_json_response_result(status, body_text, start)
|
||||
}
|
||||
|
||||
fn raw_json_response_result(
|
||||
status: u16,
|
||||
body_text: impl Into<String>,
|
||||
start: std::time::Instant,
|
||||
) -> crate::types::QueryResult {
|
||||
crate::types::QueryResult {
|
||||
columns: vec!["status".to_string(), "response".to_string()],
|
||||
column_types: Vec::new(),
|
||||
column_sortables: vec![],
|
||||
rows: vec![vec![serde_json::Value::Number(status.into()), serde_json::Value::String(body_text.into())]],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: start.elapsed().as_millis(),
|
||||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -962,11 +971,13 @@ fn parse_elasticsearch_rest_response(
|
|||
start: std::time::Instant,
|
||||
) -> Result<crate::types::QueryResult, String> {
|
||||
if body_text.trim().is_empty() {
|
||||
return parse_elasticsearch_response(status, serde_json::Value::Null, start);
|
||||
return Ok(json_response_result(status, &serde_json::Value::Null, start));
|
||||
}
|
||||
|
||||
if let Ok(body) = serde_json::from_str::<serde_json::Value>(body_text) {
|
||||
return parse_elasticsearch_response(status, body, start);
|
||||
if serde_json::from_str::<serde_json::Value>(body_text).is_ok() {
|
||||
// Validate the payload as JSON, but retain the HTTP body verbatim so
|
||||
// numeric literals are not changed by a parse/serialize round trip.
|
||||
return Ok(raw_json_response_result(status, body_text, start));
|
||||
}
|
||||
|
||||
// CAT APIs default to text/plain for human-readable output. Keep those
|
||||
|
|
@ -1501,6 +1512,35 @@ mod tests {
|
|||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
|
||||
async fn read_http_request(socket: &mut tokio::net::TcpStream) -> String {
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
let mut bytes = Vec::new();
|
||||
let mut buffer = [0_u8; 1024];
|
||||
loop {
|
||||
let read = socket.read(&mut buffer).await.unwrap();
|
||||
assert!(read > 0, "HTTP request ended before its body was received");
|
||||
bytes.extend_from_slice(&buffer[..read]);
|
||||
|
||||
let Some(headers_end) = bytes.windows(4).position(|window| window == b"\r\n\r\n") else {
|
||||
continue;
|
||||
};
|
||||
let content_length = std::str::from_utf8(&bytes[..headers_end])
|
||||
.unwrap()
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.eq_ignore_ascii_case("content-length").then(|| value.trim().parse::<usize>().unwrap())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let request_end = headers_end + 4 + content_length;
|
||||
if bytes.len() >= request_end {
|
||||
bytes.truncate(request_end);
|
||||
return String::from_utf8(bytes).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_params_can_disable_elasticsearch_tls_verification() {
|
||||
assert!(elasticsearch_accept_invalid_certs(false, Some("sslmode=disable")));
|
||||
|
|
@ -1718,10 +1758,29 @@ mod tests {
|
|||
)
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(result.columns, vec!["status", "response"]);
|
||||
let name_idx = result.columns.iter().position(|column| column == "name").unwrap();
|
||||
assert_eq!(result.rows[0][name_idx], json!("Alice"));
|
||||
let routing_idx = result.columns.iter().position(|column| column == "_routing").unwrap();
|
||||
assert_eq!(result.rows[0][routing_idx], json!("tenant-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_sql_api_response_tabular() {
|
||||
let result = super::parse_elasticsearch_response(
|
||||
200,
|
||||
json!({
|
||||
"columns": [{ "name": "name" }],
|
||||
"rows": [["Alice"]]
|
||||
}),
|
||||
std::time::Instant::now(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.columns, vec!["name"]);
|
||||
assert_eq!(result.rows, vec![vec![json!("Alice")]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_aggregation_response_before_empty_hits() {
|
||||
let result = super::parse_elasticsearch_response(
|
||||
|
|
@ -1769,6 +1828,27 @@ mod tests {
|
|||
assert_eq!(result.affected_rows, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_mapping_rest_response_numeric_literals_lossless() {
|
||||
let body = r#"{
|
||||
"products": {
|
||||
"mappings": {
|
||||
"_meta": {
|
||||
"largest_id": 123456789012345678901234567890,
|
||||
"ratio": 0.123456789012345678901234567890,
|
||||
"estimate": 1e400
|
||||
},
|
||||
"properties": { "name": { "type": "keyword" } }
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
let result = super::parse_elasticsearch_rest_response(200, body, std::time::Instant::now()).unwrap();
|
||||
|
||||
assert_eq!(result.columns, vec!["status", "response"]);
|
||||
assert_eq!(result.rows[0][0], json!(200));
|
||||
assert_eq!(result.rows[0][1].as_str(), Some(body));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_rest_query_keeps_plain_text_response_body() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
|
@ -1800,6 +1880,181 @@ mod tests {
|
|||
assert_eq!(result.rows[1][0], json!("green open app-log-2026-07 42 10mb"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_rest_query_preserves_numeric_literals_from_http_body() {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
let response_body = r#"{"largest_id":123456789012345678901234567890,"ratio":0.123456789012345678901234567890,"estimate":1e400}"#;
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let request = read_http_request(&mut socket).await;
|
||||
assert!(request.starts_with("GET /products/_mapping "));
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
response_body.len(),
|
||||
response_body
|
||||
);
|
||||
socket.write_all(response.as_bytes()).await.unwrap();
|
||||
});
|
||||
|
||||
let client = EsClient::new(&format!("http://{addr}"), None, None, false, Duration::from_secs(1));
|
||||
let result = super::execute_rest_query(&client, "GET /products/_mapping").await.unwrap();
|
||||
server.await.unwrap();
|
||||
|
||||
assert_eq!(result.columns, vec!["status", "response"]);
|
||||
assert_eq!(result.rows[0][0], json!(200));
|
||||
assert_eq!(result.rows[0][1].as_str(), Some(response_body));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_rest_delete_sends_json_body() {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let request = read_http_request(&mut socket).await;
|
||||
let (headers, body) = request.split_once("\r\n\r\n").unwrap();
|
||||
assert!(headers.starts_with("DELETE /_search/scroll "));
|
||||
assert_eq!(serde_json::from_str::<serde_json::Value>(body).unwrap(), json!({ "scroll_id": ["scroll-1"] }));
|
||||
|
||||
let response_body = r#"{"succeeded":true,"num_freed":1}"#;
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
response_body.len(),
|
||||
response_body
|
||||
);
|
||||
socket.write_all(response.as_bytes()).await.unwrap();
|
||||
});
|
||||
|
||||
let client = EsClient::new(&format!("http://{addr}"), None, None, false, Duration::from_secs(1));
|
||||
let result =
|
||||
super::execute_rest_query(&client, "DELETE /_search/scroll\n{\"scroll_id\":[\"scroll-1\"]}").await.unwrap();
|
||||
server.await.unwrap();
|
||||
|
||||
assert_eq!(result.columns, vec!["status", "response"]);
|
||||
assert_eq!(result.rows[0][0], json!(200));
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(result.rows[0][1].as_str().unwrap()).unwrap(),
|
||||
json!({ "succeeded": true, "num_freed": 1 })
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_rest_query_keeps_json_error_response() {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
let response_body = r#"{"error":{"type":"index_not_found_exception","reason":"no such index"},"status":404}"#;
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let request = read_http_request(&mut socket).await;
|
||||
assert!(request.starts_with("GET /missing/_mapping "));
|
||||
let response = format!(
|
||||
"HTTP/1.1 404 Not Found\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
response_body.len(),
|
||||
response_body
|
||||
);
|
||||
socket.write_all(response.as_bytes()).await.unwrap();
|
||||
});
|
||||
|
||||
let client = EsClient::new(&format!("http://{addr}"), None, None, false, Duration::from_secs(1));
|
||||
let result = super::execute_rest_query(&client, "GET /missing/_mapping").await.unwrap();
|
||||
server.await.unwrap();
|
||||
|
||||
assert_eq!(result.columns, vec!["status", "response"]);
|
||||
assert_eq!(result.rows[0][0], json!(404));
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(result.rows[0][1].as_str().unwrap()).unwrap(),
|
||||
json!({ "error": { "type": "index_not_found_exception", "reason": "no such index" }, "status": 404 })
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_select_query_keeps_search_response_tabular() {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
let response_body = r#"{"hits":{"hits":[{"_id":"product-1","_source":{"name":"Notebook"}}]}}"#;
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let request = read_http_request(&mut socket).await;
|
||||
assert!(request.starts_with("POST /products/_search "));
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
response_body.len(),
|
||||
response_body
|
||||
);
|
||||
socket.write_all(response.as_bytes()).await.unwrap();
|
||||
});
|
||||
|
||||
let client = EsClient::new(&format!("http://{addr}"), None, None, false, Duration::from_secs(1));
|
||||
let result = super::execute_rest_query(&client, "SELECT * FROM products LIMIT 1").await.unwrap();
|
||||
server.await.unwrap();
|
||||
|
||||
assert_ne!(result.columns, vec!["status", "response"]);
|
||||
let name_idx = result.columns.iter().position(|column| column == "name").unwrap();
|
||||
assert_eq!(result.rows[0][name_idx], json!("Notebook"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_rest_search_preserves_full_json_response() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
let body = json!({
|
||||
"took": 3,
|
||||
"hits": {
|
||||
"total": { "value": 1, "relation": "eq" },
|
||||
"max_score": 1.0,
|
||||
"hits": [{
|
||||
"_index": "products",
|
||||
"_id": "product-1",
|
||||
"_score": 1.0,
|
||||
"_source": { "name": "Notebook", "price": 1299 },
|
||||
"highlight": { "name": ["<em>Note</em>book"] }
|
||||
}]
|
||||
},
|
||||
"aggregations": {
|
||||
"by_category": {
|
||||
"buckets": [{ "key": "electronics", "doc_count": 1 }]
|
||||
}
|
||||
}
|
||||
});
|
||||
let response_body = body.to_string();
|
||||
let server_response_body = response_body.clone();
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let mut request = [0_u8; 4096];
|
||||
let read = socket.read(&mut request).await.unwrap();
|
||||
let request = String::from_utf8_lossy(&request[..read]);
|
||||
assert!(request.starts_with("POST /products/_search "));
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
server_response_body.len(),
|
||||
server_response_body
|
||||
);
|
||||
socket.write_all(response.as_bytes()).await.unwrap();
|
||||
});
|
||||
|
||||
let client = EsClient::new(&format!("http://{addr}"), None, None, false, Duration::from_secs(1));
|
||||
let result =
|
||||
super::execute_rest_query(&client, "POST /products/_search\n{\"query\":{\"match_all\":{}}}").await.unwrap();
|
||||
server.await.unwrap();
|
||||
|
||||
assert_eq!(result.columns, vec!["status", "response"]);
|
||||
assert_eq!(result.rows[0][0], json!(200));
|
||||
let response = result.rows[0][1].as_str().unwrap();
|
||||
assert_eq!(response, response_body);
|
||||
assert_eq!(serde_json::from_str::<serde_json::Value>(response).unwrap(), body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_body_removes_elasticsearch_id_metadata() {
|
||||
let doc = super::elasticsearch_document_body_from_json(r#"{"_id":"abc","_routing":"tenant-1","name":"Alice"}"#)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import { test } from "vitest";
|
||||
import { elasticsearchJsonResponseForResult } from "../../apps/desktop/src/lib/elasticsearch/elasticsearchJsonResponse.ts";
|
||||
import type { QueryResult } from "../../apps/desktop/src/types/database.ts";
|
||||
|
||||
function jsonResponse(overrides: Partial<QueryResult> = {}): QueryResult {
|
||||
return {
|
||||
columns: ["status", "response"],
|
||||
rows: [[200, '{\n "acknowledged": true\n}']],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("classifies Elasticsearch GET mapping and POST JSON responses", () => {
|
||||
const mapping = jsonResponse();
|
||||
assert.deepEqual(elasticsearchJsonResponseForResult("elasticsearch", "GET /products/_mapping", mapping), {
|
||||
status: 200,
|
||||
body: '{\n "acknowledged": true\n}',
|
||||
});
|
||||
|
||||
const search = jsonResponse({ rows: [[201, '{"hits":{"hits":[]}}']] });
|
||||
assert.deepEqual(elasticsearchJsonResponseForResult("elasticsearch", ' post /products/_search\n{"query":{"match_all":{}}}', search), {
|
||||
status: 201,
|
||||
body: '{"hits":{"hits":[]}}',
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects SQL and non-JSON Elasticsearch result shapes", () => {
|
||||
const response = jsonResponse();
|
||||
|
||||
assert.equal(elasticsearchJsonResponseForResult("elasticsearch", "SELECT * FROM products", response), undefined);
|
||||
assert.equal(elasticsearchJsonResponseForResult("postgres", "GET /products/_mapping", response), undefined);
|
||||
assert.equal(
|
||||
elasticsearchJsonResponseForResult("elasticsearch", "GET /_cat/indices", {
|
||||
columns: ["response"],
|
||||
rows: [["green open products"]],
|
||||
affected_rows: 1,
|
||||
execution_time_ms: 1,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects invalid Elasticsearch JSON response status and row shapes", () => {
|
||||
const invalidResults: QueryResult[] = [
|
||||
jsonResponse({ columns: ["response", "status"] }),
|
||||
jsonResponse({ rows: [[200, "{}", "unexpected"]] }),
|
||||
jsonResponse({ rows: [[99, "{}"]] }),
|
||||
jsonResponse({ rows: [[600, "{}"]] }),
|
||||
jsonResponse({ rows: [["200", "{}"]] }),
|
||||
jsonResponse({ rows: [[200, null]] }),
|
||||
];
|
||||
|
||||
for (const result of invalidResults) {
|
||||
assert.equal(elasticsearchJsonResponseForResult("elasticsearch", "GET /products/_mapping", result), undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("uses the supplied result source statement to classify the response", () => {
|
||||
const result = jsonResponse({ sourceStatement: "GET /products/_mapping" });
|
||||
|
||||
assert.deepEqual(elasticsearchJsonResponseForResult("elasticsearch", result.sourceStatement, result), {
|
||||
status: 200,
|
||||
body: '{\n "acknowledged": true\n}',
|
||||
});
|
||||
assert.equal(elasticsearchJsonResponseForResult("elasticsearch", "SELECT * FROM products", result), undefined);
|
||||
assert.equal(elasticsearchJsonResponseForResult("elasticsearch", undefined, result), undefined);
|
||||
});
|
||||
|
|
@ -87,3 +87,12 @@ test("DataGrid marks toolbar refresh separately from current-result reloads", ()
|
|||
assert.match(dataGrid, /emit\("reload", props\.sql,[^;]+"refresh"\);/);
|
||||
assert.match(dataGrid, /function onToolbarRollback\(\)[\s\S]*?emit\("reload", props\.sql,[^;]+\);/);
|
||||
});
|
||||
|
||||
test("Elasticsearch JSON refresh preserves multi-result query groups", () => {
|
||||
const contentArea = source(contentAreaPath);
|
||||
|
||||
assert.match(
|
||||
contentArea,
|
||||
/if \(activeElasticsearchJsonResponse\.value\) \{[\s\S]*?emit\("reload", activeResultSql\.value, undefined, undefined, undefined, undefined, undefined, "refresh"\);/,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue