Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 83 additions & 11 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/commands/browse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1030,7 +1030,7 @@ fn extract_count(result: QueryResult) -> Result<u64, String> {

/// 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",
Expand Down
44 changes: 44 additions & 0 deletions src-tauri/src/commands/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<String>,
options: WrapQueryOptions,
state: State<'_, AppState>,
) -> Result<ApiResponse<QueryResult>, 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
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/database/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
56 changes: 46 additions & 10 deletions src-tauri/src/database/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()`
///
Expand Down Expand Up @@ -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::<f64>().is_ok() {
format!("{} = {} OR {}", quoted, term, text_condition)
Expand All @@ -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
Expand Down
Loading
Loading