Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src-tauri/src/ai_activity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,7 @@ fn has_complete_end_before_terminal_statement(upper_sql: &str) -> bool {
/// string-literal, comment, and quoted-identifier bytes with whitespace, so
/// any `;` that survives here is a real statement terminator under the
/// escaping interpretation used to produce `stripped`.
fn has_trailing_statements(stripped: &str) -> bool {
pub(crate) fn has_trailing_statements(stripped: &str) -> bool {
let mut found_semi = false;
for c in stripped.chars() {
if found_semi && !c.is_whitespace() {
Expand Down
59 changes: 59 additions & 0 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3548,6 +3548,40 @@ pub async fn cancel_query(
cancel_query_impl(&state, &connection_id)
}

/// Payload for the `database-dropped` event, emitted after a `DROP DATABASE`
/// statement succeeds so a listener can reconcile the connection's database
/// selection instead of leaving the dropped database in the sidebar until the
/// next reconnect (#525).
// `connectionId` rather than `connection_id`: the `connection-health-failed`
// event already carries this same field in camelCase, and matching the field
// name for the same concept matters more than matching the neighbouring
// `batch-statement-complete` payload, which happens to use snake_case.
#[derive(serde::Serialize, Clone)]
#[serde(rename_all = "camelCase")]
struct DatabaseDroppedEvent<'a> {
connection_id: &'a str,
database: &'a str,
}

/// Announces that `database` no longer exists on the server behind
/// `connection_id`. Emitting is best-effort: a listener that missed the event
/// only means the sidebar stays stale until the next manual refresh, which is
/// the pre-#525 behaviour, so a failed emit must not fail the query.
fn emit_database_dropped<R: Runtime>(app: &AppHandle<R>, connection_id: &str, database: &str) {
log::info!(
"DROP DATABASE detected on connection {}: '{}'",
connection_id,
database
);
let _ = app.emit(
"database-dropped",
DatabaseDroppedEvent {
connection_id,
database,
},
);
}

#[tauri::command]
pub async fn execute_query<R: Runtime>(
app: AppHandle<R>,
Expand All @@ -3571,6 +3605,10 @@ pub async fn execute_query<R: Runtime>(
let expanded_params = expand_k8s_connection_params(&app, &expanded_params).await?;
let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?;

// Detected before the spawn, which takes ownership of `sanitized_query`.
// Cheap: only allocates when the statement really is a DROP DATABASE.
let dropped = crate::sql_database_statements::dropped_database(&sanitized_query);

let drv = driver_for(&saved_conn.params.driver).await?;
let task = tokio::spawn(async move {
drv.execute_query(
Expand All @@ -3596,6 +3634,9 @@ pub async fn execute_query<R: Runtime>(
"Query executed successfully, returned {} rows",
query_result.rows.len()
);
if let Some(database) = &dropped {
emit_database_dropped(&app, &connection_id, database);
}
Ok(query_result)
}
Ok(Err(e)) => {
Expand Down Expand Up @@ -3653,6 +3694,14 @@ pub async fn execute_query_batch<R: Runtime>(

let sanitized_queries: Vec<String> = queries.iter().map(|q| sanitize_user_query(q)).collect();

// One entry per statement, computed before the spawn takes ownership of
// `sanitized_queries`. Index-aligned with the results returned below, the
// same assumption the `batch-statement-complete` event already relies on.
let dropped_per_statement: Vec<Option<String>> = sanitized_queries
.iter()
.map(|q| crate::sql_database_statements::dropped_database(q))
.collect();

let saved_conn = find_connection_by_id(&app, &connection_id)?;
let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?;
let expanded_params = expand_k8s_connection_params(&app, &expanded_params).await?;
Expand Down Expand Up @@ -3707,6 +3756,16 @@ pub async fn execute_query_batch<R: Runtime>(
batch_results.len() - success_count,
batch_results.len()
);
// A batch reports per-statement outcomes, so only announce drops
// whose own statement succeeded; a failed DROP leaves the database
// in place.
for (dropped, statement) in dropped_per_statement.iter().zip(&batch_results) {
if let Some(database) = dropped {
if statement.result.is_some() {
emit_database_dropped(&app, &connection_id, database);
}
}
}
Ok(batch_results)
}
Ok(Err(e)) => {
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ pub mod preferences;
pub mod query_history;
#[cfg(test)]
pub mod query_history_tests;
pub mod sql_database_statements;
pub mod saved_queries;
#[cfg(test)]
pub mod saved_queries_tests;
Expand Down
116 changes: 116 additions & 0 deletions src-tauri/src/sql_database_statements.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
//! Recognises the few SQL statements that change which databases exist, so the
//! app can keep the sidebar in sync with the server instead of waiting for a
//! manual refresh (#525, following #518 and #524).
//!
//! Everything here is deliberately narrow and fails closed: a statement that
//! isn't unambiguously recognised returns `None` rather than a guess. A missed
//! detection just leaves the sidebar stale until the next refresh, which is
//! today's behaviour anyway; a wrong detection would tell the user a database
//! is gone when it isn't, which is worse than doing nothing.

use crate::ai_activity::{has_trailing_statements, strip_strings_and_comments};

#[cfg(test)]
mod tests;

/// The database targeted by a `DROP DATABASE` / `DROP SCHEMA` statement.
///
/// Returns `None` unless the input is exactly one such statement. In
/// particular, a multi-statement payload returns `None`: which statement
/// actually succeeded is the caller's problem to disambiguate, not this
/// function's to guess.
///
/// The statement must also start with `DROP`, so a leading comment (`-- note`
/// before the statement) is not recognised. Callers pass the statement they
/// just executed, which in practice starts at the keyword; and a miss here only
/// costs a stale sidebar until the next refresh.
pub fn dropped_database(sql: &str) -> Option<String> {
// Only used to reject multi-statement payloads and to ignore text inside
// comments and string literals. The walk below reads the original string,
// because stripping also blanks out quoted identifiers -- which is exactly
// where the database name lives.
if has_trailing_statements(&strip_strings_and_comments(sql)) {
return None;
}

let rest = after_keyword(sql, "DROP")?;
// MySQL and Postgres both accept SCHEMA as a synonym of DATABASE here.
let rest = after_keyword(rest, "DATABASE").or_else(|| after_keyword(rest, "SCHEMA"))?;
let rest = match after_keyword(rest, "IF") {
Some(after_if) => after_keyword(after_if, "EXISTS")?,
None => rest,
};

identifier(rest)
}

/// The text following an ASCII SQL keyword at the start of `s` (after leading
/// whitespace), matched case-insensitively and left-trimmed.
///
/// Requires a word boundary after the keyword -- whitespace, an identifier
/// quote, or end of input -- so `DATABASEX` never matches `DATABASE`.
fn after_keyword<'a>(s: &'a str, keyword: &str) -> Option<&'a str> {
let s = s.trim_start();
// Byte slicing is safe here: the keyword is pure ASCII, so if the leading
// bytes match it they are all single-byte characters and `keyword.len()`
// lands on a character boundary.
if s.len() < keyword.len()
|| !s.as_bytes()[..keyword.len()].eq_ignore_ascii_case(keyword.as_bytes())
{
return None;
}
let tail = &s[keyword.len()..];
match tail.chars().next() {
None => Some(tail),
Some(c) if c.is_whitespace() || is_quote(c) => Some(tail.trim_start()),
Some(_) => None,
}
}

/// Whether `c` opens a quoted identifier in any dialect the app supports.
fn is_quote(c: char) -> bool {
matches!(c, '`' | '"' | '[')
}

/// The single identifier `s` consists of, unquoted.
///
/// Accepts backticks (MySQL/MariaDB), double quotes (Postgres, SQL standard)
/// and brackets (SQL Server), or a bare name. Anything other than whitespace
/// and an optional `;` after the identifier returns `None`, so
/// `DROP DATABASE foo bar` yields nothing rather than silently taking `foo`.
///
/// A doubled quote inside a quoted identifier (a literal backtick in the name)
/// is not handled: it ends the name early, the leftover fails the trailing
/// check, and the result is `None`. That shape is vanishingly rare for a
/// database name, and failing closed costs a missed detection rather than a
/// truncated name.
fn identifier(s: &str) -> Option<String> {
let name = match s.chars().next()? {
quote @ ('`' | '"') => delimited(s, quote, quote)?,
'[' => delimited(s, '[', ']')?,
_ => bare(s)?,
};
Some(name.to_string())
}

/// The text between `open` and the next `close`, if what follows it is only
/// whitespace and an optional statement terminator.
fn delimited(s: &str, open: char, close: char) -> Option<&str> {
let body = s.strip_prefix(open)?;
let (name, after) = body.split_once(close)?;
only_terminator(after).then_some(name)
}

/// A leading unquoted identifier: alphanumerics, `_` and `$` (accepted by
/// MySQL, and present in some legacy schema names).
fn bare(s: &str) -> Option<&str> {
let end = s
.find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
.unwrap_or(s.len());
let (name, after) = s.split_at(end);
(!name.is_empty() && only_terminator(after)).then_some(name)
}

fn only_terminator(s: &str) -> bool {
matches!(s.trim(), "" | ";")
}
Loading