v0.7.8: MySQL/SQLite backup-sync, Excel export, query cancel, settings import/export, Windows title-bar fix, SQLite table editor (#16)

* [P1-T1] feat(backup): MySQL/SQLite backup models + db_type on SyncOptions (Task 1.1)

* [P1-T2] feat: enable tools(mysql,sqlite) + tableManagement(sqlite) + add fflate (Task 1.2)

* [P1-T3] feat(cancel): CancelHandle enum + CancelRegistry (Task 1.3)

* [P2-T1] feat(export): hand-rolled XLSX writer with inline-string cells (Task 2.1)

* [P2-T2] feat(backup): SQLite .dump/restore/sync core, fail-closed virtual tables (Task 2.2)

* [P2-T3] feat(backup): MySQL dump/restore/sync arg builders + tool resolution (Task 2.3)

* [P2-T4] feat(settings): pure import validator + SettingsExport envelope + Store.apply_settings (Task 2.4)

* [P2-T5] feat(table-editor): SQLite create/diff/rebuild SQL generation, fail-closed AUTOINCREMENT (Task 2.5)

* [P3-T1] feat(commands): MySQL/SQLite backup + settings export/import commands + wrappers (Task 3.1)

* [P3-T2] feat(cancel): capture cancel primitives at connect; cancel_query command; SQLite interrupt test (Task 3.2)

* [P3-T3] feat(table-editor): SQLite object-change dispatch + execute_change Ddl/RebuildTable (Task 3.3)

* [P4-T1] feat(tools): DB-aware backup/restore/sync pages (Task 4.1)

* [P4-T2] feat(export): xlsx export in grid toolbar + overflow menu (Task 4.2)

* [P4-T3] feat(query): cancel button wired to cancelQuery (Task 4.3)

* [P4-T4] feat(settings): export/import buttons + validation gate (Task 4.4)

* [P4-T5] fix(ui): gate macOS overlay drag strip to macOS only (Task 4.5)

* [P4-T6] feat(table-editor): SQLite Create/Edit Table mode (Task 4.6)

* [P5-T1] chore: bump 0.7.7 -> 0.7.8 + README/AGENTS/ROADMAP status (Task 5.1)

* [P5-T2] build(release): bundle mariadb-dump + mariadb client (system-first fallback) (Task 5.2)

* fix(cancel): propagate cancellations past wrapped->raw fallback (SQLite/PG/MySQL) + MySQL CONNECTION_ID cast

* fix(export): Excel export from overflow menu did nothing + add export success/error toasts

* fix(export): tree kebab export fetches table data when rows not loaded

* docs(readme): surface v0.7.8 features (MySQL/SQLite backup-sync, Excel export, query cancel, SQLite table editor, settings import/export)
This commit is contained in:
2026-08-08 00:09:36 +08:00
committed by GitHub
parent 9fb3222956
commit 32a7b852ec
59 changed files with 4068 additions and 572 deletions
+1 -1
View File
@@ -1783,7 +1783,7 @@ dependencies = [
[[package]]
name = "gridline"
version = "0.7.7"
version = "0.7.8"
dependencies = [
"chrono",
"deadpool-postgres",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "gridline"
version = "0.7.7"
version = "0.7.8"
description = "An open-source, high-performance database GUI client for PostgreSQL and beyond"
authors = ["you"]
edition = "2021"
+13
View File
@@ -0,0 +1,13 @@
# Bundled MariaDB client tools
Static `mariadb-dump` and `mariadb` client binaries (wire-compatible with MySQL
servers) go here. They are NOT committed — `.github/workflows/release.yml`
builds and checksum-verifies them per matrix target, copying the result into
`resources/mysql_tools/{mariadb-dump,mariadb}` (plus `mariadb-dump.exe` /
`mariadb.exe` and the runtime `libmariadb.dll` on Windows). The app resolves
tools system-first and falls back to these bundled binaries (see
`backup::resolve_mysql_tool`).
In `tauri dev`, this dir is usually empty — the app falls back to any
`mariadb-dump`/`mariadb` on `PATH` (system-first resolution). MySQL
backup/restore degrades with a clear error if neither is present.
+164
View File
@@ -0,0 +1,164 @@
//! 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::{Arc, Mutex};
use rusqlite::InterruptHandle;
use sqlx::mysql::MySqlConnectOptions;
use tokio_postgres::CancelToken;
use crate::db::tls::TlsDecision;
/// PostgreSQL cancel data. `cancel_token` stores the socket address (incl.
/// SSH tunnel endpoint) so `cancel_query` needs no host. `tls_config` is the
/// rustls config used to **build** the connection (or `None` for NoTls) so the
/// cancel connection reuses the exact same TLS decision.
#[derive(Clone)]
pub struct PgCancel {
pub cancel_token: CancelToken,
pub tls_decision: TlsDecision,
pub tls_config: Option<std::sync::Arc<rustls::ClientConfig>>,
}
/// MySQL cancel data. `conn_id` is the `CONNECTION_ID()` of the dedicated
/// connection currently running a query (one active per connection because the
/// pool lock serializes queries). `connect_options` lets `cancel` open a
/// brand-new connection (bypassing the pool) to run `KILL QUERY ?`.
#[derive(Clone)]
pub struct MySqlCancel {
pub conn_id: Option<i64>,
pub connect_options: MySqlConnectOptions,
}
/// SQLite cancel data — a cloneable, thread-safe interrupt handle.
/// (`InterruptHandle` itself is not `Clone` in rusqlite 0.31, so it's kept
/// behind an `Arc`.)
#[derive(Clone)]
pub struct SqliteCancel {
handle: Arc<InterruptHandle>,
}
impl SqliteCancel {
pub fn new(handle: InterruptHandle) -> Self {
Self { handle: Arc::new(handle) }
}
pub fn interrupt(&self) {
self.handle.interrupt();
}
}
#[derive(Clone)]
pub enum CancelHandle {
Pg(PgCancel),
MySql(MySqlCancel),
Sqlite(SqliteCancel),
}
/// Send + Sync registry keyed by connection id. `cancel_query` takes only
/// this `Mutex` (NOT the pool lock).
#[derive(Default)]
pub struct CancelRegistry {
map: Mutex<HashMap<String, CancelHandle>>,
}
impl CancelRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn set_pg(&self, id: &str, c: PgCancel) {
self.set(id, CancelHandle::Pg(c));
}
pub fn set_mysql(&self, id: &str, c: MySqlCancel) {
self.set(id, CancelHandle::MySql(c));
}
pub fn set_sqlite(&self, id: &str, c: SqliteCancel) {
self.set(id, CancelHandle::Sqlite(c));
}
pub fn set_mysql_conn_id(&self, id: &str, conn_id: Option<i64>) {
let mut g = self.map.lock().unwrap();
if let Some(CancelHandle::MySql(m)) = g.get_mut(id) {
m.conn_id = conn_id;
}
}
fn set(&self, id: &str, h: CancelHandle) {
self.map.lock().unwrap().insert(id.to_string(), h);
}
pub fn get(&self, id: &str) -> Option<CancelHandle> {
self.map.lock().unwrap().get(id).cloned()
}
pub fn remove(&self, id: &str) {
self.map.lock().unwrap().remove(id);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sqlite_insert_and_drain() {
let reg = CancelRegistry::new();
let conn = rusqlite::Connection::open_in_memory().unwrap();
let handle = conn.get_interrupt_handle();
reg.set_sqlite("c1", SqliteCancel::new(handle));
assert!(matches!(reg.get("c1"), Some(CancelHandle::Sqlite(_))));
reg.remove("c1");
assert!(reg.get("c1").is_none());
}
#[test]
fn sqlite_interrupt_aborts_running_query() {
use std::sync::{Arc, Mutex};
let conn = rusqlite::Connection::open_in_memory().unwrap();
conn.execute_batch("CREATE TABLE t(n); INSERT INTO t VALUES (0);")
.unwrap();
let handle = conn.get_interrupt_handle();
let conn2 = Arc::new(Mutex::new(conn));
let c = conn2.clone();
let done = Arc::new(Mutex::new(None::<Result<usize, String>>));
let d = done.clone();
let worker = std::thread::spawn(move || {
let l = c.lock().unwrap();
// `query()` binds params only — the first sqlite3_step (where the
// interrupt lands) happens in `rs.next()`, so errors must be
// propagated with `?` rather than swallowed by `is_ok()`.
let r = l
.prepare("WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM c LIMIT 200000000) SELECT count(*) FROM c")
.unwrap()
.query([])
.and_then(|mut rs| {
let mut n = 0;
while rs.next()?.is_some() {
n += 1;
}
Ok(n)
});
*d.lock().unwrap() = Some(r.map_err(|e| e.to_string()));
});
std::thread::sleep(std::time::Duration::from_millis(50));
handle.interrupt();
worker.join().unwrap();
let outcome = done.lock().unwrap().clone();
assert!(
matches!(&outcome, Some(Err(e)) if e.to_lowercase().contains("interrupted")),
"cancelled query must report interrupted; got {outcome:?}"
);
}
#[test]
fn mysql_overwrites_single_active_slot() {
let reg = CancelRegistry::new();
reg.set_mysql("c1", MySqlCancel { conn_id: Some(1), connect_options: fake_opts() });
reg.set_mysql("c1", MySqlCancel { conn_id: Some(2), connect_options: fake_opts() });
match reg.get("c1") {
Some(CancelHandle::MySql(m)) => assert_eq!(m.conn_id, Some(2)),
_ => panic!("expected MySql"),
}
}
fn fake_opts() -> sqlx::mysql::MySqlConnectOptions {
sqlx::mysql::MySqlConnectOptions::new()
.host("127.0.0.1").port(1).username("u").password("p").database("d")
}
}
+621
View File
@@ -1,3 +1,5 @@
use rusqlite::{types::ValueRef, Connection};
use std::io::Write;
use std::process::{Command, Stdio};
use tauri::{AppHandle, Emitter, Manager, State};
@@ -317,6 +319,285 @@ pub fn run_db_sync(
}
}
// ---------------------------------------------------------------------------
// SQLite dump / restore / sync (headless-testable core)
// ---------------------------------------------------------------------------
/// Quote a SQLite identifier with double quotes (preserving the spec's no-injection rule).
fn sqlite_quote_ident(name: &str) -> String {
format!("\"{}\"", name.replace('"', "\"\""))
}
/// SQL literal for a rusqlite value (mirrors sqlite3 .dump output).
fn sqlite_literal(v: ValueRef) -> String {
match v {
ValueRef::Null => "NULL".to_string(),
ValueRef::Integer(i) => i.to_string(),
ValueRef::Real(r) => r.to_string(),
ValueRef::Text(t) => format!("'{}'", String::from_utf8_lossy(t).replace('\'', "''")),
ValueRef::Blob(b) => {
format!("X'{}'", b.iter().map(|x| format!("{:02x}", x)).collect::<String>())
}
}
}
/// Generate a `.dump`-format SQL script from `conn`, streaming to `out`. Calls
/// `on_progress(table_name)` per table. Fails closed on virtual tables.
pub fn dump_sqlite_to<W: Write, F: FnMut(&str)>(
conn: &Connection,
out: &mut W,
mut on_progress: F,
) -> Result<(), String> {
writeln!(out, "PRAGMA foreign_keys=OFF;").map_err(|e| e.to_string())?;
writeln!(out, "BEGIN TRANSACTION;").map_err(|e| e.to_string())?;
// 1. Tables (schema + data), fail-closed on virtual tables.
let table_names: Vec<String> = conn
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND sql IS NOT NULL ORDER BY name")
.map_err(|e| e.to_string())?
.query_map([], |r| r.get::<_, String>(0))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
for name in &table_names {
let create_sql: String = conn
.query_row("SELECT sql FROM sqlite_master WHERE type='table' AND name=?1", [name], |r| {
r.get::<_, String>(0)
})
.map_err(|e| e.to_string())?;
if create_sql.to_lowercase().contains("create virtual table") {
return Err(format!(
"SQLite virtual tables are not supported for .dump in v0.7.8 (table: {name})"
));
}
writeln!(out, "{create_sql};").map_err(|e| e.to_string())?;
on_progress(name);
// Columns excluding generated/hidden (hidden != 0) via pragma_table_xinfo.
let xinfo: Vec<(String, i64)> = conn
.prepare(&format!(
"SELECT name, hidden FROM pragma_table_xinfo(\"{}\")",
name.replace('"', "\"\"")
))
.map_err(|e| e.to_string())?
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
let emitted: Vec<String> = xinfo.iter().filter(|(_, h)| *h == 0).map(|(n, _)| n.clone()).collect();
let col_list = emitted.iter().map(|n| sqlite_quote_ident(n)).collect::<Vec<_>>().join(", ");
// Emit one INSERT per row (faithful to .dump).
let select = format!(
"SELECT {} FROM \"{}\"",
emitted.iter().map(|n| sqlite_quote_ident(n)).collect::<Vec<_>>().join(", "),
name.replace('"', "\"\"")
);
let mut stmt = conn.prepare(&select).map_err(|e| e.to_string())?;
let nrows = stmt
.query_map([], |row| {
let vals: Vec<String> = (0..emitted.len())
.map(|i| sqlite_literal(row.get_ref(i).unwrap_or(ValueRef::Null)))
.collect();
Ok(format!(
"INSERT INTO {} ({}) VALUES ({});",
sqlite_quote_ident(name),
col_list,
vals.join(", ")
))
})
.map_err(|e| e.to_string())?;
for r in nrows.filter_map(|r| r.ok()) {
writeln!(out, "{r}").map_err(|e| e.to_string())?;
}
}
// 2. Indexes, triggers, views (sql not null).
for ty in ["index", "trigger", "view"] {
let sqls: Vec<String> = conn
.prepare("SELECT sql FROM sqlite_master WHERE type=?1 AND sql IS NOT NULL AND name NOT LIKE 'sqlite_%' ORDER BY name")
.map_err(|e| e.to_string())?
.query_map([ty], |r| r.get::<_, String>(0))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
for s in sqls {
writeln!(out, "{s};").map_err(|e| e.to_string())?;
}
}
// 3. sqlite_sequence (AUTOINCREMENT counters) if it exists.
if conn
.prepare("SELECT name FROM sqlite_master WHERE name='sqlite_sequence'")
.map_err(|e| e.to_string())?
.exists([])
.unwrap_or(false)
{
writeln!(out, "DELETE FROM sqlite_sequence;").map_err(|e| e.to_string())?;
let rows: Vec<(String, i64)> = conn
.prepare("SELECT name, seq FROM sqlite_sequence")
.map_err(|e| e.to_string())?
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
for (t, seq) in rows {
writeln!(
out,
"INSERT INTO sqlite_sequence VALUES ('{}', {});",
t.replace('\'', "''"),
seq
)
.map_err(|e| e.to_string())?;
}
}
writeln!(out, "COMMIT;").map_err(|e| e.to_string())?;
Ok(())
}
/// Restore a `.dump` SQL script into `conn`. `clean` drops existing
/// user tables/views/indexes/triggers first.
pub fn restore_sqlite(conn: &Connection, dump_text: &str, clean: bool) -> Result<(), String> {
if clean {
let names: Vec<String> = conn
.prepare("SELECT name FROM sqlite_master WHERE type IN ('table','view','index','trigger') AND name NOT LIKE 'sqlite_%' ORDER BY type DESC")
.map_err(|e| e.to_string())?
.query_map([], |r| r.get::<_, String>(0))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
conn.execute_batch("PRAGMA foreign_keys=OFF;").map_err(|e| e.to_string())?;
for n in names {
let _ = conn.execute(&format!("DROP TABLE IF EXISTS \"{}\"", n.replace('"', "\"\"")), []);
let _ = conn.execute(&format!("DROP VIEW IF EXISTS \"{}\"", n.replace('"', "\"\"")), []);
let _ = conn.execute(&format!("DROP INDEX IF EXISTS \"{}\"", n.replace('"', "\"\"")), []);
let _ = conn.execute(&format!("DROP TRIGGER IF EXISTS \"{}\"", n.replace('"', "\"\"")), []);
}
}
// The dump text already wraps in PRAGMA foreign_keys=OFF + BEGIN/COMMIT.
conn.execute_batch(dump_text).map_err(|e| format!("restore failed: {e}"))
}
/// Dump `source_path` and restore into `target_path` (one-shot).
pub fn run_sqlite_sync(source_path: &str, target_path: &str) -> Result<(), String> {
let src = Connection::open(source_path).map_err(|e| format!("open source: {e}"))?;
let mut buf: Vec<u8> = Vec::new();
dump_sqlite_to(&src, &mut std::io::Cursor::new(&mut buf), |_| {})?;
let text = String::from_utf8(buf).map_err(|e| e.to_string())?;
let dst = Connection::open(target_path).map_err(|e| format!("open target: {e}"))?;
restore_sqlite(&dst, &text, true)
}
// ---------------------------------------------------------------------------
// MySQL dump / restore / sync (headless-testable core)
// ---------------------------------------------------------------------------
fn base_mysql_args(conn: &MySqlConnParams) -> Vec<String> {
vec![
format!("--host={}", conn.host),
format!("--port={}", conn.port),
format!("--user={}", conn.username),
]
}
/// `mariadb-dump`/`mysqldump` args. Passwords go via MYSQL_PWD env (set by the
/// command), NEVER --password (process-list visibility).
pub fn build_mysql_dump_args(conn: &MySqlConnParams, options: &MySqlBackupOptions) -> Vec<String> {
let mut a = base_mysql_args(conn);
if options.single_transaction { a.push("--single-transaction".into()); }
if options.no_data { a.push("--no-data".into()); }
if options.routines { a.push("--routines".into()); }
if options.triggers { a.push("--triggers".into()); }
if options.events { a.push("--events".into()); }
a.push(format!("--databases={}", options.database));
a.push(format!("--result-file={}", options.file_path));
a.push("--skip-column-statistics".into());
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();
let bundled = app.path().resource_dir().ok()
.map(|rd| rd.join("mysql_tools").join(bundled_bin_name(tool)))
.filter(|p| p.exists())
.map(|p| p.to_string_lossy().to_string());
pick_tool(system_ok, bundled.as_deref(), tool)
}
pub fn resolve_mysql_tool_paths(app: &AppHandle) -> MySqlToolPaths {
let (d, _) = resolve_mysql_tool(app, "mariadb-dump");
let (m, _) = resolve_mysql_tool(app, "mariadb");
MySqlToolPaths { mysqldump: d, mysql: m }
}
/// Headless core: spawn dump with MYSQL_PWD env. `tls_mode` (e.g. `REQUIRED`)
/// is appended as `--ssl-mode=` when routing through an SSH tunnel. (Live
/// behavior = #[ignore] integration test.)
pub fn run_mysql_dump(
conn: &MySqlConnParams,
options: &MySqlBackupOptions,
tools: &MySqlToolPaths,
tls_mode: Option<&str>,
) -> Result<(), String> {
let mut args = build_mysql_dump_args(conn, options);
if let Some(m) = tls_mode {
args.push(format!("--ssl-mode={m}"));
}
let out = Command::new(&tools.mysqldump).env("MYSQL_PWD", &conn.password).args(&args).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_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,
tools: &MySqlToolPaths,
tls_mode: Option<&str>,
) -> Result<(), String> {
let mut dump_args = base_mysql_args(source);
dump_args.push("--single-transaction".into());
dump_args.push("--add-drop-table".into());
dump_args.push(format!("--databases={}", source.database));
if let Some(m) = tls_mode {
dump_args.push(format!("--ssl-mode={m}"));
}
let mut dump = Command::new(&tools.mysqldump).env("MYSQL_PWD", &source.password).args(&dump_args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| format!("dump: {e}"))?;
let stdout = dump.stdout.take().unwrap();
let mut restore_args = base_mysql_args(target);
restore_args.push(format!("--database={}", target.database));
let restore = Command::new(&tools.mysql).env("MYSQL_PWD", &target.password).args(&restore_args).stdin(stdout).output();
let _ = dump.wait();
match restore {
Ok(o) if o.status.success() => Ok(()),
Ok(o) => Err(format!("restore: {}", sanitize_error(&String::from_utf8_lossy(&o.stderr)))),
Err(e) => Err(format!("restore: {e}")),
}
}
// ---------------------------------------------------------------------------
// Tauri commands (thin wrappers: store lookup + keychain + event emission)
// ---------------------------------------------------------------------------
@@ -520,6 +801,346 @@ pub async fn db_sync(
Ok(job_id)
}
// ---------------------------------------------------------------------------
// MySQL dump / restore / sync commands (v0.7.8)
// ---------------------------------------------------------------------------
#[tauri::command]
pub fn detect_mysql_tools(app_handle: AppHandle) -> MySqlToolStatus {
let (d, ds) = resolve_mysql_tool(&app_handle, "mariadb-dump");
let (m, ms) = resolve_mysql_tool(&app_handle, "mariadb");
MySqlToolStatus {
mysqldump_found: Command::new(&d).arg("--version").output().is_ok(),
mysql_found: Command::new(&m).arg("--version").output().is_ok(),
mysqldump_version: get_version(&d),
mysql_version: get_version(&m),
mysqldump_source: ds,
mysql_source: ms,
}
}
/// Resolve (host, port, via_tunnel) for a MySQL connection, routing through the
/// SSH tunnel endpoint when present.
fn mysql_endpoint(
state: &crate::AppState,
connection_id: &str,
conn: &crate::models::Connection,
) -> (String, i64, bool) {
let via_tunnel = state.ssh_manager.lock().unwrap().get_local_port(connection_id);
if let Some(port) = via_tunnel {
return ("127.0.0.1".into(), port as i64, true);
}
(conn.host.clone(), conn.port.unwrap_or(3306), false)
}
#[tauri::command]
pub async fn mysql_dump(
connection_id: String,
options: MySqlBackupOptions,
state: State<'_, crate::AppState>,
app_handle: AppHandle,
) -> Result<String, String> {
let job_id = uuid::Uuid::new_v4().to_string();
let conn = {
let store = state.db_store.lock().map_err(|e| e.to_string())?;
let connections = store.get_connections().map_err(|e| e.to_string())?;
connections
.into_iter()
.find(|c| c.id == connection_id)
.ok_or_else(|| format!("Connection not found: {connection_id}"))?
};
let password =
crate::commands::keychain::get_connection_password_internal(&app_handle, &connection_id)
.unwrap_or_default()
.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,
);
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_dump(&params, &options, &tools, tls);
emit_result(&app_handle_clone, &job_id_clone, result);
});
Ok(job_id)
}
#[tauri::command]
pub async fn mysql_restore(
connection_id: String,
options: MySqlRestoreOptions,
state: State<'_, crate::AppState>,
app_handle: AppHandle,
) -> Result<String, String> {
let job_id = uuid::Uuid::new_v4().to_string();
let conn = {
let store = state.db_store.lock().map_err(|e| e.to_string())?;
let connections = store.get_connections().map_err(|e| e.to_string())?;
connections
.into_iter()
.find(|c| c.id == connection_id)
.ok_or_else(|| format!("Connection not found: {connection_id}"))?
};
let password =
crate::commands::keychain::get_connection_password_internal(&app_handle, &connection_id)
.unwrap_or_default()
.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,
);
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);
emit_result(&app_handle_clone, &job_id_clone, result);
});
Ok(job_id)
}
#[tauri::command]
pub async fn mysql_sync(
options: SyncOptions,
state: State<'_, crate::AppState>,
app_handle: AppHandle,
) -> Result<String, String> {
let job_id = uuid::Uuid::new_v4().to_string();
// Get both connections from store
let (source_conn, target_conn) = {
let store = state.db_store.lock().map_err(|e| e.to_string())?;
let connections = store.get_connections().map_err(|e| e.to_string())?;
let src = connections
.iter()
.find(|c| c.id == options.source_connection_id)
.ok_or_else(|| {
format!(
"Source connection not found: {}",
options.source_connection_id
)
})?
.clone();
let tgt = connections
.iter()
.find(|c| c.id == options.target_connection_id)
.ok_or_else(|| {
format!(
"Target connection not found: {}",
options.target_connection_id
)
})?
.clone();
(src, tgt)
};
let src_password =
crate::commands::keychain::get_connection_password_internal(&app_handle, &source_conn.id)
.unwrap_or_default()
.unwrap_or_default();
let tgt_password =
crate::commands::keychain::get_connection_password_internal(&app_handle, &target_conn.id)
.unwrap_or_default()
.unwrap_or_default();
let (src_host, src_port, src_via_tunnel) = mysql_endpoint(&state, &source_conn.id, &source_conn);
let (tgt_host, tgt_port, tgt_via_tunnel) = mysql_endpoint(&state, &target_conn.id, &target_conn);
let source = MySqlConnParams::new(
src_host,
src_port,
source_conn
.username
.clone()
.unwrap_or_else(|| "root".into()),
source_conn
.database
.clone()
.unwrap_or_else(|| "mysql".into()),
src_password,
);
let target = MySqlConnParams::new(
tgt_host,
tgt_port,
target_conn
.username
.clone()
.unwrap_or_else(|| "root".into()),
target_conn
.database
.clone()
.unwrap_or_else(|| "mysql".into()),
tgt_password,
);
let tools = resolve_mysql_tool_paths(&app_handle);
// Through a tunnel the peer is loopback, so force encrypt-only `REQUIRED`
// (mirrors how the app degrades verify-ca/verify-full through tunnels).
let tls = if src_via_tunnel || tgt_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_sync(&source, &target, &tools, tls);
emit_result(&app_handle_clone, &job_id_clone, result);
});
Ok(job_id)
}
// ---------------------------------------------------------------------------
// SQLite dump / restore / sync commands (v0.7.8) — the stored `conn.host` IS
// the SQLite file path; each command opens its own connection in the blocking
// task (the pool's SQLite handle is never touched).
// ---------------------------------------------------------------------------
#[tauri::command]
pub async fn sqlite_dump(
connection_id: String,
options: SqliteBackupOptions,
state: State<'_, crate::AppState>,
app_handle: AppHandle,
) -> Result<String, String> {
let job_id = uuid::Uuid::new_v4().to_string();
let conn = {
let store = state.db_store.lock().map_err(|e| e.to_string())?;
let connections = store.get_connections().map_err(|e| e.to_string())?;
connections
.into_iter()
.find(|c| c.id == connection_id)
.ok_or_else(|| format!("Connection not found: {connection_id}"))?
};
let path = conn.host.clone();
let out_path = options.file_path.clone();
let job_id_clone = job_id.clone();
let app_handle_clone = app_handle.clone();
tokio::task::spawn_blocking(move || {
let result = (|| -> Result<(), String> {
let src = Connection::open(&path).map_err(|e| format!("open source: {e}"))?;
let mut out =
std::fs::File::create(&out_path).map_err(|e| format!("create dump file: {e}"))?;
dump_sqlite_to(&src, &mut out, |_| {})
})();
emit_result(&app_handle_clone, &job_id_clone, result);
});
Ok(job_id)
}
#[tauri::command]
pub async fn sqlite_restore(
connection_id: String,
options: SqliteRestoreOptions,
state: State<'_, crate::AppState>,
app_handle: AppHandle,
) -> Result<String, String> {
let job_id = uuid::Uuid::new_v4().to_string();
let conn = {
let store = state.db_store.lock().map_err(|e| e.to_string())?;
let connections = store.get_connections().map_err(|e| e.to_string())?;
connections
.into_iter()
.find(|c| c.id == connection_id)
.ok_or_else(|| format!("Connection not found: {connection_id}"))?
};
let path = conn.host.clone();
let in_path = options.file_path.clone();
let clean = options.clean;
let job_id_clone = job_id.clone();
let app_handle_clone = app_handle.clone();
tokio::task::spawn_blocking(move || {
let result = (|| -> Result<(), String> {
let dst = Connection::open(&path).map_err(|e| format!("open target: {e}"))?;
let text =
std::fs::read_to_string(&in_path).map_err(|e| format!("read dump: {e}"))?;
restore_sqlite(&dst, &text, clean)
})();
emit_result(&app_handle_clone, &job_id_clone, result);
});
Ok(job_id)
}
#[tauri::command]
pub async fn sqlite_sync(
options: SyncOptions,
state: State<'_, crate::AppState>,
app_handle: AppHandle,
) -> Result<String, String> {
let job_id = uuid::Uuid::new_v4().to_string();
let (source_path, target_path) = {
let store = state.db_store.lock().map_err(|e| e.to_string())?;
let connections = store.get_connections().map_err(|e| e.to_string())?;
let src = connections
.iter()
.find(|c| c.id == options.source_connection_id)
.ok_or_else(|| {
format!(
"Source connection not found: {}",
options.source_connection_id
)
})?
.clone();
let tgt = connections
.iter()
.find(|c| c.id == options.target_connection_id)
.ok_or_else(|| {
format!(
"Target connection not found: {}",
options.target_connection_id
)
})?
.clone();
(src.host, tgt.host)
};
let job_id_clone = job_id.clone();
let app_handle_clone = app_handle.clone();
tokio::task::spawn_blocking(move || {
let result = run_sqlite_sync(&source_path, &target_path);
emit_result(&app_handle_clone, &job_id_clone, result);
});
Ok(job_id)
}
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
+114
View File
@@ -418,3 +418,117 @@ fn bundled_bin_name_appends_exe_on_windows() {
let name = bundled_bin_name("pg_dump");
if cfg!(windows) { assert_eq!(name, "pg_dump.exe"); } else { assert_eq!(name, "pg_dump"); }
}
// ------------------------------------------------------------------
// SQLite .dump / restore / sync core (Task 2.2)
// ------------------------------------------------------------------
use rusqlite::Connection;
use std::io::Cursor;
fn seed_sqlite() -> Connection {
let c = Connection::open_in_memory().unwrap();
c.execute_batch(
"CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL);
CREATE INDEX users_name ON users(name);
INSERT INTO users (name) VALUES ('Alice'),('Bob');
CREATE TABLE blobs (id INTEGER PRIMARY KEY, data BLOB);
INSERT INTO blobs VALUES (1, x'010203');",
)
.unwrap();
c
}
#[test]
fn sqlite_dump_and_restore_roundtrip() {
let src = seed_sqlite();
let mut buf: Vec<u8> = Vec::new();
let mut cur = std::io::Cursor::new(&mut buf);
dump_sqlite_to(&src, &mut cur, |_| {}).unwrap();
let text = String::from_utf8(buf).unwrap();
assert!(text.contains("PRAGMA foreign_keys=OFF"));
assert!(text.contains("BEGIN TRANSACTION"));
assert!(text.contains("CREATE TABLE users"));
assert!(text.contains("INSERT INTO \"users\""));
assert!(text.contains("X'010203'"), "BLOB must be hex-literal");
assert!(text.contains("CREATE INDEX users_name"));
let dst = Connection::open_in_memory().unwrap();
restore_sqlite(&dst, &text, false).unwrap();
let n: i64 = dst.query_row("SELECT COUNT(*) FROM users", [], |r| r.get(0)).unwrap();
assert_eq!(n, 2);
let blobs: i64 = dst.query_row("SELECT COUNT(*) FROM blobs", [], |r| r.get(0)).unwrap();
assert_eq!(blobs, 1);
}
#[test]
fn sqlite_dump_preserves_autoincrement_sequence() {
let src = Connection::open_in_memory().unwrap();
src.execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, v TEXT); INSERT INTO t(v) VALUES ('a'),('b');").unwrap();
let mut buf = Vec::new();
dump_sqlite_to(&src, &mut Cursor::new(&mut buf), |_| {}).unwrap();
let text = String::from_utf8(buf).unwrap();
let dst = Connection::open_in_memory().unwrap();
restore_sqlite(&dst, &text, false).unwrap();
dst.execute("INSERT INTO t(v) VALUES ('c')", []).unwrap();
let id: i64 = dst.query_row("SELECT id FROM t WHERE v='c'", [], |r| r.get(0)).unwrap();
assert_eq!(id, 3);
}
#[test]
fn sqlite_dump_fail_closed_for_virtual_tables() {
let src = Connection::open_in_memory().unwrap();
src.execute_batch("CREATE VIRTUAL TABLE ft USING fts4(content)").unwrap();
let mut buf = Vec::new();
let err = dump_sqlite_to(&src, &mut Cursor::new(&mut buf), |_| {}).unwrap_err();
assert!(err.to_lowercase().contains("virtual table"), "got: {err}");
}
#[test]
fn sqlite_restore_clean_drops_existing() {
let src = seed_sqlite();
let mut buf = Vec::new();
dump_sqlite_to(&src, &mut Cursor::new(&mut buf), |_| {}).unwrap();
let text = String::from_utf8(buf).unwrap();
let dst = Connection::open_in_memory().unwrap();
dst.execute_batch("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT); INSERT INTO users VALUES (99,'old');").unwrap();
restore_sqlite(&dst, &text, true).unwrap();
let names: Vec<String> = dst.prepare("SELECT name FROM users ORDER BY id").unwrap().query_map([], |r| r.get::<_, String>(0)).unwrap().filter_map(|r| r.ok()).collect();
assert_eq!(names, vec!["Alice".to_string(), "Bob".to_string()]);
}
// ------------------------------------------------------------------
// MySQL dump / restore / sync arg building (Task 2.3)
// ------------------------------------------------------------------
#[test]
fn mysql_dump_args_single_transaction_no_data_routines() {
let opts = MySqlBackupOptions {
database: "shop".into(), file_path: "/tmp/d.sql".into(),
single_transaction: true, no_data: true, routines: true, triggers: false, events: false,
};
let args = build_mysql_dump_args(&MySqlConnParams::new("h".into(), 3306, "u".into(), "shop".into(), "p".into()), &opts);
assert!(args.iter().any(|a| a == "--single-transaction"));
assert!(args.iter().any(|a| a == "--no-data"));
assert!(args.iter().any(|a| a == "--routines"));
assert!(args.iter().any(|a| a == "--databases=shop"));
assert!(args.iter().any(|a| a == "--result-file=/tmp/d.sql"));
// no --password on the command line (uses MYSQL_PWD env)
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")));
}
+48 -7
View File
@@ -891,6 +891,7 @@ pub(crate) async fn run_mysql_connect(
config: &crate::db::pool::DbConfig,
ssh_manager: &std::sync::Mutex<crate::commands::ssh::SshTunnelManager>,
pool_manager: &tokio::sync::Mutex<crate::db::pool::ConnectionPoolManager>,
cancel_registry: &crate::cancel::CancelRegistry,
) -> Result<(), String> {
use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions, MySqlSslMode};
if config.host.trim().is_empty() {
@@ -968,6 +969,10 @@ pub(crate) async fn run_mysql_connect(
}
}
// Clone the (already final) options before `connect_with` consumes them so
// `cancel_query` can open its own connection to run `KILL QUERY ?`.
let cancel_opts = opts.clone();
match MySqlPoolOptions::new()
.max_connections(5)
.acquire_timeout(std::time::Duration::from_secs(10))
@@ -979,6 +984,13 @@ pub(crate) async fn run_mysql_connect(
.lock()
.await
.register(connection_id, crate::db::pool::DbHandle::MySql(pool));
cancel_registry.set_mysql(
connection_id,
crate::cancel::MySqlCancel {
conn_id: None,
connect_options: cancel_opts,
},
);
Ok(())
}
Err(e) => {
@@ -1023,6 +1035,8 @@ pub async fn db_connect(
config.ssl_key_path.as_deref(),
)
.map_err(|e| sanitize_error(&e))?;
// Snapshot before `match tls` consumes the Option below.
let cancel_cfg = tls.clone();
// SSH tunnel: if configured, open a loopback tunnel to the remote DB
// and connect through it. The blocking ssh2 handshake runs in
@@ -1080,6 +1094,18 @@ pub async fn db_connect(
match result {
Ok((client, handle)) => {
// Capture a cancel token + the exact TLS decision/config used to
// build the connection so `cancel_query` can open an identical
// (short-lived) cancel connection later.
let pgtoken = client.cancel_token();
state.cancel_registry.set_pg(
&connection_id,
crate::cancel::PgCancel {
cancel_token: pgtoken,
tls_decision: decision,
tls_config: cancel_cfg,
},
);
let mut pm = state.pool_manager.lock().await;
pm.register(
&connection_id,
@@ -1101,8 +1127,14 @@ pub async fn db_connect(
} else if config.db_type == "sqlite" {
match rusqlite::Connection::open(&config.host) {
Ok(conn) => {
// Capture the per-connection interrupt handle so `cancel_query`
// can abort a running SQLite query from another thread.
let interrupt = conn.get_interrupt_handle();
let mut pm = state.pool_manager.lock().await;
pm.register(&connection_id, crate::db::pool::DbHandle::Sqlite(conn));
state
.cancel_registry
.set_sqlite(&connection_id, crate::cancel::SqliteCancel::new(interrupt));
Ok(())
}
Err(e) => Err(format!("Connection failed: {}", e)),
@@ -1113,6 +1145,7 @@ pub async fn db_connect(
&config,
&state.ssh_manager,
&state.pool_manager,
&state.cancel_registry,
)
.await
} else {
@@ -1130,6 +1163,7 @@ pub async fn db_disconnect(
) -> Result<(), String> {
let mut pm = state.pool_manager.lock().await;
pm.remove(&connection_id);
state.cancel_registry.remove(&connection_id);
Ok(())
}
@@ -2323,11 +2357,15 @@ pub(crate) async fn execute_change_inner(
conn.execute(sql, []).map_err(|e| e.to_string())?;
return Ok(());
}
Change::Ddl { .. } => {
return Err("Object management is PostgreSQL-only".to_string());
Change::Ddl { sql, .. } => {
// Object DDL can be multi-statement; run as a batch.
conn.execute_batch(sql).map_err(|e| e.to_string())?;
return Ok(());
}
Change::RebuildTable { .. } => {
return Err("Object management is PostgreSQL-only".to_string());
Change::RebuildTable { sql, .. } => {
// Rebuild script (create tmp / copy / drop / rename).
conn.execute_batch(sql).map_err(|e| e.to_string())?;
return Ok(());
}
Change::BulkInsert {
table,
@@ -3309,8 +3347,9 @@ mod tests {
};
let ssh = StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend)));
let pm = fresh_pool_manager().await;
let reg = crate::cancel::CancelRegistry::new();
let id = "mysql-empty-host";
let res = run_mysql_connect(id, &cfg, &ssh, &pm).await;
let res = run_mysql_connect(id, &cfg, &ssh, &pm, &reg).await;
assert!(res.is_err(), "empty host must fail before any network call");
let mut pmg = pm.lock().await;
assert!(pmg.get(id).is_none(), "no handle registered on failure");
@@ -3369,8 +3408,9 @@ mod tests {
};
let ssh = StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend)));
let pm = fresh_pool_manager().await;
let reg = crate::cancel::CancelRegistry::new();
let id = "mysql-unreachable";
let res = run_mysql_connect(id, &cfg, &ssh, &pm).await;
let res = run_mysql_connect(id, &cfg, &ssh, &pm, &reg).await;
assert!(
res.is_err(),
"port 1 should refuse; must be a clean Err, not panic"
@@ -3419,8 +3459,9 @@ mod tests {
};
let ssh = StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend)));
let pm = fresh_pool_manager().await;
let reg = crate::cancel::CancelRegistry::new();
let id = format!("mysql-it-{}", uuid::Uuid::new_v4());
run_mysql_connect(&id, &cfg, &ssh, &pm).await.expect("connect");
run_mysql_connect(&id, &cfg, &ssh, &pm, &reg).await.expect("connect");
let tables = get_tables_inner(&pm, &id, Some(&db)).await.expect("tables");
assert!(!tables.is_empty(), "test DB must contain at least one table");
let first = &tables[0];
+254 -128
View File
@@ -147,13 +147,120 @@ pub async fn get_object_dependencies(connection_id: String, schema: String, obje
get_object_dependencies_inner(&state.pool_manager, &connection_id, &schema, &object_type, &name).await
}
/// Introspect the live columns of a SQLite table (for the edit/rebuild diff).
/// Mirrors the `PRAGMA table_info` + `PRAGMA index_list`/`index_info` reads used
/// elsewhere in db_viewer; `auto_increment` requires an INTEGER PK whose stored
/// DDL (sqlite_master) actually says AUTOINCREMENT, and `unique` means a
/// single-column unique index (excluding the PK autoindex).
fn sqlite_live_columns(conn: &rusqlite::Connection, table: &str) -> Result<Vec<SqliteColumn>, String> {
let pragma_query = format!("PRAGMA table_info('{}')", table);
let mut stmt = conn.prepare(&pragma_query).map_err(|e| e.to_string())?;
let col_meta: Vec<(String, String, bool, bool, Option<String>)> = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(1)?, // name
row.get::<_, String>(2)?, // type
row.get::<_, bool>(3)?, // notnull
row.get::<_, bool>(5)?, // pk
row.get::<_, Option<String>>(4)?, // dflt_value
))
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
// AUTOINCREMENT only appears in the stored DDL of an INTEGER PK table.
let autoinc = conn
.query_row(
"SELECT sql FROM sqlite_master WHERE type='table' AND name=?1",
[table],
|row| row.get::<_, String>(0),
)
.map(|sql| sql.to_uppercase().contains("AUTOINCREMENT"))
.unwrap_or(false);
// Columns covered by a single-column unique index (origin != 'pk').
let mut unique_cols: std::collections::HashSet<String> = std::collections::HashSet::new();
if let Ok(mut idx_stmt) = conn.prepare(&format!("PRAGMA index_list('{}')", table)) {
let indexes: Vec<(String, bool, String)> = idx_stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(1)?, // name
row.get::<_, bool>(2)?, // unique
row.get::<_, String>(3)?, // origin
))
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
for (idx_name, is_unique, origin) in indexes {
if !is_unique || origin == "pk" {
continue;
}
if let Ok(mut info_stmt) =
conn.prepare(&format!("PRAGMA index_info('{}')", idx_name.replace('\'', "''")))
{
let cols: Vec<String> = info_stmt
.query_map([], |row| row.get::<_, String>(2))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
if cols.len() == 1 {
unique_cols.insert(cols[0].clone());
}
}
}
}
Ok(col_meta
.iter()
.map(|(name, dtype, notnull, is_pk, default)| SqliteColumn {
name: name.clone(),
type_: dtype.clone(),
nullable: !notnull,
default: default.clone(),
is_pk: *is_pk,
auto_increment: autoinc && *is_pk && dtype.trim().eq_ignore_ascii_case("INTEGER"),
unique: unique_cols.contains(name),
})
.collect())
}
/// Build SQL for an object CRUD operation. The pool is resolved only to enforce
/// PostgreSQL-only / present-connection; the SQL itself is built by the pure
/// `crate::db::object_crud::build_ddl` dispatcher (one statement per String).
/// SQLite routes through the table-editor builders in `crate::db::object_ddl`
/// (create / edit / rebuild ops on the `table` kind).
pub(crate) async fn build_object_ddl_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, kind: &str, params: serde_json::Value) -> Result<Vec<String>, String> {
let mut pm = pm.lock().await;
match pm.get(connection_id) {
Some(DbHandle::Postgresql(_, _)) => build_ddl(kind, params),
Some(DbHandle::Sqlite(conn)) => {
// params: { schema, name, action: { op, columns: [...] } }
let action = params.get("action").ok_or("missing action")?;
let op = action
.get("op")
.and_then(|v| v.as_str())
.ok_or("missing op")?;
let cols: Vec<SqliteColumn> = match action.get("columns") {
Some(v) => serde_json::from_value(v.clone()).map_err(|e| e.to_string())?,
None => vec![],
};
let table = params
.get("name")
.and_then(|v| v.as_str())
.ok_or("missing name")?
.to_string();
match op {
"create" => Ok(vec![sqlite_create_table_sql(&table, &cols, &[])?]),
"edit" => {
let old = sqlite_live_columns(conn, &table)?;
sqlite_column_diff_sql(&table, &old, &cols)
}
"rebuild" => {
let old = sqlite_live_columns(conn, &table)?;
sqlite_rebuild_script(&table, &old, &cols)
}
_ => Err(format!("unknown op {op}")),
}
}
Some(_) => Err("Object management is PostgreSQL-only".into()),
None => Err("Connection not found".into()),
}
@@ -201,125 +308,136 @@ pub(crate) async fn build_rebuild_script_inner(
new_columns: serde_json::Value,
) -> Result<String, String> {
let mut pm = pm.lock().await;
let client = match pm.get(connection_id) {
Some(DbHandle::Postgresql(c, _)) => c,
match pm.get(connection_id) {
Some(DbHandle::Sqlite(conn)) => {
let new_cols: Vec<SqliteColumn> =
serde_json::from_value(new_columns).map_err(|e| e.to_string())?;
let live = sqlite_live_columns(conn, table)?;
if let Some(reason) = sqlite_rebuild_refusal(&live) {
return Err(reason);
}
return Ok(sqlite_rebuild_script(table, &live, &new_cols)?.join(";\n"));
}
Some(DbHandle::Postgresql(client, _)) => {
let new_cols: Vec<TableColumn> =
serde_json::from_value(new_columns).map_err(|e| e.to_string())?;
// 1. live columns — validate reorder-only: the (name,type) multiset must be
// unchanged (attribute edits belong in the diff path, not the rebuild).
let live = client
.query(&crate::db::introspection::pg_columns_query(schema, table), &[])
.await
.map_err(|e| sanitize(&e.to_string()))?;
let mut live_pairs: Vec<(String, String)> = live
.iter()
.map(|r| (r.get::<_, String>(0), r.get::<_, String>(1).trim().to_string()))
.collect();
let mut new_pairs: Vec<(String, String)> = new_cols
.iter()
.map(|c| (c.name.clone(), c.type_.trim().to_string()))
.collect();
live_pairs.sort();
new_pairs.sort();
if live_pairs != new_pairs {
return Err(
"Reorder must preserve column names and types; undo attribute changes or stage a diff"
.into(),
);
}
// 2. assemble RebuildInput from live introspection (one client, all sub-queries).
let fk_out_rows = client
.query(&crate::db::introspection::pg_table_fk_out_query(), &[&schema, &table])
.await
.map_err(|e| sanitize(&e.to_string()))?;
let fk_in_rows = client
.query(&crate::db::introspection::pg_table_fk_in_query(), &[&schema, &table])
.await
.map_err(|e| sanitize(&e.to_string()))?;
let grant_rows = client
.query(&crate::db::introspection::pg_table_grants_query(), &[&schema, &table])
.await
.map_err(|e| sanitize(&e.to_string()))?;
let seq_rows = client
.query(&crate::db::introspection::pg_table_owned_sequences_query(), &[&schema, &table])
.await
.map_err(|e| sanitize(&e.to_string()))?;
let index_rows = client
.query(&crate::db::introspection::pg_indexes_query(schema), &[&schema])
.await
.map_err(|e| sanitize(&e.to_string()))?;
// PK/UNIQUE/CHECK (contype p/u/c) scoped to this table; FKs are carried
// separately as fks_out/fks_in so they are not double-applied.
let constraint_rows = client
.query(
"SELECT c.conname AS name, ns.nspname AS schema, cl.relname AS table_name, \
c.contype::text, pg_get_constraintdef(c.oid) AS definition \
FROM pg_constraint c \
JOIN pg_class cl ON c.conrelid = cl.oid \
JOIN pg_namespace ns ON cl.relnamespace = ns.oid \
WHERE ns.nspname = $1 AND cl.relname = $2 AND c.contype IN ('p','u','c') \
ORDER BY c.conname",
&[&schema, &table],
)
.await
.map_err(|e| sanitize(&e.to_string()))?;
let input = RebuildInput {
schema: schema.to_string(),
name: table.to_string(),
constraints: constraint_rows
.iter()
.map(|r| RebuildConstraint {
name: r.get(0),
definition: r.get(4),
})
.collect(),
indexes: index_rows
.iter()
.filter(|r| r.get::<_, String>(2) == table)
.map(|r| RebuildIndex {
name: r.get(0),
definition: r.get(3),
})
.collect(),
fks_out: fk_out_rows
.iter()
.map(|r| RebuildFk {
name: r.get(0),
definition: r.get(1),
})
.collect(),
fks_in: fk_in_rows
.iter()
.map(|r| RebuildFkIn {
name: r.get(0),
own_schema: r.get(1),
own_table: r.get(2),
definition: r.get(3),
})
.collect(),
grants: grant_rows
.iter()
.map(|r| RebuildGrant {
grantee: r.get(0),
privileges: r.get(1),
grantable: r.get(2),
})
.collect(),
owned_sequences: seq_rows
.iter()
.map(|r| RebuildOwnedSequence {
seq_schema: r.get(0),
seq_name: r.get(1),
column: r.get(2),
})
.collect(),
};
rebuild_script(&input, &new_cols)
}
Some(_) => return Err("Rebuild is PostgreSQL-only".into()),
None => return Err("Connection not found".into()),
};
let new_cols: Vec<TableColumn> = serde_json::from_value(new_columns).map_err(|e| e.to_string())?;
// 1. live columns — validate reorder-only: the (name,type) multiset must be
// unchanged (attribute edits belong in the diff path, not the rebuild).
let live = client
.query(&crate::db::introspection::pg_columns_query(schema, table), &[])
.await
.map_err(|e| sanitize(&e.to_string()))?;
let mut live_pairs: Vec<(String, String)> = live
.iter()
.map(|r| (r.get::<_, String>(0), r.get::<_, String>(1).trim().to_string()))
.collect();
let mut new_pairs: Vec<(String, String)> = new_cols
.iter()
.map(|c| (c.name.clone(), c.type_.trim().to_string()))
.collect();
live_pairs.sort();
new_pairs.sort();
if live_pairs != new_pairs {
return Err(
"Reorder must preserve column names and types; undo attribute changes or stage a diff"
.into(),
);
}
// 2. assemble RebuildInput from live introspection (one client, all sub-queries).
let fk_out_rows = client
.query(&crate::db::introspection::pg_table_fk_out_query(), &[&schema, &table])
.await
.map_err(|e| sanitize(&e.to_string()))?;
let fk_in_rows = client
.query(&crate::db::introspection::pg_table_fk_in_query(), &[&schema, &table])
.await
.map_err(|e| sanitize(&e.to_string()))?;
let grant_rows = client
.query(&crate::db::introspection::pg_table_grants_query(), &[&schema, &table])
.await
.map_err(|e| sanitize(&e.to_string()))?;
let seq_rows = client
.query(&crate::db::introspection::pg_table_owned_sequences_query(), &[&schema, &table])
.await
.map_err(|e| sanitize(&e.to_string()))?;
let index_rows = client
.query(&crate::db::introspection::pg_indexes_query(schema), &[&schema])
.await
.map_err(|e| sanitize(&e.to_string()))?;
// PK/UNIQUE/CHECK (contype p/u/c) scoped to this table; FKs are carried
// separately as fks_out/fks_in so they are not double-applied.
let constraint_rows = client
.query(
"SELECT c.conname AS name, ns.nspname AS schema, cl.relname AS table_name, \
c.contype::text, pg_get_constraintdef(c.oid) AS definition \
FROM pg_constraint c \
JOIN pg_class cl ON c.conrelid = cl.oid \
JOIN pg_namespace ns ON cl.relnamespace = ns.oid \
WHERE ns.nspname = $1 AND cl.relname = $2 AND c.contype IN ('p','u','c') \
ORDER BY c.conname",
&[&schema, &table],
)
.await
.map_err(|e| sanitize(&e.to_string()))?;
let input = RebuildInput {
schema: schema.to_string(),
name: table.to_string(),
constraints: constraint_rows
.iter()
.map(|r| RebuildConstraint {
name: r.get(0),
definition: r.get(4),
})
.collect(),
indexes: index_rows
.iter()
.filter(|r| r.get::<_, String>(2) == table)
.map(|r| RebuildIndex {
name: r.get(0),
definition: r.get(3),
})
.collect(),
fks_out: fk_out_rows
.iter()
.map(|r| RebuildFk {
name: r.get(0),
definition: r.get(1),
})
.collect(),
fks_in: fk_in_rows
.iter()
.map(|r| RebuildFkIn {
name: r.get(0),
own_schema: r.get(1),
own_table: r.get(2),
definition: r.get(3),
})
.collect(),
grants: grant_rows
.iter()
.map(|r| RebuildGrant {
grantee: r.get(0),
privileges: r.get(1),
grantable: r.get(2),
})
.collect(),
owned_sequences: seq_rows
.iter()
.map(|r| RebuildOwnedSequence {
seq_schema: r.get(0),
seq_name: r.get(1),
column: r.get(2),
})
.collect(),
};
rebuild_script(&input, &new_cols)
}
#[tauri::command]
@@ -417,20 +535,28 @@ pub async fn get_role_privileges(connection_id: String, role: String, state: Sta
/// Check whether a table can be rebuilt (no triggers, policies, inheritance, partitioning, generated columns).
pub(crate) async fn get_table_rebuild_readiness_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, table: &str) -> Result<crate::models::RebuildReadiness, String> {
let mut pm = pm.lock().await;
let client = match pm.get(connection_id) {
Some(DbHandle::Postgresql(c, _)) => c,
match pm.get(connection_id) {
Some(DbHandle::Sqlite(conn)) => {
let live = sqlite_live_columns(conn, table)?;
Ok(match sqlite_rebuild_refusal(&live) {
Some(reason) => crate::models::RebuildReadiness { ok: false, reasons: vec![reason] },
None => crate::models::RebuildReadiness { ok: true, reasons: vec![] },
})
}
Some(DbHandle::Postgresql(client, _)) => {
let row = client.query_one(&crate::db::introspection::pg_rebuild_readiness_query(), &[&schema, &table]).await
.map_err(|e| sanitize(&e.to_string()))?;
let mut reasons = Vec::new();
if row.get::<_, bool>("has_triggers") { reasons.push("table has triggers".into()); }
if row.get::<_, bool>("has_policies") { reasons.push("table has RLS policies".into()); }
if row.get::<_, bool>("is_inherits") { reasons.push("table participates in inheritance".into()); }
if row.get::<_, bool>("is_partitioned") { reasons.push("table is partitioned".into()); }
if row.get::<_, bool>("has_generated") { reasons.push("table has generated/identity columns".into()); }
Ok(crate::models::RebuildReadiness { ok: reasons.is_empty(), reasons })
}
Some(_) => return Err("Rebuild is PostgreSQL-only".into()),
None => return Err("Connection not found".into()),
};
let row = client.query_one(&crate::db::introspection::pg_rebuild_readiness_query(), &[&schema, &table]).await
.map_err(|e| sanitize(&e.to_string()))?;
let mut reasons = Vec::new();
if row.get::<_, bool>("has_triggers") { reasons.push("table has triggers".into()); }
if row.get::<_, bool>("has_policies") { reasons.push("table has RLS policies".into()); }
if row.get::<_, bool>("is_inherits") { reasons.push("table participates in inheritance".into()); }
if row.get::<_, bool>("is_partitioned") { reasons.push("table is partitioned".into()); }
if row.get::<_, bool>("has_generated") { reasons.push("table has generated/identity columns".into()); }
Ok(crate::models::RebuildReadiness { ok: reasons.is_empty(), reasons })
}
}
#[tauri::command]
+46 -3
View File
@@ -58,19 +58,62 @@ async fn object_ddl_for_sequence_enum_function() {
}
#[tokio::test]
async fn build_object_ddl_inner_guards_postgresql_only() {
async fn build_object_ddl_inner_guards_missing_connection_and_rejects_unknown_op() {
let pm = tokio::sync::Mutex::new(ConnectionPoolManager::new());
// Missing connection -> Connection not found
let err = build_object_ddl_inner(&pm, "missing", "sequence", serde_json::json!({
"schema": "public", "name": "s", "action": { "op": "drop" }
})).await.unwrap_err();
assert!(err.contains("Connection not found"), "{err}");
// Non-PostgreSQL handle -> PostgreSQL-only error
// SQLite now dispatches to the SQLite table builders; non-table ops are rejected.
pm.lock().await.register("sqlite", DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()));
let err = build_object_ddl_inner(&pm, "sqlite", "sequence", serde_json::json!({
"schema": "public", "name": "s", "action": { "op": "drop" }
})).await.unwrap_err();
assert!(err.contains("PostgreSQL-only"), "{err}");
assert!(err.contains("unknown op"), "{err}");
}
/// In-memory SQLite pool registered under id "c" (no Tauri, no live PG).
fn fresh_pool_with_sqlite() -> ConnectionPoolManager {
let mut pm = ConnectionPoolManager::new();
pm.register("c", DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()));
pm
}
#[tokio::test]
async fn build_object_ddl_sqlite_create_yields_sqlite_sql() {
let pm = fresh_pool_with_sqlite();
let params = serde_json::json!({
"schema": "main", "name": "users",
"action": { "op": "create", "columns": [
{ "name": "id", "type": "integer", "nullable": false, "default": null, "is_pk": true, "auto_increment": true, "unique": false },
{ "name": "name", "type": "text", "nullable": true, "default": null, "is_pk": false, "auto_increment": false, "unique": false }
] }
});
let sqls = build_object_ddl_inner(&tokio::sync::Mutex::new(pm), "c", "table", params).await.unwrap();
assert!(sqls.iter().any(|s| s.contains("INTEGER PRIMARY KEY AUTOINCREMENT")), "{sqls:?}");
assert!(sqls.iter().all(|s| !s.contains("serial")), "{sqls:?}");
}
#[tokio::test]
async fn execute_change_sqlite_ddl_runs_create() {
let pm = tokio::sync::Mutex::new(fresh_pool_with_sqlite());
let change = crate::models::db_viewer::Change::Ddl { id: "x".into(), sql: "CREATE TABLE u(id INTEGER)".into() };
let r = crate::commands::db_viewer::execute_change_inner(&pm, "c", change).await;
assert!(r.is_ok(), "{r:?}");
// the table really landed on the live connection
{
let mut g = pm.lock().await;
match g.get("c").unwrap() {
crate::db::pool::DbHandle::Sqlite(conn) => {
let n: i64 = conn
.query_row("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='u'", [], |row| row.get(0))
.unwrap();
assert_eq!(n, 1);
}
_ => panic!("expected a sqlite handle"),
}
}
}
#[tokio::test]
+250 -11
View File
@@ -17,6 +17,30 @@ use std::time::Instant;
use tauri::State;
use uuid::Uuid;
// ---------------------------------------------------------------------------
// Cancel-error classification
// ---------------------------------------------------------------------------
// The wrapped→raw fallback exists for queries that can't be wrapped (CTEs,
// multi-statement, non-SELECT). A USER CANCELLATION is NOT a wrapping failure:
// swallowing it would re-run the very query the user just cancelled — and for
// SQLite the interrupt flag is consumed by the aborted step, so the re-run
// runs completely free. These helpers let the fallbacks propagate cancellations.
fn is_sqlite_cancel_error(e: &rusqlite::Error) -> bool {
e.sqlite_error_code() == Some(rusqlite::ErrorCode::OperationInterrupted)
}
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 {
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"),
}
}
// ---------------------------------------------------------------------------
// QueryHistoryEntry
// ---------------------------------------------------------------------------
@@ -87,6 +111,7 @@ impl From<crate::store::SavedQueryRow> for SavedQueryCommand {
pub(crate) async fn execute_query_inner(
pool_manager: &mut crate::db::pool::ConnectionPoolManager,
db_store: &std::sync::Mutex<crate::store::Store>,
cancel_registry: &crate::cancel::CancelRegistry,
connection_id: &str,
query: &str,
page: i64,
@@ -101,7 +126,25 @@ pub(crate) async fn execute_query_inner(
execute_pg_query(client, query, page, page_size).await
}
Some(DbHandle::Sqlite(conn)) => execute_sqlite_query(conn, query, page, page_size),
Some(DbHandle::MySql(pool)) => execute_mysql_query(pool, query, page, page_size).await,
Some(DbHandle::MySql(pool)) => {
// Run on a dedicated pooled connection so the query's MySQL
// CONNECTION_ID can be tracked for cancellation (`KILL QUERY ?`).
let mut conn = pool
.acquire()
.await
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
// CAST to SIGNED: MySQL returns CONNECTION_ID() as BIGINT UNSIGNED,
// which sqlx refuses to decode into i64 (ColumnDecode error) — a
// silent unwrap_or(-1) here would make KILL QUERY -1 fail.
let conn_id: i64 = sqlx::query_scalar("SELECT CAST(CONNECTION_ID() AS SIGNED)")
.fetch_one(&mut *conn)
.await
.unwrap_or(-1);
cancel_registry.set_mysql_conn_id(connection_id, Some(conn_id));
let result = execute_mysql_query_on(&mut *conn, query, page, page_size).await;
cancel_registry.set_mysql_conn_id(connection_id, None);
result
}
None => {
let elapsed = start.elapsed().as_millis() as i64;
let err = "Connection not found".to_string();
@@ -210,6 +253,7 @@ async fn execute_pg_query(
// Try the wrapped count query first — if this fails we fall back to raw.
let total_rows: i64 = match client.query_one(&wrapped_count, &[]).await {
Ok(row) => row.get::<_, i64>(0),
Err(e) if is_pg_cancel_error(&e) => return Err("Query cancelled".to_string()),
Err(_) => {
// Wrapping failed — fall back to raw execution.
return execute_pg_raw(client, trimmed, page, page_size, off).await;
@@ -219,6 +263,7 @@ async fn execute_pg_query(
// Now execute the wrapped data query.
let data_rows = match client.query(&wrapped_data, &[&page_size, &off]).await {
Ok(rows) => rows,
Err(e) if is_pg_cancel_error(&e) => return Err("Query cancelled".to_string()),
Err(_) => {
return execute_pg_raw(client, trimmed, page, page_size, off).await;
}
@@ -381,6 +426,7 @@ fn execute_sqlite_query(
// Try the wrapped count query first.
let total_rows: i64 = match conn.query_row(&wrapped_count, [], |row| row.get::<_, i64>(0)) {
Ok(n) => n,
Err(e) if is_sqlite_cancel_error(&e) => return Err("Query cancelled".to_string()),
Err(_) => {
// Wrapping failed — fall back to raw execution.
return execute_sqlite_raw(conn, trimmed, page, page_size, off);
@@ -390,6 +436,11 @@ fn execute_sqlite_query(
// Execute the wrapped data query.
let (columns, all_rows) = match execute_sqlite_with_query(conn, &wrapped_data) {
Ok(result) => result,
// The data-step error is already a String (mapped inside
// execute_sqlite_with_query); classify by the interrupt message.
Err(e) if e.to_lowercase().contains("interrupted") => {
return Err("Query cancelled".to_string());
}
Err(_) => {
return execute_sqlite_raw(conn, trimmed, page, page_size, off);
}
@@ -533,8 +584,8 @@ pub(crate) fn mysql_cell_to_json(row: &sqlx::mysql::MySqlRow, i: usize) -> serde
serde_json::Value::Null
}
async fn execute_mysql_query(
pool: &sqlx::MySqlPool,
async fn execute_mysql_query_on(
conn: &mut sqlx::mysql::MySqlConnection,
query: &str,
page: i64,
page_size: i64,
@@ -547,21 +598,23 @@ async fn execute_mysql_query(
// Try the wrapped count first; fall back to raw on failure.
let total_rows: i64 = match sqlx::query_scalar::<_, i64>(&mysql_wrap_count(trimmed))
.fetch_one(pool)
.fetch_one(&mut *conn)
.await
{
Ok(n) => n,
Err(_) => return execute_mysql_raw(pool, trimmed, page, page_size, off).await,
Err(e) if is_mysql_cancel_error(&e) => return Err("Query cancelled".to_string()),
Err(_) => return execute_mysql_raw(&mut *conn, trimmed, page, page_size, off).await,
};
let data_rows = match sqlx::query(&mysql_wrap_data(trimmed))
.bind(page_size)
.bind(off)
.fetch_all(pool)
.fetch_all(&mut *conn)
.await
{
Ok(rows) => rows,
Err(_) => return execute_mysql_raw(pool, trimmed, page, page_size, off).await,
Err(e) if is_mysql_cancel_error(&e) => return Err("Query cancelled".to_string()),
Err(_) => return execute_mysql_raw(&mut *conn, trimmed, page, page_size, off).await,
};
let columns: Vec<ColumnInfo> = match data_rows.first() {
@@ -580,7 +633,7 @@ async fn execute_mysql_query(
is_generated: false,
})
.collect(),
None => return execute_mysql_raw(pool, trimmed, page, page_size, off).await,
None => return execute_mysql_raw(&mut *conn, trimmed, page, page_size, off).await,
};
let rows: Vec<Vec<serde_json::Value>> = data_rows
@@ -602,14 +655,14 @@ async fn execute_mysql_query(
/// mirroring the PG `simple_query` raw path. Used when wrapping fails
/// (e.g., multi-statement or non-selectable SQL).
async fn execute_mysql_raw(
pool: &sqlx::MySqlPool,
conn: &mut sqlx::mysql::MySqlConnection,
query: &str,
page: i64,
page_size: i64,
off: i64,
) -> Result<QueryResult, String> {
let rows = sqlx::query(query)
.fetch_all(pool)
.fetch_all(&mut *conn)
.await
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
@@ -748,7 +801,70 @@ pub async fn execute_query(
let p = page.unwrap_or(1);
let ps = page_size.unwrap_or(50);
let mut pm = state.pool_manager.lock().await;
execute_query_inner(&mut pm, &state.db_store, &connection_id, &query, p, ps).await
execute_query_inner(
&mut pm,
&state.db_store,
&state.cancel_registry,
&connection_id,
&query,
p,
ps,
)
.await
}
/// Cancel a query currently running on the given connection.
///
/// - PostgreSQL: opens a short-lived cancel connection (reusing the exact TLS
/// decision/config from connect) and sends a cancel request keyed to the
/// original backend.
/// - MySQL: opens a fresh connection and runs `KILL QUERY <conn_id>` for the
/// connection currently running the query (registered per-query).
/// - SQLite: signals the per-connection `InterruptHandle` (thread-safe).
#[tauri::command]
pub async fn cancel_query(
connection_id: String,
state: State<'_, crate::AppState>,
) -> Result<(), String> {
use sqlx::ConnectOptions;
match state.cancel_registry.get(&connection_id) {
Some(crate::cancel::CancelHandle::Pg(pg)) => {
// A Verify/Require decision always carries a built rustls config,
// so the unwrap on the non-Disable branch is safe by construction.
match pg.tls_decision {
crate::db::tls::TlsDecision::Disable => {
pg.cancel_token.cancel_query(tokio_postgres::NoTls).await
}
_ => {
let connector = tokio_postgres_rustls::MakeRustlsConnect::new(
(*pg.tls_config.expect("tls config for non-disable decision")).clone(),
);
pg.cancel_token.cancel_query(connector).await
}
}
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))
}
Some(crate::cancel::CancelHandle::MySql(m)) => {
let id = m
.conn_id
.ok_or_else(|| "No active query on this connection".to_string())?;
let mut c = m
.connect_options
.connect()
.await
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
sqlx::query(&format!("KILL QUERY {id}"))
.execute(&mut c)
.await
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
Ok(())
}
Some(crate::cancel::CancelHandle::Sqlite(s)) => {
s.interrupt();
Ok(())
}
None => Err("No active cancel handle for this connection".into()),
}
}
#[tauri::command]
@@ -1138,4 +1254,127 @@ mod tests {
let q = mysql_wrap_count("SELECT * FROM t");
assert_eq!(q, "SELECT COUNT(*) FROM (SELECT * FROM t) AS _gridline_cnt");
}
// ------------------------------------------------------------------
// Cancel propagation (v0.7.8 bugfix): a user cancel must NOT be swallowed
// by the wrapped→raw fallback (which would re-run the cancelled query).
// ------------------------------------------------------------------
#[test]
fn is_sqlite_cancel_error_classifies_interrupt() {
let interrupted = rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_INTERRUPT),
Some("interrupted".to_string()),
);
assert!(is_sqlite_cancel_error(&interrupted));
let other = rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_ERROR),
Some("SQL logic error".to_string()),
);
assert!(!is_sqlite_cancel_error(&other));
}
#[test]
fn sqlite_cancel_aborts_wrapped_query_without_rerun() {
use std::sync::mpsc;
// A slow query whose wrapped COUNT step takes seconds — interrupt() must
// abort it and surface "Query cancelled" instead of falling back to the
// raw re-run (which would consume the interrupt flag and run to
// completion, hiding the cancellation).
let conn = rusqlite::Connection::open_in_memory().unwrap();
let handle = conn.get_interrupt_handle();
let slow = "WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM c LIMIT 50000000) SELECT count(*) AS n FROM c";
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let res = execute_sqlite_query(&conn, slow, 1, 50);
let cancelled = match &res {
Err(msg) => msg.contains("Query cancelled"),
Ok(_) => false,
};
let _ = tx.send(cancelled);
});
std::thread::sleep(std::time::Duration::from_millis(50));
handle.interrupt();
let cancelled = rx
.recv_timeout(std::time::Duration::from_secs(15))
.expect("query thread must finish");
assert!(cancelled, "cancel must abort the wrapped query with 'Query cancelled' instead of re-running it");
}
#[tokio::test]
#[ignore]
async fn pg_cancel_aborts_wrapped_query_without_rerun() {
let h = std::env::var("GRIDLINE_TEST_PG_HOST").expect("set GRIDLINE_TEST_PG_HOST");
let p: u16 = std::env::var("GRIDLINE_TEST_PG_PORT")
.unwrap_or_else(|_| "5432".into())
.parse()
.unwrap();
let u = std::env::var("GRIDLINE_TEST_PG_USER").expect("set GRIDLINE_TEST_PG_USER");
let d = std::env::var("GRIDLINE_TEST_PG_DB").expect("set GRIDLINE_TEST_PG_DB");
let pw = std::env::var("GRIDLINE_TEST_PG_PASSWORD").unwrap_or_default();
let (client, conn) = tokio_postgres::connect(
&format!("host={h} port={p} user={u} dbname={d} password={pw}"),
tokio_postgres::NoTls,
)
.await
.expect("connect to test PG");
let handle = tokio::spawn(async move {
let _ = conn.await;
});
let token = client.cancel_token();
let run = tokio::spawn(async move {
execute_pg_query(&client, "SELECT pg_sleep(3)", 1, 50).await
});
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
token
.cancel_query(tokio_postgres::NoTls)
.await
.expect("cancel request");
let res = run.await.expect("query task");
assert!(res.is_err(), "pg_sleep must be cancelled, not re-run; got {res:?}");
assert!(res.unwrap_err().contains("Query cancelled"));
let _ = handle;
}
#[tokio::test]
#[ignore]
async fn mysql_cancel_aborts_wrapped_query_without_rerun() {
use sqlx::ConnectOptions;
let h = std::env::var("GRIDLINE_TEST_MYSQL_HOST").expect("set GRIDLINE_TEST_MYSQL_HOST");
let p: u16 = std::env::var("GRIDLINE_TEST_MYSQL_PORT")
.unwrap_or_else(|_| "3306".into())
.parse()
.unwrap();
let u = std::env::var("GRIDLINE_TEST_MYSQL_USER").expect("set GRIDLINE_TEST_MYSQL_USER");
let pw = std::env::var("GRIDLINE_TEST_MYSQL_PASS").unwrap_or_default();
let db = std::env::var("GRIDLINE_TEST_MYSQL_DB").unwrap_or_default();
let opts = sqlx::mysql::MySqlConnectOptions::new()
.host(&h)
.port(p)
.username(&u)
.password(&pw)
.database(&db);
let pool = sqlx::mysql::MySqlPoolOptions::new()
.connect_with(opts.clone())
.await
.expect("connect to test MySQL");
let mut conn = pool.acquire().await.expect("acquire");
let conn_id: i64 = sqlx::query_scalar("SELECT CAST(CONNECTION_ID() AS SIGNED)")
.fetch_one(&mut *conn)
.await
.unwrap();
let run = tokio::spawn(async move {
execute_mysql_query_on(&mut *conn, "SELECT SLEEP(3)", 1, 50).await
});
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
let mut killer = opts.connect().await.expect("killer connect");
sqlx::query(&format!("KILL QUERY {conn_id}"))
.execute(&mut killer)
.await
.expect("kill");
let res = run.await.expect("query task");
assert!(res.is_err(), "SLEEP(3) must be killed, not re-run; got {res:?}");
assert!(res.unwrap_err().contains("Query cancelled"));
}
}
+90
View File
@@ -1,5 +1,6 @@
use crate::models::Settings;
use crate::store::Store;
use std::collections::HashMap;
use std::sync::Mutex;
pub fn get_settings_inner(state: &Mutex<Store>) -> Result<Settings, String> {
@@ -26,6 +27,68 @@ pub fn update_setting(
update_setting_inner(&state.db_store, &key, &value)
}
// ---------------------------------------------------------------------------
// Settings export / import (v0.7.8)
// ---------------------------------------------------------------------------
/// Flatten a `Settings` struct into the store's key/value map. Keys and value
/// formats must round-trip through `Store::get_settings` (e.g. a `None`
/// `default_folder_id` is stored as the literal `"null"` sentinel, which
/// `get_settings` filters back to `None`).
fn settings_to_kv(s: &crate::models::Settings) -> HashMap<String, String> {
let mut m = HashMap::new();
m.insert("confirm_before_delete".into(), s.confirm_before_delete.to_string());
if let Some(f) = &s.default_folder_id {
m.insert("default_folder_id".into(), f.clone());
} else {
m.insert("default_folder_id".into(), "null".into());
}
m.insert("theme".into(), s.theme.clone());
m.insert("font_size".into(), s.font_size.clone());
m.insert("accent_color".into(), s.accent_color.clone());
m.insert("table_refresh_rate".into(), s.table_refresh_rate.to_string());
m.insert("table_page_size".into(), s.table_page_size.to_string());
m.insert("editor_font_size".into(), s.editor_font_size.to_string());
m.insert("editor_font_family".into(), s.editor_font_family.clone());
m.insert("editor_word_wrap".into(), s.editor_word_wrap.clone());
m.insert("editor_minimap".into(), s.editor_minimap.to_string());
m.insert("editor_tab_size".into(), s.editor_tab_size.to_string());
if let Some(o) = &s.tag_order {
m.insert("tag_order".into(), o.clone());
}
m.insert(
"default_ports".into(),
serde_json::to_string(&s.default_ports).unwrap_or_default(),
);
m.insert(
"shortcuts".into(),
serde_json::to_string(&s.shortcuts).unwrap_or_default(),
);
m
}
#[tauri::command]
pub fn export_settings(state: tauri::State<crate::AppState>) -> Result<String, String> {
let s = get_settings_inner(&state.db_store)?;
serde_json::to_string(&crate::models::settings::SettingsExport {
schema_version: 1,
settings: s,
})
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn import_settings(
json: String,
state: tauri::State<crate::AppState>,
) -> Result<(), String> {
let env: crate::models::settings::SettingsExport =
serde_json::from_str(&json).map_err(|e| format!("invalid settings file: {e}"))?;
let map = settings_to_kv(&env.settings);
let store = state.db_store.lock().map_err(|e| e.to_string())?;
store.apply_settings(&map)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -59,4 +122,31 @@ mod tests {
update_setting_inner(&st, "accent_color", "#EF4444").unwrap();
assert_eq!(get_settings_inner(&st).unwrap().accent_color, "#EF4444");
}
#[test]
fn settings_export_envelope_camel_case() {
use std::collections::HashMap;
let env = crate::models::settings::SettingsExport { schema_version: 1, settings: crate::models::Settings {
confirm_before_delete: true, default_folder_id: None, theme: "dark".into(), font_size: "medium".into(),
default_ports: HashMap::new(), tag_order: None, table_refresh_rate: 5, table_page_size: 50,
shortcuts: HashMap::new(), accent_color: "#2563EB".into(), editor_font_size: 14,
editor_font_family: "Menlo".into(), editor_word_wrap: "off".into(), editor_minimap: true, editor_tab_size: 2,
}};
let json = serde_json::to_string(&env).unwrap();
assert!(json.contains("\"schemaVersion\":1"));
assert!(json.contains("\"settings\":"));
}
#[test]
fn store_apply_settings_writes_all_keys() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::store::migrations::run_migrations(&conn).unwrap();
let store = crate::store::Store::from_connection(conn);
let mut map = std::collections::HashMap::new();
map.insert("theme".to_string(), "light".to_string());
map.insert("accent_color".to_string(), "#EF4444".to_string());
store.apply_settings(&map).unwrap();
assert_eq!(store.get_settings().unwrap().theme, "light");
assert_eq!(store.get_settings().unwrap().accent_color, "#EF4444");
}
}
+152
View File
@@ -1,7 +1,21 @@
//! Pure builders for schema DDL, cross-object search, pg_depend lookups,
//! and synthesized object DDL. No DB I/O — deterministic string builders.
use serde::{Deserialize, Serialize};
use crate::models::db_viewer::{SequenceInfo, EnumInfo, ExtensionInfo, ConstraintInfo};
/// Column model for the SQLite table editor. Mirrors the frontend payload.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SqliteColumn {
pub name: String,
#[serde(rename = "type")]
pub type_: String,
pub nullable: bool,
pub default: Option<String>,
pub is_pk: bool,
pub auto_increment: bool,
pub unique: bool,
}
/// Double-quote an identifier, doubling embedded quotes.
pub fn quote_ident(name: &str) -> String {
format!("\"{}\"", name.replace('"', "\"\""))
@@ -125,6 +139,98 @@ pub fn constraint_ddl(c: &ConstraintInfo) -> String {
quote_ident(&c.schema), quote_ident(&c.table), quote_ident(&c.name), c.definition)
}
// --- SQLite table editor ---
fn validate_type_fragment(t: &str) -> Result<(), String> {
let l = t.trim().to_lowercase();
if l.is_empty() { return Err("column type is required".into()); }
if l.contains(';') || l.contains("--") || l.contains("/*") { return Err("invalid characters in type".into()); }
Ok(())
}
/// CREATE TABLE for SQLite. PK inline for AUTOINCREMENT; single non-AUTOINCREMENT
/// PKs get a table-level PRIMARY KEY clause; FKs appended inline (SQLite grammar).
pub fn sqlite_create_table_sql(table: &str, cols: &[SqliteColumn], fks: &[(&str, &str)]) -> Result<String, String> {
validate_object_name(table)?;
let mut defs: Vec<String> = vec![];
let mut pk_cols: Vec<String> = vec![];
for c in cols {
validate_object_name(&c.name)?;
validate_type_fragment(&c.type_)?;
let mut d = format!("{} {}", quote_ident(&c.name), c.type_.trim());
if c.auto_increment && c.is_pk && c.type_.trim().eq_ignore_ascii_case("INTEGER") {
d = format!("{} INTEGER PRIMARY KEY AUTOINCREMENT", quote_ident(&c.name));
} else {
if !c.nullable { d.push_str(" NOT NULL"); }
if let Some(def) = &c.default { d.push_str(&format!(" DEFAULT {def}")); }
if c.unique && !c.is_pk { d.push_str(" UNIQUE"); }
if c.is_pk { pk_cols.push(quote_ident(&c.name)); }
}
defs.push(d);
}
// Always emit a table-level PRIMARY KEY when there are non-AUTOINCREMENT PK
// columns (single or composite) — the plan draft's `len > 1` condition would
// silently drop a single-column PK constraint.
if !pk_cols.is_empty() {
defs.push(format!("PRIMARY KEY ({})", pk_cols.join(", ")));
}
for (lc, refc) in fks {
defs.push(format!("FOREIGN KEY ({}) REFERENCES {}", quote_ident(lc), refc));
}
Ok(format!("CREATE TABLE \"main\".{} ({})", quote_ident(table), defs.join(", ")))
}
/// Emit one statement per needed edit; falls back to a rebuild script (multi-stmt)
/// for edits SQLite's ALTER TABLE can't express.
pub fn sqlite_column_diff_sql(table: &str, old: &[SqliteColumn], new: &[SqliteColumn]) -> Result<Vec<String>, String> {
// rename detection: same position+type+nullable+default, name changed
for (i, n) in new.iter().enumerate() {
if let Some(o) = old.get(i) {
if o.name != n.name && o.type_ == n.type_ && o.default == n.default && o.nullable == n.nullable {
return Ok(vec![format!("ALTER TABLE \"main\".{} RENAME COLUMN {} TO {}", quote_ident(table), quote_ident(&o.name), quote_ident(&n.name))]);
}
}
}
// add column (new tail column, safe only if nullable or defaulted)
if new.len() > old.len() {
if let Some(c) = new.last() {
if c.nullable || c.default.is_some() {
validate_object_name(&c.name)?;
validate_type_fragment(&c.type_)?;
let mut d = format!("ALTER TABLE \"main\".{} ADD COLUMN {} {}", quote_ident(table), quote_ident(&c.name), c.type_.trim());
if !c.nullable {
d.push_str(&format!(" DEFAULT {}", c.default.as_deref().unwrap_or("''")));
}
return Ok(vec![d]);
}
}
}
// otherwise: full rebuild (type change, NOT NULL, drop, PK/UNIQUE/FK add, reorder)
sqlite_rebuild_script(table, old, new)
}
pub fn sqlite_rebuild_script(table: &str, _old: &[SqliteColumn], new: &[SqliteColumn]) -> Result<Vec<String>, String> {
validate_object_name(table)?;
let tmp = format!("_gl_{}_tmp", table);
let create = sqlite_create_table_sql(&tmp, new, &[])?;
let cols = new.iter().map(|c| quote_ident(&c.name)).collect::<Vec<_>>().join(", ");
Ok(vec![
create,
format!("INSERT INTO \"main\".{} ({}) SELECT * FROM \"main\".{}", quote_ident(&tmp), cols, quote_ident(table)),
format!("DROP TABLE \"main\".{}", quote_ident(table)),
format!("ALTER TABLE \"main\".{} RENAME TO {}", quote_ident(&tmp), quote_ident(table)),
])
}
/// Fail-closed readiness reason, or None if rebuild is safe.
/// AUTOINCREMENT tables are refused in v0.7.8 (rowid counter would be lost).
pub fn sqlite_rebuild_refusal(old: &[SqliteColumn]) -> Option<String> {
if old.iter().any(|c| c.auto_increment) {
return Some("rebuild is not supported for AUTOINCREMENT tables in v0.7.8 (rowid counter would be lost)".into());
}
None
}
#[cfg(test)]
mod tests {
use super::*;
@@ -258,4 +364,50 @@ mod tests {
let ddl = constraint_ddl(&c);
assert_eq!(ddl, "ALTER TABLE \"public\".\"orders\" ADD CONSTRAINT \"ck_pos\" CHECK (amount > 0)");
}
#[test]
fn sqlite_create_with_autoincrement_pk() {
let cols = vec![
SqliteColumn { name: "id".into(), type_: "INTEGER".into(), nullable: false, default: None, is_pk: true, auto_increment: true, unique: false },
SqliteColumn { name: "name".into(), type_: "TEXT".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false },
];
let sql = sqlite_create_table_sql("users", &cols, &[]).unwrap();
assert!(sql.contains("\"id\" INTEGER PRIMARY KEY AUTOINCREMENT"));
assert!(sql.contains("\"name\" TEXT"));
assert!(sql.starts_with("CREATE TABLE \"main\".\"users\""));
}
#[test]
fn sqlite_create_rejects_bad_identifier() {
let cols = vec![SqliteColumn { name: "a;b".into(), type_: "INTEGER".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false }];
assert!(sqlite_create_table_sql("bad name", &cols, &[]).is_err());
}
#[test]
fn sqlite_edit_add_column_when_safe() {
let old = vec![SqliteColumn { name: "id".into(), type_: "INTEGER".into(), nullable: false, default: None, is_pk: true, auto_increment: true, unique: false }];
let new = vec![
old[0].clone(),
SqliteColumn { name: "email".into(), type_: "TEXT".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false },
];
let stmts = sqlite_column_diff_sql("users", &old, &new).unwrap();
assert_eq!(stmts.len(), 1);
assert!(stmts[0].contains("ALTER TABLE \"main\".\"users\" ADD COLUMN \"email\" TEXT"));
}
#[test]
fn sqlite_edit_rename_column() {
let old = vec![SqliteColumn { name: "id".into(), type_: "INTEGER".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false }];
let new = vec![SqliteColumn { name: "id2".into(), type_: "INTEGER".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false }];
let stmts = sqlite_column_diff_sql("t", &old, &new).unwrap();
assert!(stmts.iter().any(|s| s.contains("RENAME COLUMN \"id\" TO \"id2\"")));
}
#[test]
fn sqlite_edit_typechange_requires_rebuild() {
let old = vec![SqliteColumn { name: "v".into(), type_: "TEXT".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false }];
let new = vec![SqliteColumn { name: "v".into(), type_: "INTEGER".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false }];
let stmts = sqlite_column_diff_sql("t", &old, &new).unwrap();
assert!(stmts.iter().any(|s| s.contains("CREATE TABLE \"main\".\"_gl_t_tmp\"")), "type change must rebuild; got {stmts:?}");
}
}
+16
View File
@@ -2,6 +2,7 @@
// of runtime usage, producing expected dead_code/unused warnings during development.
#![allow(dead_code)]
mod cancel;
mod commands;
mod db;
mod models;
@@ -17,6 +18,7 @@ pub struct AppState {
pub db_store: StdMutex<Store>,
pub pool_manager: tokio::sync::Mutex<ConnectionPoolManager>,
pub ssh_manager: StdMutex<SshTunnelManager>,
pub cancel_registry: crate::cancel::CancelRegistry,
}
use commands::{
@@ -48,6 +50,7 @@ pub fn run() {
db_store: store_ref,
pool_manager: tokio::sync::Mutex::new(ConnectionPoolManager::new()),
ssh_manager: StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend))),
cancel_registry: crate::cancel::CancelRegistry::default(),
})
.setup(move |app| {
let state = app.state::<AppState>();
@@ -69,6 +72,9 @@ pub fn run() {
if let Ok(mut mgr) = s.ssh_manager.lock() {
mgr.close_tunnel(id);
}
// Drop the cancel handles for the evicted connection
// (tokens/interrupts outlive the pool otherwise).
s.cancel_registry.remove(id);
}
}));
@@ -143,8 +149,18 @@ pub fn run() {
backup::pg_dump,
backup::pg_restore,
backup::db_sync,
backup::detect_mysql_tools,
backup::mysql_dump,
backup::mysql_restore,
backup::mysql_sync,
backup::sqlite_dump,
backup::sqlite_restore,
backup::sqlite_sync,
settings::export_settings,
settings::import_settings,
schema_graph::get_schema_graph,
query::execute_query,
query::cancel_query,
query::get_query_history,
query::clear_query_history,
query::set_history_favorite,
+125
View File
@@ -26,6 +26,12 @@ pub struct SyncOptions {
pub target_connection_id: String,
pub schema: Option<String>,
pub tables: Option<Vec<String>>,
#[serde(default = "default_sync_db_type")]
pub db_type: String,
}
fn default_sync_db_type() -> String {
"postgresql".into()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -46,6 +52,73 @@ pub struct PgToolPaths {
pub psql: String,
}
/// Connection params for a MySQL server (decoupled from store/keychain so the
/// dump/restore/sync core stays headless-testable). Mirrors `PgConnParams`.
#[derive(Debug, Clone)]
pub struct MySqlConnParams {
pub host: String,
pub port: i64,
pub username: String,
pub database: String,
pub password: String,
}
impl MySqlConnParams {
pub fn new(host: String, port: i64, username: String, database: String, password: String) -> Self {
Self { host, port, username, database, password }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MySqlBackupOptions {
pub database: String,
pub file_path: String,
pub single_transaction: bool,
pub no_data: bool,
pub routines: bool,
pub triggers: bool,
pub events: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MySqlRestoreOptions {
pub database: String,
pub file_path: String,
pub clean: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SqliteBackupOptions {
pub file_path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SqliteRestoreOptions {
pub file_path: String,
pub clean: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MySqlToolStatus {
pub mysqldump_found: bool,
pub mysql_found: bool,
pub mysqldump_version: Option<String>,
pub mysql_version: Option<String>,
pub mysqldump_source: Option<String>,
pub mysql_source: Option<String>,
}
#[derive(Debug, Clone)]
pub struct MySqlToolPaths {
pub mysqldump: String,
pub mysql: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackupJob {
pub id: String,
@@ -117,4 +190,56 @@ mod tests {
assert!(json.contains("\"pg_dump_source\":\"system\""));
assert!(json.contains("\"pg_restore_source\":\"bundled\""));
}
#[test]
fn mysql_backup_options_serialize_camel_case() {
let opts = MySqlBackupOptions {
database: "shop".into(),
file_path: "/tmp/dump.sql".into(),
single_transaction: true,
no_data: false,
routines: true,
triggers: true,
events: false,
};
let json = serde_json::to_string(&opts).unwrap();
assert!(json.contains("\"singleTransaction\":true"));
assert!(json.contains("\"filePath\":\"/tmp/dump.sql\""));
assert!(!json.contains("no_owner"));
}
#[test]
fn sqlite_restore_options_serialize_camel_case() {
let opts = SqliteRestoreOptions { file_path: "/tmp/in.sql".into(), clean: true };
let json = serde_json::to_string(&opts).unwrap();
assert!(json.contains("\"filePath\":\"/tmp/in.sql\""));
assert!(json.contains("\"clean\":true"));
}
#[test]
fn mysql_tool_status_reports_source() {
let s = MySqlToolStatus {
mysqldump_found: true,
mysql_found: true,
mysqldump_version: Some("mariadb-dump 10.6".into()),
mysql_version: Some("mariadb 10.6".into()),
mysqldump_source: Some("bundled".into()),
mysql_source: Some("system".into()),
};
let json = serde_json::to_string(&s).unwrap();
assert!(json.contains("\"mysqldumpSource\":\"bundled\""));
}
#[test]
fn sync_options_carry_db_type() {
let s = SyncOptions {
source_connection_id: "a".into(),
target_connection_id: "b".into(),
schema: None,
tables: None,
db_type: "mysql".into(),
};
let json = serde_json::to_string(&s).unwrap();
assert!(json.contains("\"dbType\":\"mysql\""));
}
}
+7
View File
@@ -20,3 +20,10 @@ pub struct Settings {
pub editor_minimap: bool,
pub editor_tab_size: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SettingsExport {
pub schema_version: u32,
pub settings: Settings,
}
+15
View File
@@ -575,6 +575,21 @@ impl Store {
Ok(())
}
/// Bulk-write settings keys in one transaction (used by settings import).
pub fn apply_settings(&self, map: &std::collections::HashMap<String, String>) -> Result<(), String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
conn.execute_batch("BEGIN").map_err(|e| e.to_string())?;
for (k, v) in map.iter() {
conn.execute(
"INSERT INTO settings(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
rusqlite::params![k, v],
)
.map_err(|e| e.to_string())?;
}
conn.execute_batch("COMMIT").map_err(|e| e.to_string())?;
Ok(())
}
/// Insert a row into the `query_history` table.
/// Dedups consecutive identical queries per connection (UPDATE the last row
/// instead of INSERTing a new one) and prunes to at most 500 rows per connection.
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Gridline",
"version": "0.7.7",
"version": "0.7.8",
"identifier": "com.adrianbonpin.gridline",
"build": {
"beforeDevCommand": "bun run dev",
@@ -26,7 +26,7 @@
"bundle": {
"active": true,
"targets": "all",
"resources": ["resources/pg_tools/*"],
"resources": ["resources/pg_tools/*", "resources/mysql_tools/*"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",