Skip to content

Commit 5d0fcb5

Browse files
committed
feat(visual-explain): add visual explain modal and analysis
Closes #22
2 parents 4d110fa + 6f5290e commit 5d0fcb5

38 files changed

Lines changed: 5227 additions & 82 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -456,12 +456,12 @@ pnpm tauri build
456456
## Roadmap
457457

458458
- [x] [[Feat]: Allow loading of multiple Databases per connection](https://github.com/debba/tabularis/issues/47)
459+
- [x] [Visual Explain Analyze](https://github.com/debba/tabularis/issues/22)
459460
- [x] [Plugin System](https://github.com/debba/tabularis/issues/19)
460461
- [ ] [Feature: Remote Control](https://github.com/debba/tabularis/issues/46)
461462
- [ ] [Command Palette](https://github.com/debba/tabularis/issues/25)
462463
- [ ] [JSON/JSONB Editor & Viewer](https://github.com/debba/tabularis/issues/24)
463464
- [ ] [SQL Formatting / Prettier](https://github.com/debba/tabularis/issues/23)
464-
- [ ] [Visual Explain Analyze](https://github.com/debba/tabularis/issues/22)
465465
- [ ] [Data Compare / Diff Tool](https://github.com/debba/tabularis/issues/21)
466466
- [ ] [Team Collaboration](https://github.com/debba/tabularis/issues/20)
467467
- [ ] [Query History](https://github.com/debba/tabularis/issues/18)

roadmap.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44
"done": true,
55
"url": "https://github.com/debba/tabularis/issues/47"
66
},
7+
{
8+
"label": "Visual Explain Analyze",
9+
"done": true,
10+
"url": "https://github.com/debba/tabularis/issues/22"
11+
},
712
{
813
"label": "Plugin System",
914
"done": true,
@@ -29,11 +34,6 @@
2934
"done": false,
3035
"url": "https://github.com/debba/tabularis/issues/23"
3136
},
32-
{
33-
"label": "Visual Explain Analyze",
34-
"done": false,
35-
"url": "https://github.com/debba/tabularis/issues/22"
36-
},
3737
{
3838
"label": "Data Compare / Diff Tool",
3939
"done": false,

src-tauri/src/ai.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,14 @@ pub async fn explain_ai_query(app: AppHandle, req: AiExplainRequest) -> Result<S
354354
explain_query(app, req).await
355355
}
356356

357+
#[tauri::command]
358+
pub async fn analyze_ai_explain_plan(
359+
app: AppHandle,
360+
req: AiExplainRequest,
361+
) -> Result<String, String> {
362+
analyze_explain_plan(app, req).await
363+
}
364+
357365
#[tauri::command]
358366
pub async fn generate_cell_name(app: AppHandle, req: AiCellNameRequest) -> Result<String, String> {
359367
generate_cellname(app, req).await
@@ -475,6 +483,42 @@ pub async fn explain_query(app: AppHandle, mut req: AiExplainRequest) -> Result<
475483
result
476484
}
477485

486+
pub async fn analyze_explain_plan(
487+
app: AppHandle,
488+
mut req: AiExplainRequest,
489+
) -> Result<String, String> {
490+
log::info!(
491+
"Analyzing explain plan using AI provider: {}",
492+
req.provider
493+
);
494+
495+
let app_config = config::load_config_internal(&app);
496+
let ollama_port = app_config.ai_ollama_port.unwrap_or(11434);
497+
req.model = resolve_model(&req.provider, &req.model, &app_config, ollama_port).await?;
498+
499+
let raw_prompt = config::get_explainplan_prompt(app);
500+
let system_prompt = raw_prompt.replace("{{LANGUAGE}}", &req.language);
501+
502+
let gen_req = AiGenerateRequest {
503+
provider: req.provider.clone(),
504+
model: req.model.clone(),
505+
prompt: req.query.clone(),
506+
schema: String::new(),
507+
};
508+
509+
let result = dispatch_provider(&app_config, &gen_req, &system_prompt, ollama_port).await;
510+
511+
match &result {
512+
Ok(_) => log::info!(
513+
"Explain plan analysis generated successfully using {}",
514+
req.model
515+
),
516+
Err(e) => log::error!("Explain plan analysis failed: {}", e),
517+
}
518+
519+
result
520+
}
521+
478522
async fn generate_with_simple_prompt(
479523
app: AppHandle,
480524
provider: String,

src-tauri/src/commands.rs

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ use uuid::Uuid;
1010
use crate::credential_cache;
1111
use crate::keychain_utils;
1212
use crate::models::{
13-
ColumnDefinition, ConnectionGroup, ConnectionParams, ConnectionsFile, ForeignKey, Index,
14-
QueryResult, RoutineInfo, RoutineParameter, SavedConnection, SshConnection,
13+
ColumnDefinition, ConnectionGroup, ConnectionParams, ConnectionsFile, ExplainPlan, ForeignKey,
14+
Index, QueryResult, RoutineInfo, RoutineParameter, SavedConnection, SshConnection,
1515
SshConnectionInput, SshTestParams, TableColumn, TableInfo, TestConnectionRequest,
1616
};
1717
use crate::persistence;
@@ -2061,6 +2061,79 @@ pub async fn execute_query<R: Runtime>(
20612061
}
20622062
}
20632063

2064+
// --- Explain Query Plan ---
2065+
2066+
#[tauri::command]
2067+
pub async fn explain_query_plan<R: Runtime>(
2068+
app: AppHandle<R>,
2069+
state: State<'_, QueryCancellationState>,
2070+
connection_id: String,
2071+
query: String,
2072+
analyze: bool,
2073+
schema: Option<String>,
2074+
) -> Result<ExplainPlan, String> {
2075+
log::info!(
2076+
"Explaining query on connection: {} | analyze: {} | Query: {}",
2077+
connection_id,
2078+
analyze,
2079+
query
2080+
);
2081+
2082+
let sanitized_query = query
2083+
.trim()
2084+
.trim_end_matches(';')
2085+
.replace('\u{2018}', "'")
2086+
.replace('\u{2019}', "'")
2087+
.replace('\u{201C}', "\"")
2088+
.replace('\u{201D}', "\"")
2089+
.to_string();
2090+
2091+
if !crate::drivers::common::is_explainable_query(&sanitized_query) {
2092+
return Err(
2093+
"EXPLAIN is only supported for DML statements (SELECT, INSERT, UPDATE, DELETE, REPLACE). DDL statements like CREATE, DROP, or ALTER cannot be explained."
2094+
.into(),
2095+
);
2096+
}
2097+
2098+
let saved_conn = find_connection_by_id(&app, &connection_id)?;
2099+
let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?;
2100+
let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?;
2101+
2102+
let drv = driver_for(&saved_conn.params.driver).await?;
2103+
let task = tokio::spawn(async move {
2104+
drv.explain_query(&params, &sanitized_query, analyze, schema.as_deref())
2105+
.await
2106+
});
2107+
2108+
let abort_handle = task.abort_handle();
2109+
{
2110+
let mut handles = state.handles.lock().unwrap();
2111+
handles.insert(format!("explain_{}", connection_id), abort_handle);
2112+
}
2113+
2114+
let result = task.await;
2115+
2116+
{
2117+
let mut handles = state.handles.lock().unwrap();
2118+
handles.remove(&format!("explain_{}", connection_id));
2119+
}
2120+
2121+
match result {
2122+
Ok(Ok(plan)) => {
2123+
log::info!("Explain query completed successfully");
2124+
Ok(plan)
2125+
}
2126+
Ok(Err(e)) => {
2127+
log::error!("Explain query failed: {}", e);
2128+
Err(e)
2129+
}
2130+
Err(_) => {
2131+
log::warn!("Explain query was cancelled");
2132+
Err("Explain query cancelled".into())
2133+
}
2134+
}
2135+
}
2136+
20642137
// --- Count Query ---
20652138

20662139
#[tauri::command]

src-tauri/src/config.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,8 @@ pub fn check_ai_key_status(provider: String) -> AiKeyStatus {
380380
const DEFAULT_SYSTEM_PROMPT: &str = "You are an expert SQL assistant. Your task is to generate a SQL query based on the user's request and the provided database schema.\nReturn ONLY the SQL query, without any markdown formatting, explanations, or code blocks.\n\nSchema:\n{{SCHEMA}}";
381381
const DEFAULT_EXPLAIN_PROMPT: &str =
382382
"You are a helpful SQL assistant. Explain SQL queries in {{LANGUAGE}}.";
383+
const DEFAULT_EXPLAINPLAN_PROMPT: &str =
384+
"You are a database performance expert. Analyze the following SQL query and its EXPLAIN plan output. Identify performance bottlenecks, suggest index improvements, and explain the execution strategy. Respond in {{LANGUAGE}}.";
383385
const DEFAULT_CELLNAME_PROMPT: &str = "You are an assistant that generates concise, descriptive names for notebook cells.\nGiven a SQL query or Markdown content, return ONLY a short name (3-6 words max) that describes what the cell does or what it is about.\nDo not include quotes, punctuation, or explanations. Just the name.";
384386
const DEFAULT_TABRENAME_PROMPT: &str = "You are an assistant that generates concise, descriptive names for SQL query result tabs.\nGiven a SQL query, return ONLY a short name (3-6 words max) that describes what the query does.\nDo not include quotes, punctuation, or explanations. Just the name.";
385387

@@ -438,6 +440,19 @@ pub fn reset_explain_prompt(app: AppHandle) -> Result<String, String> {
438440
reset_prompt(&app, "prompt_explain.txt", DEFAULT_EXPLAIN_PROMPT)
439441
}
440442

443+
#[tauri::command]
444+
pub fn get_explainplan_prompt(app: AppHandle) -> String {
445+
get_prompt(&app, "prompt_explainplan.txt", DEFAULT_EXPLAINPLAN_PROMPT)
446+
}
447+
#[tauri::command]
448+
pub fn save_explainplan_prompt(app: AppHandle, prompt: String) -> Result<(), String> {
449+
save_prompt(&app, "prompt_explainplan.txt", &prompt)
450+
}
451+
#[tauri::command]
452+
pub fn reset_explainplan_prompt(app: AppHandle) -> Result<String, String> {
453+
reset_prompt(&app, "prompt_explainplan.txt", DEFAULT_EXPLAINPLAN_PROMPT)
454+
}
455+
441456
#[tauri::command]
442457
pub fn get_cellname_prompt(app: AppHandle) -> String {
443458
get_prompt(&app, "prompt_cellname.txt", DEFAULT_CELLNAME_PROMPT)

src-tauri/src/drivers/common.rs

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,46 @@ pub fn is_select_query(query: &str) -> bool {
115115
query.trim_start().to_uppercase().starts_with("SELECT")
116116
}
117117

118+
/// Strip leading SQL comments (`-- …` line comments and `/* … */` block
119+
/// comments) and whitespace so the first statement keyword is at position 0.
120+
pub fn strip_leading_sql_comments(query: &str) -> &str {
121+
let mut s = query;
122+
loop {
123+
s = s.trim_start();
124+
if s.starts_with("--") {
125+
match s.find('\n') {
126+
Some(pos) => s = &s[pos + 1..],
127+
None => return "",
128+
}
129+
} else if s.starts_with("/*") {
130+
match s.find("*/") {
131+
Some(pos) => s = &s[pos + 2..],
132+
None => return "",
133+
}
134+
} else {
135+
break;
136+
}
137+
}
138+
s
139+
}
140+
141+
/// Check if a query type supports EXPLAIN.
142+
///
143+
/// MySQL/MariaDB support EXPLAIN for DML statements only:
144+
/// SELECT, INSERT, UPDATE, DELETE, REPLACE, and WITH (CTE).
145+
/// DDL statements (CREATE, DROP, ALTER, TRUNCATE, etc.) are not supported.
146+
/// Leading SQL comments are stripped before checking.
147+
pub fn is_explainable_query(query: &str) -> bool {
148+
let upper = strip_leading_sql_comments(query).to_uppercase();
149+
upper.starts_with("SELECT")
150+
|| upper.starts_with("INSERT")
151+
|| upper.starts_with("UPDATE")
152+
|| upper.starts_with("DELETE")
153+
|| upper.starts_with("REPLACE")
154+
|| upper.starts_with("WITH")
155+
|| upper.starts_with("TABLE")
156+
}
157+
118158
/// Calculate offset for pagination
119159
pub fn calculate_offset(page: u32, page_size: u32) -> u32 {
120160
(page - 1) * page_size
@@ -213,6 +253,95 @@ mod tests {
213253
assert_eq!(decoded, svg);
214254
}
215255

256+
#[test]
257+
fn test_strip_leading_sql_comments_line() {
258+
assert_eq!(
259+
strip_leading_sql_comments("-- comment\nSELECT 1"),
260+
"SELECT 1"
261+
);
262+
assert_eq!(
263+
strip_leading_sql_comments("-- line1\n-- line2\nSELECT 1"),
264+
"SELECT 1"
265+
);
266+
}
267+
268+
#[test]
269+
fn test_strip_leading_sql_comments_block() {
270+
assert_eq!(
271+
strip_leading_sql_comments("/* block */ SELECT 1"),
272+
"SELECT 1"
273+
);
274+
assert_eq!(
275+
strip_leading_sql_comments("/* a */ /* b */ SELECT 1"),
276+
"SELECT 1"
277+
);
278+
}
279+
280+
#[test]
281+
fn test_strip_leading_sql_comments_mixed() {
282+
assert_eq!(
283+
strip_leading_sql_comments("-- line\n/* block */\nSELECT 1"),
284+
"SELECT 1"
285+
);
286+
}
287+
288+
#[test]
289+
fn test_strip_leading_sql_comments_no_comments() {
290+
assert_eq!(strip_leading_sql_comments("SELECT 1"), "SELECT 1");
291+
assert_eq!(strip_leading_sql_comments(" SELECT 1"), "SELECT 1");
292+
}
293+
294+
#[test]
295+
fn test_strip_leading_sql_comments_unterminated() {
296+
assert_eq!(strip_leading_sql_comments("-- only comment"), "");
297+
assert_eq!(strip_leading_sql_comments("/* never closed"), "");
298+
}
299+
300+
#[test]
301+
fn test_is_explainable_query_dml() {
302+
assert!(is_explainable_query("SELECT * FROM users"));
303+
assert!(is_explainable_query(" select * from users"));
304+
assert!(is_explainable_query("INSERT INTO users VALUES (1)"));
305+
assert!(is_explainable_query("UPDATE users SET name = 'test'"));
306+
assert!(is_explainable_query("DELETE FROM users WHERE id = 1"));
307+
assert!(is_explainable_query("REPLACE INTO users VALUES (1, 'a')"));
308+
assert!(is_explainable_query("WITH cte AS (SELECT 1) SELECT * FROM cte"));
309+
assert!(is_explainable_query("TABLE users"));
310+
}
311+
312+
#[test]
313+
fn test_is_explainable_query_ddl() {
314+
assert!(!is_explainable_query("CREATE INDEX idx ON t(col)"));
315+
assert!(!is_explainable_query("CREATE TABLE users (id INT)"));
316+
assert!(!is_explainable_query("DROP TABLE users"));
317+
assert!(!is_explainable_query("ALTER TABLE users ADD COLUMN name TEXT"));
318+
assert!(!is_explainable_query("TRUNCATE TABLE users"));
319+
assert!(!is_explainable_query("GRANT SELECT ON users TO 'user'"));
320+
assert!(!is_explainable_query("REVOKE SELECT ON users FROM 'user'"));
321+
}
322+
323+
#[test]
324+
fn test_is_explainable_query_whitespace() {
325+
assert!(is_explainable_query("\n\t SELECT 1"));
326+
assert!(!is_explainable_query("\n\t CREATE INDEX idx ON t(col)"));
327+
}
328+
329+
#[test]
330+
fn test_is_explainable_query_with_comments() {
331+
assert!(is_explainable_query(
332+
"-- BEFORE index: full scan\nSELECT * FROM audit_log"
333+
));
334+
assert!(is_explainable_query(
335+
"/* explain this */ SELECT * FROM users"
336+
));
337+
assert!(is_explainable_query(
338+
"-- comment\n-- another\nDELETE FROM users WHERE id = 1"
339+
));
340+
assert!(!is_explainable_query(
341+
"-- setup\nCREATE INDEX idx ON t(col)"
342+
));
343+
}
344+
216345
#[test]
217346
fn test_is_select_query() {
218347
assert!(is_select_query("SELECT * FROM users"));

src-tauri/src/drivers/driver_trait.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ use sqlx::{AnyConnection, Connection};
77
use std::str::FromStr;
88

99
use crate::models::{
10-
ColumnDefinition, ConnectionParams, DataTypeInfo, ForeignKey, Index, QueryResult, RoutineInfo,
11-
RoutineParameter, TableColumn, TableInfo, TableSchema, ViewInfo,
10+
ColumnDefinition, ConnectionParams, DataTypeInfo, ExplainPlan, ForeignKey, Index, QueryResult,
11+
RoutineInfo, RoutineParameter, TableColumn, TableInfo, TableSchema, ViewInfo,
1212
};
1313

1414
/// Capabilities advertised by a driver.
@@ -309,6 +309,19 @@ pub trait DatabaseDriver: Send + Sync {
309309
schema: Option<&str>,
310310
) -> Result<QueryResult, String>;
311311

312+
/// Runs EXPLAIN (or EXPLAIN ANALYZE) on the given query and returns a
313+
/// parsed execution plan tree. Drivers that do not support EXPLAIN can
314+
/// rely on the default implementation which returns an error.
315+
async fn explain_query(
316+
&self,
317+
_params: &ConnectionParams,
318+
_query: &str,
319+
_analyze: bool,
320+
_schema: Option<&str>,
321+
) -> Result<ExplainPlan, String> {
322+
Err("EXPLAIN not supported by this driver".into())
323+
}
324+
312325
// --- CRUD ---------------------------------------------------------------
313326

314327
async fn insert_record(

0 commit comments

Comments
 (0)