feat(oracle): support explain plans

This commit is contained in:
zipg 2026-07-10 17:55:03 +08:00 committed by GitHub
parent 0f4abce2b8
commit e1d6d1224e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 605 additions and 117 deletions

View File

@ -468,7 +468,12 @@ func (s *server) dispatch(method string, params map[string]json.RawMessage) (any
return result, false, err
case "get_explain_info":
sqlText := stringParam(params, "sql")
plan, err := s.getExplainInfo(sqlText)
plan, err := s.getExplainInfo(
sqlText,
stringParam(params, "database"),
stringParam(params, "schema"),
intParam(params, "timeoutSecs"),
)
return map[string]any{"plan": plan, "has_actual_stats": false}, false, err
case "execute_transaction":
result, err := s.executeTransaction(params)
@ -1506,16 +1511,59 @@ func isOracleCharacterType(dataType string) bool {
}
}
func (s *server) getExplainInfo(sqlText string) (string, error) {
func (s *server) getExplainInfo(sqlText, database, schema string, timeoutSecs int) (string, error) {
if strings.TrimSpace(sqlText) == "" {
return "", errors.New("sql is required")
}
rows, err := s.queryRows("EXPLAIN PLAN FOR "+trimStatementSQL(sqlText), nil)
db, err := s.requireDB()
if err != nil {
return "", err
}
rows.Close()
planRows, err := s.queryRows("SELECT PLAN_TABLE_OUTPUT FROM TABLE(DBMS_XPLAN.DISPLAY())", nil)
ctx := context.Background()
var cancel context.CancelFunc
if timeoutSecs > 0 {
ctx, cancel = context.WithTimeout(ctx, time.Duration(timeoutSecs)*time.Second)
} else {
ctx, cancel = context.WithCancel(ctx)
}
defer cancel()
conn, err := db.Conn(ctx)
if err != nil {
return "", err
}
defer conn.Close()
targetSchema := strings.TrimSpace(schema)
if targetSchema == "" && !strings.EqualFold(strings.TrimSpace(database), strings.TrimSpace(s.params.Database)) {
targetSchema = strings.TrimSpace(database)
}
if targetSchema != "" {
var originalSchema string
if err := conn.QueryRowContext(ctx, "SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') FROM DUAL").Scan(&originalSchema); err != nil {
return "", err
}
if !strings.EqualFold(originalSchema, targetSchema) {
if _, err := conn.ExecContext(ctx, "ALTER SESSION SET CURRENT_SCHEMA = "+quoteIdentifier(targetSchema)); err != nil {
return "", err
}
defer restoreOracleCurrentSchema(conn, originalSchema)
}
}
statementID := "DBX_" + strings.ToUpper(strconv.FormatInt(time.Now().UnixNano(), 36))
defer cleanupOracleExplainPlan(conn, statementID)
statementSQL := trimStatementSQL(sqlText)
explainArgs := oracleExplainPlanBindArgs(statementSQL)
if _, err := conn.ExecContext(ctx, "EXPLAIN PLAN SET STATEMENT_ID = '"+statementID+"' FOR "+statementSQL, explainArgs...); err != nil {
return "", err
}
planRows, err := conn.QueryContext(
ctx,
"SELECT PLAN_TABLE_OUTPUT FROM TABLE(DBMS_XPLAN.DISPLAY('PLAN_TABLE', :1, 'TYPICAL +PREDICATE'))",
statementID,
)
if err != nil {
return "", err
}
@ -1532,6 +1580,99 @@ func (s *server) getExplainInfo(sqlText string) (string, error) {
return strings.TrimSpace(builder.String()), planRows.Err()
}
func cleanupOracleExplainPlan(conn *sql.Conn, statementID string) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, _ = conn.ExecContext(ctx, "DELETE FROM PLAN_TABLE WHERE STATEMENT_ID = :1", statementID)
}
type oracleBindParam struct {
Name string
Positional bool
}
func oracleExplainPlanBindArgs(sqlText string) []any {
params := oracleExplainPlanBindParams(sqlText)
args := make([]any, 0, len(params))
for _, param := range params {
if param.Positional {
args = append(args, nil)
continue
}
args = append(args, sql.Named(param.Name, nil))
}
return args
}
func oracleExplainPlanBindParams(sqlText string) []oracleBindParam {
params := make([]oracleBindParam, 0)
seenNamed := map[string]bool{}
for pos := 0; pos < len(sqlText); pos++ {
switch sqlText[pos] {
case '\'':
pos = skipSingleQuotedSQL(sqlText, pos)
case '"':
pos = skipDoubleQuotedSQL(sqlText, pos)
case 'q', 'Q':
if end, ok := skipOracleAlternativeQuotedSQL(sqlText, pos); ok {
pos = end
}
case '-':
if pos+1 < len(sqlText) && sqlText[pos+1] == '-' {
pos = skipLineCommentSQL(sqlText, pos)
}
case '/':
if pos+1 < len(sqlText) && sqlText[pos+1] == '*' {
pos = skipBlockCommentSQL(sqlText, pos)
}
case ':':
param, end, ok := readOracleBindParam(sqlText, pos)
if !ok {
continue
}
if param.Positional {
params = append(params, param)
} else if key := strings.ToUpper(param.Name); !seenNamed[key] {
seenNamed[key] = true
params = append(params, param)
}
pos = end - 1
}
}
return params
}
func readOracleBindParam(sqlText string, pos int) (oracleBindParam, int, bool) {
if pos < 0 || pos+1 >= len(sqlText) || sqlText[pos] != ':' {
return oracleBindParam{}, pos, false
}
if pos > 0 && sqlText[pos-1] == ':' {
return oracleBindParam{}, pos, false
}
next := sqlText[pos+1]
if next >= '0' && next <= '9' {
end := pos + 2
for end < len(sqlText) && sqlText[end] >= '0' && sqlText[end] <= '9' {
end++
}
return oracleBindParam{Name: sqlText[pos+1 : end], Positional: true}, end, true
}
if !isOracleIdentifierStart(next) {
return oracleBindParam{}, pos, false
}
end := pos + 2
for end < len(sqlText) && isOracleIdentifierPart(sqlText[end]) {
end++
}
return oracleBindParam{Name: sqlText[pos+1 : end]}, end, true
}
func restoreOracleCurrentSchema(conn *sql.Conn, schema string) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, _ = conn.ExecContext(ctx, "ALTER SESSION SET CURRENT_SCHEMA = "+quoteIdentifier(schema))
}
func (s *server) executeTransaction(params map[string]json.RawMessage) (queryResult, error) {
var payload struct {
Statements []string `json:"statements"`
@ -2459,6 +2600,30 @@ func skipSingleQuotedSQL(value string, pos int) int {
return len(value) - 1
}
func skipOracleAlternativeQuotedSQL(value string, pos int) (int, bool) {
if pos+2 >= len(value) || (value[pos] != 'q' && value[pos] != 'Q') || value[pos+1] != '\'' {
return pos, false
}
open := value[pos+2]
close := open
switch open {
case '[':
close = ']'
case '{':
close = '}'
case '(':
close = ')'
case '<':
close = '>'
}
for end := pos + 3; end+1 < len(value); end++ {
if value[end] == close && value[end+1] == '\'' {
return end + 1, true
}
}
return len(value) - 1, true
}
func skipDoubleQuotedSQL(value string, pos int) int {
pos++
for pos < len(value) {

View File

@ -1,10 +1,12 @@
package main
import (
"database/sql"
"encoding/json"
"errors"
"net/url"
"os"
"reflect"
"strings"
"testing"
)
@ -247,6 +249,67 @@ func TestTrimStatementSQLRemovesRegularStatementSemicolon(t *testing.T) {
}
}
func TestOracleExplainPlanBindParamsIncludesNamedParameters(t *testing.T) {
sqlText := `
SELECT *
FROM orders
WHERE id = :id
AND status = :status
AND parent_id = :id`
want := []oracleBindParam{
{Name: "id"},
{Name: "status"},
}
if got := oracleExplainPlanBindParams(sqlText); !reflect.DeepEqual(got, want) {
t.Fatalf("oracleExplainPlanBindParams() = %#v, want %#v", got, want)
}
}
func TestOracleExplainPlanBindParamsSkipsQuotedTextAndComments(t *testing.T) {
sqlText := `
SELECT ':literal' AS literal_value,
q'[not :q_param]' AS q_literal,
"COL:NAME" AS quoted_identifier
FROM orders
WHERE id = :id
-- ignored :comment_param
AND note <> 'escaped '' :text_param'
/* ignored :block_param */`
want := []oracleBindParam{{Name: "id"}}
if got := oracleExplainPlanBindParams(sqlText); !reflect.DeepEqual(got, want) {
t.Fatalf("oracleExplainPlanBindParams() = %#v, want %#v", got, want)
}
}
func TestOracleExplainPlanBindParamsIncludesPositionalParameters(t *testing.T) {
sqlText := "SELECT * FROM orders WHERE id = :1 AND status = :status"
want := []oracleBindParam{
{Name: "1", Positional: true},
{Name: "status"},
}
if got := oracleExplainPlanBindParams(sqlText); !reflect.DeepEqual(got, want) {
t.Fatalf("oracleExplainPlanBindParams() = %#v, want %#v", got, want)
}
}
func TestOracleExplainPlanBindArgsUsesNamedArguments(t *testing.T) {
args := oracleExplainPlanBindArgs("SELECT * FROM orders WHERE id = :id")
if len(args) != 1 {
t.Fatalf("expected one bind argument, got %#v", args)
}
named, ok := args[0].(sql.NamedArg)
if !ok {
t.Fatalf("expected sql.NamedArg, got %#v", args[0])
}
if named.Name != "id" || named.Value != nil {
t.Fatalf("unexpected named bind argument: %#v", named)
}
}
func protocolContract(t *testing.T) struct {
ProtocolVersion int `json:"protocolVersion"`
AllCapabilities []string `json:"allCapabilities"`

View File

@ -67,7 +67,7 @@ import { useDatabaseOptions } from "@/composables/useDatabaseOptions";
import { decodeSelectableDatabaseValue, encodeSelectableDatabaseValue, formatDatabaseLabel, resolveDefaultDatabase } from "@/lib/database/defaultDatabase";
import { isSchemaAware } from "@/lib/database/databaseCapabilities";
import ExplainPlanViewer from "@/components/explain/ExplainPlanViewer.vue";
import { parseExplainResult, type ParsedExplainPlan } from "@/lib/diagram/explainPlan";
import { parseExplainResult, parseOracleExplainText, type ParsedExplainPlan } from "@/lib/diagram/explainPlan";
import { copyToClipboard } from "@/lib/common/clipboard";
import { AI_TABLE_MENTION_CANDIDATE_LIMIT, AI_TABLE_MENTION_SCHEMA_LIMIT, filterAiTableMentionCandidates, formatAiTableMention, parseAiTableMentions, type AiTableMention } from "@/lib/ai/aiTableMentions";
import { isAiPromptImeCompositionEvent, shouldSubmitAiPromptOnKeydown } from "@/lib/ai/aiPromptKeyboard";
@ -707,6 +707,9 @@ function extractExplainData(result: unknown): unknown | undefined {
/** Parse explain_data (a serialized QueryResult) into ParsedExplainPlan */
function parseExplainFromData(explainData: unknown, dbType: string): ParsedExplainPlan | undefined {
if (dbType === "oracle" && typeof explainData === "string") {
return parseOracleExplainText(explainData);
}
if (!explainData || typeof explainData !== "object") return undefined;
const supportedTypes = ["mysql", "postgres", "dameng", "questdb"] as const;
if (!supportedTypes.includes(dbType as (typeof supportedTypes)[number])) return undefined;

View File

@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { flattenExplainPlanNodes, parseOracleExplainText, supportsExplainPlan } from "@/lib/diagram/explainPlan";
const ORACLE_PLAN = `Plan hash value: 321708281
-----------------------------------------------------------------------------------------
| Id | Operation | Name | Rows | Bytes | Cost (%CPU)| Time |
-----------------------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | 1 | 34 | 5 (0)| 00:00:01 |
| 1 | NESTED LOOPS | | 1 | 34 | 5 (0)| 00:00:01 |
| 2 | NESTED LOOPS | | 1 | 31 | 4 (0)| 00:00:01 |
|* 3 | TABLE ACCESS BY INDEX ROWID| USER$ | 1 | 28 | 3 (0)| 00:00:01 |
|* 4 | INDEX RANGE SCAN | I_USER1 | 3 | | 1 (0)| 00:00:01 |
| 5 | TABLE ACCESS CLUSTER | TS$ | 1 | 3 | 1 (0)| 00:00:01 |
|* 6 | INDEX UNIQUE SCAN | I_TS# | 1 | | 0 (0)| 00:00:01 |
| 7 | TABLE ACCESS CLUSTER | TS$ | 1 | 3 | 1 (0)| 00:00:01 |
|* 8 | INDEX UNIQUE SCAN | I_TS# | 1 | | 0 (0)| 00:00:01 |
-----------------------------------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
3 - filter("U"."TYPE#"=1 AND "U"."USER#">0)
4 - access("U"."NAME" LIKE 'S%' AND
"U"."CREATED_AT">=TO_DATE(' 2025-12-01 00:00:00',
'syyyy-mm-dd hh24:mi:ss'))
filter("U"."NAME" LIKE 'S%')`;
describe("Oracle explain plan", () => {
it("is enabled by the driver capability manifest", () => {
expect(supportsExplainPlan("oracle")).toBe(true);
});
it("parses DBMS_XPLAN text into a hierarchy with predicates", () => {
const plan = parseOracleExplainText(ORACLE_PLAN);
const nodes = flattenExplainPlanNodes(plan.nodes);
expect(plan.databaseType).toBe("oracle");
expect(plan.raw).toBe(ORACLE_PLAN);
expect(plan.nodes).toHaveLength(1);
expect(plan.nodes[0].nodeType).toBe("SELECT STATEMENT");
expect(plan.nodes[0].children[0].nodeType).toBe("NESTED LOOPS");
expect(plan.nodes[0].children[0].children.map((node) => node.id)).toEqual(["2", "7"]);
const tableAccess = nodes.find((node) => node.id === "3");
expect(tableAccess).toMatchObject({ relation: "USER$", rows: "1", cost: "3 (0)" });
expect(tableAccess?.details).toContain('Predicate: filter("U"."TYPE#"=1 AND "U"."USER#">0)');
const indexScan = nodes.find((node) => node.id === "4");
expect(indexScan?.index).toBe("I_USER1");
expect(indexScan?.details).toEqual(["Time: 00:00:01", 'Predicate: access("U"."NAME" LIKE \'S%\' AND "U"."CREATED_AT">=TO_DATE(\' 2025-12-01 00:00:00\', \'syyyy-mm-dd hh24:mi:ss\'))', 'Predicate: filter("U"."NAME" LIKE \'S%\')']);
});
it("keeps unrecognized text available in the raw view", () => {
expect(parseOracleExplainText("Oracle plan unavailable")).toEqual({
databaseType: "oracle",
raw: "Oracle plan unavailable",
nodes: [],
});
});
});

View File

@ -16,15 +16,15 @@ export interface ExplainPlanNode {
}
export interface ParsedExplainPlan {
databaseType: "mysql" | "postgres" | "dameng" | "questdb";
databaseType: "mysql" | "postgres" | "dameng" | "questdb" | "oracle";
raw: unknown;
nodes: ExplainPlanNode[];
}
export type BuildExplainSqlResult = { ok: true; sql: string } | { ok: false; reason: "unsupported" | "empty" | "unsafe" };
const SUPPORTED_EXPLAIN_TYPES = new Set<DatabaseType>(["mysql", "postgres", "dameng", "questdb"]);
export function supportsExplainPlan(databaseType?: DatabaseType): databaseType is "mysql" | "postgres" | "dameng" | "questdb" {
const SUPPORTED_EXPLAIN_TYPES = new Set<DatabaseType>(["mysql", "postgres", "dameng", "questdb", "oracle"]);
export function supportsExplainPlan(databaseType?: DatabaseType): databaseType is "mysql" | "postgres" | "dameng" | "questdb" | "oracle" {
return !!databaseType && supportsDatabaseFeature(databaseType, "sqlExplain") && SUPPORTED_EXPLAIN_TYPES.has(databaseType);
}
@ -142,6 +142,124 @@ export function parseDamengExplainText(planText: string): ParsedExplainPlan {
return { databaseType: "dameng", raw: planText, nodes: rootNodes };
}
export function parseOracleExplainText(planText: string): ParsedExplainPlan {
const lines = planText.split("\n");
const headerIndex = lines.findIndex((line) => /^\|\s*Id\s*\|/i.test(line) && line.includes("Operation"));
if (headerIndex < 0) return { databaseType: "oracle", raw: planText, nodes: [] };
const headers = splitOraclePlanColumns(lines[headerIndex]).map((header) => header.trim().toLowerCase().replace(/\s+/g, " "));
const columnIndex = (name: string) => headers.findIndex((header) => header === name || header.startsWith(`${name} `));
const idIndex = columnIndex("id");
const operationIndex = columnIndex("operation");
const nameIndex = columnIndex("name");
const rowsIndex = columnIndex("rows");
const bytesIndex = columnIndex("bytes");
const costIndex = columnIndex("cost");
const timeIndex = columnIndex("time");
const predicates = oraclePredicateDetails(lines);
const parsedRows: Array<{
id: string;
depth: number;
operation: string;
name?: string;
rows?: string;
cost?: string;
bytes?: string;
time?: string;
}> = [];
let baseIndent: number | undefined;
for (const line of lines.slice(headerIndex + 1)) {
if (!line.startsWith("|")) continue;
const cells = splitOraclePlanColumns(line);
const idMatch = cells[idIndex]?.match(/\d+/);
const operationCell = cells[operationIndex];
if (!idMatch || operationCell == null) continue;
const indent = operationCell.search(/\S/);
if (indent < 0) continue;
baseIndent ??= indent;
parsedRows.push({
id: idMatch[0],
depth: Math.max(0, indent - baseIndent),
operation: operationCell.trim(),
name: cellValue(cells, nameIndex),
rows: cellValue(cells, rowsIndex),
cost: cellValue(cells, costIndex),
bytes: cellValue(cells, bytesIndex),
time: cellValue(cells, timeIndex),
});
}
const roots: ExplainPlanNode[] = [];
const parents: ExplainPlanNode[] = [];
for (const row of parsedRows) {
const isIndex = /\bINDEX\b/i.test(row.operation) && !/\bTABLE ACCESS\b/i.test(row.operation);
const relation = row.name && !isIndex ? row.name : undefined;
const index = row.name && isIndex ? row.name : undefined;
const details = [row.bytes ? `Bytes: ${row.bytes}` : "", row.time ? `Time: ${row.time}` : "", ...(predicates.get(row.id) ?? []).map((predicate) => `Predicate: ${predicate}`)].filter(Boolean);
const node: ExplainPlanNode = {
id: row.id,
title: row.name ? `${row.operation} on ${row.name}` : row.operation,
nodeType: row.operation,
relation,
index,
cost: row.cost,
rows: row.rows,
details,
children: [],
};
while (parents.length > row.depth) parents.pop();
const parent = row.depth > 0 ? parents[row.depth - 1] : undefined;
if (parent) parent.children.push(node);
else roots.push(node);
parents[row.depth] = node;
parents.length = row.depth + 1;
}
return { databaseType: "oracle", raw: planText, nodes: roots };
}
function splitOraclePlanColumns(line: string): string[] {
const columns = line.split("|");
return columns.length >= 3 ? columns.slice(1, -1) : [];
}
function cellValue(cells: string[], index: number): string | undefined {
if (index < 0) return undefined;
const value = cells[index]?.trim();
return value || undefined;
}
function oraclePredicateDetails(lines: string[]): Map<string, string[]> {
const predicates = new Map<string, string[]>();
const start = lines.findIndex((line) => line.trim().toLowerCase().startsWith("predicate information"));
if (start < 0) return predicates;
let currentId = "";
for (const rawLine of lines.slice(start + 1)) {
const line = rawLine.trim();
if (!line || /^-+$/.test(line)) continue;
if (/^note\b/i.test(line)) break;
const entry = line.match(/^(\d+)\s*-\s*(.+)$/);
if (entry) {
currentId = entry[1];
predicates.set(currentId, [entry[2]]);
} else if (currentId) {
const details = predicates.get(currentId);
if (!details?.length) continue;
if (/^(?:access|filter|storage)\s*\(/i.test(line)) {
details.push(line);
} else {
details[details.length - 1] = `${details[details.length - 1]} ${line}`;
}
}
}
return predicates;
}
interface DamengOpInfo {
operation: string;
nodeType: string;

View File

@ -5,7 +5,7 @@ import { useI18n } from "vue-i18n";
import type { DatabaseType, ObjectBrowserViewport, QueryResult, QueryTab, TableInfoTab, TableStructureEditorTarget } from "@/types/database";
import { orderPinnedFirst } from "@/lib/app/pinnedItems";
import { canCancelQueryExecution } from "@/lib/sql/queryExecutionState";
import { buildExplainSql, parseExplainResult, parseDamengExplainText } from "@/lib/diagram/explainPlan";
import { buildExplainSql, parseExplainResult, parseDamengExplainText, parseOracleExplainText } from "@/lib/diagram/explainPlan";
import { allEditableColumnsWriteable, allPrimaryKeysPresent, sourceColumnsForResult, type EditableQueryInfo, type EditableQuerySource } from "@/lib/sql/sqlAnalysis";
import { ACTIVE_TAB_STORAGE_KEY, OPEN_TABS_STORAGE_KEY, restoreOpenTabsPayload, restoreOpenTabsState, serializeOpenTabs } from "@/lib/app/openTabsPersistence";
import {
@ -2736,10 +2736,23 @@ export const useQueryStore = defineStore("query", () => {
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") {
// DM and Oracle agents expose native text plans. DM also supports autotrace.
if (databaseType === "dameng" || databaseType === "oracle") {
let explainSql = sql;
if (databaseType === "oracle") {
const built = await buildExplainSql(databaseType, sql);
if (!built.ok) {
tab.isExplaining = false;
tab.explainExecutionId = undefined;
tab.explainPlan = undefined;
tab.explainError = built.reason;
return built;
}
explainSql = built.sql;
}
// Autotrace executes the SQL, so keep its stricter safety check.
if (databaseType === "dameng" && explainMode === "autotrace") {
const DANGER_RE = /^\s*(DROP|DELETE|TRUNCATE|ALTER|UPDATE|MERGE|REPLACE)\b/i;
const cleaned = sql
.replace(/\/\*[\s\S]*?\*\//g, " ")
@ -2752,13 +2765,13 @@ export const useQueryStore = defineStore("query", () => {
}
}
try {
const mode = explainMode === "autotrace" ? "autotrace" : "explain";
const mode = databaseType === "dameng" && 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.explainPlan = databaseType === "oracle" ? parseOracleExplainText(planText) : parseDamengExplainText(planText);
current.explainSql = explainSql;
current.explainError = undefined;
} else {
current.explainPlan = undefined;
@ -2775,9 +2788,10 @@ export const useQueryStore = defineStore("query", () => {
const current = tabs.value.find((t) => t.id === id);
if (current?.explainExecutionId === executionId) {
current.isExplaining = false;
current.explainExecutionId = undefined;
}
}
return { ok: true as const };
return { ok: true as const, sql: explainSql };
}
const built = await buildExplainSql(databaseType, sql);

View File

@ -292,7 +292,7 @@
"sqlFileExecution": true,
"databaseCreate": false,
"fieldLineage": true,
"sqlExplain": false,
"sqlExplain": true,
"userAdmin": false,
"driverManagement": true
}

View File

@ -0,0 +1,75 @@
use serde_json::Value;
use crate::connection::{AppState, PoolKind};
use crate::query_execution_sql::{is_safe_dameng_autotrace_sql, is_safe_explain_sql};
pub async fn get_agent_explain_info_core(
state: &AppState,
connection_id: &str,
database: Option<&str>,
schema: Option<&str>,
sql: &str,
mode: Option<&str>,
) -> Result<String, String> {
let mode = mode.unwrap_or("explain");
let safe = if mode.eq_ignore_ascii_case("autotrace") {
is_safe_dameng_autotrace_sql(sql)
} else {
is_safe_explain_sql(sql)
};
if !safe {
return Err("unsafe".to_string());
}
let database_for_pool = database.filter(|value| !value.trim().is_empty());
state.get_or_create_pool(connection_id, database_for_pool).await?;
let client = {
let connections = state.connections.read().await;
let pool = connections.get(connection_id).ok_or_else(|| "Connection not found".to_string())?;
match pool {
PoolKind::Agent(client) => client.clone(),
_ => return Err("Connection is not an agent-based connection".to_string()),
}
};
let timeout_secs = {
let configs = state.configs.read().await;
configs.get(connection_id).ok_or_else(|| "Connection config not found".to_string())?.query_timeout_secs
};
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 mut client = client.lock().await;
let result: Value = client.get_explain_info(params).await?;
decode_agent_explain_result(result)
}
fn decode_agent_explain_result(result: Value) -> Result<String, String> {
match result {
Value::String(plan) => Ok(plan),
Value::Object(object) => Ok(object.get("plan").and_then(Value::as_str).unwrap_or_default().to_string()),
value => Err(format!("Unexpected result type from getExplainInfo: {value:?}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decodes_string_and_object_agent_explain_results() {
assert_eq!(decode_agent_explain_result(Value::String("plan text".to_string())).unwrap(), "plan text");
assert_eq!(
decode_agent_explain_result(serde_json::json!({ "plan": "object plan", "has_actual_stats": false }))
.unwrap(),
"object plan"
);
assert!(decode_agent_explain_result(serde_json::json!(["unexpected"])).is_err());
}
}

View File

@ -598,6 +598,22 @@ async fn execute_explain_query(
}
}
if *db_type == DatabaseType::Oracle {
return match crate::agent_explain::get_agent_explain_info_core(
state,
connection_id,
Some(database),
None,
sql,
Some("explain"),
)
.await
{
Ok(plan) => (Ok(plan.clone()), Some(serde_json::Value::String(plan))),
Err(error) => (Err(error), None),
};
}
// Build the database-specific EXPLAIN SQL
let explain_result = build_explain_sql(ExplainSqlOptions { database_type: Some(*db_type), sql: sql.to_string() });
@ -823,6 +839,14 @@ mod tests {
));
}
#[test]
fn oracle_agent_tools_include_explain_query() {
let tools = all_tools(DatabaseType::Oracle, AgentSqlPermissions::default());
let names: Vec<&str> = tools.iter().map(|tool| tool.name).collect();
assert!(names.contains(&"explain_query"));
}
#[test]
fn build_browse_query_qdrant() {
let q = build_browse_query(&DatabaseType::Qdrant, "articles", "", 10).unwrap();

View File

@ -1,6 +1,7 @@
pub mod agent_catalog;
pub mod agent_connection;
pub mod agent_events;
pub mod agent_explain;
pub mod agent_kv;
pub mod agent_loop;
pub mod agent_manager;

View File

@ -37,7 +37,7 @@ pub fn build_explain_sql(options: ExplainSqlOptions) -> ExplainSqlBuildResult {
if source.is_empty() {
return explain_err("empty");
}
if !is_safe_explain_source(&source) {
if !is_safe_explain_sql(&source) {
return explain_err("unsafe");
}
@ -48,6 +48,7 @@ pub fn build_explain_sql(options: ExplainSqlOptions) -> ExplainSqlBuildResult {
Some(DatabaseType::Dameng | DatabaseType::Questdb) => {
format!("EXPLAIN {source}")
}
Some(DatabaseType::Oracle) => format!("EXPLAIN PLAN FOR {source}"),
_ => format!("EXPLAIN FORMAT=JSON {source}"),
};
ExplainSqlBuildResult { ok: true, sql: Some(sql), reason: None }
@ -75,10 +76,24 @@ 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 | DatabaseType::Questdb | DatabaseType::Dameng)
Some(
DatabaseType::Mysql
| DatabaseType::Postgres
| DatabaseType::Questdb
| DatabaseType::Dameng
| DatabaseType::Oracle
)
)
}
pub fn is_safe_explain_sql(sql: &str) -> bool {
let source = strip_trailing_semicolons(sql.trim());
!source.is_empty()
&& !has_extra_statement_after_semicolon(&source)
&& is_safe_explain_source(&source)
&& !contains_dangerous_sql_keyword(&source)
}
/// Returns true for databases that support SQL query execution (execute_query / get_sample_data).
/// Non-SQL databases (Redis, MongoDB, Elasticsearch, InfluxDB, Neo4j, etcd) are excluded.
pub fn supports_sql_query(database_type: DatabaseType) -> bool {
@ -420,6 +435,23 @@ mod tests {
);
}
#[test]
fn builds_oracle_explain_plan_sql() {
let result = build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::Oracle),
sql: "WITH rows AS (SELECT 1 AS id FROM dual) SELECT * FROM rows;".to_string(),
});
assert_eq!(
result,
ExplainSqlBuildResult {
ok: true,
sql: Some("EXPLAIN PLAN FOR WITH rows AS (SELECT 1 AS id FROM dual) SELECT * FROM rows".to_string()),
reason: None,
}
);
}
#[test]
fn validates_dameng_autotrace_sql_safety() {
assert!(is_safe_dameng_autotrace_sql("SELECT * FROM t WHERE name = 'delete';"));
@ -451,6 +483,14 @@ mod tests {
}),
ExplainSqlBuildResult { ok: false, sql: None, reason: Some("unsafe".to_string()) }
);
assert_eq!(
build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::Mysql),
sql: "SELECT * FROM users; DELETE FROM users".to_string(),
}),
ExplainSqlBuildResult { ok: false, sql: None, reason: Some("unsafe".to_string()) }
);
}
#[test]

View File

@ -518,49 +518,17 @@ pub async fn get_explain_info(
State(state): State<Arc<WebState>>,
Json(req): Json<GetExplainInfoRequest>,
) -> Result<Json<String>, AppError> {
let database_for_pool = req.database.as_deref().filter(|database| !database.trim().is_empty());
state.app.get_or_create_pool(&req.connection_id, database_for_pool).await.map_err(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)),
}
let plan = dbx_core::agent_explain::get_agent_explain_info_core(
&state.app,
&req.connection_id,
req.database.as_deref(),
req.schema.as_deref(),
&req.sql,
req.mode.as_deref(),
)
.await
.map_err(AppError)?;
Ok(Json(plan))
}
pub async fn build_create_user_sql(Json(req): Json<BuildCreateUserSqlRequest>) -> Result<Json<String>, AppError> {

View File

@ -583,59 +583,15 @@ pub async fn get_explain_info(
sql: String,
mode: Option<String>,
) -> Result<String, String> {
let database_for_pool = database.as_deref().filter(|database| !database.trim().is_empty());
state.get_or_create_pool(&connection_id, database_for_pool).await?;
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)
}
}
dbx_core::agent_explain::get_agent_explain_info_core(
&state,
&connection_id,
database.as_deref(),
schema.as_deref(),
&sql,
mode.as_deref(),
)
.await
}
#[tauri::command]