diff --git a/agents/drivers/oracle-go/main.go b/agents/drivers/oracle-go/main.go index f3412b282..c3fff0ee6 100644 --- a/agents/drivers/oracle-go/main.go +++ b/agents/drivers/oracle-go/main.go @@ -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) { diff --git a/agents/drivers/oracle-go/main_test.go b/agents/drivers/oracle-go/main_test.go index 6f9cdfcd1..dfc128780 100644 --- a/agents/drivers/oracle-go/main_test.go +++ b/agents/drivers/oracle-go/main_test.go @@ -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"` diff --git a/apps/desktop/src/components/editor/AiAssistant.vue b/apps/desktop/src/components/editor/AiAssistant.vue index 7e36afa93..398698bfc 100644 --- a/apps/desktop/src/components/editor/AiAssistant.vue +++ b/apps/desktop/src/components/editor/AiAssistant.vue @@ -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; diff --git a/apps/desktop/src/lib/__tests__/query/oracleExplainPlan.spec.ts b/apps/desktop/src/lib/__tests__/query/oracleExplainPlan.spec.ts new file mode 100644 index 000000000..bb67af562 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/query/oracleExplainPlan.spec.ts @@ -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: [], + }); + }); +}); diff --git a/apps/desktop/src/lib/diagram/explainPlan.ts b/apps/desktop/src/lib/diagram/explainPlan.ts index fc78a94c2..626d58542 100644 --- a/apps/desktop/src/lib/diagram/explainPlan.ts +++ b/apps/desktop/src/lib/diagram/explainPlan.ts @@ -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(["mysql", "postgres", "dameng", "questdb"]); -export function supportsExplainPlan(databaseType?: DatabaseType): databaseType is "mysql" | "postgres" | "dameng" | "questdb" { +const SUPPORTED_EXPLAIN_TYPES = new Set(["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 { + const predicates = new Map(); + 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; diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 90473a2fc..0d9a0c977 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -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); diff --git a/crates/dbx-core/assets/database-drivers.manifest.json b/crates/dbx-core/assets/database-drivers.manifest.json index ea7a63b53..b202a6e53 100644 --- a/crates/dbx-core/assets/database-drivers.manifest.json +++ b/crates/dbx-core/assets/database-drivers.manifest.json @@ -292,7 +292,7 @@ "sqlFileExecution": true, "databaseCreate": false, "fieldLineage": true, - "sqlExplain": false, + "sqlExplain": true, "userAdmin": false, "driverManagement": true } diff --git a/crates/dbx-core/src/agent_explain.rs b/crates/dbx-core/src/agent_explain.rs new file mode 100644 index 000000000..9db8d5a1a --- /dev/null +++ b/crates/dbx-core/src/agent_explain.rs @@ -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 { + 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 { + 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()); + } +} diff --git a/crates/dbx-core/src/agent_tools.rs b/crates/dbx-core/src/agent_tools.rs index be980e130..76a1b3a7f 100644 --- a/crates/dbx-core/src/agent_tools.rs +++ b/crates/dbx-core/src/agent_tools.rs @@ -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(); diff --git a/crates/dbx-core/src/lib.rs b/crates/dbx-core/src/lib.rs index 965ce2ee4..624fe2ae3 100644 --- a/crates/dbx-core/src/lib.rs +++ b/crates/dbx-core/src/lib.rs @@ -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; diff --git a/crates/dbx-core/src/query_execution_sql.rs b/crates/dbx-core/src/query_execution_sql.rs index 0eeb23d9e..bcb341bbd 100644 --- a/crates/dbx-core/src/query_execution_sql.rs +++ b/crates/dbx-core/src/query_execution_sql.rs @@ -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) -> 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] diff --git a/crates/dbx-web/src/routes/query.rs b/crates/dbx-web/src/routes/query.rs index 44ddb7cd1..906fcf7f0 100644 --- a/crates/dbx-web/src/routes/query.rs +++ b/crates/dbx-web/src/routes/query.rs @@ -518,49 +518,17 @@ pub async fn get_explain_info( State(state): State>, Json(req): Json, ) -> Result, 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 = client.get_explain_info::(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) -> Result, AppError> { diff --git a/src-tauri/src/commands/query.rs b/src-tauri/src/commands/query.rs index b123d8ddd..8f961d649 100644 --- a/src-tauri/src/commands/query.rs +++ b/src-tauri/src/commands/query.rs @@ -583,59 +583,15 @@ pub async fn get_explain_info( sql: String, mode: Option, ) -> Result { - 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 = client.get_explain_info::(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]