refactor: convert to bun-workspaces monorepo (desktop/ + www/)

- Move the Tauri app (React frontend + Rust backend) into desktop/ via
  git mv — configs unchanged (relative paths: tauri.conf.json, vite.config.ts)
- Root package.json becomes a private bun workspace container with
  orchestration scripts; desktop package renamed gridline-desktop
- Scaffold www/ with Astro 7 + Tailwind v4 (hand-wired vite plugin,
  static output, ready for Dokploy)
- Update release.yml for the new layout: projectPath: desktop,
  desktop/src-tauri resource paths, rust-cache workspace path
- Update README + AGENTS.md structure trees and dev commands
- Verified: vitest (1074), cargo test (354), desktop build, tauri dev
  (window launches), tauri build (dmg + app bundles), Astro build
This commit is contained in:
2026-08-16 19:31:53 +08:00
parent d752642b4d
commit 6854d889c5
400 changed files with 857 additions and 149 deletions
+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")
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,534 @@
use super::*;
// ------------------------------------------------------------------
// build_args_for_test (unit tests for arg construction logic)
// ------------------------------------------------------------------
#[test]
fn build_pg_dump_args_plain_format() {
let args = build_args_for_test(
"pg_dump",
"mydb",
"plain",
"/tmp/dump.sql",
true,
None,
None,
);
assert!(
args.iter().any(|a| a.contains("--no-owner")),
"should include --no-owner"
);
assert!(
args.iter().any(|a| a == "--file=/tmp/dump.sql"),
"should include --file flag"
);
// plain format should NOT add a --format flag
assert!(
!args.iter().any(|a| a.starts_with("--format")),
"plain format should not emit --format"
);
}
#[test]
fn build_pg_dump_args_custom_format() {
let args = build_args_for_test(
"pg_dump",
"mydb",
"custom",
"/tmp/dump.bak",
true,
Some("public"),
None,
);
assert!(
args.iter().any(|a| a == "--format=c"),
"custom format should emit --format=c"
);
assert!(
args.iter().any(|a| a == "--schema=public"),
"should include --schema flag"
);
}
#[test]
fn build_pg_dump_args_tar_format() {
let args = build_args_for_test(
"pg_dump",
"testdb",
"tar",
"/tmp/test.tar",
false,
None,
None,
);
assert!(
args.iter().any(|a| a == "--format=t"),
"should emit --format=t"
);
assert!(
!args.iter().any(|a| a.contains("--no-owner")),
"should NOT include --no-owner when false"
);
}
#[test]
fn build_pg_dump_args_directory_format() {
let args = build_args_for_test(
"pg_dump",
"proddb",
"directory",
"/tmp/dumpdir",
false,
None,
Some(vec!["users", "orders"]),
);
assert!(
args.iter().any(|a| a == "--format=d"),
"should emit --format=d"
);
assert!(args.iter().any(|a| a == "--table=users"));
assert!(args.iter().any(|a| a == "--table=orders"));
}
#[test]
fn build_pg_restore_args() {
let args = build_args_for_test(
"pg_restore",
"targetdb",
"custom",
"/tmp/dump.bak",
false,
None,
None,
);
// pg_restore should NOT emit --file=, it should pass the path as positional
assert!(
!args.iter().any(|a| a.starts_with("--file")),
"pg_restore should not use --file flag"
);
assert!(
args.iter().any(|a| a == "/tmp/dump.bak"),
"pg_restore should include file path as positional arg"
);
}
#[test]
fn build_pg_restore_args_with_schema() {
let args = build_args_for_test(
"pg_restore",
"mydb",
"plain",
"/tmp/dump.sql",
false,
Some("public"),
None,
);
assert!(args.iter().any(|a| a == "--schema=public"));
}
// ------------------------------------------------------------------
// detect_pg_tools
// ------------------------------------------------------------------
#[test]
fn detect_pg_tools_does_not_panic() {
// detect_pg_tools needs a Tauri AppHandle; the headless core keeps the
// status-shaping logic testable without one.
let status = build_pg_tool_status("pg_dump", "pg_restore", None, None);
// May or may not find tools, but the call itself must not panic
let _ = status.pg_dump_found;
let _ = status.pg_restore_found;
let _ = status.pg_dump_version;
let _ = status.pg_restore_version;
}
#[test]
fn pg_tool_status_serialization() {
let status = PgToolStatus {
pg_dump_found: true,
pg_restore_found: false,
pg_dump_version: Some("pg_dump (PostgreSQL) 16.0".into()),
pg_restore_version: None,
pg_dump_source: None,
pg_restore_source: None,
};
let json = serde_json::to_string(&status).unwrap();
assert!(json.contains("pg_dump_found"));
assert!(json.contains("pg_restore_found"));
assert!(json.contains("pg_dump (PostgreSQL) 16.0"));
}
// ------------------------------------------------------------------
// BackupProgressEvent serialization
// ------------------------------------------------------------------
#[test]
fn backup_progress_event_completed() {
let evt = BackupProgressEvent {
job_id: "job-1".into(),
status: "completed".into(),
progress: Some(1.0),
output_line: None,
error: None,
};
let json = serde_json::to_string(&evt).unwrap();
assert!(json.contains("\"completed\""));
assert!(json.contains("\"progress\":1.0"));
}
#[test]
fn backup_progress_event_failed() {
let evt = BackupProgressEvent {
job_id: "job-2".into(),
status: "failed".into(),
progress: None,
output_line: None,
error: Some("connection refused".into()),
};
let json = serde_json::to_string(&evt).unwrap();
assert!(json.contains("\"failed\""));
assert!(json.contains("\"connection refused\""));
}
// ------------------------------------------------------------------
// Integration tests (headless, against live DBs)
//
// These exercise the real dump/restore/sync code path (run_pg_dump,
// run_pg_restore, run_db_sync) with passwords passed directly — the
// only thing skipped is the OS-keychain lookup, which is a thin,
// separately-tested concern.
//
// They are #[ignore]d by default so they don't run in normal `cargo test`.
// Run them explicitly with:
//
// GRIDLINE_TEST_SRC_HOST=... GRIDLINE_TEST_SRC_PORT=... \
// GRIDLINE_TEST_SRC_USER=... GRIDLINE_TEST_SRC_DB=... \
// GRIDLINE_TEST_SRC_PASSWORD=... \
// GRIDLINE_TEST_TGT_HOST=... GRIDLINE_TEST_TGT_PORT=... \
// GRIDLINE_TEST_TGT_USER=... GRIDLINE_TEST_TGT_DB=... \
// GRIDLINE_TEST_TGT_PASSWORD=... \
// cargo test --lib backup -- --ignored
// ------------------------------------------------------------------
fn env(name: &str) -> String {
std::env::var(name).unwrap_or_else(|_| panic!("missing env var {name}"))
}
// Serializes the live-DB integration tests so they don't clobber each other
// when cargo runs them in parallel.
static INTEGRATION_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn conn_from_env(prefix: &str) -> PgConnParams {
PgConnParams::new(
env(&format!("{prefix}_HOST")),
env(&format!("{prefix}_PORT")).parse().unwrap(),
env(&format!("{prefix}_USER")),
env(&format!("{prefix}_DB")),
env(&format!("{prefix}_PASSWORD")),
)
}
fn psql_exec(conn: &PgConnParams, sql: &str) {
let out = Command::new("psql")
.env("PGPASSWORD", &conn.password)
.args([
format!("--host={}", conn.host),
format!("--port={}", conn.port),
format!("--username={}", conn.username),
format!("--dbname={}", conn.database),
"-tA".into(),
"-c".into(),
sql.into(),
])
.output()
.expect("psql should run");
assert!(
out.status.success(),
"psql failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn psql_count(conn: &PgConnParams, query: &str) -> i64 {
let out = Command::new("psql")
.env("PGPASSWORD", &conn.password)
.args([
format!("--host={}", conn.host),
format!("--port={}", conn.port),
format!("--username={}", conn.username),
format!("--dbname={}", conn.database),
"-tA".into(),
"-c".into(),
query.into(),
])
.output()
.expect("psql should run");
assert!(
out.status.success(),
"psql failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().parse().unwrap()
}
#[test]
#[ignore]
fn integration_dump_restore_sync() {
let _guard = INTEGRATION_LOCK.lock().unwrap();
let src = conn_from_env("GRIDLINE_TEST_SRC");
let tgt = conn_from_env("GRIDLINE_TEST_TGT");
// Unique temp file per run to avoid collisions.
let dump_path = std::env::temp_dir().join(format!(
"gridline_it_dump_{}_{}.bak",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let dump_path_str = dump_path.to_str().unwrap().to_string();
// --- 1. Dump source (custom format — the only format pg_restore can read) ---
let dump_opts = BackupOptions {
format: "custom".into(),
file_path: dump_path_str.clone(),
schema: None,
tables: None,
no_owner: true,
};
run_pg_dump(&src, &dump_opts, &PgToolPaths { pg_dump: "pg_dump".into(), pg_restore: "pg_restore".into(), psql: "psql".into() })
.expect("pg_dump should succeed");
// --- 2. Restore into target ---
let restore_opts = RestoreOptions {
format: "custom".into(),
file_path: dump_path_str.clone(),
clean: true,
schema: None,
};
run_pg_restore(&tgt, &restore_opts, &PgToolPaths { pg_dump: "pg_dump".into(), pg_restore: "pg_restore".into(), psql: "psql".into() })
.expect("pg_restore should succeed");
// --- 3. Verify data landed in target ---
assert_eq!(
psql_count(&tgt, "SELECT count(*) FROM public.products;"),
3,
"products should be restored"
);
assert_eq!(
psql_count(&tgt, "SELECT count(*) FROM public.orders;"),
3,
"orders should be restored"
);
// --- 4. Sync source -> target (target already has tables from the restore
// above — db_sync now passes --clean --if-exists, so it must succeed into a
// non-empty target). ---
run_db_sync(&src, &tgt, None, None, &PgToolPaths { pg_dump: "pg_dump".into(), pg_restore: "pg_restore".into(), psql: "psql".into() })
.expect("db_sync should succeed");
assert_eq!(
psql_count(&tgt, "SELECT count(*) FROM public.products;"),
3,
"sync should re-copy products"
);
assert_eq!(
psql_count(&tgt, "SELECT count(*) FROM public.orders;"),
3,
"sync should re-copy orders"
);
// --- Cleanup ---
let _ = std::fs::remove_file(&dump_path);
}
#[test]
#[ignore]
fn integration_plain_dump_restore() {
let _guard = INTEGRATION_LOCK.lock().unwrap();
let src = conn_from_env("GRIDLINE_TEST_SRC");
let tgt = conn_from_env("GRIDLINE_TEST_TGT");
// psql can't DROP-before-CREATE, so start from a clean target.
psql_exec(
&tgt,
"DROP TABLE IF EXISTS public.orders, public.products CASCADE;",
);
let dump_path = std::env::temp_dir().join(format!(
"gridline_it_plain_{}_{}.sql",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let dump_path_str = dump_path.to_str().unwrap().to_string();
// --- 1. Dump source in plain format ---
let dump_opts = BackupOptions {
format: "plain".into(),
file_path: dump_path_str.clone(),
schema: None,
tables: None,
no_owner: true,
};
run_pg_dump(&src, &dump_opts, &PgToolPaths { pg_dump: "pg_dump".into(), pg_restore: "pg_restore".into(), psql: "psql".into() })
.expect("pg_dump (plain) should succeed");
// --- 2. Restore into target (plain -> psql path) ---
let restore_opts = RestoreOptions {
format: "plain".into(),
file_path: dump_path_str.clone(),
clean: false,
schema: None,
};
run_pg_restore(&tgt, &restore_opts, &PgToolPaths { pg_dump: "pg_dump".into(), pg_restore: "pg_restore".into(), psql: "psql".into() })
.expect("pg_restore (plain/psql) should succeed");
// --- 3. Verify data landed in target ---
assert_eq!(
psql_count(&tgt, "SELECT count(*) FROM public.products;"),
3,
"plain restore should load products"
);
assert_eq!(
psql_count(&tgt, "SELECT count(*) FROM public.orders;"),
3,
"plain restore should load orders"
);
let _ = std::fs::remove_file(&dump_path);
}
// ------------------------------------------------------------------
// Tool resolution (Task 2.1: system-first, bundled-fallback)
// ------------------------------------------------------------------
#[test]
fn pick_tool_prefers_system_then_bundled_then_bare() {
assert_eq!(pick_tool(true, None, "pg_dump"), ("pg_dump".to_string(), Some("system".into())));
assert_eq!(pick_tool(false, Some("/r/pg_dump"), "pg_dump"), ("/r/pg_dump".to_string(), Some("bundled".into())));
assert_eq!(pick_tool(false, None, "pg_dump"), ("pg_dump".to_string(), None));
}
#[test]
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")));
}
@@ -0,0 +1,362 @@
use crate::models::{Connection, ConnectionInput};
use crate::store::Store;
use std::sync::Mutex;
const VALID_DB_TYPES: [&str; 4] = ["postgresql", "mysql", "sqlite", "redis"];
fn validate(input: &ConnectionInput) -> Result<(), String> {
if input.name.is_empty() || input.name.chars().count() > 100 {
return Err("name is required and must be 100 chars or fewer".into());
}
if !VALID_DB_TYPES.contains(&input.db_type.as_str()) {
return Err(format!(
"db_type must be one of: {}",
VALID_DB_TYPES.join(", ")
));
}
if input.host.is_empty() || input.host.chars().count() > 255 {
return Err("host is required and must be 255 chars or fewer".into());
}
if input.db_type != "sqlite" {
match input.port {
Some(p) if (1..=65535).contains(&p) => {}
_ => return Err("port must be an integer between 1 and 65535 for this db_type".into()),
}
}
if let Some(u) = &input.username {
if u.chars().count() > 100 {
return Err("username must be 100 chars or fewer".into());
}
}
Ok(())
}
pub fn get_connections_inner(state: &Mutex<Store>) -> Result<Vec<Connection>, String> {
let store = state.lock().map_err(|e| e.to_string())?;
store.get_connections()
}
pub fn create_connection_inner(
state: &Mutex<Store>,
input: ConnectionInput,
) -> Result<Connection, String> {
validate(&input)?;
let store = state.lock().map_err(|e| e.to_string())?;
store.create_connection(input)
}
pub fn update_connection_inner(
state: &Mutex<Store>,
id: String,
input: ConnectionInput,
) -> Result<Connection, String> {
validate(&input)?;
let store = state.lock().map_err(|e| e.to_string())?;
store.update_connection(&id, input)
}
pub fn delete_connection_inner(state: &Mutex<Store>, id: &str) -> Result<(), String> {
let store = state.lock().map_err(|e| e.to_string())?;
store.delete_connection(id)
}
pub fn add_connection_tags_inner(
state: &Mutex<Store>,
connection_id: String,
tag_ids: Vec<String>,
) -> Result<(), String> {
let store = state.lock().map_err(|e| e.to_string())?;
store.add_connection_tags(&connection_id, &tag_ids)
}
#[tauri::command]
pub fn get_connections(state: tauri::State<crate::AppState>) -> Result<Vec<Connection>, String> {
get_connections_inner(&state.db_store)
}
#[tauri::command]
pub fn create_connection(
state: tauri::State<crate::AppState>,
input: ConnectionInput,
) -> Result<Connection, String> {
create_connection_inner(&state.db_store, input)
}
#[tauri::command]
pub fn update_connection(
state: tauri::State<crate::AppState>,
id: String,
input: ConnectionInput,
) -> Result<Connection, String> {
update_connection_inner(&state.db_store, id, input)
}
#[tauri::command]
pub fn delete_connection(
state: tauri::State<crate::AppState>,
id: String,
app: tauri::AppHandle,
) -> Result<(), String> {
delete_connection_inner(&state.db_store, &id)?;
// Purge keychain secrets (missing entries are no-ops) and close any
// SSH tunnel associated with the deleted connection.
let _ = crate::commands::keychain::delete_connection_password_internal(&app, &id);
let _ = crate::commands::keychain::delete_connection_ssh_password_internal(&app, &id);
let _ = crate::commands::keychain::delete_connection_ssh_passphrase_internal(&app, &id);
if let Ok(mut mgr) = state.ssh_manager.lock() {
mgr.close_tunnel(&id);
}
Ok(())
}
#[tauri::command]
pub fn add_connection_tags(
state: tauri::State<crate::AppState>,
connection_id: String,
tag_ids: Vec<String>,
) -> Result<(), String> {
add_connection_tags_inner(&state.db_store, connection_id, tag_ids)
}
pub fn set_connection_favorite_inner(
state: &Mutex<Store>,
connection_id: String,
favorite: bool,
) -> Result<(), String> {
let store = state.lock().map_err(|e| e.to_string())?;
store.set_connection_favorite(&connection_id, favorite)
}
pub fn record_recent_connection_inner(
state: &Mutex<Store>,
connection_id: String,
) -> Result<(), String> {
let store = state.lock().map_err(|e| e.to_string())?;
store.record_recent_connection(&connection_id)
}
pub fn get_recent_connections_inner(
state: &Mutex<Store>,
limit: i64,
) -> Result<Vec<crate::models::RecentConnection>, String> {
let store = state.lock().map_err(|e| e.to_string())?;
store.get_recent_connections(limit)
}
pub fn clear_recent_connections_inner(state: &Mutex<Store>) -> Result<(), String> {
let store = state.lock().map_err(|e| e.to_string())?;
store.clear_recent_connections()
}
#[tauri::command]
pub fn set_connection_favorite(
state: tauri::State<crate::AppState>,
connection_id: String,
favorite: bool,
) -> Result<(), String> {
set_connection_favorite_inner(&state.db_store, connection_id, favorite)
}
#[tauri::command]
pub fn record_recent_connection(
state: tauri::State<crate::AppState>,
connection_id: String,
) -> Result<(), String> {
record_recent_connection_inner(&state.db_store, connection_id)
}
#[tauri::command]
pub fn get_recent_connections(
state: tauri::State<crate::AppState>,
limit: i64,
) -> Result<Vec<crate::models::RecentConnection>, String> {
get_recent_connections_inner(&state.db_store, limit)
}
#[tauri::command]
pub fn clear_recent_connections(state: tauri::State<crate::AppState>) -> Result<(), String> {
clear_recent_connections_inner(&state.db_store)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::ConnectionInput;
use crate::store::Store;
fn state() -> std::sync::Mutex<Store> {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::store::migrations::run_migrations(&conn).unwrap();
std::sync::Mutex::new(Store::from_connection(conn))
}
#[test]
fn get_connections_returns_list() {
let st = state();
let result = get_connections_inner(&st);
assert!(result.is_ok());
assert_eq!(result.unwrap().len(), 0);
}
#[test]
fn create_connection_command_returns_connection() {
let st = state();
let input = ConnectionInput {
name: "Prod".into(),
db_type: "postgresql".into(),
host: "h".into(),
port: Some(5432),
username: None,
folder_id: None,
password: None,
database: None,
environment: None,
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_private_key_path: None,
ssh_password: None,
ssh_passphrase: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
tag_ids: vec![],
use_keychain: true,
};
let result = create_connection_inner(&st, input.clone()).unwrap();
assert_eq!(result.name, "Prod");
assert_eq!(get_connections_inner(&st).unwrap().len(), 1);
}
#[test]
fn create_connection_rejects_invalid_db_type() {
let st = state();
let input = ConnectionInput {
name: "X".into(),
db_type: "mongodb".into(),
host: "h".into(),
port: Some(5432),
username: None,
folder_id: None,
password: None,
database: None,
environment: None,
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_private_key_path: None,
ssh_password: None,
ssh_passphrase: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
tag_ids: vec![],
use_keychain: true,
};
assert!(create_connection_inner(&st, input).is_err());
}
#[test]
fn delete_connection_command_removes_it() {
let st = state();
let input = ConnectionInput {
name: "X".into(),
db_type: "postgresql".into(),
host: "h".into(),
port: Some(5432),
username: None,
folder_id: None,
password: None,
database: None,
environment: None,
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_private_key_path: None,
ssh_password: None,
ssh_passphrase: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
tag_ids: vec![],
use_keychain: true,
};
let conn = create_connection_inner(&st, input).unwrap();
delete_connection_inner(&st, &conn.id).unwrap();
assert_eq!(get_connections_inner(&st).unwrap().len(), 0);
}
#[test]
fn set_connection_favorite_command_persists() {
let st = state();
let input = ConnectionInput {
name: "P".into(),
db_type: "postgresql".into(),
host: "h".into(),
port: Some(5432),
username: None,
folder_id: None,
tag_ids: vec![],
use_keychain: true,
password: None,
database: None,
environment: None,
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_private_key_path: None,
ssh_password: None,
ssh_passphrase: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
};
let conn = create_connection_inner(&st, input).unwrap();
set_connection_favorite_inner(&st, conn.id.clone(), true).unwrap();
assert_eq!(get_connections_inner(&st).unwrap()[0].favorite, true);
}
#[test]
fn record_recent_command_upserts() {
let st = state();
let input = ConnectionInput {
name: "P".into(),
db_type: "postgresql".into(),
host: "h".into(),
port: Some(5432),
username: None,
folder_id: None,
tag_ids: vec![],
use_keychain: true,
password: None,
database: None,
environment: None,
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_private_key_path: None,
ssh_password: None,
ssh_passphrase: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
};
let conn = create_connection_inner(&st, input).unwrap();
record_recent_connection_inner(&st, conn.id.clone()).unwrap();
record_recent_connection_inner(&st, conn.id.clone()).unwrap(); // idempotent upsert
let recent = get_recent_connections_inner(&st, 10).unwrap();
assert_eq!(recent.len(), 1);
clear_recent_connections_inner(&st).unwrap();
assert_eq!(get_recent_connections_inner(&st, 10).unwrap().len(), 0);
}
}
File diff suppressed because it is too large Load Diff
+164
View File
@@ -0,0 +1,164 @@
use crate::models::ConnectionInput;
use crate::store::Store;
use crate::AppState;
use rusqlite::Connection;
use std::path::Path;
use std::sync::Mutex;
use tauri::Manager;
const DEMO_DB_FILENAME: &str = "demo.db";
const DEMO_CONNECTION_NAME: &str = "Gridline Demo (SQLite)";
/// Bump whenever the demo schema or seed data changes so existing demo files
/// are recreated on the next launch. The demo is disposable by design — it
/// should always showcase the current feature set.
const DEMO_SCHEMA_VERSION: i64 = 3;
/// True when the demo file's `PRAGMA user_version` is at or above the current
/// schema version (i.e. the file already carries the full feature set).
fn demo_file_is_current(conn: &Connection) -> Result<bool, String> {
let version: i64 = conn
.query_row("PRAGMA user_version", [], |r| r.get(0))
.map_err(|e| e.to_string())?;
Ok(version >= DEMO_SCHEMA_VERSION)
}
/// Ensure the demo SQLite file at `path` exists and is seeded with the current
/// schema. Files that predate `DEMO_SCHEMA_VERSION` are recreated so the demo
/// always showcases every feature.
fn ensure_demo_file(path: &Path) -> Result<(), String> {
let stale = path.exists() && {
let conn = Connection::open(path).map_err(|e| e.to_string())?;
!demo_file_is_current(&conn).unwrap_or(false)
};
if stale {
std::fs::remove_file(path).map_err(|e| e.to_string())?;
}
if !path.exists() {
let conn = Connection::open(path).map_err(|e| format!("Failed to create demo DB: {e}"))?;
conn.execute_batch(&get_demo_schema())
.map_err(|e| format!("Failed to seed demo DB: {e}"))?;
}
Ok(())
}
/// Build the connection input for the demo SQLite file at `db_path`.
fn demo_connection_input(db_path: &Path) -> ConnectionInput {
ConnectionInput {
name: DEMO_CONNECTION_NAME.to_string(),
db_type: "sqlite".to_string(),
host: db_path.to_string_lossy().to_string(),
port: None,
username: None,
password: None,
database: None,
folder_id: None,
tag_ids: vec![],
use_keychain: true,
environment: Some("development".to_string()),
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_private_key_path: None,
ssh_password: None,
ssh_passphrase: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
}
}
/// Resolve the demo database file under the app data directory (the same
/// location the startup `ensure_demo_db` flow seeds), creating the directory
/// when needed.
fn demo_db_path(app: &tauri::AppHandle) -> Result<std::path::PathBuf, String> {
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
std::fs::create_dir_all(&data_dir).map_err(|e| e.to_string())?;
Ok(data_dir.join(DEMO_DB_FILENAME))
}
/// Register the demo connection unless one already exists.
fn ensure_demo_connection(store: &Mutex<Store>, db_path: &Path) -> Result<(), String> {
let exists = {
let s = store.lock().map_err(|e| e.to_string())?;
s.get_connections()?
.iter()
.any(|c| c.name == DEMO_CONNECTION_NAME)
};
if exists {
return Ok(());
}
let input = demo_connection_input(db_path);
let s = store.lock().map_err(|e| e.to_string())?;
s.create_connection(input)?;
Ok(())
}
/// Drop any live pool handle for the demo connection so its SQLite file can
/// be deleted and recreated cleanly.
async fn disconnect_demo_pool(state: &tauri::State<'_, AppState>) -> Result<(), String> {
let demo_id = {
let s = state.db_store.lock().map_err(|e| e.to_string())?;
s.get_connections()?
.iter()
.find(|c| c.name == DEMO_CONNECTION_NAME)
.map(|c| c.id.clone())
};
if let Some(id) = demo_id {
let mut pm = state.pool_manager.lock().await;
pm.remove(&id);
}
Ok(())
}
/// Ensure the demo SQLite database exists and a corresponding connection is
/// registered. Safe to call on every app start — it's idempotent, and it
/// upgrades stale demo files to the current schema automatically.
pub fn ensure_demo_db(app_handle: &tauri::AppHandle, store: &Mutex<Store>) -> Result<(), String> {
let db_path = demo_db_path(app_handle)?;
ensure_demo_file(&db_path)?;
ensure_demo_connection(store, &db_path)
}
/// Tauri command to re-add the demo connection from the settings screen.
/// Recreates the demo file when it is missing or stale, then registers the
/// connection (always under the app data directory, matching startup).
#[tauri::command]
pub fn recreate_demo_db(
app: tauri::AppHandle,
state: tauri::State<AppState>,
) -> Result<String, String> {
let db_path = demo_db_path(&app)?;
ensure_demo_file(&db_path)?;
ensure_demo_connection(&state.db_store, &db_path)?;
Ok("Demo database connection re-created.".to_string())
}
/// Tauri command to regenerate the demo database from the settings screen:
/// drops any live pool handle, wipes the current file (including any edits
/// made against it) and re-seeds it with fresh demo data.
#[tauri::command]
pub async fn regenerate_demo_db(
app: tauri::AppHandle,
state: tauri::State<'_, AppState>,
) -> Result<String, String> {
let db_path = demo_db_path(&app)?;
disconnect_demo_pool(&state).await?;
if db_path.exists() {
std::fs::remove_file(&db_path).map_err(|e| e.to_string())?;
}
ensure_demo_file(&db_path)?;
ensure_demo_connection(&state.db_store, &db_path)?;
Ok("Demo database regenerated with fresh data.".to_string())
}
/// The demo schema + seed data. Loaded from `demo_schema.sql` so the SQL can
/// be edited and reviewed with normal editor tooling.
fn get_demo_schema() -> String {
include_str!("demo_schema.sql").to_string()
}
#[cfg(test)]
#[path = "demo.test.rs"]
mod tests;
+379
View File
@@ -0,0 +1,379 @@
use super::*;
use crate::commands::db_viewer::build_sqlite_data_select;
/// Open an in-memory SQLite database seeded with the demo schema.
fn seed_demo() -> rusqlite::Connection {
let conn = rusqlite::Connection::open_in_memory().unwrap();
conn.execute_batch(&get_demo_schema()).unwrap();
conn
}
/// Count rows in a table/view.
fn count(conn: &rusqlite::Connection, table: &str) -> i64 {
conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))
.unwrap()
}
#[test]
fn demo_schema_creates_expected_objects() {
let conn = seed_demo();
let mut stmt = conn
.prepare("SELECT name, type FROM sqlite_master WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%' ORDER BY name")
.unwrap();
let objects: Vec<(String, String)> = stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
.unwrap()
.filter_map(|r| r.ok())
.collect();
let expected: Vec<(&str, &str)> = vec![
("addresses", "table"),
("app_settings", "table"),
("audit_log", "table"),
("categories", "table"),
("files", "table"),
("marketing_campaigns", "table"),
("order_items", "table"),
("order_summary", "view"),
("orders", "table"),
("page_views", "table"),
("products", "table"),
("users", "table"),
];
assert_eq!(
objects,
expected
.into_iter()
.map(|(n, t)| (n.to_string(), t.to_string()))
.collect::<Vec<_>>(),
"demo schema must contain exactly the expected tables + view"
);
}
#[test]
fn demo_schema_seeds_expected_rows() {
let conn = seed_demo();
assert_eq!(count(&conn, "users"), 20);
assert_eq!(count(&conn, "categories"), 6);
assert_eq!(count(&conn, "products"), 24);
assert_eq!(count(&conn, "addresses"), 23);
assert_eq!(count(&conn, "orders"), 50);
assert_eq!(count(&conn, "order_items"), 105);
assert_eq!(
count(&conn, "audit_log"),
500,
"audit_log must seed 500 rows for pagination/virtualization demos"
);
assert_eq!(count(&conn, "files"), 8);
assert_eq!(count(&conn, "page_views"), 100);
assert_eq!(count(&conn, "app_settings"), 6);
assert_eq!(
count(&conn, "marketing_campaigns"),
0,
"marketing_campaigns must stay empty to demo the Empty Table change"
);
assert_eq!(
count(&conn, "order_summary"),
50,
"view must return one row per order"
);
}
#[test]
fn demo_schema_is_idempotent() {
let conn = seed_demo();
// Re-running the full schema (e.g. on a fresh file after a partial seed)
// must not duplicate rows.
conn.execute_batch(&get_demo_schema()).unwrap();
assert_eq!(count(&conn, "users"), 20);
assert_eq!(count(&conn, "audit_log"), 500);
assert_eq!(count(&conn, "page_views"), 100);
assert_eq!(count(&conn, "products"), 24);
}
#[test]
fn demo_schema_sets_user_version() {
let conn = seed_demo();
let v: i64 = conn
.query_row("PRAGMA user_version", [], |r| r.get(0))
.unwrap();
assert_eq!(
v, DEMO_SCHEMA_VERSION,
"demo file must stamp PRAGMA user_version for upgrade detection"
);
}
#[test]
fn demo_json_columns_hold_valid_json() {
let conn = seed_demo();
// Every non-NULL value in a `json`-declared column must parse as JSON so
// the grid's JSON popover can format it.
for (table, column) in [
("users", "preferences"),
("products", "attributes"),
("audit_log", "details"),
] {
let mut stmt = conn
.prepare(&format!(
"SELECT {column} FROM {table} WHERE {column} IS NOT NULL"
))
.unwrap();
let values: Vec<String> = stmt
.query_map([], |row| row.get::<_, String>(0))
.unwrap()
.filter_map(|r| r.ok())
.collect();
assert!(
!values.is_empty(),
"{table}.{column} should have non-null values"
);
for v in &values {
assert!(
serde_json::from_str::<serde_json::Value>(v).is_ok(),
"{table}.{column} must hold valid JSON, got: {v}"
);
}
}
// And the declared type must be lowercase `json` so the frontend's
// `data_type === "json"` check triggers the JSON cell popover.
for (table, column) in [
("users", "preferences"),
("products", "attributes"),
("audit_log", "details"),
] {
let dt: String = conn
.query_row(
&format!("SELECT type FROM pragma_table_info('{table}') WHERE name = '{column}'"),
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
dt, "json",
"{table}.{column} must be declared lowercase json"
);
}
}
#[test]
fn demo_view_is_queryable() {
let conn = seed_demo();
let mut stmt = conn.prepare("SELECT order_id, customer_name, item_count, order_total, status FROM order_summary ORDER BY order_id").unwrap();
let rows: Vec<(i64, String, i64, f64, String)> = stmt
.query_map([], |row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
))
})
.unwrap()
.filter_map(|r| r.ok())
.collect();
assert_eq!(rows.len(), 50);
let first = &rows[0];
assert_eq!(first.0, 1);
assert_eq!(first.1, "Sarah Chen");
assert_eq!(first.4, "processing");
// order 1 is populated by generated line items; just ensure it has at least one.
assert!(first.2 >= 1, "order 1 should have at least one line item");
}
#[test]
fn demo_view_loads_through_table_data_path() {
// Regression: a view tab used to fail with "no such column: rowid" because
// the data SELECT appended the rowid locator (views have no PK → has_pk
// false → locator appended → views expose no rowid). This mirrors the
// SQLite branch of get_table_data exactly.
let conn = seed_demo();
let is_view: bool = conn
.query_row(
"SELECT type = 'view' FROM sqlite_master WHERE name = ?1 AND type IN ('table', 'view')",
["order_summary"],
|r| r.get::<_, bool>(0),
)
.unwrap();
assert!(is_view, "order_summary must be a view");
let mut pragma_stmt = conn.prepare("PRAGMA table_info('order_summary')").unwrap();
let col_meta: Vec<(String, bool)> = pragma_stmt
.query_map([], |row| {
Ok((row.get::<_, String>(1)?, row.get::<_, bool>(5)?))
})
.unwrap()
.filter_map(|r| r.ok())
.collect();
let has_pk = col_meta.iter().any(|(_, pk)| *pk);
assert!(!has_pk, "views report no PK from PRAGMA table_info");
let visible_names: Vec<String> = col_meta.iter().map(|(n, _)| n.clone()).collect();
let data_query = format!(
"{} WHERE 1=1 LIMIT 50 OFFSET 0",
build_sqlite_data_select("order_summary", &visible_names, !has_pk && !is_view)
);
assert!(
!data_query.contains("rowid"),
"view data SELECT must not select rowid; got: {}",
data_query
);
let mut stmt = conn.prepare(&data_query).unwrap();
let col_count = stmt.column_count();
let rows: Vec<Vec<rusqlite::types::Value>> = stmt
.query_map([], |row| {
let mut vals = Vec::new();
for i in 0..col_count {
vals.push(row.get::<_, rusqlite::types::Value>(i)?);
}
Ok(vals)
})
.unwrap()
.filter_map(|r| r.ok())
.collect();
let expected: i64 = conn
.query_row("SELECT COUNT(*) FROM \"main\".\"order_summary\"", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(
rows.len() as i64,
expected,
"view data query must return every view row (count = {expected})"
);
assert!(!rows.is_empty(), "seeded view must not be empty");
}
#[test]
fn demo_foreign_keys_are_consistent() {
let conn = seed_demo();
// PRAGMA foreign_key_check reports any orphaned child rows.
let violations: Vec<(String, i64, String, i64)> = conn
.prepare("PRAGMA foreign_key_check")
.unwrap()
.query_map([], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
})
.unwrap()
.filter_map(|r| r.ok())
.collect();
assert!(
violations.is_empty(),
"seed data must satisfy every FK: {violations:?}"
);
// audit_log.user_id must be NULL or reference an existing user.
let orphans: i64 = conn
.query_row(
"SELECT COUNT(*) FROM audit_log WHERE user_id IS NOT NULL AND user_id NOT IN (SELECT id FROM users)",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(orphans, 0);
}
#[test]
fn demo_tables_exercise_key_constraint_shapes() {
let conn = seed_demo();
// order_items: composite PRIMARY KEY across two columns. SQLite reports
// pk as the 1-based position within the key, so both columns are > 0.
let composite: Vec<(String, bool)> = conn
.prepare("SELECT name, pk FROM pragma_table_info('order_items') WHERE pk > 0 ORDER BY pk")
.unwrap()
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
.unwrap()
.filter_map(|r| r.ok())
.collect();
assert_eq!(
composite
.iter()
.map(|(n, _)| n.as_str())
.collect::<Vec<_>>(),
vec!["order_id", "product_id"]
);
// page_views: no primary key at all (rowid row-locator editing demo).
let pk_cols: i64 = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('page_views') WHERE pk > 0",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
pk_cols, 0,
"page_views must have no PK so the rowid locator kicks in"
);
// app_settings: TEXT primary key.
let text_pk: String = conn
.query_row(
"SELECT name FROM pragma_table_info('app_settings') WHERE pk > 0",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(text_pk, "key");
// categories: self-referencing FK.
let self_fk: i64 = conn
.query_row(
"SELECT COUNT(*) FROM pragma_foreign_key_list('categories') WHERE \"table\" = 'categories'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(self_fk, 1);
}
#[test]
fn demo_file_is_recreated_when_stale() {
// A file stamped with an older user_version must be recreated by
// ensure_demo_file; a current file must be left untouched.
let dir = std::env::temp_dir().join(format!("gridline-demo-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("demo.db");
// Stale file: old version, old schema.
let stale = rusqlite::Connection::open(&path).unwrap();
stale
.execute_batch("PRAGMA user_version = 1; CREATE TABLE legacy (id INTEGER PRIMARY KEY);")
.unwrap();
drop(stale);
ensure_demo_file(&path).unwrap();
let conn = rusqlite::Connection::open(&path).unwrap();
let v: i64 = conn
.query_row("PRAGMA user_version", [], |r| r.get(0))
.unwrap();
assert_eq!(
v, DEMO_SCHEMA_VERSION,
"stale demo file must be recreated with the current version"
);
assert_eq!(
count(&conn, "users"),
20,
"recreated file must be fully seeded"
);
drop(conn);
// Current file: ensure_demo_file must not touch it.
ensure_demo_file(&path).unwrap();
let conn = rusqlite::Connection::open(&path).unwrap();
assert_eq!(
count(&conn, "users"),
20,
"current demo file must not be reseeded"
);
// Regenerate flow: the file is deleted entirely, then recreated from
// scratch with the current schema (what `regenerate_demo_db` does).
drop(conn);
std::fs::remove_file(&path).unwrap();
ensure_demo_file(&path).unwrap();
let conn = rusqlite::Connection::open(&path).unwrap();
let v: i64 = conn
.query_row("PRAGMA user_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v, DEMO_SCHEMA_VERSION);
assert_eq!(count(&conn, "users"), 20);
assert_eq!(count(&conn, "audit_log"), 500);
std::fs::remove_dir_all(&dir).ok();
}
@@ -0,0 +1,492 @@
PRAGMA user_version = 3;
-- ═══════════════════════════════════════════════════════════════════════
-- Gridline Demo (SQLite) — realistic e-commerce dataset
--
-- Designed to exercise every SQLite-aware Gridline feature:
-- • PK/FK/composite-PK/self-FK metadata
-- • JSON cells + JSON popover
-- • BLOBs
-- • CHECK / UNIQUE constraints, defaults
-- • indexes
-- • views
-- • an empty table (Empty Table change demo)
-- • a no-PK table (rowid row-locator editing)
-- • a TEXT primary key
-- • a 500-row table for pagination / virtualization / filtering
-- • nullable columns, long text, smart-sort tiers
-- ═══════════════════════════════════════════════════════════════════════
-- ── Core: users ─────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
role TEXT NOT NULL DEFAULT 'customer' CHECK (role IN ('admin','moderator','customer','guest')),
phone TEXT,
birth_date TEXT,
bio TEXT,
preferences json,
balance REAL NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
last_login_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ── Core: categories (self-referencing FK) ──────────────────────────────
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
parent_id INTEGER REFERENCES categories(id),
slug TEXT NOT NULL UNIQUE,
sort_order INTEGER NOT NULL DEFAULT 0,
description TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ── Core: products ──────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sku TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT,
price REAL NOT NULL CHECK (price >= 0),
category_id INTEGER REFERENCES categories(id),
stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
rating REAL CHECK (rating IS NULL OR (rating >= 0 AND rating <= 5)),
discontinued INTEGER NOT NULL DEFAULT 0,
attributes json,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ── Core: addresses (1:N from users, FK dropdown editor) ────────────────
CREATE TABLE IF NOT EXISTS addresses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
label TEXT NOT NULL DEFAULT 'Home',
street TEXT NOT NULL,
city TEXT NOT NULL,
state TEXT,
zip TEXT,
country TEXT NOT NULL DEFAULT 'USA',
is_primary INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ── Core: orders (FKs -> users and addresses; CHECK status) ─────────────
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
shipping_address_id INTEGER REFERENCES addresses(id),
total REAL NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','processing','shipped','delivered','completed','cancelled','refunded')),
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
shipped_at TEXT
);
-- ── Core: order_items (composite PRIMARY KEY; CHECK) ───────────────────
CREATE TABLE IF NOT EXISTS order_items (
order_id INTEGER NOT NULL REFERENCES orders(id),
product_id INTEGER NOT NULL REFERENCES products(id),
quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity > 0),
unit_price REAL NOT NULL,
PRIMARY KEY (order_id, product_id)
);
-- ── Big table for pagination / virtualization / filtering (500 rows) ──
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id),
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id INTEGER,
severity TEXT NOT NULL DEFAULT 'info'
CHECK (severity IN ('info','warning','error','critical')),
details json,
duration_ms INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ── BLOB demo ───────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
mime_type TEXT NOT NULL,
content BLOB,
size_bytes INTEGER NOT NULL,
uploaded_by INTEGER REFERENCES users(id),
uploaded_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ── No primary key on purpose: exercises the rowid row-locator editing ──
CREATE TABLE IF NOT EXISTS page_views (
url TEXT NOT NULL,
session_id TEXT NOT NULL,
user_agent TEXT,
viewed_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ── Non-integer (TEXT) primary key ─────────────────────────────────────
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ── Deliberately empty: exercises the Empty Table change + empty state ──
CREATE TABLE IF NOT EXISTS marketing_campaigns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
budget REAL,
starts_at TEXT,
ends_at TEXT,
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft','active','paused','completed','cancelled'))
);
-- ── Read-only view for the query editor / ER diagram ───────────────────
CREATE VIEW IF NOT EXISTS order_summary AS
SELECT
o.id AS order_id,
u.name AS customer_name,
COUNT(oi.product_id) AS item_count,
o.total AS order_total,
o.status,
o.created_at
FROM orders o
JOIN users u ON u.id = o.user_id
LEFT JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id, u.name, o.total, o.status, o.created_at;
-- ── Indexes (surface in the copied DDL) ─────────────────────────────────
CREATE INDEX IF NOT EXISTS idx_orders_user_id ON orders(user_id);
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
CREATE INDEX IF NOT EXISTS idx_order_items_product_id ON order_items(product_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at);
CREATE INDEX IF NOT EXISTS idx_page_views_viewed_at ON page_views(viewed_at);
-- ═══════════════════════════════════════════════════════════════════════
-- Seed data
-- ═══════════════════════════════════════════════════════════════════════
-- ── Categories ──────────────────────────────────────────────────────────
INSERT OR IGNORE INTO categories (id, name, parent_id, slug, sort_order, description, created_at) VALUES
(1, 'Electronics', NULL, 'electronics', 1, 'Computers, phones, and peripherals.', datetime('now','-400 days')),
(2, 'Accessories', NULL, 'accessories', 2, 'Cables, cases, hubs, and add-ons.', datetime('now','-400 days')),
(3, 'Office', NULL, 'office', 3, 'Furniture, lighting, and desk essentials.', datetime('now','-400 days')),
(4, 'Furniture', NULL, 'furniture', 4, 'Chairs, desks, shelves, and storage.', datetime('now','-380 days')),
(5, 'Keyboards', 1, 'keyboards', 1, 'Mechanical and membrane keyboards.', datetime('now','-300 days')),
(6, 'Monitors', 1, 'monitors', 2, 'External displays and monitor arms.', datetime('now','-300 days'));
-- ── Users: a realistic mix of admins, moderators, customers, and guests ───
INSERT OR IGNORE INTO users (id, name, email, role, phone, birth_date, bio, preferences, balance, is_active, last_login_at, created_at, updated_at) VALUES
(1, 'Sarah Chen', 'sarah.chen@acme.dev', 'admin', '+1-415-555-0101', '1990-04-12',
'Founding engineer at Acme Dev. Manages the catalog, reviews flagged orders, and keeps the demo data looking sharp.',
'{"theme":"dark","notifications":{"email":true,"push":true},"locale":"en-US","timezone":"America/Los_Angeles"}',
1240.75, 1, datetime('now','-35 minutes'), datetime('now','-540 days'), datetime('now','-35 minutes')),
(2, 'Marcus Johnson', 'marcus.j@pixelforge.studio', 'moderator', '+1-512-555-0192', '1985-11-30',
'Customer-success lead and part-time photographer. Moderates reviews, handles refunds, and tracks the weekly audit log.',
'{"theme":"system","notifications":{"email":true,"push":false},"locale":"en-GB","timezone":"America/Chicago"}',
48.20, 1, datetime('now','-6 hours'), datetime('now','-510 days'), datetime('now','-6 hours')),
(3, 'Emily Rodriguez', 'emily.r@techbloom.io', 'customer', '+1-206-555-0143', '1993-07-19',
'Remote frontend developer based in Seattle. Buys desk accessories in bulk and leaves detailed product reviews.',
'{"theme":"light","notifications":{"email":true,"push":true},"locale":"en-US","timezone":"America/Los_Angeles"}',
315.50, 1, datetime('now','-2 days'), datetime('now','-480 days'), datetime('now','-2 days')),
(4, 'David Kim', 'david.kim@outlook.com', 'customer', '+1-212-555-0178', '1988-03-04',
'Freelance sound engineer in New York. Orders AV gear and cables regularly for his home studio.',
'{"theme":"dark","notifications":{"email":false,"push":true},"locale":"ko-KR","timezone":"America/New_York"}',
0.00, 1, datetime('now','-4 days'), datetime('now','-450 days'), datetime('now','-4 days')),
(5, 'Aisha Patel', 'aisha.patel@horizonlabs.co', 'customer', '+1-303-555-0165', '1996-02-08',
'Data analyst and ergonomic-chair evangelist. Currently outfitting a standing-desk setup for her team.',
'{"theme":"dark","notifications":{"email":true,"push":false},"locale":"en-US","timezone":"America/Denver"}',
892.10, 1, datetime('now','-12 hours'), datetime('now','-420 days'), datetime('now','-12 hours')),
(6, 'James OBrien', 'james.obrien@fastmail.com', 'customer', '+1-617-555-0134', '1991-09-23',
'DevOps contractor. Bought a monitor arm and never looked back. Always asks for gift receipts.',
NULL,
67.99, 1, datetime('now','-1 day'), datetime('now','-390 days'), datetime('now','-1 day')),
(7, 'Yuki Tanaka', 'yuki.tanaka@me.com', 'customer', '+1-650-555-0189', '1994-05-17',
'Product designer at a fintech startup. Switches between light and dark mode depending on the weather.',
'{"theme":"system","notifications":{"email":true,"push":true},"locale":"ja-JP","timezone":"America/Los_Angeles"}',
150.00, 0, datetime('now','-28 days'), datetime('now','-360 days'), datetime('now','-28 days')),
(8, 'Olivia Müller', 'olivia.mueller@werkstatt.de', 'customer', '+49-30-555-0921', '1987-12-01',
'Berlin-based architect. Orders office lamps and cable organizers for her co-working space.',
'{"theme":"light","notifications":{"email":true,"push":false},"locale":"de-DE","timezone":"Europe/Berlin"}',
540.30, 1, datetime('now','-5 days'), datetime('now','-330 days'), datetime('now','-5 days')),
(9, 'Carlos Rivera', 'carlos.rivera@openmail.net', 'customer', '+1-305-555-0156', '1992-08-30',
'Miami-based content creator. Replaced his entire streaming setup through the store last quarter.',
'{"theme":"dark","notifications":{"email":false,"push":false},"locale":"es-MX","timezone":"America/New_York"}',
28.50, 1, datetime('now','-3 hours'), datetime('now','-300 days'), datetime('now','-3 hours')),
(10, 'Priya Sharma', 'priya.sharma@nimbus.team', 'customer', '+1-408-555-0127', '1995-01-14',
'Engineering manager at Nimbus. Buys team gear in batches and expects invoices by email.',
'{"theme":"dark","notifications":{"email":true,"push":true},"locale":"en-IN","timezone":"America/Los_Angeles"}',
2100.00, 1, datetime('now','-50 minutes'), datetime('now','-270 days'), datetime('now','-50 minutes')),
(11, 'Liam Thompson', 'liam.t@rivermail.com', 'guest', NULL, '1999-06-05',
'University student window-shopping for a first mechanical keyboard. Has not completed a purchase yet.',
'{"theme":"system","notifications":{"email":false,"push":false},"locale":"en-US","timezone":"America/New_York"}',
0.00, 1, datetime('now','-7 days'), datetime('now','-240 days'), datetime('now','-7 days')),
(12, 'Sofia Andersen', 'sofia.andersen@nordic.design', 'customer', '+46-8-555-0733', '1989-10-28',
'Scandinavian design consultant. Values minimal packaging and fast, trackable shipping.',
'{"theme":"light","notifications":{"email":true,"push":false},"locale":"sv-SE","timezone":"Europe/Stockholm"}',
175.80, 1, datetime('now','-10 hours'), datetime('now','-210 days'), datetime('now','-10 hours')),
(13, 'Benjamin Wright', 'ben.wright@compose.ly', 'customer', '+1-503-555-0198', '1993-04-11',
'Music producer and software tinkerer. Always adds a USB-C cable to every order just in case.',
'{"theme":"dark","notifications":{"email":true,"push":true},"locale":"en-US","timezone":"America/Los_Angeles"}',
430.25, 1, datetime('now','-18 hours'), datetime('now','-180 days'), datetime('now','-18 hours')),
(14, 'Hannah Lee', 'hannah.lee@cloudscale.io', 'admin', '+1-206-555-0204', '1990-07-22',
'Platform reliability lead. Monitors the audit log and keeps the demo app settings up to date.',
'{"theme":"dark","notifications":{"email":true,"push":true},"locale":"en-US","timezone":"America/Los_Angeles"}',
3500.00, 1, datetime('now','-15 minutes'), datetime('now','-150 days'), datetime('now','-15 minutes')),
(15, 'Noah Fischer', 'noah.fischer@bytehaus.at', 'customer', '+43-1-555-0817', '1997-03-15',
'Vienna-based indie game dev. Needs low-latency peripherals and a quiet mechanical keyboard.',
'{"theme":"system","notifications":{"email":false,"push":true},"locale":"de-AT","timezone":"Europe/Vienna"}',
95.00, 1, datetime('now','-2 days'), datetime('now','-120 days'), datetime('now','-2 days')),
(16, 'Grace Okafor', 'grace.okafor@lift.ng', 'customer', '+234-1-555-0294', '1986-11-09',
'Remote team lead in Lagos. Orders standing desks and monitor arms for distributed teammates.',
'{"theme":"dark","notifications":{"email":true,"push":false},"locale":"en-NG","timezone":"Africa/Lagos"}',
1280.00, 1, datetime('now','-9 hours'), datetime('now','-90 days'), datetime('now','-9 hours')),
(17, 'Ethan Brooks', 'ethan.brooks@nullsecurity.dev', 'customer', '+1-720-555-0112', '1994-12-30',
'Security researcher with a dry sense of humor. Tests the demo with suspiciously long JSON blobs.',
'{"theme":"dark","notifications":{"email":false,"push":false},"locale":"en-US","timezone":"America/Denver"}',
12.49, 1, datetime('now','-5 days'), datetime('now','-60 days'), datetime('now','-5 days')),
(18, 'Mia Rossi', 'mia.rossi@artigiano.it', 'customer', '+39-02-555-0366', '1991-05-03',
'Graphic designer from Milan. Cares a lot about color-accurate monitors and clean desk lighting.',
'{"theme":"light","notifications":{"email":true,"push":true},"locale":"it-IT","timezone":"Europe/Rome"}',
725.60, 1, datetime('now','-1 day'), datetime('now','-30 days'), datetime('now','-1 day')),
(19, 'Alexander Petrov', 'alex.petrov@polycode.ru', 'customer', '+7-495-555-0412', '1984-08-18',
'Backend engineer in Moscow. Maintains a home lab and buys networking accessories in pairs.',
'{"theme":"system","notifications":{"email":true,"push":false},"locale":"ru-RU","timezone":"Europe/Moscow"}',
199.00, 0, datetime('now','-45 days'), datetime('now','-15 days'), datetime('now','-45 days')),
(20, 'Zoe Williams', 'zoe.williams@greenleaf.org', 'moderator', '+1-510-555-0285', '1992-02-25',
'Sustainability coordinator. Reviews product descriptions and pushes for paperless invoices.',
'{"theme":"light","notifications":{"email":true,"push":false},"locale":"en-US","timezone":"America/Los_Angeles"}',
88.00, 1, datetime('now','-6 hours'), datetime('now','-7 days'), datetime('now','-6 hours'));
-- ── Products: realistic SKUs, descriptions, and attributes ──────────────
INSERT OR IGNORE INTO products (id, sku, name, description, price, category_id, stock, rating, discontinued, attributes, created_at, updated_at) VALUES
(1, 'GL-MSE-001', 'Gridline Wireless Mouse', 'Ergonomic wireless mouse with silent switches, 2.4 GHz and Bluetooth 5.0, adjustable 800-1600 DPI, and a 12-month battery life.', 34.99, 1, 142, 4.6, 0, '{"color":"Graphite","wireless":true,"dpi_max":1600,"buttons":5}', datetime('now','-360 days'), datetime('now','-3 days')),
(2, 'GL-KBD-001', 'Gridline Mechanical Keyboard', 'Hot-swappable TKL board with tactile brown switches, per-key RGB, PBT keycaps, and a USB-C braided cable.', 119.99, 5, 68, 4.8, 0, '{"layout":"US ANSI","switches":"brown","backlit":true,"connection":"wired"}', datetime('now','-350 days'), datetime('now','-5 days')),
(3, 'GL-HUB-001', 'Gridline 7-in-1 USB-C Hub', 'Aluminium hub with 4K HDMI, 100 W power delivery, two USB-A 3.2 ports, SD/microSD slots, and a braided cable.', 44.99, 2, 215, 4.4, 0, '{"ports":7,"hdmi":"4K60","power_delivery":"100W","material":"aluminium"}', datetime('now','-340 days'), datetime('now','-2 days')),
(4, 'GL-MON-001', 'Gridline 27" 4K USB-C Monitor', '27-inch IPS panel, 3840×2160, 60 Hz, 99% sRGB, USB-C upstream with 90 W charging, fully adjustable stand.', 499.99, 6, 28, 4.7, 0, '{"resolution":"3840x2160","refresh_hz":60,"panel":"IPS","usb_c_power":90}', datetime('now','-330 days'), datetime('now','-8 days')),
(5, 'GL-STD-001', 'Gridline Aluminium Laptop Stand', 'Foldable aluminium stand with six height positions, ventilated design and silicone pads. Fits 12-16 inch laptops.', 54.99, 2, 108, 4.2, 0, '{"color":"Silver","max_height_mm":280,"foldable":true}', datetime('now','-320 days'), datetime('now','-10 days')),
(6, 'GL-CAM-001', 'Gridline 1080p Webcam', 'Full HD webcam with privacy shutter, dual noise-reducing mics, autofocus, and plug-and-play compatibility.', 69.99, 1, 0, 4.1, 1, '{"resolution":"1920x1080","fps":30,"microphone":true,"autofocus":true}', datetime('now','-310 days'), datetime('now','-60 days')),
(7, 'GL-LMP-001', 'Gridline LED Desk Lamp', 'Dimmable LED lamp with 2700-6500 K colour temperature, flexible neck, and a built-in USB-A charging port.', 42.99, 3, 134, 4.5, 0, '{"color_temp_k":"2700-6500","dimmable":true,"usb_port":true}', datetime('now','-300 days'), datetime('now','-6 days')),
(8, 'GL-CHR-001', 'Gridline Ergonomic Mesh Chair', 'Breathable mesh back, adjustable lumbar support, 4D armrests, and a gas lift rated up to 150 kg.', 649.99, 4, 12, 4.7, 0, '{"material":"mesh","lumbar_support":true,"max_weight_kg":150}', datetime('now','-290 days'), datetime('now','-15 days')),
(9, 'GL-CBL-001', 'Gridline Braided USB-C Cable 2 m', 'Braided USB-C to USB-C cable rated for 100 W charging and USB 3.2 data. Tested to 10,000 bends.', 16.99, 2, 480, 4.3, 0, '{"length_m":2,"charging_w":100,"data_speed":"USB 3.2","color":"Midnight"}', datetime('now','-280 days'), datetime('now','-4 days')),
(10, 'GL-ARM-001', 'Gridline Single Monitor Arm', 'Gas-spring monitor arm with 75/100 mm VESA, 360° rotation, and cable management. Supports up to 9 kg.', 89.99, 6, 36, 4.5, 0, '{"weight_capacity_kg":9,"vesa":"75/100","rotation":"360"}', datetime('now','-270 days'), datetime('now','-9 days')),
(11, 'GL-MSE-002', 'Gridline Pro Wireless Mouse', 'Premium wireless mouse with 4000 DPI sensor, USB-C rechargeable battery, and magnetic storage case.', 79.99, 1, 64, 4.7, 0, '{"color":"Pearl","wireless":true,"dpi_max":4000,"rechargeable":true}', datetime('now','-260 days'), datetime('now','-7 days')),
(12, 'GL-KBD-002', 'Gridline Compact Keyboard', '65% low-profile mechanical keyboard with red linear switches, white backlight, and Bluetooth/wired dual mode.', 94.99, 5, 91, 4.4, 0, '{"layout":"65%","switches":"red linear","backlit":true,"wireless":true}', datetime('now','-250 days'), datetime('now','-6 days')),
(13, 'GL-HUB-002', 'Gridline 10-in-1 Docking Station', 'Thunderbolt-compatible dock with dual 4K display support, 2.5 GbE, and 140 W power delivery.', 219.99, 2, 41, 4.6, 0, '{"ports":10,"ethernet":"2.5G","power_delivery":"140W","displays":2}', datetime('now','-240 days'), datetime('now','-3 days')),
(14, 'GL-MON-002', 'Gridline 32" Curved Monitor', '32-inch curved VA panel, 2560×1440, 165 Hz refresh rate, HDR400, and adaptive sync.', 429.99, 6, 19, 4.5, 0, '{"resolution":"2560x1440","refresh_hz":165,"panel":"VA","curved":true}', datetime('now','-230 days'), datetime('now','-11 days')),
(15, 'GL-DES-001', 'Gridline Standing Desk Frame', 'Electric sit-stand desk frame with dual motors, memory presets, and a quiet 45 dB lift mechanism.', 549.99, 4, 8, 4.6, 0, '{"width_adjustable":true,"memory_presets":4,"noise_db":45}', datetime('now','-220 days'), datetime('now','-12 days')),
(16, 'GL-KEY-001', 'Gridline Keycap Set', 'PBT dye-sub keycap set with 140 keys, compatible with most ANSI and ISO layouts.', 39.99, 5, 77, 4.3, 0, '{"keys":140,"material":"PBT","profile":"Cherry"}', datetime('now','-210 days'), datetime('now','-5 days')),
(17, 'GL-PAD-001', 'Gridline Desk Mat', 'Oversized felt desk mat with stitched edges and anti-slip base. 900 × 400 mm.', 29.99, 3, 156, 4.4, 0, '{"material":"felt","size_mm":"900x400","anti_slip":true}', datetime('now','-200 days'), datetime('now','-8 days')),
(18, 'GL-MIC-001', 'Gridline USB Microphone', 'Cardioid condenser mic with built-in pop filter, mute button, and 24-bit/96 kHz sampling.', 119.99, 1, 33, 4.5, 0, '{"pattern":"cardioid","sample_rate":"96kHz","bit_depth":24}', datetime('now','-190 days'), datetime('now','-9 days')),
(19, 'GL-LGT-001', 'Gridline Monitor Light Bar', 'Screen-hanging light bar with auto-dimming, warm/cool temperature control, and no glare on the panel.', 59.99, 3, 88, 4.2, 0, '{"mount":"screen_hanging","auto_dim":true,"color_temp_k":"3000-6500"}', datetime('now','-180 days'), datetime('now','-7 days')),
(20, 'GL-CBL-002', 'Gridline Magnetic Cable Trio', 'Set of three braided magnetic cables (USB-C, Lightning, Micro-USB) with one interchangeable tip dock.', 34.99, 2, 203, 4.0, 0, '{"cables":3,"tips":["USB-C","Lightning","Micro-USB"],"magnetic":true}', datetime('now','-170 days'), datetime('now','-6 days')),
(21, 'GL-BAG-001', 'Gridline Tech Pouch', 'Water-resistant tech pouch with elastic loops, mesh pockets, and a cable pass-through.', 44.99, 2, 119, 4.5, 0, '{"water_resistant":true,"compartments":8,"color":"Charcoal"}', datetime('now','-160 days'), datetime('now','-4 days')),
(22, 'GL-SPK-001', 'Gridline Desktop Speakers', 'Pair of powered bookshelf speakers with Bluetooth input, RCA/AUX, and solid wood enclosures.', 149.99, 1, 22, 4.3, 0, '{"pair":true,"inputs":["Bluetooth","RCA","AUX"],"enclosure":"wood"}', datetime('now','-150 days'), datetime('now','-10 days')),
(23, 'GL-TRP-001', 'Gridline Tripod Desk Lamp', 'Minimal tripod desk lamp with touch dimming, USB-C power, and a 360° rotating head.', 49.99, 3, 62, 4.1, 0, '{"base":"tripod","touch_dim":true,"power":"USB-C"}', datetime('now','-140 days'), datetime('now','-8 days')),
(24, 'GL-HDM-001', 'Gridline HDMI 2.1 Cable 1.5 m', 'Certified Ultra High Speed HDMI 2.1 cable supporting 8K@60 Hz and 4K@120 Hz, 48 Gbps.', 24.99, 2, 312, 4.4, 0, '{"length_m":1.5,"hdmi_version":"2.1","bandwidth_gbps":48}', datetime('now','-130 days'), datetime('now','-3 days'));
-- ── Addresses: 1-3 realistic addresses per user ────────────────────────
INSERT OR IGNORE INTO addresses (id, user_id, label, street, city, state, zip, country, is_primary, created_at) VALUES
(1, 1, 'Home', '1234 Mission Street, Apt 42', 'San Francisco', 'CA', '94103', 'USA', 1, datetime('now','-500 days')),
(2, 1, 'Work', '555 Montgomery Street, Floor 8', 'San Francisco', 'CA', '94111', 'USA', 0, datetime('now','-480 days')),
(3, 2, 'Home', '2100 Barton Creek Blvd', 'Austin', 'TX', '78735', 'USA', 1, datetime('now','-490 days')),
(4, 3, 'Home', '4542 11th Avenue NE', 'Seattle', 'WA', '98105', 'USA', 1, datetime('now','-470 days')),
(5, 3, 'Office', '7200 Woodlawn Ave NE', 'Seattle', 'WA', '98115', 'USA', 0, datetime('now','-460 days')),
(6, 4, 'Home', '850 7th Avenue, Apt 12B', 'New York', 'NY', '10019', 'USA', 1, datetime('now','-440 days')),
(7, 5, 'Home', '1600 Glenarm Place', 'Denver', 'CO', '80202', 'USA', 1, datetime('now','-410 days')),
(8, 5, 'Warehouse', '4650 Paris Street', 'Denver', 'CO', '80239', 'USA', 0, datetime('now','-400 days')),
(9, 6, 'Home', '44 Prince Street', 'Boston', 'MA', '02113', 'USA', 1, datetime('now','-380 days')),
(10, 7, 'Home', '1080 Arastradero Road', 'Palo Alto', 'CA', '94304', 'USA', 1, datetime('now','-350 days')),
(11, 8, 'Office', 'Friedrichstraße 123', 'Berlin', 'Berlin', '10117', 'Germany', 1, datetime('now','-320 days')),
(12, 9, 'Home', '1500 Ocean Drive, Apt 805', 'Miami Beach', 'FL', '33139', 'USA', 1, datetime('now','-290 days')),
(13, 10, 'Work', '555 Ellis Street', 'Mountain View', 'CA', '94043', 'USA', 1, datetime('now','-260 days')),
(14, 11, 'Home', '1925 SE Hawthorne Blvd', 'Portland', 'OR', '97214', 'USA', 1, datetime('now','-230 days')),
(15, 12, 'Office', 'Birger Jarlsgatan 58', 'Stockholm', 'Stockholm', '111 45', 'Sweden', 1, datetime('now','-200 days')),
(16, 13, 'Home', '925 W 5th Avenue', 'Chicago', 'IL', '60642', 'USA', 1, datetime('now','-170 days')),
(17, 14, 'Home', '2211 7th Avenue', 'Seattle', 'WA', '98121', 'USA', 1, datetime('now','-140 days')),
(18, 15, 'Home', 'Opernring 5', 'Vienna', 'Vienna', '1010', 'Austria', 1, datetime('now','-110 days')),
(19, 16, 'Home', '12 Ikoyi Crescent', 'Lagos', 'Lagos', '101233', 'Nigeria', 1, datetime('now','-80 days')),
(20, 17, 'Home', '1600 Walnut Street, Unit 300', 'Denver', 'CO', '80202', 'USA', 1, datetime('now','-50 days')),
(21, 18, 'Home', 'Via Tortona 12', 'Milan', 'MI', '20144', 'Italy', 1, datetime('now','-25 days')),
(22, 19, 'Home', 'Ulitsa Bolshaya Dmitrovka 9', 'Moscow', 'Moscow', '125009', 'Russia', 1, datetime('now','-10 days')),
(23, 20, 'Home', '1900 Broadway', 'Oakland', 'CA', '94612', 'USA', 1, datetime('now','-5 days'));
-- ── Files: realistic file attachments with BLOB headers ─────────────────
INSERT OR IGNORE INTO files (id, name, mime_type, content, size_bytes, uploaded_by, uploaded_at) VALUES
(1, 'gridline_logo.png', 'image/png', X'89504E470D0A1A0A0000000D49484452000001000000010008060000005C72A866000000017352474200AECE1CE90000000467414D410000B18F0BFC61050000', 14256, 1, datetime('now','-45 days')),
(2, 'product_photos.zip', 'application/zip', X'504B03040A00000000008B6B2D570000000000000000000000000800000070726F64756374732F504B0102001F000A00000000008B6B2D57000000000000000000000000080000000000000000000000A4810000000070726F64756374732F504B050600000000010001003A0000001A0000000000', 28934, 2, datetime('now','-42 days')),
(3, 'invoice_2026_001.pdf', 'application/pdf', X'255044462D312E340A25E2E3CFD30A342030206F626A0A3C3C202F4C696E656172697A65642031202F4C2031203E3E0A3E3E0A73747265616D0A0A42510A0A656E6473747265616D0A656E646F626A0A', 18432, 10, datetime('now','-38 days')),
(4, 'shipping_labels.pdf', 'application/pdf', X'255044462D312E340A25E2E3CFD30A342030206F626A0A3C3C202F54797065202F436174616C6F67202F50616765732031302020302020520A2F4F75746C696E65732032302020302020520A3E3E0A656E646F626A0A', 12288, 14, datetime('now','-30 days')),
(5, 'user_avatars.jpg', 'image/jpeg', X'FFD8FFE000104A46494600010100000100010000FFDB004300080606070605080707070909080A0C140D0C0B0B0C1912130F14311A1F1F1A1C232D', 8934, 1, datetime('now','-25 days')),
(6, 'q4_inventory.csv', 'text/csv', X'69642C6E616D652C73746F636B2C72657365727665640A312C47726E646C696E6520576972656C657373204D6F7573652C3134322C300A322C47726E646C696E65204D656368616E6963616C204B6579626F6172642C36382C350A', 5632, 14, datetime('now','-18 days')),
(7, 'backup.sql', 'application/sql', X'2D2D20477269646C696E652064656D6F206261636B75700A50524F4752416D757365725F76657273696F6E203D20333B0A435245415445205441424C45204946204E4F542045584953545320757365727320282E2E2E293B', 4096, 14, datetime('now','-7 days')),
(8, 'favicon.ico', 'image/x-icon', X'00000100010010100000000020006804000016000000280000001000000020000000010008', 4286, 1, datetime('now','-3 days'));
-- ── App settings: TEXT primary key ──────────────────────────────────────
INSERT OR IGNORE INTO app_settings (key, value, updated_at) VALUES
('site_name', 'Gridline Demo Store', datetime('now','-60 days')),
('maintenance_mode', 'false', datetime('now','-5 days')),
('max_cart_items', '50', datetime('now','-30 days')),
('currency', 'USD', datetime('now','-60 days')),
('default_shipping_country', 'USA', datetime('now','-20 days')),
('support_email', 'support@gridline.dev', datetime('now','-10 days'));
-- ═══════════════════════════════════════════════════════════════════════
-- Generated data: orders, line items, page views, audit log
-- ═══════════════════════════════════════════════════════════════════════
-- 50 realistic orders across the user base with varied statuses and dates.
WITH RECURSIVE order_seq(n) AS (
SELECT 1 UNION ALL SELECT n + 1 FROM order_seq WHERE n < 50
)
INSERT OR IGNORE INTO orders (id, user_id, shipping_address_id, total, status, notes, created_at, updated_at, shipped_at)
SELECT
n,
-- Distribute orders across users 1-18; users 19 (inactive) and 20 (recent) get fewer.
CASE
WHEN n <= 35 THEN ((n - 1) % 18) + 1
WHEN n <= 45 THEN ((n - 1) % 12) + 1
ELSE ((n - 1) % 8) + 1
END,
-- Pick a primary or secondary address for that user if one exists.
NULL,
0, -- total recalculated from line items below
CASE n % 12
WHEN 0 THEN 'pending'
WHEN 1 THEN 'processing'
WHEN 2 THEN 'shipped'
WHEN 3 THEN 'delivered'
WHEN 4 THEN 'completed'
WHEN 5 THEN 'cancelled'
WHEN 6 THEN 'refunded'
WHEN 7 THEN 'shipped'
WHEN 8 THEN 'processing'
WHEN 9 THEN 'completed'
WHEN 10 THEN 'pending'
ELSE 'delivered'
END,
CASE n % 8
WHEN 0 THEN 'Please leave the package at the front desk.'
WHEN 1 THEN 'Gift wrap, please.'
WHEN 2 THEN 'Customer requested eco-friendly packaging.'
WHEN 3 THEN 'Ship after the 15th — office move in progress.'
WHEN 4 THEN NULL
WHEN 5 THEN 'Call before delivery.'
WHEN 6 THEN 'Authority to leave if not home.'
ELSE NULL
END,
datetime('now', printf('-%d days', 60 - (n % 58))),
datetime('now', printf('-%d days', 58 - (n % 56))),
CASE WHEN n % 12 IN (2,3,4,7,11) THEN datetime('now', printf('-%d days', 55 - (n % 53))) ELSE NULL END
FROM order_seq;
-- Assign a realistic shipping address to each order from the user's address set.
UPDATE orders SET shipping_address_id = (
SELECT a.id FROM addresses a
WHERE a.user_id = orders.user_id
ORDER BY a.is_primary DESC, a.id
LIMIT 1
);
-- 100+ order line items: 1-3 products per order.
WITH RECURSIVE line_seq(n) AS (
SELECT 1 UNION ALL SELECT n + 1 FROM line_seq WHERE n < 105
)
INSERT OR IGNORE INTO order_items (order_id, product_id, quantity, unit_price)
SELECT
((n - 1) % 50) + 1,
((n - 1) % 24) + 1,
CASE n % 5 WHEN 0 THEN 3 WHEN 1 THEN 2 ELSE 1 END,
(SELECT price FROM products WHERE id = ((n - 1) % 24) + 1)
FROM line_seq;
-- Recalculate order totals from line items.
UPDATE orders SET total = (
SELECT ROUND(SUM(quantity * unit_price), 2)
FROM order_items
WHERE order_items.order_id = orders.id
);
-- 100 page views with realistic user agents and URLs.
WITH RECURSIVE pv_seq(n) AS (
SELECT 1 UNION ALL SELECT n + 1 FROM pv_seq WHERE n < 100
)
INSERT INTO page_views (url, session_id, user_agent, viewed_at)
SELECT
CASE n % 10
WHEN 0 THEN '/products'
WHEN 1 THEN '/products/' || ((n % 24) + 1)
WHEN 2 THEN '/cart'
WHEN 3 THEN '/checkout'
WHEN 4 THEN '/orders'
WHEN 5 THEN '/settings'
WHEN 6 THEN '/categories/electronics'
WHEN 7 THEN '/categories/office'
WHEN 8 THEN '/search?q=keyboard'
ELSE '/'
END,
'sess-' || printf('%03d', (n % 30) + 1),
CASE n % 6
WHEN 0 THEN 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36'
WHEN 1 THEN 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36'
WHEN 2 THEN 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1'
WHEN 3 THEN 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36'
WHEN 4 THEN 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:128.0) Gecko/20100101 Firefox/128.0'
ELSE 'Mozilla/5.0 (iPad; CPU OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1'
END,
datetime('now', printf('-%d minutes', n * 13))
FROM pv_seq
WHERE NOT EXISTS (SELECT 1 FROM page_views);
-- 500 audit-log rows for pagination / virtualization / filtering demos.
WITH RECURSIVE audit_seq(n) AS (
SELECT 1 UNION ALL SELECT n + 1 FROM audit_seq WHERE n < 500
)
INSERT INTO audit_log (user_id, action, entity_type, entity_id, severity, details, duration_ms, created_at)
SELECT
CASE WHEN n % 9 = 0 THEN NULL ELSE ((n - 1) % 18) + 1 END,
CASE n % 8
WHEN 0 THEN 'login'
WHEN 1 THEN 'page_view'
WHEN 2 THEN 'update'
WHEN 3 THEN 'create'
WHEN 4 THEN 'delete'
WHEN 5 THEN 'export'
WHEN 6 THEN 'refund'
ELSE 'ship'
END,
CASE n % 5
WHEN 0 THEN 'order'
WHEN 1 THEN 'product'
WHEN 2 THEN 'user'
WHEN 3 THEN 'address'
ELSE 'report'
END,
(n % 50) + 1,
CASE n % 6
WHEN 0 THEN 'info'
WHEN 1 THEN 'info'
WHEN 2 THEN 'warning'
WHEN 3 THEN 'error'
WHEN 4 THEN 'critical'
ELSE 'warning'
END,
CASE WHEN n % 7 = 0 THEN NULL
ELSE '{"page":"' ||
CASE n % 4 WHEN 0 THEN '/' WHEN 1 THEN '/products' WHEN 2 THEN '/checkout' ELSE '/orders' END ||
'","retries":' || (n % 3) || ',"row":' || n || ',"region":"' ||
CASE n % 5 WHEN 0 THEN 'us-east' WHEN 1 THEN 'us-west' WHEN 2 THEN 'eu-central' WHEN 3 THEN 'ap-south' ELSE 'sa-east' END ||
'"}'
END,
(n * 37) % 2500,
datetime('now', printf('-%d minutes', n * 7))
FROM audit_seq
WHERE NOT EXISTS (SELECT 1 FROM audit_log);
+140
View File
@@ -0,0 +1,140 @@
use crate::models::{Folder, FolderInput};
use crate::store::Store;
use std::sync::Mutex;
fn validate(input: &FolderInput) -> Result<(), String> {
if input.name.is_empty() || input.name.chars().count() > 100 {
return Err("name is required and must be 100 chars or fewer".into());
}
Ok(())
}
pub fn get_folders_inner(state: &Mutex<Store>) -> Result<Vec<Folder>, String> {
let store = state.lock().map_err(|e| e.to_string())?;
store.get_folders()
}
pub fn create_folder_inner(state: &Mutex<Store>, input: FolderInput) -> Result<Folder, String> {
validate(&input)?;
let store = state.lock().map_err(|e| e.to_string())?;
store.create_folder(input)
}
pub fn delete_folder_inner(state: &Mutex<Store>, id: &str) -> Result<(), String> {
let store = state.lock().map_err(|e| e.to_string())?;
store.delete_folder(id)
}
pub fn add_folder_tags_inner(
state: &Mutex<Store>,
folder_id: String,
tag_ids: Vec<String>,
) -> Result<(), String> {
let store = state.lock().map_err(|e| e.to_string())?;
store.add_folder_tags(&folder_id, &tag_ids)
}
pub fn update_folder_inner(
state: &Mutex<Store>,
id: String,
input: FolderInput,
) -> Result<Folder, String> {
validate(&input)?;
let store = state.lock().map_err(|e| e.to_string())?;
store.update_folder(&id, input)
}
#[tauri::command]
pub fn get_folders(state: tauri::State<crate::AppState>) -> Result<Vec<Folder>, String> {
get_folders_inner(&state.db_store)
}
#[tauri::command]
pub fn create_folder(
state: tauri::State<crate::AppState>,
input: FolderInput,
) -> Result<Folder, String> {
create_folder_inner(&state.db_store, input)
}
#[tauri::command]
pub fn delete_folder(state: tauri::State<crate::AppState>, id: String) -> Result<(), String> {
delete_folder_inner(&state.db_store, &id)
}
#[tauri::command]
pub fn add_folder_tags(
state: tauri::State<crate::AppState>,
folder_id: String,
tag_ids: Vec<String>,
) -> Result<(), String> {
add_folder_tags_inner(&state.db_store, folder_id, tag_ids)
}
#[tauri::command]
pub fn update_folder(
state: tauri::State<crate::AppState>,
id: String,
input: FolderInput,
) -> Result<Folder, String> {
update_folder_inner(&state.db_store, id, input)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::FolderInput;
use crate::store::Store;
fn state() -> std::sync::Mutex<Store> {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::store::migrations::run_migrations(&conn).unwrap();
std::sync::Mutex::new(Store::from_connection(conn))
}
#[test]
fn create_folder_command_works() {
let st = state();
let folder = create_folder_inner(
&st,
FolderInput {
tag_ids: None,
name: "Work".into(),
parent_id: None,
},
)
.unwrap();
assert_eq!(get_folders_inner(&st).unwrap().len(), 1);
assert_eq!(folder.name, "Work");
}
#[test]
fn create_folder_rejects_empty_name() {
let st = state();
let result = create_folder_inner(
&st,
FolderInput {
tag_ids: None,
name: "".into(),
parent_id: None,
},
);
assert!(result.is_err());
}
#[test]
fn delete_folder_command_works() {
let st = state();
let folder = create_folder_inner(
&st,
FolderInput {
tag_ids: None,
name: "Work".into(),
parent_id: None,
},
)
.unwrap();
delete_folder_inner(&st, &folder.id).unwrap();
assert_eq!(get_folders_inner(&st).unwrap().len(), 0);
}
}
@@ -0,0 +1,224 @@
use crate::models::ConnectionInput;
use crate::store::Store;
use serde::{Deserialize, Serialize};
use std::sync::Mutex;
const VALID_DB_TYPES: [&str; 4] = ["postgresql", "mysql", "sqlite", "redis"];
#[derive(Debug, Deserialize)]
pub(crate) struct ImportRecord {
name: Option<String>,
db_type: String,
host: String,
port: Option<i64>,
username: Option<String>,
folder_id: Option<String>,
tag_ids: Option<Vec<String>>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SkippedRecord {
pub index: usize,
pub reason: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ImportResult {
pub imported: usize,
pub skipped: usize,
pub skipped_records: Vec<SkippedRecord>,
}
#[allow(dead_code)]
pub fn parse_import(json: &str) -> Result<Vec<ImportRecord>, String> {
let records: Vec<ImportRecord> =
serde_json::from_str(json).map_err(|e| format!("invalid JSON: {}", e))?;
for (i, rec) in records.iter().enumerate() {
if rec.name.as_deref().unwrap_or("").is_empty() {
return Err(format!("record {}: name is required", i));
}
if !VALID_DB_TYPES.contains(&rec.db_type.as_str()) {
return Err(format!("record {}: invalid db_type: {}", i, rec.db_type));
}
}
Ok(records)
}
pub fn import_connections_inner(
state: &Mutex<Store>,
json: String,
) -> Result<ImportResult, String> {
let records: Vec<ImportRecord> =
serde_json::from_str(&json).map_err(|e| format!("invalid JSON: {}", e))?;
let store = state.lock().map_err(|e| e.to_string())?;
let mut imported = 0usize;
let mut skipped_records = Vec::new();
for (i, rec) in records.iter().enumerate() {
let name = match &rec.name {
Some(n) if !n.is_empty() => n.clone(),
_ => {
skipped_records.push(SkippedRecord {
index: i,
reason: "missing or empty name".into(),
});
continue;
}
};
if !VALID_DB_TYPES.contains(&rec.db_type.as_str()) {
skipped_records.push(SkippedRecord {
index: i,
reason: format!("invalid db_type: {}", rec.db_type),
});
continue;
}
if rec.host.is_empty() {
skipped_records.push(SkippedRecord {
index: i,
reason: "missing or empty host".into(),
});
continue;
}
let input = ConnectionInput {
name,
db_type: rec.db_type.clone(),
host: rec.host.clone(),
port: rec.port,
username: rec.username.clone(),
folder_id: rec.folder_id.clone(),
password: None,
database: None,
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_private_key_path: None,
ssh_password: None,
ssh_passphrase: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
environment: None,
tag_ids: rec.tag_ids.clone().unwrap_or_default(),
use_keychain: true,
};
match store.create_connection(input) {
Ok(_) => imported += 1,
Err(e) => skipped_records.push(SkippedRecord {
index: i,
reason: e,
}),
}
}
Ok(ImportResult {
imported,
skipped: skipped_records.len(),
skipped_records,
})
}
pub fn export_connections_inner(state: &Mutex<Store>) -> Result<String, String> {
let store = state.lock().map_err(|e| e.to_string())?;
let conns = store.get_connections()?;
let export = serde_json::json!({ "version": 1, "connections": conns });
serde_json::to_string_pretty(&export).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn import_connections(
state: tauri::State<crate::AppState>,
json: String,
) -> Result<ImportResult, String> {
import_connections_inner(&state.db_store, json)
}
#[tauri::command]
pub fn export_connections(state: tauri::State<crate::AppState>) -> Result<String, String> {
export_connections_inner(&state.db_store)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::ConnectionInput;
use crate::store::Store;
fn state() -> std::sync::Mutex<Store> {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::store::migrations::run_migrations(&conn).unwrap();
std::sync::Mutex::new(Store::from_connection(conn))
}
#[test]
fn parse_import_validates_required_fields() {
let json = r#"[{ "name": "X", "db_type": "postgresql", "host": "h", "port": 5432 }]"#;
let parsed = parse_import(json).unwrap();
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].name.as_deref(), Some("X"));
}
#[test]
fn parse_import_rejects_missing_name() {
let json = r#"[{ "db_type": "postgresql", "host": "h", "port": 5432 }]"#;
assert!(parse_import(json).is_err());
}
#[test]
fn parse_import_rejects_invalid_db_type() {
let json = r#"[{ "name": "X", "db_type": "mongodb", "host": "h", "port": 5432 }]"#;
assert!(parse_import(json).is_err());
}
#[test]
fn import_connections_inserts_all() {
let st = state();
let json = r#"[{ "name": "A", "db_type": "postgresql", "host": "h", "port": 5432 }, { "name": "B", "db_type": "redis", "host": "r", "port": 6379 }]"#;
let result = import_connections_inner(&st, json.to_string()).unwrap();
assert_eq!(result.imported, 2);
assert_eq!(result.skipped, 0);
}
#[test]
fn import_connections_skips_invalid_keeps_valid() {
let st = state();
let json = r#"[{ "name": "A", "db_type": "postgresql", "host": "h", "port": 5432 }, { "db_type": "postgresql", "host": "h", "port": 5432 }, { "name": "B", "db_type": "redis", "host": "r", "port": 6379 }]"#;
let result = import_connections_inner(&st, json.to_string()).unwrap();
assert_eq!(result.imported, 2);
assert_eq!(result.skipped, 1);
assert_eq!(result.skipped_records[0].reason, "missing or empty name");
}
#[test]
fn export_connections_returns_json() {
let st = state();
let _ = st.lock().unwrap().create_connection(ConnectionInput {
name: "A".into(),
db_type: "postgresql".into(),
host: "h".into(),
port: Some(5432),
username: None,
folder_id: None,
password: None,
database: None,
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_private_key_path: None,
ssh_password: None,
ssh_passphrase: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
environment: None,
tag_ids: vec![],
use_keychain: true,
});
let json = export_connections_inner(&st).unwrap();
assert!(json.contains("\"name\""));
assert!(json.contains("\"version\""));
}
}
+193
View File
@@ -0,0 +1,193 @@
use tauri_plugin_keyring_store::KeyringExt;
/// Store a connection password in the OS keychain.
/// The connection ID is used as the keyring account name.
#[tauri::command]
pub fn save_connection_password(
app: tauri::AppHandle,
connection_id: String,
password: String,
) -> Result<(), String> {
app.keyring()
.store
.set_password(&connection_id, &password)
.map_err(|e| e.to_string())
}
/// Retrieve a connection password from the OS keychain.
/// Returns None if no password was stored for this connection.
#[tauri::command]
pub fn get_connection_password(
app: tauri::AppHandle,
connection_id: String,
) -> Result<Option<String>, String> {
app.keyring()
.store
.get_password(&connection_id)
.map_err(|e| e.to_string())
}
/// Retrieve a connection password from the OS keychain (internal helper).
/// Returns None if no password was stored for this connection.
pub fn get_connection_password_internal(
app: &tauri::AppHandle,
connection_id: &str,
) -> Result<Option<String>, String> {
app.keyring()
.store
.get_password(connection_id)
.map_err(|e| e.to_string())
}
/// Build the keyring account name for an SSH secret (password or passphrase).
/// The `connection_id` namespaces each secret per connection.
pub fn ssh_account(kind: &str, connection_id: &str) -> String {
format!("ssh_{kind}:{connection_id}")
}
/// Store an SSH tunnel password in the OS keychain.
#[tauri::command]
pub fn save_connection_ssh_password(
app: tauri::AppHandle,
connection_id: String,
password: String,
) -> Result<(), String> {
app.keyring()
.store
.set_password(&ssh_account("password", &connection_id), &password)
.map_err(|e| e.to_string())
}
/// Retrieve an SSH tunnel password from the OS keychain.
/// Returns None if no SSH password was stored for this connection.
#[tauri::command]
pub fn get_connection_ssh_password(
app: tauri::AppHandle,
connection_id: String,
) -> Result<Option<String>, String> {
app.keyring()
.store
.get_password(&ssh_account("password", &connection_id))
.map_err(|e| e.to_string())
}
/// Delete an SSH tunnel password from the OS keychain.
#[tauri::command]
pub fn delete_connection_ssh_password(
app: tauri::AppHandle,
connection_id: String,
) -> Result<(), String> {
app.keyring()
.store
.delete(&ssh_account("password", &connection_id))
.map_err(|e| e.to_string())
}
/// Store an SSH private-key passphrase in the OS keychain.
#[tauri::command]
pub fn save_connection_ssh_passphrase(
app: tauri::AppHandle,
connection_id: String,
passphrase: String,
) -> Result<(), String> {
app.keyring()
.store
.set_password(&ssh_account("passphrase", &connection_id), &passphrase)
.map_err(|e| e.to_string())
}
/// Retrieve an SSH private-key passphrase from the OS keychain.
/// Returns None if no passphrase was stored for this connection.
#[tauri::command]
pub fn get_connection_ssh_passphrase(
app: tauri::AppHandle,
connection_id: String,
) -> Result<Option<String>, String> {
app.keyring()
.store
.get_password(&ssh_account("passphrase", &connection_id))
.map_err(|e| e.to_string())
}
/// Delete an SSH private-key passphrase from the OS keychain.
#[tauri::command]
pub fn delete_connection_ssh_passphrase(
app: tauri::AppHandle,
connection_id: String,
) -> Result<(), String> {
app.keyring()
.store
.delete(&ssh_account("passphrase", &connection_id))
.map_err(|e| e.to_string())
}
/// Retrieve an SSH tunnel password from the OS keychain (internal helper).
/// Returns None if no SSH password was stored for this connection.
pub fn get_connection_ssh_password_internal(
app: &tauri::AppHandle,
connection_id: &str,
) -> Result<Option<String>, String> {
app.keyring()
.store
.get_password(&ssh_account("password", connection_id))
.map_err(|e| e.to_string())
}
/// Delete an SSH tunnel password from the OS keychain (internal helper).
/// Errors are ignored by callers (deleting an absent key is a no-op).
pub fn delete_connection_ssh_password_internal(
app: &tauri::AppHandle,
connection_id: &str,
) -> Result<(), String> {
app.keyring()
.store
.delete(&ssh_account("password", connection_id))
.map_err(|e| e.to_string())
}
/// Delete an SSH private-key passphrase from the OS keychain (internal helper).
/// Errors are ignored by callers (deleting an absent key is a no-op).
pub fn delete_connection_ssh_passphrase_internal(
app: &tauri::AppHandle,
connection_id: &str,
) -> Result<(), String> {
app.keyring()
.store
.delete(&ssh_account("passphrase", connection_id))
.map_err(|e| e.to_string())
}
/// Delete a connection password from the OS keychain.
#[tauri::command]
pub fn delete_connection_password(
app: tauri::AppHandle,
connection_id: String,
) -> Result<(), String> {
app.keyring()
.store
.delete(&connection_id)
.map_err(|e| e.to_string())
}
/// Delete a connection password from the OS keychain (internal helper).
/// Errors are ignored by callers (deleting an absent key is a no-op).
pub fn delete_connection_password_internal(
app: &tauri::AppHandle,
connection_id: &str,
) -> Result<(), String> {
app.keyring()
.store
.delete(connection_id)
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ssh_account_namespaces_password() {
assert_eq!(ssh_account("password", "c1"), "ssh_password:c1");
assert_eq!(ssh_account("passphrase", "c1"), "ssh_passphrase:c1");
}
}
@@ -0,0 +1,39 @@
use tauri::State;
use crate::db::pool::{ConnectionPoolManager, DbHandle};
use crate::models::MaintenanceResult;
/// Run a table-scoped maintenance command via the SIMPLE query protocol
/// (never inside a transaction — VACUUM cannot run in a transaction block).
pub(crate) async fn run_maintenance_inner(
pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, table: &str, action: &str,
) -> Result<MaintenanceResult, String> {
let mut pm = pm.lock().await;
let client = match pm.get(connection_id) {
Some(DbHandle::Postgresql(c, _)) => c,
Some(_) => return Err("Maintenance is PostgreSQL-only".into()),
None => return Err("Connection not found".into()),
};
crate::db::object_ddl::validate_object_name(&schema)?;
crate::db::object_ddl::validate_object_name(&table)?;
let verb = match action {
"vacuum" => "VACUUM",
"analyze" => "ANALYZE",
"reindex" => "REINDEX TABLE",
_ => return Err(format!("Unknown maintenance action: {action}")),
};
let sql = format!("{} {}.{}", verb,
crate::db::object_ddl::quote_ident(&schema), crate::db::object_ddl::quote_ident(&table));
let start = std::time::Instant::now();
client.simple_query(&sql).await
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
Ok(MaintenanceResult { duration_ms: start.elapsed().as_millis() as i64, message: format!("{} completed on {}.{}", verb, schema, table) })
}
#[tauri::command]
pub async fn run_maintenance(connection_id: String, schema: String, table: String, action: String, state: State<'_, crate::AppState>) -> Result<MaintenanceResult, String> {
run_maintenance_inner(&state.pool_manager, &connection_id, &schema, &table, &action).await
}
#[cfg(test)]
#[path = "maintenance.test.rs"]
mod maintenance_test;
@@ -0,0 +1,28 @@
use crate::db::pool::{ConnectionPoolManager, DbHandle};
/// Connect directly via tokio-postgres (no tunnel) so tests are Tauri-free.
async fn pool() -> (tokio::sync::Mutex<ConnectionPoolManager>, String) {
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 mut pm = ConnectionPoolManager::new();
let id = "test-conn".to_string();
pm.register(&id, DbHandle::Postgresql(client, handle));
(tokio::sync::Mutex::new(pm), id)
}
#[tokio::test]
#[ignore]
async fn run_maintenance_analyze_succeeds() {
let (pm, id) = pool().await;
let res = crate::commands::maintenance::run_maintenance_inner(&pm, &id, "public", "pg_type", "analyze").await.unwrap();
assert!(res.duration_ms >= 0);
assert!(res.message.to_lowercase().contains("analyze"));
}
+15
View File
@@ -0,0 +1,15 @@
pub mod backup;
pub mod connections;
pub mod db_viewer;
pub mod demo;
pub mod folders;
pub mod import_export;
pub mod keychain;
pub mod maintenance;
pub mod objects;
pub mod query;
pub mod schema_graph;
pub mod settings;
pub mod ssh;
pub mod tags;
pub mod test_connection;
+587
View File
@@ -0,0 +1,587 @@
use tauri::State;
use crate::db::pool::{ConnectionPoolManager, DbHandle};
use crate::db::object_crud::{build_ddl, rebuild_script, RebuildConstraint, RebuildFk, RebuildFkIn, RebuildGrant, RebuildIndex, RebuildInput, RebuildOwnedSequence, TableColumn};
use crate::db::object_ddl::*;
use crate::models::db_viewer::{ObjectSearchHit, DependencyInfo, ExtensionInfo};
fn sanitize(e: &str) -> String { crate::commands::db_viewer::sanitize_error(e) }
async fn exec_sql(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, sql: String) -> Result<(), String> {
let mut pm = pm.lock().await;
match pm.get(connection_id) {
Some(DbHandle::Postgresql(client, _)) => client.execute(&sql, &[]).await.map(|_| ()).map_err(|e| sanitize(&e.to_string())),
Some(_) => Err("Schema CRUD is PostgreSQL-only".into()),
None => Err("Connection not found".into()),
}
}
pub(crate) async fn create_schema_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, name: &str) -> Result<(), String> {
exec_sql(pm, connection_id, create_schema_sql(name)?).await
}
pub(crate) async fn rename_schema_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, old: &str, new: &str) -> Result<(), String> {
exec_sql(pm, connection_id, rename_schema_sql(old, new)?).await
}
pub(crate) async fn drop_schema_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, name: &str, cascade: bool) -> Result<(), String> {
exec_sql(pm, connection_id, drop_schema_sql(name, cascade)?).await
}
#[tauri::command]
pub async fn create_schema(connection_id: String, name: String, state: State<'_, crate::AppState>) -> Result<(), String> {
create_schema_inner(&state.pool_manager, &connection_id, &name).await
}
#[tauri::command]
pub async fn rename_schema(connection_id: String, old_name: String, new_name: String, state: State<'_, crate::AppState>) -> Result<(), String> {
rename_schema_inner(&state.pool_manager, &connection_id, &old_name, &new_name).await
}
#[tauri::command]
pub async fn drop_schema(connection_id: String, name: String, cascade: bool, state: State<'_, crate::AppState>) -> Result<(), String> {
drop_schema_inner(&state.pool_manager, &connection_id, &name, cascade).await
}
pub(crate) async fn search_objects_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, needle: &str) -> Result<Vec<ObjectSearchHit>, String> {
let sql = pg_object_search_query();
let mut pm = pm.lock().await;
match pm.get(connection_id) {
Some(DbHandle::Postgresql(client, _)) => {
let rows = client.query(&sql, &[&needle, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(rows.iter().map(|r| ObjectSearchHit {
name: r.get(0), schema: r.get(1), object_type: r.get(2),
}).collect())
}
Some(DbHandle::Sqlite(_)) | Some(DbHandle::MySql(_)) => Ok(vec![]),
None => Err("Connection not found".into()),
}
}
#[tauri::command]
pub async fn search_objects(connection_id: String, schema: String, query: String, state: State<'_, crate::AppState>) -> Result<Vec<ObjectSearchHit>, String> {
search_objects_inner(&state.pool_manager, &connection_id, &schema, &query).await
}
pub(crate) async fn get_object_ddl_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, object_type: &str, name: &str) -> Result<String, String> {
let mut pm = pm.lock().await;
let client = match pm.get(connection_id) {
Some(DbHandle::Postgresql(c, _)) => c,
Some(_) => return Err("Copy-as-DDL is PostgreSQL-only".into()),
None => return Err("Connection not found".into()),
};
match object_type {
"function" | "procedure" => {
let row = client.query_one("SELECT pg_get_functiondef(p.oid) FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE p.proname=$1 AND n.nspname=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(row.get::<_, String>(0))
}
"trigger" => {
let row = client.query_one("SELECT pg_get_triggerdef(t.oid) FROM pg_trigger t JOIN pg_class c ON t.tgrelid=c.oid JOIN pg_namespace n ON c.relnamespace=n.oid WHERE t.tgname=$1 AND n.nspname=$2 AND NOT t.tgisinternal LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(row.get::<_, String>(0))
}
"index" => {
let row = client.query_one("SELECT pg_get_indexdef(ix.indexrelid) FROM pg_index ix JOIN pg_class i ON i.oid=ix.indexrelid JOIN pg_class t ON t.oid=ix.indrelid JOIN pg_namespace n ON t.relnamespace=n.oid WHERE i.relname=$1 AND n.nspname=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(row.get::<_, String>(0))
}
"constraint" => {
let row = client.query_one("SELECT c.conname, ns.nspname, cl.relname, pg_get_constraintdef(c.oid) FROM pg_constraint c JOIN pg_class cl ON c.conrelid=cl.oid JOIN pg_namespace ns ON cl.relnamespace=ns.oid WHERE c.conname=$1 AND ns.nspname=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(constraint_ddl(&crate::models::db_viewer::ConstraintInfo {
name: row.get(0), schema: row.get(1), table: row.get(2), contype: "CHECK".into(),
definition: row.get(3), deferrable: false, validated: true, columns: vec![] }))
}
"view" => {
let row = client.query_one("SELECT pg_get_viewdef(c.oid, true) FROM pg_class c JOIN pg_namespace n ON c.relnamespace=n.oid WHERE c.relname=$1 AND n.nspname=$2 AND c.relkind='v' LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(view_ddl(schema, name, &row.get::<_, String>(0)))
}
"materialized view" => {
let row = client.query_one("SELECT definition FROM pg_matviews WHERE matviewname=$1 AND schemaname=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(matview_ddl(schema, name, &row.get::<_, String>(0)))
}
"sequence" => {
let row = client.query_one("SELECT sequence_name, sequence_schema, start_value::text, minimum_value::text, maximum_value::text, increment::text, COALESCE(pg_catalog.pg_sequence_last_value(sequence_name::regclass)::text,'0'), cycle_option::text FROM information_schema.sequences WHERE sequence_name=$1 AND sequence_schema=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(sequence_ddl(&crate::models::db_viewer::SequenceInfo { name: row.get(0), schema: row.get(1), start_value: row.get(2), min_value: row.get(3), max_value: row.get(4), increment: row.get(5), current_value: row.get(6), cycle: row.get::<_, String>(7).eq_ignore_ascii_case("YES") }))
}
"enum" => {
let row = client.query_one("SELECT t.typname, n.nspname, ARRAY(SELECT e.enumlabel FROM pg_enum e WHERE e.enumtypid=t.oid ORDER BY e.enumsortorder) FROM pg_type t JOIN pg_namespace n ON t.typnamespace=n.oid WHERE t.typname=$1 AND n.nspname=$2 AND t.typtype='e' LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(enum_ddl(&crate::models::db_viewer::EnumInfo { name: row.get(0), schema: row.get(1), labels: row.get::<_, Vec<String>>(2) }))
}
"extension" => {
let row = client.query_one("SELECT e.extname, n.nspname, e.extversion::text FROM pg_extension e JOIN pg_namespace n ON e.extnamespace=n.oid WHERE e.extname=$1 AND n.nspname=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(extension_ddl(&crate::models::db_viewer::ExtensionInfo { name: row.get(0), schema: row.get(1), version: row.get(2), comment: None }))
}
"table" => {
// Tables reuse the pg_dump path (bundled-aware); the command wrapper resolves the tool path via AppHandle.
Err("table DDL uses get_table_ddl".into())
}
other => Err(format!("Unsupported object type for DDL: {other}")),
}
}
#[tauri::command]
pub async fn get_object_ddl(connection_id: String, schema: String, object_type: String, name: String, state: State<'_, crate::AppState>, app: tauri::AppHandle) -> Result<String, String> {
if object_type == "table" || object_type == "TABLE" {
return crate::commands::db_viewer::get_table_ddl(connection_id, schema, name, state, app).await;
}
get_object_ddl_inner(&state.pool_manager, &connection_id, &schema, &object_type.to_lowercase(), &name).await
}
pub(crate) async fn get_object_dependencies_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, object_type: &str, name: &str) -> Result<Vec<DependencyInfo>, String> {
let mut pm = pm.lock().await;
let client = match pm.get(connection_id) {
Some(DbHandle::Postgresql(c, _)) => c,
Some(_) => return Err("Dependencies are PostgreSQL-only".into()),
None => return Err("Connection not found".into()),
};
if object_type.eq_ignore_ascii_case("schema") {
let rows = client.query(&pg_schema_contents_query(), &[&name]).await.map_err(|e| sanitize(&e.to_string()))?;
return Ok(rows.iter().map(|r| DependencyInfo {
deptype: "n".into(), class: format!("pg_class: {}", r.get::<_, String>(1)), name: r.get(0),
}).collect());
}
let oid_sql = pg_object_oid_query(&object_type.to_lowercase());
if oid_sql.is_empty() { return Err(format!("Unsupported object type: {object_type}")); }
let oid: tokio_postgres::types::Oid = client.query_one(&oid_sql, &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?.get(0);
let rows = client.query(&pg_depend_query(), &[&oid]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(rows.iter().map(|r| DependencyInfo {
deptype: r.get(0), class: r.get(1), name: r.get::<_, String>(2),
}).collect())
}
#[tauri::command]
pub async fn get_object_dependencies(connection_id: String, schema: String, object_type: String, name: String, state: State<'_, crate::AppState>) -> Result<Vec<DependencyInfo>, String> {
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()),
}
}
#[tauri::command]
pub async fn build_object_ddl(connection_id: String, kind: String, params: serde_json::Value, state: State<'_, crate::AppState>) -> Result<Vec<String>, String> {
build_object_ddl_inner(&state.pool_manager, &connection_id, &kind, params).await
}
/// List extensions installable on this server (`pg_available_extensions`):
/// name + default version + comment. The picker uses name/version only;
/// `schema` is left empty (available extensions are schema-wide).
pub(crate) async fn get_available_extensions_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str) -> Result<Vec<ExtensionInfo>, String> {
let mut pm = pm.lock().await;
let client = match pm.get(connection_id) {
Some(DbHandle::Postgresql(c, _)) => c,
Some(_) => return Err("Extensions are PostgreSQL-only".into()),
None => return Err("Connection not found".into()),
};
let rows = client.query(&crate::db::introspection::pg_available_extensions_query(), &[]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(rows.iter().map(|r| ExtensionInfo {
name: r.get(0),
schema: String::new(),
version: r.get(1),
comment: r.get(2),
}).collect())
}
#[tauri::command]
pub async fn get_available_extensions(connection_id: String, state: State<'_, crate::AppState>) -> Result<Vec<ExtensionInfo>, String> {
get_available_extensions_inner(&state.pool_manager, &connection_id).await
}
/// Build a reorder-only table rebuild script (executed transactionally via
/// `execute_change`'s `RebuildTable` arm). Headless inner: locks the pool once,
/// validates the new column list against the live snapshot (names + types must
/// be preserved), assembles a `RebuildInput` from live introspection, and
/// delegates to `rebuild_script`.
pub(crate) async fn build_rebuild_script_inner(
pm: &tokio::sync::Mutex<ConnectionPoolManager>,
connection_id: &str,
schema: &str,
table: &str,
new_columns: serde_json::Value,
) -> Result<String, String> {
let mut pm = pm.lock().await;
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()),
}
}
#[tauri::command]
pub async fn build_rebuild_script(
connection_id: String,
schema: String,
table: String,
new_columns: serde_json::Value,
state: State<'_, crate::AppState>,
) -> Result<String, String> {
build_rebuild_script_inner(&state.pool_manager, &connection_id, &schema, &table, new_columns)
.await
}
// ---------------------------------------------------------------------------
// Roles, privileges, rebuild readiness, tablespaces
// ---------------------------------------------------------------------------
/// List non-system roles with all attributes, memberships grouped by member role.
pub(crate) async fn get_roles_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str) -> Result<Vec<crate::models::RoleInfo>, String> {
let mut pm = pm.lock().await;
let client = match pm.get(connection_id) {
Some(DbHandle::Postgresql(c, _)) => c,
Some(_) => return Err("Roles are PostgreSQL-only".into()),
None => return Err("Connection not found".into()),
};
let roles = client.query(&crate::db::introspection::pg_roles_query(), &[]).await
.map_err(|e| sanitize(&e.to_string()))?;
let mems = client.query(&crate::db::introspection::pg_role_memberships_query(), &[]).await
.map_err(|e| sanitize(&e.to_string()))?;
let mut by_name: std::collections::HashMap<String, crate::models::RoleInfo> = std::collections::HashMap::new();
for r in roles {
let name: String = r.get("rolname");
by_name.insert(name.clone(), crate::models::RoleInfo {
name, superuser: r.get("rolsuper"), inherit: r.get("rolinherit"),
create_db: r.get("rolcreatedb"), create_role: r.get("rolcreaterole"),
can_login: r.get("rolcanlogin"), replication: r.get("rolreplication"),
bypass_rls: r.get("rolbypassrls"), connection_limit: r.get("rolconnlimit"),
valid_until: { let v: String = r.get("rolvaliduntil"); if v.is_empty() { None } else { Some(v) } },
memberships: vec![],
});
}
for m in mems {
let member: String = m.get("member");
if let Some(ri) = by_name.get_mut(&member) {
ri.memberships.push(crate::models::RoleMembership {
role: m.get("role"), member, grantor: m.get("grantor"), admin_option: m.get("admin_option"),
});
}
}
Ok(by_name.into_values().collect())
}
#[tauri::command]
pub async fn get_roles(connection_id: String, state: State<'_, crate::AppState>) -> Result<Vec<crate::models::RoleInfo>, String> {
get_roles_inner(&state.pool_manager, &connection_id).await
}
/// All privilege grants for a role across tables, sequences, routines, schemas, and databases.
pub(crate) async fn get_role_privileges_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, role: &str) -> Result<Vec<crate::models::PrivilegeEntry>, String> {
let mut pm = pm.lock().await;
let client = match pm.get(connection_id) {
Some(DbHandle::Postgresql(c, _)) => c,
Some(_) => return Err("Privileges are PostgreSQL-only".into()),
None => return Err("Connection not found".into()),
};
validate_object_name(role)?; // role is interpolated into the privilege queries
let mut out: Vec<crate::models::PrivilegeEntry> = Vec::new();
let push = |out: &mut Vec<crate::models::PrivilegeEntry>, class: &str, schema: Option<String>, name: String, privileges: Vec<String>, grantable: bool| {
out.push(crate::models::PrivilegeEntry { object_class: class.into(), schema, name, privileges, grantable });
};
for row in client.query(&crate::db::introspection::pg_table_privileges_query(role), &[]).await.map_err(|e| sanitize(&e.to_string()))? {
push(&mut out, "table", Some(row.get("schema")), row.get("name"), row.get("privileges"), row.get("grantable"));
}
for row in client.query(&crate::db::introspection::pg_sequence_privileges_query(role), &[]).await.map_err(|e| sanitize(&e.to_string()))? {
push(&mut out, "sequence", Some(row.get("schema")), row.get("name"), row.get("privileges"), row.get("grantable"));
}
for row in client.query(&crate::db::introspection::pg_routine_privileges_query(role), &[]).await.map_err(|e| sanitize(&e.to_string()))? {
push(&mut out, "routine", Some(row.get("schema")), row.get("name"), row.get("privileges"), row.get("grantable"));
}
for row in client.query(&crate::db::introspection::pg_schema_privileges_query(role), &[]).await.map_err(|e| sanitize(&e.to_string()))? {
push(&mut out, "schema", None, row.get("name"), row.get("privileges"), row.get("grantable"));
}
for row in client.query(&crate::db::introspection::pg_database_privileges_query(role), &[]).await.map_err(|e| sanitize(&e.to_string()))? {
push(&mut out, "database", None, row.get("name"), row.get("privileges"), row.get("grantable"));
}
Ok(out)
}
#[tauri::command]
pub async fn get_role_privileges(connection_id: String, role: String, state: State<'_, crate::AppState>) -> Result<Vec<crate::models::PrivilegeEntry>, String> {
get_role_privileges_inner(&state.pool_manager, &connection_id, &role).await
}
/// 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;
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()),
}
}
#[tauri::command]
pub async fn get_table_rebuild_readiness(connection_id: String, schema: String, table: String, state: State<'_, crate::AppState>) -> Result<crate::models::RebuildReadiness, String> {
get_table_rebuild_readiness_inner(&state.pool_manager, &connection_id, &schema, &table).await
}
/// List non-system tablespaces for the table-options picker.
pub(crate) async fn get_tablespaces_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str) -> Result<Vec<crate::models::TablespaceInfo>, String> {
let mut pm = pm.lock().await;
let client = match pm.get(connection_id) {
Some(DbHandle::Postgresql(c, _)) => c,
Some(_) => return Err("Tablespaces are PostgreSQL-only".into()),
None => return Err("Connection not found".into()),
};
let rows = client.query(&crate::db::introspection::pg_tablespaces_query(), &[]).await
.map_err(|e| sanitize(&e.to_string()))?;
Ok(rows.into_iter().map(|r| crate::models::TablespaceInfo { name: r.get("spcname") }).collect())
}
#[tauri::command]
pub async fn get_tablespaces(connection_id: String, state: State<'_, crate::AppState>) -> Result<Vec<crate::models::TablespaceInfo>, String> {
get_tablespaces_inner(&state.pool_manager, &connection_id).await
}
#[cfg(test)]
#[path = "objects.test.rs"]
mod tests;
@@ -0,0 +1,212 @@
use super::*;
use crate::db::pool::{ConnectionPoolManager, DbHandle};
/// Connect directly via tokio-postgres (no tunnel) so tests are Tauri-free.
async fn pool() -> (tokio::sync::Mutex<ConnectionPoolManager>, String) {
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 mut pm = ConnectionPoolManager::new();
let id = "test-conn".to_string();
pm.register(&id, DbHandle::Postgresql(client, handle));
(tokio::sync::Mutex::new(pm), id)
}
#[tokio::test]
#[ignore]
async fn schema_crud_create_rename_drop() {
let (pm, id) = pool().await;
let name = "gridline_test_schema";
create_schema_inner(&pm, &id, name).await.unwrap();
assert!(create_schema_inner(&pm, &id, name).await.is_err(), "duplicate should error");
rename_schema_inner(&pm, &id, name, "gridline_test_schema2").await.unwrap();
drop_schema_inner(&pm, &id, "gridline_test_schema2", false).await.unwrap();
}
#[tokio::test]
#[ignore]
async fn search_objects_finds_table_and_function() {
let (pm, id) = pool().await;
let hits = search_objects_inner(&pm, &id, "public", "users").await.unwrap();
assert!(hits.iter().any(|h| h.name == "users" && h.object_type == "TABLE"), "demo has a users table");
let fns = search_objects_inner(&pm, &id, "public", "get").await.unwrap();
// substring match across types; assert it returns a Vec<ObjectSearchHit>
assert!(fns.iter().all(|h| h.object_type != ""));
// empty needle returns nothing matched by position('' in name) > 0 is always true — so empty returns all (capped at 100)
let all = search_objects_inner(&pm, &id, "public", "").await.unwrap();
assert!(all.len() <= 100);
}
#[tokio::test]
#[ignore]
async fn object_ddl_for_sequence_enum_function() {
let (pm, id) = pool().await;
// demo has users_id_seq, an enum, and a function
let seq = get_object_ddl_inner(&pm, &id, "public", "sequence", "users_id_seq").await.unwrap();
assert!(seq.starts_with("CREATE SEQUENCE"), "{seq}");
// function: pg_get_functiondef passthrough
let f = get_object_ddl_inner(&pm, &id, "public", "function", "audit_log").await; // name per demo
assert!(f.is_ok());
assert!(f.clone().unwrap().contains("CREATE FUNCTION") || f.unwrap().contains("CREATE OR REPLACE FUNCTION"));
}
#[tokio::test]
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}");
// 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("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]
#[ignore]
async fn build_object_ddl_inner_on_postgresql_pool() {
let (pm, id) = pool().await;
let sql = build_object_ddl_inner(&pm, &id, "sequence", serde_json::json!({
"schema": "public", "name": "s", "action": { "op": "drop" }
})).await.unwrap();
assert_eq!(sql, vec!["DROP SEQUENCE \"public\".\"s\""]);
}
#[tokio::test]
async fn get_available_extensions_inner_guards_postgresql_only() {
let pm = tokio::sync::Mutex::new(ConnectionPoolManager::new());
// Missing connection -> Connection not found
let err = get_available_extensions_inner(&pm, "missing").await.unwrap_err();
assert!(err.contains("Connection not found"), "{err}");
// Non-PostgreSQL handle -> PostgreSQL-only error
pm.lock().await.register("sqlite", DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()));
let err = get_available_extensions_inner(&pm, "sqlite").await.unwrap_err();
assert!(err.contains("PostgreSQL-only"), "{err}");
}
#[tokio::test]
#[ignore]
async fn get_available_extensions_on_postgresql_pool() {
let (pm, id) = pool().await;
let exts = get_available_extensions_inner(&pm, &id).await.unwrap();
assert!(!exts.is_empty(), "pg_available_extensions should list built-ins");
assert!(exts.iter().all(|e| !e.name.is_empty() && !e.version.is_empty()), "every extension needs name + default version: {exts:?}");
}
#[tokio::test]
#[ignore]
async fn object_dependencies_for_table_includes_view() {
let (pm, id) = pool().await;
// demo has order_summary VIEW depending on orders — drop would break it
let deps = get_object_dependencies_inner(&pm, &id, "public", "table", "orders").await.unwrap();
assert!(deps.iter().any(|d| d.class.contains("pg_class") && d.name.contains("order_summary")), "view depending on orders should surface: {deps:?}");
// schema contents path
let contents = get_object_dependencies_inner(&pm, &id, "public", "schema", "public").await.unwrap();
assert!(!contents.is_empty(), "public schema should list contents");
}
#[tokio::test]
#[ignore]
async fn get_roles_returns_current_role_and_memberships() {
let (pm, id) = pool().await;
let roles = crate::commands::objects::get_roles_inner(&pm, &id).await.unwrap();
assert!(roles.iter().any(|r| r.can_login), "at least one login role (the test user)");
}
#[tokio::test]
#[ignore]
async fn rebuild_table_rolls_back_on_failure() {
let (pm, id) = pool().await;
// setup: a table with a PK + one row
{
let mut g = pm.lock().await;
if let crate::db::pool::DbHandle::Postgresql(c, _) = g.get(&id).unwrap() {
c.batch_execute("DROP TABLE IF EXISTS rebuild_t; CREATE TABLE rebuild_t (id int PRIMARY KEY, v text); INSERT INTO rebuild_t VALUES (1,'a');").await.unwrap();
}
}
// build a rebuild script whose final statement intentionally fails (syntax error)
// so the whole transaction rolls back and rebuild_t keeps its row.
let bad_script = "CREATE TABLE _gridline_rb_rebuild_t (id int PRIMARY KEY, v text); \
INSERT INTO _gridline_rb_rebuild_t (id, v) SELECT id, v FROM rebuild_t; \
DROP TABLE rebuild_t; \
ALTER TABLE _gridline_rb_rebuild_t RENAME TO rebuild_t; \
THIS IS NOT SQL;";
let change = crate::models::db_viewer::Change::RebuildTable { id: "rb".into(), sql: bad_script.to_string() };
let res = crate::commands::db_viewer::execute_change_inner(&pm, &id, change).await;
assert!(res.is_err(), "expected rollback (transaction should fail on bad SQL)");
// table still intact
let mut g = pm.lock().await;
if let crate::db::pool::DbHandle::Postgresql(c, _) = g.get(&id).unwrap() {
let row = c.query_one("SELECT count(*) FROM rebuild_t", &[]).await.unwrap();
assert_eq!(row.get::<_, i64>(0), 1, "rollback must preserve the original table");
c.batch_execute("DROP TABLE rebuild_t").await.unwrap();
}
}
#[tokio::test]
#[ignore = "requires live PG"]
async fn get_role_privileges_returns_grants_across_object_classes() {
let (pm, id) = pool().await;
let out = crate::commands::objects::get_role_privileges_inner(&pm, &id, "read_only").await
.unwrap_or_else(|e| panic!("privileges command failed: {e}"));
let classes: std::collections::HashSet<String> =
out.iter().map(|p| p.object_class.clone()).collect();
assert!(classes.contains("table"), "expected a table grant: {out:?}");
assert!(classes.contains("routine"), "expected a routine grant: {out:?}");
assert!(classes.contains("schema"), "expected a schema grant: {out:?}");
assert!(classes.contains("database"), "expected a database grant: {out:?}");
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,538 @@
#[allow(unused_imports)]
use crate::db::pool::DbHandle;
use crate::models::db_viewer::{GraphColumn, Relationship, SchemaGraph, TableNode};
use std::collections::HashMap;
use tauri::State;
/// Validate a schema name for safe use in parameterized queries.
/// Rejects empty strings and names containing SQL metacharacters.
pub fn validate_schema_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("Schema name cannot be empty".into());
}
if name.contains(';')
|| name.contains("--")
|| name.contains("/*")
|| name.contains('\'')
|| name.contains('"')
|| name.contains('\\')
{
return Err(format!("Invalid schema name: {}", name));
}
Ok(())
}
/// Build a parameterized query that fetches all tables, columns, and
/// PK/FK/UNIQUE metadata for a PostgreSQL schema in a single round-trip.
pub fn build_pg_schema_graph_query(_schema: &str) -> String {
// Uses pg_catalog directly instead of information_schema views.
// information_schema views are extremely slow on some servers (remote/
// cloud) because they scan all databases' catalogs. pg_catalog with
// LATERAL joins is typically 500x+ faster (~200ms vs 120s for 55 tables).
r#"SELECT
c.relname AS table_name,
n.nspname AS table_schema,
CASE WHEN c.relkind = 'v' THEN 'VIEW' ELSE 'BASE TABLE' END AS table_type,
a.attname AS column_name,
pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type,
NOT a.attnotnull AS is_nullable,
a.attnum AS ordinal_position,
COALESCE(pk.is_pk, false) AS is_pk,
COALESCE(fk.is_fk, false) AS is_fk,
fk.foreign_table_schema,
fk.foreign_table_name,
fk.foreign_column_name,
COALESCE(uq.is_unique, false) AS is_unique
FROM pg_catalog.pg_class c
JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid
JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid
LEFT JOIN LATERAL (
SELECT true AS is_pk
FROM pg_catalog.pg_constraint pk2
WHERE pk2.conrelid = c.oid AND pk2.contype = 'p' AND a.attnum = ANY(pk2.conkey)
LIMIT 1
) pk ON true
LEFT JOIN LATERAL (
SELECT true AS is_fk,
ref_n.nspname AS foreign_table_schema,
ref_c.relname AS foreign_table_name,
ref_a.attname AS foreign_column_name
FROM pg_catalog.pg_constraint fk2
JOIN pg_catalog.pg_class ref_c ON fk2.confrelid = ref_c.oid
JOIN pg_catalog.pg_namespace ref_n ON ref_c.relnamespace = ref_n.oid
JOIN pg_catalog.pg_attribute ref_a
ON ref_a.attrelid = ref_c.oid AND ref_a.attnum = ANY(fk2.confkey)
WHERE fk2.conrelid = c.oid AND fk2.contype = 'f'
AND a.attnum = ANY(fk2.conkey)
LIMIT 1
) fk ON true
LEFT JOIN LATERAL (
SELECT true AS is_unique
FROM pg_catalog.pg_constraint uq2
WHERE uq2.conrelid = c.oid AND uq2.contype = 'u' AND a.attnum = ANY(uq2.conkey)
LIMIT 1
) uq ON true
WHERE n.nspname = $1
AND c.relkind IN ('r', 'v', 'p')
AND a.attnum > 0
AND NOT a.attisdropped
ORDER BY c.relname, a.attnum"#
.to_string()
}
/// Infer relationship cardinality from constraint metadata.
///
/// - `is_pk`: the FK column is also part of the primary key
/// - `is_unique`: the FK column has a UNIQUE constraint
/// - `is_nullable`: the FK column allows NULL values
/// - `is_join_table_fk`: this FK belongs to a join table
pub fn infer_cardinality(
is_pk: bool,
is_unique: bool,
is_nullable: bool,
is_join_table_fk: bool,
) -> String {
if is_join_table_fk {
return "N:M".into();
}
let one_side = is_pk || is_unique;
match (one_side, is_nullable) {
(true, false) => "1:1".into(),
(true, true) => "0..1:0..1".into(),
(false, false) => "1:N".into(),
(false, true) => "0..N".into(),
}
}
pub fn parse_pg_schema_rows(
rows: &[Vec<serde_json::Value>],
) -> (Vec<TableNode>, Vec<Relationship>) {
let mut table_map: HashMap<(String, String), (String, Vec<GraphColumn>)> = HashMap::new();
let mut relationships: Vec<Relationship> = Vec::new();
for row in rows {
let table_name = row[0].as_str().unwrap_or_default().to_string();
let table_schema = row[1].as_str().unwrap_or_default().to_string();
let table_type = row[2].as_str().unwrap_or_default().to_string();
let col_name = row[3].as_str().unwrap_or_default().to_string();
let data_type = row[4].as_str().unwrap_or_default().to_string();
let is_nullable = row[5].as_bool().unwrap_or(false);
let is_pk = row[7].as_bool().unwrap_or(false);
let is_fk = row[8].as_bool().unwrap_or(false);
let fk_schema = row[9].as_str().map(String::from);
let fk_table = row[10].as_str().map(String::from);
let fk_column = row[11].as_str().map(String::from);
let is_unique = row[12].as_bool().unwrap_or(false);
let fk_ref = if is_fk {
match (&fk_schema, &fk_table, &fk_column) {
(Some(s), Some(t), Some(c)) => Some((s.clone(), t.clone(), c.clone())),
_ => None,
}
} else {
None
};
let col = GraphColumn {
name: col_name.clone(),
data_type,
is_pk,
is_fk,
is_unique: is_unique || is_pk,
is_nullable,
fk_ref: fk_ref.clone(),
};
let key = (table_schema.clone(), table_name.clone());
table_map
.entry(key)
.or_insert_with(|| (table_type.clone(), Vec::new()))
.1
.push(col);
if let Some((ref_schema, ref_table, ref_column)) = fk_ref {
relationships.push(Relationship {
source_schema: table_schema.clone(),
source_table: table_name.clone(),
source_column: col_name,
target_schema: ref_schema,
target_table: ref_table,
target_column: ref_column,
cardinality: String::new(),
});
}
}
// Detect N:M join tables: tables where ALL PK columns are also FK columns
let join_table_keys: Vec<(String, String)> = table_map
.iter()
.filter(|(_, (_, cols))| {
let pk_cols: Vec<&GraphColumn> = cols.iter().filter(|c| c.is_pk).collect();
!pk_cols.is_empty() && pk_cols.iter().all(|c| c.is_fk)
})
.map(|(k, _)| k.clone())
.collect();
// Assign cardinality to each relationship
for rel in &mut relationships {
let source_key = (rel.source_schema.clone(), rel.source_table.clone());
let is_join = join_table_keys.contains(&source_key);
let (is_pk_or_unique, is_nullable) = table_map
.get(&source_key)
.and_then(|(_, cols)| cols.iter().find(|c| c.name == rel.source_column))
.map(|c| (c.is_pk || c.is_unique, c.is_nullable))
.unwrap_or((false, false));
rel.cardinality = infer_cardinality(is_pk_or_unique, is_pk_or_unique, is_nullable, is_join);
}
let mut tables: Vec<TableNode> = table_map
.into_iter()
.map(|((schema, name), (table_type, columns))| TableNode {
name,
schema,
table_type,
columns,
})
.collect();
tables.sort_by(|a, b| a.name.cmp(&b.name));
(tables, relationships)
}
fn build_sqlite_schema_graph(
conn: &rusqlite::Connection,
schema: &str,
) -> Result<SchemaGraph, String> {
if schema != "main" {
return Err(format!(
"SQLite only supports schema 'main', got: {}",
schema
));
}
let mut stmt = conn
.prepare("SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' ORDER BY name")
.map_err(|e| e.to_string())?;
let table_rows: Vec<(String, String)> = stmt
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
let mut tables: Vec<TableNode> = Vec::new();
let mut relationships: Vec<Relationship> = Vec::new();
for (table_name, table_type) in &table_rows {
let pragma_sql = format!("PRAGMA table_info('{}')", table_name);
let mut ps = conn.prepare(&pragma_sql).map_err(|e| e.to_string())?;
let col_meta: Vec<(String, String, bool, bool)> = ps
.query_map([], |row| {
Ok((
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, bool>(3)?,
row.get::<_, bool>(5)?,
))
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
let fk_sql = format!("PRAGMA foreign_key_list('{}')", table_name);
let fk_cols: HashMap<String, (String, String)> = if let Ok(mut fs) = conn.prepare(&fk_sql) {
fs.query_map([], |row| {
Ok((
row.get::<_, String>(3)?,
row.get::<_, String>(2)?,
row.get::<_, String>(4)?,
))
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.map(|(col, ref_t, ref_c)| (col, (ref_t, ref_c)))
.collect()
} else {
HashMap::new()
};
let columns: Vec<GraphColumn> = col_meta
.iter()
.map(|(name, dtype, _nn, is_pk)| {
let fk = fk_cols.get(name);
let is_fk = fk.is_some();
let fk_ref = fk.map(|(t, c)| ("main".into(), t.clone(), c.clone()));
if let Some((ref_t, ref_c)) = fk {
relationships.push(Relationship {
source_schema: "main".into(),
source_table: table_name.clone(),
source_column: name.clone(),
target_schema: "main".into(),
target_table: ref_t.clone(),
target_column: ref_c.clone(),
cardinality: infer_cardinality(*is_pk, false, !_nn, false),
});
}
GraphColumn {
name: name.clone(),
data_type: if dtype.is_empty() {
"TEXT".into()
} else {
dtype.clone()
},
is_pk: *is_pk,
is_fk,
is_unique: *is_pk,
is_nullable: !_nn,
fk_ref: fk_ref.map(|(s, t, c)| (s, t, c)),
}
})
.collect();
tables.push(TableNode {
name: table_name.clone(),
schema: "main".into(),
table_type: table_type.to_uppercase(),
columns,
});
}
Ok(SchemaGraph {
tables,
relationships,
})
}
#[tauri::command]
pub async fn get_schema_graph(
connection_id: String,
schema: Option<String>,
state: State<'_, crate::AppState>,
) -> Result<SchemaGraph, String> {
let schema = schema.unwrap_or_else(|| "public".to_string());
validate_schema_name(&schema)?;
let mut pm = state.pool_manager.lock().await;
match pm.get(&connection_id) {
Some(DbHandle::Postgresql(client, _)) => {
let query = build_pg_schema_graph_query(&schema);
let rows = client
.query(&query, &[&schema])
.await
.map_err(|e| crate::commands::db_viewer::pg_error_message(&e))?;
let json_rows: Vec<Vec<serde_json::Value>> = rows
.iter()
.map(|row| {
(0..row.len())
.map(|i| crate::commands::db_viewer::pg_value_to_json(row, i))
.collect()
})
.collect();
let (tables, relationships) = parse_pg_schema_rows(&json_rows);
Ok(SchemaGraph {
tables,
relationships,
})
}
Some(DbHandle::Sqlite(conn)) => build_sqlite_schema_graph(conn, &schema),
Some(DbHandle::MySql(_)) => Err("Schema visualizer not supported for MySQL".to_string()),
None => Err("Connection not found".into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_schema_name_rejects_empty() {
assert!(validate_schema_name("").is_err());
}
#[test]
fn validate_schema_name_rejects_semicolon() {
assert!(validate_schema_name("public; DROP TABLE users").is_err());
}
#[test]
fn validate_schema_name_rejects_sql_comment() {
assert!(validate_schema_name("public--comment").is_err());
assert!(validate_schema_name("public/*comment*/").is_err());
}
#[test]
fn validate_schema_name_rejects_quotes() {
assert!(validate_schema_name("pub'lic").is_err());
assert!(validate_schema_name("pub\"lic").is_err());
}
#[test]
fn validate_schema_name_rejects_backslash() {
assert!(validate_schema_name("public\\schema").is_err());
}
#[test]
fn validate_schema_name_accepts_valid_names() {
assert!(validate_schema_name("public").is_ok());
assert!(validate_schema_name("my_schema").is_ok());
assert!(validate_schema_name("schema123").is_ok());
assert!(validate_schema_name("auth").is_ok());
}
#[test]
fn build_pg_schema_graph_query_is_parameterized() {
let sql = build_pg_schema_graph_query("public");
// Must use $1 for schema parameter (parameterized)
assert!(
sql.contains("$1"),
"query should use $1 placeholder; got: {}",
sql
);
// Must not interpolate schema name directly in a potentially unsafe way
assert!(
!sql.contains("'public'"),
"query should not use literal 'public'"
);
}
#[test]
fn build_pg_schema_graph_query_queries_columns() {
let sql = build_pg_schema_graph_query("myschema");
assert!(sql.contains("pg_catalog.pg_class"), "should query pg_class");
assert!(
sql.contains("pg_catalog.pg_attribute"),
"should query pg_attribute"
);
assert!(
sql.contains("pg_catalog.pg_constraint"),
"should include constraint info"
);
}
#[test]
fn infer_cardinality_one_to_one_pk() {
assert_eq!(infer_cardinality(true, false, false, false), "1:1");
}
#[test]
fn infer_cardinality_zero_or_one() {
// UNIQUE + nullable → 0..1:0..1
assert_eq!(infer_cardinality(false, true, true, false), "0..1:0..1");
}
#[test]
fn infer_cardinality_one_to_many() {
assert_eq!(infer_cardinality(false, false, false, false), "1:N");
}
#[test]
fn infer_cardinality_zero_or_many() {
// not PK, not UNIQUE, nullable → 0..N
assert_eq!(infer_cardinality(false, false, true, false), "0..N");
}
#[test]
fn infer_cardinality_many_to_many() {
assert_eq!(infer_cardinality(false, false, false, true), "N:M");
}
#[test]
fn parse_pg_schema_rows_builds_correct_graph() {
let rows: Vec<Vec<serde_json::Value>> = vec![
// users.id (PK)
vec![
serde_json::json!("users"),
serde_json::json!("public"),
serde_json::json!("BASE TABLE"),
serde_json::json!("id"),
serde_json::json!("integer"),
serde_json::json!("NO"),
serde_json::json!(1),
serde_json::json!(true),
serde_json::json!(false),
serde_json::Value::Null,
serde_json::Value::Null,
serde_json::Value::Null,
serde_json::json!(true),
],
// users.email (non-key)
vec![
serde_json::json!("users"),
serde_json::json!("public"),
serde_json::json!("BASE TABLE"),
serde_json::json!("email"),
serde_json::json!("text"),
serde_json::json!("NO"),
serde_json::json!(2),
serde_json::json!(false),
serde_json::json!(false),
serde_json::Value::Null,
serde_json::Value::Null,
serde_json::Value::Null,
serde_json::json!(true),
],
// orders.id (PK)
vec![
serde_json::json!("orders"),
serde_json::json!("public"),
serde_json::json!("BASE TABLE"),
serde_json::json!("id"),
serde_json::json!("integer"),
serde_json::json!("NO"),
serde_json::json!(1),
serde_json::json!(true),
serde_json::json!(false),
serde_json::Value::Null,
serde_json::Value::Null,
serde_json::Value::Null,
serde_json::json!(true),
],
// orders.user_id (FK → users.id)
vec![
serde_json::json!("orders"),
serde_json::json!("public"),
serde_json::json!("BASE TABLE"),
serde_json::json!("user_id"),
serde_json::json!("integer"),
serde_json::json!("NO"),
serde_json::json!(2),
serde_json::json!(false),
serde_json::json!(true),
serde_json::json!("public"),
serde_json::json!("users"),
serde_json::json!("id"),
serde_json::json!(false),
],
];
let (tables, relationships) = parse_pg_schema_rows(&rows);
assert_eq!(tables.len(), 2, "should have 2 tables");
assert_eq!(relationships.len(), 1, "should have 1 relationship");
let users = tables.iter().find(|t| t.name == "users").unwrap();
assert_eq!(users.columns.len(), 2);
assert!(users.columns[0].is_pk);
let orders = tables.iter().find(|t| t.name == "orders").unwrap();
assert_eq!(orders.columns.len(), 2);
let rel = &relationships[0];
assert_eq!(rel.source_table, "orders");
assert_eq!(rel.target_table, "users");
assert_eq!(rel.source_column, "user_id");
assert_eq!(rel.target_column, "id");
assert_eq!(rel.cardinality, "1:N");
}
#[test]
fn parse_pg_schema_rows_empty_yields_empty_graph() {
let rows: Vec<Vec<serde_json::Value>> = vec![];
let (tables, relationships) = parse_pg_schema_rows(&rows);
assert!(tables.is_empty());
assert!(relationships.is_empty());
}
}
+152
View File
@@ -0,0 +1,152 @@
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> {
let store = state.lock().map_err(|e| e.to_string())?;
store.get_settings()
}
pub fn update_setting_inner(state: &Mutex<Store>, key: &str, value: &str) -> Result<(), String> {
let store = state.lock().map_err(|e| e.to_string())?;
store.update_setting(key, value)
}
#[tauri::command]
pub fn get_settings(state: tauri::State<crate::AppState>) -> Result<Settings, String> {
get_settings_inner(&state.db_store)
}
#[tauri::command]
pub fn update_setting(
state: tauri::State<crate::AppState>,
key: String,
value: String,
) -> Result<(), String> {
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::*;
use crate::store::Store;
fn state() -> std::sync::Mutex<Store> {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::store::migrations::run_migrations(&conn).unwrap();
std::sync::Mutex::new(Store::from_connection(conn))
}
#[test]
fn get_settings_returns_defaults() {
let st = state();
let s = get_settings_inner(&st).unwrap();
assert_eq!(s.theme, "system");
assert_eq!(s.font_size, "medium");
assert_eq!(s.accent_color, "#2563EB");
}
#[test]
fn update_setting_persists() {
let st = state();
update_setting_inner(&st, "theme", "light").unwrap();
assert_eq!(get_settings_inner(&st).unwrap().theme, "light");
}
#[test]
fn update_accent_color_persists() {
let st = state();
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");
}
}
+426
View File
@@ -0,0 +1,426 @@
use crate::db::tls::TlsDecision;
use crate::models::SshConfig;
use std::collections::HashMap;
use std::sync::Arc;
/// Through a tunnel the TLS peer is loopback (`127.0.0.1`), so certificate
/// verification is meaningless: `verify-ca`/`verify-full` degrade to
/// encrypt-only `require`. A direct (non-tunneled) connection honors the
/// user's mode unchanged.
pub fn effective_tls_decision(d: TlsDecision, via_tunnel: bool) -> TlsDecision {
if via_tunnel && matches!(d, TlsDecision::Verify) {
TlsDecision::Require
} else {
d
}
}
/// A live tunnel handle. `closer` drops the listener + ssh session when called.
pub struct Tunnel {
pub local_port: u16,
closer: Option<Box<dyn FnOnce() + Send>>,
}
impl Tunnel {
/// Create a tunnel handle with no resources to clean up (test backend).
pub fn fake(port: u16) -> Self {
Tunnel {
local_port: port,
closer: None,
}
}
}
/// Backend that actually establishes SSH tunnels.
///
/// The manager only does bookkeeping; opening/closing the OS-level tunnel is
/// delegated here so it can be faked in tests.
pub trait TunnelBackend: Send + Sync {
/// Open a tunnel to `remote_host:remote_port` via `cfg` and return a
/// handle exposing the bound local port.
fn open(
&self,
key: &str,
cfg: &SshConfig,
remote_host: &str,
remote_port: u16,
password: Option<&str>,
passphrase: Option<&str>,
) -> Result<Tunnel, String>;
}
/// Manages SSH tunnels, mapping connection keys to active tunnels.
///
/// Bookkeeping only: validation, key->tunnel map, and lifecycle hooks.
/// The actual SSH connectivity is delegated to a `TunnelBackend` so the
/// manager's behavior is unit-testable with a fake backend.
pub struct SshTunnelManager {
tunnels: HashMap<String, Tunnel>,
backend: Arc<dyn TunnelBackend>,
}
impl SshTunnelManager {
/// Create a new tunnel manager backed by `backend`.
pub fn new(backend: Arc<dyn TunnelBackend>) -> Self {
SshTunnelManager {
tunnels: HashMap::new(),
backend,
}
}
/// Open an SSH tunnel for the given config.
///
/// Returns the local port on success. Replaces any existing tunnel for
/// the same key (closing the old one).
pub fn open_tunnel(
&mut self,
key: &str,
cfg: &SshConfig,
remote_host: &str,
remote_port: u16,
password: Option<&str>,
passphrase: Option<&str>,
) -> Result<u16, String> {
if !cfg.is_valid() {
return Err("invalid SSH configuration".to_string());
}
let tunnel = self
.backend
.open(key, cfg, remote_host, remote_port, password, passphrase)?;
let port = tunnel.local_port;
if let Some(old) = self.tunnels.insert(key.to_string(), tunnel) {
drop(old.closer);
}
Ok(port)
}
/// Close and remove the SSH tunnel for the given key.
pub fn close_tunnel(&mut self, key: &str) {
if let Some(t) = self.tunnels.remove(key) {
drop(t.closer);
}
}
/// Close all active SSH tunnels.
pub fn close_all(&mut self) {
let tunnels = std::mem::take(&mut self.tunnels);
for (_, t) in tunnels {
drop(t.closer);
}
}
/// Get the local port for an active tunnel, if any.
pub fn get_local_port(&self, key: &str) -> Option<u16> {
self.tunnels.get(key).map(|t| t.local_port)
}
/// Clone of the active backend, for handing into `spawn_blocking` so the
/// blocking ssh2 work never blocks an async runtime thread.
pub fn backend_clone(&self) -> Arc<dyn TunnelBackend> {
self.backend.clone()
}
/// Insert an already-opened tunnel under `key`, closing any previous one.
pub fn insert_tunnel(&mut self, key: String, tunnel: Tunnel) {
if let Some(old) = self.tunnels.insert(key, tunnel) {
drop(old.closer);
}
}
/// Return the number of active tunnels.
pub fn active_count(&self) -> usize {
self.tunnels.len()
}
}
/// Real ssh2 backend: binds a loopback listener, authenticates to the SSH
/// host over a blocking socket, and pumps data between the local client and
/// the remote DB over an SSH direct-tcpip channel.
///
/// The whole `open` runs inside `tokio::task::spawn_blocking` at the call
/// sites because `ssh2::Session` is purely blocking.
pub struct Ssh2Backend;
impl TunnelBackend for Ssh2Backend {
fn open(
&self,
_key: &str,
cfg: &SshConfig,
remote_host: &str,
remote_port: u16,
password: Option<&str>,
passphrase: Option<&str>,
) -> Result<Tunnel, String> {
use ssh2::Session;
// Loopback-only listener with an ephemeral port.
let listener = std::net::TcpListener::bind("127.0.0.1:0")
.map_err(|e| format!("bind local tunnel port: {e}"))?;
let local_port = listener
.local_addr()
.map_err(|e| format!("local tunnel address: {e}"))?
.port();
let tcp = std::net::TcpStream::connect((cfg.host.as_str(), cfg.port))
.map_err(|e| format!("connect ssh host: {e}"))?;
let mut session = Session::new().map_err(|e| format!("ssh session: {e}"))?;
session.set_tcp_stream(tcp);
session
.handshake()
.map_err(|e| format!("ssh handshake: {e}"))?;
match cfg.auth_method.as_str() {
"key" => {
let path = cfg
.private_key_path
.as_deref()
.ok_or_else(|| "private_key_path required for key auth".to_string())?;
session
.userauth_pubkey_file(&cfg.user, None, std::path::Path::new(path), passphrase)
.map_err(|e| format!("ssh key auth: {e}"))?;
}
_ => session
.userauth_password(&cfg.user, password.unwrap_or(""))
.map_err(|e| format!("ssh password auth: {e}"))?,
}
if !session.authenticated() {
return Err("SSH authentication failed".into());
}
let remote_host = remote_host.to_string();
let session = Arc::new(std::sync::Mutex::new(session));
let (closer_tx, closer_rx) = std::sync::mpsc::channel::<()>();
std::thread::spawn(move || {
if let Ok((mut local, _)) = listener.accept() {
// Open the direct-tcpip channel to the remote DB. `Channel` is
// cloneable (Arc-shared inner), so one clone per direction
// lets two pump threads copy data in parallel.
let mut channel = match session.lock().unwrap().channel_direct_tcpip(
&remote_host,
remote_port as u16,
None,
) {
Ok(c) => c,
Err(_) => return,
};
// The accepted socket stays owned by this thread; when the
// tunnel is closed the closer wakes us, we drop `local` and
// the pumps end on EOF/broken pipe.
let mut upstream = channel.clone();
let down = local.try_clone();
let pump = match down {
Ok(down) => Some(std::thread::spawn(move || {
let mut down = down;
// client -> remote DB
let _ = std::io::copy(&mut down, &mut upstream);
})),
Err(_) => None,
};
// remote DB -> client (this thread)
let _ = std::io::copy(&mut channel, &mut local);
if let Some(p) = pump {
let _ = p.join();
}
}
let _ = closer_rx.recv();
});
Ok(Tunnel {
local_port,
closer: Some(Box::new(move || {
let _ = closer_tx.send(());
})),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
// ------------------------------------------------------------------
// SshConfig validation
// ------------------------------------------------------------------
#[test]
fn ssh_config_validation() {
// Invalid: empty host
let config = SshConfig::new(
"".to_string(),
22,
"user".to_string(),
"password".to_string(),
);
assert!(!config.is_valid(), "empty host should be invalid");
// Invalid: empty user
let config = SshConfig::new(
"host.example.com".to_string(),
22,
"".to_string(),
"password".to_string(),
);
assert!(!config.is_valid(), "empty user should be invalid");
// Valid: all required fields present
let config = SshConfig::new(
"host.example.com".to_string(),
2222,
"tunnel".to_string(),
"key".to_string(),
);
assert!(config.is_valid(), "valid config should be accepted");
}
#[test]
fn ssh_config_rejects_non_standard_ports() {
// Port 0 is invalid
let config = SshConfig::new(
"host.example.com".to_string(),
0,
"user".to_string(),
"password".to_string(),
);
assert!(!config.is_valid(), "port 0 should be invalid");
// Port 1 is valid (boundary)
let config = SshConfig::new(
"host.example.com".to_string(),
1,
"user".to_string(),
"password".to_string(),
);
assert!(config.is_valid(), "port 1 should be valid");
}
// ------------------------------------------------------------------
// Tunnel manager tests (fake backend)
// ------------------------------------------------------------------
#[derive(Debug, Default)]
struct FakeBackend {
opens: std::sync::Mutex<Vec<String>>,
next_port: u16,
}
impl Clone for FakeBackend {
fn clone(&self) -> Self {
Self {
opens: std::sync::Mutex::new(self.opens.lock().unwrap().clone()),
next_port: self.next_port,
}
}
}
impl TunnelBackend for FakeBackend {
fn open(
&self,
key: &str,
_cfg: &crate::models::SshConfig,
_remote_host: &str,
_remote_port: u16,
_pw: Option<&str>,
_pp: Option<&str>,
) -> Result<Tunnel, String> {
self.opens.lock().unwrap().push(key.to_string());
let p = self.next_port;
Ok(Tunnel::fake(p))
}
}
#[test]
fn manager_open_and_get_port() {
let backend = Arc::new(FakeBackend {
next_port: 22222,
..Default::default()
});
let mut mgr = SshTunnelManager::new(backend.clone());
let cfg = crate::models::SshConfig::new("h".into(), 22, "u".into(), "password".into());
let port = mgr
.open_tunnel("c1", &cfg, "db.host", 5432, None, None)
.unwrap();
assert_eq!(port, 22222);
assert_eq!(mgr.get_local_port("c1"), Some(22222));
}
#[test]
fn manager_invalid_config_errors() {
let backend = Arc::new(FakeBackend::default());
let mut mgr = SshTunnelManager::new(backend);
let cfg = crate::models::SshConfig::new("".into(), 22, "u".into(), "password".into());
assert!(mgr
.open_tunnel("c1", &cfg, "db.host", 5432, None, None)
.is_err());
}
#[test]
fn manager_close_removes_tunnel() {
let backend = Arc::new(FakeBackend {
next_port: 1,
..Default::default()
});
let mut mgr = SshTunnelManager::new(backend);
let cfg = crate::models::SshConfig::new("h".into(), 22, "u".into(), "password".into());
mgr.open_tunnel("c1", &cfg, "db.host", 5432, None, None)
.unwrap();
mgr.close_tunnel("c1");
assert_eq!(mgr.get_local_port("c1"), None);
assert_eq!(mgr.active_count(), 0);
}
#[test]
fn tunneled_tls_is_downgraded_to_require() {
// verify-full through a tunnel degrades to encrypt-only `require`
assert_eq!(
effective_tls_decision(crate::db::tls::tls_decision(Some("verify-full")), true),
crate::db::tls::TlsDecision::Require
);
// direct (non-tunneled) connection keeps the user's mode
assert_eq!(
effective_tls_decision(crate::db::tls::tls_decision(Some("verify-full")), false),
crate::db::tls::TlsDecision::Verify
);
// disable stays disabled regardless of tunneling
assert_eq!(
effective_tls_decision(crate::db::tls::tls_decision(Some("disable")), true),
crate::db::tls::TlsDecision::Disable
);
}
#[test]
fn manager_backend_clone_returns_backend() {
let backend = Arc::new(FakeBackend {
next_port: 7,
..Default::default()
});
let mgr = SshTunnelManager::new(backend.clone());
// The cloned Arc points at the same fake backend.
let cloned = mgr.backend_clone();
let cfg = crate::models::SshConfig::new("h".into(), 22, "u".into(), "password".into());
let tunnel = cloned
.open("c1", &cfg, "db.host", 5432, None, None)
.unwrap();
assert_eq!(tunnel.local_port, 7);
}
#[test]
fn manager_insert_tunnel_replaces_and_closes_old() {
let mut mgr = SshTunnelManager::new(Arc::new(FakeBackend::default()));
mgr.insert_tunnel("c1".to_string(), Tunnel::fake(1111));
assert_eq!(mgr.get_local_port("c1"), Some(1111));
// Re-inserting under the same key replaces the old tunnel.
mgr.insert_tunnel("c1".to_string(), Tunnel::fake(2222));
assert_eq!(mgr.get_local_port("c1"), Some(2222));
assert_eq!(mgr.active_count(), 1);
}
#[test]
fn manager_close_all() {
let backend = Arc::new(FakeBackend::default());
let mut mgr = SshTunnelManager::new(backend);
let cfg = crate::models::SshConfig::new("h".into(), 22, "u".into(), "password".into());
mgr.open_tunnel("a", &cfg, "db", 5432, None, None).ok();
mgr.open_tunnel("b", &cfg, "db", 5432, None, None).ok();
mgr.close_all();
assert_eq!(mgr.active_count(), 0);
}
}
+97
View File
@@ -0,0 +1,97 @@
use crate::models::{Tag, TagInput};
use crate::store::Store;
use std::sync::Mutex;
fn validate(input: &TagInput) -> Result<(), String> {
if input.name.is_empty() || input.name.chars().count() > 50 {
return Err("name is required and must be 50 chars or fewer".into());
}
Ok(())
}
pub fn get_tags_inner(state: &Mutex<Store>) -> Result<Vec<Tag>, String> {
let store = state.lock().map_err(|e| e.to_string())?;
store.get_tags()
}
pub fn create_tag_inner(state: &Mutex<Store>, input: TagInput) -> Result<Tag, String> {
validate(&input)?;
let store = state.lock().map_err(|e| e.to_string())?;
store.create_tag(input)
}
pub fn delete_tag_inner(state: &Mutex<Store>, id: &str) -> Result<(), String> {
let store = state.lock().map_err(|e| e.to_string())?;
store.delete_tag(id)
}
pub fn update_tag_inner(state: &Mutex<Store>, id: String, input: TagInput) -> Result<Tag, String> {
validate(&input)?;
let store = state.lock().map_err(|e| e.to_string())?;
store.update_tag(&id, input)
}
#[tauri::command]
pub fn get_tags(state: tauri::State<crate::AppState>) -> Result<Vec<Tag>, String> {
get_tags_inner(&state.db_store)
}
#[tauri::command]
pub fn create_tag(state: tauri::State<crate::AppState>, input: TagInput) -> Result<Tag, String> {
create_tag_inner(&state.db_store, input)
}
#[tauri::command]
pub fn delete_tag(state: tauri::State<crate::AppState>, id: String) -> Result<(), String> {
delete_tag_inner(&state.db_store, &id)
}
#[tauri::command]
pub fn update_tag(
state: tauri::State<crate::AppState>,
id: String,
input: TagInput,
) -> Result<Tag, String> {
update_tag_inner(&state.db_store, id, input)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::TagInput;
use crate::store::Store;
fn state() -> std::sync::Mutex<Store> {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::store::migrations::run_migrations(&conn).unwrap();
std::sync::Mutex::new(Store::from_connection(conn))
}
#[test]
fn create_tag_command_works() {
let st = state();
let tag = create_tag_inner(
&st,
TagInput {
name: "prod".into(),
color: "#ef4444".into(),
},
)
.unwrap();
assert_eq!(get_tags_inner(&st).unwrap().len(), 1);
assert_eq!(tag.name, "prod");
}
#[test]
fn create_tag_rejects_long_name() {
let st = state();
let result = create_tag_inner(
&st,
TagInput {
name: "x".repeat(51),
color: "#fff".into(),
},
);
assert!(result.is_err());
}
}
@@ -0,0 +1,726 @@
use serde::{Deserialize, Serialize};
use tauri::State;
use crate::commands::ssh::SshTunnelManager;
use crate::db::pool::DbConfig;
/// The SSH tunnel manager behind a mutex (as stored in `AppState`).
type SshManager = std::sync::Mutex<SshTunnelManager>;
/// Result of a test database connection attempt.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestConnectionResult {
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub server_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub latency_ms: Option<u64>,
}
/// Strip credentials and sensitive information from error messages while
/// preserving the useful diagnostic detail (severity, message, SQLSTATE).
///
/// Redacts `password=...`, `user=...`, `postgresql://user:pwd@host` URLs,
/// and `@host` credential fragments rather than discarding the whole
/// message — so the user can still see e.g. "password authentication
/// failed for user 'foo'" without leaking the password itself.
pub fn sanitize_error(msg: &str) -> String {
let mut out = String::with_capacity(msg.len());
let bytes = msg.as_bytes();
let mut i = 0;
while i < bytes.len() {
let lower = msg[i..].to_lowercase();
if let Some(scheme_end) = url_scheme_end(msg, i) {
out.push_str("[redacted-url://");
let rest = &msg[scheme_end..];
let end = match rest.find(['/', '?']) {
Some(pos) => scheme_end + pos,
None => msg.len(),
};
i = end;
} else if lower.starts_with("password=") {
out.push_str("[redacted]");
let rest = &msg[i + "password=".len()..];
let skip = rest.find(char::is_whitespace).unwrap_or(rest.len());
i += "password=".len() + skip;
} else if lower.starts_with("user=") {
out.push_str("[redacted]");
let rest = &msg[i + "user=".len()..];
let skip = rest.find(char::is_whitespace).unwrap_or(rest.len());
i += "user=".len() + skip;
} else if lower.starts_with("secret") {
out.push_str("secret=[redacted]");
let rest = &msg[i + "secret".len()..];
let skip = rest.find(char::is_whitespace).unwrap_or(rest.len());
i += "secret".len() + skip;
} else {
let ch = msg[i..].chars().next().unwrap();
out.push(ch);
i += ch.len_utf8();
}
}
// Truncate at 300 characters for safety.
if out.len() > 300 {
format!("{}...", &out[..297])
} else {
out
}
}
/// If `msg[i..]` begins a `scheme://authority` URL — a 1-16 char scheme of
/// alphanumerics/`+`/`-`/`.` preceded by a non-word boundary — return the byte
/// index just past the `://`. This redacts embedded credentials for any scheme
/// (`postgres://`, `redis://`, `mysql://`, ...) without matching non-URL text.
fn url_scheme_end(msg: &str, i: usize) -> Option<usize> {
let rest = &msg[i..];
let colon = rest.find("://")?;
if colon == 0 || colon > 16 {
return None;
}
let scheme = &rest[..colon];
if !scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || "+-.".contains(c))
{
return None;
}
// Require a boundary before the scheme so mid-word text is not a URL.
if let Some(c) = msg[..i].chars().next_back() {
if c.is_ascii_alphanumeric() || c == '_' {
return None;
}
}
Some(i + colon + 3)
}
/// Validate `DbConfig` before attempting a connection test.
///
/// Returns `Some(error_message)` if the config is invalid, or `None` if valid.
///
/// Validation rules:
/// - `db_type` must be one of: `postgresql`, `mysql`, `sqlite`, `redis`
/// - For `postgresql`, `mysql`, `redis`: `host` must not be empty, `port` must
/// be `Some(1..=65535)`
/// - For `sqlite`: `host` (file path) must not be empty
pub fn validate_test_input(config: &DbConfig) -> Option<String> {
let db_type = config.db_type.to_lowercase();
let valid_types = ["postgresql", "mysql", "sqlite", "redis"];
if !valid_types.contains(&db_type.as_str()) {
return Some(format!(
"unsupported database type: {}. Supported types: {}",
config.db_type,
valid_types.join(", ")
));
}
if config.host.is_empty() {
return Some("host must not be empty".to_string());
}
// SQLite does not require a port (host is the file path)
if db_type != "sqlite" {
match config.port {
Some(p) if (1..=65535).contains(&p) => {}
_ => {
return Some(
"port must be an integer between 1 and 65535 for this db_type".to_string(),
);
}
}
}
None
}
/// Effective connect target after optional SSH tunnel resolution.
struct ConnectTarget {
host: String,
port: u16,
via_tunnel: bool,
/// Key of the opened tunnel, if any — must be closed after the probe.
tunnel_key: Option<String>,
}
/// Open an SSH tunnel if `config` has one configured, returning the loopback
/// target to connect through. The blocking ssh2 handshake runs in
/// `spawn_blocking` so it never blocks the async runtime.
///
/// The caller must close the tunnel (via `tunnel_key`) after the probe, on
/// every path.
async fn resolve_connect_target(
config: &DbConfig,
ssh: &SshManager,
default_port: u16,
) -> Result<ConnectTarget, String> {
match config.ssh_config() {
Some(ssh_cfg) => {
let key = format!("test-{}", uuid::Uuid::new_v4());
let open_key = key.clone();
let remote_host = config.host.clone();
let remote_port = config.port.unwrap_or(default_port as i64) as u16;
let pw = config.ssh_password.clone();
let pp = config.ssh_passphrase.clone();
let backend = ssh.lock().unwrap().backend_clone();
let tunnel = tokio::task::spawn_blocking(move || {
backend.open(
&open_key,
&ssh_cfg,
&remote_host,
remote_port,
pw.as_deref(),
pp.as_deref(),
)
})
.await
.map_err(|e| e.to_string())??;
let lp = tunnel.local_port;
ssh.lock().unwrap().insert_tunnel(key.clone(), tunnel);
Ok(ConnectTarget {
host: "127.0.0.1".to_string(),
port: lp,
via_tunnel: true,
tunnel_key: Some(key),
})
}
None => Ok(ConnectTarget {
host: config.host.clone(),
port: config.port.unwrap_or(default_port as i64) as u16,
via_tunnel: false,
tunnel_key: None,
}),
}
}
/// Close the probe tunnel if one was opened.
fn close_probe_tunnel(ssh: &SshManager, key: Option<&str>) {
if let Some(k) = key {
ssh.lock().unwrap().close_tunnel(k);
}
}
/// Test a database connection for the given configuration.
///
/// Dispatches to the appropriate type-specific connection test based on
/// `config.db_type`. Returns a `TestConnectionResult` indicating success
/// or failure with a sanitized error message.
pub async fn test_database_connection(config: &DbConfig, ssh: &SshManager) -> TestConnectionResult {
// Validate input first
if let Some(err) = validate_test_input(config) {
return TestConnectionResult {
ok: false,
error: Some(err),
server_version: None,
latency_ms: None,
};
}
let result = match config.db_type.to_lowercase().as_str() {
"postgresql" => test_pg_connection(config, ssh).await,
"mysql" => test_mysql_connection(config, ssh).await,
"sqlite" => test_sqlite_connection(config),
"redis" => test_redis_connection(config, ssh).await,
other => TestConnectionResult {
ok: false,
error: Some(format!("unsupported database type: {other}")),
server_version: None,
latency_ms: None,
},
};
TestConnectionResult {
ok: result.ok,
error: result.error.map(|e| sanitize_error(&e)),
server_version: result.server_version,
latency_ms: result.latency_ms,
}
}
/// Test a PostgreSQL connection using `tokio-postgres`.
///
/// Connects through an SSH tunnel when configured, with TLS selected from
/// `ssl_mode` (downgraded to encrypt-only through a tunnel). The connection
/// handler is spawned and immediately dropped after confirming the
/// connection is alive; any probe tunnel is closed in every path.
async fn test_pg_connection(config: &DbConfig, ssh: &SshManager) -> TestConnectionResult {
let user = config.username.as_deref().unwrap_or("postgres");
let dbname = config.database.as_deref().unwrap_or("postgres");
let password = config.password.as_deref().unwrap_or("");
let target = match resolve_connect_target(config, ssh, 5432).await {
Ok(t) => t,
Err(e) => {
return TestConnectionResult {
ok: false,
error: Some(e),
server_version: None,
latency_ms: None,
}
}
};
// 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(config.ssl_mode.as_deref()),
target.via_tunnel,
);
let tls = match crate::db::tls::build_tls_config(
decision,
config.ssl_ca_path.as_deref(),
config.ssl_cert_path.as_deref(),
config.ssl_key_path.as_deref(),
) {
Ok(t) => t,
Err(e) => {
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
return TestConnectionResult {
ok: false,
error: Some(e),
server_version: None,
latency_ms: None,
};
}
};
// Config builder: user/password/dbname are sent as-is (no URL
// percent-encoding needed), and the TLS connector is chosen explicitly.
let mut pgconfig = tokio_postgres::Config::new();
pgconfig
.host(target.host)
.port(target.port)
.user(user)
.password(password)
.dbname(dbname)
.connect_timeout(std::time::Duration::from_secs(10));
let start = std::time::Instant::now();
let result = match tls {
None => crate::commands::db_viewer::connect_pg_with(&pgconfig, tokio_postgres::NoTls).await,
Some(cc) => {
let connector = tokio_postgres_rustls::MakeRustlsConnect::new((*cc).clone());
crate::commands::db_viewer::connect_pg_with(&pgconfig, connector).await
}
};
let latency_ms = Some(start.elapsed().as_millis() as u64);
match result {
Ok((client, _handle)) => {
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
// Best-effort server version from the live client; None if the
// query fails. The driver task is already spawned inside
// `connect_pg_with`, so the client is fully usable here.
let server_version = client
.query_one("SELECT current_setting('server_version')", &[])
.await
.ok()
.and_then(|row| row.try_get::<_, String>(0).ok());
TestConnectionResult {
ok: true,
error: None,
server_version,
latency_ms,
}
}
Err(e) => {
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
TestConnectionResult {
ok: false,
error: Some(e.to_string()),
server_version: None,
latency_ms: None,
}
}
}
}
/// Test a MySQL connection using `sqlx`.
///
/// Uses `MySqlPoolOptions` with a pool size of 1 and a 10-second
/// `acquire_timeout`, connecting through an SSH tunnel when configured and
/// mapping `ssl_mode` onto `MySqlSslMode` (downgraded to encrypt-only
/// through a tunnel). Any probe tunnel is closed in every path.
async fn test_mysql_connection(config: &DbConfig, ssh: &SshManager) -> TestConnectionResult {
use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions, MySqlSslMode};
let target = match resolve_connect_target(config, ssh, 3306).await {
Ok(t) => t,
Err(e) => {
return TestConnectionResult {
ok: false,
error: Some(e),
server_version: None,
latency_ms: None,
}
}
};
let mut 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(config.ssl_mode.as_deref()),
target.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);
}
}
}
let start = std::time::Instant::now();
match MySqlPoolOptions::new()
.max_connections(1)
.acquire_timeout(std::time::Duration::from_secs(10))
.connect_with(opts)
.await
{
Ok(pool) => {
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
// Best-effort server version; None if the query fails.
let server_version = sqlx::query_scalar::<_, String>("SELECT VERSION()")
.fetch_one(&pool)
.await
.ok();
let latency_ms = Some(start.elapsed().as_millis() as u64);
pool.close().await;
TestConnectionResult {
ok: true,
error: None,
server_version,
latency_ms,
}
}
Err(e) => {
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
TestConnectionResult {
ok: false,
error: Some(e.to_string()),
server_version: None,
latency_ms: None,
}
}
}
}
/// Test a SQLite connection using `rusqlite`.
///
/// Opens the database file at `config.host`. Returns success if the file
/// can be opened as a valid SQLite database.
fn test_sqlite_connection(config: &DbConfig) -> TestConnectionResult {
let start = std::time::Instant::now();
match rusqlite::Connection::open(&config.host) {
Ok(conn) => {
// Best-effort server version; None if the query fails.
let server_version = conn
.query_row("SELECT sqlite_version()", [], |r| r.get::<_, String>(0))
.ok();
TestConnectionResult {
ok: true,
error: None,
server_version,
latency_ms: Some(start.elapsed().as_millis() as u64),
}
}
Err(e) => TestConnectionResult {
ok: false,
error: Some(e.to_string()),
server_version: None,
latency_ms: None,
},
}
}
/// Test a Redis connection using the `redis` crate.
///
/// Uses `redis::Client::open` followed by `get_async_connection` with a
/// 10-second timeout via `tokio::time::timeout`. Connects through an SSH
/// tunnel when configured; any probe tunnel is closed in every path.
async fn test_redis_connection(config: &DbConfig, ssh: &SshManager) -> TestConnectionResult {
use tokio::time::timeout;
let target = match resolve_connect_target(config, ssh, 6379).await {
Ok(t) => t,
Err(e) => {
return TestConnectionResult {
ok: false,
error: Some(e),
server_version: None,
latency_ms: None,
}
}
};
let password = config.password.as_deref();
let conn_str = if let Some(pwd) = password {
format!("redis://:{}@{}:{}/", pwd, target.host, target.port)
} else {
format!("redis://{}:{}/", target.host, target.port)
};
let start = std::time::Instant::now();
match redis::Client::open(conn_str.as_str()) {
Ok(client) => {
match timeout(
std::time::Duration::from_secs(10),
client.get_multiplexed_async_connection(),
)
.await
{
Ok(Ok(_conn)) => {
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
TestConnectionResult {
ok: true,
error: None,
server_version: None,
latency_ms: Some(start.elapsed().as_millis() as u64),
}
}
Ok(Err(e)) => {
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
TestConnectionResult {
ok: false,
error: Some(e.to_string()),
server_version: None,
latency_ms: None,
}
}
Err(_) => {
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
TestConnectionResult {
ok: false,
error: Some("connection timed out after 10 seconds".to_string()),
server_version: None,
latency_ms: None,
}
}
}
}
Err(e) => {
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
TestConnectionResult {
ok: false,
error: Some(e.to_string()),
server_version: None,
latency_ms: None,
}
}
}
}
/// Tauri command to test a database connection.
///
/// Calls `test_database_connection` and returns the result. `state` is
/// auto-injected; the frontend only passes `config`.
#[tauri::command]
pub async fn test_connection(
config: DbConfig,
state: State<'_, crate::AppState>,
) -> Result<TestConnectionResult, String> {
Ok(test_database_connection(&config, &state.ssh_manager).await)
}
#[cfg(test)]
mod tests {
use super::*;
// ------------------------------------------------------------------
// TestConnectionResult serialization
// ------------------------------------------------------------------
#[test]
fn test_connection_result_serialization() {
// ok=true result with server_version/latency serializes all fields
let result = TestConnectionResult {
ok: true,
error: None,
server_version: Some("15.2".to_string()),
latency_ms: Some(12),
};
let json = serde_json::to_string(&result).unwrap();
assert!(
json.contains("\"ok\":true"),
"ok=true should appear in JSON: {json}"
);
assert!(
json.contains("\"server_version\":\"15.2\""),
"server_version should appear in JSON: {json}"
);
assert!(
json.contains("\"latency_ms\":12"),
"latency_ms should appear in JSON: {json}"
);
// error result includes the error message; None fields are skipped
let result = TestConnectionResult {
ok: false,
error: Some("connection refused".to_string()),
server_version: None,
latency_ms: None,
};
let json = serde_json::to_string(&result).unwrap();
assert!(
json.contains("\"connection refused\""),
"error message should appear in JSON: {json}"
);
assert!(
!json.contains("server_version"),
"None server_version should be skipped: {json}"
);
assert!(
!json.contains("latency_ms"),
"None latency_ms should be skipped: {json}"
);
}
// ------------------------------------------------------------------
// sanitize_error
// ------------------------------------------------------------------
#[test]
fn test_connection_sanitizes_error() {
let msg = "connection failed: password=secret123 user=admin";
let sanitized = sanitize_error(msg);
assert!(
!sanitized.contains("secret123"),
"should not leak password value"
);
assert!(
!sanitized.contains("admin"),
"should not leak username value"
);
assert!(
!sanitized.contains("password="),
"should remove password= pattern"
);
assert!(!sanitized.contains("user="), "should remove user= pattern");
}
#[test]
fn sanitize_error_redacts_tunnel_style_urls() {
// Tunnel connect errors can carry a URL with embedded credentials, e.g.
// the redis:// string built for SSH-tunneled connections.
let msg = "SSH tunnel connect failed: redis://:hunter2@127.0.0.1:6379/";
let sanitized = sanitize_error(msg);
assert!(
!sanitized.contains("hunter2"),
"must redact URL password: {sanitized}"
);
assert!(
sanitized.contains("SSH tunnel connect failed"),
"must keep the diagnostic prefix: {sanitized}"
);
}
// ------------------------------------------------------------------
// validate_test_input rejection
// ------------------------------------------------------------------
#[test]
fn validate_test_input_rejects_invalid() {
// Unsupported db type
let config = DbConfig {
db_type: "mongodb".to_string(),
host: "localhost".to_string(),
port: Some(27017),
..Default::default()
};
assert!(
validate_test_input(&config).is_some(),
"mongodb should be rejected"
);
// Empty host
let config = DbConfig {
db_type: "postgresql".to_string(),
host: "".to_string(),
port: Some(5432),
..Default::default()
};
assert!(
validate_test_input(&config).is_some(),
"empty host should be rejected"
);
// Port 0
let config = DbConfig {
db_type: "postgresql".to_string(),
host: "localhost".to_string(),
port: Some(0),
..Default::default()
};
assert!(
validate_test_input(&config).is_some(),
"port 0 should be rejected"
);
}
// ------------------------------------------------------------------
// validate_test_input acceptance
// ------------------------------------------------------------------
#[test]
fn validate_test_input_accepts_valid() {
let config = DbConfig {
db_type: "postgresql".to_string(),
host: "localhost".to_string(),
port: Some(5432),
username: Some("user".to_string()),
database: Some("mydb".to_string()),
..Default::default()
};
assert!(
validate_test_input(&config).is_none(),
"valid postgresql config should be accepted"
);
}
#[test]
fn sqlite_accepts_no_port() {
// SQLite does not require a port
let config = DbConfig {
db_type: "sqlite".to_string(),
host: "/tmp/test.db".to_string(),
port: None,
..Default::default()
};
assert!(
validate_test_input(&config).is_none(),
"sqlite without port should be accepted"
);
// SQLite should also accept a config with any port (port is ignored)
let config = DbConfig {
db_type: "sqlite".to_string(),
host: "/tmp/test.db".to_string(),
port: Some(9999),
..Default::default()
};
assert!(
validate_test_input(&config).is_none(),
"sqlite with any port should be accepted"
);
}
}
+889
View File
@@ -0,0 +1,889 @@
//! Schema introspection query builders.
//!
//! This module provides pure functions that generate SQL query strings
//! for database schema introspection. No actual DB connections are needed
//! for testing — all functions are deterministic string builders.
// ---------------------------------------------------------------------------
// PostgreSQL
// ---------------------------------------------------------------------------
/// Approximate row count for a table via `pg_class.reltuples`.
pub fn pg_reltuples_query(schema: &str, table: &str) -> String {
format!(
"SELECT reltuples::bigint AS count FROM pg_class \
WHERE relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = '{}') \
AND relname = '{}'",
schema, table
)
}
/// List tables and views in a schema (or all non-system schemata).
///
/// When `schema` is `None` all schemata except the built-in system schemata
/// (`pg_catalog`, `information_schema`) are included.
pub fn pg_tables_query(schema: Option<&str>) -> String {
match schema {
Some(s) => format!(
"SELECT table_name, table_type FROM information_schema.tables \
WHERE table_schema = '{}' \
UNION ALL \
SELECT matviewname AS table_name, 'MATERIALIZED VIEW' AS table_type \
FROM pg_matviews WHERE schemaname = '{}' \
ORDER BY table_name",
s, s
),
None => {
"SELECT table_name, table_type, table_schema FROM information_schema.tables \
WHERE table_schema NOT IN ('pg_catalog', 'information_schema') \
UNION ALL \
SELECT matviewname AS table_name, 'MATERIALIZED VIEW' AS table_type, schemaname AS table_schema \
FROM pg_matviews WHERE schemaname NOT IN ('pg_catalog', 'information_schema') \
ORDER BY table_schema, table_name"
.to_string()
}
}
}
/// List all non-system schemata.
pub fn pg_schemas_query() -> String {
"SELECT schema_name FROM information_schema.schemata \
WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast') \
ORDER BY schema_name"
.to_string()
}
/// List non-template databases.
pub fn pg_databases_query() -> String {
"SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname".to_string()
}
/// Column details with primary-key and foreign-key annotations.
///
/// Joins `information_schema.columns` with constraint metadata so that
/// each row includes PK / FK information when applicable.
pub fn pg_columns_query(schema: &str, table: &str) -> String {
format!(
r#"SELECT
c.column_name,
CASE WHEN c.data_type = 'USER-DEFINED' THEN c.udt_name ELSE c.data_type END AS data_type,
c.is_nullable,
c.character_maximum_length,
c.numeric_precision,
c.numeric_scale,
c.column_default,
c.ordinal_position,
pk.constraint_type,
fk.foreign_table_schema,
fk.foreign_table_name,
fk.foreign_column_name
FROM information_schema.columns c
LEFT JOIN (
SELECT kcu.column_name, kcu.table_schema, kcu.table_name, 'PRIMARY KEY' AS constraint_type
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_catalog = kcu.constraint_catalog
AND tc.constraint_schema = kcu.constraint_schema
AND tc.constraint_name = kcu.constraint_name
WHERE tc.constraint_type = 'PRIMARY KEY'
) pk ON c.table_schema = pk.table_schema AND c.table_name = pk.table_name AND c.column_name = pk.column_name
LEFT JOIN (
SELECT
kcu.column_name,
kcu.table_schema,
kcu.table_name,
ccu.table_schema AS foreign_table_schema,
ccu.table_name AS foreign_table_name,
ccu.column_name AS foreign_column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_catalog = kcu.constraint_catalog
AND tc.constraint_schema = kcu.constraint_schema
AND tc.constraint_name = kcu.constraint_name
JOIN information_schema.constraint_column_usage ccu
ON tc.constraint_catalog = ccu.constraint_catalog
AND tc.constraint_schema = ccu.constraint_schema
AND tc.constraint_name = ccu.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
) fk ON c.table_schema = fk.table_schema AND c.table_name = fk.table_name AND c.column_name = fk.column_name
WHERE c.table_schema = '{}' AND c.table_name = '{}'
ORDER BY c.ordinal_position"#,
schema, table
)
}
// ---------------------------------------------------------------------------
// MySQL
// ---------------------------------------------------------------------------
/// List tables (and views) in the given schema.
///
/// When `schema` is `None` all non-system schemata are included (excluding
/// `information_schema`, `performance_schema`, `mysql`, and `sys`).
pub fn mysql_tables_query(schema: Option<&str>) -> String {
match schema {
Some(s) => format!(
"SELECT table_name, table_type FROM information_schema.tables \
WHERE table_schema = '{}' ORDER BY table_name",
s
),
None => {
"SELECT table_name, table_type, table_schema FROM information_schema.tables \
WHERE table_schema NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys') \
ORDER BY table_schema, table_name"
.to_string()
}
}
}
// ---------------------------------------------------------------------------
// SQLite
// ---------------------------------------------------------------------------
/// List tables and views from `sqlite_master`.
pub fn sqlite_tables_query() -> String {
"SELECT name AS table_name, type AS table_type FROM sqlite_master \
WHERE type IN ('table', 'view') ORDER BY name"
.to_string()
}
/// Column metadata via `PRAGMA table_info`.
pub fn sqlite_columns_query(table: &str) -> String {
format!("PRAGMA table_info('{}')", table)
}
/// Foreign-key metadata via `PRAGMA foreign_key_list`.
pub fn sqlite_foreign_keys_query(table: &str) -> String {
format!("PRAGMA foreign_key_list('{}')", table)
}
// ---------------------------------------------------------------------------
// Generic helpers
// ---------------------------------------------------------------------------
/// Build a paginated `SELECT` query.
///
/// Returns the SQL string (with `$1` / `$2` placeholders for `LIMIT` and
/// `OFFSET`) together with a vector of the corresponding `i64` parameter
/// values `[page_size, page * page_size]`.
///
/// When `columns` is empty the query uses `*`.
pub fn build_select_query(
schema: &str,
table: &str,
columns: &[String],
page: i64,
page_size: i64,
) -> (String, Vec<i64>) {
let cols = if columns.is_empty() {
"*".to_string()
} else {
let mut buf = String::new();
for (i, col) in columns.iter().enumerate() {
if i > 0 {
buf.push_str(", ");
}
buf.push('"');
buf.push_str(col);
buf.push('"');
}
buf
};
let sql = format!(
"SELECT {} FROM \"{}\".\"{}\" LIMIT $1 OFFSET $2",
cols, schema, table
);
let params = vec![page_size, page * page_size];
(sql, params)
}
/// Build a `COUNT(*)` query.
pub fn build_count_query(schema: &str, table: &str) -> String {
format!("SELECT COUNT(*) FROM \"{}\".\"{}\"", schema, table)
}
// ---------------------------------------------------------------------------
// Object introspection (functions, triggers, sequences, enums, extensions)
// ---------------------------------------------------------------------------
/// Query functions and procedures in a schema.
pub fn pg_functions_query(_schema: &str) -> String {
format!(
"SELECT p.proname, n.nspname, \
pg_catalog.format_type(p.prorettype, NULL) AS return_type, \
ARRAY(SELECT unnest(p.proargtypes::regtype[]::text[])) AS arg_types, \
ARRAY(SELECT unnest(p.proargnames::text[])) AS arg_names, \
ARRAY(SELECT unnest(p.proargmodes::text[])) AS arg_modes, \
l.lanname, pg_get_functiondef(p.oid) AS source, \
p.prokind::text \
FROM pg_proc p \
JOIN pg_namespace n ON p.pronamespace = n.oid \
JOIN pg_language l ON p.prolang = l.oid \
WHERE n.nspname = $1 \
AND p.prokind IN ('f', 'p') \
ORDER BY p.proname"
)
}
/// Query triggers in a schema.
pub fn pg_triggers_query(_schema: &str) -> String {
format!(
"SELECT t.tgname, tn.nspname AS trigger_schema, \
cn.nspname AS table_schema, c.relname AS table_name, \
CASE \
WHEN t.tgtype::int2 & 4 = 4 THEN 'INSERT' \
WHEN t.tgtype::int2 & 8 = 8 THEN 'DELETE' \
WHEN t.tgtype::int2 & 16 = 16 THEN 'UPDATE' \
WHEN t.tgtype::int2 & 32 = 32 THEN 'TRUNCATE' \
ELSE 'UNKNOWN' END AS event, \
CASE WHEN t.tgtype::int2 & 2 = 2 THEN 'BEFORE' ELSE 'AFTER' END AS timing, \
CASE WHEN t.tgtype::int2 & 1 = 1 THEN 'ROW' ELSE 'STATEMENT' END AS orientation, \
pg_get_triggerdef(t.oid) AS definition, \
t.tgenabled::text \
FROM pg_trigger t \
JOIN pg_class c ON t.tgrelid = c.oid \
JOIN pg_namespace cn ON c.relnamespace = cn.oid \
CROSS JOIN LATERAL (SELECT nspname FROM pg_namespace WHERE oid = (SELECT pronamespace FROM pg_proc WHERE oid = t.tgfoid)) tn \
WHERE cn.nspname = $1 AND NOT t.tgisinternal \
ORDER BY t.tgname"
)
}
/// Query sequences in a schema via information_schema.
pub fn pg_sequences_query(schema: &str) -> String {
format!(
"SELECT sequence_name, '{}' AS schema, \
COALESCE(start_value::text, '1'), \
COALESCE(minimum_value::text, '1'), \
COALESCE(maximum_value::text, '9223372036854775807'), \
COALESCE(increment::text, '1'), \
COALESCE(pg_catalog.pg_sequence_last_value(sequence_name::regclass)::text, '0'), \
COALESCE(cycle_option::text, 'NO') \
FROM information_schema.sequences \
WHERE sequence_schema = $1 \
ORDER BY sequence_name",
schema
)
}
/// Query enums in a schema.
pub fn pg_enums_query(_schema: &str) -> String {
format!(
"SELECT t.typname, n.nspname, \
ARRAY(SELECT e.enumlabel FROM pg_enum e \
WHERE e.enumtypid = t.oid ORDER BY e.enumsortorder) AS labels \
FROM pg_type t \
JOIN pg_namespace n ON t.typnamespace = n.oid \
WHERE t.typtype = 'e' AND n.nspname = $1 \
ORDER BY t.typname"
)
}
/// Query installed extensions.
pub fn pg_extensions_query() -> String {
"SELECT e.extname, n.nspname, e.extversion::text, \
pg_catalog.obj_description(e.oid, 'pg_extension') AS comment \
FROM pg_extension e \
JOIN pg_namespace n ON e.extnamespace = n.oid \
ORDER BY e.extname"
.to_string()
}
/// Query available (installable) extensions with default version + comment.
/// Schema-wide: `pg_available_extensions` is not schema-scoped.
pub fn pg_available_extensions_query() -> String {
"SELECT name, default_version::text AS version, comment \
FROM pg_available_extensions ORDER BY name"
.to_string()
}
/// Query indexes in a schema.
///
/// Returns index name, schema, table, definition (`pg_get_indexdef`),
/// uniqueness, access method, columns CSV, size in bytes, and tablespace.
pub fn pg_indexes_query(_schema: &str) -> String {
format!(
"SELECT \
i.relname AS index_name, \
ns.nspname AS schema, \
t.relname AS table_name, \
pg_get_indexdef(ix.indexrelid) AS definition, \
ix.indisunique AS is_unique, \
am.amname AS method, \
(SELECT string_agg(a.attname, ', ' ORDER BY ord.ord) \
FROM unnest(ix.indkey) WITH ORDINALITY AS ord(attnum, ord) \
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ord.attnum) AS columns, \
pg_relation_size(i.oid) AS size_bytes, \
ts.spcname AS tablespace \
FROM pg_index ix \
JOIN pg_class i ON i.oid = ix.indexrelid \
JOIN pg_class t ON t.oid = ix.indrelid \
JOIN pg_namespace ns ON t.relnamespace = ns.oid \
JOIN pg_am am ON i.relam = am.oid \
LEFT JOIN pg_tablespace ts ON i.reltablespace = ts.oid \
WHERE ns.nspname = $1 \
ORDER BY i.relname"
)
}
/// Query CHECK / UNIQUE / EXCLUSION constraints in a schema.
///
/// Primary and foreign keys are intentionally excluded — they surface in the
/// table grid. Returns name, schema, table, contype, definition
/// (`pg_get_constraintdef`), deferrability, validation, and columns CSV.
pub fn pg_constraints_query(_schema: &str) -> String {
format!(
"SELECT \
c.conname AS name, \
ns.nspname AS schema, \
cl.relname AS table_name, \
c.contype::text, \
pg_get_constraintdef(c.oid) AS definition, \
c.condeferrable, \
c.convalidated, \
(SELECT string_agg(a.attname, ', ' ORDER BY ord.ord) \
FROM unnest(c.conkey) WITH ORDINALITY AS ord(attnum, ord) \
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ord.attnum) AS columns \
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 c.contype IN ('c', 'u', 'x') \
ORDER BY c.conname"
)
}
// ---------------------------------------------------------------------------
// Roles, privileges, tablespaces, rebuild readiness
// ---------------------------------------------------------------------------
/// List non-system roles with all attributes used by the role form.
pub fn pg_roles_query() -> String {
"SELECT rolname, rolsuper, rolinherit, rolcreatedb, rolcreaterole, rolcanlogin, \
rolreplication, rolbypassrls, rolconnlimit::int8, COALESCE(rolvaliduntil::text, '') AS rolvaliduntil \
FROM pg_roles WHERE rolname !~ '^pg_' ORDER BY rolname"
.to_string()
}
/// All role-to-role memberships (member/admin/grantor).
pub fn pg_role_memberships_query() -> String {
"SELECT roleid::regrole::text AS role, member::regrole::text AS member, \
grantor::regrole::text AS grantor, admin_option \
FROM pg_auth_members ORDER BY role"
.to_string()
}
/// Table/view/matview privileges for a grantee, grouped one row per object.
pub fn pg_table_privileges_query(role: &str) -> String {
format!(
"SELECT table_schema AS schema, table_name AS name, \
array_agg(privilege_type::text) AS privileges, \
bool_or(is_grantable = 'YES') AS grantable \
FROM information_schema.table_privileges \
WHERE grantee = '{}' AND table_schema NOT IN ('pg_catalog','information_schema') \
GROUP BY table_schema, table_name \
ORDER BY table_schema, table_name",
role
)
}
/// Sequence privileges for a grantee, grouped one row per sequence.
/// aclexplode-based (role_sequence_grants was removed in PG 15).
pub fn pg_sequence_privileges_query(role: &str) -> String {
format!(
"SELECT n.nspname AS schema, c.relname AS name, \
array_agg(p.privilege_type::text) AS privileges, \
bool_or(p.is_grantable) AS grantable \
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace, \
LATERAL aclexplode(c.relacl) p \
WHERE c.relkind = 'S' AND n.nspname NOT IN ('pg_catalog','information_schema') \
AND p.grantee = (SELECT oid FROM pg_roles WHERE rolname = '{}') \
GROUP BY n.nspname, c.relname \
ORDER BY n.nspname, c.relname",
role
)
}
/// Routine (function/procedure) privileges for a grantee.
pub fn pg_routine_privileges_query(role: &str) -> String {
format!(
"SELECT routine_schema AS schema, routine_name AS name, \
array_agg(privilege_type::text) AS privileges, \
bool_or(is_grantable = 'YES') AS grantable \
FROM information_schema.routine_privileges \
WHERE grantee = '{}' \
GROUP BY routine_schema, routine_name \
ORDER BY routine_schema, routine_name",
role
)
}
/// Schema privileges for a grantee (USAGE/CREATE). aclexplode-based —
/// information_schema has no schema_privileges view.
pub fn pg_schema_privileges_query(role: &str) -> String {
format!(
"SELECT n.nspname AS name, \
array_agg(p.privilege_type::text) AS privileges, \
bool_or(p.is_grantable) AS grantable \
FROM pg_namespace n, LATERAL aclexplode(n.nspacl) p \
WHERE n.nspname NOT IN ('pg_catalog','information_schema') \
AND p.grantee = (SELECT oid FROM pg_roles WHERE rolname = '{}') \
GROUP BY n.nspname ORDER BY n.nspname",
role
)
}
/// Database privileges for a grantee via aclexplode (broadly compatible; avoids PG15-only view).
pub fn pg_database_privileges_query(role: &str) -> String {
format!(
"SELECT d.datname AS name, array_agg(a.privilege_type) AS privileges, \
bool_or(a.is_grantable) AS grantable \
FROM pg_database d, LATERAL aclexplode(d.datacl) a \
WHERE a.grantee = (SELECT oid FROM pg_roles WHERE rolname = '{}') \
AND d.datistemplate = false \
GROUP BY d.datname \
ORDER BY d.datname",
role
)
}
/// Non-system tablespaces for the table-options picker.
pub fn pg_tablespaces_query() -> String {
"SELECT spcname FROM pg_tablespace WHERE spcname !~ '^pg_' ORDER BY spcname".to_string()
}
/// Single query returning boolean blockers for a table rebuild (triggers, policies,
/// inheritance, partitioning, generated/identity columns). Parameterized via $1/$2.
pub fn pg_rebuild_readiness_query() -> String {
"SELECT \
EXISTS(SELECT 1 FROM pg_trigger t JOIN pg_class c ON t.tgrelid = c.oid \
JOIN pg_namespace n ON c.relnamespace = n.oid \
WHERE n.nspname = $1 AND c.relname = $2 AND NOT t.tgisinternal) AS has_triggers, \
EXISTS(SELECT 1 FROM pg_policy p JOIN pg_class c ON p.polrelid = c.oid \
JOIN pg_namespace n ON c.relnamespace = n.oid \
WHERE n.nspname = $1 AND c.relname = $2) AS has_policies, \
EXISTS(SELECT 1 FROM pg_inherits i JOIN pg_class c ON i.inhrelid = c.oid \
JOIN pg_namespace n ON c.relnamespace = n.oid \
WHERE n.nspname = $1 AND c.relname = $2) AS is_inherits, \
EXISTS(SELECT 1 FROM pg_partitioned_table pt JOIN pg_class c ON pt.partrelid = c.oid \
JOIN pg_namespace n ON c.relnamespace = n.oid \
WHERE n.nspname = $1 AND c.relname = $2) AS is_partitioned, \
EXISTS(SELECT 1 FROM information_schema.columns \
WHERE table_schema = $1 AND table_name = $2 AND is_generated <> '') AS has_generated"
.to_string()
}
/// FKs owned by this table (contype='f'). Parameterized $1 schema, $2 table.
pub fn pg_table_fk_out_query() -> String {
"SELECT c.conname, pg_get_constraintdef(c.oid) AS definition \
FROM pg_constraint c JOIN pg_class cl ON c.conrelid = cl.oid \
JOIN pg_namespace n ON cl.relnamespace = n.oid \
WHERE n.nspname = $1 AND cl.relname = $2 AND c.contype = 'f'".to_string()
}
/// FKs from other tables referencing this table. Parameterized $1 schema, $2 table.
pub fn pg_table_fk_in_query() -> String {
"SELECT c.conname, cn.nspname AS own_schema, cl.relname AS own_table, \
pg_get_constraintdef(c.oid) AS definition \
FROM pg_constraint c JOIN pg_class cl ON c.conrelid = cl.oid \
JOIN pg_namespace cn ON cl.relnamespace = cn.oid \
JOIN pg_class r ON c.confrelid = r.oid \
JOIN pg_namespace rn ON r.relnamespace = rn.oid \
WHERE rn.nspname = $1 AND r.relname = $2 AND c.contype = 'f'".to_string()
}
/// Grants on this table (all grantees), grouped. Parameterized $1 schema, $2 table.
pub fn pg_table_grants_query() -> String {
"SELECT grantee, array_agg(privilege_type::text) AS privileges, \
bool_or(is_grantable = 'YES') AS grantable \
FROM information_schema.table_privileges \
WHERE table_schema = $1 AND table_name = $2 AND grantee <> 'PUBLIC' \
GROUP BY grantee".to_string()
}
/// Sequences owned by this table's columns (via pg_depend). Parameterized $1 schema, $2 table.
pub fn pg_table_owned_sequences_query() -> String {
"SELECT sn.nspname AS seq_schema, s.relname AS seq_name, a.attname AS column \
FROM pg_depend d JOIN pg_class s ON d.objid = s.oid \
JOIN pg_namespace sn ON s.relnamespace = sn.oid \
JOIN pg_class t ON d.refobjid = t.oid \
JOIN pg_namespace tn ON t.relnamespace = tn.oid \
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.refobjsubid \
WHERE tn.nspname = $1 AND t.relname = $2 AND d.classid = 'pg_class'::regclass AND s.relkind = 'S'".to_string()
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
// ---------------------------------------------------------------
// PostgreSQL
// ---------------------------------------------------------------
#[test]
fn pg_count_approximation_query_is_valid() {
let sql = pg_reltuples_query("public", "users");
assert!(
sql.contains("pg_class"),
"should query pg_class for row estimates; got: {}",
sql
);
assert!(sql.contains("public"), "should contain schema name");
assert!(sql.contains("users"), "should contain table name");
}
#[test]
fn pg_table_list_query_is_valid() {
let sql = pg_tables_query(Some("public"));
assert!(
sql.contains("information_schema.tables"),
"should query information_schema.tables; got: {}",
sql
);
assert!(sql.contains("public"), "should contain the given schema");
// Without schema filter — should exclude system schemata
let all_sql = pg_tables_query(None);
assert!(
all_sql.contains("information_schema.tables"),
"should query information_schema.tables"
);
assert!(
all_sql.contains("pg_catalog"),
"should exclude pg_catalog via NOT IN"
);
}
#[test]
fn pg_column_query_is_valid() {
let sql = pg_columns_query("public", "orders");
assert!(
sql.contains("information_schema.columns"),
"should query information_schema.columns; got: {}",
sql
);
assert!(sql.contains("public"), "should contain schema name");
assert!(sql.contains("orders"), "should contain table name");
assert!(
sql.contains("FOREIGN KEY"),
"should include FK constraint metadata"
);
assert!(
sql.contains("PRIMARY KEY"),
"should include PK constraint metadata"
);
}
#[test]
fn pg_schemas_query_is_valid() {
let sql = pg_schemas_query();
assert!(sql.contains("information_schema.schemata"));
assert!(sql.contains("pg_catalog"));
}
#[test]
fn pg_databases_query_is_valid() {
let sql = pg_databases_query();
assert!(sql.contains("pg_database"));
assert!(sql.contains("datistemplate"));
}
#[test]
fn pg_indexes_query_is_parameterized_and_joins() {
let sql = pg_indexes_query("public");
assert!(
sql.contains("$1"),
"schema must be parameterized; got: {}",
sql
);
assert!(
sql.contains("pg_indexes") || sql.contains("pg_index"),
"should query pg_index; got: {}",
sql
);
assert!(
sql.contains("pg_get_indexdef"),
"should include index definition"
);
assert!(sql.contains("indisunique"), "should include uniqueness");
}
#[test]
fn pg_constraints_query_filters_check_unique_exclusion() {
let sql = pg_constraints_query("public");
assert!(
sql.contains("$1"),
"schema must be parameterized; got: {}",
sql
);
assert!(
sql.contains("pg_constraint"),
"should query pg_constraint; got: {}",
sql
);
assert!(sql.contains("contype"), "should select contype");
assert!(sql.contains("'c'"), "should filter CHECK ('c')");
assert!(sql.contains("'u'"), "should filter UNIQUE ('u')");
assert!(sql.contains("'x'"), "should filter EXCLUSION ('x')");
assert!(
sql.contains("pg_get_constraintdef"),
"should include definition"
);
}
#[test]
fn pg_tables_query_includes_materialized_views() {
let sql = pg_tables_query(Some("public"));
assert!(
sql.contains("pg_matviews"),
"matview UNION must source pg_matviews; got: {}",
sql,
);
assert!(
sql.contains("MATERIALIZED VIEW"),
"should label materialized views"
);
}
// ---------------------------------------------------------------
// MySQL
// ---------------------------------------------------------------
#[test]
fn mysql_table_list_query_is_valid() {
let sql = mysql_tables_query(Some("mydb"));
assert!(
sql.contains("information_schema.tables"),
"should query information_schema.tables; got: {}",
sql
);
assert!(sql.contains("mydb"), "should contain the given schema");
let all_sql = mysql_tables_query(None);
assert!(all_sql.contains("information_schema.tables"));
assert!(all_sql.contains("performance_schema"));
}
// ---------------------------------------------------------------
// SQLite
// ---------------------------------------------------------------
#[test]
fn sqlite_table_list_query_is_valid() {
let sql = sqlite_tables_query();
assert!(
sql.contains("sqlite_master"),
"should query sqlite_master; got: {}",
sql
);
}
#[test]
fn sqlite_columns_query_is_valid() {
let sql = sqlite_columns_query("users");
assert!(
sql.contains("PRAGMA table_info"),
"should use PRAGMA table_info; got: {}",
sql
);
assert!(sql.contains("users"), "should contain table name");
}
#[test]
fn sqlite_foreign_keys_query_is_valid() {
let sql = sqlite_foreign_keys_query("orders");
assert!(
sql.contains("PRAGMA foreign_key_list"),
"should use PRAGMA foreign_key_list; got: {}",
sql
);
assert!(sql.contains("orders"), "should contain table name");
}
// ---------------------------------------------------------------
// Generic helpers
// ---------------------------------------------------------------
#[test]
fn build_paginated_query_with_limits() {
let columns = vec!["id".to_string(), "name".to_string()];
let (sql, params) = build_select_query("public", "users", &columns, 2, 25);
assert!(
sql.contains("LIMIT"),
"should contain LIMIT clause; got: {}",
sql
);
assert!(
sql.contains("OFFSET"),
"should contain OFFSET clause; got: {}",
sql
);
assert!(sql.contains("public"), "should contain schema name");
assert!(sql.contains("users"), "should contain table name");
assert!(sql.contains("\"id\""), "should quote column names");
assert!(sql.contains("\"name\""), "should quote column names");
// page=2, page_size=25 => offset = 50
assert_eq!(params, vec![25, 50], "params should be [page_size, offset]");
}
#[test]
fn build_paginated_query_empty_columns_uses_star() {
let (sql, _) = build_select_query("public", "users", &[], 0, 10);
assert!(
sql.contains('*'),
"empty columns should produce SELECT *; got: {}",
sql
);
}
#[test]
fn build_count_query_is_valid() {
let sql = build_count_query("public", "orders");
assert!(
sql.contains("COUNT(*)"),
"should contain COUNT(*); got: {}",
sql
);
assert!(sql.contains("public"), "should contain schema name");
assert!(sql.contains("orders"), "should contain table name");
}
// ---------------------------------------------------------------
// Object introspection
// ---------------------------------------------------------------
#[test]
fn pg_functions_query_has_expected_columns() {
let sql = pg_functions_query("public");
assert!(sql.contains("pg_proc"));
assert!(sql.contains("proname"));
}
#[test]
fn pg_triggers_query_has_expected_columns() {
let sql = pg_triggers_query("public");
assert!(sql.contains("pg_trigger"));
assert!(sql.contains("tgname"));
}
#[test]
fn pg_sequences_query_filters_by_schema() {
let sql = pg_sequences_query("myschema");
assert!(sql.contains("myschema"));
}
#[test]
fn pg_enums_query_has_typtype_e() {
let sql = pg_enums_query("public");
assert!(sql.contains("typtype = 'e'"));
}
#[test]
fn pg_extensions_query_selects_from_pg_extension() {
let sql = pg_extensions_query();
assert!(sql.contains("pg_extension"));
}
#[test]
fn pg_available_extensions_query_has_name_default_version_comment() {
let sql = pg_available_extensions_query();
assert!(sql.contains("pg_available_extensions"));
assert!(sql.contains("default_version"), "should select default version; got: {sql}");
assert!(sql.contains("comment"), "should select comment; got: {sql}");
assert!(sql.contains("ORDER BY name"), "should order by name; got: {sql}");
}
// ---------------------------------------------------------------
// Roles, privileges, tablespaces, rebuild readiness
// ---------------------------------------------------------------
#[test]
fn pg_roles_query_filters_pg_prefix() {
let sql = pg_roles_query();
assert!(sql.contains("pg_roles"));
assert!(sql.contains("rolname !~ '^pg_'"));
assert!(sql.contains("rolcanlogin"));
assert!(sql.contains("rolconnlimit::int8"));
}
#[test]
fn pg_role_memberships_query_uses_pg_auth_members() {
let sql = pg_role_memberships_query();
assert!(sql.contains("pg_auth_members"));
assert!(sql.contains("admin_option"));
}
#[test]
fn pg_table_privileges_query_filters_grantee_and_aggregates() {
let sql = pg_table_privileges_query("appuser");
assert!(sql.contains("information_schema.table_privileges"));
assert!(sql.contains("grantee = 'appuser'"));
assert!(sql.contains("array_agg"));
assert!(sql.contains("GROUP BY"));
}
#[test]
fn pg_database_privileges_query_uses_aclexplode() {
let sql = pg_database_privileges_query("appuser");
assert!(sql.contains("aclexplode"));
assert!(sql.contains("pg_database"));
assert!(sql.contains("appuser"));
}
#[test]
fn pg_rebuild_readiness_query_checks_blockers() {
let sql = pg_rebuild_readiness_query();
assert!(sql.contains("pg_trigger"));
assert!(sql.contains("pg_policy"));
assert!(sql.contains("pg_inherits"));
assert!(sql.contains("pg_partitioned_table"));
assert!(sql.contains("is_generated"));
}
#[test]
fn pg_tablespaces_query_filters_pg_prefix() {
let sql = pg_tablespaces_query();
assert!(sql.contains("pg_tablespace"));
assert!(sql.contains("spcname !~ '^pg_'"));
}
#[test]
fn pg_table_fk_out_query_uses_pg_constraint_f() {
let sql = pg_table_fk_out_query();
assert!(sql.contains("pg_constraint"));
assert!(sql.contains("contype = 'f'"));
assert!(sql.contains("$1") && sql.contains("$2"));
}
#[test]
fn pg_table_fk_in_query_finds_referencing_tables() {
let sql = pg_table_fk_in_query();
assert!(sql.contains("pg_constraint"));
assert!(sql.contains("confrel"));
assert!(sql.contains("$1") && sql.contains("$2"));
}
#[test]
fn pg_table_grants_query_uses_table_privileges() {
let sql = pg_table_grants_query();
assert!(sql.contains("information_schema.table_privileges"));
assert!(sql.contains("$1") && sql.contains("$2"));
assert!(sql.contains("array_agg"));
}
#[test]
fn pg_table_owned_sequences_query_uses_pg_depend() {
let sql = pg_table_owned_sequences_query();
assert!(sql.contains("pg_depend"));
assert!(sql.contains("pg_class"));
assert!(sql.contains("$1") && sql.contains("$2"));
}
}
+9
View File
@@ -0,0 +1,9 @@
pub mod introspection;
pub mod mysql;
pub mod object_crud;
pub mod object_ddl;
pub mod pool;
pub mod tls;
#[allow(unused_imports)]
pub use pool::{ConnectionPoolManager, DbConfig, DbHandle};
+326
View File
@@ -0,0 +1,326 @@
//! Pure MySQL SQL builders for the DB viewer. Identifiers are backtick-quoted
//! (never string-concatenated); values are bound via `?` placeholders at the
//! call site. Mirrors the PG builders in `commands/db_viewer.rs` but with
//! MySQL quoting and `LIMIT 1` on single-row UPDATE/DELETE.
use crate::models::db_viewer::{FilterRule, SortRule};
use sqlx::Row;
/// `SHOW DATABASES` — the browsing branches filter system DBs client-side
/// (see [`MYSQL_SYSTEM_DBS`]).
pub fn mysql_databases_query() -> String {
"SHOW DATABASES".to_string()
}
/// System databases hidden from the DB viewer's database/schema selector.
pub const MYSQL_SYSTEM_DBS: [&str; 4] = ["information_schema", "mysql", "performance_schema", "sys"];
/// Quote a MySQL identifier with backticks, doubling any embedded backticks.
pub fn mysql_quote_ident(name: &str) -> String {
format!("`{}`", name.replace('`', "``"))
}
/// Decode a MySQL row cell as a String. `information_schema` / `SHOW`
/// metadata columns can surface as VARBINARY (bytes) depending on the
/// connection charset, so fall back from String to a UTF-8 lossy decode.
pub fn mysql_row_string(row: &sqlx::mysql::MySqlRow, i: usize) -> String {
if let Ok(s) = row.try_get::<String, _>(i) {
return s;
}
if let Ok(b) = row.try_get::<Vec<u8>, _>(i) {
return String::from_utf8_lossy(&b).into_owned();
}
String::new()
}
/// information_schema.columns query for a table — returns column metadata in
/// the column order the grid expects (name, data_type, is_nullable, column_key,
/// default, extra). Caller maps these into `ColumnInfo`.
pub fn mysql_columns_query(schema: &str, table: &str) -> String {
format!(
"SELECT column_name, data_type, is_nullable, column_key, column_default, extra \
FROM information_schema.columns \
WHERE table_schema = '{}' AND table_name = '{}' \
ORDER BY ordinal_position",
schema.replace('\'', "''"),
table.replace('\'', "''")
)
}
/// Build a `SELECT ... FROM \`schema\`.\`table\` [WHERE ...] [ORDER BY ...] LIMIT ? OFFSET ?`.
/// `filters` produce `?` placeholders (values bound by the caller); `sorts`
/// are quoted identifiers. `default_sort` is used when `sorts` is empty.
pub fn mysql_select_data_query(
schema: &str,
table: &str,
filters: &[FilterRule],
sorts: &[SortRule],
default_sort: &str,
) -> String {
let mut where_parts: Vec<String> = Vec::new();
for f in filters {
let col = mysql_quote_ident(&f.column);
let op = match f.operator.as_str() {
"eq" => format!("{} = ?", col),
"neq" => format!("{} <> ?", col),
"contains" => format!("{} LIKE CONCAT('%', ?, '%')", col),
"starts" => format!("{} LIKE CONCAT(?, '%')", col),
"ends" => format!("{} LIKE CONCAT('%', ?)", col),
"gt" => format!("{} > ?", col),
"lt" => format!("{} < ?", col),
"null" => format!("{} IS NULL", col),
"notnull" => format!("{} IS NOT NULL", col),
_ => format!("{} = ?", col),
};
where_parts.push(op);
}
let where_clause = if where_parts.is_empty() {
String::new()
} else {
format!(" WHERE {}", where_parts.join(" AND "))
};
let order_cols: Vec<String> = sorts
.iter()
.map(|s| format!("{} {}", mysql_quote_ident(&s.column), if s.order.eq_ignore_ascii_case("desc") { "DESC" } else { "ASC" }))
.collect();
let order_clause = if order_cols.is_empty() {
if default_sort.is_empty() {
String::new()
} else {
format!(" ORDER BY {}", mysql_quote_ident(default_sort))
}
} else {
format!(" ORDER BY {}", order_cols.join(", "))
};
format!(
"SELECT * FROM {}{}{} LIMIT ? OFFSET ?",
qualified(schema, table),
where_clause,
order_clause
)
}
pub fn mysql_count_query(schema: &str, table: &str) -> String {
format!("SELECT COUNT(*) FROM {}", qualified(schema, table))
}
pub fn mysql_ddl_query(schema: &str, table: &str) -> String {
format!("SHOW CREATE TABLE {}", qualified(schema, table))
}
/// Foreign-key columns for a table (referenced table/column).
pub fn mysql_fk_query(schema: &str, table: &str) -> String {
format!(
"SELECT column_name, referenced_table_schema, referenced_table_name, referenced_column_name \
FROM information_schema.key_column_usage \
WHERE table_schema = '{}' AND table_name = '{}' AND referenced_table_name IS NOT NULL",
schema.replace('\'', "''"),
table.replace('\'', "''")
)
}
/// Choose a default sort column: prefer an `id`-like column, else the first.
pub fn mysql_default_sort(columns: &[String]) -> &str {
columns.iter().find(|c| c.as_str() == "id").map(|s| s.as_str()).unwrap_or_else(|| {
columns.first().map(|s| s.as_str()).unwrap_or("")
})
}
// ── Change-SQL builders ──────────────────────────────────────────────
pub fn mysql_build_update_sql(
schema: &str,
table: &str,
primary_key: &[(String, serde_json::Value)],
new_data: &[(String, serde_json::Value)],
) -> Result<(String, Vec<serde_json::Value>), String> {
if primary_key.is_empty() {
return Err("cannot update a row without a primary key (MySQL has no ctid)".to_string());
}
let mut params: Vec<serde_json::Value> = Vec::new();
let set_clause: Vec<String> = new_data
.iter()
.map(|(col, val)| { params.push(val.clone()); format!("{} = ?", mysql_quote_ident(col)) })
.collect();
let where_clause: Vec<String> = primary_key
.iter()
.map(|(col, val)| { params.push(val.clone()); format!("{} = ?", mysql_quote_ident(col)) })
.collect();
Ok((
format!(
"UPDATE {} SET {} WHERE {} LIMIT 1",
qualified(schema, table),
set_clause.join(", "),
where_clause.join(" AND ")
),
params,
))
}
pub fn mysql_build_delete_sql(
schema: &str,
table: &str,
primary_key: &[(String, serde_json::Value)],
) -> Result<(String, Vec<serde_json::Value>), String> {
if primary_key.is_empty() {
return Err("cannot delete a row without a primary key (MySQL has no ctid)".to_string());
}
let mut params: Vec<serde_json::Value> = Vec::new();
let where_clause: Vec<String> = primary_key
.iter()
.map(|(col, val)| { params.push(val.clone()); format!("{} = ?", mysql_quote_ident(col)) })
.collect();
Ok((
format!("DELETE FROM {} WHERE {} LIMIT 1", qualified(schema, table), where_clause.join(" AND ")),
params,
))
}
pub fn mysql_build_insert_sql(
schema: &str,
table: &str,
pairs: &[(String, serde_json::Value)],
) -> (String, Vec<serde_json::Value>) {
let cols: Vec<String> = pairs.iter().map(|(c, _)| mysql_quote_ident(c)).collect();
let placeholders: Vec<&str> = pairs.iter().map(|_| "?").collect();
let params: Vec<serde_json::Value> = pairs.iter().map(|(_, v)| v.clone()).collect();
(
format!(
"INSERT INTO {} ({}) VALUES ({})",
qualified(schema, table),
cols.join(", "),
placeholders.join(", ")
),
params,
)
}
pub fn mysql_build_bulk_insert_sql(schema: &str, table: &str, columns: &[String], row_count: usize) -> String {
let cols: Vec<String> = columns.iter().map(|c| mysql_quote_ident(c)).collect();
let one_row = format!("({})", columns.iter().map(|_| "?").collect::<Vec<_>>().join(", "));
let rows = vec![one_row; row_count].join(", ");
format!("INSERT INTO {} ({}) VALUES {}", qualified(schema, table), cols.join(", "), rows)
}
pub fn mysql_build_drop_sql(schema: &str, table: &str) -> String {
format!("DROP TABLE {}", qualified(schema, table))
}
pub fn mysql_build_empty_sql(schema: &str, table: &str) -> String {
format!("DELETE FROM {}", qualified(schema, table))
}
fn qualified(schema: &str, table: &str) -> String {
format!("{}.{}", mysql_quote_ident(schema), mysql_quote_ident(table))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::db_viewer::FilterRule;
#[test]
fn quote_ident_backticks_and_escapes_embedded_backticks() {
assert_eq!(mysql_quote_ident("name"), "`name`");
assert_eq!(mysql_quote_ident("o`d`d"), "`o``d``d`");
assert_eq!(mysql_quote_ident("select"), "`select`");
}
#[test]
fn columns_query_targets_information_schema() {
let q = mysql_columns_query("shop", "orders");
assert!(q.contains("FROM information_schema.columns"));
assert!(q.contains("table_schema = 'shop'"));
assert!(q.contains("table_name = 'orders'"));
assert!(q.contains("ORDER BY ordinal_position"));
}
#[test]
fn select_data_quotes_schema_table_and_applies_limit_offset() {
let q = mysql_select_data_query("shop", "orders", &[], &[], "id");
assert!(q.contains("SELECT * FROM `shop`.`orders`"));
assert!(q.contains("ORDER BY `id`"));
assert!(q.contains("LIMIT ? OFFSET ?"));
}
#[test]
fn select_data_adds_where_for_filters_with_question_placeholders() {
let filters = vec![FilterRule { id: "f1".into(), column: "status".into(), operator: "eq".into(), value: "paid".into() }];
let q = mysql_select_data_query("shop", "orders", &filters, &[], "id");
assert!(q.contains("WHERE `status` = ?"));
assert!(q.contains("LIMIT ? OFFSET ?"));
}
#[test]
fn count_query_uses_quoted_table() {
let q = mysql_count_query("shop", "orders");
assert_eq!(q, "SELECT COUNT(*) FROM `shop`.`orders`");
}
#[test]
fn ddl_query_uses_show_create_table() {
let q = mysql_ddl_query("shop", "orders");
assert_eq!(q, "SHOW CREATE TABLE `shop`.`orders`");
}
#[test]
fn fk_query_targets_key_column_usage() {
let q = mysql_fk_query("shop", "orders");
assert!(q.contains("FROM information_schema.key_column_usage"));
assert!(q.contains("referenced_table_name IS NOT NULL"));
}
#[test]
fn build_update_uses_backticks_question_and_limit_one() {
let pk = vec![("id".to_string(), serde_json::json!(1))];
let data = vec![("status".to_string(), serde_json::json!("paid"))];
let (sql, params) = mysql_build_update_sql("shop", "orders", &pk, &data).unwrap();
assert_eq!(sql, "UPDATE `shop`.`orders` SET `status` = ? WHERE `id` = ? LIMIT 1");
assert_eq!(params, vec![serde_json::json!("paid"), serde_json::json!(1)]);
}
#[test]
fn build_update_rejects_empty_primary_key() {
let r = mysql_build_update_sql("shop", "orders", &[], &[]);
assert!(r.is_err());
}
#[test]
fn build_delete_uses_backticks_question_and_limit_one() {
let pk = vec![("id".to_string(), serde_json::json!(1))];
let (sql, params) = mysql_build_delete_sql("shop", "orders", &pk).unwrap();
assert_eq!(sql, "DELETE FROM `shop`.`orders` WHERE `id` = ? LIMIT 1");
assert_eq!(params, vec![serde_json::json!(1)]);
}
#[test]
fn build_insert_emits_columns_and_question_placeholders() {
let pairs = vec![
("a".to_string(), serde_json::json!(1)),
("b".to_string(), serde_json::json!("x")),
];
let (sql, params) = mysql_build_insert_sql("shop", "orders", &pairs);
assert_eq!(sql, "INSERT INTO `shop`.`orders` (`a`, `b`) VALUES (?, ?)");
assert_eq!(params, vec![serde_json::json!(1), serde_json::json!("x")]);
}
#[test]
fn build_drop_and_empty_table_quoted() {
assert_eq!(mysql_build_drop_sql("shop", "orders"), "DROP TABLE `shop`.`orders`");
assert_eq!(mysql_build_empty_sql("shop", "orders"), "DELETE FROM `shop`.`orders`");
}
#[test]
fn build_bulk_insert_columns_and_placeholders() {
let cols = vec!["a".to_string(), "b".to_string()];
let sql = mysql_build_bulk_insert_sql("shop", "orders", &cols, 2);
assert_eq!(sql, "INSERT INTO `shop`.`orders` (`a`, `b`) VALUES (?, ?), (?, ?)");
}
#[test]
fn default_sort_picks_id_then_first_column() {
assert_eq!(mysql_default_sort(&["updated_at".into(), "id".into()]), "id");
assert_eq!(mysql_default_sort(&["name".into()]), "name");
assert_eq!(mysql_default_sort(&[]), "");
}
}
File diff suppressed because it is too large Load Diff
+413
View File
@@ -0,0 +1,413 @@
//! 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('"', "\"\""))
}
/// Reject empty / SQL-injection-prone names (mirrors schema_graph::validate_schema_name).
pub fn validate_object_name(name: &str) -> Result<(), String> {
if name.trim().is_empty() {
return Err("Name must not be empty".into());
}
for bad in [";", "--", "/*", "'", "\"", "\\"] {
if name.contains(bad) {
return Err(format!("Name contains forbidden character(s): {bad}"));
}
}
Ok(())
}
pub fn create_schema_sql(name: &str) -> Result<String, String> {
validate_object_name(name)?;
Ok(format!("CREATE SCHEMA {}", quote_ident(name)))
}
pub fn rename_schema_sql(old: &str, new: &str) -> Result<String, String> {
validate_object_name(old)?;
validate_object_name(new)?;
Ok(format!("ALTER SCHEMA {} RENAME TO {}", quote_ident(old), quote_ident(new)))
}
pub fn drop_schema_sql(name: &str, cascade: bool) -> Result<String, String> {
validate_object_name(name)?;
Ok(format!("DROP SCHEMA {}{}", quote_ident(name), if cascade { " CASCADE" } else { "" }))
}
/// UNION ALL of every browsable object type in `$2` (schema), substring-matching `$1` (needle)
/// via position() — case-insensitive, no wildcard-escaping pitfalls. LIMIT 100.
pub fn pg_object_search_query() -> String {
"SELECT name, schema, type FROM (
SELECT table_name AS name, table_schema AS schema, CASE WHEN table_type = 'VIEW' THEN 'VIEW' ELSE 'TABLE' END AS type FROM information_schema.tables WHERE table_schema=$2 AND position(lower($1) in lower(table_name))>0
UNION ALL
SELECT matviewname, schemaname, 'MATERIALIZED VIEW' FROM pg_matviews WHERE schemaname=$2 AND position(lower($1) in lower(matviewname))>0
UNION ALL
SELECT p.proname, n.nspname, CASE p.prokind WHEN 'f' THEN 'FUNCTION' WHEN 'p' THEN 'PROCEDURE' END FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE n.nspname=$2 AND position(lower($1) in lower(p.proname))>0
UNION ALL
SELECT t.tgname, cn.nspname, 'TRIGGER' FROM pg_trigger t JOIN pg_class c ON t.tgrelid=c.oid JOIN pg_namespace cn ON c.relnamespace=cn.oid WHERE cn.nspname=$2 AND NOT t.tgisinternal AND position(lower($1) in lower(t.tgname))>0
UNION ALL
SELECT sequence_name, sequence_schema, 'SEQUENCE' FROM information_schema.sequences WHERE sequence_schema=$2 AND position(lower($1) in lower(sequence_name))>0
UNION ALL
SELECT t.typname, n.nspname, 'ENUM' FROM pg_type t JOIN pg_namespace n ON t.typnamespace=n.oid WHERE t.typtype='e' AND n.nspname=$2 AND position(lower($1) in lower(t.typname))>0
UNION ALL
SELECT e.extname, n.nspname, 'EXTENSION' FROM pg_extension e JOIN pg_namespace n ON e.extnamespace=n.oid WHERE n.nspname=$2 AND position(lower($1) in lower(e.extname))>0
UNION ALL
SELECT i.relname, ns.nspname, 'INDEX' FROM pg_index ix JOIN pg_class i ON i.oid=ix.indexrelid JOIN pg_class t ON t.oid=ix.indrelid JOIN pg_namespace ns ON t.relnamespace=ns.oid WHERE ns.nspname=$2 AND position(lower($1) in lower(i.relname))>0
UNION ALL
SELECT c.conname, ns.nspname, 'CONSTRAINT' 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=$2 AND c.contype IN ('c','u','x') AND position(lower($1) in lower(c.conname))>0
) AS hits ORDER BY type, name LIMIT 100".to_string()
}
/// Resolve a single object oid by type. $1=name, $2=schema. (Overloads: first match — see spec open questions.)
pub fn pg_object_oid_query(object_type: &str) -> String {
match object_type {
"table" | "view" | "materialized view" | "sequence" | "index" =>
"SELECT c.oid FROM pg_class c JOIN pg_namespace n ON c.relnamespace=n.oid WHERE c.relname=$1 AND n.nspname=$2 LIMIT 1".to_string(),
"function" | "procedure" =>
"SELECT p.oid FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE p.proname=$1 AND n.nspname=$2 LIMIT 1".to_string(),
"trigger" =>
"SELECT t.oid FROM pg_trigger t JOIN pg_class c ON t.tgrelid=c.oid JOIN pg_namespace n ON c.relnamespace=n.oid WHERE t.tgname=$1 AND n.nspname=$2 AND NOT t.tgisinternal LIMIT 1".to_string(),
"enum" =>
"SELECT t.oid FROM pg_type t JOIN pg_namespace n ON t.typnamespace=n.oid WHERE t.typname=$1 AND n.nspname=$2 AND t.typtype='e' LIMIT 1".to_string(),
"extension" =>
"SELECT e.oid FROM pg_extension e JOIN pg_namespace n ON e.extnamespace=n.oid WHERE e.extname=$1 AND n.nspname=$2 LIMIT 1".to_string(),
"constraint" =>
"SELECT c.oid FROM pg_constraint c JOIN pg_class cl ON c.conrelid=cl.oid JOIN pg_namespace n ON cl.relnamespace=n.oid WHERE c.conname=$1 AND n.nspname=$2 LIMIT 1".to_string(),
_ => String::new(),
}
}
/// Objects that depend on `$1` (the target oid). Excludes internal/pinned deps; resolves readable name per classid.
pub fn pg_depend_query() -> String {
"SELECT d.deptype::text AS deptype,
CASE WHEN d.classid = 'pg_rewrite'::regclass THEN 'pg_class'
ELSE d.classid::regclass::text END AS class,
CASE
WHEN d.classid='pg_class'::regclass THEN (SELECT relname FROM pg_class WHERE oid=d.objid)
WHEN d.classid='pg_proc'::regclass THEN (SELECT proname FROM pg_proc WHERE oid=d.objid)
WHEN d.classid='pg_trigger'::regclass THEN (SELECT tgname FROM pg_trigger WHERE oid=d.objid)
WHEN d.classid='pg_type'::regclass THEN (SELECT typname FROM pg_type WHERE oid=d.objid)
WHEN d.classid='pg_constraint'::regclass THEN (SELECT conname FROM pg_constraint WHERE oid=d.objid)
WHEN d.classid='pg_rewrite'::regclass THEN (SELECT ev_class::regclass::text FROM pg_rewrite WHERE oid=d.objid)
ELSE ''
END AS name
FROM pg_depend d
WHERE d.refobjid = $1 AND d.deptype IN ('n', 'a')
ORDER BY d.deptype, name".to_string()
}
/// Objects contained in a schema (for the schema-drop dependency warning). $1=schema.
pub fn pg_schema_contents_query() -> String {
"SELECT c.relname, c.relkind::text FROM pg_class c JOIN pg_namespace n ON c.relnamespace=n.oid WHERE n.nspname=$1 AND c.relkind IN ('r','v','m','S','i','c') ORDER BY c.relkind, c.relname".to_string()
}
// --- Synthesized DDL ---
pub fn sequence_ddl(s: &SequenceInfo) -> String {
format!("CREATE SEQUENCE {}.{}\n INCREMENT BY {}\n MINVALUE {}\n MAXVALUE {}\n START WITH {}\n {}",
quote_ident(&s.schema), quote_ident(&s.name), s.increment, s.min_value, s.max_value, s.start_value,
if s.cycle { "CYCLE" } else { "NO CYCLE" })
}
pub fn enum_ddl(e: &EnumInfo) -> String {
let labels: Vec<String> = e.labels.iter().map(|l| format!("'{}'", l.replace('\'', "''"))).collect();
format!("CREATE TYPE {}.{} AS ENUM ({});", quote_ident(&e.schema), quote_ident(&e.name), labels.join(", "))
}
pub fn extension_ddl(x: &ExtensionInfo) -> String {
format!("CREATE EXTENSION IF NOT EXISTS {} WITH SCHEMA {} VERSION '{}';", quote_ident(&x.name), quote_ident(&x.schema), x.version.replace('\'', "''"))
}
pub fn view_ddl(schema: &str, name: &str, selectdef: &str) -> String {
format!("CREATE OR REPLACE VIEW {}.{} AS\n{}", quote_ident(schema), quote_ident(name), selectdef)
}
pub fn matview_ddl(schema: &str, name: &str, selectdef: &str) -> String {
format!("CREATE MATERIALIZED VIEW {}.{} AS\n{}", quote_ident(schema), quote_ident(name), selectdef)
}
pub fn constraint_ddl(c: &ConstraintInfo) -> String {
format!("ALTER TABLE {}.{} ADD CONSTRAINT {} {}",
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::*;
#[test]
fn quote_ident_doubles_embedded_quotes() {
assert_eq!(quote_ident("public"), "\"public\"");
assert_eq!(quote_ident("a\"b"), "\"a\"\"b\"");
}
#[test]
fn validate_object_name_rejects_dangerous_chars() {
assert!(validate_object_name("public").is_ok());
assert!(validate_object_name("").is_err());
assert!(validate_object_name("a; DROP").is_err());
assert!(validate_object_name("a--b").is_err());
assert!(validate_object_name("a'b").is_err());
assert!(validate_object_name("a\"b").is_err());
assert!(validate_object_name("a\\b").is_err());
assert!(validate_object_name("/*x*/").is_err());
}
#[test]
fn create_schema_sql_quotes_name() {
assert_eq!(create_schema_sql("my_schema").unwrap(), "CREATE SCHEMA \"my_schema\"");
assert!(create_schema_sql("bad; name").is_err());
}
#[test]
fn rename_schema_sql_quotes_both() {
assert_eq!(
rename_schema_sql("old", "new").unwrap(),
"ALTER SCHEMA \"old\" RENAME TO \"new\""
);
}
#[test]
fn drop_schema_sql_cascade_flag() {
assert_eq!(drop_schema_sql("s", false).unwrap(), "DROP SCHEMA \"s\"");
assert_eq!(drop_schema_sql("s", true).unwrap(), "DROP SCHEMA \"s\" CASCADE");
}
#[test]
fn object_search_query_unions_types_and_uses_position_match() {
let sql = pg_object_search_query();
assert!(sql.contains("position(lower($1) in lower("), "case-insensitive substring, no wildcard escaping: {sql}");
assert!(sql.contains("$2"), "schema is $2");
// covers every browsable type
for t in ["'TABLE'", "'VIEW'", "'MATERIALIZED VIEW'", "'FUNCTION'", "'PROCEDURE'", "'TRIGGER'", "'SEQUENCE'", "'ENUM'", "'EXTENSION'", "'INDEX'", "'CONSTRAINT'"] {
assert!(sql.contains(t), "search must cover {t}");
}
assert!(sql.contains("LIMIT 100"));
}
#[test]
fn object_oid_query_branches_per_type() {
assert!(pg_object_oid_query("table").contains("pg_class"));
assert!(pg_object_oid_query("function").contains("pg_proc"));
assert!(pg_object_oid_query("trigger").contains("pg_trigger"));
assert!(pg_object_oid_query("enum").contains("pg_type"));
assert!(pg_object_oid_query("extension").contains("pg_extension"));
assert!(pg_object_oid_query("constraint").contains("pg_constraint"));
}
#[test]
fn depend_query_filters_internal_and_resolves_names() {
let sql = pg_depend_query();
assert!(sql.contains("refobjid = $1"));
assert!(sql.contains("deptype IN ('n', 'a')"), "exclude internal 'i' / pinned 'p'");
assert!(sql.contains("pg_proc'::regclass"));
assert!(sql.contains("pg_trigger'::regclass"));
assert!(sql.contains("pg_constraint'::regclass"));
}
#[test]
fn depend_query_maps_rewrite_rules_to_the_view_class() {
// View dependencies surface in pg_depend as rewrite-rule rows
// (classid = pg_rewrite). The dependent object a user cares about is
// the VIEW itself, so the class must be reported as pg_class and the
// name resolved through ev_class (the view's relation).
let sql = pg_depend_query();
assert!(
sql.contains("WHEN d.classid = 'pg_rewrite'::regclass THEN 'pg_class'"),
"pg_rewrite rows must be reported as pg_class: {sql}"
);
assert!(
sql.contains("pg_rewrite'::regclass THEN (SELECT ev_class::regclass::text FROM pg_rewrite WHERE oid=d.objid)"),
"pg_rewrite name resolves through ev_class: {sql}"
);
}
#[test]
fn sequence_ddl_is_synthesized() {
let s = SequenceInfo { name: "users_id_seq".into(), schema: "public".into(),
start_value: "1".into(), min_value: "1".into(), max_value: "9".into(),
increment: "1".into(), current_value: "5".into(), cycle: false };
let ddl = sequence_ddl(&s);
assert!(ddl.starts_with("CREATE SEQUENCE \"public\".\"users_id_seq\""));
assert!(ddl.contains("INCREMENT BY 1"));
assert!(ddl.contains("NO CYCLE"));
}
#[test]
fn enum_ddl_lists_labels_quoted() {
let e = EnumInfo { name: "role".into(), schema: "public".into(), labels: vec!["admin".into(), "user's".into()] };
let ddl = enum_ddl(&e);
assert!(ddl.starts_with("CREATE TYPE \"public\".\"role\" AS ENUM ("));
assert!(ddl.contains("'admin'"));
assert!(ddl.contains("'user''s'"), "single quotes doubled");
}
#[test]
fn extension_ddl_is_synthesized() {
let x = ExtensionInfo { name: "pgcrypto".into(), schema: "public".into(), version: "1.3".into(), comment: None };
let ddl = extension_ddl(&x);
assert!(ddl.contains("CREATE EXTENSION IF NOT EXISTS \"pgcrypto\""));
assert!(ddl.contains("WITH SCHEMA \"public\""));
assert!(ddl.contains("VERSION '1.3'"));
}
#[test]
fn view_ddl_wraps_selectdef() {
assert_eq!(view_ddl("public", "v_users", "SELECT * FROM users"),
"CREATE OR REPLACE VIEW \"public\".\"v_users\" AS\nSELECT * FROM users");
}
#[test]
fn constraint_ddl_wraps_definition() {
let c = ConstraintInfo { name: "ck_pos".into(), schema: "public".into(), table: "orders".into(),
contype: "CHECK".into(), definition: "CHECK (amount > 0)".into(), deferrable: false, validated: true, columns: vec!["amount".into()] };
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:?}");
}
}
+443
View File
@@ -0,0 +1,443 @@
use serde::{Deserialize, Serialize};
use std::time::Instant;
/// Configuration for establishing a database connection.
///
/// Fields map to connection parameters. For SQLite, `host` stores the
/// file path and `port` is always `None`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DbConfig {
pub db_type: String,
pub host: String,
pub port: Option<i64>,
pub username: Option<String>,
pub password: Option<String>,
pub database: Option<String>,
pub ssl_mode: Option<String>,
pub ssl_ca_path: Option<String>,
pub ssl_cert_path: Option<String>,
pub ssl_key_path: Option<String>,
#[serde(default)]
pub ssh_host: Option<String>,
#[serde(default)]
pub ssh_port: Option<i64>,
#[serde(default)]
pub ssh_user: Option<String>,
#[serde(default)]
pub ssh_auth_method: Option<String>,
#[serde(default)]
pub ssh_password: Option<String>,
#[serde(default)]
pub ssh_private_key_path: Option<String>,
#[serde(default)]
pub ssh_passphrase: Option<String>,
}
impl DbConfig {
/// Create a `DbConfig` for a SQLite database at `path`.
///
/// `host` is set to the file path; all other optional fields are `None`.
pub fn sqlite(path: &str) -> Self {
Self {
db_type: "SQLite".into(),
host: path.into(),
port: None,
username: None,
password: None,
database: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_password: None,
ssh_private_key_path: None,
ssh_passphrase: None,
}
}
/// Build an `SshConfig` from the flat SSH fields, or `None` if no SSH host is set.
pub fn ssh_config(&self) -> Option<crate::models::SshConfig> {
let host = self.ssh_host.clone()?;
if host.is_empty() {
return None;
}
Some(crate::models::SshConfig {
host,
port: self.ssh_port.unwrap_or(22) as u16,
user: self.ssh_user.clone().unwrap_or_default(),
auth_method: self
.ssh_auth_method
.clone()
.unwrap_or_else(|| "password".to_string()),
password: self.ssh_password.clone(),
private_key_path: self.ssh_private_key_path.clone(),
passphrase: self.ssh_passphrase.clone(),
})
}
}
/// A handle to an active database connection.
///
/// Supports `Sqlite` (synchronous via `rusqlite`), `Postgresql` (async via
/// `tokio-postgres`), and `MySql` (async via `sqlx`). Redis has no DB-viewer
/// support. Eviction relies on each variant's `Drop`: `MySqlPool` closes its
/// connections when dropped (mirroring `Postgresql`'s `JoinHandle` abort).
#[derive(Debug)]
pub enum DbHandle {
/// A synchronous SQLite connection via `rusqlite`.
Sqlite(rusqlite::Connection),
/// An asynchronous PostgreSQL connection via `tokio-postgres`.
/// Stores the client handle and the background connection task.
Postgresql(tokio_postgres::Client, tokio::task::JoinHandle<()>),
/// An asynchronous MySQL connection pool via `sqlx`.
MySql(sqlx::MySqlPool),
}
/// Internal entry stored in the pool manager.
///
/// Tracks the database handle and the last time it was accessed for LRU
/// eviction.
#[derive(Debug)]
pub(crate) struct DbPoolEntry {
pub(crate) handle: DbHandle,
pub(crate) last_accessed: Instant,
}
/// A connection pool manager with LRU eviction.
///
/// Manages a set of active database handles keyed by a user-defined
/// identifier. When the number of registered pools exceeds `max_pools`,
/// the least-recently-used entry (i.e. the pool whose handle was accessed
/// furthest in the past) is evicted.
///
/// Default `max_pools` is 5.
pub struct ConnectionPoolManager {
pools: indexmap::IndexMap<String, DbPoolEntry>,
max_pools: usize,
/// Invoked with the id of every pool that gets evicted (LRU overflow in
/// `register` or shrinkage in `set_max_pools`). Lets callers free
/// associated resources (e.g. SSH tunnels).
on_evict: Option<Box<dyn Fn(&str) + Send + Sync>>,
}
impl ConnectionPoolManager {
/// Create a new manager with a maximum of 5 pools.
pub fn new() -> Self {
Self {
pools: indexmap::IndexMap::new(),
max_pools: 5,
on_evict: None,
}
}
/// Register a callback invoked with the id of every evicted pool.
pub fn set_on_evict(&mut self, cb: Box<dyn Fn(&str) + Send + Sync>) {
self.on_evict = Some(cb);
}
/// Set the maximum number of pools before LRU eviction kicks in.
///
/// If the current pool count exceeds the new maximum, the oldest
/// entries are evicted immediately.
pub fn set_max_pools(&mut self, max: usize) {
self.max_pools = max;
while self.pools.len() > self.max_pools {
if let Some((evicted_id, _)) = self.pools.shift_remove_index(0) {
if let Some(cb) = &self.on_evict {
cb(&evicted_id);
}
}
}
}
/// Register a new database handle under `id`.
///
/// * If `id` already exists the old entry is removed first.
/// * The new entry is inserted as the most-recently-used.
/// * If the total pool count exceeds `max_pools` the least-recently-used
/// (oldest) entry is evicted.
pub fn register(&mut self, id: &str, handle: DbHandle) {
// Remove existing entry if present
self.pools.shift_remove(id);
let entry = DbPoolEntry {
handle,
last_accessed: Instant::now(),
};
self.pools.insert(id.to_string(), entry);
// LRU eviction: remove oldest (front) entries until within capacity
while self.pools.len() > self.max_pools {
if let Some((evicted_id, _)) = self.pools.shift_remove_index(0) {
if let Some(cb) = &self.on_evict {
cb(&evicted_id);
}
}
}
}
/// Get a mutable reference to the handle for `id`, or `None`.
///
/// Updates the last-accessed timestamp and re-orders the entry to
/// mark it as most-recently-used.
pub fn get(&mut self, id: &str) -> Option<&mut DbHandle> {
if let Some((key, mut entry)) = self.pools.shift_remove_entry(id) {
entry.last_accessed = Instant::now();
self.pools.insert(key, entry);
// The newly inserted entry is at the end (MRU position)
self.pools.last_mut().map(|(_, e)| &mut e.handle)
} else {
None
}
}
/// Remove the pool with `id` from the manager.
pub fn remove(&mut self, id: &str) {
self.pools.shift_remove(id);
}
/// Return a reference to the underlying pool map.
pub(crate) fn pools(&self) -> &indexmap::IndexMap<String, DbPoolEntry> {
&self.pools
}
/// Return `true` if a pool with `id` is registered.
pub fn contains(&self, id: &str) -> bool {
self.pools.contains_key(id)
}
}
#[cfg(test)]
mod tests {
use super::*;
// ------------------------------------------------------------------
// DbConfig tests
// ------------------------------------------------------------------
#[test]
fn create_pg_pool_with_minimal_config() {
let cfg = DbConfig {
db_type: "PostgreSQL".into(),
host: "pg.example.com".into(),
port: Some(5432),
username: Some("admin".into()),
password: Some("secret".into()),
database: Some("mydb".into()),
..Default::default()
};
assert_eq!(cfg.db_type, "PostgreSQL");
assert_eq!(cfg.host, "pg.example.com");
assert_eq!(cfg.port, Some(5432));
assert_eq!(cfg.username.as_deref(), Some("admin"));
assert_eq!(cfg.password.as_deref(), Some("secret"));
assert_eq!(cfg.database.as_deref(), Some("mydb"));
assert!(cfg.ssl_mode.is_none());
}
#[test]
fn db_config_for_sqlite_has_no_port() {
let cfg = DbConfig::sqlite("/tmp/test.db");
assert_eq!(cfg.db_type, "SQLite");
assert_eq!(cfg.host, "/tmp/test.db");
assert!(cfg.port.is_none());
assert!(cfg.username.is_none());
assert!(cfg.database.is_none());
}
#[test]
fn db_config_ssh_config_is_none_when_no_host() {
let cfg = DbConfig {
db_type: "PostgreSQL".into(),
host: "h".into(),
port: Some(5432),
username: None,
password: None,
database: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_password: None,
ssh_private_key_path: None,
ssh_passphrase: None,
};
assert!(cfg.ssh_config().is_none());
}
#[test]
fn db_config_ssh_config_builds_from_flat_fields() {
let cfg = DbConfig {
db_type: "PostgreSQL".into(),
host: "db".into(),
port: Some(5432),
username: None,
password: None,
database: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
ssh_host: Some("jump".into()),
ssh_port: Some(2222),
ssh_user: Some("u".into()),
ssh_auth_method: Some("password".into()),
ssh_password: Some("pw".into()),
ssh_private_key_path: None,
ssh_passphrase: None,
};
let s = cfg.ssh_config().expect("ssh config present");
assert_eq!(s.host, "jump");
assert_eq!(s.port, 2222);
assert_eq!(s.user, "u");
assert_eq!(s.auth_method, "password");
assert_eq!(s.password.as_deref(), Some("pw"));
}
// ------------------------------------------------------------------
// ConnectionPoolManager tests
// ------------------------------------------------------------------
#[test]
fn pool_manager_starts_empty() {
let manager = ConnectionPoolManager::new();
assert_eq!(manager.pools().len(), 0);
}
#[test]
fn pool_manager_register_and_evict() {
let mut manager = ConnectionPoolManager::new();
manager.set_max_pools(2);
let conn_a = rusqlite::Connection::open_in_memory().unwrap();
let conn_b = rusqlite::Connection::open_in_memory().unwrap();
let conn_c = rusqlite::Connection::open_in_memory().unwrap();
// Register A, B, then C with max=2 -- A should be evicted (LRU)
manager.register("a", DbHandle::Sqlite(conn_a));
manager.register("b", DbHandle::Sqlite(conn_b));
manager.register("c", DbHandle::Sqlite(conn_c));
assert_eq!(manager.pools().len(), 2);
assert!(!manager.contains("a"), "'a' should have been evicted (LRU)");
assert!(manager.contains("b"));
assert!(manager.contains("c"));
}
#[test]
fn pool_manager_remove_closes_pool() {
let mut manager = ConnectionPoolManager::new();
let conn = rusqlite::Connection::open_in_memory().unwrap();
manager.register("tmp", DbHandle::Sqlite(conn));
assert!(manager.contains("tmp"));
manager.remove("tmp");
assert!(!manager.contains("tmp"));
assert_eq!(manager.pools().len(), 0);
}
#[test]
fn pool_manager_get_updates_access_time() {
let mut manager = ConnectionPoolManager::new();
manager.set_max_pools(3);
let conn_a = rusqlite::Connection::open_in_memory().unwrap();
let conn_b = rusqlite::Connection::open_in_memory().unwrap();
let conn_c = rusqlite::Connection::open_in_memory().unwrap();
manager.register("a", DbHandle::Sqlite(conn_a));
manager.register("b", DbHandle::Sqlite(conn_b));
manager.register("c", DbHandle::Sqlite(conn_c));
// Access "a" -- makes it MRU
let _handle = manager.get("a").unwrap();
// Register "d" with max=3 -- "b" (now LRU) should be evicted, not "a"
let conn_d = rusqlite::Connection::open_in_memory().unwrap();
manager.register("d", DbHandle::Sqlite(conn_d));
assert_eq!(manager.pools().len(), 3);
assert!(
manager.contains("a"),
"'a' was recently accessed, should survive"
);
assert!(!manager.contains("b"), "'b' is LRU and should be evicted");
assert!(manager.contains("c"));
assert!(manager.contains("d"));
}
#[test]
fn pool_invokes_on_evict_with_evicted_id() {
let mut manager = ConnectionPoolManager::new();
manager.set_max_pools(1);
let evicted: std::sync::Arc<std::sync::Mutex<Vec<String>>> = std::sync::Arc::default();
let evicted_cb = evicted.clone();
manager.set_on_evict(Box::new(move |id: &str| {
evicted_cb.lock().unwrap().push(id.to_string());
}));
manager.register(
"a",
DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()),
);
manager.register(
"b",
DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()),
);
assert_eq!(evicted.lock().unwrap().as_slice(), ["a".to_string()]);
}
#[test]
fn pool_invokes_on_evict_on_max_pools_shrink() {
let mut manager = ConnectionPoolManager::new();
let evicted: std::sync::Arc<std::sync::Mutex<Vec<String>>> = std::sync::Arc::default();
let evicted_cb = evicted.clone();
manager.set_on_evict(Box::new(move |id: &str| {
evicted_cb.lock().unwrap().push(id.to_string());
}));
manager.register(
"a",
DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()),
);
manager.register(
"b",
DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()),
);
// Shrinking max_pools below the current count evicts oldest first.
manager.set_max_pools(1);
assert_eq!(evicted.lock().unwrap().as_slice(), ["a".to_string()]);
}
#[tokio::test]
async fn mysql_handle_can_be_registered_and_evicted() {
// Lazy pool: parses the URL without connecting (no network touch).
let pool = sqlx::mysql::MySqlPoolOptions::new()
.connect_lazy("mysql://__gridline_test__:3306/__none__")
.expect("lazy pool parses url without connecting");
let mut manager = ConnectionPoolManager::new();
manager.set_max_pools(1);
manager.register("mysql-conn", DbHandle::MySql(pool));
assert!(matches!(
manager.get("mysql-conn"),
Some(DbHandle::MySql(_))
));
// Registering a second connection evicts the first (max_pools=1).
let sqlite = rusqlite::Connection::open_in_memory().unwrap();
manager.register("sqlite-conn", DbHandle::Sqlite(sqlite));
assert!(manager.get("mysql-conn").is_none());
assert!(matches!(
manager.get("sqlite-conn"),
Some(DbHandle::Sqlite(_))
));
}
}
+264
View File
@@ -0,0 +1,264 @@
//! TLS connector factory for tokio-postgres.
//!
//! Maps the user-facing SSL modes to rustls `ClientConfig` values:
//! - `disable` -> no TLS (returns `None`)
//! - `require` -> encrypt without verifying the server certificate (custom `NoVerifier`)
//! - `verify-ca` / `verify-full` -> standard rustls webpki verification (chain AND
//! hostname; `verify-ca` is intentionally identical to `verify-full` in v1)
//!
//! Client certificates are supported via optional `cert_path` / `key_path` pair.
use std::sync::Arc;
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime};
use rustls::{ClientConfig, DigitallySignedStruct, RootCertStore};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TlsDecision {
Disable,
Require,
Verify,
}
pub fn tls_decision(ssl_mode: Option<&str>) -> TlsDecision {
match ssl_mode {
Some("require") => TlsDecision::Require,
Some("verify-ca") | Some("verify-full") => TlsDecision::Verify,
_ => TlsDecision::Disable,
}
}
/// Build a rustls `ClientConfig` for tokio-postgres, or `None` for disable.
/// `ca_path` is required for Verify; `cert_path`/`key_path` are optional client auth.
pub fn build_tls_config(
decision: TlsDecision,
ca_path: Option<&str>,
cert_path: Option<&str>,
key_path: Option<&str>,
) -> Result<Option<Arc<ClientConfig>>, String> {
if matches!(decision, TlsDecision::Disable) {
return Ok(None);
}
let client_auth = match (cert_path, key_path) {
(Some(c), Some(k)) => Some(load_client_identity(c, k)?),
(Some(_), None) | (None, Some(_)) => {
return Err("both ssl_cert_path and ssl_key_path must be set for client auth".into())
}
(None, None) => None,
};
let config = match decision {
TlsDecision::Require => {
// Encrypt without verifying the server certificate.
let builder = ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoVerifier));
match client_auth {
Some((certs, key)) => builder
.with_client_auth_cert(certs, key)
.map_err(|e| format!("client cert: {e}"))?,
None => builder.with_no_client_auth(),
}
}
TlsDecision::Verify => {
let mut roots = RootCertStore::empty();
if let Some(ca) = ca_path {
add_ca_file(&mut roots, ca)?;
} else {
return Err("ssl_ca_path is required for verify-ca / verify-full".into());
}
for ta in rustls_native_certs::load_native_certs()
.map_err(|e| format!("native certs: {e}"))?
{
let _ = roots.add(ta);
}
let builder = ClientConfig::builder().with_root_certificates(roots);
match client_auth {
Some((certs, key)) => builder
.with_client_auth_cert(certs, key)
.map_err(|e| format!("client cert: {e}"))?,
None => builder.with_no_client_auth(),
}
}
TlsDecision::Disable => unreachable!(),
};
Ok(Some(Arc::new(config)))
}
fn add_ca_file(roots: &mut RootCertStore, path: &str) -> Result<(), String> {
let bytes = std::fs::read(path).map_err(|e| format!("failed to read CA file {path}: {e}"))?;
let mut reader = std::io::BufReader::new(bytes.as_slice());
let parsed = rustls_pemfile::certs(&mut reader)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("parse CA: {e}"))?;
let added = parsed.into_iter().filter_map(|c| roots.add(c).ok()).count();
if added == 0 {
return Err("no usable CA certificates found".into());
}
Ok(())
}
fn load_client_identity(
cert_path: &str,
key_path: &str,
) -> Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>), String> {
// rustls-pemfile cannot decrypt PKCS#8-encrypted keys, so reject them up
// front with a clear message before touching the certificate file.
let kb = std::fs::read(key_path).map_err(|e| format!("read key: {e}"))?;
if String::from_utf8_lossy(&kb).contains("ENCRYPTED PRIVATE KEY") {
return Err(
"encrypted client keys are not supported in v1; use an unencrypted PEM key".into(),
);
}
let cb = std::fs::read(cert_path).map_err(|e| format!("read cert: {e}"))?;
let certs: Vec<CertificateDer<'static>> =
rustls_pemfile::certs(&mut std::io::BufReader::new(cb.as_slice()))
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("parse cert: {e}"))?
.into_iter()
.map(|c| c.into_owned())
.collect();
if certs.is_empty() {
return Err("no client certificates parsed".into());
}
let key = rustls_pemfile::private_key(&mut std::io::BufReader::new(kb.as_slice()))
.map_err(|e| format!("parse key: {e}"))?
.ok_or_else(|| "no private key parsed".to_string())?
.clone_key();
Ok((certs, key))
}
/// Accepts every certificate: TLS encryption without authentication (`require` mode).
#[derive(Debug)]
struct NoVerifier;
impl ServerCertVerifier for NoVerifier {
fn verify_server_cert(
&self,
_ee: &CertificateDer<'_>,
_ic: &[CertificateDer<'_>],
_n: &ServerName<'_>,
_ocsp: &[u8],
_now: UnixTime,
) -> Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
rustls::crypto::ring::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tls_decision_maps_modes() {
assert!(matches!(tls_decision(None), TlsDecision::Disable));
assert!(matches!(
tls_decision(Some("disable")),
TlsDecision::Disable
));
assert!(matches!(
tls_decision(Some("require")),
TlsDecision::Require
));
assert!(matches!(
tls_decision(Some("verify-ca")),
TlsDecision::Verify
));
assert!(matches!(
tls_decision(Some("verify-full")),
TlsDecision::Verify
));
assert!(matches!(tls_decision(Some("bogus")), TlsDecision::Disable));
}
#[test]
fn build_tls_disable_returns_none() {
assert!(build_tls_config(TlsDecision::Disable, None, None, None)
.unwrap()
.is_none());
}
#[test]
fn build_tls_require_returns_some_without_files() {
assert!(build_tls_config(TlsDecision::Require, None, None, None)
.unwrap()
.is_some());
}
#[test]
fn build_tls_verify_missing_ca_errors() {
let err = build_tls_config(TlsDecision::Verify, Some("/nonexistent/ca.pem"), None, None)
.unwrap_err();
assert!(err.to_lowercase().contains("ca"), "got: {err}");
}
#[test]
fn build_tls_client_cert_missing_key_errors() {
// cert set without key
let err = build_tls_config(
TlsDecision::Require,
None,
Some("/nonexistent/cert.pem"),
None,
)
.unwrap_err();
assert!(err.to_lowercase().contains("cert") || err.to_lowercase().contains("key"));
}
#[test]
fn build_tls_rejects_encrypted_key_marker() {
// rustls-pemfile cannot decrypt PKCS#8-encrypted keys, so an ENCRYPTED
// PRIVATE KEY header must be rejected with a clear error. The cert file
// is a dummy: the key check fires before the cert is read.
let dir = std::env::temp_dir();
let cert_path = dir.join("gl_tls_cert_dummy.pem");
let key_path = dir.join("gl_tls_enc_key.pem");
std::fs::write(
&cert_path,
"-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n",
)
.unwrap();
std::fs::write(
&key_path,
"-----BEGIN ENCRYPTED PRIVATE KEY-----\nabc\n-----END ENCRYPTED PRIVATE KEY-----\n",
)
.unwrap();
let r = build_tls_config(
TlsDecision::Require,
None,
Some(cert_path.to_str().unwrap()),
Some(key_path.to_str().unwrap()),
);
assert!(r.is_err());
assert!(
r.unwrap_err().to_lowercase().contains("encrypt"),
"must mention encryption"
);
}
}
+198
View File
@@ -0,0 +1,198 @@
// Infrastructure modules: types, introspection, and DB viewer commands are built ahead
// of runtime usage, producing expected dead_code/unused warnings during development.
#![allow(dead_code)]
mod cancel;
mod commands;
mod db;
mod models;
mod store;
use commands::ssh::{Ssh2Backend, SshTunnelManager};
use db::pool::ConnectionPoolManager;
use std::sync::{Arc, Mutex as StdMutex};
use store::Store;
use tauri::Manager;
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::{
backup, connections, db_viewer, demo, folders, import_export, keychain, maintenance, objects, query,
schema_graph, settings, tags,
};
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// Install the ring crypto provider so rustls `ClientConfig::builder()` works (no-op if
// another provider is already installed).
let _ = rustls::crypto::ring::default_provider().install_default();
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_keyring_store::init())
.setup(move |app| {
// Open the local store under the OS app-data directory. When the
// app is launched from Finder/LaunchServices the working directory
// is `/`, so a relative "gridline.db" path panics ("failed to
// open db", exit 101) before the UI ever starts. The demo DB uses
// the same directory (see commands/demo.rs).
let data_dir = app
.path()
.app_data_dir()
.map_err(|e| format!("failed to resolve app data dir: {e}"))?;
std::fs::create_dir_all(&data_dir)
.map_err(|e| format!("failed to create app data dir: {e}"))?;
let store =
Store::open(&data_dir.join("gridline.db").to_string_lossy()).expect("failed to open db");
app.manage(AppState {
db_store: StdMutex::new(store),
pool_manager: tokio::sync::Mutex::new(ConnectionPoolManager::new()),
ssh_manager: StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend))),
cancel_registry: crate::cancel::CancelRegistry::default(),
});
let state = app.state::<AppState>();
demo::ensure_demo_db(app.handle(), &state.db_store)
.map_err(|e| {
eprintln!("Failed to set up demo DB: {e}");
})
.ok();
// Close the SSH tunnel for a connection when its pool is evicted
// (LRU overflow or max-pool shrink). The hook captures a clone of
// the app handle and resolves AppState through the manager.
let handle = app.handle().clone();
state
.pool_manager
.blocking_lock()
.set_on_evict(Box::new(move |id: &str| {
if let Some(s) = handle.try_state::<AppState>() {
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);
}
}));
Ok(())
})
.invoke_handler(tauri::generate_handler![
greet,
connections::get_connections,
connections::create_connection,
connections::update_connection,
connections::delete_connection,
connections::add_connection_tags,
connections::set_connection_favorite,
connections::record_recent_connection,
connections::get_recent_connections,
connections::clear_recent_connections,
folders::get_folders,
folders::create_folder,
folders::delete_folder,
folders::update_folder,
folders::add_folder_tags,
tags::get_tags,
tags::create_tag,
tags::delete_tag,
tags::update_tag,
settings::get_settings,
settings::update_setting,
import_export::import_connections,
import_export::export_connections,
commands::test_connection::test_connection,
db_viewer::db_connect,
db_viewer::db_disconnect,
db_viewer::get_databases,
db_viewer::get_schemas,
db_viewer::get_tables,
db_viewer::get_table_data,
db_viewer::get_table_columns,
db_viewer::get_fk_preview,
db_viewer::execute_change,
db_viewer::get_table_ddl,
db_viewer::refresh_connection,
db_viewer::get_functions,
db_viewer::get_triggers,
db_viewer::get_sequences,
db_viewer::get_enums,
db_viewer::get_extensions,
db_viewer::get_indexes,
db_viewer::get_constraints,
objects::create_schema,
objects::rename_schema,
objects::drop_schema,
objects::search_objects,
objects::get_object_ddl,
objects::get_object_dependencies,
objects::build_object_ddl,
objects::build_rebuild_script,
objects::get_available_extensions,
objects::get_roles, objects::get_role_privileges, objects::get_table_rebuild_readiness, objects::get_tablespaces,
maintenance::run_maintenance,
keychain::save_connection_password,
keychain::get_connection_password,
keychain::delete_connection_password,
keychain::save_connection_ssh_password,
keychain::get_connection_ssh_password,
keychain::delete_connection_ssh_password,
keychain::save_connection_ssh_passphrase,
keychain::get_connection_ssh_passphrase,
keychain::delete_connection_ssh_passphrase,
demo::recreate_demo_db,
demo::regenerate_demo_db,
backup::detect_pg_tools,
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,
query::save_query,
query::get_saved_queries,
query::update_saved_query,
query::delete_saved_query,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| {
// 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
) {
if let Ok(mut mgr) = app_handle.state::<AppState>().ssh_manager.lock() {
mgr.close_all();
}
}
});
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
gridline_lib::run()
}
+245
View File
@@ -0,0 +1,245 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BackupOptions {
pub format: String, // "plain" | "custom" | "tar" | "directory"
pub file_path: String,
pub schema: Option<String>,
pub tables: Option<Vec<String>>,
pub no_owner: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RestoreOptions {
pub format: String,
pub file_path: String,
pub clean: bool,
pub schema: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncOptions {
pub source_connection_id: String,
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)]
pub struct PgToolStatus {
pub pg_dump_found: bool,
pub pg_restore_found: bool,
pub pg_dump_version: Option<String>,
pub pg_restore_version: Option<String>,
pub pg_dump_source: Option<String>, // "system" | "bundled" | None
pub pg_restore_source: Option<String>,
}
/// Resolved on-disk paths for the three client tools (system-first, bundled-fallback).
#[derive(Debug, Clone)]
pub struct PgToolPaths {
pub pg_dump: String,
pub pg_restore: String,
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,
pub connection_id: String,
pub r#type: String, // "dump" | "restore" | "sync"
pub format: Option<String>,
pub file_path: Option<String>,
pub source_connection_id: Option<String>,
pub status: String,
pub error_message: Option<String>,
pub size_bytes: Option<i64>,
pub started_at: String,
pub completed_at: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct BackupProgressEvent {
pub job_id: String,
pub status: String,
pub progress: Option<f64>,
pub output_line: Option<String>,
pub error: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backup_options_serialization() {
let opts = BackupOptions {
format: "plain".into(),
file_path: "/tmp/dump.sql".into(),
schema: Some("public".into()),
tables: None,
no_owner: true,
};
let json = serde_json::to_string(&opts).unwrap();
assert!(json.contains("plain"));
assert!(json.contains("noOwner"));
}
#[test]
fn backup_job_serialization() {
let job = BackupJob {
id: "job-1".into(),
connection_id: "conn-1".into(),
r#type: "dump".into(),
format: Some("plain".into()),
file_path: Some("/tmp/dump.sql".into()),
source_connection_id: None,
status: "completed".into(),
error_message: None,
size_bytes: Some(1024),
started_at: "2025-07-28T10:00:00Z".into(),
completed_at: Some("2025-07-28T10:01:00Z".into()),
};
let json = serde_json::to_string(&job).unwrap();
assert!(json.contains("dump"));
assert!(json.contains("completed"));
}
#[test]
fn pg_tool_status_reports_source() {
let s = PgToolStatus { pg_dump_found: true, pg_restore_found: true,
pg_dump_version: Some("pg_dump 16".into()), pg_restore_version: Some("pg_restore 16".into()),
pg_dump_source: Some("system".into()), pg_restore_source: Some("bundled".into()) };
let json = serde_json::to_string(&s).unwrap();
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\""));
}
}
+211
View File
@@ -0,0 +1,211 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Connection {
pub id: String,
pub name: String,
pub db_type: String,
pub host: String,
pub port: Option<i64>,
pub username: Option<String>,
pub database: Option<String>,
pub folder_id: Option<String>,
pub keychain_ref: Option<String>,
pub environment: Option<String>,
pub favorite: bool,
pub ssh_host: Option<String>,
pub ssh_port: Option<i64>,
pub ssh_user: Option<String>,
pub ssh_auth_method: Option<String>,
pub ssh_private_key_path: Option<String>,
pub ssl_mode: Option<String>,
pub ssl_ca_path: Option<String>,
pub ssl_cert_path: Option<String>,
pub ssl_key_path: Option<String>,
pub tag_ids: Vec<String>,
pub use_keychain: bool,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectionInput {
pub name: String,
pub db_type: String,
pub host: String,
pub port: Option<i64>,
pub username: Option<String>,
pub folder_id: Option<String>,
pub tag_ids: Vec<String>,
pub password: Option<String>,
pub database: Option<String>,
pub environment: Option<String>,
pub ssh_host: Option<String>,
pub ssh_port: Option<i64>,
pub ssh_user: Option<String>,
pub ssh_auth_method: Option<String>,
pub ssh_private_key_path: Option<String>,
pub ssh_password: Option<String>,
pub ssh_passphrase: Option<String>,
pub ssl_mode: Option<String>,
pub ssl_ca_path: Option<String>,
pub ssl_cert_path: Option<String>,
pub ssl_key_path: Option<String>,
#[serde(default = "default_true")]
pub use_keychain: bool,
}
fn default_true() -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn connection_input_roundtrip_with_all_fields() {
let input = ConnectionInput {
name: "Test DB".to_string(),
db_type: "PostgreSQL".to_string(),
host: "db.example.com".to_string(),
port: Some(5432),
username: Some("admin".to_string()),
folder_id: Some("folder1".to_string()),
tag_ids: vec!["tag1".to_string(), "tag2".to_string()],
use_keychain: true,
password: Some("secret123".to_string()),
database: Some("mydb".to_string()),
ssh_host: Some("jumphost.example.com".to_string()),
ssh_port: Some(2222),
ssh_user: Some("tunnel".to_string()),
ssh_auth_method: Some("Key".to_string()),
ssh_private_key_path: Some("/path/to/key".to_string()),
ssh_password: Some("ssh-pw".to_string()),
ssh_passphrase: Some("passphrase".to_string()),
ssl_mode: Some("require".to_string()),
ssl_ca_path: Some("/path/to/ca".to_string()),
ssl_cert_path: Some("/path/to/cert".to_string()),
ssl_key_path: Some("/path/to/key".to_string()),
environment: Some("production".to_string()),
};
let json = serde_json::to_string(&input).unwrap();
let deserialized: ConnectionInput = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.name, "Test DB");
assert_eq!(deserialized.db_type, "PostgreSQL");
assert_eq!(deserialized.host, "db.example.com");
assert_eq!(deserialized.port, Some(5432));
assert_eq!(deserialized.username, Some("admin".to_string()));
assert_eq!(deserialized.folder_id, Some("folder1".to_string()));
assert_eq!(
deserialized.tag_ids,
vec!["tag1".to_string(), "tag2".to_string()]
);
assert_eq!(deserialized.password, Some("secret123".to_string()));
assert_eq!(deserialized.database, Some("mydb".to_string()));
assert_eq!(
deserialized.ssh_host,
Some("jumphost.example.com".to_string())
);
assert_eq!(deserialized.ssh_port, Some(2222));
assert_eq!(deserialized.ssh_user, Some("tunnel".to_string()));
assert_eq!(deserialized.ssh_auth_method, Some("Key".to_string()));
assert_eq!(
deserialized.ssh_private_key_path,
Some("/path/to/key".to_string())
);
assert_eq!(deserialized.ssh_password, Some("ssh-pw".to_string()));
assert_eq!(deserialized.ssh_passphrase, Some("passphrase".to_string()));
assert_eq!(deserialized.ssl_mode, Some("require".to_string()));
assert_eq!(deserialized.ssl_ca_path, Some("/path/to/ca".to_string()));
assert_eq!(
deserialized.ssl_cert_path,
Some("/path/to/cert".to_string())
);
assert_eq!(deserialized.ssl_key_path, Some("/path/to/key".to_string()));
}
#[test]
fn connection_persisted_does_not_include_password() {
let conn = Connection {
id: "test-id".to_string(),
name: "Test".to_string(),
db_type: "PostgreSQL".to_string(),
host: "localhost".to_string(),
port: Some(5432),
username: Some("user".to_string()),
folder_id: Some("folder".to_string()),
keychain_ref: Some("keychain-ref".to_string()),
environment: None,
favorite: false,
tag_ids: vec![],
use_keychain: true,
created_at: "2024-01-01T00:00:00Z".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(),
database: Some("mydb".to_string()),
ssh_host: Some("ssh-host".to_string()),
ssh_port: Some(2222),
ssh_user: Some("ssh-user".to_string()),
ssh_auth_method: Some("Key".to_string()),
ssh_private_key_path: Some("/path/to/key".to_string()),
ssl_mode: Some("require".to_string()),
ssl_ca_path: Some("/path/to/ca".to_string()),
ssl_cert_path: Some("/path/to/cert".to_string()),
ssl_key_path: Some("/path/to/key".to_string()),
};
let json = serde_json::to_string(&conn).unwrap();
assert!(
!json.contains("password"),
"Connection JSON should not contain password field"
);
}
#[test]
fn connection_serializes_favorite_field() {
let conn = Connection {
id: "x".into(),
name: "n".into(),
db_type: "postgresql".into(),
host: "h".into(),
port: Some(5432),
username: None,
database: None,
folder_id: None,
keychain_ref: None,
environment: None,
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_private_key_path: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
tag_ids: vec![],
use_keychain: true,
favorite: true,
created_at: "2024-01-01T00:00:00Z".into(),
updated_at: "2024-01-01T00:00:00Z".into(),
};
let json = serde_json::to_string(&conn).unwrap();
assert!(json.contains("\"favorite\":true"));
}
#[test]
fn connection_input_use_keychain_defaults_true_when_absent() {
let json = r#"{"name":"n","db_type":"postgresql","host":"h","port":5432,"tag_ids":[]}"#;
let input: ConnectionInput = serde_json::from_str(json).unwrap();
assert!(input.use_keychain, "absent use_keychain defaults to true (opt-out)");
}
#[test]
fn connection_input_use_keychain_preserves_false() {
let json = r#"{"name":"n","db_type":"postgresql","host":"h","port":5432,"tag_ids":[],"use_keychain":false}"#;
let input: ConnectionInput = serde_json::from_str(json).unwrap();
assert!(!input.use_keychain);
}
}
+733
View File
@@ -0,0 +1,733 @@
use serde::{Deserialize, Serialize};
/// A single filter rule sent from the frontend.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FilterRule {
pub id: String,
pub column: String,
pub operator: String, // "eq" | "neq" | "contains" | "starts" | "ends" | "gt" | "lt" | "null" | "notnull"
pub value: String,
}
/// A single sort rule sent from the frontend.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SortRule {
pub id: String,
pub column: String,
pub order: String, // "asc" | "desc"
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableInfo {
pub name: String,
pub schema: String,
pub table_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ColumnInfo {
pub name: String,
pub data_type: String,
pub is_nullable: bool,
pub is_pk: bool,
pub is_fk: bool,
pub fk_ref: Option<(String, String)>,
pub default_value: Option<String>,
pub editable: bool,
pub is_generated: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexInfo {
pub name: String,
pub schema: String,
pub table: String,
pub definition: String,
pub is_unique: bool,
pub method: String,
pub columns: Vec<String>,
pub size_bytes: Option<i64>,
pub tablespace: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConstraintInfo {
pub name: String,
pub schema: String,
pub table: String,
pub contype: String, // "CHECK" | "UNIQUE" | "EXCLUSION"
pub definition: String,
pub deferrable: bool,
pub validated: bool,
pub columns: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueryResult {
pub columns: Vec<ColumnInfo>,
pub rows: Vec<Vec<serde_json::Value>>,
pub total_rows: i64,
pub page: i64,
pub page_size: i64,
pub execution_time_ms: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pagination {
pub page: i64,
pub page_size: i64,
pub total_rows: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionInfo {
pub name: String,
pub schema: String,
pub return_type: String,
pub argument_types: Vec<String>,
pub argument_names: Vec<String>,
pub argument_modes: Vec<String>,
pub language: String,
pub source: Option<String>,
pub kind: String, // 'f' = function, 'p' = procedure
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TriggerInfo {
pub name: String,
pub schema: String,
pub table_schema: String,
pub table_name: String,
pub event_manipulation: String,
pub action_timing: String,
pub action_orientation: String,
pub action_statement: String,
pub enabled: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SequenceInfo {
pub name: String,
pub schema: String,
pub start_value: String,
pub min_value: String,
pub max_value: String,
pub increment: String,
pub current_value: String,
pub cycle: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnumInfo {
pub name: String,
pub schema: String,
pub labels: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtensionInfo {
pub name: String,
pub schema: String,
pub version: String,
pub comment: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObjectSearchHit {
pub name: String,
pub schema: String,
pub object_type: String, // TABLE | VIEW | MATERIALIZED VIEW | FUNCTION | PROCEDURE | TRIGGER | SEQUENCE | ENUM | EXTENSION | INDEX | CONSTRAINT
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DependencyInfo {
pub deptype: String, // "n" (normal) | "a" (auto)
pub class: String, // pg_class | pg_proc | pg_trigger | pg_type | pg_constraint | pg_rewrite
pub name: String, // resolved dependent object name
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoleMembership {
pub role: String,
pub member: String,
pub grantor: String,
pub admin_option: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoleInfo {
pub name: String,
pub superuser: bool,
pub inherit: bool,
pub create_db: bool,
pub create_role: bool,
pub can_login: bool,
pub replication: bool,
pub bypass_rls: bool,
pub connection_limit: i64,
pub valid_until: Option<String>,
pub memberships: Vec<RoleMembership>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrivilegeEntry {
pub object_class: String, // "table" | "sequence" | "routine" | "schema" | "database"
pub schema: Option<String>,
pub name: String,
pub privileges: Vec<String>,
pub grantable: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RebuildReadiness {
pub ok: bool,
pub reasons: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MaintenanceResult {
pub duration_ms: i64,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TablespaceInfo {
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Change {
Update {
id: String,
schema: String,
table: String,
primary_key: String,
old_data: String,
new_data: String,
},
Insert {
id: String,
schema: String,
table: String,
data: String,
},
Delete {
id: String,
schema: String,
table: String,
primary_key: String,
},
AlterTable {
id: String,
schema: String,
table: String,
sql: String,
rollback_sql: String,
},
BulkInsert {
id: String,
schema: String,
table: String,
columns: Vec<String>,
rows: Vec<Vec<serde_json::Value>>,
},
DropTable {
id: String,
schema: String,
table: String,
},
EmptyTable {
id: String,
schema: String,
table: String,
},
Ddl {
id: String,
sql: String,
},
RebuildTable {
id: String,
sql: String,
},
}
impl Change {
pub fn id(&self) -> &str {
match self {
Change::Update { id, .. }
| Change::Insert { id, .. }
| Change::Delete { id, .. }
| Change::AlterTable { id, .. }
| Change::BulkInsert { id, .. }
| Change::DropTable { id, .. }
| Change::EmptyTable { id, .. }
| Change::Ddl { id, .. }
| Change::RebuildTable { id, .. } => id,
}
}
}
/// Complete schema graph for the ER diagram visualizer.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchemaGraph {
pub tables: Vec<TableNode>,
pub relationships: Vec<Relationship>,
}
/// A table node in the schema graph, including all columns.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableNode {
pub name: String,
pub schema: String,
pub table_type: String,
pub columns: Vec<GraphColumn>,
}
/// Column metadata for schema graph visualization.
///
/// Includes PK/FK/UNIQUE flags and an optional foreign-key reference
/// (referenced_schema, referenced_table, referenced_column).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphColumn {
pub name: String,
pub data_type: String,
pub is_pk: bool,
pub is_fk: bool,
pub is_unique: bool,
pub is_nullable: bool,
pub fk_ref: Option<(String, String, String)>,
}
/// A foreign-key relationship between two tables.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Relationship {
pub source_schema: String,
pub source_table: String,
pub source_column: String,
pub target_schema: String,
pub target_table: String,
pub target_column: String,
/// Inferred cardinality: "1:1", "1:N", or "N:M"
pub cardinality: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn table_info_serialization() {
let info = TableInfo {
name: "users".to_string(),
schema: "public".to_string(),
table_type: "TABLE".to_string(),
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("users"));
assert!(json.contains("public"));
assert!(json.contains("TABLE"));
}
#[test]
fn query_result_can_be_empty() {
let result = QueryResult {
columns: vec![],
rows: vec![],
total_rows: 0,
page: 1,
page_size: 100,
execution_time_ms: None,
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains(r#""rows":[]"#));
}
#[test]
fn change_id_method() {
let update = Change::Update {
id: "chg-1".to_string(),
schema: "public".to_string(),
table: "users".to_string(),
primary_key: "{\"id\": 1}".to_string(),
old_data: "{\"name\": \"old\"}".to_string(),
new_data: "{\"name\": \"new\"}".to_string(),
};
assert_eq!(update.id(), "chg-1");
let inserted = Change::Insert {
id: "chg-2".to_string(),
schema: "public".to_string(),
table: "users".to_string(),
data: "{\"name\": \"alice\"}".to_string(),
};
assert_eq!(inserted.id(), "chg-2");
let deleted = Change::Delete {
id: "chg-3".to_string(),
schema: "public".to_string(),
table: "users".to_string(),
primary_key: "{\"id\": 2}".to_string(),
};
assert_eq!(deleted.id(), "chg-3");
let alter = Change::AlterTable {
id: "chg-4".to_string(),
schema: "public".to_string(),
table: "users".to_string(),
sql: "ALTER TABLE users ADD COLUMN age INT".to_string(),
rollback_sql: "ALTER TABLE users DROP COLUMN age".to_string(),
};
assert_eq!(alter.id(), "chg-4");
}
#[test]
fn change_serde_tag() {
let update = Change::Update {
id: "chg-1".to_string(),
schema: "public".to_string(),
table: "users".to_string(),
primary_key: "{\"id\": 1}".to_string(),
old_data: "{\"name\": \"old\"}".to_string(),
new_data: "{\"name\": \"new\"}".to_string(),
};
let json = serde_json::to_string(&update).unwrap();
assert!(
json.contains(r#""type":"update""#),
"serialized Change::Update should use snake_case tag 'update'; got: {}",
json
);
}
#[test]
fn change_bulk_insert_roundtrip() {
let json = serde_json::json!({
"type": "bulk_insert", "id": "x", "schema": "public", "table": "t",
"columns": ["a", "b"],
"rows": [[1, "y"], [2, "z"]]
});
let c: Change = serde_json::from_value(json).unwrap();
match c {
Change::BulkInsert { columns, rows, .. } => {
assert_eq!(columns, vec!["a".to_string(), "b".to_string()]);
assert_eq!(rows.len(), 2);
}
_ => panic!("expected BulkInsert"),
}
}
#[test]
fn change_drop_and_empty_roundtrip() {
let drop: Change = serde_json::from_value(serde_json::json!({
"type": "drop_table", "id": "d", "schema": "public", "table": "t"
}))
.unwrap();
assert_eq!(drop.id(), "d");
let empty: Change = serde_json::from_value(serde_json::json!({
"type": "empty_table", "id": "e", "schema": "public", "table": "t"
}))
.unwrap();
assert_eq!(empty.id(), "e");
}
#[test]
fn column_info_fk_ref() {
let col = ColumnInfo {
name: "user_id".to_string(),
data_type: "integer".to_string(),
is_nullable: true,
is_pk: false,
is_fk: true,
fk_ref: Some(("users".to_string(), "id".to_string())),
default_value: None,
editable: true,
is_generated: false,
};
let json = serde_json::to_string(&col).unwrap();
assert!(json.contains("user_id"));
assert!(json.contains("users"));
}
#[test]
fn column_info_has_editability_fields() {
let c = ColumnInfo {
name: "id".into(),
data_type: "integer".into(),
is_nullable: false,
is_pk: true,
is_fk: false,
fk_ref: None,
default_value: None,
editable: false,
is_generated: false,
};
let json = serde_json::to_string(&c).unwrap();
assert!(json.contains("\"editable\":false"));
assert!(json.contains("\"is_generated\":false"));
}
#[test]
fn pagination_serialization() {
let pagination = Pagination {
page: 2,
page_size: 50,
total_rows: 250,
};
let json = serde_json::to_string(&pagination).unwrap();
assert!(json.contains(r#""page":2"#));
assert!(json.contains(r#""page_size":50"#));
assert!(json.contains(r#""total_rows":250"#));
}
#[test]
fn function_info_serialization() {
let info = FunctionInfo {
name: "get_user".into(),
schema: "public".into(),
return_type: "TABLE(id integer, name text)".into(),
argument_types: vec!["integer".into()],
argument_names: vec!["p_id".into()],
argument_modes: vec!["IN".into()],
language: "plpgsql".into(),
source: Some("BEGIN RETURN; END;".into()),
kind: "f".into(),
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("get_user"));
assert!(json.contains("plpgsql"));
}
#[test]
fn trigger_info_serialization() {
let info = TriggerInfo {
name: "trg_audit".into(),
schema: "public".into(),
table_schema: "public".into(),
table_name: "users".into(),
event_manipulation: "INSERT".into(),
action_timing: "AFTER".into(),
action_orientation: "ROW".into(),
action_statement: "EXECUTE FUNCTION audit_log()".into(),
enabled: "O".into(),
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("trg_audit"));
}
#[test]
fn sequence_info_serialization() {
let info = SequenceInfo {
name: "users_id_seq".into(),
schema: "public".into(),
start_value: "1".into(),
min_value: "1".into(),
max_value: "9223372036854775807".into(),
increment: "1".into(),
current_value: "42".into(),
cycle: false,
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("users_id_seq"));
}
#[test]
fn enum_info_serialization() {
let info = EnumInfo {
name: "user_role".into(),
schema: "public".into(),
labels: vec!["admin".into(), "editor".into(), "viewer".into()],
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("admin"));
}
#[test]
fn extension_info_serialization() {
let info = ExtensionInfo {
name: "pg_stat_statements".into(),
schema: "public".into(),
version: "1.10".into(),
comment: Some("track SQL statistics".into()),
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("pg_stat_statements"));
}
#[test]
fn schema_graph_serialization() {
let graph = SchemaGraph {
tables: vec![TableNode {
name: "users".into(),
schema: "public".into(),
table_type: "TABLE".into(),
columns: vec![
GraphColumn {
name: "id".into(),
data_type: "integer".into(),
is_pk: true,
is_fk: false,
is_unique: true,
is_nullable: false,
fk_ref: None,
},
GraphColumn {
name: "email".into(),
data_type: "text".into(),
is_pk: false,
is_fk: false,
is_unique: true,
is_nullable: false,
fk_ref: None,
},
],
}],
relationships: vec![Relationship {
source_schema: "public".into(),
source_table: "orders".into(),
source_column: "user_id".into(),
target_schema: "public".into(),
target_table: "users".into(),
target_column: "id".into(),
cardinality: "1:N".into(),
}],
};
let json = serde_json::to_string(&graph).unwrap();
assert!(json.contains("users"), "should contain table name");
assert!(
json.contains("orders"),
"should contain relationship source table"
);
assert!(json.contains("1:N"), "should contain cardinality");
assert!(json.contains("is_pk"), "should contain is_pk field");
assert!(json.contains("is_fk"), "should contain is_fk field");
assert!(json.contains("is_unique"), "should contain is_unique field");
// Round-trip deserialization
let parsed: SchemaGraph = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.tables.len(), 1);
assert_eq!(parsed.tables[0].columns.len(), 2);
assert_eq!(parsed.relationships.len(), 1);
assert_eq!(parsed.relationships[0].cardinality, "1:N");
}
#[test]
fn schema_graph_empty_is_valid() {
let graph = SchemaGraph {
tables: vec![],
relationships: vec![],
};
let json = serde_json::to_string(&graph).unwrap();
let parsed: SchemaGraph = serde_json::from_str(&json).unwrap();
assert!(parsed.tables.is_empty());
assert!(parsed.relationships.is_empty());
}
#[test]
fn graph_column_fk_ref_serialization() {
// fk_ref = None
let col_none = GraphColumn {
name: "name".into(),
data_type: "text".into(),
is_pk: false,
is_fk: false,
is_unique: false,
is_nullable: false,
fk_ref: None,
};
let json = serde_json::to_string(&col_none).unwrap();
assert!(
json.contains("null"),
"fk_ref=None should serialize as null"
);
// fk_ref = Some(...)
let col_some = GraphColumn {
name: "user_id".into(),
data_type: "integer".into(),
is_pk: false,
is_fk: true,
is_unique: false,
is_nullable: false,
fk_ref: Some(("public".into(), "users".into(), "id".into())),
};
let json = serde_json::to_string(&col_some).unwrap();
assert!(json.contains("public"), "should contain referenced schema");
assert!(json.contains("users"), "should contain referenced table");
assert!(json.contains("id"), "should contain referenced column");
}
#[test]
fn object_search_hit_tagged_roundtrip() {
let hit = ObjectSearchHit { name: "users".into(), schema: "public".into(), object_type: "TABLE".into() };
let json = serde_json::to_string(&hit).unwrap();
assert!(json.contains("\"object_type\":\"TABLE\""));
let back: ObjectSearchHit = serde_json::from_str(&json).unwrap();
assert_eq!(back.name, "users");
}
#[test]
fn dependency_info_roundtrip() {
let d = DependencyInfo { deptype: "n".into(), class: "pg_class".into(), name: "v_users".into() };
let json = serde_json::to_string(&d).unwrap();
assert!(json.contains("\"deptype\":\"n\""));
assert!(json.contains("v_users"));
}
#[test]
fn change_rebuild_table_serializes_with_snake_case_tag() {
let change = Change::RebuildTable {
id: "chg-rb1".to_string(),
sql: "CREATE TABLE _t ...".to_string(),
};
let json = serde_json::to_string(&change).unwrap();
assert!(
json.contains(r#""type":"rebuild_table""#),
"RebuildTable should serialize with tag 'rebuild_table'; got: {json}"
);
assert_eq!(change.id(), "chg-rb1");
}
#[test]
fn change_rebuild_table_roundtrip() {
let json = serde_json::json!({
"type": "rebuild_table", "id": "rb", "sql": "SELECT 1"
});
let c: Change = serde_json::from_value(json).unwrap();
match c {
Change::RebuildTable { sql, .. } => assert_eq!(sql, "SELECT 1"),
_ => panic!("expected RebuildTable"),
}
}
#[test]
fn role_info_roundtrip() {
let r = RoleInfo {
name: "app".into(), superuser: false, inherit: true, create_db: false,
create_role: false, can_login: true, replication: false, bypass_rls: false,
connection_limit: -1, valid_until: None, memberships: vec![RoleMembership {
role: "parent".into(), member: "app".into(), grantor: "admin".into(), admin_option: false,
}],
};
let json = serde_json::to_string(&r).unwrap();
assert!(json.contains(r#""name":"app""#));
assert!(json.contains(r#""can_login":true"#));
let back: RoleInfo = serde_json::from_str(&json).unwrap();
assert_eq!(back.memberships.len(), 1);
}
#[test]
fn privilege_entry_and_readiness_roundtrip() {
let pe = PrivilegeEntry {
object_class: "table".into(), schema: Some("public".into()), name: "users".into(),
privileges: vec!["SELECT".into(), "INSERT".into()], grantable: false,
};
assert!(serde_json::to_string(&pe).unwrap().contains(r#""object_class":"table""#));
let rr = RebuildReadiness { ok: false, reasons: vec!["has triggers".into()] };
assert!(serde_json::to_string(&rr).unwrap().contains(r#""ok":false"#));
let mr = MaintenanceResult { duration_ms: 42, message: "ok".into() };
assert!(serde_json::to_string(&mr).unwrap().contains(r#""duration_ms":42"#));
let ts = TablespaceInfo { name: "pg_default".into() };
assert!(serde_json::to_string(&ts).unwrap().contains("pg_default"));
}
}
+18
View File
@@ -0,0 +1,18 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Folder {
pub id: String,
pub name: String,
pub parent_id: Option<String>,
pub tag_ids: Vec<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FolderInput {
pub name: String,
pub parent_id: Option<String>,
pub tag_ids: Option<Vec<String>>,
}
+17
View File
@@ -0,0 +1,17 @@
pub mod backup;
pub mod connection;
pub mod db_viewer;
pub mod folder;
pub mod recent;
pub mod settings;
pub mod ssh;
pub mod tag;
pub use connection::{Connection, ConnectionInput};
#[allow(unused_imports)]
pub use db_viewer::{Change, ColumnInfo, FilterRule, MaintenanceResult, Pagination, PrivilegeEntry, QueryResult, RebuildReadiness, RoleInfo, RoleMembership, SortRule, TableInfo, TablespaceInfo};
pub use folder::{Folder, FolderInput};
pub use recent::RecentConnection;
pub use settings::Settings;
pub use ssh::SshConfig;
pub use tag::{Tag, TagInput};
+7
View File
@@ -0,0 +1,7 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecentConnection {
pub connection_id: String,
pub opened_at: String,
}
+29
View File
@@ -0,0 +1,29 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
pub confirm_before_delete: bool,
pub default_folder_id: Option<String>,
pub theme: String,
pub font_size: String,
pub default_ports: HashMap<String, Option<i64>>,
pub tag_order: Option<String>,
pub table_refresh_rate: i64,
pub table_page_size: i64,
pub shortcuts: HashMap<String, String>,
pub accent_color: String,
// Editor (Plan A)
pub editor_font_size: i64,
pub editor_font_family: String,
pub editor_word_wrap: String,
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,
}
+39
View File
@@ -0,0 +1,39 @@
use serde::{Deserialize, Serialize};
/// SSH tunnel configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SshConfig {
pub host: String,
pub port: u16,
pub user: String,
/// "password" or "key"
pub auth_method: String,
pub password: Option<String>,
pub private_key_path: Option<String>,
pub passphrase: Option<String>,
}
impl SshConfig {
/// Create a new `SshConfig` with the required fields.
pub fn new(host: String, port: u16, user: String, auth_method: String) -> Self {
SshConfig {
host,
port,
user,
auth_method,
password: None,
private_key_path: None,
passphrase: None,
}
}
/// Validate SSH configuration.
///
/// Returns `true` if:
/// - `host` is not empty
/// - `port` is in range 1..=65535 (u16 guarantees <= 65535)
/// - `user` is not empty
pub fn is_valid(&self) -> bool {
!self.host.is_empty() && self.port >= 1 && !self.user.is_empty()
}
}
+15
View File
@@ -0,0 +1,15 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tag {
pub id: String,
pub name: String,
pub color: String,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TagInput {
pub name: String,
pub color: String,
}
+618
View File
@@ -0,0 +1,618 @@
use rusqlite::Connection;
/// All new-column additions for the connections table since version 1.
const CONNECTION_COLUMNS_V2: &[(&str, &str)] = &[
("database", "TEXT"),
("ssh_host", "TEXT"),
("ssh_port", "INTEGER"),
("ssh_user", "TEXT"),
("ssh_auth_method", "TEXT"),
("ssh_private_key_path", "TEXT"),
("ssl_mode", "TEXT"),
("ssl_ca_path", "TEXT"),
("ssl_cert_path", "TEXT"),
("ssl_key_path", "TEXT"),
];
/// New columns added since version 2.
const CONNECTION_COLUMNS_V3: &[(&str, &str)] = &[("environment", "TEXT")];
/// New columns added in version 7.
const CONNECTION_COLUMNS_V7: &[(&str, &str)] = &[("favorite", "INTEGER NOT NULL DEFAULT 0")];
pub fn run_migrations(conn: &Connection) -> Result<(), String> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY);
CREATE TABLE IF NOT EXISTS folders (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
parent_id TEXT REFERENCES folders(id) ON DELETE SET NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS connections (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
db_type TEXT NOT NULL,
host TEXT NOT NULL,
port INTEGER,
username TEXT,
database TEXT,
folder_id TEXT REFERENCES folders(id) ON DELETE SET NULL,
keychain_ref TEXT,
ssh_host TEXT,
ssh_port INTEGER,
ssh_user TEXT,
ssh_auth_method TEXT,
ssh_private_key_path TEXT,
ssl_mode TEXT,
ssl_ca_path TEXT,
ssl_cert_path TEXT,
ssl_key_path TEXT,
environment TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tags (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
color TEXT NOT NULL DEFAULT '#8b5cf6',
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS connection_tags (
connection_id TEXT NOT NULL REFERENCES connections(id) ON DELETE CASCADE,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (connection_id, tag_id)
);
CREATE TABLE IF NOT EXISTS folder_tags (
folder_id TEXT NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (folder_id, tag_id)
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);",
)
.map_err(|e| e.to_string())?;
// --- Version-specific migrations ----------------------------------------
let current_ver: i64 = conn
.query_row(
"SELECT COALESCE(MAX(version), 0) FROM schema_version",
[],
|row| row.get(0),
)
.unwrap_or(0);
if current_ver < 2 {
// Discover which columns the connections table already has.
let existing: Vec<String> = {
let mut stmt = conn
.prepare("PRAGMA table_info(connections)")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| row.get::<_, String>(1))
.map_err(|e| e.to_string())?;
rows.filter_map(|r| r.ok()).collect()
};
for (col_name, col_type) in CONNECTION_COLUMNS_V2 {
if !existing.contains(&col_name.to_string()) {
let sql = format!(
"ALTER TABLE connections ADD COLUMN {} {}",
col_name, col_type
);
conn.execute(&sql, []).map_err(|e| e.to_string())?;
}
}
// Record the migration.
conn.execute("INSERT INTO schema_version (version) VALUES (2)", [])
.map_err(|e| e.to_string())?;
}
if current_ver < 3 {
let existing: Vec<String> = {
let mut stmt = conn
.prepare("PRAGMA table_info(connections)")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| row.get::<_, String>(1))
.map_err(|e| e.to_string())?;
rows.filter_map(|r| r.ok()).collect()
};
for (col_name, col_type) in CONNECTION_COLUMNS_V3 {
if !existing.contains(&col_name.to_string()) {
let sql = format!(
"ALTER TABLE connections ADD COLUMN {} {}",
col_name, col_type
);
conn.execute(&sql, []).map_err(|e| e.to_string())?;
}
}
conn.execute("INSERT INTO schema_version (version) VALUES (3)", [])
.map_err(|e| e.to_string())?;
}
// v4: backup_history
if current_ver < 4 {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS backup_history (
id TEXT PRIMARY KEY,
connection_id TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('dump', 'restore', 'sync')),
format TEXT,
file_path TEXT,
source_connection_id TEXT,
status TEXT NOT NULL DEFAULT 'running'
CHECK(status IN ('running', 'completed', 'failed', 'cancelled')),
error_message TEXT,
size_bytes INTEGER,
started_at TEXT NOT NULL,
completed_at TEXT
);",
)
.map_err(|e| e.to_string())?;
conn.execute("INSERT INTO schema_version (version) VALUES (4)", [])
.map_err(|e| e.to_string())?;
}
// v5: query_history
if current_ver < 5 {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS query_history (
id TEXT PRIMARY KEY,
connection_id TEXT NOT NULL,
query_text TEXT NOT NULL,
execution_time_ms INTEGER,
row_count INTEGER,
status TEXT NOT NULL CHECK(status IN ('success', 'error')),
error_message TEXT,
executed_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (connection_id) REFERENCES connections(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_query_history_connection
ON query_history(connection_id, executed_at DESC);",
)
.map_err(|e| e.to_string())?;
conn.execute("INSERT INTO schema_version (version) VALUES (5)", [])
.map_err(|e| e.to_string())?;
}
// v6: query history favorites + saved queries
if current_ver < 6 {
conn.execute_batch(
"ALTER TABLE query_history ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0;
CREATE TABLE IF NOT EXISTS queries (
id TEXT PRIMARY KEY,
connection_id TEXT,
name TEXT NOT NULL,
query_text TEXT NOT NULL,
folder TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (connection_id) REFERENCES connections(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_queries_connection ON queries(connection_id);
CREATE INDEX IF NOT EXISTS idx_queries_folder ON queries(folder);",
)
.map_err(|e| e.to_string())?;
conn.execute("INSERT INTO schema_version (version) VALUES (6)", [])
.map_err(|e| e.to_string())?;
}
// v7: connection favorites + recent_connections
if current_ver < 7 {
let existing: Vec<String> = {
let mut stmt = conn
.prepare("PRAGMA table_info(connections)")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| row.get::<_, String>(1))
.map_err(|e| e.to_string())?;
rows.filter_map(|r| r.ok()).collect()
};
for (col_name, col_type) in CONNECTION_COLUMNS_V7 {
if !existing.contains(&col_name.to_string()) {
let sql = format!(
"ALTER TABLE connections ADD COLUMN {} {}",
col_name, col_type
);
conn.execute(&sql, []).map_err(|e| e.to_string())?;
}
}
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS recent_connections (
connection_id TEXT PRIMARY KEY REFERENCES connections(id) ON DELETE CASCADE,
opened_at TEXT NOT NULL
);",
)
.map_err(|e| e.to_string())?;
conn.execute("INSERT INTO schema_version (version) VALUES (7)", [])
.map_err(|e| e.to_string())?;
}
// v8: use_keychain opt-out flag on connections
if current_ver < 8 {
let existing: Vec<String> = {
let mut stmt = conn
.prepare("PRAGMA table_info(connections)")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| row.get::<_, String>(1))
.map_err(|e| e.to_string())?;
rows.filter_map(|r| r.ok()).collect()
};
if !existing.iter().any(|c| c == "use_keychain") {
conn.execute(
"ALTER TABLE connections ADD COLUMN use_keychain INTEGER NOT NULL DEFAULT 1",
[],
)
.map_err(|e| e.to_string())?;
}
conn.execute("INSERT INTO schema_version (version) VALUES (8)", [])
.map_err(|e| e.to_string())?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
fn fresh_db() -> Connection {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
conn
}
#[test]
fn migrations_create_all_tables() {
let conn = fresh_db();
let tables: Vec<String> = conn
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.unwrap()
.query_map([], |row| row.get(0))
.unwrap()
.filter_map(|r| r.ok())
.collect();
assert!(tables.contains(&"folders".to_string()));
assert!(tables.contains(&"connections".to_string()));
assert!(tables.contains(&"tags".to_string()));
assert!(tables.contains(&"connection_tags".to_string()));
assert!(tables.contains(&"settings".to_string()));
assert!(tables.contains(&"schema_version".to_string()));
}
#[test]
fn v4_creates_backup_history_table() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM backup_history", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 0);
}
#[test]
fn migrations_are_idempotent() {
let conn = fresh_db();
// Running again must not error
run_migrations(&conn).unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM schema_version", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 7);
}
#[test]
fn migration_v8_adds_use_keychain_column_default_1() {
let conn = Connection::open_in_memory().unwrap();
// v7 baseline
run_migrations(&conn).unwrap();
// simulate an existing connection row (pre-v8 shape had no use_keychain)
conn.execute(
"INSERT INTO connections (id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, created_at, updated_at) VALUES ('c1','n','postgresql','h',5432,'u','d',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'t','t','t')",
[],
)
.unwrap();
run_migrations(&conn).unwrap();
let uses: i64 = conn
.query_row("SELECT use_keychain FROM connections WHERE id='c1'", [], |r| r.get(0))
.unwrap();
assert_eq!(uses, 1, "existing connections default to use_keychain=1 (ON)");
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM schema_version WHERE version=8", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 1, "schema_version row 8 inserted exactly once");
}
#[test]
fn migration_v8_is_idempotent() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
run_migrations(&conn).unwrap();
let v8: i64 = conn
.query_row("SELECT COUNT(*) FROM schema_version WHERE version=8", [], |r| r.get(0))
.unwrap();
assert_eq!(v8, 1);
// PRAGMA confirms exactly one use_keychain column
let cols: Vec<String> = conn
.prepare("PRAGMA table_info(connections)")
.unwrap()
.query_map([], |r| r.get::<_, String>(1))
.unwrap()
.filter_map(Result::ok)
.collect();
assert_eq!(cols.iter().filter(|c| c == &"use_keychain").count(), 1);
}
#[test]
fn v5_creates_query_history_table() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
// Verify the table exists
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM query_history", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 0);
// Verify columns via PRAGMA
let columns: Vec<String> = {
let mut stmt = conn.prepare("PRAGMA table_info(query_history)").unwrap();
let rows = stmt.query_map([], |row| row.get::<_, String>(1)).unwrap();
rows.filter_map(|r| r.ok()).collect()
};
assert!(columns.contains(&"id".to_string()));
assert!(columns.contains(&"connection_id".to_string()));
assert!(columns.contains(&"query_text".to_string()));
assert!(columns.contains(&"execution_time_ms".to_string()));
assert!(columns.contains(&"row_count".to_string()));
assert!(columns.contains(&"status".to_string()));
assert!(columns.contains(&"error_message".to_string()));
assert!(columns.contains(&"executed_at".to_string()));
}
#[test]
fn query_history_cascades_on_connection_delete() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
// Insert a connection
let conn_id = "test-conn-id";
conn.execute(
"INSERT INTO connections (id, name, db_type, host, port, created_at, updated_at) VALUES (?1, 't', 'postgresql', 'h', 5432, datetime('now'), datetime('now'))",
rusqlite::params![conn_id],
).unwrap();
// Insert query history entry
conn.execute(
"INSERT INTO query_history (id, connection_id, query_text, status, executed_at) VALUES ('qh1', ?1, 'SELECT 1', 'success', datetime('now'))",
rusqlite::params![conn_id],
).unwrap();
// Delete connection — should cascade
conn.execute(
"DELETE FROM connections WHERE id = ?1",
rusqlite::params![conn_id],
)
.unwrap();
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM query_history WHERE connection_id = ?1",
rusqlite::params![conn_id],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 0);
}
#[test]
fn v6_adds_favorite_column_to_query_history() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
// Verify the favorite column exists via PRAGMA
let columns: Vec<String> = {
let mut stmt = conn.prepare("PRAGMA table_info(query_history)").unwrap();
let rows = stmt.query_map([], |row| row.get::<_, String>(1)).unwrap();
rows.filter_map(|r| r.ok()).collect()
};
assert!(
columns.contains(&"favorite".to_string()),
"Expected query_history to have a 'favorite' column after v6 migration"
);
// Existing rows default to 0
let fav: i64 = conn
.query_row(
"SELECT COUNT(*) FROM query_history WHERE favorite != 0",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(fav, 0);
}
#[test]
fn v6_creates_queries_table() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
// Table exists
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM queries", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 0);
// Column check
let columns: Vec<String> = {
let mut stmt = conn.prepare("PRAGMA table_info(queries)").unwrap();
let rows = stmt.query_map([], |row| row.get::<_, String>(1)).unwrap();
rows.filter_map(|r| r.ok()).collect()
};
for c in &[
"id",
"connection_id",
"name",
"query_text",
"folder",
"created_at",
"updated_at",
] {
assert!(
columns.contains(&c.to_string()),
"Expected queries table to have column: {}",
c
);
}
}
#[test]
fn v6_queries_cascade_on_connection_delete() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
// Insert a connection
let conn_id = "test-qc-conn";
conn.execute(
"INSERT INTO connections (id, name, db_type, host, port, created_at, updated_at) VALUES (?1, 't', 'postgresql', 'h', 5432, datetime('now'), datetime('now'))",
rusqlite::params![conn_id],
).unwrap();
// Insert a saved query for that connection
conn.execute(
"INSERT INTO queries (id, connection_id, name, query_text, folder) VALUES ('q1', ?1, 'my query', 'SELECT 1', '')",
rusqlite::params![conn_id],
).unwrap();
// Insert a global saved query (connection_id NULL)
conn.execute(
"INSERT INTO queries (id, connection_id, name, query_text, folder) VALUES ('q2', NULL, 'global query', 'SELECT 2', '')",
[],
).unwrap();
// Delete connection — should cascade the non-NULL row
conn.execute(
"DELETE FROM connections WHERE id = ?1",
rusqlite::params![conn_id],
)
.unwrap();
let count_scoped: i64 = conn
.query_row(
"SELECT COUNT(*) FROM queries WHERE connection_id = ?1",
rusqlite::params![conn_id],
|r| r.get(0),
)
.unwrap();
assert_eq!(
count_scoped, 0,
"Scoped saved query should be cascade-deleted"
);
// NULL-saved query survives
let count_global: i64 = conn
.query_row("SELECT COUNT(*) FROM queries WHERE id = 'q2'", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(
count_global, 1,
"Global saved query (connection_id NULL) should survive"
);
}
#[test]
fn v6_bumps_schema_version_to_6() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM schema_version WHERE version = 6",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
count, 1,
"Schema version 6 should be recorded after v6 migration"
);
}
#[test]
fn v7_adds_favorite_column_to_connections() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
let columns: Vec<String> = {
let mut stmt = conn.prepare("PRAGMA table_info(connections)").unwrap();
let rows = stmt.query_map([], |row| row.get::<_, String>(1)).unwrap();
rows.filter_map(|r| r.ok()).collect()
};
assert!(
columns.contains(&"favorite".to_string()),
"Expected connections to have a 'favorite' column after v7"
);
let conn_id = "fav-test";
conn.execute(
"INSERT INTO connections (id, name, db_type, host, port, created_at, updated_at) VALUES (?1, 't', 'postgresql', 'h', 5432, datetime('now'), datetime('now'))",
rusqlite::params![conn_id],
).unwrap();
let fav: i64 = conn
.query_row(
"SELECT favorite FROM connections WHERE id = ?1",
rusqlite::params![conn_id],
|r| r.get(0),
)
.unwrap();
assert_eq!(fav, 0, "favorite defaults to 0");
}
#[test]
fn v7_creates_recent_connections_table() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM recent_connections", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 0);
}
#[test]
fn v7_recent_connections_cascade_on_connection_delete() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
let conn_id = "rc-cascade";
conn.execute(
"INSERT INTO connections (id, name, db_type, host, port, created_at, updated_at) VALUES (?1, 't', 'postgresql', 'h', 5432, datetime('now'), datetime('now'))",
rusqlite::params![conn_id],
).unwrap();
conn.execute(
"INSERT INTO recent_connections (connection_id, opened_at) VALUES (?1, datetime('now'))",
rusqlite::params![conn_id],
).unwrap();
conn.execute(
"DELETE FROM connections WHERE id = ?1",
rusqlite::params![conn_id],
)
.unwrap();
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM recent_connections WHERE connection_id = ?1",
rusqlite::params![conn_id],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 0);
}
#[test]
fn v8_bumps_schema_version_to_8() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
let ver: i64 = conn
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
.unwrap();
assert_eq!(ver, 8);
}
}
File diff suppressed because it is too large Load Diff