Fixes #41. MySQL columns now decode by their declared type instead of a blind try_get chain, so every non-string type (integers, DECIMAL, BIT, YEAR, DATE/TIME/DATETIME/TIMESTAMP) renders its value instead of NULL. MySQL restore runs in-process over the sqlx driver (no bundled MariaDB client, so MySQL 8 caching_sha2_password works) and the Clean toggle is implemented. .sql files can be opened with Gridline from the OS file manager. Verified: cargo test --lib 442 passed / 0 failed; bun run test 133 files / 1151 tests exit 0; tsc clean; both live MySQL 8 integration tests pass with the bundled CLI hidden.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
//! Per-connection cancellation handles, stored independently of the pool
|
||||
//! lock so `cancel_query` can dispatch while a long query holds the pool mutex.
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use rusqlite::InterruptHandle;
|
||||
@@ -59,6 +60,12 @@ pub enum CancelHandle {
|
||||
#[derive(Default)]
|
||||
pub struct CancelRegistry {
|
||||
map: Mutex<HashMap<String, CancelHandle>>,
|
||||
/// Live "cancel requested" flags, keyed by connection id. `cancel_query`
|
||||
/// sets one for MySQL; long-running in-process operations (currently the
|
||||
/// native restore engine) poll it between statements and clear it when
|
||||
/// they finish. Kept in a separate map from the handles so it needs no
|
||||
/// `Clone` on the registry and survives handle replacement.
|
||||
flags: Mutex<HashMap<String, Arc<AtomicBool>>>,
|
||||
}
|
||||
|
||||
impl CancelRegistry {
|
||||
@@ -66,6 +73,41 @@ impl CancelRegistry {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Get (or create) the cancellation flag for a connection. Polling callers
|
||||
/// should clear it before starting and after finishing.
|
||||
pub fn cancel_flag(&self, id: &str) -> Arc<AtomicBool> {
|
||||
self.flags
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(id.to_string())
|
||||
.or_default()
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Mark a connection as cancel-requested. Called by `cancel_query` before
|
||||
/// it sends the driver-specific cancel so in-process consumers observe the
|
||||
/// request even when the driver surfaces it as an unrecognized error.
|
||||
pub fn mark_cancelled(&self, id: &str) {
|
||||
self.cancel_flag(id).store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Whether a cancel has been requested for this connection.
|
||||
pub fn is_cancelled(&self, id: &str) -> bool {
|
||||
self.flags
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(id)
|
||||
.map(|f| f.load(Ordering::SeqCst))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Clear the cancel-requested flag for this connection.
|
||||
pub fn clear_cancelled(&self, id: &str) {
|
||||
if let Some(f) = self.flags.lock().unwrap().get(id) {
|
||||
f.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_pg(&self, id: &str, c: PgCancel) {
|
||||
self.set(id, CancelHandle::Pg(c));
|
||||
}
|
||||
@@ -157,6 +199,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_flag_is_tracked_per_connection() {
|
||||
let reg = CancelRegistry::new();
|
||||
assert!(!reg.is_cancelled("c1"));
|
||||
reg.mark_cancelled("c1");
|
||||
assert!(reg.is_cancelled("c1"));
|
||||
assert!(!reg.is_cancelled("c2"), "flag must not leak across connections");
|
||||
reg.clear_cancelled("c1");
|
||||
assert!(!reg.is_cancelled("c1"));
|
||||
// The same Arc is shared between getter and setter.
|
||||
let flag = reg.cancel_flag("c1");
|
||||
reg.mark_cancelled("c1");
|
||||
assert!(flag.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
fn fake_opts() -> sqlx::mysql::MySqlConnectOptions {
|
||||
sqlx::mysql::MySqlConnectOptions::new()
|
||||
.host("127.0.0.1").port(1).username("u").password("p").database("d")
|
||||
|
||||
@@ -516,12 +516,6 @@ pub fn build_mysql_dump_args(conn: &MySqlConnParams, options: &MySqlBackupOption
|
||||
a
|
||||
}
|
||||
|
||||
pub fn build_mysql_restore_args(conn: &MySqlConnParams, options: &MySqlRestoreOptions) -> Vec<String> {
|
||||
let mut a = base_mysql_args(conn);
|
||||
a.push(format!("--database={}", options.database));
|
||||
a
|
||||
}
|
||||
|
||||
/// System-first mariadb-dump/mariadb (bundled fallback in resources/mysql_tools).
|
||||
pub fn resolve_mysql_tool(app: &AppHandle, tool: &str) -> (String, Option<String>) {
|
||||
let system_ok = Command::new(tool).arg("--version").output().is_ok();
|
||||
@@ -556,22 +550,6 @@ pub fn run_mysql_dump(
|
||||
if out.status.success() { Ok(()) } else { Err(sanitize_error(&String::from_utf8_lossy(&out.stderr))) }
|
||||
}
|
||||
|
||||
pub fn run_mysql_restore(
|
||||
conn: &MySqlConnParams,
|
||||
options: &MySqlRestoreOptions,
|
||||
tools: &MySqlToolPaths,
|
||||
tls_mode: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let mut args = build_mysql_restore_args(conn, options);
|
||||
if let Some(m) = tls_mode {
|
||||
args.push(format!("--ssl-mode={m}"));
|
||||
}
|
||||
let file = std::fs::File::open(&options.file_path).map_err(|e| format!("open dump: {e}"))?;
|
||||
let out = Command::new(&tools.mysql).env("MYSQL_PWD", &conn.password).args(&args).stdin(Stdio::from(file)).output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if out.status.success() { Ok(()) } else { Err(sanitize_error(&String::from_utf8_lossy(&out.stderr))) }
|
||||
}
|
||||
|
||||
pub fn run_mysql_sync(
|
||||
source: &MySqlConnParams,
|
||||
target: &MySqlConnParams,
|
||||
@@ -598,6 +576,147 @@ pub fn run_mysql_sync(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Native MySQL restore engine (in-process via sqlx — no CLI, no subprocess)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Restore reads from disk, so it is capped at the existing CSV/JSON import
|
||||
/// limit (100 MB) rather than the 5 MB *editor* cap — a file too large to open
|
||||
/// in the editor must still be restorable (spec §2).
|
||||
pub const MAX_RESTORE_FILE_BYTES: u64 = 100 * 1024 * 1024;
|
||||
|
||||
/// Enforce the restore size cap. Extracted from the command so the boundary is
|
||||
/// testable; restore reads from disk, so it uses the 100 MB bulk cap rather
|
||||
/// than the 5 MB *editor* cap (spec §2).
|
||||
pub fn check_restore_file_size(bytes: u64, cap: u64) -> Result<(), String> {
|
||||
if bytes <= cap {
|
||||
return Ok(());
|
||||
}
|
||||
Err(format!(
|
||||
"Restore file is {} MB; the limit is {} MB",
|
||||
bytes.div_ceil(1024 * 1024),
|
||||
cap / (1024 * 1024)
|
||||
))
|
||||
}
|
||||
|
||||
/// Inputs for one native restore run. A struct rather than a long argument
|
||||
/// list so the headless core stays readable and directly testable.
|
||||
pub struct MySqlRestoreRun<'a> {
|
||||
pub database: &'a str,
|
||||
pub statements: &'a [String],
|
||||
pub clean: bool,
|
||||
}
|
||||
|
||||
/// Progress units for a restore: every statement, plus one for the clean
|
||||
/// drop phase so the fraction never jumps backwards.
|
||||
pub fn restore_progress_total(statement_count: usize, clean: bool) -> usize {
|
||||
statement_count + usize::from(clean)
|
||||
}
|
||||
|
||||
/// The error a failed restore reports. Includes the position so the user can
|
||||
/// find the statement in their file, and never echoes the statement text
|
||||
/// (SQL must not be logged — project guardrail).
|
||||
pub fn format_restore_statement_error(index_1based: usize, total: usize, detail: &str) -> String {
|
||||
format!("Statement {index_1based} of {total} failed: {detail}")
|
||||
}
|
||||
|
||||
/// Map a driver error for the restore path: a `KILL QUERY` cancellation must
|
||||
/// read as a cancellation, not as a broken statement.
|
||||
fn restore_statement_error(e: &sqlx::Error) -> String {
|
||||
if crate::commands::query::is_mysql_cancel_error(e) {
|
||||
"Query cancelled".to_string()
|
||||
} else {
|
||||
sanitize_error(&format!("{e}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute an already-split restore script over one live MySQL connection.
|
||||
///
|
||||
/// Stop-at-first-error by contract: MySQL DDL is non-transactional and
|
||||
/// auto-commits, so there is nothing to roll back and continuing past a
|
||||
/// failure would compound an already-partial restore (spec §6).
|
||||
pub async fn run_mysql_restore_on(
|
||||
conn: &mut sqlx::mysql::MySqlConnection,
|
||||
run: MySqlRestoreRun<'_>,
|
||||
on_progress: &mut (dyn FnMut(usize, usize) + Send),
|
||||
is_cancelled: &(dyn Fn() -> bool + Sync),
|
||||
) -> Result<(), String> {
|
||||
crate::commands::query::check_statement_budget(
|
||||
run.statements.len(),
|
||||
crate::commands::query::MAX_RESTORE_STATEMENTS,
|
||||
true,
|
||||
)?;
|
||||
|
||||
// The form lets the user target a database other than the one this
|
||||
// connection defaults to, so select it explicitly.
|
||||
exec_restore_statement(
|
||||
conn,
|
||||
&format!("USE {}", crate::db::mysql::mysql_quote_ident(run.database)),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||
|
||||
let total = restore_progress_total(run.statements.len(), run.clean);
|
||||
let mut done = 0usize;
|
||||
|
||||
if run.clean {
|
||||
let drops = crate::db::mysql_clean::plan_clean_drops(run.database, run.statements);
|
||||
if !drops.is_empty() {
|
||||
// Foreign keys off for the drop phase, so object ordering cannot
|
||||
// fail the restore; restored on the same session right after.
|
||||
exec_restore_statement(conn, "SET FOREIGN_KEY_CHECKS = 0")
|
||||
.await
|
||||
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||
for drop_sql in &drops {
|
||||
if is_cancelled() {
|
||||
return Err("Query cancelled".to_string());
|
||||
}
|
||||
exec_restore_statement(conn, drop_sql)
|
||||
.await
|
||||
.map_err(|e| restore_statement_error(&e))?;
|
||||
}
|
||||
exec_restore_statement(conn, "SET FOREIGN_KEY_CHECKS = 1")
|
||||
.await
|
||||
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||
}
|
||||
done += 1;
|
||||
on_progress(done, total);
|
||||
}
|
||||
|
||||
for (idx, stmt) in run.statements.iter().enumerate() {
|
||||
if is_cancelled() {
|
||||
return Err("Query cancelled".to_string());
|
||||
}
|
||||
if let Err(e) = exec_restore_statement(conn, stmt).await {
|
||||
if crate::commands::query::is_mysql_cancel_error(&e) {
|
||||
return Err("Query cancelled".to_string());
|
||||
}
|
||||
return Err(format_restore_statement_error(
|
||||
idx + 1,
|
||||
run.statements.len(),
|
||||
&sanitize_error(&format!("{e}")),
|
||||
));
|
||||
}
|
||||
done += 1;
|
||||
on_progress(done, total);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run a single restore statement, preserving the driver error so the caller
|
||||
/// can distinguish a cancellation from a genuine statement failure.
|
||||
///
|
||||
/// Uses the *simple* query protocol (a bare string through `Executor`), not the
|
||||
/// prepared-statement protocol: MySQL rejects several restore statements
|
||||
/// (`USE`, `SET`, some DDL) with error 1295 when prepared.
|
||||
async fn exec_restore_statement(
|
||||
conn: &mut sqlx::mysql::MySqlConnection,
|
||||
sql: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
use sqlx::Executor;
|
||||
conn.execute(sql).await.map(|_| ())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tauri commands (thin wrappers: store lookup + keychain + event emission)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -901,20 +1020,128 @@ pub async fn mysql_restore(
|
||||
.unwrap_or_default();
|
||||
|
||||
let (host, port, via_tunnel) = mysql_endpoint(&state, &connection_id, &conn);
|
||||
let params = MySqlConnParams::new(
|
||||
host,
|
||||
port,
|
||||
conn.username.clone().unwrap_or_else(|| "root".into()),
|
||||
options.database.clone(),
|
||||
password,
|
||||
|
||||
// Native restore: no tool resolution, no subprocess, no MYSQL_PWD in a
|
||||
// child environment. The sqlx driver handles MySQL 8 auth and TLS.
|
||||
let statements = {
|
||||
let bytes = std::fs::metadata(&options.file_path)
|
||||
.map_err(|e| format!("Cannot read restore file: {e}"))?
|
||||
.len();
|
||||
check_restore_file_size(bytes, MAX_RESTORE_FILE_BYTES)?;
|
||||
let text = std::fs::read_to_string(&options.file_path)
|
||||
.map_err(|e| format!("Cannot read restore file: {e}"))?;
|
||||
let statements = crate::db::sql_split::split_statements(&text);
|
||||
crate::commands::query::check_statement_budget(
|
||||
statements.len(),
|
||||
crate::commands::query::MAX_RESTORE_STATEMENTS,
|
||||
true,
|
||||
)?;
|
||||
statements
|
||||
};
|
||||
|
||||
let database = options.database.clone();
|
||||
let clean = options.clean;
|
||||
let original_host = conn.host.clone();
|
||||
let ssl_mode = conn.ssl_mode.clone();
|
||||
let ssl_ca = conn.ssl_ca_path.clone();
|
||||
let username = conn.username.clone().unwrap_or_else(|| "root".into());
|
||||
|
||||
// Real cancellation: a per-restore flag that the existing `cancel_query`
|
||||
// command sets, plus the restore session's MySQL thread id registered so
|
||||
// `KILL QUERY` aborts the in-flight statement. Cleared before each run so a
|
||||
// stale request from a previous operation cannot abort this one.
|
||||
let cancel_flag = state.cancel_registry.cancel_flag(&connection_id);
|
||||
cancel_flag.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
|
||||
let cancel_opts = crate::commands::db_viewer::mysql_connect_options(
|
||||
&host,
|
||||
port as u16,
|
||||
&username,
|
||||
&password,
|
||||
&database,
|
||||
&original_host,
|
||||
ssl_mode.as_deref(),
|
||||
ssl_ca.as_deref(),
|
||||
via_tunnel,
|
||||
);
|
||||
let tools = resolve_mysql_tool_paths(&app_handle);
|
||||
let tls = if via_tunnel { Some("REQUIRED") } else { None };
|
||||
|
||||
let job_id_clone = job_id.clone();
|
||||
let app_handle_clone = app_handle.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = run_mysql_restore(¶ms, &options, &tools, tls);
|
||||
let connection_id_for_cancel = connection_id.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut last_emit = std::time::Instant::now() - std::time::Duration::from_secs(1);
|
||||
let mut emit_progress = |done: usize, total: usize| {
|
||||
// Throttle: a 50k-statement dump must not flood the IPC channel.
|
||||
if last_emit.elapsed() < std::time::Duration::from_millis(200) && done < total {
|
||||
return;
|
||||
}
|
||||
last_emit = std::time::Instant::now();
|
||||
let _ = app_handle_clone.emit(
|
||||
"backup-progress",
|
||||
BackupProgressEvent {
|
||||
job_id: job_id_clone.clone(),
|
||||
status: "running".into(),
|
||||
progress: Some(done as f64 / total.max(1) as f64),
|
||||
output_line: Some(format!("Statement {done} of {total}")),
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
let is_cancelled = {
|
||||
let flag = cancel_flag.clone();
|
||||
move || flag.load(std::sync::atomic::Ordering::SeqCst)
|
||||
};
|
||||
|
||||
let result = async {
|
||||
let mut mconn = crate::commands::db_viewer::open_mysql_connection(
|
||||
&host,
|
||||
port as u16,
|
||||
&username,
|
||||
&password,
|
||||
&database,
|
||||
&original_host,
|
||||
ssl_mode.as_deref(),
|
||||
ssl_ca.as_deref(),
|
||||
via_tunnel,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Register the restore session so the UI's `cancel_query` can
|
||||
// `KILL QUERY` it (mirrors how browsing registers pooled conns).
|
||||
// The registry is reached through the app handle because it is not
|
||||
// `Clone`.
|
||||
let registry = app_handle_clone.state::<crate::AppState>();
|
||||
let restore_conn_id: i64 =
|
||||
sqlx::query_scalar("SELECT CAST(CONNECTION_ID() AS SIGNED)")
|
||||
.fetch_one(&mut mconn)
|
||||
.await
|
||||
.unwrap_or(-1);
|
||||
registry.cancel_registry.set_mysql(
|
||||
&connection_id_for_cancel,
|
||||
crate::cancel::MySqlCancel {
|
||||
conn_id: Some(restore_conn_id),
|
||||
connect_options: cancel_opts,
|
||||
},
|
||||
);
|
||||
|
||||
let r = run_mysql_restore_on(
|
||||
&mut mconn,
|
||||
MySqlRestoreRun { database: &database, statements: &statements, clean },
|
||||
&mut emit_progress,
|
||||
&is_cancelled,
|
||||
)
|
||||
.await;
|
||||
|
||||
registry
|
||||
.cancel_registry
|
||||
.set_mysql_conn_id(&connection_id_for_cancel, None);
|
||||
registry.cancel_registry.clear_cancelled(&connection_id_for_cancel);
|
||||
r
|
||||
}
|
||||
.await;
|
||||
|
||||
emit_result(&app_handle_clone, &job_id_clone, result);
|
||||
});
|
||||
|
||||
|
||||
@@ -517,18 +517,121 @@ fn mysql_dump_args_single_transaction_no_data_routines() {
|
||||
assert!(args.iter().all(|a| !a.starts_with("--password")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_restore_args_no_clean_flags() {
|
||||
let opts = MySqlRestoreOptions { database: "shop".into(), file_path: "/tmp/d.sql".into(), clean: false };
|
||||
let args = build_mysql_restore_args(&MySqlConnParams::new("h".into(), 3306, "u".into(), "shop".into(), "p".into()), &opts);
|
||||
assert!(args.iter().any(|a| a == "--database=shop"));
|
||||
assert!(args.iter().any(|a| a == "--host=h"));
|
||||
assert!(args.iter().all(|a| a != "--force"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_env_uses_mysql_pwd_not_password_arg() {
|
||||
let opts = MySqlBackupOptions { database: "db".into(), file_path: "/tmp/x.sql".into(), single_transaction: false, no_data: false, routines: false, triggers: false, events: false };
|
||||
let args = build_mysql_dump_args(&MySqlConnParams::new("h".into(), 3306, "u".into(), "db".into(), "p".into()), &opts);
|
||||
assert!(args.iter().all(|a| !a.starts_with("--password")));
|
||||
}
|
||||
|
||||
// ── native MySQL restore: error reporting contract ──────────────────
|
||||
|
||||
#[test]
|
||||
fn restore_failure_names_the_statement_position() {
|
||||
// The user must be able to find the offending statement in their file.
|
||||
let err = format_restore_statement_error(3, 10, "syntax error near 'CREAT'");
|
||||
assert_eq!(err, "Statement 3 of 10 failed: syntax error near 'CREAT'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_progress_total_counts_the_clean_phase() {
|
||||
// clean adds one progress unit (the drop phase) so the bar never resets.
|
||||
assert_eq!(restore_progress_total(5, false), 5);
|
||||
assert_eq!(restore_progress_total(5, true), 6);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn mysql_native_restore_applies_a_script_and_honours_clean() {
|
||||
use sqlx::ConnectOptions;
|
||||
use sqlx::Connection;
|
||||
|
||||
let host = match std::env::var("GRIDLINE_TEST_MYSQL_HOST") { Ok(v) => v, Err(_) => return };
|
||||
let port: u16 = std::env::var("GRIDLINE_TEST_MYSQL_PORT").unwrap_or_else(|_| "3306".into()).parse().unwrap();
|
||||
let user = std::env::var("GRIDLINE_TEST_MYSQL_USER").unwrap_or_else(|_| "root".into());
|
||||
let pass = std::env::var("GRIDLINE_TEST_MYSQL_PASS").unwrap_or_default();
|
||||
let db = match std::env::var("GRIDLINE_TEST_MYSQL_DB") { Ok(v) => v, Err(_) => return };
|
||||
|
||||
let mut conn = sqlx::mysql::MySqlConnectOptions::new()
|
||||
.host(&host).port(port).username(&user).password(&pass).database(&db)
|
||||
.ssl_mode(sqlx::mysql::MySqlSslMode::Disabled)
|
||||
.disable_statement_logging()
|
||||
.connect().await.expect("connect");
|
||||
|
||||
// Scratch table, removed again at the end, so the live DB is never harmed.
|
||||
let table = "gl_restore_probe";
|
||||
sqlx::query(&format!("DROP TABLE IF EXISTS `{db}`.`{table}`"))
|
||||
.execute(&mut conn).await.expect("pre-clean");
|
||||
|
||||
let script = format!(
|
||||
"CREATE TABLE `{table}` (id INT PRIMARY KEY, name VARCHAR(20));\n\
|
||||
INSERT INTO `{table}` VALUES (1, 'first');"
|
||||
);
|
||||
let statements = crate::db::sql_split::split_statements(&script);
|
||||
|
||||
let mut events: Vec<(usize, usize)> = Vec::new();
|
||||
let mut on_progress = |done: usize, total: usize| events.push((done, total));
|
||||
let never = || false;
|
||||
|
||||
run_mysql_restore_on(
|
||||
&mut conn,
|
||||
MySqlRestoreRun { database: &db, statements: &statements, clean: true },
|
||||
&mut on_progress,
|
||||
&never,
|
||||
).await.expect("first restore");
|
||||
|
||||
let count: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM `{db}`.`{table}`"))
|
||||
.fetch_one(&mut conn).await.expect("count");
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(events.last().copied(), Some((statements.len() + 1, statements.len() + 1)));
|
||||
|
||||
// Second run with clean: the table must be dropped and recreated, so the
|
||||
// insert cannot collide with the existing primary key.
|
||||
let mut events2: Vec<(usize, usize)> = Vec::new();
|
||||
let mut on_progress2 = |done: usize, total: usize| events2.push((done, total));
|
||||
run_mysql_restore_on(
|
||||
&mut conn,
|
||||
MySqlRestoreRun { database: &db, statements: &statements, clean: true },
|
||||
&mut on_progress2,
|
||||
&never,
|
||||
).await.expect("clean restore must not hit a duplicate key");
|
||||
|
||||
let count2: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM `{db}`.`{table}`"))
|
||||
.fetch_one(&mut conn).await.expect("count2");
|
||||
assert_eq!(count2, 1, "clean restore must replace, not append");
|
||||
|
||||
// A statement that cannot succeed must fail and name its position.
|
||||
let bad = crate::db::sql_split::split_statements("SELECT 1;\nSELECT * FROM gl_no_such_table_xyz;");
|
||||
let mut noop = |_: usize, _: usize| {};
|
||||
let err = run_mysql_restore_on(
|
||||
&mut conn,
|
||||
MySqlRestoreRun { database: &db, statements: &bad, clean: false },
|
||||
&mut noop,
|
||||
&never,
|
||||
).await.expect_err("must fail");
|
||||
assert!(err.starts_with("Statement 2 of 2 failed:"), "got {err:?}");
|
||||
|
||||
let _ = conn.close().await;
|
||||
let mut cleanup = sqlx::mysql::MySqlConnectOptions::new()
|
||||
.host(&host).port(port).username(&user).password(&pass).database(&db)
|
||||
.ssl_mode(sqlx::mysql::MySqlSslMode::Disabled).disable_statement_logging()
|
||||
.connect().await.expect("reconnect");
|
||||
sqlx::query(&format!("DROP TABLE IF EXISTS `{db}`.`{table}`"))
|
||||
.execute(&mut cleanup).await.expect("cleanup");
|
||||
let _ = cleanup.close().await;
|
||||
}
|
||||
|
||||
// ── restore file-size cap ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn restore_size_cap_accepts_a_file_at_the_limit() {
|
||||
assert!(check_restore_file_size(MAX_RESTORE_FILE_BYTES, MAX_RESTORE_FILE_BYTES).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_size_cap_rejects_a_file_over_the_limit_and_states_both_sizes() {
|
||||
let err = check_restore_file_size(MAX_RESTORE_FILE_BYTES + 1, MAX_RESTORE_FILE_BYTES)
|
||||
.expect_err("must reject");
|
||||
assert!(err.contains("100 MB"), "error must state the limit in MB; got {err:?}");
|
||||
assert!(err.contains("MB"), "error must state the actual size in MB; got {err:?}");
|
||||
}
|
||||
|
||||
@@ -941,6 +941,102 @@ where
|
||||
Ok((client, handle))
|
||||
}
|
||||
|
||||
/// Apply Gridline's MySQL TLS policy to connect options.
|
||||
///
|
||||
/// Extracted from `run_mysql_connect` so the native restore path connects
|
||||
/// *exactly* as browsing does. That equivalence is the whole point of the fix:
|
||||
/// the sqlx driver reaches MySQL 8 with `caching_sha2_password` and handles
|
||||
/// TLS in-process, where the bundled MariaDB client cannot.
|
||||
pub(crate) fn apply_mysql_tls(
|
||||
mut opts: sqlx::mysql::MySqlConnectOptions,
|
||||
original_host: &str,
|
||||
ssl_mode: Option<&str>,
|
||||
ssl_ca_path: Option<&str>,
|
||||
via_tunnel: bool,
|
||||
) -> sqlx::mysql::MySqlConnectOptions {
|
||||
use sqlx::mysql::MySqlSslMode;
|
||||
|
||||
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
|
||||
// degrade to encrypt-only `require`. Direct connections honor the mode.
|
||||
let decision = crate::commands::ssh::effective_tls_decision(
|
||||
crate::db::tls::tls_decision_for(original_host, ssl_mode),
|
||||
via_tunnel,
|
||||
);
|
||||
match decision {
|
||||
crate::db::tls::TlsDecision::Disable => {
|
||||
opts = opts.ssl_mode(MySqlSslMode::Disabled);
|
||||
}
|
||||
crate::db::tls::TlsDecision::Require => {
|
||||
opts = opts.ssl_mode(MySqlSslMode::Required);
|
||||
}
|
||||
crate::db::tls::TlsDecision::Verify => {
|
||||
// sqlx 0.8 has no VerifyFull: verify-ca -> VerifyCa (chain only),
|
||||
// verify-full -> VerifyIdentity (chain + hostname).
|
||||
match ssl_mode {
|
||||
Some("verify-ca") => opts = opts.ssl_mode(MySqlSslMode::VerifyCa),
|
||||
_ => opts = opts.ssl_mode(MySqlSslMode::VerifyIdentity),
|
||||
}
|
||||
if let Some(ca) = ssl_ca_path {
|
||||
opts = opts.ssl_ca(ca);
|
||||
}
|
||||
}
|
||||
}
|
||||
opts
|
||||
}
|
||||
|
||||
/// Build the final MySQL connect options (base params + TLS policy) for a
|
||||
/// headless, non-pooled connection.
|
||||
pub(crate) fn mysql_connect_options(
|
||||
host: &str,
|
||||
port: u16,
|
||||
username: &str,
|
||||
password: &str,
|
||||
database: &str,
|
||||
original_host: &str,
|
||||
ssl_mode: Option<&str>,
|
||||
ssl_ca_path: Option<&str>,
|
||||
via_tunnel: bool,
|
||||
) -> sqlx::mysql::MySqlConnectOptions {
|
||||
let opts = sqlx::mysql::MySqlConnectOptions::new()
|
||||
.host(host)
|
||||
.port(port)
|
||||
.username(username)
|
||||
.password(password)
|
||||
.database(database);
|
||||
apply_mysql_tls(opts, original_host, ssl_mode, ssl_ca_path, via_tunnel)
|
||||
}
|
||||
|
||||
/// Open a single standalone MySQL connection for a non-browsing operation
|
||||
/// (currently: native restore). Deliberately not pooled — the restore owns the
|
||||
/// session so `USE` and `FOREIGN_KEY_CHECKS` stay coherent for its whole run.
|
||||
pub(crate) async fn open_mysql_connection(
|
||||
host: &str,
|
||||
port: u16,
|
||||
username: &str,
|
||||
password: &str,
|
||||
database: &str,
|
||||
original_host: &str,
|
||||
ssl_mode: Option<&str>,
|
||||
ssl_ca_path: Option<&str>,
|
||||
via_tunnel: bool,
|
||||
) -> Result<sqlx::mysql::MySqlConnection, String> {
|
||||
use sqlx::ConnectOptions;
|
||||
let opts = mysql_connect_options(
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
password,
|
||||
database,
|
||||
original_host,
|
||||
ssl_mode,
|
||||
ssl_ca_path,
|
||||
via_tunnel,
|
||||
);
|
||||
opts.connect()
|
||||
.await
|
||||
.map_err(|e| sanitize_error(&format!("MySQL connection failed: {e}")))
|
||||
}
|
||||
|
||||
/// Headless MySQL connect (no Tauri `State`). Opens an SSH tunnel when
|
||||
/// configured (binding 127.0.0.1 only), maps SSL modes, and registers a
|
||||
/// `DbHandle::MySql` pool. Errors are sanitized so no `mysql://user:pass@host`
|
||||
@@ -952,7 +1048,7 @@ pub(crate) async fn run_mysql_connect(
|
||||
pool_manager: &tokio::sync::Mutex<crate::db::pool::ConnectionPoolManager>,
|
||||
cancel_registry: &crate::cancel::CancelRegistry,
|
||||
) -> Result<(), String> {
|
||||
use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions, MySqlSslMode};
|
||||
use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions};
|
||||
if config.host.trim().is_empty() {
|
||||
return Err("host is required".to_string());
|
||||
}
|
||||
@@ -995,38 +1091,22 @@ pub(crate) async fn run_mysql_connect(
|
||||
via_tunnel = false;
|
||||
}
|
||||
|
||||
let mut opts = MySqlConnectOptions::new()
|
||||
let opts = MySqlConnectOptions::new()
|
||||
.host(&target_host)
|
||||
.port(target_port)
|
||||
.username(config.username.as_deref().unwrap_or("root"))
|
||||
.password(config.password.as_deref().unwrap_or(""))
|
||||
.database(config.database.as_deref().unwrap_or("mysql"));
|
||||
|
||||
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
|
||||
// degrade to encrypt-only `require`. Direct connections honor the mode.
|
||||
let decision = crate::commands::ssh::effective_tls_decision(
|
||||
crate::db::tls::tls_decision_for(&config.host, config.ssl_mode.as_deref()),
|
||||
// TLS: shared with the native restore path so browsing and restore make
|
||||
// identical TLS decisions.
|
||||
let opts = apply_mysql_tls(
|
||||
opts,
|
||||
&config.host,
|
||||
config.ssl_mode.as_deref(),
|
||||
config.ssl_ca_path.as_deref(),
|
||||
via_tunnel,
|
||||
);
|
||||
match decision {
|
||||
crate::db::tls::TlsDecision::Disable => {
|
||||
opts = opts.ssl_mode(MySqlSslMode::Disabled);
|
||||
}
|
||||
crate::db::tls::TlsDecision::Require => {
|
||||
opts = opts.ssl_mode(MySqlSslMode::Required);
|
||||
}
|
||||
crate::db::tls::TlsDecision::Verify => {
|
||||
// sqlx 0.8 has no VerifyFull: verify-ca -> VerifyCa (chain only),
|
||||
// verify-full -> VerifyIdentity (chain + hostname).
|
||||
match config.ssl_mode.as_deref() {
|
||||
Some("verify-ca") => opts = opts.ssl_mode(MySqlSslMode::VerifyCa),
|
||||
_ => opts = opts.ssl_mode(MySqlSslMode::VerifyIdentity),
|
||||
}
|
||||
if let Some(ca) = config.ssl_ca_path.as_deref() {
|
||||
opts = opts.ssl_ca(ca);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clone the (already final) options before `connect_with` consumes them so
|
||||
// `cancel_query` can open its own connection to run `KILL QUERY ?`.
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod import_export;
|
||||
pub mod keychain;
|
||||
pub mod maintenance;
|
||||
pub mod objects;
|
||||
pub mod open_files;
|
||||
pub mod query;
|
||||
pub mod query_export;
|
||||
pub mod schema_diff;
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
//! OS-delivered "open this .sql file with Gridline" requests.
|
||||
//!
|
||||
//! Every platform funnels into [`accept_path`], which validates strictly and
|
||||
//! queues the path. The frontend only ever calls [`take_pending_sql_files`],
|
||||
//! which takes **no path argument** and drains what was queued — so the webview
|
||||
//! cannot name a file to read. That is the boundary that keeps this feature
|
||||
//! from becoming an arbitrary-filesystem-read primitive and defeating the
|
||||
//! deliberate `fs` scope (spec §2).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use tauri::State;
|
||||
|
||||
/// Editor-load cap. A larger script is routed to Tools → Restore instead of
|
||||
/// being handed to Monaco (spec §2).
|
||||
pub const MAX_SQL_FILE_BYTES: u64 = 5 * 1024 * 1024;
|
||||
|
||||
/// A validated open request, delivered to the frontend with its contents.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PendingSqlFile {
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Process-wide FIFO of accepted paths. Lives in Rust (not the webview) so a
|
||||
/// window reload cannot lose an open request.
|
||||
#[derive(Default)]
|
||||
pub struct PendingOpenFiles(Mutex<Vec<PathBuf>>);
|
||||
|
||||
/// Strict validation. Fail closed: anything we are not certain about is
|
||||
/// rejected rather than opened (spec §6).
|
||||
pub fn validate_sql_path_with_cap(raw: &str, cap: u64) -> Option<PathBuf> {
|
||||
let path = Path::new(raw);
|
||||
|
||||
// Absolute only — the OS always hands us absolute paths, and accepting a
|
||||
// relative one would resolve against the process CWD. Note the app is
|
||||
// often launched with CWD `/` (see the app-data-dir note in lib.rs).
|
||||
if !path.is_absolute() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ext = path.extension()?.to_str()?;
|
||||
if !ext.eq_ignore_ascii_case("sql") {
|
||||
return None;
|
||||
}
|
||||
|
||||
// symlink_metadata, not metadata: a symlink must not be followed into an
|
||||
// arbitrary target (and `file_type().is_file()` is false for one).
|
||||
let meta = std::fs::symlink_metadata(path).ok()?;
|
||||
if !meta.file_type().is_file() {
|
||||
return None;
|
||||
}
|
||||
if meta.len() > cap {
|
||||
return None;
|
||||
}
|
||||
Some(path.to_path_buf())
|
||||
}
|
||||
|
||||
/// Validate against the production cap.
|
||||
pub fn validate_sql_path(raw: &str) -> Option<PathBuf> {
|
||||
validate_sql_path_with_cap(raw, MAX_SQL_FILE_BYTES)
|
||||
}
|
||||
|
||||
/// Queue an OS-supplied path. Returns `true` when it was newly queued, so
|
||||
/// callers can decide whether a nudge event is worth emitting.
|
||||
pub fn accept_path(buffer: &PendingOpenFiles, raw: &str) -> bool {
|
||||
let Some(path) = validate_sql_path(raw) else {
|
||||
return false;
|
||||
};
|
||||
let mut queue = buffer.0.lock().unwrap();
|
||||
if queue.iter().any(|p| p == &path) {
|
||||
return false;
|
||||
}
|
||||
queue.push(path);
|
||||
true
|
||||
}
|
||||
|
||||
/// Read and remove everything queued. Separated from the command so it is
|
||||
/// testable without a Tauri `State`.
|
||||
pub fn take_from_buffer(buffer: &PendingOpenFiles) -> Result<Vec<PendingSqlFile>, String> {
|
||||
let drained: Vec<PathBuf> = {
|
||||
let mut queue = buffer.0.lock().unwrap();
|
||||
std::mem::take(&mut *queue)
|
||||
};
|
||||
|
||||
drained
|
||||
.into_iter()
|
||||
.map(|path| {
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| path.to_string_lossy().into_owned());
|
||||
// Fail closed on undecodable bytes rather than guessing an
|
||||
// encoding for the user's SQL.
|
||||
let bytes =
|
||||
std::fs::read(&path).map_err(|e| format!("Cannot read {}: {e}", name))?;
|
||||
let content =
|
||||
String::from_utf8(bytes).map_err(|_| format!("{name} is not valid UTF-8"))?;
|
||||
Ok(PendingSqlFile {
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
name,
|
||||
content,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Drain every queued open request. The only file-reading command the webview
|
||||
/// can call — deliberately argument-free (see the module docs).
|
||||
#[tauri::command]
|
||||
pub fn take_pending_sql_files(
|
||||
state: State<'_, PendingOpenFiles>,
|
||||
) -> Result<Vec<PendingSqlFile>, String> {
|
||||
take_from_buffer(&state)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "open_files.test.rs"]
|
||||
mod open_files_tests;
|
||||
@@ -0,0 +1,107 @@
|
||||
use super::*;
|
||||
|
||||
fn tmp_sql(name: &str, repeats: usize) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("gridline-open-{name}"));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("sample.sql");
|
||||
std::fs::write(&file, "SELECT 1;".repeat(repeats.max(1))).unwrap();
|
||||
file
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_relative_path() {
|
||||
assert!(validate_sql_path_with_cap("student_db.sql", 1024).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_non_sql_extension() {
|
||||
let f = tmp_sql("ext", 1);
|
||||
let renamed = f.with_extension("txt");
|
||||
std::fs::rename(&f, &renamed).unwrap();
|
||||
assert!(validate_sql_path_with_cap(&renamed.to_string_lossy(), 1024).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_directory() {
|
||||
let dir = std::env::temp_dir().join("gridline-open-dir");
|
||||
std::fs::create_dir_all(dir.join("not-a-file.sql")).unwrap();
|
||||
assert!(
|
||||
validate_sql_path_with_cap(&dir.join("not-a-file.sql").to_string_lossy(), 1024).is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_missing_file() {
|
||||
let p = std::env::temp_dir().join("gridline-definitely-missing-xyz.sql");
|
||||
assert!(validate_sql_path_with_cap(&p.to_string_lossy(), 1024).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_file_over_the_cap() {
|
||||
let f = tmp_sql("big", 200);
|
||||
assert!(validate_sql_path_with_cap(&f.to_string_lossy(), 16).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_symlink() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let f = tmp_sql("sym", 1);
|
||||
let link = f.with_file_name("link.sql");
|
||||
let _ = std::fs::remove_file(&link);
|
||||
std::os::unix::fs::symlink(&f, &link).unwrap();
|
||||
assert!(
|
||||
validate_sql_path_with_cap(&link.to_string_lossy(), 1024).is_none(),
|
||||
"a symlink must not be accepted (spec §6)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_a_valid_sql_file_with_an_uppercase_extension() {
|
||||
let f = tmp_sql("ok", 1);
|
||||
let upper = f.with_file_name("SAMPLE.SQL");
|
||||
std::fs::rename(&f, &upper).unwrap();
|
||||
assert!(validate_sql_path_with_cap(&upper.to_string_lossy(), 1024).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_path_dedupes_the_same_path() {
|
||||
let buffer = PendingOpenFiles::default();
|
||||
let f = tmp_sql("dedupe", 1);
|
||||
let s = f.to_string_lossy().to_string();
|
||||
assert!(accept_path(&buffer, &s));
|
||||
assert!(!accept_path(&buffer, &s), "second accept must be deduped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_path_ignores_an_invalid_path() {
|
||||
let buffer = PendingOpenFiles::default();
|
||||
assert!(!accept_path(&buffer, "relative.sql"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn take_returns_contents_then_drains() {
|
||||
let buffer = PendingOpenFiles::default();
|
||||
let f = tmp_sql("drain", 1);
|
||||
assert!(accept_path(&buffer, &f.to_string_lossy()));
|
||||
|
||||
let first = take_from_buffer(&buffer).expect("first drain");
|
||||
assert_eq!(first.len(), 1);
|
||||
assert_eq!(first[0].name, "sample.sql");
|
||||
assert_eq!(first[0].content, "SELECT 1;");
|
||||
|
||||
let second = take_from_buffer(&buffer).expect("second drain");
|
||||
assert!(second.is_empty(), "buffer must be drained, not reread");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_non_utf8_file() {
|
||||
let dir = std::env::temp_dir().join("gridline-open-binary");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let f = dir.join("binary.sql");
|
||||
std::fs::write(&f, [0xff, 0xfe, 0x00]).unwrap();
|
||||
let buffer = PendingOpenFiles::default();
|
||||
assert!(accept_path(&buffer, &f.to_string_lossy()));
|
||||
assert!(take_from_buffer(&buffer).is_err(), "non-UTF8 must fail closed");
|
||||
}
|
||||
@@ -34,7 +34,7 @@ fn is_pg_cancel_error(e: &tokio_postgres::Error) -> bool {
|
||||
e.code() == Some(&tokio_postgres::error::SqlState::QUERY_CANCELED)
|
||||
}
|
||||
|
||||
fn is_mysql_cancel_error(e: &sqlx::Error) -> bool {
|
||||
pub(crate) fn is_mysql_cancel_error(e: &sqlx::Error) -> bool {
|
||||
match e.as_database_error().and_then(|d| d.code()) {
|
||||
Some(code) if code == "1317" => true, // ER_QUERY_INTERRUPTED (KILL QUERY)
|
||||
_ => e.to_string().to_lowercase().contains("interrupted"),
|
||||
@@ -565,25 +565,194 @@ pub(crate) fn mysql_wrap_count(query: &str) -> String {
|
||||
format!("SELECT COUNT(*) FROM ({}) AS _gridline_cnt", query.trim())
|
||||
}
|
||||
|
||||
/// Convert a sqlx MySql row cell to serde_json::Value (via the `json` feature).
|
||||
/// How a MySQL column value must be decoded, decided from the column's
|
||||
/// declared type name.
|
||||
///
|
||||
/// Why type-directed rather than a blind try-chain: sqlx's MySQL integer
|
||||
/// compatibility is broad, so `try_get::<i16>` succeeds for a `BIGINT`
|
||||
/// holding a small value but fails once the value grows — the same column
|
||||
/// would emit different JSON types for different rows. `MySqlTypeInfo`
|
||||
/// exposes only `name()` publicly, which is sufficient because it already
|
||||
/// encodes signedness (`INT UNSIGNED`) and boolean-ness (`BOOLEAN`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum MysqlCellKind {
|
||||
/// Width in bits and signedness; drives the number-vs-string policy.
|
||||
Int { bits: u8, signed: bool },
|
||||
/// `BIT(M)` — a bitmask, rendered as a number (spec §4.1).
|
||||
Bit,
|
||||
Float,
|
||||
Double,
|
||||
Decimal,
|
||||
Date,
|
||||
Time,
|
||||
DateTime,
|
||||
Json,
|
||||
Text,
|
||||
Bytes,
|
||||
Null,
|
||||
/// Anything unrecognised (including `GEOMETRY`, which no sqlx decoder
|
||||
/// accepts even with `chrono`/`bigdecimal` enabled).
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Map a `sqlx` MySQL type name to its decode strategy.
|
||||
pub(crate) fn mysql_cell_kind(type_name: &str) -> MysqlCellKind {
|
||||
use MysqlCellKind::*;
|
||||
let t = type_name.trim().to_ascii_uppercase();
|
||||
match t.as_str() {
|
||||
"BOOLEAN" | "TINYINT" => Int { bits: 8, signed: true },
|
||||
"TINYINT UNSIGNED" => Int { bits: 8, signed: false },
|
||||
"SMALLINT" => Int { bits: 16, signed: true },
|
||||
"SMALLINT UNSIGNED" => Int { bits: 16, signed: false },
|
||||
"MEDIUMINT" => Int { bits: 24, signed: true },
|
||||
"MEDIUMINT UNSIGNED" => Int { bits: 24, signed: false },
|
||||
"INT" => Int { bits: 32, signed: true },
|
||||
"INT UNSIGNED" => Int { bits: 32, signed: false },
|
||||
"BIGINT" => Int { bits: 64, signed: true },
|
||||
"BIGINT UNSIGNED" => Int { bits: 64, signed: false },
|
||||
// YEAR is an unsigned 16-bit value; it needs the unsigned rung, not
|
||||
// the signed one (int_compatible excludes UNSIGNED columns).
|
||||
"YEAR" => Int { bits: 16, signed: false },
|
||||
"BIT" => Bit,
|
||||
"FLOAT" => Float,
|
||||
"DOUBLE" => Double,
|
||||
"DECIMAL" => Decimal,
|
||||
"DATE" => Date,
|
||||
"TIME" => Time,
|
||||
// TIMESTAMP shares the Datetime wire type. Decoded naive on purpose:
|
||||
// MySQL converts TIMESTAMP to/from the *session* time zone, so
|
||||
// claiming UTC would assert something the value does not support.
|
||||
"DATETIME" | "TIMESTAMP" => DateTime,
|
||||
"JSON" => Json,
|
||||
"CHAR" | "VARCHAR" | "TEXT" | "TINYTEXT" | "MEDIUMTEXT" | "LONGTEXT"
|
||||
| "ENUM" | "SET" => Text,
|
||||
"BINARY" | "VARBINARY" | "BLOB" | "TINYBLOB" | "MEDIUMBLOB" | "LONGBLOB" => Bytes,
|
||||
"NULL" => Null,
|
||||
_ => Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a sqlx MySql row cell to serde_json::Value, driven by the column's
|
||||
/// **declared** type (see [`mysql_cell_kind`]).
|
||||
///
|
||||
/// Type-directed rather than a blind try-chain because sqlx's MySQL integer
|
||||
/// compatibility is broad: `try_get::<i16>` succeeds for a `BIGINT` holding
|
||||
/// a small value and fails once the value grows, so a value-driven chain
|
||||
/// would emit different JSON types for different rows of one column.
|
||||
/// Shared with the DB-viewer commands (pub(crate)).
|
||||
pub(crate) fn mysql_cell_to_json(row: &sqlx::mysql::MySqlRow, i: usize) -> serde_json::Value {
|
||||
if let Ok(Some(v)) = row.try_get::<Option<serde_json::Value>, _>(i) {
|
||||
return v;
|
||||
use sqlx::{Row, TypeInfo, ValueRef};
|
||||
|
||||
// `MySqlTypeInfo.r#type`/`.flags` are `pub(crate)`, so the declared type is
|
||||
// read through the public `name()`, which already distinguishes
|
||||
// `INT UNSIGNED`, `BIGINT UNSIGNED` and `BOOLEAN` (TINYINT(1)).
|
||||
let type_name = match row.try_get_raw(i) {
|
||||
Ok(v) if !v.is_null() => v.type_info().name().to_string(),
|
||||
// Genuine SQL NULL, or a column we cannot even describe.
|
||||
_ => return serde_json::Value::Null,
|
||||
};
|
||||
|
||||
match mysql_cell_kind(&type_name) {
|
||||
MysqlCellKind::Int { bits, signed } => {
|
||||
if signed {
|
||||
match row.try_get::<i64, _>(i) {
|
||||
// <=32-bit fits a JS Number exactly; 64-bit is stringified
|
||||
// so the value survives the IPC boundary (matches PG).
|
||||
Ok(v) if bits <= 32 => serde_json::json!(v),
|
||||
Ok(v) => crate::commands::db_viewer::i64_to_json(v),
|
||||
Err(_) => serde_json::Value::Null,
|
||||
}
|
||||
} else {
|
||||
match row.try_get::<u64, _>(i) {
|
||||
Ok(v) if bits <= 32 => serde_json::json!(v),
|
||||
Ok(v) => serde_json::Value::String(v.to_string()),
|
||||
Err(_) => serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
}
|
||||
MysqlCellKind::Bit => match row.try_get::<u64, _>(i) {
|
||||
Ok(v) => serde_json::json!(v),
|
||||
Err(_) => serde_json::Value::Null,
|
||||
},
|
||||
MysqlCellKind::Float => match row.try_get::<f32, _>(i) {
|
||||
Ok(v) => serde_json::json!(v),
|
||||
Err(_) => serde_json::Value::Null,
|
||||
},
|
||||
MysqlCellKind::Double => match row.try_get::<f64, _>(i) {
|
||||
Ok(v) => serde_json::json!(v),
|
||||
Err(_) => serde_json::Value::Null,
|
||||
},
|
||||
MysqlCellKind::Decimal => match row.try_get::<sqlx::types::BigDecimal, _>(i) {
|
||||
// Stringified for the same reason as 64-bit ints: exact digits.
|
||||
Ok(v) => serde_json::Value::String(v.to_string()),
|
||||
Err(_) => serde_json::Value::Null,
|
||||
},
|
||||
// Temporal values are emitted naive, never as an instant: MySQL
|
||||
// converts TIMESTAMP to and from the *session* time zone, so an
|
||||
// explicit `Z` would assert something the value does not support.
|
||||
MysqlCellKind::Date => match row.try_get::<chrono::NaiveDate, _>(i) {
|
||||
Ok(v) => serde_json::Value::String(v.to_string()),
|
||||
Err(_) => serde_json::Value::Null,
|
||||
},
|
||||
MysqlCellKind::Time => match row.try_get::<chrono::NaiveTime, _>(i) {
|
||||
Ok(v) => serde_json::Value::String(v.to_string()),
|
||||
Err(_) => serde_json::Value::Null,
|
||||
},
|
||||
MysqlCellKind::DateTime => {
|
||||
// `try_get_unchecked` (not `try_get`) because sqlx's `NaiveDateTime`
|
||||
// `Type`/`Compatible` impl matches `ColumnType::Datetime` only, so the
|
||||
// checked path rejects a TIMESTAMP column even though both share the
|
||||
// exact same wire format and `Decode` impl. Only the type-compat gate
|
||||
// is bypassed here; the decode is the same one DATETIME uses.
|
||||
match row.try_get_unchecked::<chrono::NaiveDateTime, _>(i) {
|
||||
Ok(v) => serde_json::Value::String(v.to_string()),
|
||||
Err(_) => serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
MysqlCellKind::Json => row
|
||||
.try_get::<serde_json::Value, _>(i)
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
MysqlCellKind::Text => mysql_text_to_json(row, i),
|
||||
MysqlCellKind::Bytes => mysql_bytes_to_json(row, i),
|
||||
MysqlCellKind::Null => serde_json::Value::Null,
|
||||
MysqlCellKind::Unknown => mysql_unknown_to_json(row, i),
|
||||
}
|
||||
if let Ok(s) = row.try_get::<Option<String>, _>(i) {
|
||||
return s
|
||||
.map(|s| serde_json::Value::String(s))
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
}
|
||||
|
||||
/// Char/varchar/text/enum/set: a UTF-8 string, falling back to a lossy
|
||||
/// decode for the odd VARBINARY-shaped metadata column.
|
||||
fn mysql_text_to_json(row: &sqlx::mysql::MySqlRow, i: usize) -> serde_json::Value {
|
||||
use sqlx::Row;
|
||||
if let Ok(v) = row.try_get::<String, _>(i) {
|
||||
return serde_json::Value::String(v);
|
||||
}
|
||||
if let Ok(b) = row.try_get::<Option<Vec<u8>>, _>(i) {
|
||||
return b
|
||||
.map(|b| serde_json::Value::String(String::from_utf8_lossy(&b).into_owned()))
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
if let Ok(b) = row.try_get::<Vec<u8>, _>(i) {
|
||||
return serde_json::Value::String(String::from_utf8_lossy(&b).into_owned());
|
||||
}
|
||||
serde_json::Value::Null
|
||||
}
|
||||
|
||||
/// Binary/blob columns. Preserves the pre-existing lossy-UTF-8 rendering;
|
||||
/// changing binary presentation is explicitly out of scope (spec §3).
|
||||
fn mysql_bytes_to_json(row: &sqlx::mysql::MySqlRow, i: usize) -> serde_json::Value {
|
||||
use sqlx::Row;
|
||||
if let Ok(b) = row.try_get::<Vec<u8>, _>(i) {
|
||||
return serde_json::Value::String(String::from_utf8_lossy(&b).into_owned());
|
||||
}
|
||||
mysql_text_to_json(row, i)
|
||||
}
|
||||
|
||||
/// Conservative fallback for a type we do not recognise — exactly the three
|
||||
/// decoders that were safe before this change, so an unknown type can never
|
||||
/// be worse than before (`GEOMETRY` lands here and stays null).
|
||||
fn mysql_unknown_to_json(row: &sqlx::mysql::MySqlRow, i: usize) -> serde_json::Value {
|
||||
use sqlx::Row;
|
||||
if let Ok(v) = row.try_get::<serde_json::Value, _>(i) {
|
||||
return v;
|
||||
}
|
||||
mysql_text_to_json(row, i)
|
||||
}
|
||||
|
||||
async fn execute_mysql_query_on(
|
||||
conn: &mut sqlx::mysql::MySqlConnection,
|
||||
query: &str,
|
||||
@@ -716,7 +885,33 @@ async fn execute_mysql_raw(
|
||||
// deliberately omitted because re-running a script would re-execute DML);
|
||||
// DML/DDL produce notices; the first error stops the run, prior sets survive.
|
||||
|
||||
const MAX_STATEMENTS: usize = 50;
|
||||
/// Hard safety cap for an interactive multi-statement Run. Raised from 50
|
||||
/// because opening a `.sql` file and pressing Run is now a supported flow, and
|
||||
/// 50 rejected anything but toy scripts.
|
||||
pub(crate) const MAX_STATEMENTS: usize = 10_000;
|
||||
|
||||
/// Cap for the deliberate bulk operation (Tools → Restore). Far higher than
|
||||
/// the interactive cap because it is an explicit, confirmed, cancellable
|
||||
/// operation bounded by the 100 MB file cap rather than by a keystroke.
|
||||
pub(crate) const MAX_RESTORE_STATEMENTS: usize = 1_000_000;
|
||||
|
||||
/// Enforce a statement budget. `bulk` selects the restore wording: the
|
||||
/// interactive path tells the user where large scripts *should* run, while the
|
||||
/// bulk path must not point at a tool the user is already using.
|
||||
pub(crate) fn check_statement_budget(count: usize, cap: usize, bulk: bool) -> Result<(), String> {
|
||||
if count <= cap {
|
||||
return Ok(());
|
||||
}
|
||||
if bulk {
|
||||
Err(format!(
|
||||
"Script has {count} statements, exceeding the {cap} statement limit for a restore"
|
||||
))
|
||||
} else {
|
||||
Err(format!(
|
||||
"Too many statements: {count} (max {cap}). For large scripts use Tools → Restore."
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) enum StatementOutcome {
|
||||
Set(QueryResult),
|
||||
@@ -1135,12 +1330,7 @@ pub async fn execute_query_multi(
|
||||
if statements.is_empty() {
|
||||
return Err("Query cannot be empty".to_string());
|
||||
}
|
||||
if statements.len() > MAX_STATEMENTS {
|
||||
return Err(format!(
|
||||
"Too many statements: {} (max {MAX_STATEMENTS})",
|
||||
statements.len()
|
||||
));
|
||||
}
|
||||
check_statement_budget(statements.len(), MAX_STATEMENTS, false)?;
|
||||
|
||||
let mut result_sets: Vec<QueryResult> = Vec::new();
|
||||
let mut notices: Vec<StatementNotice> = Vec::new();
|
||||
@@ -1346,6 +1536,10 @@ pub async fn cancel_query(
|
||||
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))
|
||||
}
|
||||
Some(crate::cancel::CancelHandle::MySql(m)) => {
|
||||
// Record the request before the KILL lands: the in-process restore
|
||||
// engine polls this flag between statements, and the killed
|
||||
// statement's error is the only other signal.
|
||||
state.cancel_registry.mark_cancelled(&connection_id);
|
||||
let id = m
|
||||
.conn_id
|
||||
.ok_or_else(|| "No active query on this connection".to_string())?;
|
||||
|
||||
@@ -132,3 +132,238 @@ async fn pg_multi_statement_live() {
|
||||
|
||||
let _ = handle;
|
||||
}
|
||||
|
||||
// ── sqlx type-feature coverage ──────────────────────────────────────
|
||||
|
||||
/// Connect to the optional live MySQL test server. Returns `None` when the
|
||||
/// env vars are absent so the whole suite stays green without a server
|
||||
/// (matching the existing `#[ignore]`d integration-test convention).
|
||||
pub(crate) async fn mysql_test_connection() -> Option<sqlx::mysql::MySqlConnection> {
|
||||
use sqlx::ConnectOptions;
|
||||
let host = std::env::var("GRIDLINE_TEST_MYSQL_HOST").ok()?;
|
||||
let port: u16 = std::env::var("GRIDLINE_TEST_MYSQL_PORT")
|
||||
.ok()?
|
||||
.parse()
|
||||
.ok()?;
|
||||
let user = std::env::var("GRIDLINE_TEST_MYSQL_USER").unwrap_or_else(|_| "root".into());
|
||||
let pass = std::env::var("GRIDLINE_TEST_MYSQL_PASS").unwrap_or_default();
|
||||
let db = std::env::var("GRIDLINE_TEST_MYSQL_DB").ok()?;
|
||||
sqlx::mysql::MySqlConnectOptions::new()
|
||||
.host(&host)
|
||||
.port(port)
|
||||
.username(&user)
|
||||
.password(&pass)
|
||||
.database(&db)
|
||||
.ssl_mode(sqlx::mysql::MySqlSslMode::Disabled)
|
||||
.disable_statement_logging()
|
||||
.connect()
|
||||
.await
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn mysql_decimal_and_date_decode_with_features_enabled() {
|
||||
use sqlx::Row;
|
||||
let Some(mut conn) = mysql_test_connection().await else { return };
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TEMPORARY TABLE gl_feature_probe (
|
||||
c_decimal DECIMAL(20,4), c_date DATE, c_datetime DATETIME(6) )",
|
||||
)
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
.expect("create temporary table");
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO gl_feature_probe VALUES (
|
||||
12345678901234.5678, '2024-01-15', '2024-01-15 10:30:00.123456' )",
|
||||
)
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
.expect("insert");
|
||||
|
||||
let row = sqlx::query("SELECT * FROM gl_feature_probe")
|
||||
.fetch_one(&mut conn)
|
||||
.await
|
||||
.expect("select");
|
||||
|
||||
let dec: sqlx::types::BigDecimal = row.try_get(0).expect("DECIMAL must decode");
|
||||
assert_eq!(dec.to_string(), "12345678901234.5678");
|
||||
|
||||
let date: chrono::NaiveDate = row.try_get(1).expect("DATE must decode");
|
||||
assert_eq!(date, chrono::NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
|
||||
|
||||
let dt: chrono::NaiveDateTime = row.try_get(2).expect("DATETIME(6) must decode");
|
||||
assert_eq!(dt.to_string(), "2024-01-15 10:30:00.123456");
|
||||
}
|
||||
|
||||
// ── mysql_cell_kind (pure) ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn mysql_cell_kind_maps_every_type_name_sqlx_can_report() {
|
||||
use MysqlCellKind::*;
|
||||
// Every string here was observed from a live MySQL 8 server during the
|
||||
// architect phase (ColumnType::name(flags, max_size)).
|
||||
let cases: &[(&str, MysqlCellKind)] = &[
|
||||
("BOOLEAN", Int { bits: 8, signed: true }),
|
||||
("TINYINT", Int { bits: 8, signed: true }),
|
||||
("TINYINT UNSIGNED", Int { bits: 8, signed: false }),
|
||||
("SMALLINT", Int { bits: 16, signed: true }),
|
||||
("SMALLINT UNSIGNED", Int { bits: 16, signed: false }),
|
||||
("MEDIUMINT", Int { bits: 24, signed: true }),
|
||||
("MEDIUMINT UNSIGNED", Int { bits: 24, signed: false }),
|
||||
("INT", Int { bits: 32, signed: true }),
|
||||
("INT UNSIGNED", Int { bits: 32, signed: false }),
|
||||
("BIGINT", Int { bits: 64, signed: true }),
|
||||
("BIGINT UNSIGNED", Int { bits: 64, signed: false }),
|
||||
("YEAR", Int { bits: 16, signed: false }),
|
||||
("BIT", Bit),
|
||||
("FLOAT", Float),
|
||||
("DOUBLE", Double),
|
||||
("DECIMAL", Decimal),
|
||||
("DATE", Date),
|
||||
("TIME", Time),
|
||||
("DATETIME", DateTime),
|
||||
("TIMESTAMP", DateTime),
|
||||
("JSON", Json),
|
||||
("CHAR", Text),
|
||||
("VARCHAR", Text),
|
||||
("TEXT", Text),
|
||||
("TINYTEXT", Text),
|
||||
("MEDIUMTEXT", Text),
|
||||
("LONGTEXT", Text),
|
||||
("ENUM", Text),
|
||||
("SET", Text),
|
||||
("BINARY", Bytes),
|
||||
("VARBINARY", Bytes),
|
||||
("BLOB", Bytes),
|
||||
("TINYBLOB", Bytes),
|
||||
("MEDIUMBLOB", Bytes),
|
||||
("LONGBLOB", Bytes),
|
||||
("NULL", Null),
|
||||
("GEOMETRY", Unknown),
|
||||
("SOMETHING_NEW", Unknown),
|
||||
];
|
||||
for (name, expected) in cases {
|
||||
assert_eq!(&mysql_cell_kind(name), expected, "type name {name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_cell_kind_is_case_insensitive_and_trims() {
|
||||
assert_eq!(mysql_cell_kind(" bigint unsigned "), MysqlCellKind::Int { bits: 64, signed: false });
|
||||
assert_eq!(mysql_cell_kind("Datetime"), MysqlCellKind::DateTime);
|
||||
}
|
||||
|
||||
// ── mysql_cell_to_json: full type coverage (regression for #41) ─────
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn mysql_cell_to_json_decodes_every_type() {
|
||||
use sqlx::Row;
|
||||
let Some(mut conn) = mysql_test_connection().await else { return };
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TEMPORARY TABLE gl_decode_probe (
|
||||
c_tiny TINYINT, c_tiny_u TINYINT UNSIGNED, c_bool BOOLEAN,
|
||||
c_small SMALLINT, c_small_u SMALLINT UNSIGNED, c_medium MEDIUMINT,
|
||||
c_int INT, c_int_u INT UNSIGNED, c_big BIGINT, c_big_u BIGINT UNSIGNED,
|
||||
c_float FLOAT, c_double DOUBLE, c_decimal DECIMAL(10,2), c_bit BIT(8),
|
||||
c_year YEAR, c_date DATE, c_time TIME, c_datetime DATETIME,
|
||||
c_datetime6 DATETIME(6), c_timestamp TIMESTAMP NULL,
|
||||
c_char CHAR(4), c_varchar VARCHAR(20), c_text TEXT,
|
||||
c_binary BINARY(4), c_varbinary VARBINARY(8), c_blob BLOB,
|
||||
c_json JSON, c_enum ENUM('a','b'), c_set SET('x','y'),
|
||||
c_geometry GEOMETRY )",
|
||||
)
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
.expect("create temporary table");
|
||||
|
||||
// c_int is 70000 so a 16-bit rung cannot hold it; c_big is small so a
|
||||
// value-dependent chain would wrongly emit a number instead of a string.
|
||||
sqlx::query(
|
||||
"INSERT INTO gl_decode_probe VALUES (
|
||||
1, 2, 1, 5, 6, 7, 70000, 9, 300, 4294967296,
|
||||
11.5, 12.25, 13.75, b'10101010', 2024,
|
||||
'2024-01-15', '10:30:00', '2024-01-15 10:30:00',
|
||||
'2024-01-15 10:30:00.123456', '2024-01-15 10:30:00',
|
||||
'abcd', 'hello', 'text value', 'WXYZ', 0x0102, 0x01020304,
|
||||
JSON_OBJECT('k','v'), 'a', 'x,y', ST_GeomFromText('POINT(1 2)') )",
|
||||
)
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
.expect("insert");
|
||||
|
||||
let row = sqlx::query("SELECT * FROM gl_decode_probe")
|
||||
.fetch_one(&mut conn)
|
||||
.await
|
||||
.expect("select");
|
||||
|
||||
use serde_json::{json, Value};
|
||||
let expected: Vec<(&str, Value)> = vec![
|
||||
("c_tiny", json!(1)),
|
||||
("c_tiny_u", json!(2)),
|
||||
("c_bool", json!(1)),
|
||||
("c_small", json!(5)),
|
||||
("c_small_u", json!(6)),
|
||||
("c_medium", json!(7)),
|
||||
("c_int", json!(70000)),
|
||||
("c_int_u", json!(9)),
|
||||
("c_big", Value::String("300".into())),
|
||||
("c_big_u", Value::String("4294967296".into())),
|
||||
("c_float", json!(11.5)),
|
||||
("c_double", json!(12.25)),
|
||||
("c_decimal", Value::String("13.75".into())),
|
||||
("c_bit", json!(170)),
|
||||
("c_year", json!(2024)),
|
||||
("c_date", Value::String("2024-01-15".into())),
|
||||
("c_time", Value::String("10:30:00".into())),
|
||||
("c_datetime", Value::String("2024-01-15 10:30:00".into())),
|
||||
("c_datetime6", Value::String("2024-01-15 10:30:00.123456".into())),
|
||||
("c_timestamp", Value::String("2024-01-15 10:30:00".into())),
|
||||
("c_char", Value::String("abcd".into())),
|
||||
("c_varchar", Value::String("hello".into())),
|
||||
("c_text", Value::String("text value".into())),
|
||||
("c_binary", Value::String("WXYZ".into())),
|
||||
("c_varbinary", Value::String("\u{1}\u{2}".into())),
|
||||
("c_blob", Value::String("\u{1}\u{2}\u{3}\u{4}".into())),
|
||||
("c_json", json!({"k": "v"})),
|
||||
("c_enum", Value::String("a".into())),
|
||||
("c_set", Value::String("x,y".into())),
|
||||
// GEOMETRY has no sqlx decoder even with chrono/bigdecimal enabled;
|
||||
// staying null is the documented limitation (spec §9).
|
||||
("c_geometry", Value::Null),
|
||||
];
|
||||
|
||||
assert_eq!(row.len(), expected.len(), "column count drifted from the fixture");
|
||||
for (i, (name, want)) in expected.iter().enumerate() {
|
||||
assert_eq!(row.columns()[i].name(), *name, "column order drifted");
|
||||
assert_eq!(&mysql_cell_to_json(&row, i), want, "column {name} decoded wrong");
|
||||
}
|
||||
}
|
||||
|
||||
// ── statement-count policy ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn statement_budget_allows_a_count_at_the_cap() {
|
||||
assert!(check_statement_budget(MAX_STATEMENTS, MAX_STATEMENTS, false).is_ok());
|
||||
assert!(check_statement_budget(MAX_RESTORE_STATEMENTS, MAX_RESTORE_STATEMENTS, true).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactive_over_budget_error_points_at_restore() {
|
||||
let err = check_statement_budget(MAX_STATEMENTS + 1, MAX_STATEMENTS, false)
|
||||
.expect_err("must reject");
|
||||
assert!(err.contains("Tools"), "error must route to Tools → Restore; got {err:?}");
|
||||
assert!(err.contains(&(MAX_STATEMENTS + 1).to_string()), "error must state the count; got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_over_budget_error_states_the_limit_without_the_tool_hint() {
|
||||
let err = check_statement_budget(MAX_RESTORE_STATEMENTS + 1, MAX_RESTORE_STATEMENTS, true)
|
||||
.expect_err("must reject");
|
||||
assert!(!err.contains("Tools"), "bulk path is already in Tools; got {err:?}");
|
||||
assert!(err.contains(&MAX_RESTORE_STATEMENTS.to_string()), "got {err:?}");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod introspection;
|
||||
pub mod mysql;
|
||||
pub mod mysql_clean;
|
||||
pub mod sql_split;
|
||||
pub mod object_crud;
|
||||
pub mod object_ddl;
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
//! Planning for a MySQL "clean" restore.
|
||||
//!
|
||||
//! `clean` drops only the objects the dump recreates, matching the UI label
|
||||
//! "Clean — DROP before CREATE" and `pg_restore --clean` semantics: objects in
|
||||
//! the target database that the dump does not mention survive. It is
|
||||
//! deliberately *not* `DROP DATABASE` (spec §4.3).
|
||||
//!
|
||||
//! Pure by design: takes already-split statements, returns the DROP script.
|
||||
|
||||
use crate::db::mysql::mysql_quote_ident;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ObjectKind {
|
||||
Table,
|
||||
View,
|
||||
Procedure,
|
||||
Function,
|
||||
Trigger,
|
||||
Event,
|
||||
}
|
||||
|
||||
impl ObjectKind {
|
||||
fn keyword(self) -> &'static str {
|
||||
match self {
|
||||
ObjectKind::Table => "TABLE",
|
||||
ObjectKind::View => "VIEW",
|
||||
ObjectKind::Procedure => "PROCEDURE",
|
||||
ObjectKind::Function => "FUNCTION",
|
||||
ObjectKind::Trigger => "TRIGGER",
|
||||
ObjectKind::Event => "EVENT",
|
||||
}
|
||||
}
|
||||
|
||||
/// Dependents first, tables last, so the drop phase reads predictably.
|
||||
/// Foreign-key ordering is handled by the caller disabling
|
||||
/// `FOREIGN_KEY_CHECKS`, so this is about clarity, not correctness.
|
||||
fn order(self) -> u8 {
|
||||
match self {
|
||||
ObjectKind::Trigger => 0,
|
||||
ObjectKind::Event => 1,
|
||||
ObjectKind::Procedure => 2,
|
||||
ObjectKind::Function => 3,
|
||||
ObjectKind::View => 4,
|
||||
ObjectKind::Table => 5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tokens that may appear between the object keyword and its name.
|
||||
const MODIFIERS: &[&str] = &[
|
||||
"OR", "REPLACE", "TEMPORARY", "IF", "NOT", "EXISTS", "AGGREGATE", "SQL", "SECURITY",
|
||||
"DEFINER", "ALGORITHM", "UNDEFINED", "MERGE", "TEMPTABLE", "CASCADED", "LOCAL", "INVOKER",
|
||||
"=",
|
||||
];
|
||||
|
||||
/// Build the ordered `DROP … IF EXISTS` script for a clean restore.
|
||||
/// Returns an empty vector when no object can be identified (fail closed).
|
||||
pub fn plan_clean_drops(default_schema: &str, statements: &[String]) -> Vec<String> {
|
||||
let mut found: Vec<(ObjectKind, String)> = Vec::new();
|
||||
for stmt in statements {
|
||||
if let Some((kind, name)) = parse_create_target(stmt) {
|
||||
let qualified = qualify(default_schema, &name);
|
||||
if !found.iter().any(|(k, n)| *k == kind && *n == qualified) {
|
||||
found.push((kind, qualified));
|
||||
}
|
||||
}
|
||||
}
|
||||
found.sort_by_key(|(k, _)| k.order());
|
||||
found
|
||||
.into_iter()
|
||||
.map(|(kind, name)| format!("DROP {} IF EXISTS {}", kind.keyword(), name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Extract `(kind, raw name)` from a `CREATE …` statement, or `None`.
|
||||
fn parse_create_target(stmt: &str) -> Option<(ObjectKind, String)> {
|
||||
let tokens = unwrap_version_comments(stmt);
|
||||
let tokens = tokenize(&tokens);
|
||||
let mut it = tokens.iter().peekable();
|
||||
while let Some(tok) = it.next() {
|
||||
if !tok.eq_ignore_ascii_case("CREATE") {
|
||||
continue;
|
||||
}
|
||||
// Walk forward until an object keyword is found.
|
||||
while let Some(t) = it.next() {
|
||||
let upper = t.to_ascii_uppercase();
|
||||
let kind = match upper.as_str() {
|
||||
"TABLE" => ObjectKind::Table,
|
||||
"VIEW" => ObjectKind::View,
|
||||
"PROCEDURE" => ObjectKind::Procedure,
|
||||
"FUNCTION" => ObjectKind::Function,
|
||||
"TRIGGER" => ObjectKind::Trigger,
|
||||
"EVENT" => ObjectKind::Event,
|
||||
_ => continue,
|
||||
};
|
||||
// The first following token that is neither a modifier nor an
|
||||
// `x=y` clause is the object name.
|
||||
for cand in it.by_ref() {
|
||||
let up = cand.to_ascii_uppercase();
|
||||
if MODIFIERS.contains(&up.as_str()) || cand.contains('=') {
|
||||
continue;
|
||||
}
|
||||
// A routine's name is followed by its argument list with no
|
||||
// space (`p`()`, hence a single token); strip that trailing
|
||||
// `()` so the DROP names the routine, not its signature. This
|
||||
// must be a *string* suffix — `trim_end_matches('(')` (a char
|
||||
// pattern) strips nothing from `p`()`.
|
||||
let name = cand.strip_suffix("()").unwrap_or(cand).to_string();
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Fail closed: a token that is not a plausible identifier means
|
||||
// we cannot name the object, so plan no DROP rather than emit a
|
||||
// bogus one. `CREATE TABLE (id int)` tokenizes as
|
||||
// `["CREATE", "TABLE", "(id", "int)"]`, and `(id` is a column
|
||||
// definition fragment, not a table name.
|
||||
if !is_plausible_identifier(&name) {
|
||||
return None;
|
||||
}
|
||||
return Some((kind, name));
|
||||
}
|
||||
return None;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Reject candidates that are clearly not object names. A `(`, `)`, `,` or `;`
|
||||
/// outside a backtick-quoted region means the token is part of an argument
|
||||
/// list, a column definition, or a statement terminator — not an identifier.
|
||||
fn is_plausible_identifier(cand: &str) -> bool {
|
||||
if cand.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut in_backtick = false;
|
||||
for ch in cand.chars() {
|
||||
match ch {
|
||||
'`' => in_backtick = !in_backtick,
|
||||
'(' | ')' | ',' | ';' if !in_backtick => return false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Replace `/*!50003 … */` executable comments with their inner text — the
|
||||
/// shape mysqldump uses to wrap routine and trigger definitions.
|
||||
fn unwrap_version_comments(stmt: &str) -> String {
|
||||
let mut out = String::with_capacity(stmt.len());
|
||||
let chars: Vec<char> = stmt.chars().collect();
|
||||
let mut i = 0usize;
|
||||
while i < chars.len() {
|
||||
if chars[i] == '/' && i + 2 < chars.len() && chars[i + 1] == '*' && chars[i + 2] == '!' {
|
||||
// skip `/*!` and any leading digits
|
||||
let mut j = i + 3;
|
||||
while j < chars.len() && chars[j].is_ascii_digit() {
|
||||
j += 1;
|
||||
}
|
||||
out.push(' ');
|
||||
while j < chars.len()
|
||||
&& !(chars[j] == '*' && j + 1 < chars.len() && chars[j + 1] == '/')
|
||||
{
|
||||
out.push(chars[j]);
|
||||
j += 1;
|
||||
}
|
||||
out.push(' ');
|
||||
i = (j + 2).min(chars.len());
|
||||
} else {
|
||||
out.push(chars[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Split on whitespace, treating backtick-quoted regions as part of one token.
|
||||
fn tokenize(s: &str) -> Vec<String> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut cur = String::new();
|
||||
let mut in_backtick = false;
|
||||
for ch in s.chars() {
|
||||
match ch {
|
||||
'`' => {
|
||||
in_backtick = !in_backtick;
|
||||
cur.push(ch);
|
||||
}
|
||||
c if c.is_whitespace() && !in_backtick => {
|
||||
if !cur.is_empty() {
|
||||
tokens.push(std::mem::take(&mut cur));
|
||||
}
|
||||
}
|
||||
c => cur.push(c),
|
||||
}
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
tokens.push(cur);
|
||||
}
|
||||
tokens
|
||||
}
|
||||
|
||||
/// `a` -> `schema`.`a`; `db`.`a` / `db.a` -> `db`.`a`.
|
||||
fn qualify(default_schema: &str, name: &str) -> String {
|
||||
let parts: Vec<String> = name
|
||||
.split('.')
|
||||
.map(|p| mysql_quote_ident(strip_backticks(p)))
|
||||
.collect();
|
||||
if parts.len() == 1 {
|
||||
format!("{}.{}", mysql_quote_ident(default_schema), parts[0])
|
||||
} else {
|
||||
parts.join(".")
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_backticks(s: &str) -> &str {
|
||||
s.trim_matches('`')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mysql_clean.test.rs"]
|
||||
mod mysql_clean_tests;
|
||||
@@ -0,0 +1,78 @@
|
||||
use super::*;
|
||||
|
||||
fn plan(sql: &str) -> Vec<String> {
|
||||
plan_clean_drops("shop", &crate::db::sql_split::split_statements(sql))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plans_a_drop_for_each_recreated_table() {
|
||||
let got = plan("CREATE TABLE `orders` (id int);\nCREATE TABLE IF NOT EXISTS users (id int);");
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
"DROP TABLE IF EXISTS `shop`.`orders`".to_string(),
|
||||
"DROP TABLE IF EXISTS `shop`.`users`".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qualifies_unqualified_names_with_the_target_schema() {
|
||||
assert_eq!(
|
||||
plan("CREATE TABLE orders (id int);"),
|
||||
vec!["DROP TABLE IF EXISTS `shop`.`orders`".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_an_explicit_schema_qualifier() {
|
||||
assert_eq!(
|
||||
plan("CREATE TABLE `other`.`orders` (id int);"),
|
||||
vec!["DROP TABLE IF EXISTS `other`.`orders`".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unwraps_mysqldump_version_comments_for_routines_and_triggers() {
|
||||
// This is the shape mysqldump actually emits for routines/triggers.
|
||||
let got = plan(
|
||||
"/*!50003 CREATE*/ /*!50020 DEFINER=`root`@`localhost`*/ /*!50003 PROCEDURE `p`() BEGIN END */;;\n\
|
||||
/*!50003 CREATE*/ /*!50017 DEFINER=`root`@`localhost`*/ /*!50003 TRIGGER `trg` BEFORE INSERT ON `orders` FOR EACH ROW BEGIN END */;;",
|
||||
);
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
"DROP TRIGGER IF EXISTS `shop`.`trg`".to_string(),
|
||||
"DROP PROCEDURE IF EXISTS `shop`.`p`".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deduplicates_repeated_objects() {
|
||||
let got = plan("CREATE TABLE a (id int);\nCREATE TABLE a (id int);");
|
||||
assert_eq!(got, vec!["DROP TABLE IF EXISTS `shop`.`a`".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orders_dependents_before_tables() {
|
||||
let got = plan("CREATE TABLE t (id int);\nCREATE VIEW v AS SELECT 1;\nCREATE TRIGGER g BEFORE INSERT ON t FOR EACH ROW BEGIN END;");
|
||||
let kinds: Vec<&str> = got
|
||||
.iter()
|
||||
.map(|s| s.split_whitespace().nth(1).unwrap())
|
||||
.collect();
|
||||
assert_eq!(kinds, vec!["TRIGGER", "VIEW", "TABLE"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_create_statements() {
|
||||
let got = plan("INSERT INTO t VALUES (1);\nDROP TABLE x;\nALTER TABLE t ADD c int;\nSELECT 1;");
|
||||
assert!(got.is_empty(), "got {got:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_a_create_whose_name_cannot_be_determined() {
|
||||
// Fail closed: if we cannot name the object we must not guess at a DROP.
|
||||
let got = plan("CREATE TABLE (id int);");
|
||||
assert!(got.is_empty(), "got {got:?}");
|
||||
}
|
||||
@@ -19,9 +19,24 @@ pub fn split_statements(sql: &str) -> Vec<String> {
|
||||
let mut stmts: Vec<String> = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut i = 0usize;
|
||||
// MySQL's client-side `DELIMITER` directive. Default matches the SQL
|
||||
// standard terminator so behaviour is unchanged when no directive appears.
|
||||
let mut delimiter: Vec<char> = vec![';'];
|
||||
|
||||
while i < chars.len() {
|
||||
// A `DELIMITER` line is a client directive, not SQL. Only recognised
|
||||
// at the start of a line: quoted regions, block comments and line
|
||||
// comments are copied atomically below, so a directive inside any of
|
||||
// them can never reach this check.
|
||||
if i == 0 || chars[i - 1] == '\n' {
|
||||
if let Some((end, token)) = match_delimiter_directive(&chars, i) {
|
||||
delimiter = token.chars().collect();
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let c = chars[i];
|
||||
let at_delimiter = starts_with(&chars, i, &delimiter);
|
||||
match c {
|
||||
'\'' | '"' | '`' => {
|
||||
copy_quoted(&chars, &mut i, &mut current, c);
|
||||
@@ -32,6 +47,13 @@ pub fn split_statements(sql: &str) -> Vec<String> {
|
||||
'/' if peek(&chars, i + 1) == Some('*') => {
|
||||
copy_block_comment(&chars, &mut i, &mut current);
|
||||
}
|
||||
_ if at_delimiter => {
|
||||
if has_executable_text(¤t) {
|
||||
stmts.push(current.trim().to_string());
|
||||
}
|
||||
current.clear();
|
||||
i += delimiter.len();
|
||||
}
|
||||
'$' => {
|
||||
if let Some(end) = dollar_quote_end(&chars, i) {
|
||||
for _ in i..end {
|
||||
@@ -45,13 +67,6 @@ pub fn split_statements(sql: &str) -> Vec<String> {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
';' => {
|
||||
if has_executable_text(¤t) {
|
||||
stmts.push(current.trim().to_string());
|
||||
}
|
||||
current.clear();
|
||||
i += 1;
|
||||
}
|
||||
_ => {
|
||||
current.push(c);
|
||||
i += 1;
|
||||
@@ -177,6 +192,15 @@ pub fn has_executable_text(stmt: &str) -> bool {
|
||||
skip_until(&chars, &mut i, |ch| ch == '\n');
|
||||
}
|
||||
'/' if peek(&chars, i + 1) == Some('*') => {
|
||||
// MySQL executable comments (`/*! … */`, optionally
|
||||
// version-gated like `/*!50003 … */`) are executed by the
|
||||
// server, so a fragment that is only such a comment is still
|
||||
// a statement. mysqldump wraps routine and trigger definitions
|
||||
// in them with no surrounding SQL. Ordinary block comments
|
||||
// remain skipped.
|
||||
if peek(&chars, i + 2) == Some('!') {
|
||||
return true;
|
||||
}
|
||||
skip_block_comment(&chars, &mut i);
|
||||
}
|
||||
c if c.is_whitespace() => i += 1,
|
||||
@@ -207,6 +231,54 @@ fn skip_block_comment(chars: &[char], i: &mut usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// True when `chars[i..]` begins with `pat`.
|
||||
fn starts_with(chars: &[char], i: usize, pat: &[char]) -> bool {
|
||||
!pat.is_empty() && i + pat.len() <= chars.len() && chars[i..i + pat.len()] == *pat
|
||||
}
|
||||
|
||||
/// Match a MySQL client `DELIMITER <token>` directive that occupies a whole
|
||||
/// line. Returns the index just past the directive's newline and the new
|
||||
/// delimiter token. Conservative: anything other than
|
||||
/// `^[ \t]*DELIMITER[ \t]+<token>[ \t]*$` is left as ordinary text.
|
||||
fn match_delimiter_directive(chars: &[char], i: usize) -> Option<(usize, String)> {
|
||||
let mut j = i;
|
||||
while j < chars.len() && matches!(chars[j], ' ' | '\t') {
|
||||
j += 1;
|
||||
}
|
||||
for kw in ['D', 'E', 'L', 'I', 'M', 'I', 'T', 'E', 'R'] {
|
||||
if j >= chars.len() || chars[j].to_ascii_uppercase() != kw {
|
||||
return None;
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
if j >= chars.len() || !matches!(chars[j], ' ' | '\t') {
|
||||
return None; // `DELIMITERx` is not a directive
|
||||
}
|
||||
while j < chars.len() && matches!(chars[j], ' ' | '\t') {
|
||||
j += 1;
|
||||
}
|
||||
let start = j;
|
||||
while j < chars.len() && !matches!(chars[j], ' ' | '\t' | '\r' | '\n') {
|
||||
j += 1;
|
||||
}
|
||||
if start == j {
|
||||
return None; // no token
|
||||
}
|
||||
let token: String = chars[start..j].iter().collect();
|
||||
// The rest of the line must be blank, otherwise this is not a directive.
|
||||
let mut k = j;
|
||||
while k < chars.len() && matches!(chars[k], ' ' | '\t') {
|
||||
k += 1;
|
||||
}
|
||||
if k < chars.len() && !matches!(chars[k], '\r' | '\n') {
|
||||
return None;
|
||||
}
|
||||
while k < chars.len() && matches!(chars[k], '\r' | '\n') {
|
||||
k += 1;
|
||||
}
|
||||
Some((k, token))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sql_split.test.rs"]
|
||||
mod sql_split_tests;
|
||||
|
||||
@@ -84,3 +84,58 @@ fn empty_and_whitespace_input() {
|
||||
assert_eq!(split_statements(""), Vec::<String>::new());
|
||||
assert_eq!(split_statements(" ; ; "), Vec::<String>::new());
|
||||
}
|
||||
|
||||
// ── MySQL client DELIMITER directive ────────────────────────────────
|
||||
//
|
||||
// NOTE: the splitter strips the active statement terminator, exactly as the
|
||||
// pre-existing tests above assert (`splits_two_statements` -> ["SELECT 1",
|
||||
// "SELECT 2"]). The expectations below therefore never contain a trailing
|
||||
// `;`/delimiter, matching that established contract.
|
||||
|
||||
#[test]
|
||||
fn delimiter_directive_changes_statement_terminator() {
|
||||
let sql = "DELIMITER //\nCREATE PROCEDURE p()\nBEGIN\n SELECT 1;\nEND//\nDELIMITER ;\nSELECT 2;";
|
||||
let stmts = split_statements(sql);
|
||||
assert_eq!(stmts.len(), 2, "got {stmts:?}");
|
||||
assert!(stmts[0].starts_with("CREATE PROCEDURE p()"), "got {:?}", stmts[0]);
|
||||
// The inner `;` must NOT have split the body.
|
||||
assert!(stmts[0].contains("SELECT 1;"), "body was split: {:?}", stmts[0]);
|
||||
assert!(!stmts[0].contains("DELIMITER"), "directive leaked into statement: {:?}", stmts[0]);
|
||||
assert_eq!(stmts[1], "SELECT 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delimiter_directive_is_case_and_space_tolerant() {
|
||||
let sql = " delimiter $$\n SELECT 1$$\n Delimiter ;\nSELECT 2;";
|
||||
let stmts = split_statements(sql);
|
||||
assert_eq!(stmts, vec!["SELECT 1".to_string(), "SELECT 2".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delimiter_text_inside_a_string_is_not_a_directive() {
|
||||
let stmts = split_statements("SELECT 'DELIMITER //';\nSELECT 2;");
|
||||
assert_eq!(stmts.len(), 2);
|
||||
assert_eq!(stmts[0], "SELECT 'DELIMITER //'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delimiter_text_inside_a_line_comment_is_not_a_directive() {
|
||||
let stmts = split_statements("-- DELIMITER //\nSELECT 1;");
|
||||
assert_eq!(stmts.len(), 1);
|
||||
assert!(stmts[0].contains("SELECT 1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delimiter_with_trailing_junk_is_ordinary_text() {
|
||||
let stmts = split_statements("DELIMITER // extra\nSELECT 1;");
|
||||
assert_eq!(stmts.len(), 1);
|
||||
assert!(stmts[0].contains("DELIMITER // extra"), "got {:?}", stmts[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_semicolon_is_ordinary_text_under_a_custom_delimiter() {
|
||||
let stmts = split_statements("DELIMITER //\nSELECT 1; SELECT 2//\nDELIMITER ;\nSELECT 3;");
|
||||
assert_eq!(stmts.len(), 2);
|
||||
assert!(stmts[0].contains("SELECT 1; SELECT 2"), "got {:?}", stmts[0]);
|
||||
assert_eq!(stmts[1], "SELECT 3");
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use commands::ssh::{Ssh2Backend, SshTunnelManager};
|
||||
use db::pool::ConnectionPoolManager;
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
use store::Store;
|
||||
use tauri::Manager;
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
pub struct AppState {
|
||||
pub db_store: StdMutex<Store>,
|
||||
@@ -39,11 +39,32 @@ pub fn run() {
|
||||
// another provider is already installed).
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
tauri::Builder::default()
|
||||
let builder = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_keyring_store::init())
|
||||
// OS "open with" paths are queued here before the webview exists.
|
||||
.manage(crate::commands::open_files::PendingOpenFiles::default());
|
||||
|
||||
// Shadowed, not `mut`: on macOS this cfg block is compiled out, so a
|
||||
// mutable binding would only earn an `unused_mut` warning. The plugin
|
||||
// itself stays a Windows/Linux-only, target-scoped dependency.
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
let builder = builder.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
|
||||
let pending = app.state::<crate::commands::open_files::PendingOpenFiles>();
|
||||
let mut accepted = false;
|
||||
for arg in argv.iter().skip(1) {
|
||||
if crate::commands::open_files::accept_path(&pending, arg) {
|
||||
accepted = true;
|
||||
}
|
||||
}
|
||||
if accepted {
|
||||
let _ = app.emit("sql-file-opened", ());
|
||||
}
|
||||
}));
|
||||
|
||||
builder
|
||||
.setup(move |app| {
|
||||
// Open the local store under the OS app-data directory. When the
|
||||
// app is launched from Finder/LaunchServices the working directory
|
||||
@@ -59,6 +80,14 @@ pub fn run() {
|
||||
let store =
|
||||
Store::open(&data_dir.join("gridline.db").to_string_lossy()).expect("failed to open db");
|
||||
|
||||
// Cold-start "open with": the path arrived as a process argument
|
||||
// before the webview existed, so queue it here. macOS uses
|
||||
// RunEvent::Opened instead.
|
||||
for arg in std::env::args().skip(1) {
|
||||
let pending = app.state::<crate::commands::open_files::PendingOpenFiles>();
|
||||
let _ = crate::commands::open_files::accept_path(&pending, &arg);
|
||||
}
|
||||
|
||||
app.manage(AppState {
|
||||
db_store: StdMutex::new(store),
|
||||
pool_manager: tokio::sync::Mutex::new(ConnectionPoolManager::new()),
|
||||
@@ -178,6 +207,7 @@ pub fn run() {
|
||||
query::execute_query_multi,
|
||||
query_export::export_query_to_file,
|
||||
query::cancel_query,
|
||||
crate::commands::open_files::take_pending_sql_files,
|
||||
query::get_query_history,
|
||||
query::clear_query_history,
|
||||
query::set_history_favorite,
|
||||
@@ -188,16 +218,34 @@ pub fn run() {
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app_handle, event| {
|
||||
.run(|app_handle, event| match event {
|
||||
// macOS: Finder / LaunchServices "open with". File URLs only.
|
||||
#[cfg(target_os = "macos")]
|
||||
tauri::RunEvent::Opened { urls } => {
|
||||
let pending = app_handle
|
||||
.state::<crate::commands::open_files::PendingOpenFiles>();
|
||||
let mut accepted = false;
|
||||
for url in urls {
|
||||
if let Ok(path) = url.to_file_path() {
|
||||
if crate::commands::open_files::accept_path(
|
||||
&pending,
|
||||
&path.to_string_lossy(),
|
||||
) {
|
||||
accepted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if accepted {
|
||||
let _ = app_handle.emit("sql-file-opened", ());
|
||||
}
|
||||
}
|
||||
// Close all SSH tunnels on exit: ExitRequested fires before the
|
||||
// event loop ends, Exit fires after it has.
|
||||
if matches!(
|
||||
event,
|
||||
tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit
|
||||
) {
|
||||
tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit => {
|
||||
if let Ok(mut mgr) = app_handle.state::<AppState>().ssh_manager.lock() {
|
||||
mgr.close_all();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user