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:
Quinlevi 2026-06-08 02:07:00 +08:00 committed by GitHub
parent a5659d7637
commit 897e6998fd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1154 additions and 55 deletions

View File

@ -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]

View File

@ -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()"

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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,
};
}

View File

@ -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");

View File

@ -169,7 +169,6 @@ export const CREATE_DATABASE_SUPPORTED_TYPES = new Set<DatabaseType>([
"sqlserver",
"clickhouse",
"oracle",
"dameng",
"gaussdb",
"kwdb",
"opengauss",

View File

@ -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>)

View File

@ -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;

View File

@ -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;

View File

@ -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,

View File

@ -34,6 +34,7 @@
"execute_query_page",
"fetch_query_page",
"close_query_session",
"get_explain_info",
"execute_transaction",
"disconnect",
"shutdown"

View File

@ -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,

View File

@ -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 {

View File

@ -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!(

View File

@ -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))

View File

@ -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>> {

View File

@ -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;

View File

@ -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==}

View File

@ -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))
}

View File

@ -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,