diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 1fb11f1..3143f07 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -6,6 +6,7 @@ pub mod helpers; pub mod jdbc; pub mod query; pub mod server; +pub mod sql_analysis; pub mod store; pub mod transfer; diff --git a/src-tauri/src/commands/sql_analysis.rs b/src-tauri/src/commands/sql_analysis.rs new file mode 100644 index 0000000..2316be7 --- /dev/null +++ b/src-tauri/src/commands/sql_analysis.rs @@ -0,0 +1,384 @@ +//! SQL editability analysis. +//! +//! Parses a SELECT statement with sqlparser and decides whether its result rows +//! can be mapped back to a single base table (and therefore edited/deleted). +//! A result is editable only when the query is a plain single-table SELECT +//! (optionally with WHERE/ORDER BY/LIMIT) whose rows map 1:1 to the table. + +use serde::{Deserialize, Serialize}; +use sqlparser::ast::{ + Expr, GroupByExpr, ObjectNamePart, Query, Select, SelectItem, SetExpr, Statement, TableFactor, +}; +use sqlparser::dialect::{GenericDialect, MsSqlDialect, MySqlDialect, PostgreSqlDialect}; +use sqlparser::parser::Parser; + +/// Why a query result cannot be edited. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum NonEditableReason { + /// Statement is not a SELECT (INSERT/UPDATE/DELETE/DDL...). + NotSelect, + /// Query starts with WITH (CTE) — row identity cannot be trusted. + Cte, + /// UNION/INTERSECT/EXCEPT — rows come from multiple statements. + SetOperation, + /// GROUP BY / HAVING / DISTINCT / aggregate functions — rows are aggregated. + Aggregation, + /// More than one table source (JOIN or comma-separated). + MultipleSources, + /// No FROM clause at all. + NoTable, + /// The FROM source is a subquery/table function/parenthesized join. + ComplexSource, +} + +/// Result of analyzing whether a query's rows are editable. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SqlEditability { + pub editable: bool, + /// Present only when editable — the single base table the query reads from. + pub table_name: Option, + pub schema: Option, + pub reason: Option, +} + +fn dialect_for(db_type: &str) -> Box { + match db_type.to_ascii_lowercase().as_str() { + "postgres" | "postgresql" | "duckdb" | "cockroachdb" | "gbase8c" | "kingbasees" | "yashandb" + | "xugudb" | "timescaledb" | "redshift" | "yugabytedb" | "opengauss" | "highgo" | "uxdb" + | "gaussdb" => Box::new(PostgreSqlDialect {}), + "mysql" | "clickhouse" | "oceanbase" | "mariadb" | "gbase8a" => Box::new(MySqlDialect {}), + "sqlserver" | "mssql" => Box::new(MsSqlDialect {}), + _ => Box::new(GenericDialect {}), + } +} + +/// Analyze a SQL statement and report whether its result rows are editable. +/// +/// Safe by construction: only plain single-table SELECTs map result rows back +/// to base rows. Aggregations, set operations, CTEs, and multi-source queries +/// are reported as non-editable with a machine-readable reason. +pub fn analyze_sql_editability(sql: &str, db_type: &str) -> SqlEditability { + let dialect = dialect_for(db_type); + let Ok(statements) = Parser::parse_sql(&*dialect, sql) else { + return SqlEditability { + editable: false, + table_name: None, + schema: None, + reason: Some(NonEditableReason::ComplexSource), + }; + }; + + if statements.len() != 1 { + return SqlEditability { + editable: false, + table_name: None, + schema: None, + reason: Some(NonEditableReason::NotSelect), + }; + } + + let Some(query) = as_select_query(&statements[0]) else { + return SqlEditability { + editable: false, + table_name: None, + schema: None, + reason: Some(NonEditableReason::NotSelect), + }; + }; + + // WITH (CTE) — the top-level FROM may reference a CTE instead of a table. + if query.with.is_some() { + return non_editable(NonEditableReason::Cte); + } + + // UNION/INTERSECT/EXCEPT — rows come from multiple statements. + if !matches!(query.body.as_ref(), SetExpr::Select(_)) { + return non_editable(NonEditableReason::SetOperation); + } + + let SetExpr::Select(select) = query.body.as_ref() else { + return non_editable(NonEditableReason::SetOperation); + }; + + if select_is_aggregated(select) { + return non_editable(NonEditableReason::Aggregation); + } + + if select.from.is_empty() { + return non_editable(NonEditableReason::NoTable); + } + + // Exactly one FROM source, and it must be a plain table (no subquery, + // no table function, no parenthesized join). + if select.from.len() > 1 { + return non_editable(NonEditableReason::MultipleSources); + } + + let table_with_joins = &select.from[0]; + if !table_with_joins.joins.is_empty() { + return non_editable(NonEditableReason::MultipleSources); + } + + let TableFactor::Table { name, .. } = &table_with_joins.relation else { + return non_editable(NonEditableReason::ComplexSource); + }; + + // Extract schema (second-to-last part) and table (last part). + let parts: Vec<&String> = name + .0 + .iter() + .filter_map(ObjectNamePart::as_ident) + .map(|ident| &ident.value) + .collect(); + + let Some(table_name) = parts.last().cloned().cloned() else { + return non_editable(NonEditableReason::NoTable); + }; + + let schema = if parts.len() >= 2 { + Some(parts[parts.len() - 2].clone()) + } else { + None + }; + + SqlEditability { + editable: true, + table_name: Some(table_name), + schema, + reason: None, + } +} + +fn non_editable(reason: NonEditableReason) -> SqlEditability { + SqlEditability { + editable: false, + table_name: None, + schema: None, + reason: Some(reason), + } +} + +fn as_select_query(statement: &Statement) -> Option<&Query> { + match statement { + Statement::Query(query) => Some(query), + _ => None, + } +} + +/// True when the SELECT is aggregated: GROUP BY/HAVING/DISTINCT or an +/// aggregate function in the projection. Aggregated rows do not map 1:1 to +/// base-table rows. +fn select_is_aggregated(select: &Select) -> bool { + if select.distinct.is_some() { + return true; + } + if matches!(select.group_by, GroupByExpr::Expressions(ref exprs, _) if !exprs.is_empty()) { + return true; + } + if select.having.is_some() { + return true; + } + select.projection.iter().any(projection_has_aggregate) +} + +fn projection_has_aggregate(item: &SelectItem) -> bool { + match item { + SelectItem::UnnamedExpr(expr) | SelectItem::ExprWithAlias { expr, .. } => { + expr_has_aggregate(expr) + } + _ => false, + } +} + +fn expr_has_aggregate(expr: &Expr) -> bool { + match expr { + Expr::Function(function) => { + if is_aggregate_function(&function.name.to_string()) { + return true; + } + expr_function_has_aggregate_arg(&function.args) + || function.filter.as_deref().is_some_and(expr_has_aggregate) + } + Expr::BinaryOp { left, right, .. } => expr_has_aggregate(left) || expr_has_aggregate(right), + Expr::Nested(inner) | Expr::IsNull(inner) | Expr::IsNotNull(inner) => expr_has_aggregate(inner), + Expr::UnaryOp { expr: inner, .. } => expr_has_aggregate(inner), + Expr::Case { operand, conditions, else_result, .. } => { + operand.as_deref().is_some_and(expr_has_aggregate) + || conditions + .iter() + .any(|cond| expr_has_aggregate(&cond.condition) || expr_has_aggregate(&cond.result)) + || else_result.as_deref().is_some_and(expr_has_aggregate) + } + Expr::Subquery(query) | Expr::Exists { subquery: query, .. } => { + let mut found = false; + if let SetExpr::Select(select) = query.body.as_ref() { + found = select.projection.iter().any(projection_has_aggregate); + } + found + } + _ => false, + } +} + +fn expr_function_has_aggregate_arg(args: &sqlparser::ast::FunctionArguments) -> bool { + match args { + sqlparser::ast::FunctionArguments::List(list) => list + .args + .iter() + .any(|arg| match arg { + sqlparser::ast::FunctionArg::Unnamed(sqlparser::ast::FunctionArgExpr::Expr(e)) => { + expr_has_aggregate(e) + } + sqlparser::ast::FunctionArg::Named { arg, .. } + | sqlparser::ast::FunctionArg::ExprNamed { arg, .. } => match arg { + sqlparser::ast::FunctionArgExpr::Expr(e) => expr_has_aggregate(e), + _ => false, + }, + _ => false, + }), + sqlparser::ast::FunctionArguments::Subquery(query) => { + let mut found = false; + if let SetExpr::Select(select) = query.body.as_ref() { + found = select.projection.iter().any(projection_has_aggregate); + } + found + } + sqlparser::ast::FunctionArguments::None => false, + } +} + +fn is_aggregate_function(name: &str) -> bool { + matches!( + name.to_ascii_uppercase().as_str(), + "COUNT" | "SUM" | "AVG" | "MIN" | "MAX" | "STDDEV" | "STDDEV_POP" | "STDDEV_SAMP" | "VARIANCE" + | "VAR_POP" | "VAR_SAMP" | "ARRAY_AGG" | "STRING_AGG" | "JSON_AGG" | "JSONB_AGG" + | "BOOL_AND" | "BOOL_OR" | "EVERY" + ) +} + +/// Tauri command: analyze whether a query's result rows are editable. +/// +/// Returns the single base table (with optional schema) when the query is a +/// plain single-table SELECT; otherwise a machine-readable non-editable reason +/// the frontend can surface to the user. +#[tauri::command] +pub fn analyze_sql_editability_command(sql: String, database_type: Option) -> SqlEditability { + analyze_sql_editability(&sql, database_type.as_deref().unwrap_or("generic")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn editable(sql: &str) -> bool { + analyze_sql_editability(sql, "postgres").editable + } + + fn reason(sql: &str) -> Option { + analyze_sql_editability(sql, "postgres").reason + } + + #[test] + fn plain_select_star_is_editable() { + let result = analyze_sql_editability("SELECT * FROM apps", "postgres"); + assert!(result.editable); + assert_eq!(result.table_name.as_deref(), Some("apps")); + assert_eq!(result.schema, None); + } + + #[test] + fn select_with_qualifier_returns_schema() { + let result = analyze_sql_editability("SELECT * FROM public.apps", "postgres"); + assert!(result.editable); + assert_eq!(result.table_name.as_deref(), Some("apps")); + assert_eq!(result.schema.as_deref(), Some("public")); + } + + #[test] + fn select_with_where_order_limit_is_editable() { + assert!(editable("SELECT id, name FROM customers WHERE id > 10 ORDER BY name LIMIT 100")); + } + + #[test] + fn quoted_table_name_is_editable() { + let result = analyze_sql_editability("SELECT * FROM \"My Table\"", "postgres"); + assert!(result.editable); + assert_eq!(result.table_name.as_deref(), Some("My Table")); + } + + #[test] + fn join_is_not_editable() { + assert!(!editable("SELECT a.*, b.* FROM a JOIN b ON a.id = b.id")); + assert_eq!(reason("SELECT a.*, b.* FROM a JOIN b ON a.id = b.id"), Some(NonEditableReason::MultipleSources)); + } + + #[test] + fn comma_separated_sources_is_not_editable() { + assert_eq!(reason("SELECT * FROM a, b"), Some(NonEditableReason::MultipleSources)); + } + + #[test] + fn count_aggregation_is_not_editable() { + assert_eq!(reason("SELECT COUNT(*) FROM apps"), Some(NonEditableReason::Aggregation)); + } + + #[test] + fn group_by_is_not_editable() { + assert_eq!(reason("SELECT name, COUNT(*) FROM customers GROUP BY name"), Some(NonEditableReason::Aggregation)); + } + + #[test] + fn distinct_is_not_editable() { + assert_eq!(reason("SELECT DISTINCT name FROM customers"), Some(NonEditableReason::Aggregation)); + } + + #[test] + fn union_is_not_editable() { + assert_eq!(reason("SELECT * FROM a UNION SELECT * FROM b"), Some(NonEditableReason::SetOperation)); + } + + #[test] + fn cte_is_not_editable() { + assert_eq!( + reason("WITH t AS (SELECT * FROM apps) SELECT * FROM t"), + Some(NonEditableReason::Cte) + ); + } + + #[test] + fn subquery_source_is_not_editable() { + assert_eq!( + reason("SELECT * FROM (SELECT * FROM apps) t"), + Some(NonEditableReason::ComplexSource) + ); + } + + #[test] + fn non_select_statement_is_not_editable() { + assert_eq!(reason("UPDATE apps SET name = 'x'"), Some(NonEditableReason::NotSelect)); + assert_eq!(reason("DELETE FROM apps"), Some(NonEditableReason::NotSelect)); + assert_eq!(reason("INSERT INTO apps (name) VALUES ('x')"), Some(NonEditableReason::NotSelect)); + } + + #[test] + fn multiple_statements_is_not_editable() { + assert_eq!( + reason("SELECT * FROM apps; SELECT * FROM orders"), + Some(NonEditableReason::NotSelect) + ); + } + + #[test] + fn mysql_backtick_table_is_editable() { + let result = analyze_sql_editability("SELECT * FROM `customers`", "mysql"); + assert!(result.editable); + assert_eq!(result.table_name.as_deref(), Some("customers")); + } + + #[test] + fn no_from_is_not_editable() { + assert_eq!(reason("SELECT 1"), Some(NonEditableReason::NoTable)); + } +} diff --git a/src-tauri/src/database/mysql.rs b/src-tauri/src/database/mysql.rs index 7c5b824..1b43637 100644 --- a/src-tauri/src/database/mysql.rs +++ b/src-tauri/src/database/mysql.rs @@ -527,42 +527,42 @@ impl DatabaseAdapter for MySQLAdapter { let execution_time; if is_select { - // Execute query and get results - let result: Vec = if let Some(timeout_duration) = timeout { - tokio::time::timeout(timeout_duration, conn.query(query)) + // Use query_iter so column metadata is available even when the + // result set is empty — an empty SELECT must still render its + // columns (empty table) rather than looking like a DML statement. + let mut result = if let Some(timeout_duration) = timeout { + tokio::time::timeout(timeout_duration, conn.query_iter(query)) .await .map_err(|_| { DbError::Timeout(format!("Query timed out after {:?}", timeout_duration)) })? .map_err(mysql_error_to_db_error)? } else { - conn.query(query).await.map_err(mysql_error_to_db_error)? + conn.query_iter(query).await.map_err(mysql_error_to_db_error)? }; execution_time = start.elapsed().as_millis() as u64; - if result.is_empty() { - Ok(QueryResult::new(Vec::new()).with_execution_time(execution_time)) - } else { - let columns: Vec = result[0] - .columns_ref() - .iter() - .map(|col| col.name_str().to_string()) - .collect(); - let column_types: Vec = result[0] - .columns_ref() - .iter() - .map(|col| format!("{:?}", col.column_type())) - .collect(); - - let mut query_result = QueryResult::with_columns(columns, column_types); - for row in result { - let query_row = Self::row_to_query_row(row)?; - query_result.add_row(query_row); - } + let columns: Vec = result + .columns() + .map(|cols| cols.iter().map(|col| col.name_str().to_string()).collect()) + .unwrap_or_default(); + let column_types: Vec = result + .columns() + .map(|cols| { + cols.iter() + .map(|col| format!("{:?}", col.column_type())) + .collect() + }) + .unwrap_or_default(); - Ok(query_result.with_execution_time(execution_time)) + let mut query_result = QueryResult::with_columns(columns, column_types); + while let Some(row) = result.next().await.map_err(mysql_error_to_db_error)? { + let query_row = Self::row_to_query_row(row)?; + query_result.add_row(query_row); } + + Ok(query_result.with_execution_time(execution_time)) } else { // For INSERT, UPDATE, DELETE, etc. if let Some(timeout_duration) = timeout { diff --git a/src-tauri/src/database/postgres.rs b/src-tauri/src/database/postgres.rs index 26ce234..47524f2 100644 --- a/src-tauri/src/database/postgres.rs +++ b/src-tauri/src/database/postgres.rs @@ -1005,8 +1005,11 @@ impl DatabaseAdapter for PostgresAdapter { let execution_time; if is_select { - let result = if let Some(timeout_duration) = timeout { - tokio::time::timeout(timeout_duration, client.query(query, &[])) + // Prepare first so column metadata is available even when the + // result set is empty — an empty SELECT must still render its + // columns (empty table) rather than looking like a DML statement. + let statement = if let Some(timeout_duration) = timeout { + tokio::time::timeout(timeout_duration, client.prepare(query)) .await .map_err(|_| { DbError::Timeout(format!("Query timed out after {:?}", timeout_duration)) @@ -1014,35 +1017,45 @@ impl DatabaseAdapter for PostgresAdapter { .map_err(postgres_error_to_db_error)? } else { client - .query(query, &[]) + .prepare(query) .await .map_err(postgres_error_to_db_error)? }; - execution_time = start.elapsed().as_millis() as u64; - - if result.is_empty() { - Ok(QueryResult::new(Vec::new()).with_execution_time(execution_time)) + let result = if let Some(timeout_duration) = timeout { + tokio::time::timeout(timeout_duration, client.query(&statement, &[])) + .await + .map_err(|_| { + DbError::Timeout(format!("Query timed out after {:?}", timeout_duration)) + })? + .map_err(postgres_error_to_db_error)? } else { - let columns: Vec = result[0] - .columns() - .iter() - .map(|col| col.name().to_string()) - .collect(); - let column_types: Vec = result[0] - .columns() - .iter() - .map(|col| col.type_().name().to_string()) - .collect(); + client + .query(&statement, &[]) + .await + .map_err(postgres_error_to_db_error)? + }; - let mut query_result = QueryResult::with_columns(columns, column_types); - for row in &result { - let query_row = Self::row_to_query_row(row)?; - query_result.add_row(query_row); - } + execution_time = start.elapsed().as_millis() as u64; - Ok(query_result.with_execution_time(execution_time)) + let columns: Vec = statement + .columns() + .iter() + .map(|col| col.name().to_string()) + .collect(); + let column_types: Vec = statement + .columns() + .iter() + .map(|col| col.type_().name().to_string()) + .collect(); + + let mut query_result = QueryResult::with_columns(columns, column_types); + for row in &result { + let query_row = Self::row_to_query_row(row)?; + query_result.add_row(query_row); } + + Ok(query_result.with_execution_time(execution_time)) } else { // For INSERT, UPDATE, DELETE, etc. let affected = if let Some(timeout_duration) = timeout { diff --git a/src-tauri/src/database/sqlserver.rs b/src-tauri/src/database/sqlserver.rs index 7412af7..5ca4d83 100644 --- a/src-tauri/src/database/sqlserver.rs +++ b/src-tauri/src/database/sqlserver.rs @@ -421,7 +421,7 @@ impl DatabaseAdapter for SqlServerAdapter { .map(Duration::from_millis); // Execute query with optional timeout - let stream = if let Some(timeout_duration) = timeout { + let mut stream = if let Some(timeout_duration) = timeout { tokio::time::timeout(timeout_duration, client.simple_query(query)) .await .map_err(|_| { @@ -436,6 +436,20 @@ impl DatabaseAdapter for SqlServerAdapter { }; // Collect results + // QueryStream::columns() forwards to the first result-set metadata + // without consuming rows, so column names survive even when the result + // set is empty — an empty SELECT must still render its columns rather + // than looking like a DML statement. + let stream_columns = stream + .columns() + .await + .map_err(|e| DbError::QueryExecution(e.to_string()))? + .map(|cols| { + cols.iter() + .map(|col| (col.name().to_string(), format!("{:?}", col.column_type()))) + .collect::>() + }); + let results = stream .into_results() .await @@ -452,28 +466,34 @@ impl DatabaseAdapter for SqlServerAdapter { } else { let result_set = &results[0]; - if result_set.is_empty() { - Ok(QueryResult::new(Vec::new()).with_execution_time(execution_time)) - } else { - let columns: Vec = result_set[0] - .columns() - .iter() - .map(|col| col.name().to_string()) - .collect(); - let column_types: Vec = result_set[0] - .columns() - .iter() - .map(|col| format!("{:?}", col.column_type())) - .collect(); - - let mut query_result = QueryResult::with_columns(columns, column_types); - for row in result_set { - let query_row = Self::row_to_query_row(row)?; - query_result.add_row(query_row); - } + // Fall back to the first row for column names when the stream + // metadata is unavailable (should not happen in practice). + let columns: Vec = stream_columns + .as_ref() + .map(|cols| cols.iter().map(|(name, _)| name.clone()).collect()) + .or_else(|| { + result_set + .first() + .map(|row| row.columns().iter().map(|col| col.name().to_string()).collect()) + }) + .unwrap_or_default(); + let column_types: Vec = stream_columns + .as_ref() + .map(|cols| cols.iter().map(|(_, ty)| ty.clone()).collect()) + .or_else(|| { + result_set + .first() + .map(|row| row.columns().iter().map(|col| format!("{:?}", col.column_type())).collect()) + }) + .unwrap_or_default(); - Ok(query_result.with_execution_time(execution_time)) + let mut query_result = QueryResult::with_columns(columns, column_types); + for row in result_set { + let query_row = Self::row_to_query_row(row)?; + query_result.add_row(query_row); } + + Ok(query_result.with_execution_time(execution_time)) } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b2f2aaa..dbaac2e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -286,6 +286,7 @@ pub fn run() { commands::execute_sorted_query, commands::cancel_query, commands::explain_query, + commands::sql_analysis::analyze_sql_editability_command, // Database browsing commands commands::list_databases, commands::list_schemas, diff --git a/src/components/database-browser/QueryResultPanel.vue b/src/components/database-browser/QueryResultPanel.vue index 70b69f9..d4af3e4 100644 --- a/src/components/database-browser/QueryResultPanel.vue +++ b/src/components/database-browser/QueryResultPanel.vue @@ -1,4 +1,5 @@