feat(dameng): full DM8 database support
Adds Dameng DM8 explain/autotrace support and user-management SQL support, with backend autotrace safety checks and escaped CREATE USER SQL.
This commit is contained in:
1 parent
a5659d7637
commit
897e6998fd
22 files changed
+1154
-55
No files matched your search
@@ -1,43 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronRight } from "@lucide/vue";
|
||||
import { ref } from "vue";
|
||||
import { ChevronRight, ChevronDown } from "@lucide/vue";
|
||||
import type { ExplainPlanNode } from "@/lib/explainPlan";
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
node: ExplainPlanNode;
|
||||
depth?: number;
|
||||
}>();
|
||||
|
||||
const collapsed = ref(false);
|
||||
|
||||
function toggle() {
|
||||
if (props.node.children.length > 0) {
|
||||
collapsed.value = !collapsed.value;
|
||||
}
|
||||
}
|
||||
|
||||
function actualRowsFromDetails(): string | undefined {
|
||||
for (const d of props.node.details) {
|
||||
const m = d.match(/Actual Rows:\s*(\S+)/);
|
||||
if (m) return m[1];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const actualRows = actualRowsFromDetails();
|
||||
const hasActualStats = !!actualRows;
|
||||
const rowDiffers = hasActualStats && actualRows !== props.node.rows;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<div class="rounded border bg-background px-3 py-2 shadow-xs">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<ChevronRight class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium">{{ node.title }}</div>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span class="rounded bg-muted px-1.5 py-0.5">{{ node.nodeType }}</span>
|
||||
<span
|
||||
v-if="node.relation"
|
||||
class="rounded bg-blue-50 px-1.5 py-0.5 text-blue-700 dark:bg-blue-950 dark:text-blue-300"
|
||||
>{{ node.relation }}</span
|
||||
>
|
||||
<span
|
||||
v-if="node.index"
|
||||
class="rounded bg-emerald-50 px-1.5 py-0.5 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300"
|
||||
>{{ node.index }}</span
|
||||
>
|
||||
<span v-if="node.cost">{{ node.cost }}</span>
|
||||
<span v-if="node.rows">{{ node.rows }} rows</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="node.details.length" class="mt-2 space-y-1 border-t pt-2 text-xs text-muted-foreground">
|
||||
<div v-for="detail in node.details" :key="detail" class="break-all">{{ detail }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<!-- Single line: collapse icon + title + badges all in one row -->
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-1 rounded border bg-background px-2 py-1 text-xs hover:bg-muted/30"
|
||||
:class="{ 'border-green-300 dark:border-green-700': hasActualStats }"
|
||||
@click="toggle"
|
||||
>
|
||||
<ChevronRight v-if="node.children.length > 0 && collapsed" class="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<ChevronDown v-else-if="node.children.length > 0" class="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
|
||||
<span class="shrink-0 rounded bg-muted px-1 py-0.5 font-medium">{{ node.nodeType }}</span>
|
||||
<span v-if="node.relation" class="shrink-0 truncate max-w-[120px] text-blue-600 dark:text-blue-400">{{
|
||||
node.relation
|
||||
}}</span>
|
||||
<span v-if="node.index" class="shrink-0 text-emerald-600 dark:text-emerald-400">[{{ node.index }}]</span>
|
||||
<span v-if="node.cost" class="shrink-0 tabular-nums text-muted-foreground">c:{{ node.cost }}</span>
|
||||
<span v-if="node.rows" class="shrink-0 tabular-nums text-amber-600 dark:text-amber-400">e:{{ node.rows }}</span>
|
||||
<span
|
||||
v-if="hasActualStats"
|
||||
class="shrink-0 tabular-nums font-semibold"
|
||||
:class="rowDiffers ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'"
|
||||
>a:{{ actualRows
|
||||
}}<span v-if="rowDiffers">({{ Math.round((Number(actualRows) / Number(node.rows)) * 100) }}%)</span></span
|
||||
>
|
||||
|
||||
<!-- Details collapsed into tooltip on hover -->
|
||||
<span
|
||||
v-if="node.details.length"
|
||||
class="ml-auto shrink-0 overflow-hidden text-ellipsis whitespace-nowrap text-muted-foreground/40"
|
||||
:title="node.details.join('\n')"
|
||||
>{{ node.details.join(" ") }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div v-if="node.children.length" class="space-y-2 border-l pl-4">
|
||||
<!-- Children (collapsible) -->
|
||||
<div v-if="node.children.length && !collapsed" class="ml-3 mt-px space-y-px border-l pl-2">
|
||||
<ExplainPlanNodeTree v-for="child in node.children" :key="child.id" :node="child" :depth="(depth || 0) + 1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { AlertCircle, Braces, GitBranch, Table2 } from "@lucide/vue";
|
||||
import { AlertCircle, Braces, GitBranch, Table2, FileText } from "@lucide/vue";
|
||||
import type { ParsedExplainPlan, ExplainPlanNode } from "@/lib/explainPlan";
|
||||
import { flattenExplainPlanNodes } from "@/lib/explainPlan";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -16,7 +16,7 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const activeView = ref<"tree" | "summary" | "json">("tree");
|
||||
const activeView = ref<"tree" | "summary" | "raw">("tree");
|
||||
|
||||
const flatRows = computed(() => {
|
||||
const rows: Array<{ node: ExplainPlanNode; depth: number }> = [];
|
||||
@@ -28,7 +28,15 @@ const flatRows = computed(() => {
|
||||
return rows;
|
||||
});
|
||||
|
||||
const rawJson = computed(() => (props.plan ? JSON.stringify(props.plan.raw, null, 2) : ""));
|
||||
const rawContent = computed(() => {
|
||||
if (!props.plan?.raw) return "";
|
||||
// DM returns raw plan text as a string → show as-is
|
||||
if (typeof props.plan.raw === "string") return props.plan.raw;
|
||||
// Other DBs return JSON → pretty-print
|
||||
return JSON.stringify(props.plan.raw, null, 2);
|
||||
});
|
||||
|
||||
const isRawString = computed(() => typeof props.plan?.raw === "string");
|
||||
const nodeCount = computed(() => (props.plan ? flattenExplainPlanNodes(props.plan.nodes).length : 0));
|
||||
</script>
|
||||
|
||||
@@ -42,6 +50,12 @@ const nodeCount = computed(() => (props.plan ? flattenExplainPlanNodes(props.pla
|
||||
<span v-if="plan" class="text-muted-foreground"
|
||||
>{{ plan.databaseType.toUpperCase() }} · {{ t("explain.nodeCount", { count: nodeCount }) }}</span
|
||||
>
|
||||
<span
|
||||
v-if="plan?.databaseType === 'dameng' && isRawString && rawContent.includes('->')"
|
||||
class="ml-1 inline-flex items-center gap-1 rounded bg-green-100 px-1.5 py-0.5 font-semibold text-green-700 dark:bg-green-900/30 dark:text-green-300"
|
||||
style="font-size: 10px"
|
||||
>A-TRACE</span
|
||||
>
|
||||
<span class="flex-1" />
|
||||
<div v-if="plan" class="inline-flex rounded-md border bg-muted/40 p-0.5">
|
||||
<Button
|
||||
@@ -64,12 +78,13 @@ const nodeCount = computed(() => (props.plan ? flattenExplainPlanNodes(props.pla
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="activeView === 'json' ? 'secondary' : 'ghost'"
|
||||
:variant="activeView === 'raw' ? 'secondary' : 'ghost'"
|
||||
class="h-6 px-2 text-xs gap-1"
|
||||
@click="activeView = 'json'"
|
||||
@click="activeView = 'raw'"
|
||||
>
|
||||
<Braces class="h-3.5 w-3.5" />
|
||||
JSON
|
||||
<FileText v-if="isRawString" class="h-3.5 w-3.5" />
|
||||
<Braces v-else class="h-3.5 w-3.5" />
|
||||
{{ isRawString ? "TEXT" : "JSON" }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,7 +107,7 @@ const nodeCount = computed(() => (props.plan ? flattenExplainPlanNodes(props.pla
|
||||
</div>
|
||||
|
||||
<div v-else class="flex-1 min-h-0 overflow-auto">
|
||||
<div v-if="activeView === 'tree'" class="mx-auto max-w-5xl space-y-3 p-4">
|
||||
<div v-if="activeView === 'tree'" class="mx-auto max-w-5xl space-y-px p-2">
|
||||
<ExplainPlanNodeTree v-for="node in plan.nodes" :key="node.id" :node="node" />
|
||||
</div>
|
||||
|
||||
@@ -127,7 +142,11 @@ const nodeCount = computed(() => (props.plan ? flattenExplainPlanNodes(props.pla
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pre v-else class="m-3 overflow-auto rounded border bg-muted/30 p-3 text-xs leading-relaxed">{{ rawJson }}</pre>
|
||||
<pre
|
||||
v-else
|
||||
class="m-3 overflow-auto whitespace-pre rounded border bg-muted/30 p-3 font-mono text-xs leading-relaxed"
|
||||
>{{ rawContent }}</pre
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -33,12 +33,14 @@ const props = defineProps<{
|
||||
activeTab: QueryTab;
|
||||
activeConnection?: ConnectionConfig;
|
||||
executableSql: string;
|
||||
explainMode?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
execute: [];
|
||||
cancel: [];
|
||||
explain: [];
|
||||
"update:explainMode": [mode: "explain" | "autotrace"];
|
||||
formatSql: [];
|
||||
saveSql: [];
|
||||
openSql: [];
|
||||
@@ -165,6 +167,22 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
|
||||
activeTab.isExplaining ? t("toolbar.stopExplain") : t("toolbar.explainPlan")
|
||||
}}</TooltipContent>
|
||||
</Tooltip>
|
||||
<!-- Autotrace toggle (only for DM) -->
|
||||
<Button
|
||||
v-if="activeConnection?.db_type === 'dameng'"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
:class="
|
||||
props.explainMode === 'autotrace'
|
||||
? 'text-green-600 bg-green-100 dark:text-green-300 dark:bg-green-900/30'
|
||||
: 'text-muted-foreground/50'
|
||||
"
|
||||
:disabled="activeTab.isExecuting"
|
||||
@click="emit('update:explainMode', props.explainMode === 'autotrace' ? 'explain' : 'autotrace')"
|
||||
>
|
||||
<span class="font-bold" style="font-size: 9px">A</span>
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
|
||||
Reference in new issue
Block a user