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:
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
@@ -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;
|
||||
@@ -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 O’Brien', '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);
|
||||
@@ -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\""));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user