v0.6.0: release workflow, constraint-aware cell editing, pending-cell styling (#9)

* feat: demo DB seed/regenerate, backup/restore/sync refinements, SSH/SSL polish

- Demo SQLite DB: feature-rich seed (12 objects, 500-row audit log) + regenerate action in Settings
- Backup/restore: headless-testable core logic, psql -f for plain dumps, sync passes --clean --if-exists
- SSH/SSL runtime refinements and connection testing improvements
- Data grid: DataTypeIcon component, FK popover, table tree polish
- Docs: AGENTS.md/README updates, new screenshots, MIT LICENSE

* chore: optimize README screenshots (4.7 MB → 916 KB via pngquant, quality 70-90)

* v0.6.0: release workflow, constraint-aware cell editing, pending-cell styling

- Bump version to 0.6.0 across package.json, Cargo.toml, tauri.conf.json
- Add .github/workflows/release.yml: tag-triggered CI builds macOS (aarch64
  + x64), Windows, and Linux installers into a draft GitHub Release
- README: installer download table (unsigned note, per-platform files),
  "how releases are made" section
- CellEditor: constraint-aware commit — empty input on nullable columns
  becomes NULL, NOT NULL text-like types fall back to empty string, all
  other types blocked with an inline error bubble; replace "Set NULL"
  checkbox with a NULL row in the FK dropdown / empty enum option
- VirtualDataGrid: pending-edit dot → animated pending outline (ring) on
  staged cells; matching test updates
- docs-coverage test: align with rewritten README comparison table
This commit is contained in:
2026-08-04 17:20:14 +08:00
committed by GitHub
parent a9dbf60ed5
commit 002d8345ae
66 changed files with 3591 additions and 1602 deletions
+333 -374
View File
@@ -3,6 +3,37 @@ use tauri::{AppHandle, Emitter, State};
use crate::models::backup::*;
// ---------------------------------------------------------------------------
// Connection params (decoupled from store/keychain so logic is headless-testable)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
pub struct PgConnParams {
pub host: String,
pub port: i64,
pub username: String,
pub database: String,
pub password: String,
}
impl PgConnParams {
pub fn new(
host: String,
port: i64,
username: String,
database: String,
password: String,
) -> Self {
Self {
host,
port,
username,
database,
password,
}
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -16,6 +47,10 @@ fn get_version(tool: &str) -> Option<String> {
.map(|s| s.trim().to_string())
}
fn sanitize_error(s: &str) -> String {
crate::commands::test_connection::sanitize_error(s)
}
// ---------------------------------------------------------------------------
// detect_pg_tools
// ---------------------------------------------------------------------------
@@ -31,9 +66,237 @@ pub fn detect_pg_tools() -> PgToolStatus {
}
// ---------------------------------------------------------------------------
// pg_dump
// Core logic (headless-testable — no Tauri, no store, no keychain)
// ---------------------------------------------------------------------------
/// Builds the base connection args shared by pg_dump and pg_restore.
fn base_conn_args(conn: &PgConnParams) -> Vec<String> {
vec![
format!("--host={}", conn.host),
format!("--port={}", conn.port),
format!("--username={}", conn.username),
format!("--dbname={}", conn.database),
]
}
/// Builds pg_dump args (excluding the --file flag, which is added by the caller).
fn build_dump_args(conn: &PgConnParams, options: &BackupOptions) -> Vec<String> {
let mut args = base_conn_args(conn);
match options.format.as_str() {
"custom" => args.push("--format=c".into()),
"tar" => args.push("--format=t".into()),
"directory" => args.push("--format=d".into()),
_ => {} // "plain" is the default — no format flag needed
}
if options.no_owner {
args.push("--no-owner".into());
}
if let Some(ref schema) = options.schema {
args.push(format!("--schema={schema}"));
}
if let Some(ref tables) = options.tables {
for t in tables {
args.push(format!("--table={t}"));
}
}
args
}
/// Builds pg_restore args (file path is passed positionally by the caller).
fn build_restore_args(conn: &PgConnParams, options: &RestoreOptions) -> Vec<String> {
let mut args = base_conn_args(conn);
match options.format.as_str() {
"custom" => args.push("--format=c".into()),
"tar" => args.push("--format=t".into()),
"directory" => args.push("--format=d".into()),
_ => {}
}
if options.clean {
args.push("--clean".into());
args.push("--if-exists".into());
}
if let Some(ref schema) = options.schema {
args.push(format!("--schema={schema}"));
}
args
}
/// Runs `pg_dump` against `conn`, writing to `options.file_path`.
/// Returns `Ok(())` on success or a sanitized error message.
pub fn run_pg_dump(conn: &PgConnParams, options: &BackupOptions) -> Result<(), String> {
let mut args = build_dump_args(conn, options);
args.push(format!("--file={}", options.file_path));
let result = Command::new("pg_dump")
.env("PGPASSWORD", &conn.password)
.args(&args)
.output();
match result {
Ok(output) if output.status.success() => Ok(()),
Ok(output) => Err(sanitize_error(&String::from_utf8_lossy(&output.stderr))),
Err(e) => Err(e.to_string()),
}
}
/// Runs `pg_restore` against `conn`, reading from `options.file_path`.
/// Returns `Ok(())` on success or a sanitized error message.
///
/// Plain-format dumps are SQL text and cannot be read by `pg_restore` — they
/// are executed with `psql` instead. The `clean` option is only honored for
/// archive formats (custom/tar/directory); the UI disables it for plain.
pub fn run_pg_restore(conn: &PgConnParams, options: &RestoreOptions) -> Result<(), String> {
if options.format == "plain" {
let result = Command::new("psql")
.env("PGPASSWORD", &conn.password)
.args([
format!("--host={}", conn.host),
format!("--port={}", conn.port),
format!("--username={}", conn.username),
format!("--dbname={}", conn.database),
format!("--file={}", options.file_path),
])
.output();
return match result {
Ok(output) if output.status.success() => Ok(()),
Ok(output) => Err(sanitize_error(&String::from_utf8_lossy(&output.stderr))),
Err(e) => Err(e.to_string()),
};
}
let mut args = build_restore_args(conn, options);
args.push(options.file_path.clone());
let result = Command::new("pg_restore")
.env("PGPASSWORD", &conn.password)
.args(&args)
.output();
match result {
Ok(output) if output.status.success() => Ok(()),
Ok(output) => Err(sanitize_error(&String::from_utf8_lossy(&output.stderr))),
Err(e) => Err(e.to_string()),
}
}
/// Runs a DB-to-DB sync: `pg_dump` on `source` piped into `pg_restore` on `target`.
/// Returns `Ok(())` on success or a sanitized error message.
pub fn run_db_sync(
source: &PgConnParams,
target: &PgConnParams,
schema: Option<&str>,
tables: Option<&[String]>,
) -> Result<(), String> {
// --- Build pg_dump args ---
let mut dump_args = base_conn_args(source);
dump_args.push("--format=c".into()); // binary custom format for reliable piping
dump_args.push("--no-owner".into());
if let Some(schema) = schema {
dump_args.push(format!("--schema={schema}"));
}
if let Some(tables) = tables {
for t in tables {
dump_args.push(format!("--table={t}"));
}
}
// --- Build pg_restore args ---
// --clean --if-exists makes sync work into a non-empty target (the UI
// already requires a destructive-overwrite confirmation).
let mut restore_args = base_conn_args(target);
restore_args.push("--no-owner".into());
restore_args.push("--clean".into());
restore_args.push("--if-exists".into());
// --- Spawn pg_dump with piped stdout ---
let mut dump_child = Command::new("pg_dump")
.env("PGPASSWORD", &source.password)
.args(&dump_args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to start pg_dump: {e}"))?;
let dump_stdout = dump_child.stdout.take().unwrap();
let dump_stderr_reader = dump_child.stderr.take().unwrap();
// Read pg_dump stderr in a separate thread so the pipe doesn't block
let dump_stderr_handle = std::thread::spawn(move || {
use std::io::Read;
let mut buf = String::new();
let _ = dump_stderr_reader
.take(10 * 1024 * 1024) // cap at 10 MiB
.read_to_string(&mut buf);
buf
});
// --- Run pg_restore with pg_dump stdout as stdin ---
let restore_result = Command::new("pg_restore")
.env("PGPASSWORD", &target.password)
.args(&restore_args)
.stdin(dump_stdout)
.output();
// Wait for pg_dump to finish
let dump_status = dump_child.wait();
let dump_stderr = dump_stderr_handle.join().unwrap_or_default();
// --- Check results ---
let dump_failed = match dump_status {
Ok(status) => !status.success(),
Err(_) => true,
};
if dump_failed {
return Err(format!("pg_dump failed: {}", sanitize_error(&dump_stderr)));
}
match restore_result {
Ok(output) if output.status.success() => Ok(()),
Ok(output) => Err(format!(
"pg_restore failed: {}",
sanitize_error(&String::from_utf8_lossy(&output.stderr))
)),
Err(e) => Err(format!("pg_restore failed: {e}")),
}
}
// ---------------------------------------------------------------------------
// Tauri commands (thin wrappers: store lookup + keychain + event emission)
// ---------------------------------------------------------------------------
fn emit_result(app_handle: &AppHandle, job_id: &str, result: Result<(), String>) {
let event = match result {
Ok(()) => BackupProgressEvent {
job_id: job_id.to_string(),
status: "completed".into(),
progress: Some(1.0),
output_line: None,
error: None,
},
Err(e) => BackupProgressEvent {
job_id: job_id.to_string(),
status: "failed".into(),
progress: None,
output_line: None,
error: Some(e),
},
};
let _ = app_handle.emit("backup-progress", event);
}
#[tauri::command]
pub async fn pg_dump(
connection_id: String,
@@ -45,10 +308,7 @@ pub async fn pg_dump(
// Get connection from store (scope the std::sync::Mutex lock guard)
let conn = {
let store = state
.db_store
.lock()
.map_err(|e| e.to_string())?;
let store = state.db_store.lock().map_err(|e| e.to_string())?;
let connections = store.get_connections().map_err(|e| e.to_string())?;
connections
.into_iter()
@@ -57,112 +317,30 @@ pub async fn pg_dump(
};
// Get password from keychain
let password = crate::commands::keychain::get_connection_password_internal(
&app_handle,
&connection_id,
)
.unwrap_or_default()
.unwrap_or_default();
let password =
crate::commands::keychain::get_connection_password_internal(&app_handle, &connection_id)
.unwrap_or_default()
.unwrap_or_default();
// Extract connection fields before moving into spawn_blocking
let host = conn.host.clone();
let port = conn.port.unwrap_or(5432);
let username = conn.username.unwrap_or_else(|| "postgres".into());
let database = conn.database.unwrap_or_else(|| "postgres".into());
let file_path = options.file_path.clone();
let format = options.format.clone();
let no_owner = options.no_owner;
let schema = options.schema.clone();
let tables = options.tables.clone();
let params = PgConnParams::new(
conn.host.clone(),
conn.port.unwrap_or(5432),
conn.username.unwrap_or_else(|| "postgres".into()),
conn.database.unwrap_or_else(|| "postgres".into()),
password,
);
let job_id_clone = job_id.clone();
let app_handle_clone = app_handle.clone();
tokio::task::spawn_blocking(move || {
let mut args: Vec<String> = vec![
format!("--host={host}"),
format!("--port={port}"),
format!("--username={username}"),
format!("--dbname={database}"),
];
match format.as_str() {
"custom" => args.push("--format=c".into()),
"tar" => args.push("--format=t".into()),
"directory" => args.push("--format=d".into()),
_ => {} // "plain" is the default — no format flag needed
}
if no_owner {
args.push("--no-owner".into());
}
if let Some(ref schema) = schema {
args.push(format!("--schema={schema}"));
}
if let Some(ref tables) = tables {
for t in tables {
args.push(format!("--table={t}"));
}
}
args.push(format!("--file={file_path}"));
let result = Command::new("pg_dump")
.env("PGPASSWORD", &password)
.args(&args)
.output();
match result {
Ok(output) if output.status.success() => {
let _ = app_handle.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id_clone.clone(),
status: "completed".into(),
progress: Some(1.0),
output_line: None,
error: None,
},
);
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let _ = app_handle.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id_clone.clone(),
status: "failed".into(),
progress: None,
output_line: None,
error: Some(
crate::commands::test_connection::sanitize_error(&stderr),
),
},
);
}
Err(e) => {
let _ = app_handle.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id_clone.clone(),
status: "failed".into(),
progress: None,
output_line: None,
error: Some(e.to_string()),
},
);
}
}
let result = run_pg_dump(&params, &options);
emit_result(&app_handle_clone, &job_id_clone, result);
});
Ok(job_id)
}
// ---------------------------------------------------------------------------
// pg_restore
// ---------------------------------------------------------------------------
#[tauri::command]
pub async fn pg_restore(
connection_id: String,
@@ -173,10 +351,7 @@ pub async fn pg_restore(
let job_id = uuid::Uuid::new_v4().to_string();
let conn = {
let store = state
.db_store
.lock()
.map_err(|e| e.to_string())?;
let store = state.db_store.lock().map_err(|e| e.to_string())?;
let connections = store.get_connections().map_err(|e| e.to_string())?;
connections
.into_iter()
@@ -184,105 +359,30 @@ pub async fn pg_restore(
.ok_or_else(|| format!("Connection not found: {connection_id}"))?
};
let password = crate::commands::keychain::get_connection_password_internal(
&app_handle,
&connection_id,
)
.unwrap_or_default()
.unwrap_or_default();
let password =
crate::commands::keychain::get_connection_password_internal(&app_handle, &connection_id)
.unwrap_or_default()
.unwrap_or_default();
let host = conn.host.clone();
let port = conn.port.unwrap_or(5432);
let username = conn.username.unwrap_or_else(|| "postgres".into());
let database = conn.database.unwrap_or_else(|| "postgres".into());
let file_path = options.file_path.clone();
let format = options.format.clone();
let clean = options.clean;
let schema = options.schema.clone();
let params = PgConnParams::new(
conn.host.clone(),
conn.port.unwrap_or(5432),
conn.username.unwrap_or_else(|| "postgres".into()),
conn.database.unwrap_or_else(|| "postgres".into()),
password,
);
let job_id_clone = job_id.clone();
let app_handle_clone = app_handle.clone();
tokio::task::spawn_blocking(move || {
let mut args: Vec<String> = vec![
format!("--host={host}"),
format!("--port={port}"),
format!("--username={username}"),
format!("--dbname={database}"),
];
match format.as_str() {
"custom" => args.push("--format=c".into()),
"tar" => args.push("--format=t".into()),
"directory" => args.push("--format=d".into()),
_ => {}
}
if clean {
args.push("--clean".into());
args.push("--if-exists".into());
}
if let Some(ref schema) = schema {
args.push(format!("--schema={schema}"));
}
args.push(file_path.clone());
let result = Command::new("pg_restore")
.env("PGPASSWORD", &password)
.args(&args)
.output();
match result {
Ok(output) if output.status.success() => {
let _ = app_handle.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id_clone.clone(),
status: "completed".into(),
progress: Some(1.0),
output_line: None,
error: None,
},
);
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let _ = app_handle.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id_clone.clone(),
status: "failed".into(),
progress: None,
output_line: None,
error: Some(
crate::commands::test_connection::sanitize_error(&stderr),
),
},
);
}
Err(e) => {
let _ = app_handle.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id_clone.clone(),
status: "failed".into(),
progress: None,
output_line: None,
error: Some(e.to_string()),
},
);
}
}
let result = run_pg_restore(&params, &options);
emit_result(&app_handle_clone, &job_id_clone, result);
});
Ok(job_id)
}
// ---------------------------------------------------------------------------
// db_sync (pg_dump | pg_restore via Unix pipe)
// ---------------------------------------------------------------------------
#[tauri::command]
pub async fn db_sync(
options: SyncOptions,
@@ -293,10 +393,7 @@ pub async fn db_sync(
// Get both connections from store
let (source_conn, target_conn) = {
let store = state
.db_store
.lock()
.map_err(|e| e.to_string())?;
let store = state.db_store.lock().map_err(|e| e.to_string())?;
let connections = store.get_connections().map_err(|e| e.to_string())?;
let src = connections
@@ -325,190 +422,52 @@ pub async fn db_sync(
};
// Get passwords
let src_password = crate::commands::keychain::get_connection_password_internal(
&app_handle,
&source_conn.id,
)
.unwrap_or_default()
.unwrap_or_default();
let src_password =
crate::commands::keychain::get_connection_password_internal(&app_handle, &source_conn.id)
.unwrap_or_default()
.unwrap_or_default();
let tgt_password = crate::commands::keychain::get_connection_password_internal(
&app_handle,
&target_conn.id,
)
.unwrap_or_default()
.unwrap_or_default();
let tgt_password =
crate::commands::keychain::get_connection_password_internal(&app_handle, &target_conn.id)
.unwrap_or_default()
.unwrap_or_default();
// Extract connection fields
let src_host = source_conn.host.clone();
let src_port = source_conn.port.unwrap_or(5432);
let src_username = source_conn
.username
.clone()
.unwrap_or_else(|| "postgres".into());
let src_database = source_conn
.database
.clone()
.unwrap_or_else(|| "postgres".into());
let source = PgConnParams::new(
source_conn.host.clone(),
source_conn.port.unwrap_or(5432),
source_conn
.username
.clone()
.unwrap_or_else(|| "postgres".into()),
source_conn
.database
.clone()
.unwrap_or_else(|| "postgres".into()),
src_password,
);
let tgt_host = target_conn.host.clone();
let tgt_port = target_conn.port.unwrap_or(5432);
let tgt_username = target_conn
.username
.clone()
.unwrap_or_else(|| "postgres".into());
let tgt_database = target_conn
.database
.clone()
.unwrap_or_else(|| "postgres".into());
let target = PgConnParams::new(
target_conn.host.clone(),
target_conn.port.unwrap_or(5432),
target_conn
.username
.clone()
.unwrap_or_else(|| "postgres".into()),
target_conn
.database
.clone()
.unwrap_or_else(|| "postgres".into()),
tgt_password,
);
let schema = options.schema.clone();
let tables = options.tables.clone();
let job_id_clone = job_id.clone();
let app_handle_clone = app_handle.clone();
tokio::task::spawn_blocking(move || {
// --- Build pg_dump args ---
let mut dump_args: Vec<String> = vec![
format!("--host={src_host}"),
format!("--port={src_port}"),
format!("--username={src_username}"),
format!("--dbname={src_database}"),
"--format=c".into(), // binary custom format for reliable piping
"--no-owner".into(),
];
if let Some(ref schema) = schema {
dump_args.push(format!("--schema={schema}"));
}
if let Some(ref tables) = tables {
for t in tables {
dump_args.push(format!("--table={t}"));
}
}
// --- Build pg_restore args ---
let restore_args: Vec<String> = vec![
format!("--host={tgt_host}"),
format!("--port={tgt_port}"),
format!("--username={tgt_username}"),
format!("--dbname={tgt_database}"),
"--no-owner".into(),
];
// --- Spawn pg_dump with piped stdout ---
let mut dump_child = match Command::new("pg_dump")
.env("PGPASSWORD", &src_password)
.args(&dump_args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(child) => child,
Err(e) => {
let _ = app_handle.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id_clone.clone(),
status: "failed".into(),
progress: None,
output_line: None,
error: Some(format!("Failed to start pg_dump: {e}")),
},
);
return;
}
};
let dump_stdout = dump_child.stdout.take().unwrap();
let dump_stderr_reader = dump_child.stderr.take().unwrap();
// Read pg_dump stderr in a separate thread so the pipe doesn't block
let dump_stderr_handle = std::thread::spawn(move || {
use std::io::Read;
let mut buf = String::new();
let _ = dump_stderr_reader
.take(10 * 1024 * 1024) // cap at 10 MiB
.read_to_string(&mut buf);
buf
});
// --- Run pg_restore with pg_dump stdout as stdin ---
let restore_result = Command::new("pg_restore")
.env("PGPASSWORD", &tgt_password)
.args(&restore_args)
.stdin(dump_stdout)
.output();
// Wait for pg_dump to finish
let dump_status = dump_child.wait();
let dump_stderr = dump_stderr_handle.join().unwrap_or_default();
// --- Check results ---
let dump_failed = match dump_status {
Ok(status) => !status.success(),
Err(_) => true,
};
if dump_failed {
let _ = app_handle.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id_clone.clone(),
status: "failed".into(),
progress: None,
output_line: None,
error: Some(format!(
"pg_dump failed: {}",
crate::commands::test_connection::sanitize_error(&dump_stderr)
)),
},
);
return;
}
match restore_result {
Ok(output) if output.status.success() => {
let _ = app_handle.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id_clone.clone(),
status: "completed".into(),
progress: Some(1.0),
output_line: None,
error: None,
},
);
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let _ = app_handle.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id_clone.clone(),
status: "failed".into(),
progress: None,
output_line: None,
error: Some(format!(
"pg_restore failed: {}",
crate::commands::test_connection::sanitize_error(&stderr)
)),
},
);
}
Err(e) => {
let _ = app_handle.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id_clone.clone(),
status: "failed".into(),
progress: None,
output_line: None,
error: Some(format!("pg_restore failed: {e}")),
},
);
}
}
let result = run_db_sync(&source, &target, schema.as_deref(), tables.as_deref());
emit_result(&app_handle_clone, &job_id_clone, result);
});
Ok(job_id)
@@ -574,4 +533,4 @@ pub(crate) fn build_args_for_test(
#[cfg(test)]
#[path = "backup.test.rs"]
mod tests;
mod tests;
+207 -1
View File
@@ -185,4 +185,210 @@ fn backup_progress_event_failed() {
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).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).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).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).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).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);
}
+2 -6
View File
@@ -20,11 +20,7 @@ fn validate(input: &ConnectionInput) -> Result<(), String> {
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(),
)
}
_ => return Err("port must be an integer between 1 and 65535 for this db_type".into()),
}
}
if let Some(u) = &input.username {
@@ -358,4 +354,4 @@ mod tests {
clear_recent_connections_inner(&st).unwrap();
assert_eq!(get_recent_connections_inner(&st, 10).unwrap().len(), 0);
}
}
}
+298 -134
View File
@@ -5,8 +5,8 @@
use crate::db::pool::{DbConfig, DbHandle};
use crate::models::db_viewer::{
Change, ColumnInfo, ConstraintInfo, EnumInfo, ExtensionInfo, FunctionInfo,
IndexInfo, QueryResult, SequenceInfo, TableInfo, TriggerInfo,
Change, ColumnInfo, ConstraintInfo, EnumInfo, ExtensionInfo, FunctionInfo, IndexInfo,
QueryResult, SequenceInfo, TableInfo, TriggerInfo,
};
use std::collections::HashMap;
use tauri::State;
@@ -43,11 +43,7 @@ fn redact_secrets(s: &str) -> String {
|| s[i..].to_lowercase().starts_with("postgresql://")
{
// Skip the scheme.
let scheme_end = i
+ s[i..]
.find("://")
.unwrap_or(0)
+ 3;
let scheme_end = i + s[i..].find("://").unwrap_or(0) + 3;
out.push_str("[redacted-url://");
// Find end of authority (next '/'. '/', or end).
let rest = &s[scheme_end..];
@@ -175,7 +171,9 @@ pub fn get_pg_ddl_via_dump(
password: &str,
) -> Result<String, String> {
if !pg_dump_available() {
return Err("pg_dump not found. Install PostgreSQL client tools to copy table schema.".into());
return Err(
"pg_dump not found. Install PostgreSQL client tools to copy table schema.".into(),
);
}
let mut cmd = std::process::Command::new("pg_dump");
cmd.args([
@@ -186,7 +184,9 @@ pub fn get_pg_ddl_via_dump(
]);
cmd.args(build_pg_dump_ddl_args(schema, table));
cmd.env("PGPASSWORD", password);
let out = cmd.output().map_err(|e| format!("pg_dump spawn failed: {e}"))?;
let out = cmd
.output()
.map_err(|e| format!("pg_dump spawn failed: {e}"))?;
if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).to_string());
}
@@ -249,7 +249,9 @@ fn build_pg_filter_clause(
}
/// Build a WHERE clause from filter rules for SQLite (positional ? params).
fn build_sqlite_filter_clause(filters: &[crate::models::db_viewer::FilterRule]) -> (String, Vec<String>) {
fn build_sqlite_filter_clause(
filters: &[crate::models::db_viewer::FilterRule],
) -> (String, Vec<String>) {
let mut clauses = String::new();
let mut params: Vec<String> = Vec::new();
@@ -331,39 +333,53 @@ pub(crate) fn pg_char_to_att(value: Option<i8>) -> String {
}
/// Assemble a PG SELECT statement from pre-formatted select items (already
/// quoted and optionally `::text`-cast), appending `ctid` when the table has
/// no primary key so later UPDATE/DELETE queue changes can target the exact
/// row. `ctid` is appended last so it does not shift visible column order.
fn build_pg_select_from_items(schema: &str, table: &str, items: Vec<String>, has_pk: bool) -> String {
/// quoted and optionally `::text`-cast), appending `ctid` when `append_locator`
/// is true (a no-PK *physical table*) so later UPDATE/DELETE queue changes can
/// target the exact row. Views expose no `ctid`, so callers must pass `false`
/// for them. `ctid` is appended last so it does not shift visible column order.
fn build_pg_select_from_items(
schema: &str,
table: &str,
items: Vec<String>,
append_locator: bool,
) -> String {
let mut all_cols = items;
if !has_pk {
if append_locator {
all_cols.push("ctid".to_string());
}
format!("SELECT {} FROM \"{}\".\"{}\"", all_cols.join(", "), schema, table)
format!(
"SELECT {} FROM \"{}\".\"{}\"",
all_cols.join(", "),
schema,
table
)
}
/// Build the PG data SELECT, appending `ctid` only when the table has no PK.
/// Build the PG data SELECT, appending `ctid` only when `append_locator` is
/// true (no-PK physical tables). Never true for views.
pub(crate) fn build_pg_data_select(
schema: &str,
table: &str,
visible_cols: &[String],
has_pk: bool,
append_locator: bool,
) -> String {
let base_cols: Vec<String> = visible_cols.iter().map(|c| format!("\"{}\"", c)).collect();
build_pg_select_from_items(schema, table, base_cols, has_pk)
build_pg_select_from_items(schema, table, base_cols, append_locator)
}
/// Build the SQLite data SELECT, appending `rowid` only when the table has no
/// PK. The table is unqualified; SQLite browsing in this app is always scoped
/// to the `main` schema, where an unqualified name resolves identically.
/// Build the SQLite data SELECT, appending `rowid` when `append_locator` is
/// true (a no-PK *physical table*) so later UPDATE/DELETE queue changes can
/// target the exact row. Views expose no `rowid`, so callers must pass `false`
/// for them. The table is unqualified; SQLite browsing in this app is always
/// scoped to the `main` schema, where an unqualified name resolves identically.
pub(crate) fn build_sqlite_data_select(
table: &str,
visible_cols: &[String],
has_pk: bool,
append_locator: bool,
) -> String {
let base_cols: Vec<String> = visible_cols.iter().map(|c| format!("\"{}\"", c)).collect();
let mut all_cols = base_cols;
if !has_pk {
if append_locator {
all_cols.push("rowid".to_string());
}
format!("SELECT {} FROM \"{}\"", all_cols.join(", "), table)
@@ -683,7 +699,10 @@ pub async fn apply_bulk_insert_pg(
rows: &[Vec<serde_json::Value>],
) -> Result<usize, String> {
let sql = build_pg_bulk_insert_sql(schema, table, columns);
client.batch_execute("BEGIN").await.map_err(|e| e.to_string())?;
client
.batch_execute("BEGIN")
.await
.map_err(|e| e.to_string())?;
let mut count = 0;
for (i, row) in rows.iter().enumerate() {
let boxed: Vec<Box<dyn ToSql + Send + Sync>> = row.iter().map(pg_box_value).collect();
@@ -721,7 +740,10 @@ fn parse_json_pairs(json: &str) -> Result<Vec<(String, serde_json::Value)>, Stri
let obj = v
.as_object()
.ok_or_else(|| "change JSON must be an object".to_string())?;
Ok(obj.iter().map(|(k, val)| (k.clone(), val.clone())).collect())
Ok(obj
.iter()
.map(|(k, val)| (k.clone(), val.clone()))
.collect())
}
///
/// Each inner `Vec<serde_json::Value>` represents one row, where the values
@@ -768,13 +790,8 @@ fn sqlite_value_to_json(row: &rusqlite::Row, i: usize) -> serde_json::Value {
Ok(ValueRef::Null) => serde_json::Value::Null,
Ok(ValueRef::Integer(v)) => serde_json::json!(v),
Ok(ValueRef::Real(v)) => serde_json::json!(v),
Ok(ValueRef::Text(v)) => serde_json::Value::String(
String::from_utf8_lossy(v).to_string(),
),
Ok(ValueRef::Blob(v)) => serde_json::Value::String(format!(
"[{}B blob]",
v.len()
)),
Ok(ValueRef::Text(v)) => serde_json::Value::String(String::from_utf8_lossy(v).to_string()),
Ok(ValueRef::Blob(v)) => serde_json::Value::String(format!("[{}B blob]", v.len())),
Err(_) => serde_json::Value::Null,
}
}
@@ -941,8 +958,7 @@ pub async fn db_connect(
let result = match tls {
None => connect_pg_with(&pgconfig, tokio_postgres::NoTls).await,
Some(cc) => {
let connector =
tokio_postgres_rustls::MakeRustlsConnect::new((*cc).clone());
let connector = tokio_postgres_rustls::MakeRustlsConnect::new((*cc).clone());
connect_pg_with(&pgconfig, connector).await
}
};
@@ -1090,7 +1106,8 @@ pub async fn get_tables(
})
})
.map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
rows.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())
}
None => Err("Connection not found".to_string()),
}
@@ -1123,16 +1140,20 @@ pub async fn get_table_data(
let order_clause = build_order_clause(&sorts);
// Get total count (with filters applied)
let count_query =
format!("SELECT COUNT(*) FROM \"{}\".\"{}\" WHERE 1=1{}", schema, table, filter_clause);
let count_query = format!(
"SELECT COUNT(*) FROM \"{}\".\"{}\" WHERE 1=1{}",
schema, table, filter_clause
);
let count_row = if filter_params.is_empty() {
client
.query_one(&count_query, &[])
.await
.map_err(|e| e.to_string())?
} else {
let param_refs: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> =
filter_params.iter().map(|s| s as &(dyn tokio_postgres::types::ToSql + Sync)).collect();
let param_refs: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = filter_params
.iter()
.map(|s| s as &(dyn tokio_postgres::types::ToSql + Sync))
.collect();
client
.query_one(&count_query, &param_refs)
.await
@@ -1140,6 +1161,18 @@ pub async fn get_table_data(
};
let total_rows: i64 = count_row.get(0);
// Views expose no `ctid`; detect them so no row-locator is appended
// to the data SELECT and columns stay read-only.
let is_view: bool = client
.query_one(
"SELECT EXISTS(SELECT 1 FROM information_schema.tables \
WHERE table_schema = $1 AND table_name = $2 AND table_type = 'VIEW')",
&[&schema, &table],
)
.await
.map(|r| r.get::<_, bool>(0))
.unwrap_or(false);
// Get column info with FK detection and enum type names
let col_query = r#"SELECT
c.column_name,
@@ -1203,12 +1236,9 @@ ORDER BY c.ordinal_position"#;
// pg_attribute.attgenerated/attidentity are PG's internal
// "char" type (OID 18) → tokio-postgres delivers i8, not
// String; deserializing as String panics. Convert safely.
let attgenerated = pg_char_to_att(
r.try_get::<_, Option<i8>>(8).unwrap_or(None),
);
let attidentity = pg_char_to_att(
r.try_get::<_, Option<i8>>(9).unwrap_or(None),
);
let attgenerated =
pg_char_to_att(r.try_get::<_, Option<i8>>(8).unwrap_or(None));
let attidentity = pg_char_to_att(r.try_get::<_, Option<i8>>(9).unwrap_or(None));
let is_pk: bool = r.get(3);
ColumnInfo {
name: r.get(0),
@@ -1222,7 +1252,9 @@ ORDER BY c.ordinal_position"#;
None
},
default_value: r.get::<_, Option<String>>(7),
editable: editable_from_att(&attgenerated, &attidentity) && !is_pk,
editable: editable_from_att(&attgenerated, &attidentity)
&& !is_pk
&& !is_view,
is_generated: !attgenerated.is_empty(),
}
})
@@ -1232,15 +1264,41 @@ ORDER BY c.ordinal_position"#;
// Custom/enum types need explicit ::text cast because tokio-postgres
// FromSql<String> rejects custom type OIDs even in simple query mode.
let standard_pg_types: &[&str] = &[
"uuid", "text", "varchar", "char", "bpchar", "name",
"int2", "int4", "int8", "smallint", "integer", "bigint",
"float4", "float8", "real", "double precision",
"numeric", "decimal", "money",
"bool", "boolean",
"date", "time", "timetz", "timestamp", "timestamptz",
"interval", "json", "jsonb", "bytea", "oid",
"timestamp without time zone", "timestamp with time zone",
"time without time zone", "time with time zone",
"uuid",
"text",
"varchar",
"char",
"bpchar",
"name",
"int2",
"int4",
"int8",
"smallint",
"integer",
"bigint",
"float4",
"float8",
"real",
"double precision",
"numeric",
"decimal",
"money",
"bool",
"boolean",
"date",
"time",
"timetz",
"timestamp",
"timestamptz",
"interval",
"json",
"jsonb",
"bytea",
"oid",
"timestamp without time zone",
"timestamp with time zone",
"time without time zone",
"time with time zone",
];
let has_pk = columns.iter().any(|c| c.is_pk);
let select_items: Vec<String> = columns
@@ -1257,10 +1315,14 @@ ORDER BY c.ordinal_position"#;
.collect();
// `ctid` is appended last for no-PK tables so later UPDATE/DELETE
// queue changes can target the exact row. It stays out of `columns`.
// Views are excluded — they expose no ctid and are read-only.
let data_query = format!(
"{} WHERE 1=1{} {} LIMIT {} OFFSET {}",
build_pg_select_from_items(&schema, &table, select_items, has_pk),
filter_clause, order_clause, ps, off
build_pg_select_from_items(&schema, &table, select_items, !has_pk && !is_view),
filter_clause,
order_clause,
ps,
off
);
let data_rows = if filter_params.is_empty() {
client
@@ -1268,8 +1330,10 @@ ORDER BY c.ordinal_position"#;
.await
.map_err(|e| e.to_string())?
} else {
let param_refs: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> =
filter_params.iter().map(|s| s as &(dyn tokio_postgres::types::ToSql + Sync)).collect();
let param_refs: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = filter_params
.iter()
.map(|s| s as &(dyn tokio_postgres::types::ToSql + Sync))
.collect();
client
.query(&data_query, &param_refs)
.await
@@ -1290,19 +1354,36 @@ ORDER BY c.ordinal_position"#;
})
}
Some(crate::db::pool::DbHandle::Sqlite(conn)) => {
// Views expose no `rowid`; detect them so no row-locator is appended
// to the data SELECT and columns stay read-only.
let is_view: bool = conn
.query_row(
"SELECT type = 'view' FROM sqlite_master WHERE name = ?1 AND type IN ('table', 'view')",
[&table],
|r| r.get::<_, bool>(0),
)
.unwrap_or(false);
// Build filter clause (shared by COUNT and data queries)
let (filter_clause, filter_vals) = build_sqlite_filter_clause(&filters);
let order_clause = build_order_clause(&sorts);
let count_query =
format!("SELECT COUNT(*) FROM \"{}\".\"{}\" WHERE 1=1{}", schema, table, filter_clause);
let count_query = format!(
"SELECT COUNT(*) FROM \"{}\".\"{}\" WHERE 1=1{}",
schema, table, filter_clause
);
let total_rows: i64 = if filter_vals.is_empty() {
conn.query_row(&count_query, [], |r| r.get(0))
.map_err(|e| e.to_string())?
} else {
let refs: Vec<&dyn rusqlite::types::ToSql> = filter_vals.iter().map(|v| v as &dyn rusqlite::types::ToSql).collect();
conn.query_row(&count_query, rusqlite::params_from_iter(&refs), |r| r.get(0))
.map_err(|e| e.to_string())?
let refs: Vec<&dyn rusqlite::types::ToSql> = filter_vals
.iter()
.map(|v| v as &dyn rusqlite::types::ToSql)
.collect();
conn.query_row(&count_query, rusqlite::params_from_iter(&refs), |r| {
r.get(0)
})
.map_err(|e| e.to_string())?
};
// Get column metadata via PRAGMA table_info
@@ -1311,10 +1392,10 @@ ORDER BY c.ordinal_position"#;
let col_meta: Vec<(String, String, bool, bool, Option<String>)> = pragma_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::<_, 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
))
})
@@ -1329,9 +1410,9 @@ ORDER BY c.ordinal_position"#;
fk_stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(3)?, // from (column)
row.get::<_, String>(2)?, // table
row.get::<_, String>(4)?, // to (column)
row.get::<_, String>(3)?, // from (column)
row.get::<_, String>(2)?, // table
row.get::<_, String>(4)?, // to (column)
))
})
.map_err(|e| e.to_string())?
@@ -1348,13 +1429,17 @@ ORDER BY c.ordinal_position"#;
let fk = fk_map.get(name);
ColumnInfo {
name: name.clone(),
data_type: if dtype.is_empty() { "TEXT".to_string() } else { dtype.clone() },
data_type: if dtype.is_empty() {
"TEXT".to_string()
} else {
dtype.clone()
},
is_nullable: !notnull,
is_pk: *is_pk,
is_fk: fk.is_some(),
fk_ref: fk.map(|(t, c)| (t.clone(), c.clone())),
default_value: default_val.clone(),
editable: !*is_pk,
editable: !*is_pk && !is_view,
is_generated: false,
}
})
@@ -1363,12 +1448,16 @@ ORDER BY c.ordinal_position"#;
// Get data (with filters and sorts applied).
// `rowid` is appended last for no-PK tables so later UPDATE/DELETE
// queue changes can target the exact row. It stays out of `columns`.
// Views are excluded — they expose no rowid and are read-only.
let visible_names: Vec<String> = columns.iter().map(|c| c.name.clone()).collect();
let has_pk = columns.iter().any(|c| c.is_pk);
let data_query = format!(
"{} WHERE 1=1{} {} LIMIT {} OFFSET {}",
build_sqlite_data_select(&table, &visible_names, has_pk),
filter_clause, order_clause, ps, off
build_sqlite_data_select(&table, &visible_names, !has_pk && !is_view),
filter_clause,
order_clause,
ps,
off
);
let mut stmt = conn.prepare(&data_query).map_err(|e| e.to_string())?;
let col_count = stmt.column_count();
@@ -1385,7 +1474,10 @@ ORDER BY c.ordinal_position"#;
.filter_map(|r| r.ok())
.collect()
} else {
let refs: Vec<&dyn rusqlite::types::ToSql> = filter_vals.iter().map(|v| v as &dyn rusqlite::types::ToSql).collect();
let refs: Vec<&dyn rusqlite::types::ToSql> = filter_vals
.iter()
.map(|v| v as &dyn rusqlite::types::ToSql)
.collect();
stmt.query_map(rusqlite::params_from_iter(&refs), |row| {
let mut vals = Vec::new();
for i in 0..col_count {
@@ -1561,7 +1653,11 @@ ORDER BY c.ordinal_position"#;
let fk = fk_map.get(name);
ColumnInfo {
name: name.clone(),
data_type: if dtype.is_empty() { "TEXT".to_string() } else { dtype.clone() },
data_type: if dtype.is_empty() {
"TEXT".to_string()
} else {
dtype.clone()
},
is_nullable: !notnull,
is_pk: *is_pk,
is_fk: fk.is_some(),
@@ -1733,8 +1829,7 @@ pub async fn execute_change(
..
} => {
let pairs = parse_json_pairs(data)?;
let columns: Vec<String> =
pairs.iter().map(|(c, _)| c.clone()).collect();
let columns: Vec<String> = pairs.iter().map(|(c, _)| c.clone()).collect();
(
build_insert_sql(schema, table, &columns),
pairs.iter().map(|(_, v)| v.clone()).collect(),
@@ -1800,7 +1895,10 @@ pub async fn refresh_connection(
let mut pm = state.pool_manager.lock().await;
match pm.get(&connection_id) {
Some(crate::db::pool::DbHandle::Postgresql(client, _)) => {
client.query_one("SELECT 1", &[]).await.map_err(|e| e.to_string())?;
client
.query_one("SELECT 1", &[])
.await
.map_err(|e| e.to_string())?;
Ok(())
}
Some(crate::db::pool::DbHandle::Sqlite(conn)) => {
@@ -1901,13 +1999,14 @@ pub async fn get_constraints(
.iter()
.map(|r| {
// contype::text decodes as a String ("c" | "u" | "x").
let contype = match r.get::<_, Option<String>>(3).unwrap_or_default().as_str() {
"c" => "CHECK",
"u" => "UNIQUE",
"x" => "EXCLUSION",
other => other,
}
.to_string();
let contype =
match r.get::<_, Option<String>>(3).unwrap_or_default().as_str() {
"c" => "CHECK",
"u" => "UNIQUE",
"x" => "EXCLUSION",
other => other,
}
.to_string();
ConstraintInfo {
name: r.get(0),
schema: r.get(1),
@@ -1916,7 +2015,9 @@ pub async fn get_constraints(
definition: r.get(4),
deferrable: r.get(5),
validated: r.get(6),
columns: split_columns_csv(&r.get::<_, Option<String>>(7).unwrap_or_default()),
columns: split_columns_csv(
&r.get::<_, Option<String>>(7).unwrap_or_default(),
),
}
})
.collect())
@@ -1986,7 +2087,8 @@ pub async fn get_sequences(
max_value: r.get::<_, Option<String>>(4).unwrap_or_default(),
increment: r.get::<_, Option<String>>(5).unwrap_or_default(),
current_value: r.get::<_, Option<String>>(6).unwrap_or_default(),
cycle: r.get::<_, Option<String>>(7)
cycle: r
.get::<_, Option<String>>(7)
.map(|s| s == "YES")
.unwrap_or(false),
})
@@ -2035,10 +2137,7 @@ pub async fn get_extensions(
match pm.get(&connection_id) {
Some(DbHandle::Postgresql(client, _)) => {
let query = crate::db::introspection::pg_extensions_query();
let rows = client
.query(&query, &[])
.await
.map_err(|e| e.to_string())?;
let rows = client.query(&query, &[]).await.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|r| ExtensionInfo {
@@ -2105,7 +2204,9 @@ pub async fn get_table_ddl(
// pg_dump is blocking I/O; run it off the async runtime. Credentials
// travel via PGPASSWORD, never argv.
let ddl = tokio::task::spawn_blocking(move || {
get_pg_ddl_via_dump(&schema, &table, &dump_host, dump_port, &user, &db, &password)
get_pg_ddl_via_dump(
&schema, &table, &dump_host, dump_port, &user, &db, &password,
)
})
.await
.map_err(|e| format!("pg_dump task failed: {e}"))??;
@@ -2115,7 +2216,6 @@ pub async fn get_table_ddl(
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -2127,11 +2227,17 @@ mod tests {
#[test]
fn split_columns_csv_handles_commas_and_trims() {
assert_eq!(split_columns_csv("id, name, created_at"), vec!["id", "name", "created_at"]);
assert_eq!(
split_columns_csv("id, name, created_at"),
vec!["id", "name", "created_at"]
);
assert_eq!(split_columns_csv("id"), vec!["id"]);
assert_eq!(split_columns_csv(""), Vec::<String>::new());
// expression index column list may include parens — keep raw, just split on top-level commas
assert_eq!(split_columns_csv("lower(name), id"), vec!["lower(name)", "id"]);
assert_eq!(
split_columns_csv("lower(name), id"),
vec!["lower(name)", "id"]
);
}
/// bigint precision: values beyond 2^53 must round-trip as strings.
@@ -2140,8 +2246,11 @@ mod tests {
// A bigint beyond 2^53 must round-trip as a string, not a JS number.
let big: i64 = 9_007_199_254_740_993; // 2^53 + 1
let v = i64_to_json(big);
assert_eq!(v, serde_json::Value::String("9007199254740993".to_string()),
"bigint must be a string to avoid float precision loss");
assert_eq!(
v,
serde_json::Value::String("9007199254740993".to_string()),
"bigint must be a string to avoid float precision loss"
);
let small: i64 = 42;
let v2 = i64_to_json(small);
assert_eq!(v2, serde_json::Value::String("42".to_string()));
@@ -2303,13 +2412,7 @@ mod tests {
vec![serde_json::json!(1), serde_json::json!("y")],
vec![serde_json::json!(2), serde_json::json!("z")],
];
apply_bulk_insert_sqlite(
&conn,
"t",
&["a".to_string(), "b".to_string()],
&rows,
)
.unwrap();
apply_bulk_insert_sqlite(&conn, "t", &["a".to_string(), "b".to_string()], &rows).unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM t", [], |r| r.get(0))
.unwrap();
@@ -2328,12 +2431,7 @@ mod tests {
vec![serde_json::json!(1), serde_json::json!("y")],
vec![serde_json::json!("bad"), serde_json::json!("z")],
];
let res = apply_bulk_insert_sqlite(
&conn,
"t",
&["a".to_string(), "b".to_string()],
&rows,
);
let res = apply_bulk_insert_sqlite(&conn, "t", &["a".to_string(), "b".to_string()], &rows);
assert!(res.is_err(), "non-integer PK value should fail");
// Rollback: no rows persisted.
let count: i64 = conn
@@ -2349,10 +2447,10 @@ mod tests {
let conn = rusqlite::Connection::open_in_memory().unwrap();
// INTEGER PRIMARY KEY rejects non-integer values (datatype mismatch),
// guaranteeing row 2 fails.
conn.execute("CREATE TABLE t (a INTEGER PRIMARY KEY)", []).unwrap();
conn.execute("CREATE TABLE t (a INTEGER PRIMARY KEY)", [])
.unwrap();
let rows = vec![vec![serde_json::json!(1)], vec![serde_json::json!("x")]];
let err = apply_bulk_insert_sqlite(&conn, "t", &["a".to_string()], &rows)
.unwrap_err();
let err = apply_bulk_insert_sqlite(&conn, "t", &["a".to_string()], &rows).unwrap_err();
assert!(
err.contains("row 2"),
"error should name the failing row index (1-based): {err}"
@@ -2363,11 +2461,8 @@ mod tests {
/// and quotes schema, table, and columns.
#[test]
fn build_pg_bulk_insert_sql_shape() {
let sql = build_pg_bulk_insert_sql(
"public",
"users",
&["id".to_string(), "name".to_string()],
);
let sql =
build_pg_bulk_insert_sql("public", "users", &["id".to_string(), "name".to_string()]);
assert_eq!(
sql,
r#"INSERT INTO "public"."users" ("id", "name") VALUES ($1, $2)"#
@@ -2417,21 +2512,69 @@ mod tests {
#[test]
fn pg_locator_select_adds_ctid() {
let sql = build_pg_data_select("public", "no_pk", &["id".into(), "name".into()], false);
assert!(sql.contains("ctid"), "no-PK table must select ctid; got: {}", sql);
assert!(sql.contains("\"public\""), "schema must be quoted; got: {}", sql);
let sql = build_pg_data_select("public", "no_pk", &["id".into(), "name".into()], true);
assert!(
sql.contains("ctid"),
"no-PK table must select ctid; got: {}",
sql
);
assert!(
sql.contains("\"public\""),
"schema must be quoted; got: {}",
sql
);
}
#[test]
fn pg_locator_select_omits_ctid_when_pk_present() {
let sql = build_pg_data_select("public", "with_pk", &["id".into(), "name".into()], true);
assert!(!sql.contains("ctid"), "PK table must NOT select ctid; got: {}", sql);
let sql = build_pg_data_select("public", "with_pk", &["id".into(), "name".into()], false);
assert!(
!sql.contains("ctid"),
"PK table must NOT select ctid; got: {}",
sql
);
}
#[test]
fn pg_locator_select_omits_ctid_for_view() {
// Views expose no ctid — the locator must never be appended for them.
let sql = build_pg_data_select("public", "order_summary", &["order_id".into()], false);
assert!(
!sql.contains("ctid"),
"view must NOT select ctid; got: {}",
sql
);
assert!(
sql.contains("order_summary"),
"view name must be present; got: {}",
sql
);
}
#[test]
fn sqlite_locator_select_adds_rowid_for_no_pk() {
let sql = build_sqlite_data_select("no_pk", &["id".into(), "name".into()], false);
assert!(sql.contains("rowid"), "no-PK sqlite table must select rowid; got: {}", sql);
let sql = build_sqlite_data_select("no_pk", &["id".into(), "name".into()], true);
assert!(
sql.contains("rowid"),
"no-PK sqlite table must select rowid; got: {}",
sql
);
}
#[test]
fn sqlite_locator_select_omits_rowid_for_view() {
// Views expose no rowid — the locator must never be appended for them.
let sql = build_sqlite_data_select("order_summary", &["order_id".into()], false);
assert!(
!sql.contains("rowid"),
"view must NOT select rowid; got: {}",
sql
);
assert!(
sql.contains("order_summary"),
"view name must be present; got: {}",
sql
);
}
// -----------------------------------------------------------------------
@@ -2444,7 +2587,11 @@ mod tests {
let locator = vec![("ctid".to_string(), serde_json::json!("(0,1)"))];
let data = vec![("name".to_string(), serde_json::json!("Bob"))];
let (sql, params) = build_pg_update_sql("public", "no_pk", &locator, &data).unwrap();
assert!(sql.contains("\"ctid\" = $"), "locator update must WHERE on ctid; got: {}", sql);
assert!(
sql.contains("\"ctid\" = $"),
"locator update must WHERE on ctid; got: {}",
sql
);
assert_eq!(params.len(), 2); // 1 SET value + 1 WHERE value
}
@@ -2453,8 +2600,16 @@ mod tests {
let pk = vec![("id".to_string(), serde_json::json!(1))];
let data = vec![("name".to_string(), serde_json::json!("Bob"))];
let (sql, _params) = build_pg_update_sql("public", "users", &pk, &data).unwrap();
assert!(sql.contains("\"id\" = $"), "PK update must WHERE on id; got: {}", sql);
assert!(!sql.contains("ctid"), "PK update must NOT use ctid; got: {}", sql);
assert!(
sql.contains("\"id\" = $"),
"PK update must WHERE on id; got: {}",
sql
);
assert!(
!sql.contains("ctid"),
"PK update must NOT use ctid; got: {}",
sql
);
}
#[test]
@@ -2463,13 +2618,22 @@ mod tests {
let pk: Vec<(String, serde_json::Value)> = vec![];
let data = vec![("name".to_string(), serde_json::json!("Bob"))];
let result = build_pg_update_sql("public", "no_pk", &pk, &data);
assert!(result.is_err(), "empty primary_key must be rejected, not produce broken SQL");
assert!(
result.is_err(),
"empty primary_key must be rejected, not produce broken SQL"
);
}
#[test]
fn affected_row_count_message_for_zero_rows() {
assert_eq!(affected_count_error(0u64), Some("row was modified or removed by another session".to_string()));
assert_eq!(
affected_count_error(0u64),
Some("row was modified or removed by another session".to_string())
);
assert_eq!(affected_count_error(1u64), None);
assert_eq!(affected_count_error(2u64), Some("ambiguous row match".to_string()));
assert_eq!(
affected_count_error(2u64),
Some("ambiguous row match".to_string())
);
}
}
+7 -314
View File
@@ -7,11 +7,11 @@ use std::sync::Mutex;
use tauri::Manager;
const DEMO_DB_FILENAME: &str = "demo.db";
const DEMO_CONNECTION_NAME: &str = "Demo (SQLite)";
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 = 2;
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).
@@ -34,8 +34,7 @@ fn ensure_demo_file(path: &Path) -> Result<(), String> {
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}"))?;
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}"))?;
}
@@ -153,318 +152,12 @@ pub async fn regenerate_demo_db(
Ok("Demo database regenerated with fresh data.".to_string())
}
/// The demo schema + seed data. Validated to exercise every Gridline feature
/// available for SQLite: PK/FK/composite-PK/self-FK metadata, JSON cells,
/// BLOBs, CHECK/UNIQUE constraints, defaults, indexes, views, an empty table,
/// a no-PK table (rowid editing), a TEXT primary key, and a 500-row table for
/// pagination / virtualization / filtering demos.
/// 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 {
r#"
PRAGMA user_version = 2;
-- ── Core: users ─────────────────────────────────────────────────────────
-- Row editing, JSON popover (preferences), nullable cols (phone/birth_date),
-- long text (bio -> scrolling textarea editor), smart-sort tiers
-- (updated_at / created_at / last_login_at), UNIQUE (email), defaults.
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 'user',
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,
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,
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 (two 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')),
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'
);
-- ── 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_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
-- ═══════════════════════════════════════════════════════════════════════
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, 'Alice Johnson', 'alice@example.com', 'admin', '+1-555-0101', '1990-04-12',
'Senior platform engineer and the store''s first admin. She manages the catalog, reviews every order before it ships, and keeps the demo data realistic.',
'{"theme":"dark","notifications":{"email":true,"push":false},"locale":"en-US"}',
249.50, 1, datetime('now','-2 hours'), datetime('now','-240 days'), datetime('now','-2 hours')),
(2, 'Bob Smith', 'bob@example.com', 'user', '+1-555-0102', '1985-11-30',
'Loyal customer since 2021. Prefers mechanical keyboards and 4K displays, and always opts for the extended warranty.',
'{"theme":"light","notifications":{"email":true,"push":true},"locale":"en-GB"}',
12.00, 1, datetime('now','-1 day'), datetime('now','-210 days'), datetime('now','-1 day')),
(3, 'Carol Davis', 'carol@example.com', 'user', NULL, '1998-07-19',
'Occasional shopper who mostly buys desk accessories for her home office in Seattle.',
NULL,
0.00, 1, datetime('now','-6 days'), datetime('now','-180 days'), datetime('now','-6 days')),
(4, 'Dan Wilson', 'dan@example.com', 'user', '+1-555-0104', NULL,
'Power user testing the checkout flow. Frequently leaves detailed feedback and files the occasional bug report.',
'{"theme":"system","notifications":{"email":false,"push":false},"locale":"de-DE"}',
87.25, 0, datetime('now','-14 days'), datetime('now','-90 days'), datetime('now','-14 days')),
(5, 'Eve Martinez', 'eve@example.com', 'moderator', '+1-555-0105', '1993-02-08',
'Moderator and community lead. Approves product reviews, helps with support tickets, and watches the audit log closely.',
'{"theme":"dark","notifications":{"email":true,"push":true},"locale":"fr-FR"}',
33.75, 1, datetime('now','-35 minutes'), datetime('now','-45 days'), datetime('now','-35 minutes'));
INSERT OR IGNORE INTO categories (id, name, parent_id, slug, sort_order, description, created_at) VALUES
(1, 'Electronics', NULL, 'electronics', 1, 'Gadgets, displays and peripherals.', datetime('now','-300 days')),
(2, 'Accessories', NULL, 'accessories', 2, 'Cables, hubs and add-ons.', datetime('now','-300 days')),
(3, 'Office', NULL, 'office', 3, 'Furniture and desk essentials.', datetime('now','-300 days')),
(4, 'Keyboards', 1, 'keyboards', 1, 'Mechanical and membrane keyboards.', datetime('now','-120 days')),
(5, 'Monitors', 1, 'monitors', 2, 'Displays from 24 to 32 inches.', datetime('now','-120 days'));
INSERT OR IGNORE INTO products (id, sku, name, description, price, category_id, stock, rating, discontinued, attributes, created_at, updated_at) VALUES
(1, 'SKU-WM-001', 'Wireless Mouse',
'A comfortable ambidextrous wireless mouse with silent click switches, 2.4 GHz dongle and Bluetooth 5.0, a 1600 DPI optical sensor, and a 12-month battery life on a single AA battery.',
29.99, 1, 150, 4.5, 0, '{"color":"Graphite","wireless":true,"dpi":1600}', datetime('now','-260 days'), datetime('now','-3 days')),
(2, 'SKU-MK-001', 'Mechanical Keyboard',
'Hot-swappable tenkeyless board with brown switches, per-key RGB backlighting, PBT double-shot keycaps and a CNC aluminium case. USB-C with a detachable braided cable.',
89.99, 4, 75, 4.8, 0, '{"layout":"US ANSI","switches":"brown","backlit":true}', datetime('now','-250 days'), datetime('now','-20 days')),
(3, 'SKU-HUB-001', 'USB-C Hub',
'Seven-port hub with 4K HDMI, 100 W power delivery passthrough, two USB-A 3.2 ports, SD/microSD slots and an aluminium body that stays cool.',
34.99, 2, 200, 4.2, 0, '{"ports":"7-in-1","power_delivery":"100W"}', datetime('now','-240 days'), datetime('now','-2 days')),
(4, 'SKU-MN-001', '27" 4K Monitor',
'27-inch IPS panel with 3840x2160 resolution, 60 Hz refresh, 99% sRGB coverage, USB-C upstream with 90 W charging and a fully adjustable stand.',
449.99, 5, 30, 4.6, 0, '{"resolution":"3840x2160","refresh_hz":60,"panel":"IPS"}', datetime('now','-230 days'), datetime('now','-15 days')),
(5, 'SKU-LS-001', 'Laptop Stand',
'Foldable aluminium stand with six height positions, ventilated design and soft silicone pads. Fits laptops from 12 to 16 inches.',
49.99, 2, 100, 3.9, 0, NULL, datetime('now','-220 days'), datetime('now','-40 days')),
(6, 'SKU-WC-001', 'Webcam 1080p',
'Full HD webcam with a privacy shutter, dual noise-reducing microphones and autofocus. Works with every major video call app out of the box.',
59.99, 1, 0, 4.0, 1, '{"resolution":"1920x1080","fps":30,"microphone":true}', datetime('now','-200 days'), datetime('now','-60 days')),
(7, 'SKU-DL-001', 'Desk Lamp LED',
'Dimmable LED desk lamp with adjustable colour temperature from 2700 K to 6500 K, a flexible neck and a built-in USB charging port.',
39.99, 3, 120, 4.3, 0, '{"color_temp":"2700-6500K","dimmable":true}', datetime('now','-190 days'), datetime('now','-9 days')),
(8, 'SKU-EC-001', 'Ergonomic Chair',
'Breathable mesh back, adjustable lumbar support, 4D armrests and a gas lift rated for up to 150 kg. Assembles in under twenty minutes.',
599.99, 3, 15, 4.7, 0, '{"material":"mesh","lumbar_support":true}', datetime('now','-180 days'), datetime('now','-30 days')),
(9, 'SKU-UC-001', 'USB-C Cable 2m',
'Braided USB-C to USB-C cable rated for 100 W charging and USB 3.2 data transfer. Tested for 10,000 bends.',
14.99, 2, 500, 4.1, 0, '{"length_m":2,"charging":"100W"}', datetime('now','-170 days'), datetime('now','-5 days')),
(10, 'SKU-MA-001', 'Monitor Arm',
'Single monitor arm with gas spring, 75/100 mm VESA mount, and 360 degree rotation. Supports monitors up to 9 kg.',
79.99, 3, 40, NULL, 0, '{"weight_capacity_kg":9,"vesa":"75/100"}', datetime('now','-160 days'), datetime('now','-12 days'));
INSERT OR IGNORE INTO addresses (id, user_id, label, street, city, zip, country, is_primary, created_at) VALUES
(1, 1, 'Home', '100 Market Street', 'San Francisco', '94105', 'USA', 1, datetime('now','-200 days')),
(2, 1, 'Work', '200 Mission Street', 'San Francisco', '94105', 'USA', 0, datetime('now','-180 days')),
(3, 2, 'Home', '300 Lakeshore Drive', 'Austin', '78701', 'USA', 1, datetime('now','-150 days')),
(4, 3, 'Home', '400 Maple Avenue', 'Seattle', '98101', 'USA', 1, datetime('now','-120 days')),
(5, 4, 'Home', '500 Park Boulevard', 'New York', '10001', 'USA', 1, datetime('now','-90 days')),
(6, 5, 'Home', '600 Cedar Lane', 'Denver', '80202', 'USA', 1, datetime('now','-60 days')),
(7, 3, 'Cabin', '700 Pine Road', 'Bend', '97701', 'USA', 0, datetime('now','-30 days')),
(8, 5, 'Work', '800 Pearl Street', 'Denver', '80202', 'USA', 0, datetime('now','-7 days'));
INSERT OR IGNORE INTO orders (id, user_id, shipping_address_id, total, status, notes, created_at, updated_at, shipped_at) VALUES
(1, 1, 1, 94.97, 'completed', 'Please leave the package at the front desk.', datetime('now','-40 days'), datetime('now','-38 days'), datetime('now','-38 days')),
(2, 2, 3, 499.98, 'pending', NULL, datetime('now','-3 days'), datetime('now','-2 hours'), NULL),
(3, 3, 4, 59.99, 'completed', NULL, datetime('now','-20 days'), datetime('now','-19 days'), datetime('now','-19 days')),
(4, 1, 2, 89.99, 'shipped', 'Gift wrap, please.', datetime('now','-2 days'), datetime('now','-1 day'), datetime('now','-1 day')),
(5, 4, 5, 689.96, 'processing', NULL, datetime('now','-1 day'), datetime('now','-5 hours'), NULL),
(6, 5, 6, 74.98, 'cancelled', 'Customer requested cancellation before dispatch.', datetime('now','-5 days'), datetime('now','-4 days'), NULL);
INSERT OR IGNORE INTO order_items (order_id, product_id, quantity, unit_price) VALUES
(1, 1, 2, 29.99),
(1, 3, 1, 34.99),
(2, 4, 1, 449.99),
(2, 5, 1, 49.99),
(3, 6, 1, 59.99),
(4, 2, 1, 89.99),
(5, 8, 1, 599.99),
(5, 1, 3, 29.99),
(6, 7, 1, 39.99),
(6, 3, 1, 34.99);
-- 500 generated rows for pagination / virtualization / filter demos.
-- Guarded by NOT EXISTS so re-running the schema never duplicates rows.
WITH RECURSIVE seq(n) AS (
SELECT 1
UNION ALL
SELECT n + 1 FROM 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 % 5) + 1 END,
CASE n % 6 WHEN 0 THEN 'login' WHEN 1 THEN 'page_view' WHEN 2 THEN 'update'
WHEN 3 THEN 'create' WHEN 4 THEN 'delete' ELSE 'export' END,
CASE n % 4 WHEN 0 THEN 'order' WHEN 1 THEN 'product' WHEN 2 THEN 'user' ELSE 'report' END,
(n % 40) + 1,
CASE n % 5 WHEN 0 THEN 'info' WHEN 1 THEN 'info' WHEN 2 THEN 'warning'
WHEN 3 THEN 'error' ELSE 'critical' END,
CASE WHEN n % 7 = 0 THEN NULL
ELSE '{"page":"/demo","retries":' || (n % 3) || ',"row":' || n || '}' END,
(n * 37) % 2000,
datetime('now', printf('-%d minutes', n * 7))
FROM seq
WHERE NOT EXISTS (SELECT 1 FROM audit_log);
INSERT OR IGNORE INTO files (id, name, mime_type, content, size_bytes, uploaded_by, uploaded_at) VALUES
(1, 'logo.png', 'image/png', X'89504E470D0A1A0A0000000D49484452', length(X'89504E470D0A1A0A0000000D49484452'), 1, datetime('now','-10 days')),
(2, 'photo.jpg', 'image/jpeg', X'FFD8FFE000104A464946000101', length(X'FFD8FFE000104A464946000101'), 2, datetime('now','-9 days')),
(3, 'manual.pdf', 'application/pdf', X'255044462D312E340A25E2E3CFD3', length(X'255044462D312E340A25E2E3CFD3'), NULL, datetime('now','-5 days')),
(4, 'archive.zip', 'application/zip', NULL, 0, 3, datetime('now','-1 day')),
(5, 'report.csv', 'text/csv', X'69642C6E616D650A312C616C696365', length(X'69642C6E616D650A312C616C696365'), 1, datetime('now','-4 hours'));
INSERT INTO page_views (url, session_id, user_agent, viewed_at) SELECT * FROM (
VALUES
('/products', 'sess-001', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0', datetime('now','-23 hours')),
('/products/1', 'sess-001', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0', datetime('now','-22 hours')),
('/cart', 'sess-002', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5) AppleWebKit/605.1.15 Safari/604.1', datetime('now','-18 hours')),
('/checkout', 'sess-002', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5) AppleWebKit/605.1.15 Safari/604.1', datetime('now','-18 hours')),
('/orders', 'sess-003', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Firefox/127.0', datetime('now','-12 hours')),
('/products/8', 'sess-004', 'Mozilla/5.0 (X11; Linux x86_64) Chrome/125.0', datetime('now','-8 hours')),
('/login', 'sess-004', 'Mozilla/5.0 (X11; Linux x86_64) Chrome/125.0', datetime('now','-8 hours')),
('/settings', 'sess-001', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0', datetime('now','-5 hours')),
('/categories/electronics', 'sess-005', 'curl/8.4.0', datetime('now','-3 hours')),
('/products/4', 'sess-002', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5) AppleWebKit/605.1.15 Safari/604.1', datetime('now','-2 hours')),
('/checkout', 'sess-006', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0', datetime('now','-1 hour')),
('/orders', 'sess-002', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5) AppleWebKit/605.1.15 Safari/604.1', datetime('now','-30 minutes'))
) WHERE NOT EXISTS (SELECT 1 FROM page_views);
INSERT OR IGNORE INTO app_settings (key, value, updated_at) VALUES
('site_name', 'Gridline Demo Store', datetime('now','-30 days')),
('maintenance_mode', 'false', datetime('now','-2 days')),
('max_cart_items', '50', datetime('now','-14 days')),
('currency', 'USD', datetime('now','-30 days'));
"#
.to_string()
include_str!("demo_schema.sql").to_string()
}
#[cfg(test)]
#[path = "demo.test.rs"]
mod tests;
mod tests;
+171 -38
View File
@@ -1,4 +1,5 @@
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 {
@@ -41,7 +42,10 @@ fn demo_schema_creates_expected_objects() {
];
assert_eq!(
objects,
expected.into_iter().map(|(n, t)| (n.to_string(), t.to_string())).collect::<Vec<_>>(),
expected
.into_iter()
.map(|(n, t)| (n.to_string(), t.to_string()))
.collect::<Vec<_>>(),
"demo schema must contain exactly the expected tables + view"
);
}
@@ -49,18 +53,30 @@ fn demo_schema_creates_expected_objects() {
#[test]
fn demo_schema_seeds_expected_rows() {
let conn = seed_demo();
assert_eq!(count(&conn, "users"), 5);
assert_eq!(count(&conn, "categories"), 5);
assert_eq!(count(&conn, "products"), 10);
assert_eq!(count(&conn, "addresses"), 8);
assert_eq!(count(&conn, "orders"), 6);
assert_eq!(count(&conn, "order_items"), 10);
assert_eq!(count(&conn, "audit_log"), 500, "audit_log must seed 500 rows for pagination/virtualization demos");
assert_eq!(count(&conn, "files"), 5);
assert_eq!(count(&conn, "page_views"), 12);
assert_eq!(count(&conn, "app_settings"), 4);
assert_eq!(count(&conn, "marketing_campaigns"), 0, "marketing_campaigns must stay empty to demo the Empty Table change");
assert_eq!(count(&conn, "order_summary"), 6, "view must return one row per order");
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]
@@ -69,10 +85,10 @@ fn demo_schema_is_idempotent() {
// 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"), 5);
assert_eq!(count(&conn, "users"), 20);
assert_eq!(count(&conn, "audit_log"), 500);
assert_eq!(count(&conn, "page_views"), 12);
assert_eq!(count(&conn, "products"), 10);
assert_eq!(count(&conn, "page_views"), 100);
assert_eq!(count(&conn, "products"), 24);
}
#[test]
@@ -81,7 +97,10 @@ fn demo_schema_sets_user_version() {
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");
assert_eq!(
v, DEMO_SCHEMA_VERSION,
"demo file must stamp PRAGMA user_version for upgrade detection"
);
}
#[test]
@@ -89,7 +108,11 @@ 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")] {
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"
@@ -100,7 +123,10 @@ fn demo_json_columns_hold_valid_json() {
.unwrap()
.filter_map(|r| r.ok())
.collect();
assert!(!values.is_empty(), "{table}.{column} should have non-null values");
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(),
@@ -110,7 +136,11 @@ fn demo_json_columns_hold_valid_json() {
}
// 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")] {
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}'"),
@@ -118,7 +148,10 @@ fn demo_json_columns_hold_valid_json() {
|r| r.get(0),
)
.unwrap();
assert_eq!(dt, "json", "{table}.{column} must be declared lowercase json");
assert_eq!(
dt, "json",
"{table}.{column} must be declared lowercase json"
);
}
}
@@ -127,16 +160,88 @@ 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)?)))
.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(), 6);
assert_eq!(rows.len(), 50);
let first = &rows[0];
assert_eq!(first.0, 1);
assert_eq!(first.1, "Alice Johnson");
assert_eq!(first.2, 2, "order 1 should have 2 line items");
assert_eq!(first.4, "completed");
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]
@@ -146,7 +251,9 @@ fn demo_foreign_keys_are_consistent() {
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)?)))
.query_map([], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
})
.unwrap()
.filter_map(|r| r.ok())
.collect();
@@ -178,14 +285,24 @@ fn demo_tables_exercise_key_constraint_shapes() {
.filter_map(|r| r.ok())
.collect();
assert_eq!(
composite.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(),
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))
.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");
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(
@@ -216,20 +333,34 @@ fn demo_file_is_recreated_when_stale() {
// 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);")
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"), 5, "recreated file must be fully seeded");
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"), 5, "current demo file must not be reseeded");
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).
@@ -237,10 +368,12 @@ fn demo_file_is_recreated_when_stale() {
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();
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"), 5);
assert_eq!(count(&conn, "users"), 20);
assert_eq!(count(&conn, "audit_log"), 500);
std::fs::remove_dir_all(&dir).ok();
}
}
+492
View File
@@ -0,0 +1,492 @@
PRAGMA user_version = 3;
-- ═══════════════════════════════════════════════════════════════════════
-- Gridline Demo (SQLite) — realistic e-commerce dataset
--
-- Designed to exercise every SQLite-aware Gridline feature:
-- • PK/FK/composite-PK/self-FK metadata
-- • JSON cells + JSON popover
-- • BLOBs
-- • CHECK / UNIQUE constraints, defaults
-- • indexes
-- • views
-- • an empty table (Empty Table change demo)
-- • a no-PK table (rowid row-locator editing)
-- • a TEXT primary key
-- • a 500-row table for pagination / virtualization / filtering
-- • nullable columns, long text, smart-sort tiers
-- ═══════════════════════════════════════════════════════════════════════
-- ── Core: users ─────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
role TEXT NOT NULL DEFAULT 'customer' CHECK (role IN ('admin','moderator','customer','guest')),
phone TEXT,
birth_date TEXT,
bio TEXT,
preferences json,
balance REAL NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
last_login_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ── Core: categories (self-referencing FK) ──────────────────────────────
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
parent_id INTEGER REFERENCES categories(id),
slug TEXT NOT NULL UNIQUE,
sort_order INTEGER NOT NULL DEFAULT 0,
description TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ── Core: products ──────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sku TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT,
price REAL NOT NULL CHECK (price >= 0),
category_id INTEGER REFERENCES categories(id),
stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
rating REAL CHECK (rating IS NULL OR (rating >= 0 AND rating <= 5)),
discontinued INTEGER NOT NULL DEFAULT 0,
attributes json,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ── Core: addresses (1:N from users, FK dropdown editor) ────────────────
CREATE TABLE IF NOT EXISTS addresses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
label TEXT NOT NULL DEFAULT 'Home',
street TEXT NOT NULL,
city TEXT NOT NULL,
state TEXT,
zip TEXT,
country TEXT NOT NULL DEFAULT 'USA',
is_primary INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ── Core: orders (FKs -> users and addresses; CHECK status) ─────────────
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
shipping_address_id INTEGER REFERENCES addresses(id),
total REAL NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','processing','shipped','delivered','completed','cancelled','refunded')),
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
shipped_at TEXT
);
-- ── Core: order_items (composite PRIMARY KEY; CHECK) ───────────────────
CREATE TABLE IF NOT EXISTS order_items (
order_id INTEGER NOT NULL REFERENCES orders(id),
product_id INTEGER NOT NULL REFERENCES products(id),
quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity > 0),
unit_price REAL NOT NULL,
PRIMARY KEY (order_id, product_id)
);
-- ── Big table for pagination / virtualization / filtering (500 rows) ──
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id),
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id INTEGER,
severity TEXT NOT NULL DEFAULT 'info'
CHECK (severity IN ('info','warning','error','critical')),
details json,
duration_ms INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ── BLOB demo ───────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
mime_type TEXT NOT NULL,
content BLOB,
size_bytes INTEGER NOT NULL,
uploaded_by INTEGER REFERENCES users(id),
uploaded_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ── No primary key on purpose: exercises the rowid row-locator editing ──
CREATE TABLE IF NOT EXISTS page_views (
url TEXT NOT NULL,
session_id TEXT NOT NULL,
user_agent TEXT,
viewed_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ── Non-integer (TEXT) primary key ─────────────────────────────────────
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ── Deliberately empty: exercises the Empty Table change + empty state ──
CREATE TABLE IF NOT EXISTS marketing_campaigns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
budget REAL,
starts_at TEXT,
ends_at TEXT,
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft','active','paused','completed','cancelled'))
);
-- ── Read-only view for the query editor / ER diagram ───────────────────
CREATE VIEW IF NOT EXISTS order_summary AS
SELECT
o.id AS order_id,
u.name AS customer_name,
COUNT(oi.product_id) AS item_count,
o.total AS order_total,
o.status,
o.created_at
FROM orders o
JOIN users u ON u.id = o.user_id
LEFT JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id, u.name, o.total, o.status, o.created_at;
-- ── Indexes (surface in the copied DDL) ─────────────────────────────────
CREATE INDEX IF NOT EXISTS idx_orders_user_id ON orders(user_id);
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
CREATE INDEX IF NOT EXISTS idx_order_items_product_id ON order_items(product_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at);
CREATE INDEX IF NOT EXISTS idx_page_views_viewed_at ON page_views(viewed_at);
-- ═══════════════════════════════════════════════════════════════════════
-- Seed data
-- ═══════════════════════════════════════════════════════════════════════
-- ── Categories ──────────────────────────────────────────────────────────
INSERT OR IGNORE INTO categories (id, name, parent_id, slug, sort_order, description, created_at) VALUES
(1, 'Electronics', NULL, 'electronics', 1, 'Computers, phones, and peripherals.', datetime('now','-400 days')),
(2, 'Accessories', NULL, 'accessories', 2, 'Cables, cases, hubs, and add-ons.', datetime('now','-400 days')),
(3, 'Office', NULL, 'office', 3, 'Furniture, lighting, and desk essentials.', datetime('now','-400 days')),
(4, 'Furniture', NULL, 'furniture', 4, 'Chairs, desks, shelves, and storage.', datetime('now','-380 days')),
(5, 'Keyboards', 1, 'keyboards', 1, 'Mechanical and membrane keyboards.', datetime('now','-300 days')),
(6, 'Monitors', 1, 'monitors', 2, 'External displays and monitor arms.', datetime('now','-300 days'));
-- ── Users: a realistic mix of admins, moderators, customers, and guests ───
INSERT OR IGNORE INTO users (id, name, email, role, phone, birth_date, bio, preferences, balance, is_active, last_login_at, created_at, updated_at) VALUES
(1, 'Sarah Chen', 'sarah.chen@acme.dev', 'admin', '+1-415-555-0101', '1990-04-12',
'Founding engineer at Acme Dev. Manages the catalog, reviews flagged orders, and keeps the demo data looking sharp.',
'{"theme":"dark","notifications":{"email":true,"push":true},"locale":"en-US","timezone":"America/Los_Angeles"}',
1240.75, 1, datetime('now','-35 minutes'), datetime('now','-540 days'), datetime('now','-35 minutes')),
(2, 'Marcus Johnson', 'marcus.j@pixelforge.studio', 'moderator', '+1-512-555-0192', '1985-11-30',
'Customer-success lead and part-time photographer. Moderates reviews, handles refunds, and tracks the weekly audit log.',
'{"theme":"system","notifications":{"email":true,"push":false},"locale":"en-GB","timezone":"America/Chicago"}',
48.20, 1, datetime('now','-6 hours'), datetime('now','-510 days'), datetime('now','-6 hours')),
(3, 'Emily Rodriguez', 'emily.r@techbloom.io', 'customer', '+1-206-555-0143', '1993-07-19',
'Remote frontend developer based in Seattle. Buys desk accessories in bulk and leaves detailed product reviews.',
'{"theme":"light","notifications":{"email":true,"push":true},"locale":"en-US","timezone":"America/Los_Angeles"}',
315.50, 1, datetime('now','-2 days'), datetime('now','-480 days'), datetime('now','-2 days')),
(4, 'David Kim', 'david.kim@outlook.com', 'customer', '+1-212-555-0178', '1988-03-04',
'Freelance sound engineer in New York. Orders AV gear and cables regularly for his home studio.',
'{"theme":"dark","notifications":{"email":false,"push":true},"locale":"ko-KR","timezone":"America/New_York"}',
0.00, 1, datetime('now','-4 days'), datetime('now','-450 days'), datetime('now','-4 days')),
(5, 'Aisha Patel', 'aisha.patel@horizonlabs.co', 'customer', '+1-303-555-0165', '1996-02-08',
'Data analyst and ergonomic-chair evangelist. Currently outfitting a standing-desk setup for her team.',
'{"theme":"dark","notifications":{"email":true,"push":false},"locale":"en-US","timezone":"America/Denver"}',
892.10, 1, datetime('now','-12 hours'), datetime('now','-420 days'), datetime('now','-12 hours')),
(6, 'James OBrien', 'james.obrien@fastmail.com', 'customer', '+1-617-555-0134', '1991-09-23',
'DevOps contractor. Bought a monitor arm and never looked back. Always asks for gift receipts.',
NULL,
67.99, 1, datetime('now','-1 day'), datetime('now','-390 days'), datetime('now','-1 day')),
(7, 'Yuki Tanaka', 'yuki.tanaka@me.com', 'customer', '+1-650-555-0189', '1994-05-17',
'Product designer at a fintech startup. Switches between light and dark mode depending on the weather.',
'{"theme":"system","notifications":{"email":true,"push":true},"locale":"ja-JP","timezone":"America/Los_Angeles"}',
150.00, 0, datetime('now','-28 days'), datetime('now','-360 days'), datetime('now','-28 days')),
(8, 'Olivia Müller', 'olivia.mueller@werkstatt.de', 'customer', '+49-30-555-0921', '1987-12-01',
'Berlin-based architect. Orders office lamps and cable organizers for her co-working space.',
'{"theme":"light","notifications":{"email":true,"push":false},"locale":"de-DE","timezone":"Europe/Berlin"}',
540.30, 1, datetime('now','-5 days'), datetime('now','-330 days'), datetime('now','-5 days')),
(9, 'Carlos Rivera', 'carlos.rivera@openmail.net', 'customer', '+1-305-555-0156', '1992-08-30',
'Miami-based content creator. Replaced his entire streaming setup through the store last quarter.',
'{"theme":"dark","notifications":{"email":false,"push":false},"locale":"es-MX","timezone":"America/New_York"}',
28.50, 1, datetime('now','-3 hours'), datetime('now','-300 days'), datetime('now','-3 hours')),
(10, 'Priya Sharma', 'priya.sharma@nimbus.team', 'customer', '+1-408-555-0127', '1995-01-14',
'Engineering manager at Nimbus. Buys team gear in batches and expects invoices by email.',
'{"theme":"dark","notifications":{"email":true,"push":true},"locale":"en-IN","timezone":"America/Los_Angeles"}',
2100.00, 1, datetime('now','-50 minutes'), datetime('now','-270 days'), datetime('now','-50 minutes')),
(11, 'Liam Thompson', 'liam.t@rivermail.com', 'guest', NULL, '1999-06-05',
'University student window-shopping for a first mechanical keyboard. Has not completed a purchase yet.',
'{"theme":"system","notifications":{"email":false,"push":false},"locale":"en-US","timezone":"America/New_York"}',
0.00, 1, datetime('now','-7 days'), datetime('now','-240 days'), datetime('now','-7 days')),
(12, 'Sofia Andersen', 'sofia.andersen@nordic.design', 'customer', '+46-8-555-0733', '1989-10-28',
'Scandinavian design consultant. Values minimal packaging and fast, trackable shipping.',
'{"theme":"light","notifications":{"email":true,"push":false},"locale":"sv-SE","timezone":"Europe/Stockholm"}',
175.80, 1, datetime('now','-10 hours'), datetime('now','-210 days'), datetime('now','-10 hours')),
(13, 'Benjamin Wright', 'ben.wright@compose.ly', 'customer', '+1-503-555-0198', '1993-04-11',
'Music producer and software tinkerer. Always adds a USB-C cable to every order just in case.',
'{"theme":"dark","notifications":{"email":true,"push":true},"locale":"en-US","timezone":"America/Los_Angeles"}',
430.25, 1, datetime('now','-18 hours'), datetime('now','-180 days'), datetime('now','-18 hours')),
(14, 'Hannah Lee', 'hannah.lee@cloudscale.io', 'admin', '+1-206-555-0204', '1990-07-22',
'Platform reliability lead. Monitors the audit log and keeps the demo app settings up to date.',
'{"theme":"dark","notifications":{"email":true,"push":true},"locale":"en-US","timezone":"America/Los_Angeles"}',
3500.00, 1, datetime('now','-15 minutes'), datetime('now','-150 days'), datetime('now','-15 minutes')),
(15, 'Noah Fischer', 'noah.fischer@bytehaus.at', 'customer', '+43-1-555-0817', '1997-03-15',
'Vienna-based indie game dev. Needs low-latency peripherals and a quiet mechanical keyboard.',
'{"theme":"system","notifications":{"email":false,"push":true},"locale":"de-AT","timezone":"Europe/Vienna"}',
95.00, 1, datetime('now','-2 days'), datetime('now','-120 days'), datetime('now','-2 days')),
(16, 'Grace Okafor', 'grace.okafor@lift.ng', 'customer', '+234-1-555-0294', '1986-11-09',
'Remote team lead in Lagos. Orders standing desks and monitor arms for distributed teammates.',
'{"theme":"dark","notifications":{"email":true,"push":false},"locale":"en-NG","timezone":"Africa/Lagos"}',
1280.00, 1, datetime('now','-9 hours'), datetime('now','-90 days'), datetime('now','-9 hours')),
(17, 'Ethan Brooks', 'ethan.brooks@nullsecurity.dev', 'customer', '+1-720-555-0112', '1994-12-30',
'Security researcher with a dry sense of humor. Tests the demo with suspiciously long JSON blobs.',
'{"theme":"dark","notifications":{"email":false,"push":false},"locale":"en-US","timezone":"America/Denver"}',
12.49, 1, datetime('now','-5 days'), datetime('now','-60 days'), datetime('now','-5 days')),
(18, 'Mia Rossi', 'mia.rossi@artigiano.it', 'customer', '+39-02-555-0366', '1991-05-03',
'Graphic designer from Milan. Cares a lot about color-accurate monitors and clean desk lighting.',
'{"theme":"light","notifications":{"email":true,"push":true},"locale":"it-IT","timezone":"Europe/Rome"}',
725.60, 1, datetime('now','-1 day'), datetime('now','-30 days'), datetime('now','-1 day')),
(19, 'Alexander Petrov', 'alex.petrov@polycode.ru', 'customer', '+7-495-555-0412', '1984-08-18',
'Backend engineer in Moscow. Maintains a home lab and buys networking accessories in pairs.',
'{"theme":"system","notifications":{"email":true,"push":false},"locale":"ru-RU","timezone":"Europe/Moscow"}',
199.00, 0, datetime('now','-45 days'), datetime('now','-15 days'), datetime('now','-45 days')),
(20, 'Zoe Williams', 'zoe.williams@greenleaf.org', 'moderator', '+1-510-555-0285', '1992-02-25',
'Sustainability coordinator. Reviews product descriptions and pushes for paperless invoices.',
'{"theme":"light","notifications":{"email":true,"push":false},"locale":"en-US","timezone":"America/Los_Angeles"}',
88.00, 1, datetime('now','-6 hours'), datetime('now','-7 days'), datetime('now','-6 hours'));
-- ── Products: realistic SKUs, descriptions, and attributes ──────────────
INSERT OR IGNORE INTO products (id, sku, name, description, price, category_id, stock, rating, discontinued, attributes, created_at, updated_at) VALUES
(1, 'GL-MSE-001', 'Gridline Wireless Mouse', 'Ergonomic wireless mouse with silent switches, 2.4 GHz and Bluetooth 5.0, adjustable 800-1600 DPI, and a 12-month battery life.', 34.99, 1, 142, 4.6, 0, '{"color":"Graphite","wireless":true,"dpi_max":1600,"buttons":5}', datetime('now','-360 days'), datetime('now','-3 days')),
(2, 'GL-KBD-001', 'Gridline Mechanical Keyboard', 'Hot-swappable TKL board with tactile brown switches, per-key RGB, PBT keycaps, and a USB-C braided cable.', 119.99, 5, 68, 4.8, 0, '{"layout":"US ANSI","switches":"brown","backlit":true,"connection":"wired"}', datetime('now','-350 days'), datetime('now','-5 days')),
(3, 'GL-HUB-001', 'Gridline 7-in-1 USB-C Hub', 'Aluminium hub with 4K HDMI, 100 W power delivery, two USB-A 3.2 ports, SD/microSD slots, and a braided cable.', 44.99, 2, 215, 4.4, 0, '{"ports":7,"hdmi":"4K60","power_delivery":"100W","material":"aluminium"}', datetime('now','-340 days'), datetime('now','-2 days')),
(4, 'GL-MON-001', 'Gridline 27" 4K USB-C Monitor', '27-inch IPS panel, 3840×2160, 60 Hz, 99% sRGB, USB-C upstream with 90 W charging, fully adjustable stand.', 499.99, 6, 28, 4.7, 0, '{"resolution":"3840x2160","refresh_hz":60,"panel":"IPS","usb_c_power":90}', datetime('now','-330 days'), datetime('now','-8 days')),
(5, 'GL-STD-001', 'Gridline Aluminium Laptop Stand', 'Foldable aluminium stand with six height positions, ventilated design and silicone pads. Fits 12-16 inch laptops.', 54.99, 2, 108, 4.2, 0, '{"color":"Silver","max_height_mm":280,"foldable":true}', datetime('now','-320 days'), datetime('now','-10 days')),
(6, 'GL-CAM-001', 'Gridline 1080p Webcam', 'Full HD webcam with privacy shutter, dual noise-reducing mics, autofocus, and plug-and-play compatibility.', 69.99, 1, 0, 4.1, 1, '{"resolution":"1920x1080","fps":30,"microphone":true,"autofocus":true}', datetime('now','-310 days'), datetime('now','-60 days')),
(7, 'GL-LMP-001', 'Gridline LED Desk Lamp', 'Dimmable LED lamp with 2700-6500 K colour temperature, flexible neck, and a built-in USB-A charging port.', 42.99, 3, 134, 4.5, 0, '{"color_temp_k":"2700-6500","dimmable":true,"usb_port":true}', datetime('now','-300 days'), datetime('now','-6 days')),
(8, 'GL-CHR-001', 'Gridline Ergonomic Mesh Chair', 'Breathable mesh back, adjustable lumbar support, 4D armrests, and a gas lift rated up to 150 kg.', 649.99, 4, 12, 4.7, 0, '{"material":"mesh","lumbar_support":true,"max_weight_kg":150}', datetime('now','-290 days'), datetime('now','-15 days')),
(9, 'GL-CBL-001', 'Gridline Braided USB-C Cable 2 m', 'Braided USB-C to USB-C cable rated for 100 W charging and USB 3.2 data. Tested to 10,000 bends.', 16.99, 2, 480, 4.3, 0, '{"length_m":2,"charging_w":100,"data_speed":"USB 3.2","color":"Midnight"}', datetime('now','-280 days'), datetime('now','-4 days')),
(10, 'GL-ARM-001', 'Gridline Single Monitor Arm', 'Gas-spring monitor arm with 75/100 mm VESA, 360° rotation, and cable management. Supports up to 9 kg.', 89.99, 6, 36, 4.5, 0, '{"weight_capacity_kg":9,"vesa":"75/100","rotation":"360"}', datetime('now','-270 days'), datetime('now','-9 days')),
(11, 'GL-MSE-002', 'Gridline Pro Wireless Mouse', 'Premium wireless mouse with 4000 DPI sensor, USB-C rechargeable battery, and magnetic storage case.', 79.99, 1, 64, 4.7, 0, '{"color":"Pearl","wireless":true,"dpi_max":4000,"rechargeable":true}', datetime('now','-260 days'), datetime('now','-7 days')),
(12, 'GL-KBD-002', 'Gridline Compact Keyboard', '65% low-profile mechanical keyboard with red linear switches, white backlight, and Bluetooth/wired dual mode.', 94.99, 5, 91, 4.4, 0, '{"layout":"65%","switches":"red linear","backlit":true,"wireless":true}', datetime('now','-250 days'), datetime('now','-6 days')),
(13, 'GL-HUB-002', 'Gridline 10-in-1 Docking Station', 'Thunderbolt-compatible dock with dual 4K display support, 2.5 GbE, and 140 W power delivery.', 219.99, 2, 41, 4.6, 0, '{"ports":10,"ethernet":"2.5G","power_delivery":"140W","displays":2}', datetime('now','-240 days'), datetime('now','-3 days')),
(14, 'GL-MON-002', 'Gridline 32" Curved Monitor', '32-inch curved VA panel, 2560×1440, 165 Hz refresh rate, HDR400, and adaptive sync.', 429.99, 6, 19, 4.5, 0, '{"resolution":"2560x1440","refresh_hz":165,"panel":"VA","curved":true}', datetime('now','-230 days'), datetime('now','-11 days')),
(15, 'GL-DES-001', 'Gridline Standing Desk Frame', 'Electric sit-stand desk frame with dual motors, memory presets, and a quiet 45 dB lift mechanism.', 549.99, 4, 8, 4.6, 0, '{"width_adjustable":true,"memory_presets":4,"noise_db":45}', datetime('now','-220 days'), datetime('now','-12 days')),
(16, 'GL-KEY-001', 'Gridline Keycap Set', 'PBT dye-sub keycap set with 140 keys, compatible with most ANSI and ISO layouts.', 39.99, 5, 77, 4.3, 0, '{"keys":140,"material":"PBT","profile":"Cherry"}', datetime('now','-210 days'), datetime('now','-5 days')),
(17, 'GL-PAD-001', 'Gridline Desk Mat', 'Oversized felt desk mat with stitched edges and anti-slip base. 900 × 400 mm.', 29.99, 3, 156, 4.4, 0, '{"material":"felt","size_mm":"900x400","anti_slip":true}', datetime('now','-200 days'), datetime('now','-8 days')),
(18, 'GL-MIC-001', 'Gridline USB Microphone', 'Cardioid condenser mic with built-in pop filter, mute button, and 24-bit/96 kHz sampling.', 119.99, 1, 33, 4.5, 0, '{"pattern":"cardioid","sample_rate":"96kHz","bit_depth":24}', datetime('now','-190 days'), datetime('now','-9 days')),
(19, 'GL-LGT-001', 'Gridline Monitor Light Bar', 'Screen-hanging light bar with auto-dimming, warm/cool temperature control, and no glare on the panel.', 59.99, 3, 88, 4.2, 0, '{"mount":"screen_hanging","auto_dim":true,"color_temp_k":"3000-6500"}', datetime('now','-180 days'), datetime('now','-7 days')),
(20, 'GL-CBL-002', 'Gridline Magnetic Cable Trio', 'Set of three braided magnetic cables (USB-C, Lightning, Micro-USB) with one interchangeable tip dock.', 34.99, 2, 203, 4.0, 0, '{"cables":3,"tips":["USB-C","Lightning","Micro-USB"],"magnetic":true}', datetime('now','-170 days'), datetime('now','-6 days')),
(21, 'GL-BAG-001', 'Gridline Tech Pouch', 'Water-resistant tech pouch with elastic loops, mesh pockets, and a cable pass-through.', 44.99, 2, 119, 4.5, 0, '{"water_resistant":true,"compartments":8,"color":"Charcoal"}', datetime('now','-160 days'), datetime('now','-4 days')),
(22, 'GL-SPK-001', 'Gridline Desktop Speakers', 'Pair of powered bookshelf speakers with Bluetooth input, RCA/AUX, and solid wood enclosures.', 149.99, 1, 22, 4.3, 0, '{"pair":true,"inputs":["Bluetooth","RCA","AUX"],"enclosure":"wood"}', datetime('now','-150 days'), datetime('now','-10 days')),
(23, 'GL-TRP-001', 'Gridline Tripod Desk Lamp', 'Minimal tripod desk lamp with touch dimming, USB-C power, and a 360° rotating head.', 49.99, 3, 62, 4.1, 0, '{"base":"tripod","touch_dim":true,"power":"USB-C"}', datetime('now','-140 days'), datetime('now','-8 days')),
(24, 'GL-HDM-001', 'Gridline HDMI 2.1 Cable 1.5 m', 'Certified Ultra High Speed HDMI 2.1 cable supporting 8K@60 Hz and 4K@120 Hz, 48 Gbps.', 24.99, 2, 312, 4.4, 0, '{"length_m":1.5,"hdmi_version":"2.1","bandwidth_gbps":48}', datetime('now','-130 days'), datetime('now','-3 days'));
-- ── Addresses: 1-3 realistic addresses per user ────────────────────────
INSERT OR IGNORE INTO addresses (id, user_id, label, street, city, state, zip, country, is_primary, created_at) VALUES
(1, 1, 'Home', '1234 Mission Street, Apt 42', 'San Francisco', 'CA', '94103', 'USA', 1, datetime('now','-500 days')),
(2, 1, 'Work', '555 Montgomery Street, Floor 8', 'San Francisco', 'CA', '94111', 'USA', 0, datetime('now','-480 days')),
(3, 2, 'Home', '2100 Barton Creek Blvd', 'Austin', 'TX', '78735', 'USA', 1, datetime('now','-490 days')),
(4, 3, 'Home', '4542 11th Avenue NE', 'Seattle', 'WA', '98105', 'USA', 1, datetime('now','-470 days')),
(5, 3, 'Office', '7200 Woodlawn Ave NE', 'Seattle', 'WA', '98115', 'USA', 0, datetime('now','-460 days')),
(6, 4, 'Home', '850 7th Avenue, Apt 12B', 'New York', 'NY', '10019', 'USA', 1, datetime('now','-440 days')),
(7, 5, 'Home', '1600 Glenarm Place', 'Denver', 'CO', '80202', 'USA', 1, datetime('now','-410 days')),
(8, 5, 'Warehouse', '4650 Paris Street', 'Denver', 'CO', '80239', 'USA', 0, datetime('now','-400 days')),
(9, 6, 'Home', '44 Prince Street', 'Boston', 'MA', '02113', 'USA', 1, datetime('now','-380 days')),
(10, 7, 'Home', '1080 Arastradero Road', 'Palo Alto', 'CA', '94304', 'USA', 1, datetime('now','-350 days')),
(11, 8, 'Office', 'Friedrichstraße 123', 'Berlin', 'Berlin', '10117', 'Germany', 1, datetime('now','-320 days')),
(12, 9, 'Home', '1500 Ocean Drive, Apt 805', 'Miami Beach', 'FL', '33139', 'USA', 1, datetime('now','-290 days')),
(13, 10, 'Work', '555 Ellis Street', 'Mountain View', 'CA', '94043', 'USA', 1, datetime('now','-260 days')),
(14, 11, 'Home', '1925 SE Hawthorne Blvd', 'Portland', 'OR', '97214', 'USA', 1, datetime('now','-230 days')),
(15, 12, 'Office', 'Birger Jarlsgatan 58', 'Stockholm', 'Stockholm', '111 45', 'Sweden', 1, datetime('now','-200 days')),
(16, 13, 'Home', '925 W 5th Avenue', 'Chicago', 'IL', '60642', 'USA', 1, datetime('now','-170 days')),
(17, 14, 'Home', '2211 7th Avenue', 'Seattle', 'WA', '98121', 'USA', 1, datetime('now','-140 days')),
(18, 15, 'Home', 'Opernring 5', 'Vienna', 'Vienna', '1010', 'Austria', 1, datetime('now','-110 days')),
(19, 16, 'Home', '12 Ikoyi Crescent', 'Lagos', 'Lagos', '101233', 'Nigeria', 1, datetime('now','-80 days')),
(20, 17, 'Home', '1600 Walnut Street, Unit 300', 'Denver', 'CO', '80202', 'USA', 1, datetime('now','-50 days')),
(21, 18, 'Home', 'Via Tortona 12', 'Milan', 'MI', '20144', 'Italy', 1, datetime('now','-25 days')),
(22, 19, 'Home', 'Ulitsa Bolshaya Dmitrovka 9', 'Moscow', 'Moscow', '125009', 'Russia', 1, datetime('now','-10 days')),
(23, 20, 'Home', '1900 Broadway', 'Oakland', 'CA', '94612', 'USA', 1, datetime('now','-5 days'));
-- ── Files: realistic file attachments with BLOB headers ─────────────────
INSERT OR IGNORE INTO files (id, name, mime_type, content, size_bytes, uploaded_by, uploaded_at) VALUES
(1, 'gridline_logo.png', 'image/png', X'89504E470D0A1A0A0000000D49484452000001000000010008060000005C72A866000000017352474200AECE1CE90000000467414D410000B18F0BFC61050000', 14256, 1, datetime('now','-45 days')),
(2, 'product_photos.zip', 'application/zip', X'504B03040A00000000008B6B2D570000000000000000000000000800000070726F64756374732F504B0102001F000A00000000008B6B2D57000000000000000000000000080000000000000000000000A4810000000070726F64756374732F504B050600000000010001003A0000001A0000000000', 28934, 2, datetime('now','-42 days')),
(3, 'invoice_2026_001.pdf', 'application/pdf', X'255044462D312E340A25E2E3CFD30A342030206F626A0A3C3C202F4C696E656172697A65642031202F4C2031203E3E0A3E3E0A73747265616D0A0A42510A0A656E6473747265616D0A656E646F626A0A', 18432, 10, datetime('now','-38 days')),
(4, 'shipping_labels.pdf', 'application/pdf', X'255044462D312E340A25E2E3CFD30A342030206F626A0A3C3C202F54797065202F436174616C6F67202F50616765732031302020302020520A2F4F75746C696E65732032302020302020520A3E3E0A656E646F626A0A', 12288, 14, datetime('now','-30 days')),
(5, 'user_avatars.jpg', 'image/jpeg', X'FFD8FFE000104A46494600010100000100010000FFDB004300080606070605080707070909080A0C140D0C0B0B0C1912130F14311A1F1F1A1C232D', 8934, 1, datetime('now','-25 days')),
(6, 'q4_inventory.csv', 'text/csv', X'69642C6E616D652C73746F636B2C72657365727665640A312C47726E646C696E6520576972656C657373204D6F7573652C3134322C300A322C47726E646C696E65204D656368616E6963616C204B6579626F6172642C36382C350A', 5632, 14, datetime('now','-18 days')),
(7, 'backup.sql', 'application/sql', X'2D2D20477269646C696E652064656D6F206261636B75700A50524F4752416D757365725F76657273696F6E203D20333B0A435245415445205441424C45204946204E4F542045584953545320757365727320282E2E2E293B', 4096, 14, datetime('now','-7 days')),
(8, 'favicon.ico', 'image/x-icon', X'00000100010010100000000020006804000016000000280000001000000020000000010008', 4286, 1, datetime('now','-3 days'));
-- ── App settings: TEXT primary key ──────────────────────────────────────
INSERT OR IGNORE INTO app_settings (key, value, updated_at) VALUES
('site_name', 'Gridline Demo Store', datetime('now','-60 days')),
('maintenance_mode', 'false', datetime('now','-5 days')),
('max_cart_items', '50', datetime('now','-30 days')),
('currency', 'USD', datetime('now','-60 days')),
('default_shipping_country', 'USA', datetime('now','-20 days')),
('support_email', 'support@gridline.dev', datetime('now','-10 days'));
-- ═══════════════════════════════════════════════════════════════════════
-- Generated data: orders, line items, page views, audit log
-- ═══════════════════════════════════════════════════════════════════════
-- 50 realistic orders across the user base with varied statuses and dates.
WITH RECURSIVE order_seq(n) AS (
SELECT 1 UNION ALL SELECT n + 1 FROM order_seq WHERE n < 50
)
INSERT OR IGNORE INTO orders (id, user_id, shipping_address_id, total, status, notes, created_at, updated_at, shipped_at)
SELECT
n,
-- Distribute orders across users 1-18; users 19 (inactive) and 20 (recent) get fewer.
CASE
WHEN n <= 35 THEN ((n - 1) % 18) + 1
WHEN n <= 45 THEN ((n - 1) % 12) + 1
ELSE ((n - 1) % 8) + 1
END,
-- Pick a primary or secondary address for that user if one exists.
NULL,
0, -- total recalculated from line items below
CASE n % 12
WHEN 0 THEN 'pending'
WHEN 1 THEN 'processing'
WHEN 2 THEN 'shipped'
WHEN 3 THEN 'delivered'
WHEN 4 THEN 'completed'
WHEN 5 THEN 'cancelled'
WHEN 6 THEN 'refunded'
WHEN 7 THEN 'shipped'
WHEN 8 THEN 'processing'
WHEN 9 THEN 'completed'
WHEN 10 THEN 'pending'
ELSE 'delivered'
END,
CASE n % 8
WHEN 0 THEN 'Please leave the package at the front desk.'
WHEN 1 THEN 'Gift wrap, please.'
WHEN 2 THEN 'Customer requested eco-friendly packaging.'
WHEN 3 THEN 'Ship after the 15th — office move in progress.'
WHEN 4 THEN NULL
WHEN 5 THEN 'Call before delivery.'
WHEN 6 THEN 'Authority to leave if not home.'
ELSE NULL
END,
datetime('now', printf('-%d days', 60 - (n % 58))),
datetime('now', printf('-%d days', 58 - (n % 56))),
CASE WHEN n % 12 IN (2,3,4,7,11) THEN datetime('now', printf('-%d days', 55 - (n % 53))) ELSE NULL END
FROM order_seq;
-- Assign a realistic shipping address to each order from the user's address set.
UPDATE orders SET shipping_address_id = (
SELECT a.id FROM addresses a
WHERE a.user_id = orders.user_id
ORDER BY a.is_primary DESC, a.id
LIMIT 1
);
-- 100+ order line items: 1-3 products per order.
WITH RECURSIVE line_seq(n) AS (
SELECT 1 UNION ALL SELECT n + 1 FROM line_seq WHERE n < 105
)
INSERT OR IGNORE INTO order_items (order_id, product_id, quantity, unit_price)
SELECT
((n - 1) % 50) + 1,
((n - 1) % 24) + 1,
CASE n % 5 WHEN 0 THEN 3 WHEN 1 THEN 2 ELSE 1 END,
(SELECT price FROM products WHERE id = ((n - 1) % 24) + 1)
FROM line_seq;
-- Recalculate order totals from line items.
UPDATE orders SET total = (
SELECT ROUND(SUM(quantity * unit_price), 2)
FROM order_items
WHERE order_items.order_id = orders.id
);
-- 100 page views with realistic user agents and URLs.
WITH RECURSIVE pv_seq(n) AS (
SELECT 1 UNION ALL SELECT n + 1 FROM pv_seq WHERE n < 100
)
INSERT INTO page_views (url, session_id, user_agent, viewed_at)
SELECT
CASE n % 10
WHEN 0 THEN '/products'
WHEN 1 THEN '/products/' || ((n % 24) + 1)
WHEN 2 THEN '/cart'
WHEN 3 THEN '/checkout'
WHEN 4 THEN '/orders'
WHEN 5 THEN '/settings'
WHEN 6 THEN '/categories/electronics'
WHEN 7 THEN '/categories/office'
WHEN 8 THEN '/search?q=keyboard'
ELSE '/'
END,
'sess-' || printf('%03d', (n % 30) + 1),
CASE n % 6
WHEN 0 THEN 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36'
WHEN 1 THEN 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36'
WHEN 2 THEN 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1'
WHEN 3 THEN 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36'
WHEN 4 THEN 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:128.0) Gecko/20100101 Firefox/128.0'
ELSE 'Mozilla/5.0 (iPad; CPU OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1'
END,
datetime('now', printf('-%d minutes', n * 13))
FROM pv_seq
WHERE NOT EXISTS (SELECT 1 FROM page_views);
-- 500 audit-log rows for pagination / virtualization / filtering demos.
WITH RECURSIVE audit_seq(n) AS (
SELECT 1 UNION ALL SELECT n + 1 FROM audit_seq WHERE n < 500
)
INSERT INTO audit_log (user_id, action, entity_type, entity_id, severity, details, duration_ms, created_at)
SELECT
CASE WHEN n % 9 = 0 THEN NULL ELSE ((n - 1) % 18) + 1 END,
CASE n % 8
WHEN 0 THEN 'login'
WHEN 1 THEN 'page_view'
WHEN 2 THEN 'update'
WHEN 3 THEN 'create'
WHEN 4 THEN 'delete'
WHEN 5 THEN 'export'
WHEN 6 THEN 'refund'
ELSE 'ship'
END,
CASE n % 5
WHEN 0 THEN 'order'
WHEN 1 THEN 'product'
WHEN 2 THEN 'user'
WHEN 3 THEN 'address'
ELSE 'report'
END,
(n % 50) + 1,
CASE n % 6
WHEN 0 THEN 'info'
WHEN 1 THEN 'info'
WHEN 2 THEN 'warning'
WHEN 3 THEN 'error'
WHEN 4 THEN 'critical'
ELSE 'warning'
END,
CASE WHEN n % 7 = 0 THEN NULL
ELSE '{"page":"' ||
CASE n % 4 WHEN 0 THEN '/' WHEN 1 THEN '/products' WHEN 2 THEN '/checkout' ELSE '/orders' END ||
'","retries":' || (n % 3) || ',"row":' || n || ',"region":"' ||
CASE n % 5 WHEN 0 THEN 'us-east' WHEN 1 THEN 'us-west' WHEN 2 THEN 'eu-central' WHEN 3 THEN 'ap-south' ELSE 'sa-east' END ||
'"}'
END,
(n * 37) % 2500,
datetime('now', printf('-%d minutes', n * 7))
FROM audit_seq
WHERE NOT EXISTS (SELECT 1 FROM audit_log);
+18 -14
View File
@@ -14,10 +14,7 @@ pub fn get_folders_inner(state: &Mutex<Store>) -> Result<Vec<Folder>, String> {
store.get_folders()
}
pub fn create_folder_inner(
state: &Mutex<Store>,
input: FolderInput,
) -> Result<Folder, String> {
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)
@@ -98,12 +95,15 @@ mod tests {
#[test]
fn create_folder_command_works() {
let st = state();
let folder =
create_folder_inner(&st, FolderInput { tag_ids: None,
let folder = create_folder_inner(
&st,
FolderInput {
tag_ids: None,
name: "Work".into(),
parent_id: None,
})
.unwrap();
},
)
.unwrap();
assert_eq!(get_folders_inner(&st).unwrap().len(), 1);
assert_eq!(folder.name, "Work");
}
@@ -113,7 +113,8 @@ mod tests {
let st = state();
let result = create_folder_inner(
&st,
FolderInput { tag_ids: None,
FolderInput {
tag_ids: None,
name: "".into(),
parent_id: None,
},
@@ -124,13 +125,16 @@ mod tests {
#[test]
fn delete_folder_command_works() {
let st = state();
let folder =
create_folder_inner(&st, FolderInput { tag_ids: None,
let folder = create_folder_inner(
&st,
FolderInput {
tag_ids: None,
name: "Work".into(),
parent_id: None,
})
.unwrap();
},
)
.unwrap();
delete_folder_inner(&st, &folder.id).unwrap();
assert_eq!(get_folders_inner(&st).unwrap().len(), 0);
}
}
}
+54 -17
View File
@@ -33,7 +33,8 @@ pub struct ImportResult {
#[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))?;
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));
@@ -45,8 +46,12 @@ pub fn parse_import(json: &str) -> Result<Vec<ImportRecord>, String> {
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))?;
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();
@@ -54,16 +59,25 @@ pub fn import_connections_inner(state: &Mutex<Store>, json: String) -> Result<Im
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() });
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) });
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() });
skipped_records.push(SkippedRecord {
index: i,
reason: "missing or empty host".into(),
});
continue;
}
let input = ConnectionInput {
@@ -91,10 +105,17 @@ pub fn import_connections_inner(state: &Mutex<Store>, json: String) -> Result<Im
};
match store.create_connection(input) {
Ok(_) => imported += 1,
Err(e) => skipped_records.push(SkippedRecord { index: i, reason: e }),
Err(e) => skipped_records.push(SkippedRecord {
index: i,
reason: e,
}),
}
}
Ok(ImportResult { imported, skipped: skipped_records.len(), skipped_records })
Ok(ImportResult {
imported,
skipped: skipped_records.len(),
skipped_records,
})
}
pub fn export_connections_inner(state: &Mutex<Store>) -> Result<String, String> {
@@ -105,7 +126,10 @@ pub fn export_connections_inner(state: &Mutex<Store>) -> Result<String, String>
}
#[tauri::command]
pub fn import_connections(state: tauri::State<crate::AppState>, json: String) -> Result<ImportResult, String> {
pub fn import_connections(
state: tauri::State<crate::AppState>,
json: String,
) -> Result<ImportResult, String> {
import_connections_inner(&state.db_store, json)
}
@@ -117,8 +141,8 @@ pub fn export_connections(state: tauri::State<crate::AppState>) -> Result<String
#[cfg(test)]
mod tests {
use super::*;
use crate::store::Store;
use crate::models::ConnectionInput;
use crate::store::Store;
fn state() -> std::sync::Mutex<Store> {
let conn = rusqlite::Connection::open_in_memory().unwrap();
@@ -169,12 +193,25 @@ mod tests {
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,
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![],
});
@@ -182,4 +219,4 @@ mod tests {
assert!(json.contains("\"name\""));
assert!(json.contains("\"version\""));
}
}
}
+1 -1
View File
@@ -190,4 +190,4 @@ mod tests {
assert_eq!(ssh_account("password", "c1"), "ssh_password:c1");
assert_eq!(ssh_account("passphrase", "c1"), "ssh_passphrase:c1");
}
}
}
+9 -9
View File
@@ -1,13 +1,13 @@
pub mod backup;
pub mod connections;
pub mod db_viewer;
pub mod folders;
pub mod tags;
pub mod settings;
pub mod import_export;
pub mod test_connection;
pub mod ssh;
pub mod keychain;
pub mod demo;
pub mod backup;
pub mod folders;
pub mod import_export;
pub mod keychain;
pub mod query;
pub mod schema_graph;
pub mod query;
pub mod settings;
pub mod ssh;
pub mod tags;
pub mod test_connection;
+36 -40
View File
@@ -99,9 +99,7 @@ pub(crate) async fn execute_query_inner(
Some(DbHandle::Postgresql(client, _)) => {
execute_pg_query(client, query, page, page_size).await
}
Some(DbHandle::Sqlite(conn)) => {
execute_sqlite_query(conn, query, page, page_size)
}
Some(DbHandle::Sqlite(conn)) => execute_sqlite_query(conn, query, page, page_size),
None => {
let elapsed = start.elapsed().as_millis() as i64;
let err = "Connection not found".to_string();
@@ -205,16 +203,10 @@ async fn execute_pg_query(
"SELECT * FROM ({}) AS _gridline_data LIMIT $1 OFFSET $2",
trimmed
);
let wrapped_count = format!(
"SELECT COUNT(*) FROM ({}) AS _gridline_cnt",
trimmed
);
let wrapped_count = format!("SELECT COUNT(*) FROM ({}) AS _gridline_cnt", trimmed);
// Try the wrapped count query first — if this fails we fall back to raw.
let total_rows: i64 = match client
.query_one(&wrapped_count, &[])
.await
{
let total_rows: i64 = match client.query_one(&wrapped_count, &[]).await {
Ok(row) => row.get::<_, i64>(0),
Err(_) => {
// Wrapping failed — fall back to raw execution.
@@ -343,11 +335,7 @@ async fn execute_pg_raw(
let ulimit = page_size as usize;
let rows: Vec<Vec<serde_json::Value>> = if uoffset < all_rows.len() {
all_rows
.into_iter()
.skip(uoffset)
.take(ulimit)
.collect()
all_rows.into_iter().skip(uoffset).take(ulimit).collect()
} else {
Vec::new()
};
@@ -386,10 +374,7 @@ fn execute_sqlite_query(
"SELECT * FROM ({}) AS _gridline_data LIMIT {} OFFSET {}",
trimmed, page_size, off
);
let wrapped_count = format!(
"SELECT COUNT(*) FROM ({}) AS _gridline_cnt",
trimmed
);
let wrapped_count = format!("SELECT COUNT(*) FROM ({}) AS _gridline_cnt", trimmed);
// Try the wrapped count query first.
let total_rows: i64 = match conn.query_row(&wrapped_count, [], |row| row.get::<_, i64>(0)) {
@@ -478,11 +463,7 @@ fn execute_sqlite_raw(
let ulimit = page_size as usize;
let rows: Vec<Vec<serde_json::Value>> = if uoffset < all_rows.len() {
all_rows
.into_iter()
.skip(uoffset)
.take(ulimit)
.collect()
all_rows.into_iter().skip(uoffset).take(ulimit).collect()
} else {
Vec::new()
};
@@ -508,12 +489,8 @@ fn sqlite_value_to_json(row: &rusqlite::Row, i: usize) -> serde_json::Value {
Ok(ValueRef::Null) => serde_json::Value::Null,
Ok(ValueRef::Integer(v)) => serde_json::json!(v),
Ok(ValueRef::Real(v)) => serde_json::json!(v),
Ok(ValueRef::Text(v)) => {
serde_json::Value::String(String::from_utf8_lossy(v).to_string())
}
Ok(ValueRef::Blob(v)) => {
serde_json::Value::String(format!("[{}B blob]", v.len()))
}
Ok(ValueRef::Text(v)) => serde_json::Value::String(String::from_utf8_lossy(v).to_string()),
Ok(ValueRef::Blob(v)) => serde_json::Value::String(format!("[{}B blob]", v.len())),
Err(_) => serde_json::Value::Null,
}
}
@@ -580,7 +557,12 @@ pub(crate) fn update_saved_query_inner(
folder: Option<String>,
) -> Result<(), String> {
let store = db_store.lock().map_err(|e| e.to_string())?;
store.update_saved_query(&id, name.as_deref(), query_text.as_deref(), folder.as_deref())
store.update_saved_query(
&id,
name.as_deref(),
query_text.as_deref(),
folder.as_deref(),
)
}
pub(crate) fn delete_saved_query_inner(
@@ -671,7 +653,13 @@ pub async fn update_saved_query(
patch: UpdateSavedQueryPatch,
state: State<'_, crate::AppState>,
) -> Result<(), String> {
update_saved_query_inner(&state.db_store, id, patch.name, patch.query_text, patch.folder)
update_saved_query_inner(
&state.db_store,
id,
patch.name,
patch.query_text,
patch.folder,
)
}
#[tauri::command]
@@ -768,9 +756,13 @@ mod tests {
let conn = test_sqlite_handle();
// Page 1: 2 rows
let result =
execute_sqlite_query(&unwrap_sqlite(&conn), "SELECT * FROM users ORDER BY id", 1, 2)
.unwrap();
let result = execute_sqlite_query(
&unwrap_sqlite(&conn),
"SELECT * FROM users ORDER BY id",
1,
2,
)
.unwrap();
assert_eq!(result.total_rows, 3);
assert_eq!(result.rows.len(), 2);
@@ -780,9 +772,13 @@ mod tests {
assert_eq!(result.page_size, 2);
// Page 2: 1 row
let result2 =
execute_sqlite_query(&unwrap_sqlite(&conn), "SELECT * FROM users ORDER BY id", 2, 2)
.unwrap();
let result2 = execute_sqlite_query(
&unwrap_sqlite(&conn),
"SELECT * FROM users ORDER BY id",
2,
2,
)
.unwrap();
assert_eq!(result2.total_rows, 3);
assert_eq!(result2.rows.len(), 1);
@@ -963,4 +959,4 @@ mod tests {
_ => panic!("Expected Sqlite handle"),
}
}
}
}
+135 -57
View File
@@ -1,6 +1,6 @@
#[allow(unused_imports)]
use crate::db::pool::DbHandle;
use crate::models::db_viewer::{SchemaGraph, TableNode, GraphColumn, Relationship};
use crate::models::db_viewer::{GraphColumn, Relationship, SchemaGraph, TableNode};
use std::collections::HashMap;
use tauri::State;
@@ -76,7 +76,8 @@ 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()
ORDER BY c.relname, a.attnum"#
.to_string()
}
/// Infer relationship cardinality from constraint metadata.
@@ -203,14 +204,19 @@ fn build_sqlite_schema_graph(
schema: &str,
) -> Result<SchemaGraph, String> {
if schema != "main" {
return Err(format!("SQLite only supports schema 'main', got: {}", schema));
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)?)))
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
@@ -222,19 +228,27 @@ fn build_sqlite_schema_graph(
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)?,
)))
.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)?,
)))
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)))
@@ -243,35 +257,51 @@ fn build_sqlite_schema_graph(
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();
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,
name: table_name.clone(),
schema: "main".into(),
table_type: table_type.to_uppercase(),
columns,
});
}
Ok(SchemaGraph { tables, relationships })
Ok(SchemaGraph {
tables,
relationships,
})
}
#[tauri::command]
@@ -302,7 +332,10 @@ pub async fn get_schema_graph(
.collect();
let (tables, relationships) = parse_pg_schema_rows(&json_rows);
Ok(SchemaGraph { tables, relationships })
Ok(SchemaGraph {
tables,
relationships,
})
}
Some(DbHandle::Sqlite(conn)) => build_sqlite_schema_graph(conn, &schema),
None => Err("Connection not found".into()),
@@ -352,17 +385,30 @@ mod tests {
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);
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'");
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");
assert!(
sql.contains("pg_catalog.pg_attribute"),
"should query pg_attribute"
);
assert!(
sql.contains("pg_catalog.pg_constraint"),
"should include constraint info"
);
}
#[test]
@@ -397,34 +443,66 @@ mod tests {
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!("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!("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!("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!("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),
],
];
@@ -456,4 +534,4 @@ mod tests {
assert!(tables.is_empty());
assert!(relationships.is_empty());
}
}
}
+6 -2
View File
@@ -18,7 +18,11 @@ pub fn get_settings(state: tauri::State<crate::AppState>) -> Result<Settings, St
}
#[tauri::command]
pub fn update_setting(state: tauri::State<crate::AppState>, key: String, value: String) -> Result<(), String> {
pub fn update_setting(
state: tauri::State<crate::AppState>,
key: String,
value: String,
) -> Result<(), String> {
update_setting_inner(&state.db_store, &key, &value)
}
@@ -55,4 +59,4 @@ mod tests {
update_setting_inner(&st, "accent_color", "#EF4444").unwrap();
assert_eq!(get_settings_inner(&st).unwrap().accent_color, "#EF4444");
}
}
}
+8 -5
View File
@@ -165,13 +165,16 @@ impl TunnelBackend for Ssh2Backend {
.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}"))?;
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()
})?;
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}"))?;
@@ -420,4 +423,4 @@ mod tests {
mgr.close_all();
assert_eq!(mgr.active_count(), 0);
}
}
}
+18 -9
View File
@@ -25,11 +25,7 @@ pub fn delete_tag_inner(state: &Mutex<Store>, id: &str) -> Result<(), String> {
store.delete_tag(id)
}
pub fn update_tag_inner(
state: &Mutex<Store>,
id: String,
input: TagInput,
) -> Result<Tag, String> {
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)
@@ -62,8 +58,8 @@ pub fn update_tag(
#[cfg(test)]
mod tests {
use super::*;
use crate::store::Store;
use crate::models::TagInput;
use crate::store::Store;
fn state() -> std::sync::Mutex<Store> {
let conn = rusqlite::Connection::open_in_memory().unwrap();
@@ -74,7 +70,14 @@ mod tests {
#[test]
fn create_tag_command_works() {
let st = state();
let tag = create_tag_inner(&st, TagInput { name: "prod".into(), color: "#ef4444".into() }).unwrap();
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");
}
@@ -82,7 +85,13 @@ mod tests {
#[test]
fn create_tag_rejects_long_name() {
let st = state();
let result = create_tag_inner(&st, TagInput { name: "x".repeat(51), color: "#fff".into() });
let result = create_tag_inner(
&st,
TagInput {
name: "x".repeat(51),
color: "#fff".into(),
},
);
assert!(result.is_err());
}
}
}
+21 -20
View File
@@ -126,8 +126,7 @@ pub fn validate_test_input(config: &DbConfig) -> Option<String> {
Some(p) if (1..=65535).contains(&p) => {}
_ => {
return Some(
"port must be an integer between 1 and 65535 for this db_type"
.to_string(),
"port must be an integer between 1 and 65535 for this db_type".to_string(),
);
}
}
@@ -207,10 +206,7 @@ fn close_probe_tunnel(ssh: &SshManager, key: Option<&str>) {
/// 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 {
pub async fn test_database_connection(config: &DbConfig, ssh: &SshManager) -> TestConnectionResult {
// Validate input first
if let Some(err) = validate_test_input(config) {
return TestConnectionResult {
@@ -304,8 +300,7 @@ async fn test_pg_connection(config: &DbConfig, ssh: &SshManager) -> TestConnecti
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());
let connector = tokio_postgres_rustls::MakeRustlsConnect::new((*cc).clone());
crate::commands::db_viewer::connect_pg_with(&pgconfig, connector).await
}
};
@@ -405,11 +400,10 @@ async fn test_mysql_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
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 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 {
@@ -441,9 +435,7 @@ fn test_sqlite_connection(config: &DbConfig) -> TestConnectionResult {
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)
})
.query_row("SELECT sqlite_version()", [], |r| r.get::<_, String>(0))
.ok();
TestConnectionResult {
ok: true,
@@ -611,9 +603,18 @@ mod tests {
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("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");
}
@@ -722,4 +723,4 @@ mod tests {
"sqlite with any port should be accepted"
);
}
}
}