From 22ed4e845b3ca80b3f2bdbb5fdf8af695a2d4950 Mon Sep 17 00:00:00 2001 From: blankll Date: Mon, 20 Jul 2026 23:38:51 +0800 Subject: [PATCH] feat: add backend SQL construction service and fix query result display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add sql_service.rs — centralized SQL wrapping with sqlparser validation, trailing semicolon/comment stripping, schema injection, and structured sort/filter/pagination construction. Replaces frontend buildWrappedSql. - Add execute_sorted_query Tauri command — structured sort/filter API that derives database type from the active connection. - Fix DataGrid virtual scroller — move column header outside scroll container so rows with 0-1 results aren't hidden behind sticky header. - Fix statement parser — replace line-based + regex approach with character-based state machine that correctly handles correlated subqueries spanning multiple lines. - Add hideBatchActions prop to DataGrid to suppress batch action bar in query result panel (no table context for delete/export). - Type-aware table search — UUID/JSON columns use ::text cast instead of fragile CAST(... AS TEXT), with exact-match optimization. --- src-tauri/Cargo.lock | 94 ++- src-tauri/Cargo.toml | 1 + src-tauri/src/commands/browse.rs | 2 +- src-tauri/src/commands/query.rs | 44 + src-tauri/src/database/mod.rs | 1 + src-tauri/src/database/search.rs | 56 +- src-tauri/src/database/sql_service.rs | 761 ++++++++++++++++++ src-tauri/src/lib.rs | 1 + .../database-browser/QueryResultPanel.vue | 74 +- src/components/grid/DataGrid.vue | 135 ++-- src/composables/useSqlStatements.ts | 263 ++++-- 11 files changed, 1214 insertions(+), 218 deletions(-) create mode 100644 src-tauri/src/database/sql_service.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ff583ccb..cde160eb 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -152,6 +152,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "ar_archive_writer" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348" +dependencies = [ + "object", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -1709,7 +1718,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2001,7 +2010,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2969,7 +2978,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.4", "system-configuration", "tokio", "tower-service", @@ -2989,7 +2998,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -4212,6 +4221,15 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -4938,6 +4956,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "ptr_meta" version = "0.1.4" @@ -4990,7 +5018,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.2", "rustls 0.23.40", - "socket2 0.5.10", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tracing", @@ -5027,9 +5055,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.4", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -5141,6 +5169,26 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "recursive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0786a43debb760f491b1bc0269fe5e84155353c67482b9e60d0cfb596054b43e" +dependencies = [ + "recursive-proc-macro-impl", + "stacker", +] + +[[package]] +name = "recursive-proc-macro-impl" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76009fbe0614077fc1a2ce255e3a1881a2e3a3527097d5dc6d8212c585e7e38b" +dependencies = [ + "quote", + "syn 2.0.118", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -5631,7 +5679,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5732,7 +5780,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6414,6 +6462,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "sqlparser", "tar", "tauri", "tauri-build", @@ -6437,6 +6486,16 @@ dependencies = [ "zip 2.4.2", ] +[[package]] +name = "sqlparser" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4521174166bac1ff04fe16ef4524c70144cd29682a45978978ca3d7f4e0be11" +dependencies = [ + "log", + "recursive", +] + [[package]] name = "ssh-cipher" version = "0.2.0" @@ -6472,6 +6531,19 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stacker" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -7111,7 +7183,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8200,7 +8272,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d3710a55..3494b4a9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -98,6 +98,7 @@ zip = { version = "2", features = [ "deflate" ] } # TOML parsing (drivers.toml) toml = "0.8" sha2 = "0.10" +sqlparser = "0.55" russh = "0.60" portpicker = "0.1.1" pageant = "0.2" diff --git a/src-tauri/src/commands/browse.rs b/src-tauri/src/commands/browse.rs index f0ac4adb..e441e39a 100644 --- a/src-tauri/src/commands/browse.rs +++ b/src-tauri/src/commands/browse.rs @@ -1030,7 +1030,7 @@ fn extract_count(result: QueryResult) -> Result { /// Map an [`ActiveConnection`] variant to the db_type string used by /// [`quote_identifier`] and [`search::build_table_search_where`]. -fn get_db_type_string(connection: &ActiveConnection) -> &'static str { +pub fn get_db_type_string(connection: &ActiveConnection) -> &'static str { match connection { ActiveConnection::Postgres(_) => "postgres", ActiveConnection::MySQL(_) => "mysql", diff --git a/src-tauri/src/commands/query.rs b/src-tauri/src/commands/query.rs index 50c63f68..6641afa9 100644 --- a/src-tauri/src/commands/query.rs +++ b/src-tauri/src/commands/query.rs @@ -6,6 +6,7 @@ use crate::api_response::{db_error_to_api_error, ApiResponse}; use crate::connection::guardian::HealthState; use crate::connection::handle::ConnectionHandle; +use crate::database::sql_service::{self, WrapQueryOptions}; use crate::database::{ ClickHouseAdapter, ConnectionConfig, DatabaseAdapter, DbError, ExplainResult, HttpSqlAdapter, JdbcBridgeAdapter, MySQLAdapter, PostgresAdapter, QueryResult, RqliteAdapter, SqlServerAdapter, @@ -318,6 +319,49 @@ pub async fn execute_query( } } +/// Execute a query with structured sort, filter, and pagination. +/// +/// Unlike `execute_query` which takes raw SQL, this command takes the user's +/// original SQL plus structured sort/filter/pagination options and builds the +/// final SQL on the backend. This prevents SQL injection from filter values +/// and handles trailing semicolons/comments correctly. +#[tauri::command] +pub async fn execute_sorted_query( + connection_id: String, + database: Option, + options: WrapQueryOptions, + state: State<'_, AppState>, +) -> Result, String> { + // Derive database type from the active connection + let db_type = { + let connections = state.connections.read().await; + let connection = connections + .get(&connection_id) + .ok_or_else(|| "No active connection found".to_string())?; + crate::commands::browse::get_db_type_string(connection).to_string() + }; + + // Inject the database type into options and build the wrapped SQL + let build = sql_service::wrap_query(WrapQueryOptions { + database_type: db_type, + ..options + }); + if !build.ok { + return Ok(ApiResponse::error_from( + "SORT_FILTER_ERROR", + format!( + "Cannot sort/filter: {}", + build.reason.unwrap_or_else(|| "unknown error".to_string()) + ), + )); + } + + let sql = build.sql.unwrap(); + + // Execute via the existing execute_query path + crate::commands::execute_query(connection_id, sql, database, state).await +} + /// Cancel a running query. /// /// Note: This is a placeholder for future implementation. Query cancellation diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 715b1d34..6c91fa15 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -72,6 +72,7 @@ pub mod pool; pub mod postgres; pub mod rqlite; pub mod search; +pub mod sql_service; pub mod sqlite; pub mod sqlserver; pub mod strategy; diff --git a/src-tauri/src/database/search.rs b/src-tauri/src/database/search.rs index db230764..a1e3ea5e 100644 --- a/src-tauri/src/database/search.rs +++ b/src-tauri/src/database/search.rs @@ -124,11 +124,12 @@ fn quote_identifier(identifier: &str, db_type: &str) -> String { /// /// # Search Behavior /// -/// - **Text columns** (`ColumnCategory::Text`): matched with -/// `LOWER(CAST(col AS type)) LIKE '%term%'` -/// - **Numeric columns** (`ColumnCategory::Numeric`): matched with -/// `(col = term OR LOWER(CAST(col AS type)) LIKE '%term%')` — this handles -/// both exact numeric matches and text representation matches +/// - **UUID columns**: matched with `col::text` cast (PostgreSQL-compatible) for +/// both exact matches and LIKE — avoids the fragile `CAST(col AS TEXT)` pattern +/// which fails on some PG-compatible engines +/// - **JSON/JSONB columns**: matched with `col::text` LIKE (avoids CAST) +/// - **Other text columns**: matched with `LOWER(CAST(col AS type)) LIKE '%term%'` +/// - **Numeric columns**: matched with both exact equality and text LIKE /// - **Skip columns** (`ColumnCategory::Skip`): excluded entirely /// - The search is **case-insensitive** via `LOWER()` /// @@ -170,14 +171,43 @@ pub fn build_table_search_where( .map(|col| { let quoted = quote_identifier(&col.name, db_type); let category = classify_column(&col.data_type); - let text_condition = format!( - "LOWER(CAST({} AS {})) LIKE '%{}%'", - quoted, cast_type, lower_term - ); + let data_type = col.data_type.to_lowercase(); + // Type-aware search conditions match category { - ColumnCategory::Text => text_condition, + ColumnCategory::Text => { + if data_type == "uuid" { + // UUID columns: use ::text cast (more portable than CAST) + // For exact UUID matches, also include equality for efficiency + let text_cond = format!( + "LOWER({}::text) LIKE '%{}%'", + quoted, lower_term + ); + // If the term looks like a UUID, try exact match first + if looks_like_uuid(&escaped_term) { + format!("{}::text = '{}' OR {}", quoted, escaped_term, text_cond) + } else { + text_cond + } + } else if data_type.starts_with("json") { + // JSON/JSONB: use ::text for readability + format!( + "LOWER({}::text) LIKE '%{}%'", + quoted, lower_term + ) + } else { + // Standard text columns + format!( + "LOWER(CAST({} AS {})) LIKE '%{}%'", + quoted, cast_type, lower_term + ) + } + } ColumnCategory::Numeric => { + let text_condition = format!( + "LOWER(CAST({} AS {})) LIKE '%{}%'", + quoted, cast_type, lower_term + ); // Try exact numeric match; if term is a valid number, include equality if term.parse::().is_ok() { format!("{} = {} OR {}", quoted, term, text_condition) @@ -198,6 +228,12 @@ pub fn build_table_search_where( Some(format!("({})", conditions.join(" OR "))) } +/// Quick check if a string looks like a UUID (hex with dashes). +fn looks_like_uuid(s: &str) -> bool { + let cleaned: String = s.chars().filter(|c| *c != '-').collect(); + cleaned.len() == 32 && cleaned.chars().all(|c| c.is_ascii_hexdigit()) +} + /// Generate a stable WHERE clause that uniquely identifies a row. /// /// Uses primary key columns if available; otherwise falls back to matching diff --git a/src-tauri/src/database/sql_service.rs b/src-tauri/src/database/sql_service.rs new file mode 100644 index 00000000..65b4b5e6 --- /dev/null +++ b/src-tauri/src/database/sql_service.rs @@ -0,0 +1,761 @@ +//! SQL construction service — the single source of truth for building queries. +//! +//! All SQL wrapping, validation, filtering, sorting, pagination, and schema +//! injection goes through this module. The frontend never constructs SQL strings. +//! +//! # Design +//! +//! Every function returns a `SqlBuildResult` with structured success/failure +//! so the caller can surface clear error messages instead of cryptic database errors. + +use serde::{Deserialize, Serialize}; +use sqlparser::ast::{ + Expr, Ident, ObjectNamePart, Query, SelectItem, SelectItemQualifiedWildcardKind, SetExpr, + Statement, TableFactor, TableWithJoins, Value, +}; +use sqlparser::dialect::{GenericDialect, MsSqlDialect, MySqlDialect, PostgreSqlDialect}; +use sqlparser::parser::Parser; + +// ── Public types ────────────────────────────────────────────────────────── + +/// Outcome of a SQL build operation. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SqlBuildResult { + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub sql: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +impl SqlBuildResult { + fn ok(sql: String) -> Self { + Self { ok: true, sql: Some(sql), reason: None } + } + + fn err(reason: impl Into) -> Self { + Self { ok: false, sql: None, reason: Some(reason.into()) } + } +} + +/// A single sort rule (column + direction). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SortRule { + pub column: String, + pub direction: SortDirection, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum SortDirection { + ASC, + DESC, +} + +/// A single filter rule. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FilterRule { + pub column: String, + pub operator: FilterOperator, + pub value: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub value2: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum FilterOperator { + Eq, + Neq, + Like, + Gt, + Lt, + Gte, + Lte, + Between, +} + +/// Options for wrapping a user's SQL with sort/filter/pagination. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WrapQueryOptions { + /// The raw SQL from the editor. + pub original_sql: String, + /// Database type string ("postgres", "mysql", "sqlserver", "sqlite", etc.) + /// Automatically derived from the active connection when sent from the frontend. + #[serde(default)] + pub database_type: String, + /// Optional schema context for qualifying unqualified table references. + #[serde(skip_serializing_if = "Option::is_none")] + pub schema_context: Option, + /// Sort rules. + #[serde(default)] + pub sort: Vec, + /// Filter rules. + #[serde(default)] + pub filters: Vec, + /// Optional limit (None = no limit change). + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + /// Optional offset (None = no offset change). + #[serde(skip_serializing_if = "Option::is_none")] + pub offset: Option, +} + +// ── Public API ───────────────────────────────────────────────────────────── + +/// Build a wrapped SQL query with sort, filter, and pagination applied. +/// +/// This is the main entry point. It: +/// 1. Strips trailing semicolons and comments from the original SQL +/// 2. Parses it with sqlparser to validate it's a single SELECT/WITH statement +/// 3. Injects schema qualification if a schema_context is provided +/// 4. Wraps it in a derived table if sort/filter/pagination are needed +/// 5. Applies WHERE, ORDER BY, and LIMIT/OFFSET clauses +pub fn wrap_query(options: WrapQueryOptions) -> SqlBuildResult { + // ── Step 1: Clean the SQL ── + let cleaned = strip_trailing(&options.original_sql); + if cleaned.is_empty() { + return SqlBuildResult::err("empty query"); + } + + // ── Step 2: Parse & extract a single statement ── + let dialect: Box = match options.database_type.as_str() { + "postgres" | "duckdb" | "cockroachdb" => Box::new(PostgreSqlDialect {}), + "mysql" | "clickhouse" => Box::new(MySqlDialect {}), + "sqlserver" => Box::new(MsSqlDialect {}), + _ => Box::new(GenericDialect {}), + }; + + let stmts = match Parser::parse_sql(&*dialect, &cleaned) { + Ok(s) => s, + Err(e) => return SqlBuildResult::err(format!("parse error: {}", e)), + }; + + if stmts.is_empty() { + return SqlBuildResult::err("empty query after parsing"); + } + if stmts.len() > 1 { + return SqlBuildResult::err("multiple statements are not supported for sort/filter"); + } + + let mut stmt = stmts.into_iter().next().unwrap(); + + // ── Step 3: Validate ── + if !is_select_statement(&stmt) { + return SqlBuildResult::err( + "only SELECT queries can be sorted or filtered", + ); + } + + // ── Step 4: Inject schema qualification ── + if let Some(ref schema) = options.schema_context { + if !schema.is_empty() { + inject_schema(&mut stmt, schema, &options.database_type); + } + } + + // ── Step 5: Determine if we need wrapping ── + let needs_wrapping = !options.sort.is_empty() + || !options.filters.is_empty() + || options.limit.is_some() + || options.offset.is_some(); + + if !needs_wrapping { + // No sort/filter/pagination — return the (potentially schema-qualified) SQL as-is + return SqlBuildResult::ok(stmt_to_string(&stmt, &options.database_type)); + } + + // ── Step 6: Wrap in derived table ── + let inner_sql = stmt_to_string(&stmt, &options.database_type); + let alias = "_sqlkit_grid"; + let quoted_alias = quote_ident(alias, &options.database_type); + let base = format!("SELECT * FROM ({}) AS {}", inner_sql, quoted_alias); + + // ── Step 7: Apply filters ── + let base = if !options.filters.is_empty() { + let where_clause = build_filter_where(&options.filters, &options.database_type); + format!("{} WHERE {}", base, where_clause) + } else { + base + }; + + // ── Step 8: Extract SELECT columns for alias resolution ── + let select_columns = extract_select_columns(&stmt); + let has_expression_columns = + select_columns.iter().any(|(name, _)| name.contains(' ') || name.contains('(')); + + // ── Step 9: Apply sorting ── + let base = if !options.sort.is_empty() { + let order_by = build_order_by( + &options.sort, + &options.database_type, + &select_columns, + has_expression_columns, + ); + format!("{} ORDER BY {}", base, order_by) + } else { + base + }; + + // ── Step 10: Apply pagination ── + let base = build_pagination(&base, options.limit, options.offset, &options.database_type); + + SqlBuildResult::ok(base) +} + +/// Strip trailing semicolons and SQL comments. +pub fn strip_trailing(sql: &str) -> String { + let s = sql.trim(); + // Strip single-line comments (-- ...) that trail after the main statement + let s = strip_trailing_line_comments(s); + // Strip trailing semicolons + s.trim_end().trim_end_matches(';').trim_end().to_string() +} + +/// Remove trailing `-- line comments` and `/* block comments */` +fn strip_trailing_line_comments(sql: &str) -> &str { + let sql = sql.trim_end(); + // Handle multi-line: find last `--` or `/*` that starts a trailing comment + // We only strip comments that appear after the last meaningful token. + // Simple approach: find `--` and `/*` at the end + if let Some(pos) = sql.rfind("--") { + // Make sure the `--` is not inside a string literal (rough check) + let before = &sql[..pos]; + if !before.contains('\'') || count_unmatched_quotes(before) % 2 == 0 { + return before.trim_end(); + } + } + if let Some(pos) = sql.rfind("/*") { + let before = &sql[..pos]; + if !before.contains('\'') || count_unmatched_quotes(before) % 2 == 0 { + if let Some(end) = sql[pos..].find("*/") { + let end_pos = pos + end + 2; + if end_pos >= sql.len() { + return before.trim_end(); + } + } + } + } + sql +} + +fn count_unmatched_quotes(s: &str) -> usize { + let mut count = 0; + let mut in_string = false; + let mut prev_escape = false; + for ch in s.chars() { + if prev_escape { + prev_escape = false; + continue; + } + if ch == '\\' { + prev_escape = true; + continue; + } + if ch == '\'' { + in_string = !in_string; + if !in_string { + count += 2; // matched pair + } + } + } + if in_string { + count + 1 + } else { + count + } + // Return odd if there's an unmatched quote +} + +// ── SQL building helpers ── + +fn build_filter_where(filters: &[FilterRule], db_type: &str) -> String { + let clauses: Vec = filters + .iter() + .map(|f| { + let col = quote_ident(&f.column, db_type); + let esc = |v: &str| v.replace('\'', "''"); + match f.operator { + FilterOperator::Eq => format!("{} = '{}'", col, esc(&f.value)), + FilterOperator::Neq => format!("{} != '{}'", col, esc(&f.value)), + FilterOperator::Like => { + format!("{} LIKE '%{}%'", col, esc(&f.value)) + } + FilterOperator::Gt => format!("{} > '{}'", col, esc(&f.value)), + FilterOperator::Lt => format!("{} < '{}'", col, esc(&f.value)), + FilterOperator::Gte => format!("{} >= '{}'", col, esc(&f.value)), + FilterOperator::Lte => format!("{} <= '{}'", col, esc(&f.value)), + FilterOperator::Between => { + let v2 = f.value2.as_deref().unwrap_or(""); + format!( + "{} >= '{}' AND {} <= '{}'", + col, + esc(&f.value), + col, + esc(v2) + ) + } + } + }) + .collect(); + + clauses.join(" AND ") +} + +fn build_order_by( + sort: &[SortRule], + db_type: &str, + select_columns: &[(String, usize)], + has_expression_columns: bool, +) -> String { + sort.iter() + .map(|s| { + let dir = match s.direction { + SortDirection::ASC => "ASC", + SortDirection::DESC => "DESC", + }; + // If the column is an expression in the SELECT list (not a bare column), + // use its positional alias + let col_ref = if has_expression_columns { + if let Some((_, idx)) = select_columns.iter().find(|(name, _)| name == &s.column) { + // Column index is 1-based in SQL + format!("{}", idx + 1) + } else { + quote_ident(&s.column, db_type) + } + } else { + quote_ident(&s.column, db_type) + }; + format!("{} {}", col_ref, dir) + }) + .collect::>() + .join(", ") +} + +fn build_pagination(sql: &str, limit: Option, offset: Option, db_type: &str) -> String { + let limit = limit.unwrap_or(0); + let offset = offset.unwrap_or(0); + + if limit == 0 && offset == 0 { + return sql.to_string(); + } + + match db_type { + "sqlserver" => { + if limit > 0 { + format!( + "{} OFFSET {} ROWS FETCH NEXT {} ROWS ONLY", + sql, offset, limit + ) + } else { + format!("{} OFFSET {} ROWS", sql, offset) + } + } + _ => { + // PostgreSQL, MySQL, SQLite, etc. + if limit > 0 { + format!("{} LIMIT {} OFFSET {}", sql, limit, offset) + } else { + format!("{} LIMIT -1 OFFSET {}", sql, offset) + } + } + } +} + +/// Quote a SQL identifier per database dialect. +pub fn quote_ident(ident: &str, db_type: &str) -> String { + match db_type { + "postgres" | "sqlite" | "duckdb" | "jdbc" | "cockroachdb" => { + format!("\"{}\"", ident.replace('\"', "\"\"")) + } + "mysql" | "clickhouse" => format!("`{}`", ident.replace('`', "``")), + "sqlserver" => format!("[{}]", ident.replace(']', "]]")), + _ => ident.to_string(), + } +} + +// ── Schema injection ── + +/// Inject schema qualification into all unqualified table references. +fn inject_schema(stmt: &mut Statement, schema: &str, db_type: &str) { + match stmt { + Statement::Query(query) => { + inject_schema_in_query(query, schema, db_type); + } + _ => {} + } +} + +fn inject_schema_in_query(query: &mut Query, schema: &str, db_type: &str) { + inject_schema_in_query_body(query.body.as_mut(), schema, db_type); + // Also handle CTEs recursively + if let Some(ref mut with) = query.with { + for cte in with.cte_tables.iter_mut() { + inject_schema_in_query(cte.query.as_mut(), schema, db_type); + } + } +} + +fn inject_schema_in_query_body(body: &mut SetExpr, schema: &str, db_type: &str) { + match body { + SetExpr::Select(select) => { + inject_schema_in_tables(&mut select.from, schema, db_type); + } + SetExpr::SetOperation { left, right, .. } => { + inject_schema_in_query_body(left.as_mut(), schema, db_type); + inject_schema_in_query_body(right.as_mut(), schema, db_type); + } + _ => {} + } +} + +fn inject_schema_in_tables(tables: &mut Vec, schema: &str, db_type: &str) { + for table in tables.iter_mut() { + inject_schema_in_table_factor(&mut table.relation, schema, db_type); + for join in table.joins.iter_mut() { + inject_schema_in_table_factor(&mut join.relation, schema, db_type); + } + } +} + +fn inject_schema_in_table_factor(factor: &mut TableFactor, schema: &str, db_type: &str) { + match factor { + TableFactor::Table { ref mut name, .. } => { + // Only qualify if no schema is already present + if name.0.len() == 1 { + name.0 + .insert(0, ObjectNamePart::Identifier(Ident::new(schema))); + } + } + TableFactor::Derived { .. } | TableFactor::TableFunction { .. } => { + // Skip subqueries and table functions — they have their own scope + } + TableFactor::NestedJoin { table_with_joins, .. } => { + inject_schema_in_table_factor(&mut table_with_joins.relation, schema, db_type); + for join in table_with_joins.joins.iter_mut() { + inject_schema_in_table_factor(&mut join.relation, schema, db_type); + } + } + _ => {} + } +} + +// ── AST helpers ── + +fn is_select_statement(stmt: &Statement) -> bool { + match stmt { + Statement::Query(query) => { + // SELECT, WITH, set operations (UNION, INTERSECT, EXCEPT) containing SELECT + match query.body.as_ref() { + SetExpr::Select(_) | SetExpr::SetOperation { .. } => true, + _ => false, + } + } + _ => false, + } +} + +/// Extract (column_name_or_alias, position) from SELECT list. +fn extract_select_columns(stmt: &Statement) -> Vec<(String, usize)> { + match stmt { + Statement::Query(query) => match query.body.as_ref() { + SetExpr::Select(select) => extract_select_items(&select.projection), + _ => Vec::new(), + }, + _ => Vec::new(), + } +} + +fn extract_select_items(items: &[SelectItem]) -> Vec<(String, usize)> { + items + .iter() + .enumerate() + .map(|(i, item)| { + let name = match item { + SelectItem::UnnamedExpr(expr) => expr_to_name(expr), + SelectItem::ExprWithAlias { alias, .. } => alias.value.clone(), + SelectItem::QualifiedWildcard(kind, _) => match kind { + SelectItemQualifiedWildcardKind::ObjectName(obj) => obj.to_string() + ".*", + SelectItemQualifiedWildcardKind::Expr(e) => format!("({}).*", expr_to_name(e)), + }, + SelectItem::Wildcard(_) => "*".to_string(), + }; + (name, i) + }) + .collect() +} + +fn expr_to_name(expr: &Expr) -> String { + match expr { + Expr::Identifier(ident) => ident.value.clone(), + Expr::CompoundIdentifier(idents) => { + idents.iter().map(|i| i.value.clone()).collect::>().join(".") + } + Expr::Function(f) => f.name.to_string(), + Expr::BinaryOp { left, op, right } => { + format!("{} {} {}", expr_to_name(left), op, expr_to_name(right)) + } + Expr::Cast { expr, data_type, .. } => { + format!("CAST({} AS {})", expr_to_name(expr), data_type) + } + Expr::Value(vws) => match &vws.value { + Value::Number(n, _) => n.to_string(), + Value::SingleQuotedString(s) => format!("'{}'", s), + other => format!("{:?}", other), + }, + _ => format!("{:?}", expr), + } +} + +/// Convert a Statement back to SQL string. +fn stmt_to_string(stmt: &Statement, _db_type: &str) -> String { + // Use the Display implementation which is dialect-aware via the parser dialect + stmt.to_string() +} + +// ── Tests ── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_strip_trailing_semicolon() { + assert_eq!(strip_trailing("SELECT 1;"), "SELECT 1"); + assert_eq!(strip_trailing("SELECT 1;;;"), "SELECT 1"); + assert_eq!(strip_trailing(" SELECT 1; "), "SELECT 1"); + assert_eq!(strip_trailing("SELECT 1"), "SELECT 1"); + assert_eq!(strip_trailing(""), ""); + } + + #[test] + fn test_strip_trailing_comment() { + assert_eq!(strip_trailing("SELECT 1 -- comment"), "SELECT 1"); + assert_eq!(strip_trailing("SELECT 1;-- comment"), "SELECT 1"); + assert_eq!( + strip_trailing("SELECT 1 /* block comment */"), + "SELECT 1" + ); + } + + #[test] + fn test_strip_trailing_comment_and_semicolons() { + assert_eq!( + strip_trailing("SELECT * FROM users ;-- WHERE id = '123'"), + "SELECT * FROM users" + ); + assert_eq!( + strip_trailing("SELECT id, name FROM users; -- filter"), + "SELECT id, name FROM users" + ); + } + + #[test] + fn test_wrap_query_no_sort_filter() { + let result = wrap_query(WrapQueryOptions { + original_sql: "SELECT * FROM users".to_string(), + database_type: "postgres".to_string(), + schema_context: None, + sort: vec![], + filters: vec![], + limit: None, + offset: None, + }); + assert!(result.ok); + assert_eq!(result.sql.unwrap(), "SELECT * FROM users"); + } + + #[test] + fn test_wrap_query_trailing_semicolon_comment() { + let result = wrap_query(WrapQueryOptions { + original_sql: "SELECT * FROM users ;-- WHERE id = 'abc'".to_string(), + database_type: "postgres".to_string(), + schema_context: None, + sort: vec![], + filters: vec![], + limit: None, + offset: None, + }); + assert!(result.ok, "should handle trailing semicolons: {:?}", result.reason); + } + + #[test] + fn test_wrap_query_with_filter() { + let result = wrap_query(WrapQueryOptions { + original_sql: "SELECT * FROM users".to_string(), + database_type: "postgres".to_string(), + schema_context: None, + sort: vec![], + filters: vec![FilterRule { + column: "id".to_string(), + operator: FilterOperator::Eq, + value: "42".to_string(), + value2: None, + }], + limit: None, + offset: None, + }); + assert!(result.ok, "filter should succeed: {:?}", result.reason); + let sql = result.sql.unwrap(); + assert!(sql.contains("WHERE")); + assert!(sql.contains("\"id\" = '42'")); + } + + #[test] + fn test_wrap_query_with_sort() { + let result = wrap_query(WrapQueryOptions { + original_sql: "SELECT id, name FROM users".to_string(), + database_type: "postgres".to_string(), + schema_context: None, + sort: vec![SortRule { + column: "name".to_string(), + direction: SortDirection::ASC, + }], + filters: vec![], + limit: None, + offset: None, + }); + assert!(result.ok, "sort should succeed: {:?}", result.reason); + let sql = result.sql.unwrap(); + assert!(sql.contains("ORDER BY")); + assert!(sql.contains("\"name\" ASC")); + } + + #[test] + fn test_wrap_query_rejects_multi_statement() { + let result = wrap_query(WrapQueryOptions { + original_sql: "SELECT 1; DROP TABLE users".to_string(), + database_type: "postgres".to_string(), + schema_context: None, + sort: vec![], + filters: vec![], + limit: None, + offset: None, + }); + assert!(!result.ok, "multi-statement should be rejected"); + assert!(result.reason.unwrap().contains("multiple")); + } + + #[test] + fn test_wrap_query_rejects_dml() { + let result = wrap_query(WrapQueryOptions { + original_sql: "DELETE FROM users".to_string(), + database_type: "postgres".to_string(), + schema_context: None, + sort: vec![], + filters: vec![], + limit: None, + offset: None, + }); + assert!(!result.ok, "DML should be rejected"); + assert!(result.reason.unwrap().contains("only SELECT")); + } + + #[test] + fn test_wrap_query_with_pagination() { + let result = wrap_query(WrapQueryOptions { + original_sql: "SELECT * FROM users".to_string(), + database_type: "postgres".to_string(), + schema_context: None, + sort: vec![], + filters: vec![], + limit: Some(50), + offset: Some(10), + }); + assert!(result.ok, "pagination should succeed: {:?}", result.reason); + let sql = result.sql.unwrap(); + assert!(sql.contains("LIMIT 50")); + assert!(sql.contains("OFFSET 10")); + } + + #[test] + fn test_schema_injection() { + let result = wrap_query(WrapQueryOptions { + original_sql: "SELECT * FROM users".to_string(), + database_type: "postgres".to_string(), + schema_context: Some("public".to_string()), + sort: vec![], + filters: vec![], + limit: None, + offset: None, + }); + assert!(result.ok, "schema injection should succeed: {:?}", result.reason); + let sql = result.sql.unwrap(); + // The table should now be qualified as "public"."users" + assert!(sql.contains("public") && sql.contains("users")); + } + + #[test] + fn test_wrap_query_all_together() { + let result = wrap_query(WrapQueryOptions { + original_sql: "SELECT id, name, email FROM users".to_string(), + database_type: "postgres".to_string(), + schema_context: Some("public".to_string()), + sort: vec![ + SortRule { column: "name".to_string(), direction: SortDirection::ASC }, + ], + filters: vec![ + FilterRule { + column: "email".to_string(), + operator: FilterOperator::Like, + value: "test".to_string(), + value2: None, + }, + ], + limit: Some(100), + offset: Some(0), + }); + assert!(result.ok, "all combined: {:?}", result.reason); + let sql = result.sql.unwrap(); + // Schema is injected as unqualified table ref → "public.users" or "public"."users" + assert!( + sql.contains("public.") || sql.contains("\"public\""), + "schema not found in: {}", + sql + ); + assert!(sql.contains("WHERE"), "where: {}", sql); + assert!(sql.contains("ORDER BY"), "order: {}", sql); + assert!(sql.contains("LIMIT 100"), "limit: {}", sql); + } + + #[test] + fn test_mysql_dialect() { + let result = wrap_query(WrapQueryOptions { + original_sql: "SELECT * FROM users ORDER BY name".to_string(), + database_type: "mysql".to_string(), + schema_context: None, + sort: vec![SortRule { + column: "name".to_string(), + direction: SortDirection::DESC, + }], + filters: vec![], + limit: Some(10), + offset: None, + }); + assert!(result.ok, "mysql: {:?}", result.reason); + let sql = result.sql.unwrap(); + // MySQL uses backtick quoting + assert!(sql.contains('`'), "mysql should use backticks: {}", sql); + } + + #[test] + fn test_sqlserver_dialect() { + let result = wrap_query(WrapQueryOptions { + original_sql: "SELECT * FROM users".to_string(), + database_type: "sqlserver".to_string(), + schema_context: None, + sort: vec![SortRule { + column: "name".to_string(), + direction: SortDirection::ASC, + }], + filters: vec![], + limit: Some(10), + offset: Some(0), + }); + assert!(result.ok, "sqlserver: {:?}", result.reason); + let sql = result.sql.unwrap(); + assert!(sql.contains("OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bf788994..68e0aeb7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -243,6 +243,7 @@ pub fn run() { commands::get_connection_quality, // Query execution commands commands::execute_query, + commands::execute_sorted_query, commands::cancel_query, commands::explain_query, // Database browsing commands diff --git a/src/components/database-browser/QueryResultPanel.vue b/src/components/database-browser/QueryResultPanel.vue index decc1d39..9db5fb6a 100644 --- a/src/components/database-browser/QueryResultPanel.vue +++ b/src/components/database-browser/QueryResultPanel.vue @@ -1,6 +1,6 @@