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
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"
);
}
}