feat(mysql): native SQL execution — fix #41 type decoding, in-process restore, .sql open-with (#42)

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:
2026-09-29 08:52:47 +08:00
committed by GitHub
parent ce4a73b0bb
commit 88e7e45818
36 changed files with 2265 additions and 141 deletions
+57
View File
@@ -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")
+259 -32
View File
@@ -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(&params, &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);
});
+112 -9
View File
@@ -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:?}");
}
+105 -25
View File
@@ -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 ?`.
+1
View File
@@ -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");
}
+213 -19
View File
@@ -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
View File
@@ -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;
+220
View File
@@ -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:?}");
}
+79 -7
View File
@@ -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(&current) {
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(&current) {
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");
}
+55 -7
View File
@@ -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();
}
}
_ => {}
});
}