From 5095c40f0ff57eb20063804c5aa70fed8f84d642 Mon Sep 17 00:00:00 2001 From: blankll Date: Sat, 8 Aug 2026 03:04:26 +0800 Subject: [PATCH 1/5] feat(sql-analysis): add SQL editability analysis command Parse a SELECT statement with sqlparser and decide whether its result rows map back to a single base table. Returns table name + schema for plain single-table SELECTs; otherwise a machine-readable non-editable reason the frontend can surface. Registered as analyze_sql_editability_command. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/sql_analysis.rs | 384 +++++++++++++++++++++++++ src-tauri/src/lib.rs | 1 + 3 files changed, 386 insertions(+) create mode 100644 src-tauri/src/commands/sql_analysis.rs 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/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, From 3c415e6c00f4baf4cb71c289edfa467f379e6020 Mon Sep 17 00:00:00 2001 From: blankll Date: Sat, 8 Aug 2026 03:04:32 +0800 Subject: [PATCH 2/5] fix(query): preserve columns for empty SELECT results Empty SELECT result sets returned QueryResult with no columns, so the frontend rendered the DML success message instead of an empty table. Use each driver's statement/stream metadata (postgres prepare, mysql query_iter, sqlserver stream columns) so column names survive zero-row results. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src-tauri/src/database/mysql.rs | 48 +++++++++++----------- src-tauri/src/database/postgres.rs | 59 ++++++++++++++++----------- src-tauri/src/database/sqlserver.rs | 62 +++++++++++++++++++---------- 3 files changed, 101 insertions(+), 68 deletions(-) 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)) } } From 647d8a7c824e523369a3298e96a0569f1863f207 Mon Sep 17 00:00:00 2001 From: blankll Date: Sat, 8 Aug 2026 03:04:39 +0800 Subject: [PATCH 3/5] feat(query-result): editable results with editability reason hints QueryResultPanel analyzes the executed SQL, fetches primary keys for single-table SELECTs, and passes table/schema/pk props to DataGrid so Edit/Duplicate/Delete work. Non-editable queries surface a specific reason (join/aggregation/cte/union) instead of a generic toast. Also declares rowIndex prop on CellContextMenu to silence the Teleport attribute warning. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../database-browser/QueryResultPanel.vue | 77 ++++++++++++++++++- src/components/grid/CellContextMenu.vue | 1 + src/components/grid/DataGrid.vue | 26 ++++++- 3 files changed, 101 insertions(+), 3 deletions(-) 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 @@