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:
parent
a5659d7637
commit
897e6998fd
|
|
@ -1,4 +1,5 @@
|
|||
[env]
|
||||
# Enable SQLite's built-in math functions for rusqlite's bundled libsqlite3.
|
||||
LIBSQLITE3_FLAGS = "SQLITE_ENABLE_MATH_FUNCTIONS"
|
||||
|
||||
[target.x86_64-pc-windows-msvc]
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@ const {
|
|||
cancelActiveExecution,
|
||||
tryExplain,
|
||||
onDangerConfirm,
|
||||
explainMode,
|
||||
} = useSqlExecution({
|
||||
activeTab,
|
||||
activeConnection,
|
||||
|
|
@ -1073,6 +1074,8 @@ onUnmounted(() => {
|
|||
:active-tab="activeTab"
|
||||
:active-connection="activeConnection"
|
||||
:executable-sql="executableSql"
|
||||
:explain-mode="explainMode"
|
||||
@update:explain-mode="(m: 'explain' | 'autotrace') => (explainMode = m)"
|
||||
@execute="tryExecute()"
|
||||
@cancel="cancelActiveExecution()"
|
||||
@explain="tryExplain()"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export function useSqlExecution(deps: {
|
|||
const pendingDangerSql = ref("");
|
||||
const showDangerDialog = ref(false);
|
||||
const suppressDangerConfirm = ref(false);
|
||||
const explainMode = ref<"explain" | "autotrace">("explain");
|
||||
|
||||
async function resolvedExecutableSql(): Promise<string> {
|
||||
return deps.resolveExecutableSql ? await deps.resolveExecutableSql() : deps.executableSql.value;
|
||||
|
|
@ -123,7 +124,7 @@ export function useSqlExecution(deps: {
|
|||
}
|
||||
|
||||
deps.activeOutputView.value = "explain";
|
||||
const result = await queryStore.explainTabSql(tab.id, sql, deps.activeConnection.value?.db_type);
|
||||
const result = await queryStore.explainTabSql(tab.id, sql, deps.activeConnection.value?.db_type, explainMode.value);
|
||||
if (!result.ok) {
|
||||
toast(explainReasonMessage(result.reason), 5000);
|
||||
return;
|
||||
|
|
@ -153,5 +154,6 @@ export function useSqlExecution(deps: {
|
|||
cancelActiveExecution,
|
||||
tryExplain,
|
||||
onDangerConfirm,
|
||||
explainMode,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,6 +104,8 @@ export const findStatementAtCursor = forward("findStatementAtCursor");
|
|||
export const prepareQueryPaginationExecutionPlan = forward("prepareQueryPaginationExecutionPlan");
|
||||
export const buildSortedQuerySql = forward("buildSortedQuerySql");
|
||||
export const buildExplainSql = forward("buildExplainSql");
|
||||
export const getExplainInfo = forward("getExplainInfo");
|
||||
export const buildCreateUserSql = forward("buildCreateUserSql");
|
||||
export const buildDroppedFilePreviewSql = forward("buildDroppedFilePreviewSql");
|
||||
export const buildTableSelectSql = forward("buildTableSelectSql");
|
||||
export const buildDatabaseSearchSql = forward("buildDatabaseSearchSql");
|
||||
|
|
|
|||
|
|
@ -169,7 +169,6 @@ export const CREATE_DATABASE_SUPPORTED_TYPES = new Set<DatabaseType>([
|
|||
"sqlserver",
|
||||
"clickhouse",
|
||||
"oracle",
|
||||
"dameng",
|
||||
"gaussdb",
|
||||
"kwdb",
|
||||
"opengauss",
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export interface ExplainPlanNode {
|
|||
}
|
||||
|
||||
export interface ParsedExplainPlan {
|
||||
databaseType: "mysql" | "postgres";
|
||||
databaseType: "mysql" | "postgres" | "dameng";
|
||||
raw: unknown;
|
||||
nodes: ExplainPlanNode[];
|
||||
}
|
||||
|
|
@ -24,8 +24,8 @@ export type BuildExplainSqlResult =
|
|||
| { ok: true; sql: string }
|
||||
| { ok: false; reason: "unsupported" | "empty" | "unsafe" };
|
||||
|
||||
const SUPPORTED_EXPLAIN_TYPES = new Set<DatabaseType>(["mysql", "postgres"]);
|
||||
export function supportsExplainPlan(databaseType?: DatabaseType): databaseType is "mysql" | "postgres" {
|
||||
const SUPPORTED_EXPLAIN_TYPES = new Set<DatabaseType>(["mysql", "postgres", "dameng"]);
|
||||
export function supportsExplainPlan(databaseType?: DatabaseType): databaseType is "mysql" | "postgres" | "dameng" {
|
||||
return !!databaseType && SUPPORTED_EXPLAIN_TYPES.has(databaseType);
|
||||
}
|
||||
|
||||
|
|
@ -33,13 +33,222 @@ export function buildExplainSql(databaseType: DatabaseType | undefined, sql: str
|
|||
return api.buildExplainSql({ databaseType, sql }) as Promise<BuildExplainSqlResult>;
|
||||
}
|
||||
|
||||
export function parseExplainResult(databaseType: "mysql" | "postgres", result: QueryResult): ParsedExplainPlan {
|
||||
export function parseExplainResult(
|
||||
databaseType: "mysql" | "postgres" | "dameng",
|
||||
result: QueryResult,
|
||||
): ParsedExplainPlan {
|
||||
if (databaseType === "dameng") {
|
||||
return parseDamengExplain(result);
|
||||
}
|
||||
const raw = parseExplainCell(result.rows[0]?.[0]);
|
||||
const nodes = databaseType === "postgres" ? parsePostgresExplain(raw) : parseMysqlExplain(raw);
|
||||
|
||||
return { databaseType, raw, nodes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse DM's getExplainInfo() text output.
|
||||
* Format (flat list with indentation):
|
||||
* 1 #NSET2: [cost, rows, width]
|
||||
* 2 #PIPE2: [cost, rows, width]
|
||||
* 3 #PRJT2: [cost, rows, width]; props...
|
||||
* ...
|
||||
* Statistics
|
||||
* logical reads
|
||||
* exec time(ms)
|
||||
*
|
||||
* For autotrace, rows include ->actual: [cost, estRows->actualRows, width]
|
||||
*/
|
||||
export function parseDamengExplainText(planText: string): ParsedExplainPlan {
|
||||
const lines = planText.split("\n");
|
||||
const operatorLines: { indent: number; line: string; raw: string }[] = [];
|
||||
const stats: Record<string, string> = {};
|
||||
let inStats = false;
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const trimmed = rawLine.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
// Check for Statistics section
|
||||
if (trimmed.toLowerCase() === "statistics") {
|
||||
inStats = true;
|
||||
continue;
|
||||
}
|
||||
if (inStats) {
|
||||
const m = trimmed.match(/^(\d+)\s+(.+)$/);
|
||||
if (m) {
|
||||
stats[m[2].trim()] = m[1];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to parse operator line: <line_number><spaces>#OPERATOR...
|
||||
const opMatch = trimmed.match(/^\d+(\s+)#/);
|
||||
if (opMatch) {
|
||||
// indent = number of spaces between line number and #
|
||||
const indent = opMatch[1].length;
|
||||
operatorLines.push({ indent, line: trimmed.substring(trimmed.indexOf("#")), raw: rawLine });
|
||||
}
|
||||
}
|
||||
|
||||
// Build tree from flat operator lines using indent spacing
|
||||
// First line's indent = baseline (depth 0), each +2 spaces = +1 depth
|
||||
const baseIndent = operatorLines.length > 0 ? operatorLines[0].indent : 0;
|
||||
const rootNodes: ExplainPlanNode[] = [];
|
||||
const parentStack: ExplainPlanNode[] = [];
|
||||
|
||||
for (const { indent, line } of operatorLines) {
|
||||
const nodeInfo = parseDamengPlanLine(line);
|
||||
if (!nodeInfo) continue;
|
||||
|
||||
const depth = Math.max(0, Math.round((indent - baseIndent) / 2));
|
||||
while (parentStack.length > depth) parentStack.pop();
|
||||
|
||||
const childIndex =
|
||||
parentStack.length === 0 ? rootNodes.length + 1 : parentStack[parentStack.length - 1].children.length + 1;
|
||||
const id =
|
||||
parentStack.length === 0 ? String(childIndex) : `${parentStack[parentStack.length - 1].id}.${childIndex}`;
|
||||
|
||||
const details: string[] = [];
|
||||
if (nodeInfo.props) details.push(nodeInfo.props);
|
||||
if (nodeInfo.actualRows) details.push(`Actual Rows: ${nodeInfo.actualRows}`);
|
||||
if (nodeInfo.memUsed) details.push(`Memory: ${nodeInfo.memUsed}`);
|
||||
if (nodeInfo.diskUsed) details.push(`Disk: ${nodeInfo.diskUsed}`);
|
||||
|
||||
const node: ExplainPlanNode = {
|
||||
id,
|
||||
title: nodeInfo.relation ? `${nodeInfo.nodeType} on ${nodeInfo.relation}` : nodeInfo.nodeType,
|
||||
nodeType: nodeInfo.operation,
|
||||
cost: nodeInfo.cost || undefined,
|
||||
rows: nodeInfo.rows || undefined,
|
||||
relation: nodeInfo.relation || undefined,
|
||||
details,
|
||||
children: [],
|
||||
};
|
||||
|
||||
if (parentStack.length === 0) {
|
||||
rootNodes.push(node);
|
||||
} else {
|
||||
parentStack[parentStack.length - 1].children.push(node);
|
||||
}
|
||||
parentStack.push(node);
|
||||
}
|
||||
|
||||
// Add stats summary to root node
|
||||
if (Object.keys(stats).length > 0) {
|
||||
const statsDetail = Object.entries(stats)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join(", ");
|
||||
if (rootNodes.length > 0 && !rootNodes[0].details.some((d) => d.startsWith("Statistics:"))) {
|
||||
rootNodes[0].details.push(`Statistics: ${statsDetail}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { databaseType: "dameng", raw: planText, nodes: rootNodes };
|
||||
}
|
||||
|
||||
interface DamengOpInfo {
|
||||
operation: string;
|
||||
nodeType: string;
|
||||
cost?: string;
|
||||
rows?: string;
|
||||
actualRows?: string;
|
||||
width?: string;
|
||||
relation?: string;
|
||||
props?: string;
|
||||
memUsed?: string;
|
||||
diskUsed?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single DM operator line:
|
||||
* #NSET2: [cost, rows, width]; props
|
||||
* #HASH2 INNER JOIN: [cost, rows->actual, width]; KEY_NUM(1), MEM_USED(20352KB)
|
||||
* #CSCN2: [cost, rows, width]; INDEX_NAME; btr_scan(1)
|
||||
*/
|
||||
function parseDamengPlanLine(line: string): DamengOpInfo | null {
|
||||
// Remove leading #
|
||||
const content = line.replace(/^#+/, "").trim();
|
||||
|
||||
// Split at first colon
|
||||
const colonIdx = content.indexOf(":");
|
||||
if (colonIdx < 0) return null;
|
||||
|
||||
const operator = content.substring(0, colonIdx).trim();
|
||||
const rest = content.substring(colonIdx + 1).trim();
|
||||
|
||||
// Extract [cost, rows, width] or [cost, estRows->actualRows, width]
|
||||
let costPart = "";
|
||||
let afterBracket = rest;
|
||||
const bracketMatch = rest.match(/^\[([^\]]+)\]/);
|
||||
if (bracketMatch) {
|
||||
costPart = bracketMatch[1];
|
||||
afterBracket = rest.substring(bracketMatch[0].length).trim();
|
||||
}
|
||||
|
||||
// Parse cost part
|
||||
let cost: string | undefined;
|
||||
let rows: string | undefined;
|
||||
let actualRows: string | undefined;
|
||||
let width: string | undefined;
|
||||
|
||||
if (costPart) {
|
||||
const parts = costPart.split(",").map((s) => s.trim());
|
||||
if (parts.length >= 1) cost = parts[0];
|
||||
if (parts.length >= 2) {
|
||||
const rowPart = parts[1];
|
||||
const arrowIdx = rowPart.indexOf("->");
|
||||
if (arrowIdx >= 0) {
|
||||
rows = rowPart.substring(0, arrowIdx).trim();
|
||||
actualRows = rowPart.substring(arrowIdx + 2).trim();
|
||||
} else {
|
||||
rows = rowPart;
|
||||
}
|
||||
}
|
||||
if (parts.length >= 3) width = parts[2];
|
||||
}
|
||||
|
||||
// Extract props after semicolons
|
||||
const semiParts = afterBracket
|
||||
.split(";")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
let relation: string | undefined;
|
||||
let memUsed: string | undefined;
|
||||
let diskUsed: string | undefined;
|
||||
const otherProps: string[] = [];
|
||||
|
||||
for (const part of semiParts) {
|
||||
if (/^[A-Za-z_]\w*\(/.test(part) || part.includes("(")) {
|
||||
otherProps.push(part);
|
||||
} else if (part.toUpperCase().includes("MEM_USED")) {
|
||||
const m = part.match(/MEM_USED\((\d+)(KB|MB)\)/i);
|
||||
if (m) memUsed = `${m[1]}${m[2]}`;
|
||||
otherProps.push(part);
|
||||
} else if (part.toUpperCase().includes("DISK_USED")) {
|
||||
const m = part.match(/DISK_USED\((\d+)(KB|MB)\)/i);
|
||||
if (m) diskUsed = `${m[1]}${m[2]}`;
|
||||
otherProps.push(part);
|
||||
} else if (!relation && part.length > 0 && part.length < 100 && !part.includes(" ")) {
|
||||
relation = part; // likely an index or table name
|
||||
} else if (part.length > 0) {
|
||||
otherProps.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
operation: operator,
|
||||
nodeType: operator,
|
||||
cost,
|
||||
rows,
|
||||
actualRows,
|
||||
width,
|
||||
relation,
|
||||
props: otherProps.length > 0 ? otherProps.join("; ") : undefined,
|
||||
memUsed,
|
||||
diskUsed,
|
||||
};
|
||||
}
|
||||
|
||||
export function flattenExplainPlanNodes(nodes: ExplainPlanNode[]): ExplainPlanNode[] {
|
||||
const rows: ExplainPlanNode[] = [];
|
||||
function visit(node: ExplainPlanNode) {
|
||||
|
|
@ -59,6 +268,145 @@ function parseExplainCell(value: unknown): unknown {
|
|||
}
|
||||
}
|
||||
|
||||
// ── DM (达梦) tabular explain parser ──────────────────────────────────
|
||||
|
||||
interface DamengExplainRow {
|
||||
id: string;
|
||||
operation: string;
|
||||
options: string;
|
||||
objectName: string;
|
||||
objectType: string;
|
||||
cost: string;
|
||||
cardinality: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* DM's EXPLAIN returns a tabular result set.
|
||||
* Columns: EXPLAIN_ID, ID, OPERATION, OPTIONS, OBJECT_NAME, OBJECT_TYPE,
|
||||
* COST, CARDINALITY, CPU_COST, IO_COST, etc.
|
||||
* ID is hierarchical dot-notation (1, 2, 2.1, 2.2, 3).
|
||||
*/
|
||||
function parseDamengExplain(result: QueryResult): ParsedExplainPlan {
|
||||
const colIndex = buildColumnIndex(result.columns);
|
||||
const rows: DamengExplainRow[] = result.rows.map((row: unknown[]) => ({
|
||||
id: String(row[colIndex.id] ?? ""),
|
||||
operation: String(row[colIndex.operation] ?? ""),
|
||||
options: String(row[colIndex.options] ?? ""),
|
||||
objectName: String(row[colIndex.objectName] ?? ""),
|
||||
objectType: String(row[colIndex.objectType] ?? ""),
|
||||
cost: String(row[colIndex.cost] ?? ""),
|
||||
cardinality: String(row[colIndex.cardinality] ?? ""),
|
||||
}));
|
||||
|
||||
const nodes = buildDamengTree(rows);
|
||||
return { databaseType: "dameng", raw: rows, nodes };
|
||||
}
|
||||
|
||||
function buildColumnIndex(columns: string[]): Record<string, number> {
|
||||
const lower = columns.map((c) => c.toLowerCase());
|
||||
const idx = (name: string) => {
|
||||
const i = lower.indexOf(name);
|
||||
// fallback search for similar names
|
||||
if (i >= 0) return i;
|
||||
if (name === "id") return lower.findIndex((c) => c === "id" || c === "step" || c === "step_id");
|
||||
if (name === "operation") return lower.findIndex((c) => c.includes("operation"));
|
||||
if (name === "options") return lower.findIndex((c) => c === "options" || c === "option");
|
||||
if (name === "objectName") return lower.findIndex((c) => c.includes("object_name"));
|
||||
if (name === "objectType") return lower.findIndex((c) => c.includes("object_type"));
|
||||
if (name === "cost") return lower.findIndex((c) => c === "cost" || c === "total_cost");
|
||||
if (name === "cardinality")
|
||||
return lower.findIndex((c) => c === "cardinality" || c.includes("cardinality") || c === "rows");
|
||||
return -1;
|
||||
};
|
||||
return {
|
||||
id: idx("id"),
|
||||
operation: idx("operation"),
|
||||
options: idx("options"),
|
||||
objectName: idx("objectName"),
|
||||
objectType: idx("objectType"),
|
||||
cost: idx("cost"),
|
||||
cardinality: idx("cardinality"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a tree from flat DM explain rows using dot-notation ID hierarchy.
|
||||
* Root nodes have IDs like "1", "2", "3".
|
||||
* Children have IDs like "1.1", "1.2", "2.1.1".
|
||||
*/
|
||||
function buildDamengTree(rows: DamengExplainRow[]): ExplainPlanNode[] {
|
||||
const nodes: ExplainPlanNode[] = [];
|
||||
|
||||
// First pass: create all nodes
|
||||
const nodeMap = new Map<string, ExplainPlanNode>();
|
||||
for (const row of rows) {
|
||||
const nodeType = [row.operation, row.options].filter(Boolean).join(" ");
|
||||
const relation = row.objectName || undefined;
|
||||
const index = row.objectType === "INDEX" ? row.objectName : undefined;
|
||||
|
||||
const details: string[] = [];
|
||||
if (row.objectType && row.objectType !== "TABLE" && row.objectType !== "INDEX") {
|
||||
details.push(`Object Type: ${row.objectType}`);
|
||||
}
|
||||
if (row.cardinality && row.cardinality !== "0") {
|
||||
details.push(`Cardinality: ${row.cardinality}`);
|
||||
}
|
||||
|
||||
const node: ExplainPlanNode = {
|
||||
id: row.id,
|
||||
title: relation ? `${nodeType} on ${relation}` : nodeType || "Plan",
|
||||
nodeType: row.operation || "Plan",
|
||||
relation,
|
||||
index,
|
||||
cost: row.cost || undefined,
|
||||
rows: row.cardinality || undefined,
|
||||
details,
|
||||
children: [],
|
||||
};
|
||||
nodeMap.set(row.id, node);
|
||||
}
|
||||
|
||||
// Second pass: build parent-child relationships based on dot notation
|
||||
for (const row of rows) {
|
||||
const node = nodeMap.get(row.id);
|
||||
if (!node) continue;
|
||||
|
||||
const parts = row.id.split(".");
|
||||
if (parts.length === 1) {
|
||||
// Root-level node
|
||||
nodes.push(node);
|
||||
} else {
|
||||
// Child node: find parent
|
||||
const parentId = parts.slice(0, -1).join(".");
|
||||
const parent = nodeMap.get(parentId);
|
||||
if (parent) {
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
// Orphan — treat as root
|
||||
nodes.push(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort children by their last ID segment (numerically)
|
||||
function sortChildren(nodes: ExplainPlanNode[]) {
|
||||
for (const n of nodes) {
|
||||
sortChildren(n.children);
|
||||
}
|
||||
nodes.sort((a, b) => {
|
||||
const aLast = parseInt(a.id.split(".").pop() || "0", 10);
|
||||
const bLast = parseInt(b.id.split(".").pop() || "0", 10);
|
||||
return aLast - bLast;
|
||||
});
|
||||
}
|
||||
sortChildren(nodes);
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
// ── PostgreSQL JSON explain parser ────────────────────────────────────
|
||||
|
||||
function parsePostgresExplain(raw: unknown): ExplainPlanNode[] {
|
||||
const plans = Array.isArray(raw) ? raw : [raw];
|
||||
return plans
|
||||
|
|
@ -107,6 +455,8 @@ function parsePostgresNode(plan: Record<string, unknown> | null, id: string): Ex
|
|||
};
|
||||
}
|
||||
|
||||
// ── MySQL JSON explain parser ─────────────────────────────────────────
|
||||
|
||||
function parseMysqlExplain(raw: unknown): ExplainPlanNode[] {
|
||||
const root = objectValue(raw);
|
||||
if (!root) return [];
|
||||
|
|
@ -183,6 +533,8 @@ function parseMysqlTable(table: Record<string, unknown>, id: string): ExplainPla
|
|||
};
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function objectValue(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
|
|
|
|||
|
|
@ -607,6 +607,25 @@ export async function buildExplainSql(options: BuildExplainSqlOptions): Promise<
|
|||
return post("/api/query/build-explain-sql", { options });
|
||||
}
|
||||
|
||||
export async function buildCreateUserSql(username: string, password: string, tablespace: string): Promise<string> {
|
||||
return post("/api/query/build-create-user-sql", { username, password, tablespace });
|
||||
}
|
||||
|
||||
export async function getExplainInfo(
|
||||
connectionId: string,
|
||||
database: string | undefined,
|
||||
schema: string | undefined,
|
||||
sql: string,
|
||||
mode: string,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const result = await post<string>("/api/query/get-explain-info", { connectionId, database, schema, sql, mode });
|
||||
return result;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildDroppedFilePreviewSql(options: DroppedFilePreviewSqlOptions): Promise<string | undefined> {
|
||||
const result = await post<string | null>("/api/query/build-dropped-file-preview-sql", { options });
|
||||
return result ?? undefined;
|
||||
|
|
|
|||
|
|
@ -608,6 +608,26 @@ export async function buildExplainSql(options: BuildExplainSqlOptions): Promise<
|
|||
return invoke("build_explain_sql", { options });
|
||||
}
|
||||
|
||||
export async function buildCreateUserSql(username: string, password: string, tablespace: string): Promise<string> {
|
||||
return invoke("build_create_user_sql", { username, password, tablespace });
|
||||
}
|
||||
|
||||
export async function getExplainInfo(
|
||||
connectionId: string,
|
||||
database: string | undefined,
|
||||
schema: string | undefined,
|
||||
sql: string,
|
||||
mode: string,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const result = await invoke<string>("get_explain_info", { connectionId, database, schema, sql, mode });
|
||||
return result;
|
||||
} catch (e: any) {
|
||||
console.error("[getExplainInfo] invoke failed:", e?.message || e);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildDroppedFilePreviewSql(options: DroppedFilePreviewSqlOptions): Promise<string | undefined> {
|
||||
const result = await invoke<string | null>("build_dropped_file_preview_sql", { options });
|
||||
return result ?? undefined;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import type { DatabaseType, QueryResult, QueryTab } from "@/types/database";
|
|||
import { orderPinnedFirst } from "@/lib/pinnedItems";
|
||||
import { canCancelQueryExecution } from "@/lib/queryExecutionState";
|
||||
import { closeAllTabsState, closeOtherTabsState } from "@/lib/tabCloseActions";
|
||||
import { buildExplainSql, parseExplainResult } from "@/lib/explainPlan";
|
||||
import { buildExplainSql, parseExplainResult, parseDamengExplainText } from "@/lib/explainPlan";
|
||||
import {
|
||||
allEditableColumnsWriteable,
|
||||
allPrimaryKeysPresent,
|
||||
|
|
@ -1319,11 +1319,63 @@ export const useQueryStore = defineStore("query", () => {
|
|||
await trimResultCache();
|
||||
}
|
||||
|
||||
async function explainTabSql(id: string, sql: string, databaseType?: DatabaseType) {
|
||||
async function explainTabSql(id: string, sql: string, databaseType?: DatabaseType, explainMode?: string) {
|
||||
const tab = tabs.value.find((t) => t.id === id);
|
||||
if (!tab) return { ok: false as const, reason: "empty" as const };
|
||||
const conn = useConnectionStore().getConfig(tab.connectionId);
|
||||
const queryTimeoutSecs = queryTimeoutSecsForConnection(conn);
|
||||
const executionId = uuid();
|
||||
|
||||
tab.isExplaining = true;
|
||||
tab.explainExecutionId = executionId;
|
||||
tab.explainError = undefined;
|
||||
tab.lastExplainedSql = sql;
|
||||
|
||||
// DM uses native getExplainInfo via JDBC (supports explain + autotrace modes)
|
||||
// Autotrace mode executes the SQL — reject dangerous statements
|
||||
if (databaseType === "dameng") {
|
||||
if (explainMode === "autotrace") {
|
||||
const DANGER_RE = /^\s*(DROP|DELETE|TRUNCATE|ALTER|UPDATE|MERGE|REPLACE)\b/i;
|
||||
const cleaned = sql
|
||||
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
||||
.replace(/--.*$/gm, " ")
|
||||
.replace(/#.*$/gm, " ");
|
||||
if (cleaned.split(";").some((stmt) => DANGER_RE.test(stmt))) {
|
||||
tab.isExplaining = false;
|
||||
tab.explainExecutionId = undefined;
|
||||
return { ok: false as const, reason: "unsafe" as const };
|
||||
}
|
||||
}
|
||||
try {
|
||||
const mode = explainMode === "autotrace" ? "autotrace" : "explain";
|
||||
const planText = (await api.getExplainInfo(tab.connectionId, tab.database, tab.schema, sql, mode)) as
|
||||
| string
|
||||
| undefined;
|
||||
const current = tabs.value.find((t) => t.id === id);
|
||||
if (current?.explainExecutionId === executionId) {
|
||||
if (planText && planText.length > 0) {
|
||||
current.explainPlan = parseDamengExplainText(planText);
|
||||
current.explainSql = sql;
|
||||
current.explainError = undefined;
|
||||
} else {
|
||||
current.explainPlan = undefined;
|
||||
current.explainError = "No explain plan returned";
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
const current = tabs.value.find((t) => t.id === id);
|
||||
if (current?.explainExecutionId === executionId) {
|
||||
current.explainPlan = undefined;
|
||||
current.explainError = String(e?.message || e);
|
||||
}
|
||||
} finally {
|
||||
const current = tabs.value.find((t) => t.id === id);
|
||||
if (current?.explainExecutionId === executionId) {
|
||||
current.isExplaining = false;
|
||||
}
|
||||
}
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
const built = await buildExplainSql(databaseType, sql);
|
||||
if (!built.ok) {
|
||||
|
|
@ -1332,12 +1384,7 @@ export const useQueryStore = defineStore("query", () => {
|
|||
return built;
|
||||
}
|
||||
|
||||
const executionId = uuid();
|
||||
tab.isExplaining = true;
|
||||
tab.explainExecutionId = executionId;
|
||||
tab.explainError = undefined;
|
||||
tab.explainSql = built.sql;
|
||||
tab.lastExplainedSql = sql;
|
||||
try {
|
||||
const result = await api.executeQuery(tab.connectionId, tab.database, built.sql, tab.schema, executionId, {
|
||||
timeoutSecs: queryTimeoutSecs,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@
|
|||
"execute_query_page",
|
||||
"fetch_query_page",
|
||||
"close_query_session",
|
||||
"get_explain_info",
|
||||
"execute_transaction",
|
||||
"disconnect",
|
||||
"shutdown"
|
||||
|
|
|
|||
|
|
@ -96,13 +96,14 @@ pub enum AgentMethod {
|
|||
ExecuteQueryPage,
|
||||
FetchQueryPage,
|
||||
CloseQuerySession,
|
||||
GetExplainInfo,
|
||||
ExecuteTransaction,
|
||||
Disconnect,
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
impl AgentMethod {
|
||||
pub const ALL: [Self; 20] = [
|
||||
pub const ALL: [Self; 21] = [
|
||||
Self::Handshake,
|
||||
Self::Connect,
|
||||
Self::TestConnection,
|
||||
|
|
@ -120,6 +121,7 @@ impl AgentMethod {
|
|||
Self::ExecuteQueryPage,
|
||||
Self::FetchQueryPage,
|
||||
Self::CloseQuerySession,
|
||||
Self::GetExplainInfo,
|
||||
Self::ExecuteTransaction,
|
||||
Self::Disconnect,
|
||||
Self::Shutdown,
|
||||
|
|
@ -144,6 +146,7 @@ impl AgentMethod {
|
|||
Self::ExecuteQueryPage => "execute_query_page",
|
||||
Self::FetchQueryPage => "fetch_query_page",
|
||||
Self::CloseQuerySession => "close_query_session",
|
||||
Self::GetExplainInfo => "get_explain_info",
|
||||
Self::ExecuteTransaction => "execute_transaction",
|
||||
Self::Disconnect => "disconnect",
|
||||
Self::Shutdown => "shutdown",
|
||||
|
|
@ -542,6 +545,10 @@ impl AgentDriverClient {
|
|||
self.call_method_with_timeout(AgentMethod::FetchQueryPage, params, timeout_duration).await
|
||||
}
|
||||
|
||||
pub async fn get_explain_info<T: DeserializeOwned + Send + 'static>(&mut self, params: Value) -> Result<T, String> {
|
||||
self.call_method(AgentMethod::GetExplainInfo, params).await
|
||||
}
|
||||
|
||||
pub async fn close_query_session<T: DeserializeOwned + Send + 'static>(
|
||||
&mut self,
|
||||
session_id: &str,
|
||||
|
|
|
|||
|
|
@ -144,6 +144,15 @@ pub fn build_duckdb_attach_database_sql(options: DuckDbAttachDatabaseSqlOptions)
|
|||
)
|
||||
}
|
||||
|
||||
pub fn build_create_user_sql(username: &str, password: &str, tablespace: &str) -> String {
|
||||
format!(
|
||||
"CREATE USER {} IDENTIFIED BY {} DEFAULT TABLESPACE {};",
|
||||
quote_table_identifier(Some(DatabaseType::Dameng), username),
|
||||
quote_sql_string(password),
|
||||
quote_table_identifier(Some(DatabaseType::Dameng), tablespace)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_drop_object_sql(options: DropObjectSqlOptions) -> String {
|
||||
format!(
|
||||
"DROP {} {};",
|
||||
|
|
@ -492,6 +501,14 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_dameng_create_user_sql_with_escaped_values() {
|
||||
assert_eq!(
|
||||
build_create_user_sql("app\"user", "pa'ss", "main\"space"),
|
||||
"CREATE USER \"app\"\"user\" IDENTIFIED BY 'pa''ss' DEFAULT TABLESPACE \"main\"\"space\";"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_drop_and_clear_table_sql() {
|
||||
let options = TableAdminSqlOptions {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ pub fn build_explain_sql(options: ExplainSqlOptions) -> ExplainSqlBuildResult {
|
|||
|
||||
let sql = if options.database_type == Some(DatabaseType::Postgres) {
|
||||
format!("EXPLAIN (FORMAT JSON) {source}")
|
||||
} else if options.database_type == Some(DatabaseType::Dameng) {
|
||||
format!("EXPLAIN {source}")
|
||||
} else {
|
||||
format!("EXPLAIN FORMAT=JSON {source}")
|
||||
};
|
||||
|
|
@ -69,7 +71,15 @@ pub fn build_dropped_file_preview_sql(options: DroppedFilePreviewSqlOptions) ->
|
|||
}
|
||||
|
||||
pub fn supports_explain_plan(database_type: Option<DatabaseType>) -> bool {
|
||||
matches!(database_type, Some(DatabaseType::Mysql | DatabaseType::Postgres))
|
||||
matches!(database_type, Some(DatabaseType::Mysql | DatabaseType::Postgres | DatabaseType::Dameng))
|
||||
}
|
||||
|
||||
pub fn is_safe_dameng_autotrace_sql(sql: &str) -> bool {
|
||||
let source = strip_trailing_semicolons(sql.trim());
|
||||
if source.is_empty() || has_extra_statement_after_semicolon(&source) {
|
||||
return false;
|
||||
}
|
||||
is_safe_explain_source(&source) && !contains_dangerous_sql_keyword(&source)
|
||||
}
|
||||
|
||||
fn explain_err(reason: &str) -> ExplainSqlBuildResult {
|
||||
|
|
@ -87,6 +97,42 @@ fn is_safe_explain_source(sql: &str) -> bool {
|
|||
})
|
||||
}
|
||||
|
||||
fn contains_dangerous_sql_keyword(sql: &str) -> bool {
|
||||
let source = strip_sql_comments_and_literals(sql).to_lowercase();
|
||||
["drop", "delete", "truncate", "alter", "update", "merge", "replace", "insert", "create"]
|
||||
.iter()
|
||||
.any(|keyword| contains_word(&source, keyword))
|
||||
}
|
||||
|
||||
fn contains_word(source: &str, word: &str) -> bool {
|
||||
let bytes = source.as_bytes();
|
||||
let word_bytes = word.as_bytes();
|
||||
if word_bytes.is_empty() || bytes.len() < word_bytes.len() {
|
||||
return false;
|
||||
}
|
||||
|
||||
for idx in 0..=bytes.len() - word_bytes.len() {
|
||||
if &bytes[idx..idx + word_bytes.len()] != word_bytes {
|
||||
continue;
|
||||
}
|
||||
let before = idx.checked_sub(1).and_then(|i| bytes.get(i)).copied();
|
||||
let after = bytes.get(idx + word_bytes.len()).copied();
|
||||
if !is_identifier_byte(before) && !is_identifier_byte(after) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_identifier_byte(byte: Option<u8>) -> bool {
|
||||
byte.is_some_and(|b| b.is_ascii_alphanumeric() || b == b'_')
|
||||
}
|
||||
|
||||
fn has_extra_statement_after_semicolon(sql: &str) -> bool {
|
||||
let stripped = strip_sql_comments_and_literals(sql);
|
||||
stripped.split(';').skip(1).any(|part| !part.trim().is_empty())
|
||||
}
|
||||
|
||||
fn strip_sql_comments(sql: &str) -> String {
|
||||
let mut output = String::with_capacity(sql.len());
|
||||
let mut chars = sql.chars().peekable();
|
||||
|
|
@ -132,6 +178,87 @@ fn strip_sql_comments(sql: &str) -> String {
|
|||
output
|
||||
}
|
||||
|
||||
fn strip_sql_comments_and_literals(sql: &str) -> String {
|
||||
let mut output = String::with_capacity(sql.len());
|
||||
let mut chars = sql.chars().peekable();
|
||||
let mut in_line_comment = false;
|
||||
let mut in_block_comment = false;
|
||||
let mut in_single_quote = false;
|
||||
let mut in_double_quote = false;
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
if in_line_comment {
|
||||
if ch == '\n' {
|
||||
in_line_comment = false;
|
||||
output.push(' ');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_block_comment {
|
||||
if ch == '*' && chars.peek() == Some(&'/') {
|
||||
chars.next();
|
||||
in_block_comment = false;
|
||||
output.push(' ');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_single_quote {
|
||||
if ch == '\'' {
|
||||
if chars.peek() == Some(&'\'') {
|
||||
chars.next();
|
||||
} else {
|
||||
in_single_quote = false;
|
||||
}
|
||||
}
|
||||
output.push(' ');
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_double_quote {
|
||||
if ch == '"' {
|
||||
if chars.peek() == Some(&'"') {
|
||||
chars.next();
|
||||
} else {
|
||||
in_double_quote = false;
|
||||
}
|
||||
}
|
||||
output.push(' ');
|
||||
continue;
|
||||
}
|
||||
|
||||
if ch == '-' && chars.peek() == Some(&'-') {
|
||||
chars.next();
|
||||
in_line_comment = true;
|
||||
continue;
|
||||
}
|
||||
if ch == '#' {
|
||||
in_line_comment = true;
|
||||
continue;
|
||||
}
|
||||
if ch == '/' && chars.peek() == Some(&'*') {
|
||||
chars.next();
|
||||
in_block_comment = true;
|
||||
continue;
|
||||
}
|
||||
if ch == '\'' {
|
||||
in_single_quote = true;
|
||||
output.push(' ');
|
||||
continue;
|
||||
}
|
||||
if ch == '"' {
|
||||
in_double_quote = true;
|
||||
output.push(' ');
|
||||
continue;
|
||||
}
|
||||
|
||||
output.push(ch);
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -153,6 +280,33 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_dameng_explain_sql() {
|
||||
let result = build_explain_sql(ExplainSqlOptions {
|
||||
database_type: Some(DatabaseType::Dameng),
|
||||
sql: "SELECT * FROM t1 WHERE id = 1".to_string(),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
ExplainSqlBuildResult {
|
||||
ok: true,
|
||||
sql: Some("EXPLAIN SELECT * FROM t1 WHERE id = 1".to_string()),
|
||||
reason: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_dameng_autotrace_sql_safety() {
|
||||
assert!(is_safe_dameng_autotrace_sql("SELECT * FROM t WHERE name = 'delete';"));
|
||||
assert!(is_safe_dameng_autotrace_sql("/* comment */ WITH q AS (SELECT 1) SELECT * FROM q"));
|
||||
assert!(!is_safe_dameng_autotrace_sql("SELECT * FROM t; DELETE FROM t"));
|
||||
assert!(!is_safe_dameng_autotrace_sql("UPDATE t SET name = 'x'"));
|
||||
assert!(!is_safe_dameng_autotrace_sql("SELECT * FROM t; /* hidden */ DROP TABLE t"));
|
||||
assert!(!is_safe_dameng_autotrace_sql(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_mysql_json_explain_and_rejects_unsafe_sql() {
|
||||
assert_eq!(
|
||||
|
|
|
|||
|
|
@ -166,6 +166,8 @@ async fn main() {
|
|||
.route("/query/build-sorted-sql", post(routes::query::build_sorted_query_sql))
|
||||
.route("/query/build-explain-sql", post(routes::query::build_explain_sql))
|
||||
.route("/query/build-dropped-file-preview-sql", post(routes::query::build_dropped_file_preview_sql))
|
||||
.route("/query/get-explain-info", post(routes::query::get_explain_info))
|
||||
.route("/query/build-create-user-sql", post(routes::query::build_create_user_sql))
|
||||
.route("/query/build-table-select-sql", post(routes::query::build_table_select_sql))
|
||||
.route("/query/build-database-search-sql", post(routes::query::build_database_search_sql))
|
||||
.route("/query/build-search-result-where", post(routes::query::build_search_result_where))
|
||||
|
|
|
|||
|
|
@ -460,6 +460,74 @@ pub async fn build_explain_sql(
|
|||
Json(dbx_core::query_execution_sql::build_explain_sql(req.options))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetExplainInfoRequest {
|
||||
pub connection_id: String,
|
||||
pub database: Option<String>,
|
||||
pub schema: Option<String>,
|
||||
pub sql: String,
|
||||
pub mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildCreateUserSqlRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub tablespace: String,
|
||||
}
|
||||
|
||||
pub async fn get_explain_info(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<GetExplainInfoRequest>,
|
||||
) -> Result<Json<String>, AppError> {
|
||||
let client = {
|
||||
let connections = state.app.connections.read().await;
|
||||
let pool = connections.get(&req.connection_id).ok_or_else(|| AppError("Connection not found".to_string()))?;
|
||||
match pool {
|
||||
dbx_core::connection::PoolKind::Agent(client) => client.clone(),
|
||||
_ => return Err(AppError("Connection is not an agent-based connection".to_string())),
|
||||
}
|
||||
};
|
||||
|
||||
let config = {
|
||||
let configs = state.app.configs.read().await;
|
||||
configs.get(&req.connection_id).cloned()
|
||||
};
|
||||
let config = config.ok_or_else(|| AppError("Connection config not found".to_string()))?;
|
||||
let timeout_secs = config.query_timeout_secs;
|
||||
|
||||
let mut client = client.lock().await;
|
||||
let mode = req.mode.unwrap_or_else(|| "explain".to_string());
|
||||
if mode.eq_ignore_ascii_case("autotrace") && !dbx_core::query_execution_sql::is_safe_dameng_autotrace_sql(&req.sql)
|
||||
{
|
||||
return Err(AppError("unsafe".to_string()));
|
||||
}
|
||||
let params = serde_json::json!({
|
||||
"sql": req.sql,
|
||||
"database": req.database.unwrap_or_default(),
|
||||
"schema": req.schema.unwrap_or_default(),
|
||||
"timeoutSecs": timeout_secs as i64,
|
||||
"mode": mode,
|
||||
});
|
||||
|
||||
let result: Result<serde_json::Value, String> = client.get_explain_info::<serde_json::Value>(params).await;
|
||||
match result {
|
||||
Ok(serde_json::Value::String(s)) => Ok(Json(s)),
|
||||
Ok(serde_json::Value::Object(obj)) => {
|
||||
let plan = obj.get("plan").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
Ok(Json(plan))
|
||||
}
|
||||
Ok(val) => Err(AppError(format!("Unexpected result type from getExplainInfo: {:?}", val))),
|
||||
Err(e) => Err(AppError(e)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn build_create_user_sql(Json(req): Json<BuildCreateUserSqlRequest>) -> Result<Json<String>, AppError> {
|
||||
Ok(Json(dbx_core::db_admin_sql::build_create_user_sql(&req.username, &req.password, &req.tablespace)))
|
||||
}
|
||||
|
||||
pub async fn build_dropped_file_preview_sql(
|
||||
Json(req): Json<BuildDroppedFilePreviewSqlRequest>,
|
||||
) -> Json<Option<String>> {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import java.sql.ResultSet;
|
|||
import java.sql.ResultSetMetaData;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.SQLFeatureNotSupportedException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.sql.Statement;
|
||||
import java.sql.Time;
|
||||
import java.sql.Timestamp;
|
||||
|
|
@ -170,6 +171,14 @@ public final class DbxJdbcPlugin {
|
|||
optionalText(params, "schema"),
|
||||
requireText(params, "table")
|
||||
);
|
||||
case "getExplainInfo" -> getExplainInfo(
|
||||
connection,
|
||||
requireText(params, "sql"),
|
||||
optionalText(params, "database"),
|
||||
optionalText(params, "schema"),
|
||||
nonNegativeInt(params, "timeoutSecs", -1),
|
||||
optionalText(params, "mode")
|
||||
);
|
||||
default -> throw new IllegalArgumentException("Unsupported JDBC plugin method: " + method);
|
||||
};
|
||||
}
|
||||
|
|
@ -312,6 +321,107 @@ public final class DbxJdbcPlugin {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get DM execution plan using DmdbConnection.getExplainInfo() via reflection.
|
||||
*
|
||||
* Two modes:
|
||||
* mode="explain" (default) — dmConn.getExplainInfo(sqlStr) — direct plan, no execution
|
||||
* mode="autotrace" — execute SQL, then dmConn.getExplainInfo(stmt) — actual stats
|
||||
*
|
||||
* Falls back to standard EXPLAIN if DM driver is not available.
|
||||
*/
|
||||
private static JsonNode getExplainInfo(
|
||||
JsonNode connection,
|
||||
String sql,
|
||||
String database,
|
||||
String schema,
|
||||
int timeoutSecs,
|
||||
String mode
|
||||
) throws Exception {
|
||||
Connection conn = openConnection(connection);
|
||||
applyExecutionContext(connection, conn, database, schema);
|
||||
|
||||
boolean autotrace = "autotrace".equalsIgnoreCase(mode);
|
||||
String planText = null;
|
||||
String dmMethod = null;
|
||||
|
||||
if (autotrace) {
|
||||
if (!isSafeAutotraceSql(sql)) {
|
||||
throw new IllegalArgumentException("unsafe");
|
||||
}
|
||||
// ── Autotrace mode: execute SQL first, then getExplainInfo(stmt) ──
|
||||
boolean monitorEnabled = false;
|
||||
try (Statement s = conn.createStatement()) {
|
||||
s.execute("SF_SET_SESSION_PARA_VALUE('MONITOR_SQL_EXEC', 1)");
|
||||
monitorEnabled = true;
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
try {
|
||||
try (Statement stmt = conn.createStatement()) {
|
||||
if (timeoutSecs >= 0) {
|
||||
try { stmt.setQueryTimeout(timeoutSecs); } catch (SQLFeatureNotSupportedException ignored) {}
|
||||
}
|
||||
boolean hasResultSet = stmt.execute(trimStatementSql(sql));
|
||||
if (hasResultSet) {
|
||||
try (ResultSet rs = stmt.getResultSet()) {
|
||||
while (rs.next()) { /* consume */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Try DM getExplainInfo(Statement)
|
||||
try {
|
||||
Class<?> dmConnClass = Class.forName("dm.jdbc.driver.DmdbConnection");
|
||||
if (dmConnClass.isInstance(conn)) {
|
||||
Method m = dmConnClass.getMethod("getExplainInfo", Statement.class);
|
||||
planText = (String) m.invoke(dmConnClass.cast(conn), stmt);
|
||||
dmMethod = "getExplainInfo(stmt)";
|
||||
}
|
||||
} catch (ClassNotFoundException | NoSuchMethodException e) {
|
||||
// Not DM or DM driver version doesn't support it
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (monitorEnabled) {
|
||||
try (Statement s = conn.createStatement()) {
|
||||
s.execute("SF_SET_SESSION_PARA_VALUE('MONITOR_SQL_EXEC', 0)");
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ── Explain mode: direct plan via getExplainInfo(sqlStr), no execution ──
|
||||
try {
|
||||
Class<?> dmConnClass = Class.forName("dm.jdbc.driver.DmdbConnection");
|
||||
if (dmConnClass.isInstance(conn)) {
|
||||
Method m = dmConnClass.getMethod("getExplainInfo", String.class);
|
||||
planText = (String) m.invoke(dmConnClass.cast(conn), sql);
|
||||
dmMethod = "getExplainInfo(sql)";
|
||||
}
|
||||
} catch (ClassNotFoundException | NoSuchMethodException e) {
|
||||
// Not DM or DM driver version doesn't support it
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if DM method didn't work, try standard EXPLAIN
|
||||
if (planText == null || planText.trim().isEmpty()) {
|
||||
try (Statement explainStmt = conn.createStatement();
|
||||
ResultSet rs = explainStmt.executeQuery("EXPLAIN " + sql)) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
while (rs.next()) {
|
||||
sb.append(rs.getString(1)).append("\n");
|
||||
}
|
||||
planText = sb.toString().trim();
|
||||
}
|
||||
dmMethod = "explain(sql)";
|
||||
}
|
||||
|
||||
ObjectNode result = MAPPER.createObjectNode();
|
||||
result.put("ok", true);
|
||||
result.put("plan", planText != null ? planText : "");
|
||||
result.put("has_actual_stats", "getExplainInfo(stmt)".equals(dmMethod));
|
||||
result.put("mode", autotrace ? "autotrace" : "explain");
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void applyStatementOptions(Statement statement, int maxRows, int fetchSize, int timeoutSecs)
|
||||
throws SQLException {
|
||||
statement.setMaxRows((int) Math.min(Integer.MAX_VALUE, (long) maxRows + 1L));
|
||||
|
|
@ -333,6 +443,133 @@ public final class DbxJdbcPlugin {
|
|||
return sql == null ? "" : sql.trim().replaceFirst(";\\s*$", "");
|
||||
}
|
||||
|
||||
private static boolean isSafeAutotraceSql(String sql) {
|
||||
String stripped = stripCommentsAndLiterals(trimStatementSql(sql));
|
||||
if (stripped.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
String[] statements = stripped.split(";", -1);
|
||||
for (int i = 1; i < statements.length; i++) {
|
||||
if (!statements[i].isBlank()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
String lower = statements[0].stripLeading().toLowerCase(Locale.ROOT);
|
||||
boolean readOnly = lower.equals("select")
|
||||
|| lower.startsWith("select ")
|
||||
|| lower.startsWith("select\n")
|
||||
|| lower.equals("with")
|
||||
|| lower.startsWith("with ")
|
||||
|| lower.startsWith("with\n")
|
||||
|| lower.equals("table")
|
||||
|| lower.startsWith("table ")
|
||||
|| lower.startsWith("table\n")
|
||||
|| lower.equals("values")
|
||||
|| lower.startsWith("values ")
|
||||
|| lower.startsWith("values\n");
|
||||
if (!readOnly) {
|
||||
return false;
|
||||
}
|
||||
for (String keyword : new String[] {"drop", "delete", "truncate", "alter", "update", "merge", "replace", "insert", "create"}) {
|
||||
if (containsWord(lower, keyword)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean containsWord(String source, String word) {
|
||||
int index = source.indexOf(word);
|
||||
while (index >= 0) {
|
||||
boolean before = index == 0 || !isIdentifierChar(source.charAt(index - 1));
|
||||
int afterIndex = index + word.length();
|
||||
boolean after = afterIndex >= source.length() || !isIdentifierChar(source.charAt(afterIndex));
|
||||
if (before && after) {
|
||||
return true;
|
||||
}
|
||||
index = source.indexOf(word, index + 1);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isIdentifierChar(char ch) {
|
||||
return Character.isLetterOrDigit(ch) || ch == '_';
|
||||
}
|
||||
|
||||
private static String stripCommentsAndLiterals(String sql) {
|
||||
StringBuilder output = new StringBuilder(sql.length());
|
||||
boolean inLineComment = false;
|
||||
boolean inBlockComment = false;
|
||||
boolean inSingleQuote = false;
|
||||
boolean inDoubleQuote = false;
|
||||
|
||||
for (int i = 0; i < sql.length(); i++) {
|
||||
char ch = sql.charAt(i);
|
||||
char next = i + 1 < sql.length() ? sql.charAt(i + 1) : '\0';
|
||||
|
||||
if (inLineComment) {
|
||||
if (ch == '\n') {
|
||||
inLineComment = false;
|
||||
output.append(' ');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inBlockComment) {
|
||||
if (ch == '*' && next == '/') {
|
||||
i++;
|
||||
inBlockComment = false;
|
||||
output.append(' ');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inSingleQuote) {
|
||||
if (ch == '\'' && next == '\'') {
|
||||
i++;
|
||||
} else if (ch == '\'') {
|
||||
inSingleQuote = false;
|
||||
}
|
||||
output.append(' ');
|
||||
continue;
|
||||
}
|
||||
if (inDoubleQuote) {
|
||||
if (ch == '"' && next == '"') {
|
||||
i++;
|
||||
} else if (ch == '"') {
|
||||
inDoubleQuote = false;
|
||||
}
|
||||
output.append(' ');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch == '-' && next == '-') {
|
||||
i++;
|
||||
inLineComment = true;
|
||||
continue;
|
||||
}
|
||||
if (ch == '#') {
|
||||
inLineComment = true;
|
||||
continue;
|
||||
}
|
||||
if (ch == '/' && next == '*') {
|
||||
i++;
|
||||
inBlockComment = true;
|
||||
continue;
|
||||
}
|
||||
if (ch == '\'') {
|
||||
inSingleQuote = true;
|
||||
output.append(' ');
|
||||
continue;
|
||||
}
|
||||
if (ch == '"') {
|
||||
inDoubleQuote = true;
|
||||
output.append(' ');
|
||||
continue;
|
||||
}
|
||||
output.append(ch);
|
||||
}
|
||||
return output.toString();
|
||||
}
|
||||
|
||||
private static void applyExecutionContext(JsonNode connection, Connection conn, String database, String schema) throws SQLException {
|
||||
if (driverQuirks(connection).skipExecutionContext()) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -1004,48 +1004,56 @@ packages:
|
|||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-arm64-musl@0.53.0':
|
||||
resolution: {integrity: sha512-I6bhOTroqc3ThrwZ89l2k3ivKuELhdPLbAcJhRNyjWvlgwb0vjRgEnVL1XLx5Jud04/ypNRZBykAWrSk6l/D+g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-linux-ppc64-gnu@0.53.0':
|
||||
resolution: {integrity: sha512-w0p3JzB/PkkQjXALMJMqP9YfP3yq4w6zGsu5kezQmUnxRkN3b/Theg2l/nDgBsOcczxS3gL6Gam5XNAVrO6QJQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-gnu@0.53.0':
|
||||
resolution: {integrity: sha512-mzBhF6k1Yq1K/dqDmVe/AAafnlJfEpx7yfUiksyeWXJk5iSzZqBSxcsa02zIytYgQFRZ7h6WPZfwHg/DoOE1Kw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-musl@0.53.0':
|
||||
resolution: {integrity: sha512-AlFCpnRQhogQFzZXWbO6xB6/Udy745L+eQNmDPGg7G/OeWsYmJc4jZYfUN5pQg0reOPWSED2mOQqKZOJM1U8cA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-linux-s390x-gnu@0.53.0':
|
||||
resolution: {integrity: sha512-XD4ulY4f1DWbuuZXAqxhVn+gdPmrhnmojWtFN78ctVoupmS845fGhsUrk1HZXKQI+iymbaiz9vAjPsghHNQ7Ag==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-x64-gnu@0.53.0':
|
||||
resolution: {integrity: sha512-xg8KWX0QnxmYWRe60CgHYWXI0ZOtBbqTsXvWiWrcl2XUHJ3fht2QerOk2iWvylzX3zNT2GpvBRxGoR4d3sxPRQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-x64-musl@0.53.0':
|
||||
resolution: {integrity: sha512-MWExpYBGvl+pIvVB/gj/CcWlN2al8AizT7rUbtaYaWNoQkhWARM6W3qpgoCr72CYSN9PborzPmM5MIRe2BrNdA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-openharmony-arm64@0.53.0':
|
||||
resolution: {integrity: sha512-u4sajgO4nxgmJIgc/y2AqPhkdbOkQH8WugXpA1+pW0ESQhvGZ1oGq61Q4xMbJHJU1hFgtO18QNrcFYDPYH0gwQ==}
|
||||
|
|
@ -1118,48 +1126,56 @@ packages:
|
|||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-arm64-musl@1.68.0':
|
||||
resolution: {integrity: sha512-qVKtCZNic+OoNnOr/hCQAu22HSQzflI7Fsq/Blzkw02SnLuv163k3kfmrVpZjSBlUHgsRKj6WgQiw30d3SX02Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-linux-ppc64-gnu@1.68.0':
|
||||
resolution: {integrity: sha512-zExyZ8ZOUuAyQ0y9jpTcyjKUz62YY9JhKPyVxzvjTpXzZ3ujdqiVwfPWDdnA1SsIOrxdtxHn7KErDHLWskFjXg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-riscv64-gnu@1.68.0':
|
||||
resolution: {integrity: sha512-6C4MPuwewyDavA7sxM14wzgRi5GGL68HPIxRCdVyS75U4MDbpFVYzKO9WNR6KLKTMPq2pcz3THwo1sK2uiqngw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-riscv64-musl@1.68.0':
|
||||
resolution: {integrity: sha512-bnZooVeHAcvA+dH0EDLgx+7HY/DRi6e0hFszg3P+OBatuUjV6EvfIyNIzWOusmqAVh4L6r21GGTZtiKE4iqM4Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-linux-s390x-gnu@1.68.0':
|
||||
resolution: {integrity: sha512-dIqnZnJSmHCMOUpUcWQOiV14o3DDPVx1DSsMaSzvdhNjC1tB1iEPZbdiMSCIEYbkgbsYznHXWqFdKL8WUB3F8g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-x64-gnu@1.68.0':
|
||||
resolution: {integrity: sha512-zc9lEnfV/HreDTY6gdMlZe+irkwHSxQ4/B1pS9GyK7RVaA5LxhoZY/w6/o2vIwLLEYiXQ5ujGxOM1ZazeFAAIA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-x64-musl@1.68.0':
|
||||
resolution: {integrity: sha512-Dl5QEX0TCo/40Cdh1o1JdPS//+YiWqjC+Hrrya5OQmStZZr4svAFtdlqcpCrU9yq2Mo3vRVyO9B3h0dzD8s36Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-openharmony-arm64@1.68.0':
|
||||
resolution: {integrity: sha512-/qy6dOvi4S3/LeXq0l5BT5pRKPYA7oj3uKwJOAZOr5HRLL+HK6jdBynvWuXIA2wwfE01RzNYmbBdM7vwYx00sA==}
|
||||
|
|
@ -1220,36 +1236,42 @@ packages:
|
|||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.0.3':
|
||||
resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.0.3':
|
||||
resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-s390x-gnu@1.0.3':
|
||||
resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.0.3':
|
||||
resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.0.3':
|
||||
resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.0.3':
|
||||
resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==}
|
||||
|
|
@ -1352,24 +1374,28 @@ packages:
|
|||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.3.0':
|
||||
resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.3.0':
|
||||
resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.3.0':
|
||||
resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-wasm32-wasi@4.3.0':
|
||||
resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==}
|
||||
|
|
@ -1438,30 +1464,35 @@ packages:
|
|||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tauri-apps/cli-linux-arm64-musl@2.11.2':
|
||||
resolution: {integrity: sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tauri-apps/cli-linux-riscv64-gnu@2.11.2':
|
||||
resolution: {integrity: sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tauri-apps/cli-linux-x64-gnu@2.11.2':
|
||||
resolution: {integrity: sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tauri-apps/cli-linux-x64-musl@2.11.2':
|
||||
resolution: {integrity: sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tauri-apps/cli-win32-arm64-msvc@2.11.2':
|
||||
resolution: {integrity: sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==}
|
||||
|
|
@ -2435,7 +2466,7 @@ packages:
|
|||
|
||||
glob@7.2.3:
|
||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
deprecated: Glob versions prior to v9 are no longer supported
|
||||
|
||||
gonzales-pe@4.3.0:
|
||||
resolution: {integrity: sha512-otgSPpUmdWJ43VXyiNgEYE4luzHCL2pz4wQ0OnDluC6Eg4Ko3Vexy/SrSynglw/eR+OhkzmqFCZa/OFa/RgAOQ==}
|
||||
|
|
@ -2693,24 +2724,28 @@ packages:
|
|||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.32.0:
|
||||
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.32.0:
|
||||
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
|
||||
|
|
|
|||
|
|
@ -451,3 +451,69 @@ pub fn build_database_sql_export(
|
|||
) -> Result<String, String> {
|
||||
dbx_core::database_export::build_database_sql_export(options)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_explain_info(
|
||||
state: tauri::State<'_, std::sync::Arc<dbx_core::connection::AppState>>,
|
||||
connection_id: String,
|
||||
database: Option<String>,
|
||||
schema: Option<String>,
|
||||
sql: String,
|
||||
mode: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
let client = {
|
||||
let connections = state.connections.read().await;
|
||||
let pool = connections.get(&connection_id).ok_or_else(|| "Connection not found".to_string())?;
|
||||
match pool {
|
||||
dbx_core::connection::PoolKind::Agent(client) => client.clone(),
|
||||
_ => return Err("Connection is not an agent-based connection".to_string()),
|
||||
}
|
||||
};
|
||||
|
||||
let config = {
|
||||
let configs = state.configs.read().await;
|
||||
configs.get(&connection_id).cloned()
|
||||
};
|
||||
let config = config.ok_or_else(|| "Connection config not found".to_string())?;
|
||||
let timeout_secs = config.query_timeout_secs;
|
||||
|
||||
let mut client = client.lock().await;
|
||||
let mode = mode.unwrap_or_else(|| "explain".to_string());
|
||||
if mode.eq_ignore_ascii_case("autotrace") && !dbx_core::query_execution_sql::is_safe_dameng_autotrace_sql(&sql) {
|
||||
return Err("unsafe".to_string());
|
||||
}
|
||||
let params = serde_json::json!({
|
||||
"sql": sql,
|
||||
"database": database.unwrap_or_default(),
|
||||
"schema": schema.unwrap_or_default(),
|
||||
"timeoutSecs": timeout_secs as i64,
|
||||
"mode": mode,
|
||||
});
|
||||
|
||||
let result: Result<serde_json::Value, String> = client.get_explain_info::<serde_json::Value>(params).await;
|
||||
match result {
|
||||
Ok(serde_json::Value::String(s)) => {
|
||||
eprintln!("[get_explain_info] OK string, len={}", s.len());
|
||||
Ok(s)
|
||||
}
|
||||
Ok(serde_json::Value::Object(obj)) => {
|
||||
let plan = obj.get("plan").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let has_stats = obj.get("has_actual_stats").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
eprintln!("[get_explain_info] OK object, plan_len={}, has_actual_stats={}", plan.len(), has_stats);
|
||||
Ok(plan)
|
||||
}
|
||||
Ok(val) => {
|
||||
eprintln!("[get_explain_info] OK unexpected type: {:?}", val);
|
||||
Err(format!("Unexpected result type from getExplainInfo: {:?}", val))
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[get_explain_info] error: {e}");
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_create_user_sql(username: String, password: String, tablespace: String) -> Result<String, String> {
|
||||
Ok(dbx_core::db_admin_sql::build_create_user_sql(&username, &password, &tablespace))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -411,6 +411,8 @@ pub fn run() {
|
|||
commands::query::prepare_query_pagination_execution_plan,
|
||||
commands::query::build_sorted_query_sql,
|
||||
commands::query::build_explain_sql,
|
||||
commands::query::get_explain_info,
|
||||
commands::query::build_create_user_sql,
|
||||
commands::query::build_dropped_file_preview_sql,
|
||||
commands::query::build_table_select_sql,
|
||||
commands::query::build_database_search_sql,
|
||||
|
|
|
|||
Loading…
Reference in New Issue