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:
+333
-374
@@ -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(¶ms, &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(¶ms, &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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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, ¶m_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, ¶m_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,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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
PRAGMA user_version = 3;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- Gridline Demo (SQLite) — realistic e-commerce dataset
|
||||
--
|
||||
-- Designed to exercise every SQLite-aware Gridline feature:
|
||||
-- • PK/FK/composite-PK/self-FK metadata
|
||||
-- • JSON cells + JSON popover
|
||||
-- • BLOBs
|
||||
-- • CHECK / UNIQUE constraints, defaults
|
||||
-- • indexes
|
||||
-- • views
|
||||
-- • an empty table (Empty Table change demo)
|
||||
-- • a no-PK table (rowid row-locator editing)
|
||||
-- • a TEXT primary key
|
||||
-- • a 500-row table for pagination / virtualization / filtering
|
||||
-- • nullable columns, long text, smart-sort tiers
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- ── Core: users ─────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
role TEXT NOT NULL DEFAULT 'customer' CHECK (role IN ('admin','moderator','customer','guest')),
|
||||
phone TEXT,
|
||||
birth_date TEXT,
|
||||
bio TEXT,
|
||||
preferences json,
|
||||
balance REAL NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
last_login_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ── Core: categories (self-referencing FK) ──────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
parent_id INTEGER REFERENCES categories(id),
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
description TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ── Core: products ──────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sku TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
price REAL NOT NULL CHECK (price >= 0),
|
||||
category_id INTEGER REFERENCES categories(id),
|
||||
stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
|
||||
rating REAL CHECK (rating IS NULL OR (rating >= 0 AND rating <= 5)),
|
||||
discontinued INTEGER NOT NULL DEFAULT 0,
|
||||
attributes json,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ── Core: addresses (1:N from users, FK dropdown editor) ────────────────
|
||||
CREATE TABLE IF NOT EXISTS addresses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
label TEXT NOT NULL DEFAULT 'Home',
|
||||
street TEXT NOT NULL,
|
||||
city TEXT NOT NULL,
|
||||
state TEXT,
|
||||
zip TEXT,
|
||||
country TEXT NOT NULL DEFAULT 'USA',
|
||||
is_primary INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ── Core: orders (FKs -> users and addresses; CHECK status) ─────────────
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
shipping_address_id INTEGER REFERENCES addresses(id),
|
||||
total REAL NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending','processing','shipped','delivered','completed','cancelled','refunded')),
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
shipped_at TEXT
|
||||
);
|
||||
|
||||
-- ── Core: order_items (composite PRIMARY KEY; CHECK) ───────────────────
|
||||
CREATE TABLE IF NOT EXISTS order_items (
|
||||
order_id INTEGER NOT NULL REFERENCES orders(id),
|
||||
product_id INTEGER NOT NULL REFERENCES products(id),
|
||||
quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity > 0),
|
||||
unit_price REAL NOT NULL,
|
||||
PRIMARY KEY (order_id, product_id)
|
||||
);
|
||||
|
||||
-- ── Big table for pagination / virtualization / filtering (500 rows) ──
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
action TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id INTEGER,
|
||||
severity TEXT NOT NULL DEFAULT 'info'
|
||||
CHECK (severity IN ('info','warning','error','critical')),
|
||||
details json,
|
||||
duration_ms INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ── BLOB demo ───────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
mime_type TEXT NOT NULL,
|
||||
content BLOB,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
uploaded_by INTEGER REFERENCES users(id),
|
||||
uploaded_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ── No primary key on purpose: exercises the rowid row-locator editing ──
|
||||
CREATE TABLE IF NOT EXISTS page_views (
|
||||
url TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
user_agent TEXT,
|
||||
viewed_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ── Non-integer (TEXT) primary key ─────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ── Deliberately empty: exercises the Empty Table change + empty state ──
|
||||
CREATE TABLE IF NOT EXISTS marketing_campaigns (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
budget REAL,
|
||||
starts_at TEXT,
|
||||
ends_at TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'draft'
|
||||
CHECK (status IN ('draft','active','paused','completed','cancelled'))
|
||||
);
|
||||
|
||||
-- ── Read-only view for the query editor / ER diagram ───────────────────
|
||||
CREATE VIEW IF NOT EXISTS order_summary AS
|
||||
SELECT
|
||||
o.id AS order_id,
|
||||
u.name AS customer_name,
|
||||
COUNT(oi.product_id) AS item_count,
|
||||
o.total AS order_total,
|
||||
o.status,
|
||||
o.created_at
|
||||
FROM orders o
|
||||
JOIN users u ON u.id = o.user_id
|
||||
LEFT JOIN order_items oi ON oi.order_id = o.id
|
||||
GROUP BY o.id, u.name, o.total, o.status, o.created_at;
|
||||
|
||||
-- ── Indexes (surface in the copied DDL) ─────────────────────────────────
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_user_id ON orders(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_order_items_product_id ON order_items(product_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_page_views_viewed_at ON page_views(viewed_at);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- Seed data
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- ── Categories ──────────────────────────────────────────────────────────
|
||||
INSERT OR IGNORE INTO categories (id, name, parent_id, slug, sort_order, description, created_at) VALUES
|
||||
(1, 'Electronics', NULL, 'electronics', 1, 'Computers, phones, and peripherals.', datetime('now','-400 days')),
|
||||
(2, 'Accessories', NULL, 'accessories', 2, 'Cables, cases, hubs, and add-ons.', datetime('now','-400 days')),
|
||||
(3, 'Office', NULL, 'office', 3, 'Furniture, lighting, and desk essentials.', datetime('now','-400 days')),
|
||||
(4, 'Furniture', NULL, 'furniture', 4, 'Chairs, desks, shelves, and storage.', datetime('now','-380 days')),
|
||||
(5, 'Keyboards', 1, 'keyboards', 1, 'Mechanical and membrane keyboards.', datetime('now','-300 days')),
|
||||
(6, 'Monitors', 1, 'monitors', 2, 'External displays and monitor arms.', datetime('now','-300 days'));
|
||||
|
||||
-- ── Users: a realistic mix of admins, moderators, customers, and guests ───
|
||||
INSERT OR IGNORE INTO users (id, name, email, role, phone, birth_date, bio, preferences, balance, is_active, last_login_at, created_at, updated_at) VALUES
|
||||
(1, 'Sarah Chen', 'sarah.chen@acme.dev', 'admin', '+1-415-555-0101', '1990-04-12',
|
||||
'Founding engineer at Acme Dev. Manages the catalog, reviews flagged orders, and keeps the demo data looking sharp.',
|
||||
'{"theme":"dark","notifications":{"email":true,"push":true},"locale":"en-US","timezone":"America/Los_Angeles"}',
|
||||
1240.75, 1, datetime('now','-35 minutes'), datetime('now','-540 days'), datetime('now','-35 minutes')),
|
||||
(2, 'Marcus Johnson', 'marcus.j@pixelforge.studio', 'moderator', '+1-512-555-0192', '1985-11-30',
|
||||
'Customer-success lead and part-time photographer. Moderates reviews, handles refunds, and tracks the weekly audit log.',
|
||||
'{"theme":"system","notifications":{"email":true,"push":false},"locale":"en-GB","timezone":"America/Chicago"}',
|
||||
48.20, 1, datetime('now','-6 hours'), datetime('now','-510 days'), datetime('now','-6 hours')),
|
||||
(3, 'Emily Rodriguez', 'emily.r@techbloom.io', 'customer', '+1-206-555-0143', '1993-07-19',
|
||||
'Remote frontend developer based in Seattle. Buys desk accessories in bulk and leaves detailed product reviews.',
|
||||
'{"theme":"light","notifications":{"email":true,"push":true},"locale":"en-US","timezone":"America/Los_Angeles"}',
|
||||
315.50, 1, datetime('now','-2 days'), datetime('now','-480 days'), datetime('now','-2 days')),
|
||||
(4, 'David Kim', 'david.kim@outlook.com', 'customer', '+1-212-555-0178', '1988-03-04',
|
||||
'Freelance sound engineer in New York. Orders AV gear and cables regularly for his home studio.',
|
||||
'{"theme":"dark","notifications":{"email":false,"push":true},"locale":"ko-KR","timezone":"America/New_York"}',
|
||||
0.00, 1, datetime('now','-4 days'), datetime('now','-450 days'), datetime('now','-4 days')),
|
||||
(5, 'Aisha Patel', 'aisha.patel@horizonlabs.co', 'customer', '+1-303-555-0165', '1996-02-08',
|
||||
'Data analyst and ergonomic-chair evangelist. Currently outfitting a standing-desk setup for her team.',
|
||||
'{"theme":"dark","notifications":{"email":true,"push":false},"locale":"en-US","timezone":"America/Denver"}',
|
||||
892.10, 1, datetime('now','-12 hours'), datetime('now','-420 days'), datetime('now','-12 hours')),
|
||||
(6, 'James O’Brien', 'james.obrien@fastmail.com', 'customer', '+1-617-555-0134', '1991-09-23',
|
||||
'DevOps contractor. Bought a monitor arm and never looked back. Always asks for gift receipts.',
|
||||
NULL,
|
||||
67.99, 1, datetime('now','-1 day'), datetime('now','-390 days'), datetime('now','-1 day')),
|
||||
(7, 'Yuki Tanaka', 'yuki.tanaka@me.com', 'customer', '+1-650-555-0189', '1994-05-17',
|
||||
'Product designer at a fintech startup. Switches between light and dark mode depending on the weather.',
|
||||
'{"theme":"system","notifications":{"email":true,"push":true},"locale":"ja-JP","timezone":"America/Los_Angeles"}',
|
||||
150.00, 0, datetime('now','-28 days'), datetime('now','-360 days'), datetime('now','-28 days')),
|
||||
(8, 'Olivia Müller', 'olivia.mueller@werkstatt.de', 'customer', '+49-30-555-0921', '1987-12-01',
|
||||
'Berlin-based architect. Orders office lamps and cable organizers for her co-working space.',
|
||||
'{"theme":"light","notifications":{"email":true,"push":false},"locale":"de-DE","timezone":"Europe/Berlin"}',
|
||||
540.30, 1, datetime('now','-5 days'), datetime('now','-330 days'), datetime('now','-5 days')),
|
||||
(9, 'Carlos Rivera', 'carlos.rivera@openmail.net', 'customer', '+1-305-555-0156', '1992-08-30',
|
||||
'Miami-based content creator. Replaced his entire streaming setup through the store last quarter.',
|
||||
'{"theme":"dark","notifications":{"email":false,"push":false},"locale":"es-MX","timezone":"America/New_York"}',
|
||||
28.50, 1, datetime('now','-3 hours'), datetime('now','-300 days'), datetime('now','-3 hours')),
|
||||
(10, 'Priya Sharma', 'priya.sharma@nimbus.team', 'customer', '+1-408-555-0127', '1995-01-14',
|
||||
'Engineering manager at Nimbus. Buys team gear in batches and expects invoices by email.',
|
||||
'{"theme":"dark","notifications":{"email":true,"push":true},"locale":"en-IN","timezone":"America/Los_Angeles"}',
|
||||
2100.00, 1, datetime('now','-50 minutes'), datetime('now','-270 days'), datetime('now','-50 minutes')),
|
||||
(11, 'Liam Thompson', 'liam.t@rivermail.com', 'guest', NULL, '1999-06-05',
|
||||
'University student window-shopping for a first mechanical keyboard. Has not completed a purchase yet.',
|
||||
'{"theme":"system","notifications":{"email":false,"push":false},"locale":"en-US","timezone":"America/New_York"}',
|
||||
0.00, 1, datetime('now','-7 days'), datetime('now','-240 days'), datetime('now','-7 days')),
|
||||
(12, 'Sofia Andersen', 'sofia.andersen@nordic.design', 'customer', '+46-8-555-0733', '1989-10-28',
|
||||
'Scandinavian design consultant. Values minimal packaging and fast, trackable shipping.',
|
||||
'{"theme":"light","notifications":{"email":true,"push":false},"locale":"sv-SE","timezone":"Europe/Stockholm"}',
|
||||
175.80, 1, datetime('now','-10 hours'), datetime('now','-210 days'), datetime('now','-10 hours')),
|
||||
(13, 'Benjamin Wright', 'ben.wright@compose.ly', 'customer', '+1-503-555-0198', '1993-04-11',
|
||||
'Music producer and software tinkerer. Always adds a USB-C cable to every order just in case.',
|
||||
'{"theme":"dark","notifications":{"email":true,"push":true},"locale":"en-US","timezone":"America/Los_Angeles"}',
|
||||
430.25, 1, datetime('now','-18 hours'), datetime('now','-180 days'), datetime('now','-18 hours')),
|
||||
(14, 'Hannah Lee', 'hannah.lee@cloudscale.io', 'admin', '+1-206-555-0204', '1990-07-22',
|
||||
'Platform reliability lead. Monitors the audit log and keeps the demo app settings up to date.',
|
||||
'{"theme":"dark","notifications":{"email":true,"push":true},"locale":"en-US","timezone":"America/Los_Angeles"}',
|
||||
3500.00, 1, datetime('now','-15 minutes'), datetime('now','-150 days'), datetime('now','-15 minutes')),
|
||||
(15, 'Noah Fischer', 'noah.fischer@bytehaus.at', 'customer', '+43-1-555-0817', '1997-03-15',
|
||||
'Vienna-based indie game dev. Needs low-latency peripherals and a quiet mechanical keyboard.',
|
||||
'{"theme":"system","notifications":{"email":false,"push":true},"locale":"de-AT","timezone":"Europe/Vienna"}',
|
||||
95.00, 1, datetime('now','-2 days'), datetime('now','-120 days'), datetime('now','-2 days')),
|
||||
(16, 'Grace Okafor', 'grace.okafor@lift.ng', 'customer', '+234-1-555-0294', '1986-11-09',
|
||||
'Remote team lead in Lagos. Orders standing desks and monitor arms for distributed teammates.',
|
||||
'{"theme":"dark","notifications":{"email":true,"push":false},"locale":"en-NG","timezone":"Africa/Lagos"}',
|
||||
1280.00, 1, datetime('now','-9 hours'), datetime('now','-90 days'), datetime('now','-9 hours')),
|
||||
(17, 'Ethan Brooks', 'ethan.brooks@nullsecurity.dev', 'customer', '+1-720-555-0112', '1994-12-30',
|
||||
'Security researcher with a dry sense of humor. Tests the demo with suspiciously long JSON blobs.',
|
||||
'{"theme":"dark","notifications":{"email":false,"push":false},"locale":"en-US","timezone":"America/Denver"}',
|
||||
12.49, 1, datetime('now','-5 days'), datetime('now','-60 days'), datetime('now','-5 days')),
|
||||
(18, 'Mia Rossi', 'mia.rossi@artigiano.it', 'customer', '+39-02-555-0366', '1991-05-03',
|
||||
'Graphic designer from Milan. Cares a lot about color-accurate monitors and clean desk lighting.',
|
||||
'{"theme":"light","notifications":{"email":true,"push":true},"locale":"it-IT","timezone":"Europe/Rome"}',
|
||||
725.60, 1, datetime('now','-1 day'), datetime('now','-30 days'), datetime('now','-1 day')),
|
||||
(19, 'Alexander Petrov', 'alex.petrov@polycode.ru', 'customer', '+7-495-555-0412', '1984-08-18',
|
||||
'Backend engineer in Moscow. Maintains a home lab and buys networking accessories in pairs.',
|
||||
'{"theme":"system","notifications":{"email":true,"push":false},"locale":"ru-RU","timezone":"Europe/Moscow"}',
|
||||
199.00, 0, datetime('now','-45 days'), datetime('now','-15 days'), datetime('now','-45 days')),
|
||||
(20, 'Zoe Williams', 'zoe.williams@greenleaf.org', 'moderator', '+1-510-555-0285', '1992-02-25',
|
||||
'Sustainability coordinator. Reviews product descriptions and pushes for paperless invoices.',
|
||||
'{"theme":"light","notifications":{"email":true,"push":false},"locale":"en-US","timezone":"America/Los_Angeles"}',
|
||||
88.00, 1, datetime('now','-6 hours'), datetime('now','-7 days'), datetime('now','-6 hours'));
|
||||
|
||||
-- ── Products: realistic SKUs, descriptions, and attributes ──────────────
|
||||
INSERT OR IGNORE INTO products (id, sku, name, description, price, category_id, stock, rating, discontinued, attributes, created_at, updated_at) VALUES
|
||||
(1, 'GL-MSE-001', 'Gridline Wireless Mouse', 'Ergonomic wireless mouse with silent switches, 2.4 GHz and Bluetooth 5.0, adjustable 800-1600 DPI, and a 12-month battery life.', 34.99, 1, 142, 4.6, 0, '{"color":"Graphite","wireless":true,"dpi_max":1600,"buttons":5}', datetime('now','-360 days'), datetime('now','-3 days')),
|
||||
(2, 'GL-KBD-001', 'Gridline Mechanical Keyboard', 'Hot-swappable TKL board with tactile brown switches, per-key RGB, PBT keycaps, and a USB-C braided cable.', 119.99, 5, 68, 4.8, 0, '{"layout":"US ANSI","switches":"brown","backlit":true,"connection":"wired"}', datetime('now','-350 days'), datetime('now','-5 days')),
|
||||
(3, 'GL-HUB-001', 'Gridline 7-in-1 USB-C Hub', 'Aluminium hub with 4K HDMI, 100 W power delivery, two USB-A 3.2 ports, SD/microSD slots, and a braided cable.', 44.99, 2, 215, 4.4, 0, '{"ports":7,"hdmi":"4K60","power_delivery":"100W","material":"aluminium"}', datetime('now','-340 days'), datetime('now','-2 days')),
|
||||
(4, 'GL-MON-001', 'Gridline 27" 4K USB-C Monitor', '27-inch IPS panel, 3840×2160, 60 Hz, 99% sRGB, USB-C upstream with 90 W charging, fully adjustable stand.', 499.99, 6, 28, 4.7, 0, '{"resolution":"3840x2160","refresh_hz":60,"panel":"IPS","usb_c_power":90}', datetime('now','-330 days'), datetime('now','-8 days')),
|
||||
(5, 'GL-STD-001', 'Gridline Aluminium Laptop Stand', 'Foldable aluminium stand with six height positions, ventilated design and silicone pads. Fits 12-16 inch laptops.', 54.99, 2, 108, 4.2, 0, '{"color":"Silver","max_height_mm":280,"foldable":true}', datetime('now','-320 days'), datetime('now','-10 days')),
|
||||
(6, 'GL-CAM-001', 'Gridline 1080p Webcam', 'Full HD webcam with privacy shutter, dual noise-reducing mics, autofocus, and plug-and-play compatibility.', 69.99, 1, 0, 4.1, 1, '{"resolution":"1920x1080","fps":30,"microphone":true,"autofocus":true}', datetime('now','-310 days'), datetime('now','-60 days')),
|
||||
(7, 'GL-LMP-001', 'Gridline LED Desk Lamp', 'Dimmable LED lamp with 2700-6500 K colour temperature, flexible neck, and a built-in USB-A charging port.', 42.99, 3, 134, 4.5, 0, '{"color_temp_k":"2700-6500","dimmable":true,"usb_port":true}', datetime('now','-300 days'), datetime('now','-6 days')),
|
||||
(8, 'GL-CHR-001', 'Gridline Ergonomic Mesh Chair', 'Breathable mesh back, adjustable lumbar support, 4D armrests, and a gas lift rated up to 150 kg.', 649.99, 4, 12, 4.7, 0, '{"material":"mesh","lumbar_support":true,"max_weight_kg":150}', datetime('now','-290 days'), datetime('now','-15 days')),
|
||||
(9, 'GL-CBL-001', 'Gridline Braided USB-C Cable 2 m', 'Braided USB-C to USB-C cable rated for 100 W charging and USB 3.2 data. Tested to 10,000 bends.', 16.99, 2, 480, 4.3, 0, '{"length_m":2,"charging_w":100,"data_speed":"USB 3.2","color":"Midnight"}', datetime('now','-280 days'), datetime('now','-4 days')),
|
||||
(10, 'GL-ARM-001', 'Gridline Single Monitor Arm', 'Gas-spring monitor arm with 75/100 mm VESA, 360° rotation, and cable management. Supports up to 9 kg.', 89.99, 6, 36, 4.5, 0, '{"weight_capacity_kg":9,"vesa":"75/100","rotation":"360"}', datetime('now','-270 days'), datetime('now','-9 days')),
|
||||
(11, 'GL-MSE-002', 'Gridline Pro Wireless Mouse', 'Premium wireless mouse with 4000 DPI sensor, USB-C rechargeable battery, and magnetic storage case.', 79.99, 1, 64, 4.7, 0, '{"color":"Pearl","wireless":true,"dpi_max":4000,"rechargeable":true}', datetime('now','-260 days'), datetime('now','-7 days')),
|
||||
(12, 'GL-KBD-002', 'Gridline Compact Keyboard', '65% low-profile mechanical keyboard with red linear switches, white backlight, and Bluetooth/wired dual mode.', 94.99, 5, 91, 4.4, 0, '{"layout":"65%","switches":"red linear","backlit":true,"wireless":true}', datetime('now','-250 days'), datetime('now','-6 days')),
|
||||
(13, 'GL-HUB-002', 'Gridline 10-in-1 Docking Station', 'Thunderbolt-compatible dock with dual 4K display support, 2.5 GbE, and 140 W power delivery.', 219.99, 2, 41, 4.6, 0, '{"ports":10,"ethernet":"2.5G","power_delivery":"140W","displays":2}', datetime('now','-240 days'), datetime('now','-3 days')),
|
||||
(14, 'GL-MON-002', 'Gridline 32" Curved Monitor', '32-inch curved VA panel, 2560×1440, 165 Hz refresh rate, HDR400, and adaptive sync.', 429.99, 6, 19, 4.5, 0, '{"resolution":"2560x1440","refresh_hz":165,"panel":"VA","curved":true}', datetime('now','-230 days'), datetime('now','-11 days')),
|
||||
(15, 'GL-DES-001', 'Gridline Standing Desk Frame', 'Electric sit-stand desk frame with dual motors, memory presets, and a quiet 45 dB lift mechanism.', 549.99, 4, 8, 4.6, 0, '{"width_adjustable":true,"memory_presets":4,"noise_db":45}', datetime('now','-220 days'), datetime('now','-12 days')),
|
||||
(16, 'GL-KEY-001', 'Gridline Keycap Set', 'PBT dye-sub keycap set with 140 keys, compatible with most ANSI and ISO layouts.', 39.99, 5, 77, 4.3, 0, '{"keys":140,"material":"PBT","profile":"Cherry"}', datetime('now','-210 days'), datetime('now','-5 days')),
|
||||
(17, 'GL-PAD-001', 'Gridline Desk Mat', 'Oversized felt desk mat with stitched edges and anti-slip base. 900 × 400 mm.', 29.99, 3, 156, 4.4, 0, '{"material":"felt","size_mm":"900x400","anti_slip":true}', datetime('now','-200 days'), datetime('now','-8 days')),
|
||||
(18, 'GL-MIC-001', 'Gridline USB Microphone', 'Cardioid condenser mic with built-in pop filter, mute button, and 24-bit/96 kHz sampling.', 119.99, 1, 33, 4.5, 0, '{"pattern":"cardioid","sample_rate":"96kHz","bit_depth":24}', datetime('now','-190 days'), datetime('now','-9 days')),
|
||||
(19, 'GL-LGT-001', 'Gridline Monitor Light Bar', 'Screen-hanging light bar with auto-dimming, warm/cool temperature control, and no glare on the panel.', 59.99, 3, 88, 4.2, 0, '{"mount":"screen_hanging","auto_dim":true,"color_temp_k":"3000-6500"}', datetime('now','-180 days'), datetime('now','-7 days')),
|
||||
(20, 'GL-CBL-002', 'Gridline Magnetic Cable Trio', 'Set of three braided magnetic cables (USB-C, Lightning, Micro-USB) with one interchangeable tip dock.', 34.99, 2, 203, 4.0, 0, '{"cables":3,"tips":["USB-C","Lightning","Micro-USB"],"magnetic":true}', datetime('now','-170 days'), datetime('now','-6 days')),
|
||||
(21, 'GL-BAG-001', 'Gridline Tech Pouch', 'Water-resistant tech pouch with elastic loops, mesh pockets, and a cable pass-through.', 44.99, 2, 119, 4.5, 0, '{"water_resistant":true,"compartments":8,"color":"Charcoal"}', datetime('now','-160 days'), datetime('now','-4 days')),
|
||||
(22, 'GL-SPK-001', 'Gridline Desktop Speakers', 'Pair of powered bookshelf speakers with Bluetooth input, RCA/AUX, and solid wood enclosures.', 149.99, 1, 22, 4.3, 0, '{"pair":true,"inputs":["Bluetooth","RCA","AUX"],"enclosure":"wood"}', datetime('now','-150 days'), datetime('now','-10 days')),
|
||||
(23, 'GL-TRP-001', 'Gridline Tripod Desk Lamp', 'Minimal tripod desk lamp with touch dimming, USB-C power, and a 360° rotating head.', 49.99, 3, 62, 4.1, 0, '{"base":"tripod","touch_dim":true,"power":"USB-C"}', datetime('now','-140 days'), datetime('now','-8 days')),
|
||||
(24, 'GL-HDM-001', 'Gridline HDMI 2.1 Cable 1.5 m', 'Certified Ultra High Speed HDMI 2.1 cable supporting 8K@60 Hz and 4K@120 Hz, 48 Gbps.', 24.99, 2, 312, 4.4, 0, '{"length_m":1.5,"hdmi_version":"2.1","bandwidth_gbps":48}', datetime('now','-130 days'), datetime('now','-3 days'));
|
||||
|
||||
-- ── Addresses: 1-3 realistic addresses per user ────────────────────────
|
||||
INSERT OR IGNORE INTO addresses (id, user_id, label, street, city, state, zip, country, is_primary, created_at) VALUES
|
||||
(1, 1, 'Home', '1234 Mission Street, Apt 42', 'San Francisco', 'CA', '94103', 'USA', 1, datetime('now','-500 days')),
|
||||
(2, 1, 'Work', '555 Montgomery Street, Floor 8', 'San Francisco', 'CA', '94111', 'USA', 0, datetime('now','-480 days')),
|
||||
(3, 2, 'Home', '2100 Barton Creek Blvd', 'Austin', 'TX', '78735', 'USA', 1, datetime('now','-490 days')),
|
||||
(4, 3, 'Home', '4542 11th Avenue NE', 'Seattle', 'WA', '98105', 'USA', 1, datetime('now','-470 days')),
|
||||
(5, 3, 'Office', '7200 Woodlawn Ave NE', 'Seattle', 'WA', '98115', 'USA', 0, datetime('now','-460 days')),
|
||||
(6, 4, 'Home', '850 7th Avenue, Apt 12B', 'New York', 'NY', '10019', 'USA', 1, datetime('now','-440 days')),
|
||||
(7, 5, 'Home', '1600 Glenarm Place', 'Denver', 'CO', '80202', 'USA', 1, datetime('now','-410 days')),
|
||||
(8, 5, 'Warehouse', '4650 Paris Street', 'Denver', 'CO', '80239', 'USA', 0, datetime('now','-400 days')),
|
||||
(9, 6, 'Home', '44 Prince Street', 'Boston', 'MA', '02113', 'USA', 1, datetime('now','-380 days')),
|
||||
(10, 7, 'Home', '1080 Arastradero Road', 'Palo Alto', 'CA', '94304', 'USA', 1, datetime('now','-350 days')),
|
||||
(11, 8, 'Office', 'Friedrichstraße 123', 'Berlin', 'Berlin', '10117', 'Germany', 1, datetime('now','-320 days')),
|
||||
(12, 9, 'Home', '1500 Ocean Drive, Apt 805', 'Miami Beach', 'FL', '33139', 'USA', 1, datetime('now','-290 days')),
|
||||
(13, 10, 'Work', '555 Ellis Street', 'Mountain View', 'CA', '94043', 'USA', 1, datetime('now','-260 days')),
|
||||
(14, 11, 'Home', '1925 SE Hawthorne Blvd', 'Portland', 'OR', '97214', 'USA', 1, datetime('now','-230 days')),
|
||||
(15, 12, 'Office', 'Birger Jarlsgatan 58', 'Stockholm', 'Stockholm', '111 45', 'Sweden', 1, datetime('now','-200 days')),
|
||||
(16, 13, 'Home', '925 W 5th Avenue', 'Chicago', 'IL', '60642', 'USA', 1, datetime('now','-170 days')),
|
||||
(17, 14, 'Home', '2211 7th Avenue', 'Seattle', 'WA', '98121', 'USA', 1, datetime('now','-140 days')),
|
||||
(18, 15, 'Home', 'Opernring 5', 'Vienna', 'Vienna', '1010', 'Austria', 1, datetime('now','-110 days')),
|
||||
(19, 16, 'Home', '12 Ikoyi Crescent', 'Lagos', 'Lagos', '101233', 'Nigeria', 1, datetime('now','-80 days')),
|
||||
(20, 17, 'Home', '1600 Walnut Street, Unit 300', 'Denver', 'CO', '80202', 'USA', 1, datetime('now','-50 days')),
|
||||
(21, 18, 'Home', 'Via Tortona 12', 'Milan', 'MI', '20144', 'Italy', 1, datetime('now','-25 days')),
|
||||
(22, 19, 'Home', 'Ulitsa Bolshaya Dmitrovka 9', 'Moscow', 'Moscow', '125009', 'Russia', 1, datetime('now','-10 days')),
|
||||
(23, 20, 'Home', '1900 Broadway', 'Oakland', 'CA', '94612', 'USA', 1, datetime('now','-5 days'));
|
||||
|
||||
-- ── Files: realistic file attachments with BLOB headers ─────────────────
|
||||
INSERT OR IGNORE INTO files (id, name, mime_type, content, size_bytes, uploaded_by, uploaded_at) VALUES
|
||||
(1, 'gridline_logo.png', 'image/png', X'89504E470D0A1A0A0000000D49484452000001000000010008060000005C72A866000000017352474200AECE1CE90000000467414D410000B18F0BFC61050000', 14256, 1, datetime('now','-45 days')),
|
||||
(2, 'product_photos.zip', 'application/zip', X'504B03040A00000000008B6B2D570000000000000000000000000800000070726F64756374732F504B0102001F000A00000000008B6B2D57000000000000000000000000080000000000000000000000A4810000000070726F64756374732F504B050600000000010001003A0000001A0000000000', 28934, 2, datetime('now','-42 days')),
|
||||
(3, 'invoice_2026_001.pdf', 'application/pdf', X'255044462D312E340A25E2E3CFD30A342030206F626A0A3C3C202F4C696E656172697A65642031202F4C2031203E3E0A3E3E0A73747265616D0A0A42510A0A656E6473747265616D0A656E646F626A0A', 18432, 10, datetime('now','-38 days')),
|
||||
(4, 'shipping_labels.pdf', 'application/pdf', X'255044462D312E340A25E2E3CFD30A342030206F626A0A3C3C202F54797065202F436174616C6F67202F50616765732031302020302020520A2F4F75746C696E65732032302020302020520A3E3E0A656E646F626A0A', 12288, 14, datetime('now','-30 days')),
|
||||
(5, 'user_avatars.jpg', 'image/jpeg', X'FFD8FFE000104A46494600010100000100010000FFDB004300080606070605080707070909080A0C140D0C0B0B0C1912130F14311A1F1F1A1C232D', 8934, 1, datetime('now','-25 days')),
|
||||
(6, 'q4_inventory.csv', 'text/csv', X'69642C6E616D652C73746F636B2C72657365727665640A312C47726E646C696E6520576972656C657373204D6F7573652C3134322C300A322C47726E646C696E65204D656368616E6963616C204B6579626F6172642C36382C350A', 5632, 14, datetime('now','-18 days')),
|
||||
(7, 'backup.sql', 'application/sql', X'2D2D20477269646C696E652064656D6F206261636B75700A50524F4752416D757365725F76657273696F6E203D20333B0A435245415445205441424C45204946204E4F542045584953545320757365727320282E2E2E293B', 4096, 14, datetime('now','-7 days')),
|
||||
(8, 'favicon.ico', 'image/x-icon', X'00000100010010100000000020006804000016000000280000001000000020000000010008', 4286, 1, datetime('now','-3 days'));
|
||||
|
||||
-- ── App settings: TEXT primary key ──────────────────────────────────────
|
||||
INSERT OR IGNORE INTO app_settings (key, value, updated_at) VALUES
|
||||
('site_name', 'Gridline Demo Store', datetime('now','-60 days')),
|
||||
('maintenance_mode', 'false', datetime('now','-5 days')),
|
||||
('max_cart_items', '50', datetime('now','-30 days')),
|
||||
('currency', 'USD', datetime('now','-60 days')),
|
||||
('default_shipping_country', 'USA', datetime('now','-20 days')),
|
||||
('support_email', 'support@gridline.dev', datetime('now','-10 days'));
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- Generated data: orders, line items, page views, audit log
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- 50 realistic orders across the user base with varied statuses and dates.
|
||||
WITH RECURSIVE order_seq(n) AS (
|
||||
SELECT 1 UNION ALL SELECT n + 1 FROM order_seq WHERE n < 50
|
||||
)
|
||||
INSERT OR IGNORE INTO orders (id, user_id, shipping_address_id, total, status, notes, created_at, updated_at, shipped_at)
|
||||
SELECT
|
||||
n,
|
||||
-- Distribute orders across users 1-18; users 19 (inactive) and 20 (recent) get fewer.
|
||||
CASE
|
||||
WHEN n <= 35 THEN ((n - 1) % 18) + 1
|
||||
WHEN n <= 45 THEN ((n - 1) % 12) + 1
|
||||
ELSE ((n - 1) % 8) + 1
|
||||
END,
|
||||
-- Pick a primary or secondary address for that user if one exists.
|
||||
NULL,
|
||||
0, -- total recalculated from line items below
|
||||
CASE n % 12
|
||||
WHEN 0 THEN 'pending'
|
||||
WHEN 1 THEN 'processing'
|
||||
WHEN 2 THEN 'shipped'
|
||||
WHEN 3 THEN 'delivered'
|
||||
WHEN 4 THEN 'completed'
|
||||
WHEN 5 THEN 'cancelled'
|
||||
WHEN 6 THEN 'refunded'
|
||||
WHEN 7 THEN 'shipped'
|
||||
WHEN 8 THEN 'processing'
|
||||
WHEN 9 THEN 'completed'
|
||||
WHEN 10 THEN 'pending'
|
||||
ELSE 'delivered'
|
||||
END,
|
||||
CASE n % 8
|
||||
WHEN 0 THEN 'Please leave the package at the front desk.'
|
||||
WHEN 1 THEN 'Gift wrap, please.'
|
||||
WHEN 2 THEN 'Customer requested eco-friendly packaging.'
|
||||
WHEN 3 THEN 'Ship after the 15th — office move in progress.'
|
||||
WHEN 4 THEN NULL
|
||||
WHEN 5 THEN 'Call before delivery.'
|
||||
WHEN 6 THEN 'Authority to leave if not home.'
|
||||
ELSE NULL
|
||||
END,
|
||||
datetime('now', printf('-%d days', 60 - (n % 58))),
|
||||
datetime('now', printf('-%d days', 58 - (n % 56))),
|
||||
CASE WHEN n % 12 IN (2,3,4,7,11) THEN datetime('now', printf('-%d days', 55 - (n % 53))) ELSE NULL END
|
||||
FROM order_seq;
|
||||
|
||||
-- Assign a realistic shipping address to each order from the user's address set.
|
||||
UPDATE orders SET shipping_address_id = (
|
||||
SELECT a.id FROM addresses a
|
||||
WHERE a.user_id = orders.user_id
|
||||
ORDER BY a.is_primary DESC, a.id
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
-- 100+ order line items: 1-3 products per order.
|
||||
WITH RECURSIVE line_seq(n) AS (
|
||||
SELECT 1 UNION ALL SELECT n + 1 FROM line_seq WHERE n < 105
|
||||
)
|
||||
INSERT OR IGNORE INTO order_items (order_id, product_id, quantity, unit_price)
|
||||
SELECT
|
||||
((n - 1) % 50) + 1,
|
||||
((n - 1) % 24) + 1,
|
||||
CASE n % 5 WHEN 0 THEN 3 WHEN 1 THEN 2 ELSE 1 END,
|
||||
(SELECT price FROM products WHERE id = ((n - 1) % 24) + 1)
|
||||
FROM line_seq;
|
||||
|
||||
-- Recalculate order totals from line items.
|
||||
UPDATE orders SET total = (
|
||||
SELECT ROUND(SUM(quantity * unit_price), 2)
|
||||
FROM order_items
|
||||
WHERE order_items.order_id = orders.id
|
||||
);
|
||||
|
||||
-- 100 page views with realistic user agents and URLs.
|
||||
WITH RECURSIVE pv_seq(n) AS (
|
||||
SELECT 1 UNION ALL SELECT n + 1 FROM pv_seq WHERE n < 100
|
||||
)
|
||||
INSERT INTO page_views (url, session_id, user_agent, viewed_at)
|
||||
SELECT
|
||||
CASE n % 10
|
||||
WHEN 0 THEN '/products'
|
||||
WHEN 1 THEN '/products/' || ((n % 24) + 1)
|
||||
WHEN 2 THEN '/cart'
|
||||
WHEN 3 THEN '/checkout'
|
||||
WHEN 4 THEN '/orders'
|
||||
WHEN 5 THEN '/settings'
|
||||
WHEN 6 THEN '/categories/electronics'
|
||||
WHEN 7 THEN '/categories/office'
|
||||
WHEN 8 THEN '/search?q=keyboard'
|
||||
ELSE '/'
|
||||
END,
|
||||
'sess-' || printf('%03d', (n % 30) + 1),
|
||||
CASE n % 6
|
||||
WHEN 0 THEN 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36'
|
||||
WHEN 1 THEN 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36'
|
||||
WHEN 2 THEN 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1'
|
||||
WHEN 3 THEN 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36'
|
||||
WHEN 4 THEN 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:128.0) Gecko/20100101 Firefox/128.0'
|
||||
ELSE 'Mozilla/5.0 (iPad; CPU OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1'
|
||||
END,
|
||||
datetime('now', printf('-%d minutes', n * 13))
|
||||
FROM pv_seq
|
||||
WHERE NOT EXISTS (SELECT 1 FROM page_views);
|
||||
|
||||
-- 500 audit-log rows for pagination / virtualization / filtering demos.
|
||||
WITH RECURSIVE audit_seq(n) AS (
|
||||
SELECT 1 UNION ALL SELECT n + 1 FROM audit_seq WHERE n < 500
|
||||
)
|
||||
INSERT INTO audit_log (user_id, action, entity_type, entity_id, severity, details, duration_ms, created_at)
|
||||
SELECT
|
||||
CASE WHEN n % 9 = 0 THEN NULL ELSE ((n - 1) % 18) + 1 END,
|
||||
CASE n % 8
|
||||
WHEN 0 THEN 'login'
|
||||
WHEN 1 THEN 'page_view'
|
||||
WHEN 2 THEN 'update'
|
||||
WHEN 3 THEN 'create'
|
||||
WHEN 4 THEN 'delete'
|
||||
WHEN 5 THEN 'export'
|
||||
WHEN 6 THEN 'refund'
|
||||
ELSE 'ship'
|
||||
END,
|
||||
CASE n % 5
|
||||
WHEN 0 THEN 'order'
|
||||
WHEN 1 THEN 'product'
|
||||
WHEN 2 THEN 'user'
|
||||
WHEN 3 THEN 'address'
|
||||
ELSE 'report'
|
||||
END,
|
||||
(n % 50) + 1,
|
||||
CASE n % 6
|
||||
WHEN 0 THEN 'info'
|
||||
WHEN 1 THEN 'info'
|
||||
WHEN 2 THEN 'warning'
|
||||
WHEN 3 THEN 'error'
|
||||
WHEN 4 THEN 'critical'
|
||||
ELSE 'warning'
|
||||
END,
|
||||
CASE WHEN n % 7 = 0 THEN NULL
|
||||
ELSE '{"page":"' ||
|
||||
CASE n % 4 WHEN 0 THEN '/' WHEN 1 THEN '/products' WHEN 2 THEN '/checkout' ELSE '/orders' END ||
|
||||
'","retries":' || (n % 3) || ',"row":' || n || ',"region":"' ||
|
||||
CASE n % 5 WHEN 0 THEN 'us-east' WHEN 1 THEN 'us-west' WHEN 2 THEN 'eu-central' WHEN 3 THEN 'ap-south' ELSE 'sa-east' END ||
|
||||
'"}'
|
||||
END,
|
||||
(n * 37) % 2500,
|
||||
datetime('now', printf('-%d minutes', n * 7))
|
||||
FROM audit_seq
|
||||
WHERE NOT EXISTS (SELECT 1 FROM audit_log);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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\""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,4 +190,4 @@ mod tests {
|
||||
assert_eq!(ssh_account("password", "c1"), "ssh_password:c1");
|
||||
assert_eq!(ssh_account("passphrase", "c1"), "ssh_passphrase:c1");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user