diff --git a/apps/desktop/src/components/explain/ExplainPlanDiagram.vue b/apps/desktop/src/components/explain/ExplainPlanDiagram.vue index 3bf4623a8b..87e47c8a83 100644 --- a/apps/desktop/src/components/explain/ExplainPlanDiagram.vue +++ b/apps/desktop/src/components/explain/ExplainPlanDiagram.vue @@ -4,6 +4,7 @@ import { useI18n } from "vue-i18n"; import { ArrowDownWideNarrow, CircleHelp, GitMerge, KeyRound, Layers, ListTree, Maximize2, MonitorCheck, Pencil, Sigma, Split, Table2, ZoomIn, ZoomOut } from "@lucide/vue"; import { Button } from "@/components/ui/button"; import type { ExplainPlanNode } from "@/lib/diagram/explainPlan"; +import { formatExplainPlanDetails } from "@/lib/diagram/explainPlan"; import type { PlanCanvasCategory, PlanCanvasNode } from "@/lib/diagram/planCanvas"; import { buildPlanCanvas, edgeStrokeWidth, formatPlanRows, heatLevel, PLAN_CANVAS_GAP_X, PLAN_CANVAS_NODE_H, PLAN_CANVAS_NODE_W } from "@/lib/diagram/planCanvas"; @@ -110,7 +111,7 @@ interface DetailEntry { } const selectedDetails = computed(() => - (selected.value?.node.details ?? []) + formatExplainPlanDetails(selected.value?.node, t("explain.estimatedTime")) .map((detail): DetailEntry => { const separatorIndex = detail.indexOf(":"); if (separatorIndex === -1) return { value: detail.trim() }; diff --git a/apps/desktop/src/components/explain/ExplainPlanNodeTree.vue b/apps/desktop/src/components/explain/ExplainPlanNodeTree.vue index 62e8dfb19b..3243e1f9f2 100644 --- a/apps/desktop/src/components/explain/ExplainPlanNodeTree.vue +++ b/apps/desktop/src/components/explain/ExplainPlanNodeTree.vue @@ -5,6 +5,7 @@ import { useI18n } from "vue-i18n"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import type { ExplainPlanNode } from "@/lib/diagram/explainPlan"; +import { formatExplainPlanDetails } from "@/lib/diagram/explainPlan"; const props = defineProps<{ node: ExplainPlanNode; @@ -20,7 +21,7 @@ interface ExplainPlanDetailEntry { } const detailEntries = computed(() => - props.node.details + formatExplainPlanDetails(props.node, t("explain.estimatedTime")) .map((detail): ExplainPlanDetailEntry => { const separatorIndex = detail.indexOf(":"); if (separatorIndex === -1) return { value: detail.trim() }; diff --git a/apps/desktop/src/components/explain/ExplainPlanViewer.vue b/apps/desktop/src/components/explain/ExplainPlanViewer.vue index b7558d6531..d11aca332c 100644 --- a/apps/desktop/src/components/explain/ExplainPlanViewer.vue +++ b/apps/desktop/src/components/explain/ExplainPlanViewer.vue @@ -3,7 +3,7 @@ import { computed, ref, watch } from "vue"; import { useI18n } from "vue-i18n"; import { AlertCircle, Braces, GitBranch, Table2, FileText, Workflow } from "@lucide/vue"; import type { ParsedExplainPlan, ExplainPlanNode } from "@/lib/diagram/explainPlan"; -import { flattenExplainPlanNodes } from "@/lib/diagram/explainPlan"; +import { flattenExplainPlanNodes, formatExplainPlanDetails } from "@/lib/diagram/explainPlan"; import { extractActualRows } from "@/lib/diagram/planCanvas"; import { Button } from "@/components/ui/button"; import type { QueryResult } from "@/types/database"; @@ -185,7 +185,7 @@ function tableCellText(value: unknown): string { {{ row.node.cost || "-" }} {{ row.node.rows || "-" }} - {{ row.node.details.join("; ") || "-" }} + {{ formatExplainPlanDetails(row.node, t("explain.estimatedTime")).join("; ") || "-" }} diff --git a/apps/desktop/src/components/explain/__tests__/ExplainPlanViewer.locale.spec.ts b/apps/desktop/src/components/explain/__tests__/ExplainPlanViewer.locale.spec.ts new file mode 100644 index 0000000000..d59557bfb5 --- /dev/null +++ b/apps/desktop/src/components/explain/__tests__/ExplainPlanViewer.locale.spec.ts @@ -0,0 +1,61 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it } from "vitest"; +import { createApp, nextTick, type App } from "vue"; +import { createI18n } from "vue-i18n"; +import en from "@/i18n/locales/en"; +import zhCN from "@/i18n/locales/zh-CN"; +import { parseExplainResult } from "@/lib/diagram/explainPlan"; +import ExplainPlanViewer from "@/components/explain/ExplainPlanViewer.vue"; + +let app: App | undefined; + +afterEach(() => { + app?.unmount(); + app = undefined; + document.body.innerHTML = ""; +}); + +describe("OceanBase estimated time localization", () => { + it.each(["canvas", "tree", "summary"] as const)("updates the existing %s view when the locale changes", async (view) => { + const plan = parseExplainResult("oceanbase-oracle", { + columns: ["Query Plan"], + rows: [[JSON.stringify({ ID: 0, OPERATOR: "TABLE FULL SCAN", NAME: "EXAMPLE_TABLE", "EST.TIME(us)": 0, filter: "C1 > 4" })]], + affected_rows: 0, + execution_time_ms: 1, + }); + const originalPlan = JSON.stringify(plan); + const i18n = createI18n({ legacy: false, locale: "en", messages: { en, "zh-CN": zhCN } }); + const container = document.createElement("div"); + document.body.append(container); + app = createApp(ExplainPlanViewer, { plan }); + app.use(i18n); + app.mount(container); + await nextTick(); + + if (view !== "canvas") { + const label = en.explain[view]; + const button = [...container.querySelectorAll("button")].find((item) => item.textContent?.trim() === label); + expect(button).toBeDefined(); + button!.click(); + await nextTick(); + } + + expect(container.textContent).toContain("Estimated time"); + expect(container.textContent).toContain("0 µs"); + expect(container.textContent).toContain("C1 > 4"); + + i18n.global.locale.value = "zh-CN"; + await nextTick(); + expect(container.textContent).toContain("预计耗时"); + expect(container.textContent).not.toContain("Estimated time"); + expect(container.textContent).toContain("0 µs"); + expect(container.textContent).toContain("C1 > 4"); + + i18n.global.locale.value = "en"; + await nextTick(); + expect(container.textContent).toContain("Estimated time"); + expect(container.textContent).not.toContain("预计耗时"); + expect(JSON.stringify(plan)).toBe(originalPlan); + }); +}); diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index 9f5deae7d9..f0f8c718f4 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -2568,6 +2568,7 @@ export default withEnglishFallback({ apply: "Tətbiq et", }, explain: { + estimatedTime: "Təxmini vaxt", title: "İcra planı", standardTable: "Cədvəl", tree: "Ağac", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index ef52b094eb..66d14d28fe 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2740,6 +2740,7 @@ export default { apply: "Apply", }, explain: { + estimatedTime: "Estimated time", title: "Explain Plan", standardTable: "Table", tree: "Tree", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index c75aca4268..e599455eb5 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2640,6 +2640,7 @@ export default withEnglishFallback({ apply: "Aplicar", }, explain: { + estimatedTime: "Tiempo estimado", title: "Plan de ejecución", standardTable: "Tabla", tree: "Árbol", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 062e9b80ee..779913fc02 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2637,6 +2637,7 @@ export default withEnglishFallback({ apply: "Applica", }, explain: { + estimatedTime: "Tempo stimato", title: "Piano di Spiegazione", standardTable: "Tabella", tree: "Albero", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 2bcd3a485c..f5bce378f2 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2663,6 +2663,7 @@ export default withEnglishFallback({ apply: "適用", }, explain: { + estimatedTime: "推定時間", title: "実行計画", standardTable: "標準テーブル", tree: "ツリー", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index f1998e3422..e7b3a9c63d 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -2546,6 +2546,7 @@ export default withEnglishFallback({ apply: "적용", }, explain: { + estimatedTime: "예상 시간", title: "실행 계획", standardTable: "표", tree: "트리", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 9eb14f8b50..97347f8e40 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2639,6 +2639,7 @@ export default withEnglishFallback({ apply: "Aplicar", }, explain: { + estimatedTime: "Tempo estimado", title: "Plano de Execução", standardTable: "Tabela", tree: "Árvore", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index aa0752cdd3..71ae8c8262 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -2546,6 +2546,7 @@ export default withEnglishFallback({ apply: "Uygula", }, explain: { + estimatedTime: "Tahmini süre", title: "Yürütme Planı", standardTable: "Tablo", tree: "Ağaç", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 0da13024fc..b068ccb97e 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2652,6 +2652,7 @@ export default withEnglishFallback({ apply: "应用", }, explain: { + estimatedTime: "预计耗时", title: "执行计划", standardTable: "标准表格", tree: "树", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 0b7577a9ea..517191eb21 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2637,6 +2637,7 @@ export default withEnglishFallback({ apply: "套用", }, explain: { + estimatedTime: "預估耗時", title: "執行計畫", standardTable: "標準表格", tree: "樹狀", diff --git a/apps/desktop/src/lib/__tests__/query/oceanbaseOracleExplainPlan.spec.ts b/apps/desktop/src/lib/__tests__/query/oceanbaseOracleExplainPlan.spec.ts index d2fef97f03..347c867dff 100644 --- a/apps/desktop/src/lib/__tests__/query/oceanbaseOracleExplainPlan.spec.ts +++ b/apps/desktop/src/lib/__tests__/query/oceanbaseOracleExplainPlan.spec.ts @@ -37,7 +37,7 @@ describe("OceanBase Oracle explain plan", () => { expect(plan.raw).toEqual(JSON_PLAN); expect(nodes.map((node) => node.id)).toEqual(["0", "1", "3", "2"]); expect(nodes.map((node) => node.title)).toEqual(["HASH JOIN", "SUBPLAN SCAN on T1", "EXPRESSION", "TABLE FULL SCAN on T2"]); - expect(nodes[0]).toMatchObject({ rows: "1", details: ["Estimated time: 5 µs", "output: output([T1.C1], [T2.C1])"] }); + expect(nodes[0]).toMatchObject({ rows: "1", estimatedTimeUs: "5", details: ["output: output([T1.C1], [T2.C1])"] }); expect(nodes[1]).toMatchObject({ relation: "T1", rows: "2" }); expect(nodes[3].details).toContain("filter: filter([T2.C1 > 4])"); expect(nodes[0].cost).toBeUndefined(); @@ -50,4 +50,24 @@ describe("OceanBase Oracle explain plan", () => { nodes: [], }); }); + + it("preserves zero estimates and sorts numbered children without exposing structural fields", () => { + const plan = parseExplainResult( + "oceanbase-oracle", + explainResult( + JSON.stringify({ + ID: 0, + OPERATOR: "HASH JOIN", + "EST.TIME(us)": 0, + COST: 7, + CHILD_10: { ID: 10, OPERATOR: "TABLE FULL SCAN", "EST.TIME(us)": null }, + CHILD_2: { ID: 2, OPERATOR: "TABLE FULL SCAN", filter: "C1 > 4" }, + }), + ), + ); + expect(plan.nodes[0]).toMatchObject({ estimatedTimeUs: "0", cost: "7", details: [] }); + expect(plan.nodes[0].children.map((node) => node.id)).toEqual(["2", "10"]); + expect(plan.nodes[0].children[0]).toMatchObject({ estimatedTimeUs: undefined, details: ["filter: C1 > 4"] }); + expect(plan.nodes[0].children[1]).toMatchObject({ estimatedTimeUs: undefined, details: [] }); + }); }); diff --git a/apps/desktop/src/lib/diagram/explainPlan.ts b/apps/desktop/src/lib/diagram/explainPlan.ts index 4d1578a532..4599b25596 100644 --- a/apps/desktop/src/lib/diagram/explainPlan.ts +++ b/apps/desktop/src/lib/diagram/explainPlan.ts @@ -12,6 +12,7 @@ export interface ExplainPlanNode { cost?: string; rows?: string; width?: string; + estimatedTimeUs?: string; details: string[]; children: ExplainPlanNode[]; } @@ -26,6 +27,11 @@ export interface ParsedExplainPlan { export type BuildExplainSqlResult = { ok: true; sql: string } | { ok: false; reason: "unsupported" | "empty" | "unsafe" }; +export function formatExplainPlanDetails(node: ExplainPlanNode | undefined, estimatedTimeLabel: string): string[] { + if (!node) return []; + return node.estimatedTimeUs === undefined ? node.details : [`${estimatedTimeLabel}: ${node.estimatedTimeUs} µs`, ...node.details]; +} + const SUPPORTED_EXPLAIN_TYPES = new Set(["mysql", "postgres", "dameng", "questdb", "doris", "oracle", "oceanbase-oracle", "sqlserver"]); export function supportsExplainPlan(databaseType?: DatabaseType): databaseType is ExplainPlanDatabaseType { return !!databaseType && supportsDatabaseFeature(databaseType, "sqlExplain") && SUPPORTED_EXPLAIN_TYPES.has(databaseType); @@ -598,25 +604,29 @@ function parseOceanbaseOracleExplain(result: QueryResult): ParsedExplainPlan { return { databaseType: "oceanbase-oracle", raw, nodes: root ? [parseOceanbaseOracleNode(root, "0")] : [] }; } +const OCEANBASE_PLAN_KEYS = { id: "ID", operator: "OPERATOR", name: "NAME", rows: "EST.ROWS", time: "EST.TIME(us)", cost: "COST" } as const; +const OCEANBASE_PLAN_FIELDS = new Set(Object.values(OCEANBASE_PLAN_KEYS)); +const OCEANBASE_CHILD_KEY = /^CHILD_\d+$/; + function parseOceanbaseOracleNode(plan: Record, fallbackId: string): ExplainPlanNode { - const nodeType = stringValue(plan.OPERATOR)?.trim() || "Plan"; - const relation = stringValue(plan.NAME)?.trim() || undefined; + const nodeType = stringValue(plan[OCEANBASE_PLAN_KEYS.operator])?.trim() || "Plan"; + const relation = stringValue(plan[OCEANBASE_PLAN_KEYS.name])?.trim() || undefined; const children = Object.entries(plan) - .filter(([key, value]) => /^CHILD_\d+$/.test(key) && objectValue(value)) + .filter(([key, value]) => OCEANBASE_CHILD_KEY.test(key) && objectValue(value)) .sort(([left], [right]) => Number(left.slice(6)) - Number(right.slice(6))) .map(([key, value]) => parseOceanbaseOracleNode(objectValue(value)!, `${fallbackId}.${key.slice(6)}`)); - const estimatedTime = numberLike(plan["EST.TIME(us)"]); + const estimatedTimeUs = numberLike(plan[OCEANBASE_PLAN_KEYS.time]); const details = Object.entries(plan) - .filter(([key, value]) => !["ID", "OPERATOR", "NAME", "EST.ROWS", "EST.TIME(us)", "COST"].includes(key) && !/^CHILD_\d+$/.test(key) && value !== null) + .filter(([key, value]) => !OCEANBASE_PLAN_FIELDS.has(key) && !OCEANBASE_CHILD_KEY.test(key) && value !== null) .map(([key, value]) => `${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`); - if (estimatedTime !== undefined) details.unshift(`Estimated time: ${estimatedTime} µs`); return { - id: numberLike(plan.ID) || fallbackId, + id: numberLike(plan[OCEANBASE_PLAN_KEYS.id]) || fallbackId, title: relation && relation !== nodeType ? `${nodeType} on ${relation}` : nodeType, nodeType, relation, - cost: numberLike(plan.COST), - rows: numberLike(plan["EST.ROWS"]), + cost: numberLike(plan[OCEANBASE_PLAN_KEYS.cost]), + rows: numberLike(plan[OCEANBASE_PLAN_KEYS.rows]), + estimatedTimeUs, details, children, }; diff --git a/apps/desktop/src/stores/__tests__/queryStore.oceanbaseOracleExplain.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.oceanbaseOracleExplain.spec.ts index 782b03a946..bb42b9c593 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.oceanbaseOracleExplain.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.oceanbaseOracleExplain.spec.ts @@ -57,7 +57,7 @@ describe("queryStore OceanBase Oracle explain", () => { isExplaining: false, explainError: undefined, explainSql: "EXPLAIN FORMAT=JSON SELECT 1 FROM DUAL", - explainPlan: { databaseType: "oceanbase-oracle", nodes: [{ id: "0", nodeType: "EXPRESSION", rows: "1", details: ["Estimated time: 1 µs"] }] }, + explainPlan: { databaseType: "oceanbase-oracle", nodes: [{ id: "0", nodeType: "EXPRESSION", rows: "1", estimatedTimeUs: "1", details: [] }] }, }); }); }); diff --git a/crates/dbx-core/assets/docs-export.manifest.json b/crates/dbx-core/assets/docs-export.manifest.json index b83e9f4324..ab250b6de6 100644 --- a/crates/dbx-core/assets/docs-export.manifest.json +++ b/crates/dbx-core/assets/docs-export.manifest.json @@ -38,7 +38,7 @@ "apps/desktop/src/i18n/locales/docs/zh-TW.ts": "691cf502248dbd09358c6c276990945013b26c52fb682bcd56b26f378daea29c", "apps/desktop/src/lib/app/localeOptions.ts": "2c19ecb7c91050d5f1c84c381606087ada11870d3ee95029362f84f34de23239", "apps/desktop/src/lib/common/utils.ts": "76c4b2af1e03edeb8db389d9b2c1b56b72a57a1b5198d4e224671c1445c527da", - "apps/desktop/src/lib/diagram/erDiagram.ts": "e28aa2b5fbba001ae78bc0c4218b11f93b0e14ab9f54562f14254386b98bad11", + "apps/desktop/src/lib/diagram/erDiagram.ts": "c22416a9a5ec5e619acc2d8bc5451254a25abe29fc518e9fb4f611e076fe7b1f", "apps/desktop/src/styles/tokens.css": "5d0dd04cb28e5354a57ca7365418923aa912ee0cf28e1d0665734de04493c533", "apps/desktop/tsconfig.json": "ee02ba8f2f257b86235cd3053e8d6fbfefd7c39e0807b4ea5e36e133a78e836c", "apps/desktop/vite.docs-export.config.ts": "833bc2cbd99f9c0740cbc1584640458492bad75c9518db4d1ce0edd6958881ec"