feat(oracle): support explain plans
This commit is contained in:
parent
0f4abce2b8
commit
e1d6d1224e
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -292,7 +292,7 @@
|
|||
"sqlFileExecution": true,
|
||||
"databaseCreate": false,
|
||||
"fieldLineage": true,
|
||||
"sqlExplain": false,
|
||||
"sqlExplain": true,
|
||||
"userAdmin": false,
|
||||
"driverManagement": true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in New Issue