Skip to content

Commit cf340a7

Browse files
feat: editable query results + empty SELECT rendering (#133)
* 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 <clio-agent@sisyphuslabs.ai> * 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 <clio-agent@sisyphuslabs.ai> * 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 <clio-agent@sisyphuslabs.ai> * fix(queries): orphan tab execution guard + track last executed SQL Executing on an orphaned tab now opens a dialog offering rebind-to-active-connection, close tab, or cancel, instead of silently dropping the query. Add reattachTab to clear the orphan marker, and record lastExecutedSql so the result panel analyzes the actual executed statement rather than the whole editor buffer. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * feat(i18n): add edit-unsupported reason messages Add per-reason copy (not-select/cte/set-operation/aggregation/multiple-sources/no-table/complex-source) plus the execute-orphan dialog strings in English and Chinese. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> --------- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent bcc756d commit cf340a7

13 files changed

Lines changed: 734 additions & 74 deletions

File tree

src-tauri/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ pub mod helpers;
66
pub mod jdbc;
77
pub mod query;
88
pub mod server;
9+
pub mod sql_analysis;
910
pub mod store;
1011
pub mod transfer;
1112

Lines changed: 384 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,384 @@
1+
//! SQL editability analysis.
2+
//!
3+
//! Parses a SELECT statement with sqlparser and decides whether its result rows
4+
//! can be mapped back to a single base table (and therefore edited/deleted).
5+
//! A result is editable only when the query is a plain single-table SELECT
6+
//! (optionally with WHERE/ORDER BY/LIMIT) whose rows map 1:1 to the table.
7+
8+
use serde::{Deserialize, Serialize};
9+
use sqlparser::ast::{
10+
Expr, GroupByExpr, ObjectNamePart, Query, Select, SelectItem, SetExpr, Statement, TableFactor,
11+
};
12+
use sqlparser::dialect::{GenericDialect, MsSqlDialect, MySqlDialect, PostgreSqlDialect};
13+
use sqlparser::parser::Parser;
14+
15+
/// Why a query result cannot be edited.
16+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17+
#[serde(rename_all = "kebab-case")]
18+
pub enum NonEditableReason {
19+
/// Statement is not a SELECT (INSERT/UPDATE/DELETE/DDL...).
20+
NotSelect,
21+
/// Query starts with WITH (CTE) — row identity cannot be trusted.
22+
Cte,
23+
/// UNION/INTERSECT/EXCEPT — rows come from multiple statements.
24+
SetOperation,
25+
/// GROUP BY / HAVING / DISTINCT / aggregate functions — rows are aggregated.
26+
Aggregation,
27+
/// More than one table source (JOIN or comma-separated).
28+
MultipleSources,
29+
/// No FROM clause at all.
30+
NoTable,
31+
/// The FROM source is a subquery/table function/parenthesized join.
32+
ComplexSource,
33+
}
34+
35+
/// Result of analyzing whether a query's rows are editable.
36+
#[derive(Debug, Clone, Serialize, Deserialize)]
37+
#[serde(rename_all = "camelCase")]
38+
pub struct SqlEditability {
39+
pub editable: bool,
40+
/// Present only when editable — the single base table the query reads from.
41+
pub table_name: Option<String>,
42+
pub schema: Option<String>,
43+
pub reason: Option<NonEditableReason>,
44+
}
45+
46+
fn dialect_for(db_type: &str) -> Box<dyn sqlparser::dialect::Dialect> {
47+
match db_type.to_ascii_lowercase().as_str() {
48+
"postgres" | "postgresql" | "duckdb" | "cockroachdb" | "gbase8c" | "kingbasees" | "yashandb"
49+
| "xugudb" | "timescaledb" | "redshift" | "yugabytedb" | "opengauss" | "highgo" | "uxdb"
50+
| "gaussdb" => Box::new(PostgreSqlDialect {}),
51+
"mysql" | "clickhouse" | "oceanbase" | "mariadb" | "gbase8a" => Box::new(MySqlDialect {}),
52+
"sqlserver" | "mssql" => Box::new(MsSqlDialect {}),
53+
_ => Box::new(GenericDialect {}),
54+
}
55+
}
56+
57+
/// Analyze a SQL statement and report whether its result rows are editable.
58+
///
59+
/// Safe by construction: only plain single-table SELECTs map result rows back
60+
/// to base rows. Aggregations, set operations, CTEs, and multi-source queries
61+
/// are reported as non-editable with a machine-readable reason.
62+
pub fn analyze_sql_editability(sql: &str, db_type: &str) -> SqlEditability {
63+
let dialect = dialect_for(db_type);
64+
let Ok(statements) = Parser::parse_sql(&*dialect, sql) else {
65+
return SqlEditability {
66+
editable: false,
67+
table_name: None,
68+
schema: None,
69+
reason: Some(NonEditableReason::ComplexSource),
70+
};
71+
};
72+
73+
if statements.len() != 1 {
74+
return SqlEditability {
75+
editable: false,
76+
table_name: None,
77+
schema: None,
78+
reason: Some(NonEditableReason::NotSelect),
79+
};
80+
}
81+
82+
let Some(query) = as_select_query(&statements[0]) else {
83+
return SqlEditability {
84+
editable: false,
85+
table_name: None,
86+
schema: None,
87+
reason: Some(NonEditableReason::NotSelect),
88+
};
89+
};
90+
91+
// WITH (CTE) — the top-level FROM may reference a CTE instead of a table.
92+
if query.with.is_some() {
93+
return non_editable(NonEditableReason::Cte);
94+
}
95+
96+
// UNION/INTERSECT/EXCEPT — rows come from multiple statements.
97+
if !matches!(query.body.as_ref(), SetExpr::Select(_)) {
98+
return non_editable(NonEditableReason::SetOperation);
99+
}
100+
101+
let SetExpr::Select(select) = query.body.as_ref() else {
102+
return non_editable(NonEditableReason::SetOperation);
103+
};
104+
105+
if select_is_aggregated(select) {
106+
return non_editable(NonEditableReason::Aggregation);
107+
}
108+
109+
if select.from.is_empty() {
110+
return non_editable(NonEditableReason::NoTable);
111+
}
112+
113+
// Exactly one FROM source, and it must be a plain table (no subquery,
114+
// no table function, no parenthesized join).
115+
if select.from.len() > 1 {
116+
return non_editable(NonEditableReason::MultipleSources);
117+
}
118+
119+
let table_with_joins = &select.from[0];
120+
if !table_with_joins.joins.is_empty() {
121+
return non_editable(NonEditableReason::MultipleSources);
122+
}
123+
124+
let TableFactor::Table { name, .. } = &table_with_joins.relation else {
125+
return non_editable(NonEditableReason::ComplexSource);
126+
};
127+
128+
// Extract schema (second-to-last part) and table (last part).
129+
let parts: Vec<&String> = name
130+
.0
131+
.iter()
132+
.filter_map(ObjectNamePart::as_ident)
133+
.map(|ident| &ident.value)
134+
.collect();
135+
136+
let Some(table_name) = parts.last().cloned().cloned() else {
137+
return non_editable(NonEditableReason::NoTable);
138+
};
139+
140+
let schema = if parts.len() >= 2 {
141+
Some(parts[parts.len() - 2].clone())
142+
} else {
143+
None
144+
};
145+
146+
SqlEditability {
147+
editable: true,
148+
table_name: Some(table_name),
149+
schema,
150+
reason: None,
151+
}
152+
}
153+
154+
fn non_editable(reason: NonEditableReason) -> SqlEditability {
155+
SqlEditability {
156+
editable: false,
157+
table_name: None,
158+
schema: None,
159+
reason: Some(reason),
160+
}
161+
}
162+
163+
fn as_select_query(statement: &Statement) -> Option<&Query> {
164+
match statement {
165+
Statement::Query(query) => Some(query),
166+
_ => None,
167+
}
168+
}
169+
170+
/// True when the SELECT is aggregated: GROUP BY/HAVING/DISTINCT or an
171+
/// aggregate function in the projection. Aggregated rows do not map 1:1 to
172+
/// base-table rows.
173+
fn select_is_aggregated(select: &Select) -> bool {
174+
if select.distinct.is_some() {
175+
return true;
176+
}
177+
if matches!(select.group_by, GroupByExpr::Expressions(ref exprs, _) if !exprs.is_empty()) {
178+
return true;
179+
}
180+
if select.having.is_some() {
181+
return true;
182+
}
183+
select.projection.iter().any(projection_has_aggregate)
184+
}
185+
186+
fn projection_has_aggregate(item: &SelectItem) -> bool {
187+
match item {
188+
SelectItem::UnnamedExpr(expr) | SelectItem::ExprWithAlias { expr, .. } => {
189+
expr_has_aggregate(expr)
190+
}
191+
_ => false,
192+
}
193+
}
194+
195+
fn expr_has_aggregate(expr: &Expr) -> bool {
196+
match expr {
197+
Expr::Function(function) => {
198+
if is_aggregate_function(&function.name.to_string()) {
199+
return true;
200+
}
201+
expr_function_has_aggregate_arg(&function.args)
202+
|| function.filter.as_deref().is_some_and(expr_has_aggregate)
203+
}
204+
Expr::BinaryOp { left, right, .. } => expr_has_aggregate(left) || expr_has_aggregate(right),
205+
Expr::Nested(inner) | Expr::IsNull(inner) | Expr::IsNotNull(inner) => expr_has_aggregate(inner),
206+
Expr::UnaryOp { expr: inner, .. } => expr_has_aggregate(inner),
207+
Expr::Case { operand, conditions, else_result, .. } => {
208+
operand.as_deref().is_some_and(expr_has_aggregate)
209+
|| conditions
210+
.iter()
211+
.any(|cond| expr_has_aggregate(&cond.condition) || expr_has_aggregate(&cond.result))
212+
|| else_result.as_deref().is_some_and(expr_has_aggregate)
213+
}
214+
Expr::Subquery(query) | Expr::Exists { subquery: query, .. } => {
215+
let mut found = false;
216+
if let SetExpr::Select(select) = query.body.as_ref() {
217+
found = select.projection.iter().any(projection_has_aggregate);
218+
}
219+
found
220+
}
221+
_ => false,
222+
}
223+
}
224+
225+
fn expr_function_has_aggregate_arg(args: &sqlparser::ast::FunctionArguments) -> bool {
226+
match args {
227+
sqlparser::ast::FunctionArguments::List(list) => list
228+
.args
229+
.iter()
230+
.any(|arg| match arg {
231+
sqlparser::ast::FunctionArg::Unnamed(sqlparser::ast::FunctionArgExpr::Expr(e)) => {
232+
expr_has_aggregate(e)
233+
}
234+
sqlparser::ast::FunctionArg::Named { arg, .. }
235+
| sqlparser::ast::FunctionArg::ExprNamed { arg, .. } => match arg {
236+
sqlparser::ast::FunctionArgExpr::Expr(e) => expr_has_aggregate(e),
237+
_ => false,
238+
},
239+
_ => false,
240+
}),
241+
sqlparser::ast::FunctionArguments::Subquery(query) => {
242+
let mut found = false;
243+
if let SetExpr::Select(select) = query.body.as_ref() {
244+
found = select.projection.iter().any(projection_has_aggregate);
245+
}
246+
found
247+
}
248+
sqlparser::ast::FunctionArguments::None => false,
249+
}
250+
}
251+
252+
fn is_aggregate_function(name: &str) -> bool {
253+
matches!(
254+
name.to_ascii_uppercase().as_str(),
255+
"COUNT" | "SUM" | "AVG" | "MIN" | "MAX" | "STDDEV" | "STDDEV_POP" | "STDDEV_SAMP" | "VARIANCE"
256+
| "VAR_POP" | "VAR_SAMP" | "ARRAY_AGG" | "STRING_AGG" | "JSON_AGG" | "JSONB_AGG"
257+
| "BOOL_AND" | "BOOL_OR" | "EVERY"
258+
)
259+
}
260+
261+
/// Tauri command: analyze whether a query's result rows are editable.
262+
///
263+
/// Returns the single base table (with optional schema) when the query is a
264+
/// plain single-table SELECT; otherwise a machine-readable non-editable reason
265+
/// the frontend can surface to the user.
266+
#[tauri::command]
267+
pub fn analyze_sql_editability_command(sql: String, database_type: Option<String>) -> SqlEditability {
268+
analyze_sql_editability(&sql, database_type.as_deref().unwrap_or("generic"))
269+
}
270+
271+
#[cfg(test)]
272+
mod tests {
273+
use super::*;
274+
275+
fn editable(sql: &str) -> bool {
276+
analyze_sql_editability(sql, "postgres").editable
277+
}
278+
279+
fn reason(sql: &str) -> Option<NonEditableReason> {
280+
analyze_sql_editability(sql, "postgres").reason
281+
}
282+
283+
#[test]
284+
fn plain_select_star_is_editable() {
285+
let result = analyze_sql_editability("SELECT * FROM apps", "postgres");
286+
assert!(result.editable);
287+
assert_eq!(result.table_name.as_deref(), Some("apps"));
288+
assert_eq!(result.schema, None);
289+
}
290+
291+
#[test]
292+
fn select_with_qualifier_returns_schema() {
293+
let result = analyze_sql_editability("SELECT * FROM public.apps", "postgres");
294+
assert!(result.editable);
295+
assert_eq!(result.table_name.as_deref(), Some("apps"));
296+
assert_eq!(result.schema.as_deref(), Some("public"));
297+
}
298+
299+
#[test]
300+
fn select_with_where_order_limit_is_editable() {
301+
assert!(editable("SELECT id, name FROM customers WHERE id > 10 ORDER BY name LIMIT 100"));
302+
}
303+
304+
#[test]
305+
fn quoted_table_name_is_editable() {
306+
let result = analyze_sql_editability("SELECT * FROM \"My Table\"", "postgres");
307+
assert!(result.editable);
308+
assert_eq!(result.table_name.as_deref(), Some("My Table"));
309+
}
310+
311+
#[test]
312+
fn join_is_not_editable() {
313+
assert!(!editable("SELECT a.*, b.* FROM a JOIN b ON a.id = b.id"));
314+
assert_eq!(reason("SELECT a.*, b.* FROM a JOIN b ON a.id = b.id"), Some(NonEditableReason::MultipleSources));
315+
}
316+
317+
#[test]
318+
fn comma_separated_sources_is_not_editable() {
319+
assert_eq!(reason("SELECT * FROM a, b"), Some(NonEditableReason::MultipleSources));
320+
}
321+
322+
#[test]
323+
fn count_aggregation_is_not_editable() {
324+
assert_eq!(reason("SELECT COUNT(*) FROM apps"), Some(NonEditableReason::Aggregation));
325+
}
326+
327+
#[test]
328+
fn group_by_is_not_editable() {
329+
assert_eq!(reason("SELECT name, COUNT(*) FROM customers GROUP BY name"), Some(NonEditableReason::Aggregation));
330+
}
331+
332+
#[test]
333+
fn distinct_is_not_editable() {
334+
assert_eq!(reason("SELECT DISTINCT name FROM customers"), Some(NonEditableReason::Aggregation));
335+
}
336+
337+
#[test]
338+
fn union_is_not_editable() {
339+
assert_eq!(reason("SELECT * FROM a UNION SELECT * FROM b"), Some(NonEditableReason::SetOperation));
340+
}
341+
342+
#[test]
343+
fn cte_is_not_editable() {
344+
assert_eq!(
345+
reason("WITH t AS (SELECT * FROM apps) SELECT * FROM t"),
346+
Some(NonEditableReason::Cte)
347+
);
348+
}
349+
350+
#[test]
351+
fn subquery_source_is_not_editable() {
352+
assert_eq!(
353+
reason("SELECT * FROM (SELECT * FROM apps) t"),
354+
Some(NonEditableReason::ComplexSource)
355+
);
356+
}
357+
358+
#[test]
359+
fn non_select_statement_is_not_editable() {
360+
assert_eq!(reason("UPDATE apps SET name = 'x'"), Some(NonEditableReason::NotSelect));
361+
assert_eq!(reason("DELETE FROM apps"), Some(NonEditableReason::NotSelect));
362+
assert_eq!(reason("INSERT INTO apps (name) VALUES ('x')"), Some(NonEditableReason::NotSelect));
363+
}
364+
365+
#[test]
366+
fn multiple_statements_is_not_editable() {
367+
assert_eq!(
368+
reason("SELECT * FROM apps; SELECT * FROM orders"),
369+
Some(NonEditableReason::NotSelect)
370+
);
371+
}
372+
373+
#[test]
374+
fn mysql_backtick_table_is_editable() {
375+
let result = analyze_sql_editability("SELECT * FROM `customers`", "mysql");
376+
assert!(result.editable);
377+
assert_eq!(result.table_name.as_deref(), Some("customers"));
378+
}
379+
380+
#[test]
381+
fn no_from_is_not_editable() {
382+
assert_eq!(reason("SELECT 1"), Some(NonEditableReason::NoTable));
383+
}
384+
}

0 commit comments

Comments
 (0)