refactor: convert to bun-workspaces monorepo (desktop/ + www/)
- Move the Tauri app (React frontend + Rust backend) into desktop/ via git mv — configs unchanged (relative paths: tauri.conf.json, vite.config.ts) - Root package.json becomes a private bun workspace container with orchestration scripts; desktop package renamed gridline-desktop - Scaffold www/ with Astro 7 + Tailwind v4 (hand-wired vite plugin, static output, ready for Dokploy) - Update release.yml for the new layout: projectPath: desktop, desktop/src-tauri resource paths, rust-cache workspace path - Update README + AGENTS.md structure trees and dev commands - Verified: vitest (1074), cargo test (354), desktop build, tauri dev (window launches), tauri build (dmg + app bundles), Astro build
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
@@ -0,0 +1,50 @@
|
||||
[package]
|
||||
name = "gridline"
|
||||
version = "0.7.10"
|
||||
description = "An open-source, high-performance database GUI client for PostgreSQL and beyond"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "gridline_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
# URL percent-encoding for tokio-postgres connection strings (user/password may contain special chars)
|
||||
urlencoding = "2"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-uuid-1", "with-serde_json-1"] }
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "mysql", "tls-rustls", "json"] }
|
||||
redis = { version = "0.27", features = ["tokio-comp"] }
|
||||
# async-ssh2 is not available at v0.4; using synchronous ssh2 via tokio::task::spawn_blocking per plan's fallback
|
||||
# vendored-openssl: link OpenSSL statically so the release binary has NO
|
||||
# runtime dependency on the build machine's Homebrew openssl (@/opt/homebrew
|
||||
# absolute LC_LOAD_DYLIB — dyld aborts on user machines without it).
|
||||
ssh2 = { version = "0.9", features = ["vendored-openssl"] }
|
||||
deadpool-postgres = { version = "0.14" }
|
||||
indexmap = { version = "2", features = ["serde"] }
|
||||
tauri-plugin-keyring-store = { version = "0.2.0", default-features = false }
|
||||
tokio-postgres-rustls = "0.12"
|
||||
# rustls: ring-only crypto provider (no aws-lc-rs / cmake C build); tls12 needed for Postgres
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
|
||||
rustls-pemfile = "2"
|
||||
rustls-pki-types = "1"
|
||||
rustls-native-certs = "0.7"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-set-title",
|
||||
"core:window:allow-set-theme",
|
||||
"core:window:allow-set-background-color",
|
||||
"core:window:allow-start-dragging",
|
||||
"opener:default",
|
||||
"dialog:default",
|
||||
"fs:default",
|
||||
"keyring-store:default"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 100 KiB |
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 1000 1000" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;">
|
||||
<g transform="matrix(1.464129,0,0,1.464129,-67.349927,-273.792094)">
|
||||
<path d="M729,357.75L729,699.25C729,793.489 652.489,870 558.25,870L216.75,870C122.511,870 46,793.489 46,699.25L46,357.75C46,263.511 122.511,187 216.75,187L558.25,187C652.489,187 729,263.511 729,357.75Z" style="fill:url(#_Linear1);stroke:black;stroke-width:0.68px;"/>
|
||||
</g>
|
||||
<g id="Icon">
|
||||
<g transform="matrix(1.186992,0,0,1.186992,-93.495935,-96.300813)">
|
||||
<path d="M325,351C325,305.743 403.415,269 500,269C596.585,269 675,305.743 675,351C675,365.931 666.465,379.936 651.557,392C640.124,401.252 624.943,409.364 606.99,415.876C577.4,426.609 540.28,433 500,433C459.711,433 422.583,426.607 392.99,415.869C375.046,409.358 359.872,401.249 348.443,392C333.535,379.936 325,365.931 325,351Z" style="fill:white;stroke:black;stroke-width:14.04px;"/>
|
||||
</g>
|
||||
<g transform="matrix(1.186992,0,0,1.186992,-93.495935,-96.300813)">
|
||||
<path d="M325,433L325,351C325,365.931 333.535,379.936 348.443,392C359.872,401.249 375.046,409.358 392.99,415.869L392.99,497.869C375.046,491.358 359.872,483.249 348.443,474C333.535,461.936 325,447.931 325,433Z" style="fill:white;stroke:black;stroke-width:14.04px;"/>
|
||||
</g>
|
||||
<path d="M626.996,592.007C591.874,604.748 547.812,612.333 500,612.333L500,515C547.812,515 591.874,507.414 626.996,494.674C648.307,486.944 666.326,477.316 679.897,466.333C697.592,452.013 707.724,435.39 707.724,417.667L707.724,515C707.724,532.723 697.592,549.347 679.897,563.667C666.326,574.649 648.307,584.277 626.996,592.007Z" style="fill:white;stroke:black;stroke-width:16.67px;"/>
|
||||
<g transform="matrix(1.186992,0,0,1.186992,-93.495935,-96.300813)">
|
||||
<path d="M325,515L325,433C325,447.931 333.535,461.936 348.443,474C359.872,483.249 375.046,491.358 392.99,497.869L392.99,579.869C375.046,573.358 359.872,565.249 348.443,556C333.535,543.936 325,529.931 325,515Z" style="fill:white;stroke:black;stroke-width:14.04px;"/>
|
||||
</g>
|
||||
<g transform="matrix(1.186992,0,0,1.186992,-93.495935,-96.300813)">
|
||||
<path d="M325,597L325,515C325,529.931 333.535,543.936 348.443,556C359.872,565.249 375.046,573.358 392.99,579.869L392.99,661.869C375.046,655.358 359.872,647.249 348.443,638C333.535,625.936 325,611.931 325,597Z" style="fill:white;stroke:black;stroke-width:14.04px;"/>
|
||||
</g>
|
||||
<g transform="matrix(1.186992,0,0,1.186992,-93.495935,-96.300813)">
|
||||
<path d="M675,515L675,597C675,611.931 666.465,625.936 651.557,638C640.124,647.252 624.943,655.364 606.99,661.876L606.99,579.876C624.943,573.364 640.124,565.252 651.557,556C666.465,543.936 675,529.931 675,515Z" style="fill:white;stroke:black;stroke-width:14.04px;"/>
|
||||
</g>
|
||||
<g transform="matrix(1.186992,0,0,1.186992,-93.495935,-96.300813)">
|
||||
<path d="M675,597L675,679C675,705.383 648.352,728.872 606.99,743.876L606.99,661.876C624.943,655.364 640.124,647.252 651.557,638C666.465,625.936 675,611.931 675,597Z" style="fill:white;stroke:black;stroke-width:14.04px;"/>
|
||||
</g>
|
||||
<path d="M372.98,786.665C323.898,768.856 292.276,740.978 292.276,709.667L292.276,612.333C292.276,630.057 302.408,646.68 320.103,661C333.669,671.979 351.68,681.603 372.98,689.332C408.107,702.078 452.177,709.667 500,709.667C547.812,709.667 591.874,702.081 626.996,689.341L626.996,786.674C591.874,799.414 547.812,807 500,807C452.177,807 408.107,799.411 372.98,786.665Z" style="fill:white;stroke:black;stroke-width:16.67px;"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(683,683,-683,683,46,187)"><stop offset="0" style="stop-color:rgb(108,108,108);stop-opacity:1"/><stop offset="0.17" style="stop-color:rgb(15,15,15);stop-opacity:1"/><stop offset="0.5" style="stop-color:black;stop-opacity:1"/><stop offset="0.83" style="stop-color:rgb(15,15,15);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(108,108,108);stop-opacity:1"/></linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 74 KiB |
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 1000 1000" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;">
|
||||
<g transform="matrix(1.464129,0,0,1.464129,-67.349927,-273.792094)">
|
||||
<path d="M729,357.75L729,699.25C729,793.489 652.489,870 558.25,870L216.75,870C122.511,870 46,793.489 46,699.25L46,357.75C46,263.511 122.511,187 216.75,187L558.25,187C652.489,187 729,263.511 729,357.75Z" style="fill:url(#_Linear1);stroke:black;stroke-width:0.68px;"/>
|
||||
</g>
|
||||
<g id="Icon">
|
||||
<g transform="matrix(1.186992,0,0,1.186992,-93.495935,-96.300813)">
|
||||
<path d="M325,351C325,305.743 403.415,269 500,269C596.585,269 675,305.743 675,351C675,365.931 666.465,379.936 651.557,392C640.124,401.252 624.943,409.364 606.99,415.876C577.4,426.609 540.28,433 500,433C459.711,433 422.583,426.607 392.99,415.869C375.046,409.358 359.872,401.249 348.443,392C333.535,379.936 325,365.931 325,351Z" style="fill:white;stroke:black;stroke-width:14.04px;"/>
|
||||
</g>
|
||||
<g transform="matrix(1.186992,0,0,1.186992,-93.495935,-96.300813)">
|
||||
<path d="M325,433L325,351C325,365.931 333.535,379.936 348.443,392C359.872,401.249 375.046,409.358 392.99,415.869L392.99,497.869C375.046,491.358 359.872,483.249 348.443,474C333.535,461.936 325,447.931 325,433Z" style="fill:white;stroke:black;stroke-width:14.04px;"/>
|
||||
</g>
|
||||
<path d="M626.996,592.007C591.874,604.748 547.812,612.333 500,612.333L500,515C547.812,515 591.874,507.414 626.996,494.674C648.307,486.944 666.326,477.316 679.897,466.333C697.592,452.013 707.724,435.39 707.724,417.667L707.724,515C707.724,532.723 697.592,549.347 679.897,563.667C666.326,574.649 648.307,584.277 626.996,592.007Z" style="fill:white;stroke:black;stroke-width:16.67px;"/>
|
||||
<g transform="matrix(1.186992,0,0,1.186992,-93.495935,-96.300813)">
|
||||
<path d="M325,515L325,433C325,447.931 333.535,461.936 348.443,474C359.872,483.249 375.046,491.358 392.99,497.869L392.99,579.869C375.046,573.358 359.872,565.249 348.443,556C333.535,543.936 325,529.931 325,515Z" style="fill:white;stroke:black;stroke-width:14.04px;"/>
|
||||
</g>
|
||||
<g transform="matrix(1.186992,0,0,1.186992,-93.495935,-96.300813)">
|
||||
<path d="M325,597L325,515C325,529.931 333.535,543.936 348.443,556C359.872,565.249 375.046,573.358 392.99,579.869L392.99,661.869C375.046,655.358 359.872,647.249 348.443,638C333.535,625.936 325,611.931 325,597Z" style="fill:white;stroke:black;stroke-width:14.04px;"/>
|
||||
</g>
|
||||
<g transform="matrix(1.186992,0,0,1.186992,-93.495935,-96.300813)">
|
||||
<path d="M675,515L675,597C675,611.931 666.465,625.936 651.557,638C640.124,647.252 624.943,655.364 606.99,661.876L606.99,579.876C624.943,573.364 640.124,565.252 651.557,556C666.465,543.936 675,529.931 675,515Z" style="fill:white;stroke:black;stroke-width:14.04px;"/>
|
||||
</g>
|
||||
<g transform="matrix(1.186992,0,0,1.186992,-93.495935,-96.300813)">
|
||||
<path d="M675,597L675,679C675,705.383 648.352,728.872 606.99,743.876L606.99,661.876C624.943,655.364 640.124,647.252 651.557,638C666.465,625.936 675,611.931 675,597Z" style="fill:white;stroke:black;stroke-width:14.04px;"/>
|
||||
</g>
|
||||
<path d="M372.98,786.665C323.898,768.856 292.276,740.978 292.276,709.667L292.276,612.333C292.276,630.057 302.408,646.68 320.103,661C333.669,671.979 351.68,681.603 372.98,689.332C408.107,702.078 452.177,709.667 500,709.667C547.812,709.667 591.874,702.081 626.996,689.341L626.996,786.674C591.874,799.414 547.812,807 500,807C452.177,807 408.107,799.411 372.98,786.665Z" style="fill:white;stroke:black;stroke-width:16.67px;"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(683,683,-683,683,46,187)"><stop offset="0" style="stop-color:rgb(108,108,108);stop-opacity:1"/><stop offset="0.17" style="stop-color:rgb(15,15,15);stop-opacity:1"/><stop offset="0.5" style="stop-color:black;stop-opacity:1"/><stop offset="0.83" style="stop-color:rgb(15,15,15);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(108,108,108);stop-opacity:1"/></linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1,13 @@
|
||||
# Bundled MariaDB client tools
|
||||
|
||||
Static `mariadb-dump` and `mariadb` client binaries (wire-compatible with MySQL
|
||||
servers) go here. They are NOT committed — `.github/workflows/release.yml`
|
||||
builds and checksum-verifies them per matrix target, copying the result into
|
||||
`resources/mysql_tools/{mariadb-dump,mariadb}` (plus `mariadb-dump.exe` /
|
||||
`mariadb.exe` and the runtime `libmariadb.dll` on Windows). The app resolves
|
||||
tools system-first and falls back to these bundled binaries (see
|
||||
`backup::resolve_mysql_tool`).
|
||||
|
||||
In `tauri dev`, this dir is usually empty — the app falls back to any
|
||||
`mariadb-dump`/`mariadb` on `PATH` (system-first resolution). MySQL
|
||||
backup/restore degrades with a clear error if neither is present.
|
||||
@@ -0,0 +1,8 @@
|
||||
# Bundled PostgreSQL client tools
|
||||
|
||||
This directory is populated **at build time by CI** (`.github/workflows/release.yml`)
|
||||
with statically-linked `pg_dump`, `pg_restore`, and `psql` for the current target.
|
||||
Binaries are NOT committed to the repo.
|
||||
|
||||
In `tauri dev`, this dir is usually empty — the app falls back to any `pg_dump`/`pg_restore`
|
||||
on `PATH` (system-first resolution). Admin features degrade with a clear error if neither is present.
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Per-connection cancellation handles, stored independently of the pool
|
||||
//! lock so `cancel_query` can dispatch while a long query holds the pool mutex.
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use rusqlite::InterruptHandle;
|
||||
use sqlx::mysql::MySqlConnectOptions;
|
||||
use tokio_postgres::CancelToken;
|
||||
|
||||
use crate::db::tls::TlsDecision;
|
||||
|
||||
/// PostgreSQL cancel data. `cancel_token` stores the socket address (incl.
|
||||
/// SSH tunnel endpoint) so `cancel_query` needs no host. `tls_config` is the
|
||||
/// rustls config used to **build** the connection (or `None` for NoTls) so the
|
||||
/// cancel connection reuses the exact same TLS decision.
|
||||
#[derive(Clone)]
|
||||
pub struct PgCancel {
|
||||
pub cancel_token: CancelToken,
|
||||
pub tls_decision: TlsDecision,
|
||||
pub tls_config: Option<std::sync::Arc<rustls::ClientConfig>>,
|
||||
}
|
||||
|
||||
/// MySQL cancel data. `conn_id` is the `CONNECTION_ID()` of the dedicated
|
||||
/// connection currently running a query (one active per connection because the
|
||||
/// pool lock serializes queries). `connect_options` lets `cancel` open a
|
||||
/// brand-new connection (bypassing the pool) to run `KILL QUERY ?`.
|
||||
#[derive(Clone)]
|
||||
pub struct MySqlCancel {
|
||||
pub conn_id: Option<i64>,
|
||||
pub connect_options: MySqlConnectOptions,
|
||||
}
|
||||
|
||||
/// SQLite cancel data — a cloneable, thread-safe interrupt handle.
|
||||
/// (`InterruptHandle` itself is not `Clone` in rusqlite 0.31, so it's kept
|
||||
/// behind an `Arc`.)
|
||||
#[derive(Clone)]
|
||||
pub struct SqliteCancel {
|
||||
handle: Arc<InterruptHandle>,
|
||||
}
|
||||
|
||||
impl SqliteCancel {
|
||||
pub fn new(handle: InterruptHandle) -> Self {
|
||||
Self { handle: Arc::new(handle) }
|
||||
}
|
||||
pub fn interrupt(&self) {
|
||||
self.handle.interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum CancelHandle {
|
||||
Pg(PgCancel),
|
||||
MySql(MySqlCancel),
|
||||
Sqlite(SqliteCancel),
|
||||
}
|
||||
|
||||
/// Send + Sync registry keyed by connection id. `cancel_query` takes only
|
||||
/// this `Mutex` (NOT the pool lock).
|
||||
#[derive(Default)]
|
||||
pub struct CancelRegistry {
|
||||
map: Mutex<HashMap<String, CancelHandle>>,
|
||||
}
|
||||
|
||||
impl CancelRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn set_pg(&self, id: &str, c: PgCancel) {
|
||||
self.set(id, CancelHandle::Pg(c));
|
||||
}
|
||||
pub fn set_mysql(&self, id: &str, c: MySqlCancel) {
|
||||
self.set(id, CancelHandle::MySql(c));
|
||||
}
|
||||
pub fn set_sqlite(&self, id: &str, c: SqliteCancel) {
|
||||
self.set(id, CancelHandle::Sqlite(c));
|
||||
}
|
||||
pub fn set_mysql_conn_id(&self, id: &str, conn_id: Option<i64>) {
|
||||
let mut g = self.map.lock().unwrap();
|
||||
if let Some(CancelHandle::MySql(m)) = g.get_mut(id) {
|
||||
m.conn_id = conn_id;
|
||||
}
|
||||
}
|
||||
fn set(&self, id: &str, h: CancelHandle) {
|
||||
self.map.lock().unwrap().insert(id.to_string(), h);
|
||||
}
|
||||
pub fn get(&self, id: &str) -> Option<CancelHandle> {
|
||||
self.map.lock().unwrap().get(id).cloned()
|
||||
}
|
||||
pub fn remove(&self, id: &str) {
|
||||
self.map.lock().unwrap().remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sqlite_insert_and_drain() {
|
||||
let reg = CancelRegistry::new();
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
let handle = conn.get_interrupt_handle();
|
||||
reg.set_sqlite("c1", SqliteCancel::new(handle));
|
||||
assert!(matches!(reg.get("c1"), Some(CancelHandle::Sqlite(_))));
|
||||
reg.remove("c1");
|
||||
assert!(reg.get("c1").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_interrupt_aborts_running_query() {
|
||||
use std::sync::{Arc, Mutex};
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch("CREATE TABLE t(n); INSERT INTO t VALUES (0);")
|
||||
.unwrap();
|
||||
let handle = conn.get_interrupt_handle();
|
||||
let conn2 = Arc::new(Mutex::new(conn));
|
||||
let c = conn2.clone();
|
||||
let done = Arc::new(Mutex::new(None::<Result<usize, String>>));
|
||||
let d = done.clone();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let l = c.lock().unwrap();
|
||||
// `query()` binds params only — the first sqlite3_step (where the
|
||||
// interrupt lands) happens in `rs.next()`, so errors must be
|
||||
// propagated with `?` rather than swallowed by `is_ok()`.
|
||||
let r = l
|
||||
.prepare("WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM c LIMIT 200000000) SELECT count(*) FROM c")
|
||||
.unwrap()
|
||||
.query([])
|
||||
.and_then(|mut rs| {
|
||||
let mut n = 0;
|
||||
while rs.next()?.is_some() {
|
||||
n += 1;
|
||||
}
|
||||
Ok(n)
|
||||
});
|
||||
*d.lock().unwrap() = Some(r.map_err(|e| e.to_string()));
|
||||
});
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
handle.interrupt();
|
||||
worker.join().unwrap();
|
||||
let outcome = done.lock().unwrap().clone();
|
||||
assert!(
|
||||
matches!(&outcome, Some(Err(e)) if e.to_lowercase().contains("interrupted")),
|
||||
"cancelled query must report interrupted; got {outcome:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_overwrites_single_active_slot() {
|
||||
let reg = CancelRegistry::new();
|
||||
reg.set_mysql("c1", MySqlCancel { conn_id: Some(1), connect_options: fake_opts() });
|
||||
reg.set_mysql("c1", MySqlCancel { conn_id: Some(2), connect_options: fake_opts() });
|
||||
match reg.get("c1") {
|
||||
Some(CancelHandle::MySql(m)) => assert_eq!(m.conn_id, Some(2)),
|
||||
_ => panic!("expected MySql"),
|
||||
}
|
||||
}
|
||||
|
||||
fn fake_opts() -> sqlx::mysql::MySqlConnectOptions {
|
||||
sqlx::mysql::MySqlConnectOptions::new()
|
||||
.host("127.0.0.1").port(1).username("u").password("p").database("d")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
use super::*;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// build_args_for_test (unit tests for arg construction logic)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn build_pg_dump_args_plain_format() {
|
||||
let args = build_args_for_test(
|
||||
"pg_dump",
|
||||
"mydb",
|
||||
"plain",
|
||||
"/tmp/dump.sql",
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
args.iter().any(|a| a.contains("--no-owner")),
|
||||
"should include --no-owner"
|
||||
);
|
||||
assert!(
|
||||
args.iter().any(|a| a == "--file=/tmp/dump.sql"),
|
||||
"should include --file flag"
|
||||
);
|
||||
// plain format should NOT add a --format flag
|
||||
assert!(
|
||||
!args.iter().any(|a| a.starts_with("--format")),
|
||||
"plain format should not emit --format"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_pg_dump_args_custom_format() {
|
||||
let args = build_args_for_test(
|
||||
"pg_dump",
|
||||
"mydb",
|
||||
"custom",
|
||||
"/tmp/dump.bak",
|
||||
true,
|
||||
Some("public"),
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
args.iter().any(|a| a == "--format=c"),
|
||||
"custom format should emit --format=c"
|
||||
);
|
||||
assert!(
|
||||
args.iter().any(|a| a == "--schema=public"),
|
||||
"should include --schema flag"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_pg_dump_args_tar_format() {
|
||||
let args = build_args_for_test(
|
||||
"pg_dump",
|
||||
"testdb",
|
||||
"tar",
|
||||
"/tmp/test.tar",
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
args.iter().any(|a| a == "--format=t"),
|
||||
"should emit --format=t"
|
||||
);
|
||||
assert!(
|
||||
!args.iter().any(|a| a.contains("--no-owner")),
|
||||
"should NOT include --no-owner when false"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_pg_dump_args_directory_format() {
|
||||
let args = build_args_for_test(
|
||||
"pg_dump",
|
||||
"proddb",
|
||||
"directory",
|
||||
"/tmp/dumpdir",
|
||||
false,
|
||||
None,
|
||||
Some(vec!["users", "orders"]),
|
||||
);
|
||||
assert!(
|
||||
args.iter().any(|a| a == "--format=d"),
|
||||
"should emit --format=d"
|
||||
);
|
||||
assert!(args.iter().any(|a| a == "--table=users"));
|
||||
assert!(args.iter().any(|a| a == "--table=orders"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_pg_restore_args() {
|
||||
let args = build_args_for_test(
|
||||
"pg_restore",
|
||||
"targetdb",
|
||||
"custom",
|
||||
"/tmp/dump.bak",
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
// pg_restore should NOT emit --file=, it should pass the path as positional
|
||||
assert!(
|
||||
!args.iter().any(|a| a.starts_with("--file")),
|
||||
"pg_restore should not use --file flag"
|
||||
);
|
||||
assert!(
|
||||
args.iter().any(|a| a == "/tmp/dump.bak"),
|
||||
"pg_restore should include file path as positional arg"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_pg_restore_args_with_schema() {
|
||||
let args = build_args_for_test(
|
||||
"pg_restore",
|
||||
"mydb",
|
||||
"plain",
|
||||
"/tmp/dump.sql",
|
||||
false,
|
||||
Some("public"),
|
||||
None,
|
||||
);
|
||||
assert!(args.iter().any(|a| a == "--schema=public"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// detect_pg_tools
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn detect_pg_tools_does_not_panic() {
|
||||
// detect_pg_tools needs a Tauri AppHandle; the headless core keeps the
|
||||
// status-shaping logic testable without one.
|
||||
let status = build_pg_tool_status("pg_dump", "pg_restore", None, None);
|
||||
// May or may not find tools, but the call itself must not panic
|
||||
let _ = status.pg_dump_found;
|
||||
let _ = status.pg_restore_found;
|
||||
let _ = status.pg_dump_version;
|
||||
let _ = status.pg_restore_version;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_tool_status_serialization() {
|
||||
let status = PgToolStatus {
|
||||
pg_dump_found: true,
|
||||
pg_restore_found: false,
|
||||
pg_dump_version: Some("pg_dump (PostgreSQL) 16.0".into()),
|
||||
pg_restore_version: None,
|
||||
pg_dump_source: None,
|
||||
pg_restore_source: None,
|
||||
};
|
||||
let json = serde_json::to_string(&status).unwrap();
|
||||
assert!(json.contains("pg_dump_found"));
|
||||
assert!(json.contains("pg_restore_found"));
|
||||
assert!(json.contains("pg_dump (PostgreSQL) 16.0"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// BackupProgressEvent serialization
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn backup_progress_event_completed() {
|
||||
let evt = BackupProgressEvent {
|
||||
job_id: "job-1".into(),
|
||||
status: "completed".into(),
|
||||
progress: Some(1.0),
|
||||
output_line: None,
|
||||
error: None,
|
||||
};
|
||||
let json = serde_json::to_string(&evt).unwrap();
|
||||
assert!(json.contains("\"completed\""));
|
||||
assert!(json.contains("\"progress\":1.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_progress_event_failed() {
|
||||
let evt = BackupProgressEvent {
|
||||
job_id: "job-2".into(),
|
||||
status: "failed".into(),
|
||||
progress: None,
|
||||
output_line: None,
|
||||
error: Some("connection refused".into()),
|
||||
};
|
||||
let json = serde_json::to_string(&evt).unwrap();
|
||||
assert!(json.contains("\"failed\""));
|
||||
assert!(json.contains("\"connection refused\""));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Integration tests (headless, against live DBs)
|
||||
//
|
||||
// These exercise the real dump/restore/sync code path (run_pg_dump,
|
||||
// run_pg_restore, run_db_sync) with passwords passed directly — the
|
||||
// only thing skipped is the OS-keychain lookup, which is a thin,
|
||||
// separately-tested concern.
|
||||
//
|
||||
// They are #[ignore]d by default so they don't run in normal `cargo test`.
|
||||
// Run them explicitly with:
|
||||
//
|
||||
// GRIDLINE_TEST_SRC_HOST=... GRIDLINE_TEST_SRC_PORT=... \
|
||||
// GRIDLINE_TEST_SRC_USER=... GRIDLINE_TEST_SRC_DB=... \
|
||||
// GRIDLINE_TEST_SRC_PASSWORD=... \
|
||||
// GRIDLINE_TEST_TGT_HOST=... GRIDLINE_TEST_TGT_PORT=... \
|
||||
// GRIDLINE_TEST_TGT_USER=... GRIDLINE_TEST_TGT_DB=... \
|
||||
// GRIDLINE_TEST_TGT_PASSWORD=... \
|
||||
// cargo test --lib backup -- --ignored
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
fn env(name: &str) -> String {
|
||||
std::env::var(name).unwrap_or_else(|_| panic!("missing env var {name}"))
|
||||
}
|
||||
|
||||
// Serializes the live-DB integration tests so they don't clobber each other
|
||||
// when cargo runs them in parallel.
|
||||
static INTEGRATION_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
fn conn_from_env(prefix: &str) -> PgConnParams {
|
||||
PgConnParams::new(
|
||||
env(&format!("{prefix}_HOST")),
|
||||
env(&format!("{prefix}_PORT")).parse().unwrap(),
|
||||
env(&format!("{prefix}_USER")),
|
||||
env(&format!("{prefix}_DB")),
|
||||
env(&format!("{prefix}_PASSWORD")),
|
||||
)
|
||||
}
|
||||
|
||||
fn psql_exec(conn: &PgConnParams, sql: &str) {
|
||||
let out = Command::new("psql")
|
||||
.env("PGPASSWORD", &conn.password)
|
||||
.args([
|
||||
format!("--host={}", conn.host),
|
||||
format!("--port={}", conn.port),
|
||||
format!("--username={}", conn.username),
|
||||
format!("--dbname={}", conn.database),
|
||||
"-tA".into(),
|
||||
"-c".into(),
|
||||
sql.into(),
|
||||
])
|
||||
.output()
|
||||
.expect("psql should run");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"psql failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
fn psql_count(conn: &PgConnParams, query: &str) -> i64 {
|
||||
let out = Command::new("psql")
|
||||
.env("PGPASSWORD", &conn.password)
|
||||
.args([
|
||||
format!("--host={}", conn.host),
|
||||
format!("--port={}", conn.port),
|
||||
format!("--username={}", conn.username),
|
||||
format!("--dbname={}", conn.database),
|
||||
"-tA".into(),
|
||||
"-c".into(),
|
||||
query.into(),
|
||||
])
|
||||
.output()
|
||||
.expect("psql should run");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"psql failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
String::from_utf8_lossy(&out.stdout).trim().parse().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn integration_dump_restore_sync() {
|
||||
let _guard = INTEGRATION_LOCK.lock().unwrap();
|
||||
let src = conn_from_env("GRIDLINE_TEST_SRC");
|
||||
let tgt = conn_from_env("GRIDLINE_TEST_TGT");
|
||||
|
||||
// Unique temp file per run to avoid collisions.
|
||||
let dump_path = std::env::temp_dir().join(format!(
|
||||
"gridline_it_dump_{}_{}.bak",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
let dump_path_str = dump_path.to_str().unwrap().to_string();
|
||||
|
||||
// --- 1. Dump source (custom format — the only format pg_restore can read) ---
|
||||
let dump_opts = BackupOptions {
|
||||
format: "custom".into(),
|
||||
file_path: dump_path_str.clone(),
|
||||
schema: None,
|
||||
tables: None,
|
||||
no_owner: true,
|
||||
};
|
||||
run_pg_dump(&src, &dump_opts, &PgToolPaths { pg_dump: "pg_dump".into(), pg_restore: "pg_restore".into(), psql: "psql".into() })
|
||||
.expect("pg_dump should succeed");
|
||||
|
||||
// --- 2. Restore into target ---
|
||||
let restore_opts = RestoreOptions {
|
||||
format: "custom".into(),
|
||||
file_path: dump_path_str.clone(),
|
||||
clean: true,
|
||||
schema: None,
|
||||
};
|
||||
run_pg_restore(&tgt, &restore_opts, &PgToolPaths { pg_dump: "pg_dump".into(), pg_restore: "pg_restore".into(), psql: "psql".into() })
|
||||
.expect("pg_restore should succeed");
|
||||
|
||||
// --- 3. Verify data landed in target ---
|
||||
assert_eq!(
|
||||
psql_count(&tgt, "SELECT count(*) FROM public.products;"),
|
||||
3,
|
||||
"products should be restored"
|
||||
);
|
||||
assert_eq!(
|
||||
psql_count(&tgt, "SELECT count(*) FROM public.orders;"),
|
||||
3,
|
||||
"orders should be restored"
|
||||
);
|
||||
|
||||
// --- 4. Sync source -> target (target already has tables from the restore
|
||||
// above — db_sync now passes --clean --if-exists, so it must succeed into a
|
||||
// non-empty target). ---
|
||||
run_db_sync(&src, &tgt, None, None, &PgToolPaths { pg_dump: "pg_dump".into(), pg_restore: "pg_restore".into(), psql: "psql".into() })
|
||||
.expect("db_sync should succeed");
|
||||
assert_eq!(
|
||||
psql_count(&tgt, "SELECT count(*) FROM public.products;"),
|
||||
3,
|
||||
"sync should re-copy products"
|
||||
);
|
||||
assert_eq!(
|
||||
psql_count(&tgt, "SELECT count(*) FROM public.orders;"),
|
||||
3,
|
||||
"sync should re-copy orders"
|
||||
);
|
||||
|
||||
// --- Cleanup ---
|
||||
let _ = std::fs::remove_file(&dump_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn integration_plain_dump_restore() {
|
||||
let _guard = INTEGRATION_LOCK.lock().unwrap();
|
||||
let src = conn_from_env("GRIDLINE_TEST_SRC");
|
||||
let tgt = conn_from_env("GRIDLINE_TEST_TGT");
|
||||
|
||||
// psql can't DROP-before-CREATE, so start from a clean target.
|
||||
psql_exec(
|
||||
&tgt,
|
||||
"DROP TABLE IF EXISTS public.orders, public.products CASCADE;",
|
||||
);
|
||||
|
||||
let dump_path = std::env::temp_dir().join(format!(
|
||||
"gridline_it_plain_{}_{}.sql",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
let dump_path_str = dump_path.to_str().unwrap().to_string();
|
||||
|
||||
// --- 1. Dump source in plain format ---
|
||||
let dump_opts = BackupOptions {
|
||||
format: "plain".into(),
|
||||
file_path: dump_path_str.clone(),
|
||||
schema: None,
|
||||
tables: None,
|
||||
no_owner: true,
|
||||
};
|
||||
run_pg_dump(&src, &dump_opts, &PgToolPaths { pg_dump: "pg_dump".into(), pg_restore: "pg_restore".into(), psql: "psql".into() })
|
||||
.expect("pg_dump (plain) should succeed");
|
||||
|
||||
// --- 2. Restore into target (plain -> psql path) ---
|
||||
let restore_opts = RestoreOptions {
|
||||
format: "plain".into(),
|
||||
file_path: dump_path_str.clone(),
|
||||
clean: false,
|
||||
schema: None,
|
||||
};
|
||||
run_pg_restore(&tgt, &restore_opts, &PgToolPaths { pg_dump: "pg_dump".into(), pg_restore: "pg_restore".into(), psql: "psql".into() })
|
||||
.expect("pg_restore (plain/psql) should succeed");
|
||||
|
||||
// --- 3. Verify data landed in target ---
|
||||
assert_eq!(
|
||||
psql_count(&tgt, "SELECT count(*) FROM public.products;"),
|
||||
3,
|
||||
"plain restore should load products"
|
||||
);
|
||||
assert_eq!(
|
||||
psql_count(&tgt, "SELECT count(*) FROM public.orders;"),
|
||||
3,
|
||||
"plain restore should load orders"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&dump_path);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Tool resolution (Task 2.1: system-first, bundled-fallback)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn pick_tool_prefers_system_then_bundled_then_bare() {
|
||||
assert_eq!(pick_tool(true, None, "pg_dump"), ("pg_dump".to_string(), Some("system".into())));
|
||||
assert_eq!(pick_tool(false, Some("/r/pg_dump"), "pg_dump"), ("/r/pg_dump".to_string(), Some("bundled".into())));
|
||||
assert_eq!(pick_tool(false, None, "pg_dump"), ("pg_dump".to_string(), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_bin_name_appends_exe_on_windows() {
|
||||
let name = bundled_bin_name("pg_dump");
|
||||
if cfg!(windows) { assert_eq!(name, "pg_dump.exe"); } else { assert_eq!(name, "pg_dump"); }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// SQLite .dump / restore / sync core (Task 2.2)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
use rusqlite::Connection;
|
||||
use std::io::Cursor;
|
||||
|
||||
fn seed_sqlite() -> Connection {
|
||||
let c = Connection::open_in_memory().unwrap();
|
||||
c.execute_batch(
|
||||
"CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL);
|
||||
CREATE INDEX users_name ON users(name);
|
||||
INSERT INTO users (name) VALUES ('Alice'),('Bob');
|
||||
CREATE TABLE blobs (id INTEGER PRIMARY KEY, data BLOB);
|
||||
INSERT INTO blobs VALUES (1, x'010203');",
|
||||
)
|
||||
.unwrap();
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_dump_and_restore_roundtrip() {
|
||||
let src = seed_sqlite();
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
let mut cur = std::io::Cursor::new(&mut buf);
|
||||
dump_sqlite_to(&src, &mut cur, |_| {}).unwrap();
|
||||
let text = String::from_utf8(buf).unwrap();
|
||||
assert!(text.contains("PRAGMA foreign_keys=OFF"));
|
||||
assert!(text.contains("BEGIN TRANSACTION"));
|
||||
assert!(text.contains("CREATE TABLE users"));
|
||||
assert!(text.contains("INSERT INTO \"users\""));
|
||||
assert!(text.contains("X'010203'"), "BLOB must be hex-literal");
|
||||
assert!(text.contains("CREATE INDEX users_name"));
|
||||
|
||||
let dst = Connection::open_in_memory().unwrap();
|
||||
restore_sqlite(&dst, &text, false).unwrap();
|
||||
let n: i64 = dst.query_row("SELECT COUNT(*) FROM users", [], |r| r.get(0)).unwrap();
|
||||
assert_eq!(n, 2);
|
||||
let blobs: i64 = dst.query_row("SELECT COUNT(*) FROM blobs", [], |r| r.get(0)).unwrap();
|
||||
assert_eq!(blobs, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_dump_preserves_autoincrement_sequence() {
|
||||
let src = Connection::open_in_memory().unwrap();
|
||||
src.execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, v TEXT); INSERT INTO t(v) VALUES ('a'),('b');").unwrap();
|
||||
let mut buf = Vec::new();
|
||||
dump_sqlite_to(&src, &mut Cursor::new(&mut buf), |_| {}).unwrap();
|
||||
let text = String::from_utf8(buf).unwrap();
|
||||
let dst = Connection::open_in_memory().unwrap();
|
||||
restore_sqlite(&dst, &text, false).unwrap();
|
||||
dst.execute("INSERT INTO t(v) VALUES ('c')", []).unwrap();
|
||||
let id: i64 = dst.query_row("SELECT id FROM t WHERE v='c'", [], |r| r.get(0)).unwrap();
|
||||
assert_eq!(id, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_dump_fail_closed_for_virtual_tables() {
|
||||
let src = Connection::open_in_memory().unwrap();
|
||||
src.execute_batch("CREATE VIRTUAL TABLE ft USING fts4(content)").unwrap();
|
||||
let mut buf = Vec::new();
|
||||
let err = dump_sqlite_to(&src, &mut Cursor::new(&mut buf), |_| {}).unwrap_err();
|
||||
assert!(err.to_lowercase().contains("virtual table"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_restore_clean_drops_existing() {
|
||||
let src = seed_sqlite();
|
||||
let mut buf = Vec::new();
|
||||
dump_sqlite_to(&src, &mut Cursor::new(&mut buf), |_| {}).unwrap();
|
||||
let text = String::from_utf8(buf).unwrap();
|
||||
let dst = Connection::open_in_memory().unwrap();
|
||||
dst.execute_batch("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT); INSERT INTO users VALUES (99,'old');").unwrap();
|
||||
restore_sqlite(&dst, &text, true).unwrap();
|
||||
let names: Vec<String> = dst.prepare("SELECT name FROM users ORDER BY id").unwrap().query_map([], |r| r.get::<_, String>(0)).unwrap().filter_map(|r| r.ok()).collect();
|
||||
assert_eq!(names, vec!["Alice".to_string(), "Bob".to_string()]);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// MySQL dump / restore / sync arg building (Task 2.3)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn mysql_dump_args_single_transaction_no_data_routines() {
|
||||
let opts = MySqlBackupOptions {
|
||||
database: "shop".into(), file_path: "/tmp/d.sql".into(),
|
||||
single_transaction: true, no_data: true, routines: true, triggers: false, events: false,
|
||||
};
|
||||
let args = build_mysql_dump_args(&MySqlConnParams::new("h".into(), 3306, "u".into(), "shop".into(), "p".into()), &opts);
|
||||
assert!(args.iter().any(|a| a == "--single-transaction"));
|
||||
assert!(args.iter().any(|a| a == "--no-data"));
|
||||
assert!(args.iter().any(|a| a == "--routines"));
|
||||
assert!(args.iter().any(|a| a == "--databases=shop"));
|
||||
assert!(args.iter().any(|a| a == "--result-file=/tmp/d.sql"));
|
||||
// no --password on the command line (uses MYSQL_PWD env)
|
||||
assert!(args.iter().all(|a| !a.starts_with("--password")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_restore_args_no_clean_flags() {
|
||||
let opts = MySqlRestoreOptions { database: "shop".into(), file_path: "/tmp/d.sql".into(), clean: false };
|
||||
let args = build_mysql_restore_args(&MySqlConnParams::new("h".into(), 3306, "u".into(), "shop".into(), "p".into()), &opts);
|
||||
assert!(args.iter().any(|a| a == "--database=shop"));
|
||||
assert!(args.iter().any(|a| a == "--host=h"));
|
||||
assert!(args.iter().all(|a| a != "--force"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_env_uses_mysql_pwd_not_password_arg() {
|
||||
let opts = MySqlBackupOptions { database: "db".into(), file_path: "/tmp/x.sql".into(), single_transaction: false, no_data: false, routines: false, triggers: false, events: false };
|
||||
let args = build_mysql_dump_args(&MySqlConnParams::new("h".into(), 3306, "u".into(), "db".into(), "p".into()), &opts);
|
||||
assert!(args.iter().all(|a| !a.starts_with("--password")));
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
use crate::models::{Connection, ConnectionInput};
|
||||
use crate::store::Store;
|
||||
use std::sync::Mutex;
|
||||
|
||||
const VALID_DB_TYPES: [&str; 4] = ["postgresql", "mysql", "sqlite", "redis"];
|
||||
|
||||
fn validate(input: &ConnectionInput) -> Result<(), String> {
|
||||
if input.name.is_empty() || input.name.chars().count() > 100 {
|
||||
return Err("name is required and must be 100 chars or fewer".into());
|
||||
}
|
||||
if !VALID_DB_TYPES.contains(&input.db_type.as_str()) {
|
||||
return Err(format!(
|
||||
"db_type must be one of: {}",
|
||||
VALID_DB_TYPES.join(", ")
|
||||
));
|
||||
}
|
||||
if input.host.is_empty() || input.host.chars().count() > 255 {
|
||||
return Err("host is required and must be 255 chars or fewer".into());
|
||||
}
|
||||
if input.db_type != "sqlite" {
|
||||
match input.port {
|
||||
Some(p) if (1..=65535).contains(&p) => {}
|
||||
_ => return Err("port must be an integer between 1 and 65535 for this db_type".into()),
|
||||
}
|
||||
}
|
||||
if let Some(u) = &input.username {
|
||||
if u.chars().count() > 100 {
|
||||
return Err("username must be 100 chars or fewer".into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_connections_inner(state: &Mutex<Store>) -> Result<Vec<Connection>, String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.get_connections()
|
||||
}
|
||||
|
||||
pub fn create_connection_inner(
|
||||
state: &Mutex<Store>,
|
||||
input: ConnectionInput,
|
||||
) -> Result<Connection, String> {
|
||||
validate(&input)?;
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.create_connection(input)
|
||||
}
|
||||
|
||||
pub fn update_connection_inner(
|
||||
state: &Mutex<Store>,
|
||||
id: String,
|
||||
input: ConnectionInput,
|
||||
) -> Result<Connection, String> {
|
||||
validate(&input)?;
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.update_connection(&id, input)
|
||||
}
|
||||
|
||||
pub fn delete_connection_inner(state: &Mutex<Store>, id: &str) -> Result<(), String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.delete_connection(id)
|
||||
}
|
||||
|
||||
pub fn add_connection_tags_inner(
|
||||
state: &Mutex<Store>,
|
||||
connection_id: String,
|
||||
tag_ids: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.add_connection_tags(&connection_id, &tag_ids)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_connections(state: tauri::State<crate::AppState>) -> Result<Vec<Connection>, String> {
|
||||
get_connections_inner(&state.db_store)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_connection(
|
||||
state: tauri::State<crate::AppState>,
|
||||
input: ConnectionInput,
|
||||
) -> Result<Connection, String> {
|
||||
create_connection_inner(&state.db_store, input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_connection(
|
||||
state: tauri::State<crate::AppState>,
|
||||
id: String,
|
||||
input: ConnectionInput,
|
||||
) -> Result<Connection, String> {
|
||||
update_connection_inner(&state.db_store, id, input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_connection(
|
||||
state: tauri::State<crate::AppState>,
|
||||
id: String,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
delete_connection_inner(&state.db_store, &id)?;
|
||||
// Purge keychain secrets (missing entries are no-ops) and close any
|
||||
// SSH tunnel associated with the deleted connection.
|
||||
let _ = crate::commands::keychain::delete_connection_password_internal(&app, &id);
|
||||
let _ = crate::commands::keychain::delete_connection_ssh_password_internal(&app, &id);
|
||||
let _ = crate::commands::keychain::delete_connection_ssh_passphrase_internal(&app, &id);
|
||||
if let Ok(mut mgr) = state.ssh_manager.lock() {
|
||||
mgr.close_tunnel(&id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn add_connection_tags(
|
||||
state: tauri::State<crate::AppState>,
|
||||
connection_id: String,
|
||||
tag_ids: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
add_connection_tags_inner(&state.db_store, connection_id, tag_ids)
|
||||
}
|
||||
|
||||
pub fn set_connection_favorite_inner(
|
||||
state: &Mutex<Store>,
|
||||
connection_id: String,
|
||||
favorite: bool,
|
||||
) -> Result<(), String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.set_connection_favorite(&connection_id, favorite)
|
||||
}
|
||||
|
||||
pub fn record_recent_connection_inner(
|
||||
state: &Mutex<Store>,
|
||||
connection_id: String,
|
||||
) -> Result<(), String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.record_recent_connection(&connection_id)
|
||||
}
|
||||
|
||||
pub fn get_recent_connections_inner(
|
||||
state: &Mutex<Store>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<crate::models::RecentConnection>, String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.get_recent_connections(limit)
|
||||
}
|
||||
|
||||
pub fn clear_recent_connections_inner(state: &Mutex<Store>) -> Result<(), String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.clear_recent_connections()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_connection_favorite(
|
||||
state: tauri::State<crate::AppState>,
|
||||
connection_id: String,
|
||||
favorite: bool,
|
||||
) -> Result<(), String> {
|
||||
set_connection_favorite_inner(&state.db_store, connection_id, favorite)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn record_recent_connection(
|
||||
state: tauri::State<crate::AppState>,
|
||||
connection_id: String,
|
||||
) -> Result<(), String> {
|
||||
record_recent_connection_inner(&state.db_store, connection_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_recent_connections(
|
||||
state: tauri::State<crate::AppState>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<crate::models::RecentConnection>, String> {
|
||||
get_recent_connections_inner(&state.db_store, limit)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn clear_recent_connections(state: tauri::State<crate::AppState>) -> Result<(), String> {
|
||||
clear_recent_connections_inner(&state.db_store)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::ConnectionInput;
|
||||
use crate::store::Store;
|
||||
|
||||
fn state() -> std::sync::Mutex<Store> {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
crate::store::migrations::run_migrations(&conn).unwrap();
|
||||
std::sync::Mutex::new(Store::from_connection(conn))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_connections_returns_list() {
|
||||
let st = state();
|
||||
let result = get_connections_inner(&st);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_connection_command_returns_connection() {
|
||||
let st = state();
|
||||
let input = ConnectionInput {
|
||||
name: "Prod".into(),
|
||||
db_type: "postgresql".into(),
|
||||
host: "h".into(),
|
||||
port: Some(5432),
|
||||
username: None,
|
||||
folder_id: None,
|
||||
password: None,
|
||||
database: None,
|
||||
environment: None,
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_password: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
};
|
||||
let result = create_connection_inner(&st, input.clone()).unwrap();
|
||||
assert_eq!(result.name, "Prod");
|
||||
assert_eq!(get_connections_inner(&st).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_connection_rejects_invalid_db_type() {
|
||||
let st = state();
|
||||
let input = ConnectionInput {
|
||||
name: "X".into(),
|
||||
db_type: "mongodb".into(),
|
||||
host: "h".into(),
|
||||
port: Some(5432),
|
||||
username: None,
|
||||
folder_id: None,
|
||||
password: None,
|
||||
database: None,
|
||||
environment: None,
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_password: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
};
|
||||
assert!(create_connection_inner(&st, input).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_connection_command_removes_it() {
|
||||
let st = state();
|
||||
let input = ConnectionInput {
|
||||
name: "X".into(),
|
||||
db_type: "postgresql".into(),
|
||||
host: "h".into(),
|
||||
port: Some(5432),
|
||||
username: None,
|
||||
folder_id: None,
|
||||
password: None,
|
||||
database: None,
|
||||
environment: None,
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_password: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
};
|
||||
let conn = create_connection_inner(&st, input).unwrap();
|
||||
delete_connection_inner(&st, &conn.id).unwrap();
|
||||
assert_eq!(get_connections_inner(&st).unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_connection_favorite_command_persists() {
|
||||
let st = state();
|
||||
let input = ConnectionInput {
|
||||
name: "P".into(),
|
||||
db_type: "postgresql".into(),
|
||||
host: "h".into(),
|
||||
port: Some(5432),
|
||||
username: None,
|
||||
folder_id: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
password: None,
|
||||
database: None,
|
||||
environment: None,
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_password: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
};
|
||||
let conn = create_connection_inner(&st, input).unwrap();
|
||||
set_connection_favorite_inner(&st, conn.id.clone(), true).unwrap();
|
||||
assert_eq!(get_connections_inner(&st).unwrap()[0].favorite, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_recent_command_upserts() {
|
||||
let st = state();
|
||||
let input = ConnectionInput {
|
||||
name: "P".into(),
|
||||
db_type: "postgresql".into(),
|
||||
host: "h".into(),
|
||||
port: Some(5432),
|
||||
username: None,
|
||||
folder_id: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
password: None,
|
||||
database: None,
|
||||
environment: None,
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_password: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
};
|
||||
let conn = create_connection_inner(&st, input).unwrap();
|
||||
record_recent_connection_inner(&st, conn.id.clone()).unwrap();
|
||||
record_recent_connection_inner(&st, conn.id.clone()).unwrap(); // idempotent upsert
|
||||
let recent = get_recent_connections_inner(&st, 10).unwrap();
|
||||
assert_eq!(recent.len(), 1);
|
||||
clear_recent_connections_inner(&st).unwrap();
|
||||
assert_eq!(get_recent_connections_inner(&st, 10).unwrap().len(), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
use crate::models::ConnectionInput;
|
||||
use crate::store::Store;
|
||||
use crate::AppState;
|
||||
use rusqlite::Connection;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use tauri::Manager;
|
||||
|
||||
const DEMO_DB_FILENAME: &str = "demo.db";
|
||||
const DEMO_CONNECTION_NAME: &str = "Gridline Demo (SQLite)";
|
||||
/// Bump whenever the demo schema or seed data changes so existing demo files
|
||||
/// are recreated on the next launch. The demo is disposable by design — it
|
||||
/// should always showcase the current feature set.
|
||||
const DEMO_SCHEMA_VERSION: i64 = 3;
|
||||
|
||||
/// True when the demo file's `PRAGMA user_version` is at or above the current
|
||||
/// schema version (i.e. the file already carries the full feature set).
|
||||
fn demo_file_is_current(conn: &Connection) -> Result<bool, String> {
|
||||
let version: i64 = conn
|
||||
.query_row("PRAGMA user_version", [], |r| r.get(0))
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(version >= DEMO_SCHEMA_VERSION)
|
||||
}
|
||||
|
||||
/// Ensure the demo SQLite file at `path` exists and is seeded with the current
|
||||
/// schema. Files that predate `DEMO_SCHEMA_VERSION` are recreated so the demo
|
||||
/// always showcases every feature.
|
||||
fn ensure_demo_file(path: &Path) -> Result<(), String> {
|
||||
let stale = path.exists() && {
|
||||
let conn = Connection::open(path).map_err(|e| e.to_string())?;
|
||||
!demo_file_is_current(&conn).unwrap_or(false)
|
||||
};
|
||||
if stale {
|
||||
std::fs::remove_file(path).map_err(|e| e.to_string())?;
|
||||
}
|
||||
if !path.exists() {
|
||||
let conn = Connection::open(path).map_err(|e| format!("Failed to create demo DB: {e}"))?;
|
||||
conn.execute_batch(&get_demo_schema())
|
||||
.map_err(|e| format!("Failed to seed demo DB: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build the connection input for the demo SQLite file at `db_path`.
|
||||
fn demo_connection_input(db_path: &Path) -> ConnectionInput {
|
||||
ConnectionInput {
|
||||
name: DEMO_CONNECTION_NAME.to_string(),
|
||||
db_type: "sqlite".to_string(),
|
||||
host: db_path.to_string_lossy().to_string(),
|
||||
port: None,
|
||||
username: None,
|
||||
password: None,
|
||||
database: None,
|
||||
folder_id: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
environment: Some("development".to_string()),
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_password: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the demo database file under the app data directory (the same
|
||||
/// location the startup `ensure_demo_db` flow seeds), creating the directory
|
||||
/// when needed.
|
||||
fn demo_db_path(app: &tauri::AppHandle) -> Result<std::path::PathBuf, String> {
|
||||
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||
std::fs::create_dir_all(&data_dir).map_err(|e| e.to_string())?;
|
||||
Ok(data_dir.join(DEMO_DB_FILENAME))
|
||||
}
|
||||
|
||||
/// Register the demo connection unless one already exists.
|
||||
fn ensure_demo_connection(store: &Mutex<Store>, db_path: &Path) -> Result<(), String> {
|
||||
let exists = {
|
||||
let s = store.lock().map_err(|e| e.to_string())?;
|
||||
s.get_connections()?
|
||||
.iter()
|
||||
.any(|c| c.name == DEMO_CONNECTION_NAME)
|
||||
};
|
||||
if exists {
|
||||
return Ok(());
|
||||
}
|
||||
let input = demo_connection_input(db_path);
|
||||
let s = store.lock().map_err(|e| e.to_string())?;
|
||||
s.create_connection(input)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop any live pool handle for the demo connection so its SQLite file can
|
||||
/// be deleted and recreated cleanly.
|
||||
async fn disconnect_demo_pool(state: &tauri::State<'_, AppState>) -> Result<(), String> {
|
||||
let demo_id = {
|
||||
let s = state.db_store.lock().map_err(|e| e.to_string())?;
|
||||
s.get_connections()?
|
||||
.iter()
|
||||
.find(|c| c.name == DEMO_CONNECTION_NAME)
|
||||
.map(|c| c.id.clone())
|
||||
};
|
||||
if let Some(id) = demo_id {
|
||||
let mut pm = state.pool_manager.lock().await;
|
||||
pm.remove(&id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensure the demo SQLite database exists and a corresponding connection is
|
||||
/// registered. Safe to call on every app start — it's idempotent, and it
|
||||
/// upgrades stale demo files to the current schema automatically.
|
||||
pub fn ensure_demo_db(app_handle: &tauri::AppHandle, store: &Mutex<Store>) -> Result<(), String> {
|
||||
let db_path = demo_db_path(app_handle)?;
|
||||
ensure_demo_file(&db_path)?;
|
||||
ensure_demo_connection(store, &db_path)
|
||||
}
|
||||
|
||||
/// Tauri command to re-add the demo connection from the settings screen.
|
||||
/// Recreates the demo file when it is missing or stale, then registers the
|
||||
/// connection (always under the app data directory, matching startup).
|
||||
#[tauri::command]
|
||||
pub fn recreate_demo_db(
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<AppState>,
|
||||
) -> Result<String, String> {
|
||||
let db_path = demo_db_path(&app)?;
|
||||
ensure_demo_file(&db_path)?;
|
||||
ensure_demo_connection(&state.db_store, &db_path)?;
|
||||
Ok("Demo database connection re-created.".to_string())
|
||||
}
|
||||
|
||||
/// Tauri command to regenerate the demo database from the settings screen:
|
||||
/// drops any live pool handle, wipes the current file (including any edits
|
||||
/// made against it) and re-seeds it with fresh demo data.
|
||||
#[tauri::command]
|
||||
pub async fn regenerate_demo_db(
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
let db_path = demo_db_path(&app)?;
|
||||
disconnect_demo_pool(&state).await?;
|
||||
if db_path.exists() {
|
||||
std::fs::remove_file(&db_path).map_err(|e| e.to_string())?;
|
||||
}
|
||||
ensure_demo_file(&db_path)?;
|
||||
ensure_demo_connection(&state.db_store, &db_path)?;
|
||||
Ok("Demo database regenerated with fresh data.".to_string())
|
||||
}
|
||||
|
||||
/// The demo schema + seed data. Loaded from `demo_schema.sql` so the SQL can
|
||||
/// be edited and reviewed with normal editor tooling.
|
||||
fn get_demo_schema() -> String {
|
||||
include_str!("demo_schema.sql").to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "demo.test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,379 @@
|
||||
use super::*;
|
||||
use crate::commands::db_viewer::build_sqlite_data_select;
|
||||
|
||||
/// Open an in-memory SQLite database seeded with the demo schema.
|
||||
fn seed_demo() -> rusqlite::Connection {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(&get_demo_schema()).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
/// Count rows in a table/view.
|
||||
fn count(conn: &rusqlite::Connection, table: &str) -> i64 {
|
||||
conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_schema_creates_expected_objects() {
|
||||
let conn = seed_demo();
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT name, type FROM sqlite_master WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
||||
.unwrap();
|
||||
let objects: Vec<(String, String)> = stmt
|
||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
let expected: Vec<(&str, &str)> = vec![
|
||||
("addresses", "table"),
|
||||
("app_settings", "table"),
|
||||
("audit_log", "table"),
|
||||
("categories", "table"),
|
||||
("files", "table"),
|
||||
("marketing_campaigns", "table"),
|
||||
("order_items", "table"),
|
||||
("order_summary", "view"),
|
||||
("orders", "table"),
|
||||
("page_views", "table"),
|
||||
("products", "table"),
|
||||
("users", "table"),
|
||||
];
|
||||
assert_eq!(
|
||||
objects,
|
||||
expected
|
||||
.into_iter()
|
||||
.map(|(n, t)| (n.to_string(), t.to_string()))
|
||||
.collect::<Vec<_>>(),
|
||||
"demo schema must contain exactly the expected tables + view"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_schema_seeds_expected_rows() {
|
||||
let conn = seed_demo();
|
||||
assert_eq!(count(&conn, "users"), 20);
|
||||
assert_eq!(count(&conn, "categories"), 6);
|
||||
assert_eq!(count(&conn, "products"), 24);
|
||||
assert_eq!(count(&conn, "addresses"), 23);
|
||||
assert_eq!(count(&conn, "orders"), 50);
|
||||
assert_eq!(count(&conn, "order_items"), 105);
|
||||
assert_eq!(
|
||||
count(&conn, "audit_log"),
|
||||
500,
|
||||
"audit_log must seed 500 rows for pagination/virtualization demos"
|
||||
);
|
||||
assert_eq!(count(&conn, "files"), 8);
|
||||
assert_eq!(count(&conn, "page_views"), 100);
|
||||
assert_eq!(count(&conn, "app_settings"), 6);
|
||||
assert_eq!(
|
||||
count(&conn, "marketing_campaigns"),
|
||||
0,
|
||||
"marketing_campaigns must stay empty to demo the Empty Table change"
|
||||
);
|
||||
assert_eq!(
|
||||
count(&conn, "order_summary"),
|
||||
50,
|
||||
"view must return one row per order"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_schema_is_idempotent() {
|
||||
let conn = seed_demo();
|
||||
// Re-running the full schema (e.g. on a fresh file after a partial seed)
|
||||
// must not duplicate rows.
|
||||
conn.execute_batch(&get_demo_schema()).unwrap();
|
||||
assert_eq!(count(&conn, "users"), 20);
|
||||
assert_eq!(count(&conn, "audit_log"), 500);
|
||||
assert_eq!(count(&conn, "page_views"), 100);
|
||||
assert_eq!(count(&conn, "products"), 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_schema_sets_user_version() {
|
||||
let conn = seed_demo();
|
||||
let v: i64 = conn
|
||||
.query_row("PRAGMA user_version", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
v, DEMO_SCHEMA_VERSION,
|
||||
"demo file must stamp PRAGMA user_version for upgrade detection"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_json_columns_hold_valid_json() {
|
||||
let conn = seed_demo();
|
||||
// Every non-NULL value in a `json`-declared column must parse as JSON so
|
||||
// the grid's JSON popover can format it.
|
||||
for (table, column) in [
|
||||
("users", "preferences"),
|
||||
("products", "attributes"),
|
||||
("audit_log", "details"),
|
||||
] {
|
||||
let mut stmt = conn
|
||||
.prepare(&format!(
|
||||
"SELECT {column} FROM {table} WHERE {column} IS NOT NULL"
|
||||
))
|
||||
.unwrap();
|
||||
let values: Vec<String> = stmt
|
||||
.query_map([], |row| row.get::<_, String>(0))
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
assert!(
|
||||
!values.is_empty(),
|
||||
"{table}.{column} should have non-null values"
|
||||
);
|
||||
for v in &values {
|
||||
assert!(
|
||||
serde_json::from_str::<serde_json::Value>(v).is_ok(),
|
||||
"{table}.{column} must hold valid JSON, got: {v}"
|
||||
);
|
||||
}
|
||||
}
|
||||
// And the declared type must be lowercase `json` so the frontend's
|
||||
// `data_type === "json"` check triggers the JSON cell popover.
|
||||
for (table, column) in [
|
||||
("users", "preferences"),
|
||||
("products", "attributes"),
|
||||
("audit_log", "details"),
|
||||
] {
|
||||
let dt: String = conn
|
||||
.query_row(
|
||||
&format!("SELECT type FROM pragma_table_info('{table}') WHERE name = '{column}'"),
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
dt, "json",
|
||||
"{table}.{column} must be declared lowercase json"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_view_is_queryable() {
|
||||
let conn = seed_demo();
|
||||
let mut stmt = conn.prepare("SELECT order_id, customer_name, item_count, order_total, status FROM order_summary ORDER BY order_id").unwrap();
|
||||
let rows: Vec<(i64, String, i64, f64, String)> = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
row.get(1)?,
|
||||
row.get(2)?,
|
||||
row.get(3)?,
|
||||
row.get(4)?,
|
||||
))
|
||||
})
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
assert_eq!(rows.len(), 50);
|
||||
let first = &rows[0];
|
||||
assert_eq!(first.0, 1);
|
||||
assert_eq!(first.1, "Sarah Chen");
|
||||
assert_eq!(first.4, "processing");
|
||||
// order 1 is populated by generated line items; just ensure it has at least one.
|
||||
assert!(first.2 >= 1, "order 1 should have at least one line item");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_view_loads_through_table_data_path() {
|
||||
// Regression: a view tab used to fail with "no such column: rowid" because
|
||||
// the data SELECT appended the rowid locator (views have no PK → has_pk
|
||||
// false → locator appended → views expose no rowid). This mirrors the
|
||||
// SQLite branch of get_table_data exactly.
|
||||
let conn = seed_demo();
|
||||
let is_view: bool = conn
|
||||
.query_row(
|
||||
"SELECT type = 'view' FROM sqlite_master WHERE name = ?1 AND type IN ('table', 'view')",
|
||||
["order_summary"],
|
||||
|r| r.get::<_, bool>(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(is_view, "order_summary must be a view");
|
||||
|
||||
let mut pragma_stmt = conn.prepare("PRAGMA table_info('order_summary')").unwrap();
|
||||
let col_meta: Vec<(String, bool)> = pragma_stmt
|
||||
.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(1)?, row.get::<_, bool>(5)?))
|
||||
})
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
let has_pk = col_meta.iter().any(|(_, pk)| *pk);
|
||||
assert!(!has_pk, "views report no PK from PRAGMA table_info");
|
||||
|
||||
let visible_names: Vec<String> = col_meta.iter().map(|(n, _)| n.clone()).collect();
|
||||
let data_query = format!(
|
||||
"{} WHERE 1=1 LIMIT 50 OFFSET 0",
|
||||
build_sqlite_data_select("order_summary", &visible_names, !has_pk && !is_view)
|
||||
);
|
||||
assert!(
|
||||
!data_query.contains("rowid"),
|
||||
"view data SELECT must not select rowid; got: {}",
|
||||
data_query
|
||||
);
|
||||
let mut stmt = conn.prepare(&data_query).unwrap();
|
||||
let col_count = stmt.column_count();
|
||||
let rows: Vec<Vec<rusqlite::types::Value>> = stmt
|
||||
.query_map([], |row| {
|
||||
let mut vals = Vec::new();
|
||||
for i in 0..col_count {
|
||||
vals.push(row.get::<_, rusqlite::types::Value>(i)?);
|
||||
}
|
||||
Ok(vals)
|
||||
})
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
let expected: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM \"main\".\"order_summary\"", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
rows.len() as i64,
|
||||
expected,
|
||||
"view data query must return every view row (count = {expected})"
|
||||
);
|
||||
assert!(!rows.is_empty(), "seeded view must not be empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_foreign_keys_are_consistent() {
|
||||
let conn = seed_demo();
|
||||
// PRAGMA foreign_key_check reports any orphaned child rows.
|
||||
let violations: Vec<(String, i64, String, i64)> = conn
|
||||
.prepare("PRAGMA foreign_key_check")
|
||||
.unwrap()
|
||||
.query_map([], |row| {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
|
||||
})
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
assert!(
|
||||
violations.is_empty(),
|
||||
"seed data must satisfy every FK: {violations:?}"
|
||||
);
|
||||
// audit_log.user_id must be NULL or reference an existing user.
|
||||
let orphans: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM audit_log WHERE user_id IS NOT NULL AND user_id NOT IN (SELECT id FROM users)",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(orphans, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_tables_exercise_key_constraint_shapes() {
|
||||
let conn = seed_demo();
|
||||
// order_items: composite PRIMARY KEY across two columns. SQLite reports
|
||||
// pk as the 1-based position within the key, so both columns are > 0.
|
||||
let composite: Vec<(String, bool)> = conn
|
||||
.prepare("SELECT name, pk FROM pragma_table_info('order_items') WHERE pk > 0 ORDER BY pk")
|
||||
.unwrap()
|
||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
composite
|
||||
.iter()
|
||||
.map(|(n, _)| n.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["order_id", "product_id"]
|
||||
);
|
||||
// page_views: no primary key at all (rowid row-locator editing demo).
|
||||
let pk_cols: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('page_views') WHERE pk > 0",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
pk_cols, 0,
|
||||
"page_views must have no PK so the rowid locator kicks in"
|
||||
);
|
||||
// app_settings: TEXT primary key.
|
||||
let text_pk: String = conn
|
||||
.query_row(
|
||||
"SELECT name FROM pragma_table_info('app_settings') WHERE pk > 0",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(text_pk, "key");
|
||||
// categories: self-referencing FK.
|
||||
let self_fk: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_foreign_key_list('categories') WHERE \"table\" = 'categories'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(self_fk, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_file_is_recreated_when_stale() {
|
||||
// A file stamped with an older user_version must be recreated by
|
||||
// ensure_demo_file; a current file must be left untouched.
|
||||
let dir = std::env::temp_dir().join(format!("gridline-demo-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("demo.db");
|
||||
|
||||
// Stale file: old version, old schema.
|
||||
let stale = rusqlite::Connection::open(&path).unwrap();
|
||||
stale
|
||||
.execute_batch("PRAGMA user_version = 1; CREATE TABLE legacy (id INTEGER PRIMARY KEY);")
|
||||
.unwrap();
|
||||
drop(stale);
|
||||
ensure_demo_file(&path).unwrap();
|
||||
let conn = rusqlite::Connection::open(&path).unwrap();
|
||||
let v: i64 = conn
|
||||
.query_row("PRAGMA user_version", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
v, DEMO_SCHEMA_VERSION,
|
||||
"stale demo file must be recreated with the current version"
|
||||
);
|
||||
assert_eq!(
|
||||
count(&conn, "users"),
|
||||
20,
|
||||
"recreated file must be fully seeded"
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
// Current file: ensure_demo_file must not touch it.
|
||||
ensure_demo_file(&path).unwrap();
|
||||
let conn = rusqlite::Connection::open(&path).unwrap();
|
||||
assert_eq!(
|
||||
count(&conn, "users"),
|
||||
20,
|
||||
"current demo file must not be reseeded"
|
||||
);
|
||||
|
||||
// Regenerate flow: the file is deleted entirely, then recreated from
|
||||
// scratch with the current schema (what `regenerate_demo_db` does).
|
||||
drop(conn);
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
ensure_demo_file(&path).unwrap();
|
||||
let conn = rusqlite::Connection::open(&path).unwrap();
|
||||
let v: i64 = conn
|
||||
.query_row("PRAGMA user_version", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(v, DEMO_SCHEMA_VERSION);
|
||||
assert_eq!(count(&conn, "users"), 20);
|
||||
assert_eq!(count(&conn, "audit_log"), 500);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
PRAGMA user_version = 3;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- Gridline Demo (SQLite) — realistic e-commerce dataset
|
||||
--
|
||||
-- Designed to exercise every SQLite-aware Gridline feature:
|
||||
-- • PK/FK/composite-PK/self-FK metadata
|
||||
-- • JSON cells + JSON popover
|
||||
-- • BLOBs
|
||||
-- • CHECK / UNIQUE constraints, defaults
|
||||
-- • indexes
|
||||
-- • views
|
||||
-- • an empty table (Empty Table change demo)
|
||||
-- • a no-PK table (rowid row-locator editing)
|
||||
-- • a TEXT primary key
|
||||
-- • a 500-row table for pagination / virtualization / filtering
|
||||
-- • nullable columns, long text, smart-sort tiers
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- ── Core: users ─────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
role TEXT NOT NULL DEFAULT 'customer' CHECK (role IN ('admin','moderator','customer','guest')),
|
||||
phone TEXT,
|
||||
birth_date TEXT,
|
||||
bio TEXT,
|
||||
preferences json,
|
||||
balance REAL NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
last_login_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ── Core: categories (self-referencing FK) ──────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
parent_id INTEGER REFERENCES categories(id),
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
description TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ── Core: products ──────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sku TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
price REAL NOT NULL CHECK (price >= 0),
|
||||
category_id INTEGER REFERENCES categories(id),
|
||||
stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
|
||||
rating REAL CHECK (rating IS NULL OR (rating >= 0 AND rating <= 5)),
|
||||
discontinued INTEGER NOT NULL DEFAULT 0,
|
||||
attributes json,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ── Core: addresses (1:N from users, FK dropdown editor) ────────────────
|
||||
CREATE TABLE IF NOT EXISTS addresses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
label TEXT NOT NULL DEFAULT 'Home',
|
||||
street TEXT NOT NULL,
|
||||
city TEXT NOT NULL,
|
||||
state TEXT,
|
||||
zip TEXT,
|
||||
country TEXT NOT NULL DEFAULT 'USA',
|
||||
is_primary INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ── Core: orders (FKs -> users and addresses; CHECK status) ─────────────
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
shipping_address_id INTEGER REFERENCES addresses(id),
|
||||
total REAL NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending','processing','shipped','delivered','completed','cancelled','refunded')),
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
shipped_at TEXT
|
||||
);
|
||||
|
||||
-- ── Core: order_items (composite PRIMARY KEY; CHECK) ───────────────────
|
||||
CREATE TABLE IF NOT EXISTS order_items (
|
||||
order_id INTEGER NOT NULL REFERENCES orders(id),
|
||||
product_id INTEGER NOT NULL REFERENCES products(id),
|
||||
quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity > 0),
|
||||
unit_price REAL NOT NULL,
|
||||
PRIMARY KEY (order_id, product_id)
|
||||
);
|
||||
|
||||
-- ── Big table for pagination / virtualization / filtering (500 rows) ──
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
action TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id INTEGER,
|
||||
severity TEXT NOT NULL DEFAULT 'info'
|
||||
CHECK (severity IN ('info','warning','error','critical')),
|
||||
details json,
|
||||
duration_ms INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ── BLOB demo ───────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
mime_type TEXT NOT NULL,
|
||||
content BLOB,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
uploaded_by INTEGER REFERENCES users(id),
|
||||
uploaded_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ── No primary key on purpose: exercises the rowid row-locator editing ──
|
||||
CREATE TABLE IF NOT EXISTS page_views (
|
||||
url TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
user_agent TEXT,
|
||||
viewed_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ── Non-integer (TEXT) primary key ─────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ── Deliberately empty: exercises the Empty Table change + empty state ──
|
||||
CREATE TABLE IF NOT EXISTS marketing_campaigns (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
budget REAL,
|
||||
starts_at TEXT,
|
||||
ends_at TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'draft'
|
||||
CHECK (status IN ('draft','active','paused','completed','cancelled'))
|
||||
);
|
||||
|
||||
-- ── Read-only view for the query editor / ER diagram ───────────────────
|
||||
CREATE VIEW IF NOT EXISTS order_summary AS
|
||||
SELECT
|
||||
o.id AS order_id,
|
||||
u.name AS customer_name,
|
||||
COUNT(oi.product_id) AS item_count,
|
||||
o.total AS order_total,
|
||||
o.status,
|
||||
o.created_at
|
||||
FROM orders o
|
||||
JOIN users u ON u.id = o.user_id
|
||||
LEFT JOIN order_items oi ON oi.order_id = o.id
|
||||
GROUP BY o.id, u.name, o.total, o.status, o.created_at;
|
||||
|
||||
-- ── Indexes (surface in the copied DDL) ─────────────────────────────────
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_user_id ON orders(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_order_items_product_id ON order_items(product_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_page_views_viewed_at ON page_views(viewed_at);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- Seed data
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- ── Categories ──────────────────────────────────────────────────────────
|
||||
INSERT OR IGNORE INTO categories (id, name, parent_id, slug, sort_order, description, created_at) VALUES
|
||||
(1, 'Electronics', NULL, 'electronics', 1, 'Computers, phones, and peripherals.', datetime('now','-400 days')),
|
||||
(2, 'Accessories', NULL, 'accessories', 2, 'Cables, cases, hubs, and add-ons.', datetime('now','-400 days')),
|
||||
(3, 'Office', NULL, 'office', 3, 'Furniture, lighting, and desk essentials.', datetime('now','-400 days')),
|
||||
(4, 'Furniture', NULL, 'furniture', 4, 'Chairs, desks, shelves, and storage.', datetime('now','-380 days')),
|
||||
(5, 'Keyboards', 1, 'keyboards', 1, 'Mechanical and membrane keyboards.', datetime('now','-300 days')),
|
||||
(6, 'Monitors', 1, 'monitors', 2, 'External displays and monitor arms.', datetime('now','-300 days'));
|
||||
|
||||
-- ── Users: a realistic mix of admins, moderators, customers, and guests ───
|
||||
INSERT OR IGNORE INTO users (id, name, email, role, phone, birth_date, bio, preferences, balance, is_active, last_login_at, created_at, updated_at) VALUES
|
||||
(1, 'Sarah Chen', 'sarah.chen@acme.dev', 'admin', '+1-415-555-0101', '1990-04-12',
|
||||
'Founding engineer at Acme Dev. Manages the catalog, reviews flagged orders, and keeps the demo data looking sharp.',
|
||||
'{"theme":"dark","notifications":{"email":true,"push":true},"locale":"en-US","timezone":"America/Los_Angeles"}',
|
||||
1240.75, 1, datetime('now','-35 minutes'), datetime('now','-540 days'), datetime('now','-35 minutes')),
|
||||
(2, 'Marcus Johnson', 'marcus.j@pixelforge.studio', 'moderator', '+1-512-555-0192', '1985-11-30',
|
||||
'Customer-success lead and part-time photographer. Moderates reviews, handles refunds, and tracks the weekly audit log.',
|
||||
'{"theme":"system","notifications":{"email":true,"push":false},"locale":"en-GB","timezone":"America/Chicago"}',
|
||||
48.20, 1, datetime('now','-6 hours'), datetime('now','-510 days'), datetime('now','-6 hours')),
|
||||
(3, 'Emily Rodriguez', 'emily.r@techbloom.io', 'customer', '+1-206-555-0143', '1993-07-19',
|
||||
'Remote frontend developer based in Seattle. Buys desk accessories in bulk and leaves detailed product reviews.',
|
||||
'{"theme":"light","notifications":{"email":true,"push":true},"locale":"en-US","timezone":"America/Los_Angeles"}',
|
||||
315.50, 1, datetime('now','-2 days'), datetime('now','-480 days'), datetime('now','-2 days')),
|
||||
(4, 'David Kim', 'david.kim@outlook.com', 'customer', '+1-212-555-0178', '1988-03-04',
|
||||
'Freelance sound engineer in New York. Orders AV gear and cables regularly for his home studio.',
|
||||
'{"theme":"dark","notifications":{"email":false,"push":true},"locale":"ko-KR","timezone":"America/New_York"}',
|
||||
0.00, 1, datetime('now','-4 days'), datetime('now','-450 days'), datetime('now','-4 days')),
|
||||
(5, 'Aisha Patel', 'aisha.patel@horizonlabs.co', 'customer', '+1-303-555-0165', '1996-02-08',
|
||||
'Data analyst and ergonomic-chair evangelist. Currently outfitting a standing-desk setup for her team.',
|
||||
'{"theme":"dark","notifications":{"email":true,"push":false},"locale":"en-US","timezone":"America/Denver"}',
|
||||
892.10, 1, datetime('now','-12 hours'), datetime('now','-420 days'), datetime('now','-12 hours')),
|
||||
(6, 'James O’Brien', 'james.obrien@fastmail.com', 'customer', '+1-617-555-0134', '1991-09-23',
|
||||
'DevOps contractor. Bought a monitor arm and never looked back. Always asks for gift receipts.',
|
||||
NULL,
|
||||
67.99, 1, datetime('now','-1 day'), datetime('now','-390 days'), datetime('now','-1 day')),
|
||||
(7, 'Yuki Tanaka', 'yuki.tanaka@me.com', 'customer', '+1-650-555-0189', '1994-05-17',
|
||||
'Product designer at a fintech startup. Switches between light and dark mode depending on the weather.',
|
||||
'{"theme":"system","notifications":{"email":true,"push":true},"locale":"ja-JP","timezone":"America/Los_Angeles"}',
|
||||
150.00, 0, datetime('now','-28 days'), datetime('now','-360 days'), datetime('now','-28 days')),
|
||||
(8, 'Olivia Müller', 'olivia.mueller@werkstatt.de', 'customer', '+49-30-555-0921', '1987-12-01',
|
||||
'Berlin-based architect. Orders office lamps and cable organizers for her co-working space.',
|
||||
'{"theme":"light","notifications":{"email":true,"push":false},"locale":"de-DE","timezone":"Europe/Berlin"}',
|
||||
540.30, 1, datetime('now','-5 days'), datetime('now','-330 days'), datetime('now','-5 days')),
|
||||
(9, 'Carlos Rivera', 'carlos.rivera@openmail.net', 'customer', '+1-305-555-0156', '1992-08-30',
|
||||
'Miami-based content creator. Replaced his entire streaming setup through the store last quarter.',
|
||||
'{"theme":"dark","notifications":{"email":false,"push":false},"locale":"es-MX","timezone":"America/New_York"}',
|
||||
28.50, 1, datetime('now','-3 hours'), datetime('now','-300 days'), datetime('now','-3 hours')),
|
||||
(10, 'Priya Sharma', 'priya.sharma@nimbus.team', 'customer', '+1-408-555-0127', '1995-01-14',
|
||||
'Engineering manager at Nimbus. Buys team gear in batches and expects invoices by email.',
|
||||
'{"theme":"dark","notifications":{"email":true,"push":true},"locale":"en-IN","timezone":"America/Los_Angeles"}',
|
||||
2100.00, 1, datetime('now','-50 minutes'), datetime('now','-270 days'), datetime('now','-50 minutes')),
|
||||
(11, 'Liam Thompson', 'liam.t@rivermail.com', 'guest', NULL, '1999-06-05',
|
||||
'University student window-shopping for a first mechanical keyboard. Has not completed a purchase yet.',
|
||||
'{"theme":"system","notifications":{"email":false,"push":false},"locale":"en-US","timezone":"America/New_York"}',
|
||||
0.00, 1, datetime('now','-7 days'), datetime('now','-240 days'), datetime('now','-7 days')),
|
||||
(12, 'Sofia Andersen', 'sofia.andersen@nordic.design', 'customer', '+46-8-555-0733', '1989-10-28',
|
||||
'Scandinavian design consultant. Values minimal packaging and fast, trackable shipping.',
|
||||
'{"theme":"light","notifications":{"email":true,"push":false},"locale":"sv-SE","timezone":"Europe/Stockholm"}',
|
||||
175.80, 1, datetime('now','-10 hours'), datetime('now','-210 days'), datetime('now','-10 hours')),
|
||||
(13, 'Benjamin Wright', 'ben.wright@compose.ly', 'customer', '+1-503-555-0198', '1993-04-11',
|
||||
'Music producer and software tinkerer. Always adds a USB-C cable to every order just in case.',
|
||||
'{"theme":"dark","notifications":{"email":true,"push":true},"locale":"en-US","timezone":"America/Los_Angeles"}',
|
||||
430.25, 1, datetime('now','-18 hours'), datetime('now','-180 days'), datetime('now','-18 hours')),
|
||||
(14, 'Hannah Lee', 'hannah.lee@cloudscale.io', 'admin', '+1-206-555-0204', '1990-07-22',
|
||||
'Platform reliability lead. Monitors the audit log and keeps the demo app settings up to date.',
|
||||
'{"theme":"dark","notifications":{"email":true,"push":true},"locale":"en-US","timezone":"America/Los_Angeles"}',
|
||||
3500.00, 1, datetime('now','-15 minutes'), datetime('now','-150 days'), datetime('now','-15 minutes')),
|
||||
(15, 'Noah Fischer', 'noah.fischer@bytehaus.at', 'customer', '+43-1-555-0817', '1997-03-15',
|
||||
'Vienna-based indie game dev. Needs low-latency peripherals and a quiet mechanical keyboard.',
|
||||
'{"theme":"system","notifications":{"email":false,"push":true},"locale":"de-AT","timezone":"Europe/Vienna"}',
|
||||
95.00, 1, datetime('now','-2 days'), datetime('now','-120 days'), datetime('now','-2 days')),
|
||||
(16, 'Grace Okafor', 'grace.okafor@lift.ng', 'customer', '+234-1-555-0294', '1986-11-09',
|
||||
'Remote team lead in Lagos. Orders standing desks and monitor arms for distributed teammates.',
|
||||
'{"theme":"dark","notifications":{"email":true,"push":false},"locale":"en-NG","timezone":"Africa/Lagos"}',
|
||||
1280.00, 1, datetime('now','-9 hours'), datetime('now','-90 days'), datetime('now','-9 hours')),
|
||||
(17, 'Ethan Brooks', 'ethan.brooks@nullsecurity.dev', 'customer', '+1-720-555-0112', '1994-12-30',
|
||||
'Security researcher with a dry sense of humor. Tests the demo with suspiciously long JSON blobs.',
|
||||
'{"theme":"dark","notifications":{"email":false,"push":false},"locale":"en-US","timezone":"America/Denver"}',
|
||||
12.49, 1, datetime('now','-5 days'), datetime('now','-60 days'), datetime('now','-5 days')),
|
||||
(18, 'Mia Rossi', 'mia.rossi@artigiano.it', 'customer', '+39-02-555-0366', '1991-05-03',
|
||||
'Graphic designer from Milan. Cares a lot about color-accurate monitors and clean desk lighting.',
|
||||
'{"theme":"light","notifications":{"email":true,"push":true},"locale":"it-IT","timezone":"Europe/Rome"}',
|
||||
725.60, 1, datetime('now','-1 day'), datetime('now','-30 days'), datetime('now','-1 day')),
|
||||
(19, 'Alexander Petrov', 'alex.petrov@polycode.ru', 'customer', '+7-495-555-0412', '1984-08-18',
|
||||
'Backend engineer in Moscow. Maintains a home lab and buys networking accessories in pairs.',
|
||||
'{"theme":"system","notifications":{"email":true,"push":false},"locale":"ru-RU","timezone":"Europe/Moscow"}',
|
||||
199.00, 0, datetime('now','-45 days'), datetime('now','-15 days'), datetime('now','-45 days')),
|
||||
(20, 'Zoe Williams', 'zoe.williams@greenleaf.org', 'moderator', '+1-510-555-0285', '1992-02-25',
|
||||
'Sustainability coordinator. Reviews product descriptions and pushes for paperless invoices.',
|
||||
'{"theme":"light","notifications":{"email":true,"push":false},"locale":"en-US","timezone":"America/Los_Angeles"}',
|
||||
88.00, 1, datetime('now','-6 hours'), datetime('now','-7 days'), datetime('now','-6 hours'));
|
||||
|
||||
-- ── Products: realistic SKUs, descriptions, and attributes ──────────────
|
||||
INSERT OR IGNORE INTO products (id, sku, name, description, price, category_id, stock, rating, discontinued, attributes, created_at, updated_at) VALUES
|
||||
(1, 'GL-MSE-001', 'Gridline Wireless Mouse', 'Ergonomic wireless mouse with silent switches, 2.4 GHz and Bluetooth 5.0, adjustable 800-1600 DPI, and a 12-month battery life.', 34.99, 1, 142, 4.6, 0, '{"color":"Graphite","wireless":true,"dpi_max":1600,"buttons":5}', datetime('now','-360 days'), datetime('now','-3 days')),
|
||||
(2, 'GL-KBD-001', 'Gridline Mechanical Keyboard', 'Hot-swappable TKL board with tactile brown switches, per-key RGB, PBT keycaps, and a USB-C braided cable.', 119.99, 5, 68, 4.8, 0, '{"layout":"US ANSI","switches":"brown","backlit":true,"connection":"wired"}', datetime('now','-350 days'), datetime('now','-5 days')),
|
||||
(3, 'GL-HUB-001', 'Gridline 7-in-1 USB-C Hub', 'Aluminium hub with 4K HDMI, 100 W power delivery, two USB-A 3.2 ports, SD/microSD slots, and a braided cable.', 44.99, 2, 215, 4.4, 0, '{"ports":7,"hdmi":"4K60","power_delivery":"100W","material":"aluminium"}', datetime('now','-340 days'), datetime('now','-2 days')),
|
||||
(4, 'GL-MON-001', 'Gridline 27" 4K USB-C Monitor', '27-inch IPS panel, 3840×2160, 60 Hz, 99% sRGB, USB-C upstream with 90 W charging, fully adjustable stand.', 499.99, 6, 28, 4.7, 0, '{"resolution":"3840x2160","refresh_hz":60,"panel":"IPS","usb_c_power":90}', datetime('now','-330 days'), datetime('now','-8 days')),
|
||||
(5, 'GL-STD-001', 'Gridline Aluminium Laptop Stand', 'Foldable aluminium stand with six height positions, ventilated design and silicone pads. Fits 12-16 inch laptops.', 54.99, 2, 108, 4.2, 0, '{"color":"Silver","max_height_mm":280,"foldable":true}', datetime('now','-320 days'), datetime('now','-10 days')),
|
||||
(6, 'GL-CAM-001', 'Gridline 1080p Webcam', 'Full HD webcam with privacy shutter, dual noise-reducing mics, autofocus, and plug-and-play compatibility.', 69.99, 1, 0, 4.1, 1, '{"resolution":"1920x1080","fps":30,"microphone":true,"autofocus":true}', datetime('now','-310 days'), datetime('now','-60 days')),
|
||||
(7, 'GL-LMP-001', 'Gridline LED Desk Lamp', 'Dimmable LED lamp with 2700-6500 K colour temperature, flexible neck, and a built-in USB-A charging port.', 42.99, 3, 134, 4.5, 0, '{"color_temp_k":"2700-6500","dimmable":true,"usb_port":true}', datetime('now','-300 days'), datetime('now','-6 days')),
|
||||
(8, 'GL-CHR-001', 'Gridline Ergonomic Mesh Chair', 'Breathable mesh back, adjustable lumbar support, 4D armrests, and a gas lift rated up to 150 kg.', 649.99, 4, 12, 4.7, 0, '{"material":"mesh","lumbar_support":true,"max_weight_kg":150}', datetime('now','-290 days'), datetime('now','-15 days')),
|
||||
(9, 'GL-CBL-001', 'Gridline Braided USB-C Cable 2 m', 'Braided USB-C to USB-C cable rated for 100 W charging and USB 3.2 data. Tested to 10,000 bends.', 16.99, 2, 480, 4.3, 0, '{"length_m":2,"charging_w":100,"data_speed":"USB 3.2","color":"Midnight"}', datetime('now','-280 days'), datetime('now','-4 days')),
|
||||
(10, 'GL-ARM-001', 'Gridline Single Monitor Arm', 'Gas-spring monitor arm with 75/100 mm VESA, 360° rotation, and cable management. Supports up to 9 kg.', 89.99, 6, 36, 4.5, 0, '{"weight_capacity_kg":9,"vesa":"75/100","rotation":"360"}', datetime('now','-270 days'), datetime('now','-9 days')),
|
||||
(11, 'GL-MSE-002', 'Gridline Pro Wireless Mouse', 'Premium wireless mouse with 4000 DPI sensor, USB-C rechargeable battery, and magnetic storage case.', 79.99, 1, 64, 4.7, 0, '{"color":"Pearl","wireless":true,"dpi_max":4000,"rechargeable":true}', datetime('now','-260 days'), datetime('now','-7 days')),
|
||||
(12, 'GL-KBD-002', 'Gridline Compact Keyboard', '65% low-profile mechanical keyboard with red linear switches, white backlight, and Bluetooth/wired dual mode.', 94.99, 5, 91, 4.4, 0, '{"layout":"65%","switches":"red linear","backlit":true,"wireless":true}', datetime('now','-250 days'), datetime('now','-6 days')),
|
||||
(13, 'GL-HUB-002', 'Gridline 10-in-1 Docking Station', 'Thunderbolt-compatible dock with dual 4K display support, 2.5 GbE, and 140 W power delivery.', 219.99, 2, 41, 4.6, 0, '{"ports":10,"ethernet":"2.5G","power_delivery":"140W","displays":2}', datetime('now','-240 days'), datetime('now','-3 days')),
|
||||
(14, 'GL-MON-002', 'Gridline 32" Curved Monitor', '32-inch curved VA panel, 2560×1440, 165 Hz refresh rate, HDR400, and adaptive sync.', 429.99, 6, 19, 4.5, 0, '{"resolution":"2560x1440","refresh_hz":165,"panel":"VA","curved":true}', datetime('now','-230 days'), datetime('now','-11 days')),
|
||||
(15, 'GL-DES-001', 'Gridline Standing Desk Frame', 'Electric sit-stand desk frame with dual motors, memory presets, and a quiet 45 dB lift mechanism.', 549.99, 4, 8, 4.6, 0, '{"width_adjustable":true,"memory_presets":4,"noise_db":45}', datetime('now','-220 days'), datetime('now','-12 days')),
|
||||
(16, 'GL-KEY-001', 'Gridline Keycap Set', 'PBT dye-sub keycap set with 140 keys, compatible with most ANSI and ISO layouts.', 39.99, 5, 77, 4.3, 0, '{"keys":140,"material":"PBT","profile":"Cherry"}', datetime('now','-210 days'), datetime('now','-5 days')),
|
||||
(17, 'GL-PAD-001', 'Gridline Desk Mat', 'Oversized felt desk mat with stitched edges and anti-slip base. 900 × 400 mm.', 29.99, 3, 156, 4.4, 0, '{"material":"felt","size_mm":"900x400","anti_slip":true}', datetime('now','-200 days'), datetime('now','-8 days')),
|
||||
(18, 'GL-MIC-001', 'Gridline USB Microphone', 'Cardioid condenser mic with built-in pop filter, mute button, and 24-bit/96 kHz sampling.', 119.99, 1, 33, 4.5, 0, '{"pattern":"cardioid","sample_rate":"96kHz","bit_depth":24}', datetime('now','-190 days'), datetime('now','-9 days')),
|
||||
(19, 'GL-LGT-001', 'Gridline Monitor Light Bar', 'Screen-hanging light bar with auto-dimming, warm/cool temperature control, and no glare on the panel.', 59.99, 3, 88, 4.2, 0, '{"mount":"screen_hanging","auto_dim":true,"color_temp_k":"3000-6500"}', datetime('now','-180 days'), datetime('now','-7 days')),
|
||||
(20, 'GL-CBL-002', 'Gridline Magnetic Cable Trio', 'Set of three braided magnetic cables (USB-C, Lightning, Micro-USB) with one interchangeable tip dock.', 34.99, 2, 203, 4.0, 0, '{"cables":3,"tips":["USB-C","Lightning","Micro-USB"],"magnetic":true}', datetime('now','-170 days'), datetime('now','-6 days')),
|
||||
(21, 'GL-BAG-001', 'Gridline Tech Pouch', 'Water-resistant tech pouch with elastic loops, mesh pockets, and a cable pass-through.', 44.99, 2, 119, 4.5, 0, '{"water_resistant":true,"compartments":8,"color":"Charcoal"}', datetime('now','-160 days'), datetime('now','-4 days')),
|
||||
(22, 'GL-SPK-001', 'Gridline Desktop Speakers', 'Pair of powered bookshelf speakers with Bluetooth input, RCA/AUX, and solid wood enclosures.', 149.99, 1, 22, 4.3, 0, '{"pair":true,"inputs":["Bluetooth","RCA","AUX"],"enclosure":"wood"}', datetime('now','-150 days'), datetime('now','-10 days')),
|
||||
(23, 'GL-TRP-001', 'Gridline Tripod Desk Lamp', 'Minimal tripod desk lamp with touch dimming, USB-C power, and a 360° rotating head.', 49.99, 3, 62, 4.1, 0, '{"base":"tripod","touch_dim":true,"power":"USB-C"}', datetime('now','-140 days'), datetime('now','-8 days')),
|
||||
(24, 'GL-HDM-001', 'Gridline HDMI 2.1 Cable 1.5 m', 'Certified Ultra High Speed HDMI 2.1 cable supporting 8K@60 Hz and 4K@120 Hz, 48 Gbps.', 24.99, 2, 312, 4.4, 0, '{"length_m":1.5,"hdmi_version":"2.1","bandwidth_gbps":48}', datetime('now','-130 days'), datetime('now','-3 days'));
|
||||
|
||||
-- ── Addresses: 1-3 realistic addresses per user ────────────────────────
|
||||
INSERT OR IGNORE INTO addresses (id, user_id, label, street, city, state, zip, country, is_primary, created_at) VALUES
|
||||
(1, 1, 'Home', '1234 Mission Street, Apt 42', 'San Francisco', 'CA', '94103', 'USA', 1, datetime('now','-500 days')),
|
||||
(2, 1, 'Work', '555 Montgomery Street, Floor 8', 'San Francisco', 'CA', '94111', 'USA', 0, datetime('now','-480 days')),
|
||||
(3, 2, 'Home', '2100 Barton Creek Blvd', 'Austin', 'TX', '78735', 'USA', 1, datetime('now','-490 days')),
|
||||
(4, 3, 'Home', '4542 11th Avenue NE', 'Seattle', 'WA', '98105', 'USA', 1, datetime('now','-470 days')),
|
||||
(5, 3, 'Office', '7200 Woodlawn Ave NE', 'Seattle', 'WA', '98115', 'USA', 0, datetime('now','-460 days')),
|
||||
(6, 4, 'Home', '850 7th Avenue, Apt 12B', 'New York', 'NY', '10019', 'USA', 1, datetime('now','-440 days')),
|
||||
(7, 5, 'Home', '1600 Glenarm Place', 'Denver', 'CO', '80202', 'USA', 1, datetime('now','-410 days')),
|
||||
(8, 5, 'Warehouse', '4650 Paris Street', 'Denver', 'CO', '80239', 'USA', 0, datetime('now','-400 days')),
|
||||
(9, 6, 'Home', '44 Prince Street', 'Boston', 'MA', '02113', 'USA', 1, datetime('now','-380 days')),
|
||||
(10, 7, 'Home', '1080 Arastradero Road', 'Palo Alto', 'CA', '94304', 'USA', 1, datetime('now','-350 days')),
|
||||
(11, 8, 'Office', 'Friedrichstraße 123', 'Berlin', 'Berlin', '10117', 'Germany', 1, datetime('now','-320 days')),
|
||||
(12, 9, 'Home', '1500 Ocean Drive, Apt 805', 'Miami Beach', 'FL', '33139', 'USA', 1, datetime('now','-290 days')),
|
||||
(13, 10, 'Work', '555 Ellis Street', 'Mountain View', 'CA', '94043', 'USA', 1, datetime('now','-260 days')),
|
||||
(14, 11, 'Home', '1925 SE Hawthorne Blvd', 'Portland', 'OR', '97214', 'USA', 1, datetime('now','-230 days')),
|
||||
(15, 12, 'Office', 'Birger Jarlsgatan 58', 'Stockholm', 'Stockholm', '111 45', 'Sweden', 1, datetime('now','-200 days')),
|
||||
(16, 13, 'Home', '925 W 5th Avenue', 'Chicago', 'IL', '60642', 'USA', 1, datetime('now','-170 days')),
|
||||
(17, 14, 'Home', '2211 7th Avenue', 'Seattle', 'WA', '98121', 'USA', 1, datetime('now','-140 days')),
|
||||
(18, 15, 'Home', 'Opernring 5', 'Vienna', 'Vienna', '1010', 'Austria', 1, datetime('now','-110 days')),
|
||||
(19, 16, 'Home', '12 Ikoyi Crescent', 'Lagos', 'Lagos', '101233', 'Nigeria', 1, datetime('now','-80 days')),
|
||||
(20, 17, 'Home', '1600 Walnut Street, Unit 300', 'Denver', 'CO', '80202', 'USA', 1, datetime('now','-50 days')),
|
||||
(21, 18, 'Home', 'Via Tortona 12', 'Milan', 'MI', '20144', 'Italy', 1, datetime('now','-25 days')),
|
||||
(22, 19, 'Home', 'Ulitsa Bolshaya Dmitrovka 9', 'Moscow', 'Moscow', '125009', 'Russia', 1, datetime('now','-10 days')),
|
||||
(23, 20, 'Home', '1900 Broadway', 'Oakland', 'CA', '94612', 'USA', 1, datetime('now','-5 days'));
|
||||
|
||||
-- ── Files: realistic file attachments with BLOB headers ─────────────────
|
||||
INSERT OR IGNORE INTO files (id, name, mime_type, content, size_bytes, uploaded_by, uploaded_at) VALUES
|
||||
(1, 'gridline_logo.png', 'image/png', X'89504E470D0A1A0A0000000D49484452000001000000010008060000005C72A866000000017352474200AECE1CE90000000467414D410000B18F0BFC61050000', 14256, 1, datetime('now','-45 days')),
|
||||
(2, 'product_photos.zip', 'application/zip', X'504B03040A00000000008B6B2D570000000000000000000000000800000070726F64756374732F504B0102001F000A00000000008B6B2D57000000000000000000000000080000000000000000000000A4810000000070726F64756374732F504B050600000000010001003A0000001A0000000000', 28934, 2, datetime('now','-42 days')),
|
||||
(3, 'invoice_2026_001.pdf', 'application/pdf', X'255044462D312E340A25E2E3CFD30A342030206F626A0A3C3C202F4C696E656172697A65642031202F4C2031203E3E0A3E3E0A73747265616D0A0A42510A0A656E6473747265616D0A656E646F626A0A', 18432, 10, datetime('now','-38 days')),
|
||||
(4, 'shipping_labels.pdf', 'application/pdf', X'255044462D312E340A25E2E3CFD30A342030206F626A0A3C3C202F54797065202F436174616C6F67202F50616765732031302020302020520A2F4F75746C696E65732032302020302020520A3E3E0A656E646F626A0A', 12288, 14, datetime('now','-30 days')),
|
||||
(5, 'user_avatars.jpg', 'image/jpeg', X'FFD8FFE000104A46494600010100000100010000FFDB004300080606070605080707070909080A0C140D0C0B0B0C1912130F14311A1F1F1A1C232D', 8934, 1, datetime('now','-25 days')),
|
||||
(6, 'q4_inventory.csv', 'text/csv', X'69642C6E616D652C73746F636B2C72657365727665640A312C47726E646C696E6520576972656C657373204D6F7573652C3134322C300A322C47726E646C696E65204D656368616E6963616C204B6579626F6172642C36382C350A', 5632, 14, datetime('now','-18 days')),
|
||||
(7, 'backup.sql', 'application/sql', X'2D2D20477269646C696E652064656D6F206261636B75700A50524F4752416D757365725F76657273696F6E203D20333B0A435245415445205441424C45204946204E4F542045584953545320757365727320282E2E2E293B', 4096, 14, datetime('now','-7 days')),
|
||||
(8, 'favicon.ico', 'image/x-icon', X'00000100010010100000000020006804000016000000280000001000000020000000010008', 4286, 1, datetime('now','-3 days'));
|
||||
|
||||
-- ── App settings: TEXT primary key ──────────────────────────────────────
|
||||
INSERT OR IGNORE INTO app_settings (key, value, updated_at) VALUES
|
||||
('site_name', 'Gridline Demo Store', datetime('now','-60 days')),
|
||||
('maintenance_mode', 'false', datetime('now','-5 days')),
|
||||
('max_cart_items', '50', datetime('now','-30 days')),
|
||||
('currency', 'USD', datetime('now','-60 days')),
|
||||
('default_shipping_country', 'USA', datetime('now','-20 days')),
|
||||
('support_email', 'support@gridline.dev', datetime('now','-10 days'));
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- Generated data: orders, line items, page views, audit log
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- 50 realistic orders across the user base with varied statuses and dates.
|
||||
WITH RECURSIVE order_seq(n) AS (
|
||||
SELECT 1 UNION ALL SELECT n + 1 FROM order_seq WHERE n < 50
|
||||
)
|
||||
INSERT OR IGNORE INTO orders (id, user_id, shipping_address_id, total, status, notes, created_at, updated_at, shipped_at)
|
||||
SELECT
|
||||
n,
|
||||
-- Distribute orders across users 1-18; users 19 (inactive) and 20 (recent) get fewer.
|
||||
CASE
|
||||
WHEN n <= 35 THEN ((n - 1) % 18) + 1
|
||||
WHEN n <= 45 THEN ((n - 1) % 12) + 1
|
||||
ELSE ((n - 1) % 8) + 1
|
||||
END,
|
||||
-- Pick a primary or secondary address for that user if one exists.
|
||||
NULL,
|
||||
0, -- total recalculated from line items below
|
||||
CASE n % 12
|
||||
WHEN 0 THEN 'pending'
|
||||
WHEN 1 THEN 'processing'
|
||||
WHEN 2 THEN 'shipped'
|
||||
WHEN 3 THEN 'delivered'
|
||||
WHEN 4 THEN 'completed'
|
||||
WHEN 5 THEN 'cancelled'
|
||||
WHEN 6 THEN 'refunded'
|
||||
WHEN 7 THEN 'shipped'
|
||||
WHEN 8 THEN 'processing'
|
||||
WHEN 9 THEN 'completed'
|
||||
WHEN 10 THEN 'pending'
|
||||
ELSE 'delivered'
|
||||
END,
|
||||
CASE n % 8
|
||||
WHEN 0 THEN 'Please leave the package at the front desk.'
|
||||
WHEN 1 THEN 'Gift wrap, please.'
|
||||
WHEN 2 THEN 'Customer requested eco-friendly packaging.'
|
||||
WHEN 3 THEN 'Ship after the 15th — office move in progress.'
|
||||
WHEN 4 THEN NULL
|
||||
WHEN 5 THEN 'Call before delivery.'
|
||||
WHEN 6 THEN 'Authority to leave if not home.'
|
||||
ELSE NULL
|
||||
END,
|
||||
datetime('now', printf('-%d days', 60 - (n % 58))),
|
||||
datetime('now', printf('-%d days', 58 - (n % 56))),
|
||||
CASE WHEN n % 12 IN (2,3,4,7,11) THEN datetime('now', printf('-%d days', 55 - (n % 53))) ELSE NULL END
|
||||
FROM order_seq;
|
||||
|
||||
-- Assign a realistic shipping address to each order from the user's address set.
|
||||
UPDATE orders SET shipping_address_id = (
|
||||
SELECT a.id FROM addresses a
|
||||
WHERE a.user_id = orders.user_id
|
||||
ORDER BY a.is_primary DESC, a.id
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
-- 100+ order line items: 1-3 products per order.
|
||||
WITH RECURSIVE line_seq(n) AS (
|
||||
SELECT 1 UNION ALL SELECT n + 1 FROM line_seq WHERE n < 105
|
||||
)
|
||||
INSERT OR IGNORE INTO order_items (order_id, product_id, quantity, unit_price)
|
||||
SELECT
|
||||
((n - 1) % 50) + 1,
|
||||
((n - 1) % 24) + 1,
|
||||
CASE n % 5 WHEN 0 THEN 3 WHEN 1 THEN 2 ELSE 1 END,
|
||||
(SELECT price FROM products WHERE id = ((n - 1) % 24) + 1)
|
||||
FROM line_seq;
|
||||
|
||||
-- Recalculate order totals from line items.
|
||||
UPDATE orders SET total = (
|
||||
SELECT ROUND(SUM(quantity * unit_price), 2)
|
||||
FROM order_items
|
||||
WHERE order_items.order_id = orders.id
|
||||
);
|
||||
|
||||
-- 100 page views with realistic user agents and URLs.
|
||||
WITH RECURSIVE pv_seq(n) AS (
|
||||
SELECT 1 UNION ALL SELECT n + 1 FROM pv_seq WHERE n < 100
|
||||
)
|
||||
INSERT INTO page_views (url, session_id, user_agent, viewed_at)
|
||||
SELECT
|
||||
CASE n % 10
|
||||
WHEN 0 THEN '/products'
|
||||
WHEN 1 THEN '/products/' || ((n % 24) + 1)
|
||||
WHEN 2 THEN '/cart'
|
||||
WHEN 3 THEN '/checkout'
|
||||
WHEN 4 THEN '/orders'
|
||||
WHEN 5 THEN '/settings'
|
||||
WHEN 6 THEN '/categories/electronics'
|
||||
WHEN 7 THEN '/categories/office'
|
||||
WHEN 8 THEN '/search?q=keyboard'
|
||||
ELSE '/'
|
||||
END,
|
||||
'sess-' || printf('%03d', (n % 30) + 1),
|
||||
CASE n % 6
|
||||
WHEN 0 THEN 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36'
|
||||
WHEN 1 THEN 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36'
|
||||
WHEN 2 THEN 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1'
|
||||
WHEN 3 THEN 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36'
|
||||
WHEN 4 THEN 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:128.0) Gecko/20100101 Firefox/128.0'
|
||||
ELSE 'Mozilla/5.0 (iPad; CPU OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1'
|
||||
END,
|
||||
datetime('now', printf('-%d minutes', n * 13))
|
||||
FROM pv_seq
|
||||
WHERE NOT EXISTS (SELECT 1 FROM page_views);
|
||||
|
||||
-- 500 audit-log rows for pagination / virtualization / filtering demos.
|
||||
WITH RECURSIVE audit_seq(n) AS (
|
||||
SELECT 1 UNION ALL SELECT n + 1 FROM audit_seq WHERE n < 500
|
||||
)
|
||||
INSERT INTO audit_log (user_id, action, entity_type, entity_id, severity, details, duration_ms, created_at)
|
||||
SELECT
|
||||
CASE WHEN n % 9 = 0 THEN NULL ELSE ((n - 1) % 18) + 1 END,
|
||||
CASE n % 8
|
||||
WHEN 0 THEN 'login'
|
||||
WHEN 1 THEN 'page_view'
|
||||
WHEN 2 THEN 'update'
|
||||
WHEN 3 THEN 'create'
|
||||
WHEN 4 THEN 'delete'
|
||||
WHEN 5 THEN 'export'
|
||||
WHEN 6 THEN 'refund'
|
||||
ELSE 'ship'
|
||||
END,
|
||||
CASE n % 5
|
||||
WHEN 0 THEN 'order'
|
||||
WHEN 1 THEN 'product'
|
||||
WHEN 2 THEN 'user'
|
||||
WHEN 3 THEN 'address'
|
||||
ELSE 'report'
|
||||
END,
|
||||
(n % 50) + 1,
|
||||
CASE n % 6
|
||||
WHEN 0 THEN 'info'
|
||||
WHEN 1 THEN 'info'
|
||||
WHEN 2 THEN 'warning'
|
||||
WHEN 3 THEN 'error'
|
||||
WHEN 4 THEN 'critical'
|
||||
ELSE 'warning'
|
||||
END,
|
||||
CASE WHEN n % 7 = 0 THEN NULL
|
||||
ELSE '{"page":"' ||
|
||||
CASE n % 4 WHEN 0 THEN '/' WHEN 1 THEN '/products' WHEN 2 THEN '/checkout' ELSE '/orders' END ||
|
||||
'","retries":' || (n % 3) || ',"row":' || n || ',"region":"' ||
|
||||
CASE n % 5 WHEN 0 THEN 'us-east' WHEN 1 THEN 'us-west' WHEN 2 THEN 'eu-central' WHEN 3 THEN 'ap-south' ELSE 'sa-east' END ||
|
||||
'"}'
|
||||
END,
|
||||
(n * 37) % 2500,
|
||||
datetime('now', printf('-%d minutes', n * 7))
|
||||
FROM audit_seq
|
||||
WHERE NOT EXISTS (SELECT 1 FROM audit_log);
|
||||
@@ -0,0 +1,140 @@
|
||||
use crate::models::{Folder, FolderInput};
|
||||
use crate::store::Store;
|
||||
use std::sync::Mutex;
|
||||
|
||||
fn validate(input: &FolderInput) -> Result<(), String> {
|
||||
if input.name.is_empty() || input.name.chars().count() > 100 {
|
||||
return Err("name is required and must be 100 chars or fewer".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_folders_inner(state: &Mutex<Store>) -> Result<Vec<Folder>, String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.get_folders()
|
||||
}
|
||||
|
||||
pub fn create_folder_inner(state: &Mutex<Store>, input: FolderInput) -> Result<Folder, String> {
|
||||
validate(&input)?;
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.create_folder(input)
|
||||
}
|
||||
|
||||
pub fn delete_folder_inner(state: &Mutex<Store>, id: &str) -> Result<(), String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.delete_folder(id)
|
||||
}
|
||||
|
||||
pub fn add_folder_tags_inner(
|
||||
state: &Mutex<Store>,
|
||||
folder_id: String,
|
||||
tag_ids: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.add_folder_tags(&folder_id, &tag_ids)
|
||||
}
|
||||
|
||||
pub fn update_folder_inner(
|
||||
state: &Mutex<Store>,
|
||||
id: String,
|
||||
input: FolderInput,
|
||||
) -> Result<Folder, String> {
|
||||
validate(&input)?;
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.update_folder(&id, input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_folders(state: tauri::State<crate::AppState>) -> Result<Vec<Folder>, String> {
|
||||
get_folders_inner(&state.db_store)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_folder(
|
||||
state: tauri::State<crate::AppState>,
|
||||
input: FolderInput,
|
||||
) -> Result<Folder, String> {
|
||||
create_folder_inner(&state.db_store, input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_folder(state: tauri::State<crate::AppState>, id: String) -> Result<(), String> {
|
||||
delete_folder_inner(&state.db_store, &id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn add_folder_tags(
|
||||
state: tauri::State<crate::AppState>,
|
||||
folder_id: String,
|
||||
tag_ids: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
add_folder_tags_inner(&state.db_store, folder_id, tag_ids)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_folder(
|
||||
state: tauri::State<crate::AppState>,
|
||||
id: String,
|
||||
input: FolderInput,
|
||||
) -> Result<Folder, String> {
|
||||
update_folder_inner(&state.db_store, id, input)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::FolderInput;
|
||||
use crate::store::Store;
|
||||
|
||||
fn state() -> std::sync::Mutex<Store> {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
crate::store::migrations::run_migrations(&conn).unwrap();
|
||||
std::sync::Mutex::new(Store::from_connection(conn))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_folder_command_works() {
|
||||
let st = state();
|
||||
let folder = create_folder_inner(
|
||||
&st,
|
||||
FolderInput {
|
||||
tag_ids: None,
|
||||
name: "Work".into(),
|
||||
parent_id: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(get_folders_inner(&st).unwrap().len(), 1);
|
||||
assert_eq!(folder.name, "Work");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_folder_rejects_empty_name() {
|
||||
let st = state();
|
||||
let result = create_folder_inner(
|
||||
&st,
|
||||
FolderInput {
|
||||
tag_ids: None,
|
||||
name: "".into(),
|
||||
parent_id: None,
|
||||
},
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_folder_command_works() {
|
||||
let st = state();
|
||||
let folder = create_folder_inner(
|
||||
&st,
|
||||
FolderInput {
|
||||
tag_ids: None,
|
||||
name: "Work".into(),
|
||||
parent_id: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
delete_folder_inner(&st, &folder.id).unwrap();
|
||||
assert_eq!(get_folders_inner(&st).unwrap().len(), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
use crate::models::ConnectionInput;
|
||||
use crate::store::Store;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Mutex;
|
||||
|
||||
const VALID_DB_TYPES: [&str; 4] = ["postgresql", "mysql", "sqlite", "redis"];
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ImportRecord {
|
||||
name: Option<String>,
|
||||
db_type: String,
|
||||
host: String,
|
||||
port: Option<i64>,
|
||||
username: Option<String>,
|
||||
folder_id: Option<String>,
|
||||
tag_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SkippedRecord {
|
||||
pub index: usize,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImportResult {
|
||||
pub imported: usize,
|
||||
pub skipped: usize,
|
||||
pub skipped_records: Vec<SkippedRecord>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn parse_import(json: &str) -> Result<Vec<ImportRecord>, String> {
|
||||
let records: Vec<ImportRecord> =
|
||||
serde_json::from_str(json).map_err(|e| format!("invalid JSON: {}", e))?;
|
||||
for (i, rec) in records.iter().enumerate() {
|
||||
if rec.name.as_deref().unwrap_or("").is_empty() {
|
||||
return Err(format!("record {}: name is required", i));
|
||||
}
|
||||
if !VALID_DB_TYPES.contains(&rec.db_type.as_str()) {
|
||||
return Err(format!("record {}: invalid db_type: {}", i, rec.db_type));
|
||||
}
|
||||
}
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
pub fn import_connections_inner(
|
||||
state: &Mutex<Store>,
|
||||
json: String,
|
||||
) -> Result<ImportResult, String> {
|
||||
let records: Vec<ImportRecord> =
|
||||
serde_json::from_str(&json).map_err(|e| format!("invalid JSON: {}", e))?;
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
let mut imported = 0usize;
|
||||
let mut skipped_records = Vec::new();
|
||||
for (i, rec) in records.iter().enumerate() {
|
||||
let name = match &rec.name {
|
||||
Some(n) if !n.is_empty() => n.clone(),
|
||||
_ => {
|
||||
skipped_records.push(SkippedRecord {
|
||||
index: i,
|
||||
reason: "missing or empty name".into(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !VALID_DB_TYPES.contains(&rec.db_type.as_str()) {
|
||||
skipped_records.push(SkippedRecord {
|
||||
index: i,
|
||||
reason: format!("invalid db_type: {}", rec.db_type),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if rec.host.is_empty() {
|
||||
skipped_records.push(SkippedRecord {
|
||||
index: i,
|
||||
reason: "missing or empty host".into(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let input = ConnectionInput {
|
||||
name,
|
||||
db_type: rec.db_type.clone(),
|
||||
host: rec.host.clone(),
|
||||
port: rec.port,
|
||||
username: rec.username.clone(),
|
||||
folder_id: rec.folder_id.clone(),
|
||||
password: None,
|
||||
database: None,
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_password: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: rec.tag_ids.clone().unwrap_or_default(),
|
||||
use_keychain: true,
|
||||
};
|
||||
match store.create_connection(input) {
|
||||
Ok(_) => imported += 1,
|
||||
Err(e) => skipped_records.push(SkippedRecord {
|
||||
index: i,
|
||||
reason: e,
|
||||
}),
|
||||
}
|
||||
}
|
||||
Ok(ImportResult {
|
||||
imported,
|
||||
skipped: skipped_records.len(),
|
||||
skipped_records,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn export_connections_inner(state: &Mutex<Store>) -> Result<String, String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
let conns = store.get_connections()?;
|
||||
let export = serde_json::json!({ "version": 1, "connections": conns });
|
||||
serde_json::to_string_pretty(&export).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn import_connections(
|
||||
state: tauri::State<crate::AppState>,
|
||||
json: String,
|
||||
) -> Result<ImportResult, String> {
|
||||
import_connections_inner(&state.db_store, json)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn export_connections(state: tauri::State<crate::AppState>) -> Result<String, String> {
|
||||
export_connections_inner(&state.db_store)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::ConnectionInput;
|
||||
use crate::store::Store;
|
||||
|
||||
fn state() -> std::sync::Mutex<Store> {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
crate::store::migrations::run_migrations(&conn).unwrap();
|
||||
std::sync::Mutex::new(Store::from_connection(conn))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_import_validates_required_fields() {
|
||||
let json = r#"[{ "name": "X", "db_type": "postgresql", "host": "h", "port": 5432 }]"#;
|
||||
let parsed = parse_import(json).unwrap();
|
||||
assert_eq!(parsed.len(), 1);
|
||||
assert_eq!(parsed[0].name.as_deref(), Some("X"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_import_rejects_missing_name() {
|
||||
let json = r#"[{ "db_type": "postgresql", "host": "h", "port": 5432 }]"#;
|
||||
assert!(parse_import(json).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_import_rejects_invalid_db_type() {
|
||||
let json = r#"[{ "name": "X", "db_type": "mongodb", "host": "h", "port": 5432 }]"#;
|
||||
assert!(parse_import(json).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_connections_inserts_all() {
|
||||
let st = state();
|
||||
let json = r#"[{ "name": "A", "db_type": "postgresql", "host": "h", "port": 5432 }, { "name": "B", "db_type": "redis", "host": "r", "port": 6379 }]"#;
|
||||
let result = import_connections_inner(&st, json.to_string()).unwrap();
|
||||
assert_eq!(result.imported, 2);
|
||||
assert_eq!(result.skipped, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_connections_skips_invalid_keeps_valid() {
|
||||
let st = state();
|
||||
let json = r#"[{ "name": "A", "db_type": "postgresql", "host": "h", "port": 5432 }, { "db_type": "postgresql", "host": "h", "port": 5432 }, { "name": "B", "db_type": "redis", "host": "r", "port": 6379 }]"#;
|
||||
let result = import_connections_inner(&st, json.to_string()).unwrap();
|
||||
assert_eq!(result.imported, 2);
|
||||
assert_eq!(result.skipped, 1);
|
||||
assert_eq!(result.skipped_records[0].reason, "missing or empty name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_connections_returns_json() {
|
||||
let st = state();
|
||||
let _ = st.lock().unwrap().create_connection(ConnectionInput {
|
||||
name: "A".into(),
|
||||
db_type: "postgresql".into(),
|
||||
host: "h".into(),
|
||||
port: Some(5432),
|
||||
username: None,
|
||||
folder_id: None,
|
||||
password: None,
|
||||
database: None,
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_password: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
});
|
||||
let json = export_connections_inner(&st).unwrap();
|
||||
assert!(json.contains("\"name\""));
|
||||
assert!(json.contains("\"version\""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
use tauri_plugin_keyring_store::KeyringExt;
|
||||
|
||||
/// Store a connection password in the OS keychain.
|
||||
/// The connection ID is used as the keyring account name.
|
||||
#[tauri::command]
|
||||
pub fn save_connection_password(
|
||||
app: tauri::AppHandle,
|
||||
connection_id: String,
|
||||
password: String,
|
||||
) -> Result<(), String> {
|
||||
app.keyring()
|
||||
.store
|
||||
.set_password(&connection_id, &password)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Retrieve a connection password from the OS keychain.
|
||||
/// Returns None if no password was stored for this connection.
|
||||
#[tauri::command]
|
||||
pub fn get_connection_password(
|
||||
app: tauri::AppHandle,
|
||||
connection_id: String,
|
||||
) -> Result<Option<String>, String> {
|
||||
app.keyring()
|
||||
.store
|
||||
.get_password(&connection_id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Retrieve a connection password from the OS keychain (internal helper).
|
||||
/// Returns None if no password was stored for this connection.
|
||||
pub fn get_connection_password_internal(
|
||||
app: &tauri::AppHandle,
|
||||
connection_id: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
app.keyring()
|
||||
.store
|
||||
.get_password(connection_id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Build the keyring account name for an SSH secret (password or passphrase).
|
||||
/// The `connection_id` namespaces each secret per connection.
|
||||
pub fn ssh_account(kind: &str, connection_id: &str) -> String {
|
||||
format!("ssh_{kind}:{connection_id}")
|
||||
}
|
||||
|
||||
/// Store an SSH tunnel password in the OS keychain.
|
||||
#[tauri::command]
|
||||
pub fn save_connection_ssh_password(
|
||||
app: tauri::AppHandle,
|
||||
connection_id: String,
|
||||
password: String,
|
||||
) -> Result<(), String> {
|
||||
app.keyring()
|
||||
.store
|
||||
.set_password(&ssh_account("password", &connection_id), &password)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Retrieve an SSH tunnel password from the OS keychain.
|
||||
/// Returns None if no SSH password was stored for this connection.
|
||||
#[tauri::command]
|
||||
pub fn get_connection_ssh_password(
|
||||
app: tauri::AppHandle,
|
||||
connection_id: String,
|
||||
) -> Result<Option<String>, String> {
|
||||
app.keyring()
|
||||
.store
|
||||
.get_password(&ssh_account("password", &connection_id))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Delete an SSH tunnel password from the OS keychain.
|
||||
#[tauri::command]
|
||||
pub fn delete_connection_ssh_password(
|
||||
app: tauri::AppHandle,
|
||||
connection_id: String,
|
||||
) -> Result<(), String> {
|
||||
app.keyring()
|
||||
.store
|
||||
.delete(&ssh_account("password", &connection_id))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Store an SSH private-key passphrase in the OS keychain.
|
||||
#[tauri::command]
|
||||
pub fn save_connection_ssh_passphrase(
|
||||
app: tauri::AppHandle,
|
||||
connection_id: String,
|
||||
passphrase: String,
|
||||
) -> Result<(), String> {
|
||||
app.keyring()
|
||||
.store
|
||||
.set_password(&ssh_account("passphrase", &connection_id), &passphrase)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Retrieve an SSH private-key passphrase from the OS keychain.
|
||||
/// Returns None if no passphrase was stored for this connection.
|
||||
#[tauri::command]
|
||||
pub fn get_connection_ssh_passphrase(
|
||||
app: tauri::AppHandle,
|
||||
connection_id: String,
|
||||
) -> Result<Option<String>, String> {
|
||||
app.keyring()
|
||||
.store
|
||||
.get_password(&ssh_account("passphrase", &connection_id))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Delete an SSH private-key passphrase from the OS keychain.
|
||||
#[tauri::command]
|
||||
pub fn delete_connection_ssh_passphrase(
|
||||
app: tauri::AppHandle,
|
||||
connection_id: String,
|
||||
) -> Result<(), String> {
|
||||
app.keyring()
|
||||
.store
|
||||
.delete(&ssh_account("passphrase", &connection_id))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Retrieve an SSH tunnel password from the OS keychain (internal helper).
|
||||
/// Returns None if no SSH password was stored for this connection.
|
||||
pub fn get_connection_ssh_password_internal(
|
||||
app: &tauri::AppHandle,
|
||||
connection_id: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
app.keyring()
|
||||
.store
|
||||
.get_password(&ssh_account("password", connection_id))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Delete an SSH tunnel password from the OS keychain (internal helper).
|
||||
/// Errors are ignored by callers (deleting an absent key is a no-op).
|
||||
pub fn delete_connection_ssh_password_internal(
|
||||
app: &tauri::AppHandle,
|
||||
connection_id: &str,
|
||||
) -> Result<(), String> {
|
||||
app.keyring()
|
||||
.store
|
||||
.delete(&ssh_account("password", connection_id))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Delete an SSH private-key passphrase from the OS keychain (internal helper).
|
||||
/// Errors are ignored by callers (deleting an absent key is a no-op).
|
||||
pub fn delete_connection_ssh_passphrase_internal(
|
||||
app: &tauri::AppHandle,
|
||||
connection_id: &str,
|
||||
) -> Result<(), String> {
|
||||
app.keyring()
|
||||
.store
|
||||
.delete(&ssh_account("passphrase", connection_id))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Delete a connection password from the OS keychain.
|
||||
#[tauri::command]
|
||||
pub fn delete_connection_password(
|
||||
app: tauri::AppHandle,
|
||||
connection_id: String,
|
||||
) -> Result<(), String> {
|
||||
app.keyring()
|
||||
.store
|
||||
.delete(&connection_id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Delete a connection password from the OS keychain (internal helper).
|
||||
/// Errors are ignored by callers (deleting an absent key is a no-op).
|
||||
pub fn delete_connection_password_internal(
|
||||
app: &tauri::AppHandle,
|
||||
connection_id: &str,
|
||||
) -> Result<(), String> {
|
||||
app.keyring()
|
||||
.store
|
||||
.delete(connection_id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ssh_account_namespaces_password() {
|
||||
assert_eq!(ssh_account("password", "c1"), "ssh_password:c1");
|
||||
assert_eq!(ssh_account("passphrase", "c1"), "ssh_passphrase:c1");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
use tauri::State;
|
||||
use crate::db::pool::{ConnectionPoolManager, DbHandle};
|
||||
use crate::models::MaintenanceResult;
|
||||
|
||||
/// Run a table-scoped maintenance command via the SIMPLE query protocol
|
||||
/// (never inside a transaction — VACUUM cannot run in a transaction block).
|
||||
pub(crate) async fn run_maintenance_inner(
|
||||
pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, table: &str, action: &str,
|
||||
) -> Result<MaintenanceResult, String> {
|
||||
let mut pm = pm.lock().await;
|
||||
let client = match pm.get(connection_id) {
|
||||
Some(DbHandle::Postgresql(c, _)) => c,
|
||||
Some(_) => return Err("Maintenance is PostgreSQL-only".into()),
|
||||
None => return Err("Connection not found".into()),
|
||||
};
|
||||
crate::db::object_ddl::validate_object_name(&schema)?;
|
||||
crate::db::object_ddl::validate_object_name(&table)?;
|
||||
let verb = match action {
|
||||
"vacuum" => "VACUUM",
|
||||
"analyze" => "ANALYZE",
|
||||
"reindex" => "REINDEX TABLE",
|
||||
_ => return Err(format!("Unknown maintenance action: {action}")),
|
||||
};
|
||||
let sql = format!("{} {}.{}", verb,
|
||||
crate::db::object_ddl::quote_ident(&schema), crate::db::object_ddl::quote_ident(&table));
|
||||
let start = std::time::Instant::now();
|
||||
client.simple_query(&sql).await
|
||||
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
|
||||
Ok(MaintenanceResult { duration_ms: start.elapsed().as_millis() as i64, message: format!("{} completed on {}.{}", verb, schema, table) })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn run_maintenance(connection_id: String, schema: String, table: String, action: String, state: State<'_, crate::AppState>) -> Result<MaintenanceResult, String> {
|
||||
run_maintenance_inner(&state.pool_manager, &connection_id, &schema, &table, &action).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "maintenance.test.rs"]
|
||||
mod maintenance_test;
|
||||
@@ -0,0 +1,28 @@
|
||||
use crate::db::pool::{ConnectionPoolManager, DbHandle};
|
||||
|
||||
/// Connect directly via tokio-postgres (no tunnel) so tests are Tauri-free.
|
||||
async fn pool() -> (tokio::sync::Mutex<ConnectionPoolManager>, String) {
|
||||
let h = std::env::var("GRIDLINE_TEST_PG_HOST").expect("set GRIDLINE_TEST_PG_HOST");
|
||||
let p: u16 = std::env::var("GRIDLINE_TEST_PG_PORT").unwrap_or_else(|_| "5432".into()).parse().unwrap();
|
||||
let u = std::env::var("GRIDLINE_TEST_PG_USER").expect("set GRIDLINE_TEST_PG_USER");
|
||||
let d = std::env::var("GRIDLINE_TEST_PG_DB").expect("set GRIDLINE_TEST_PG_DB");
|
||||
let pw = std::env::var("GRIDLINE_TEST_PG_PASSWORD").unwrap_or_default();
|
||||
let (client, conn) = tokio_postgres::connect(
|
||||
&format!("host={h} port={p} user={u} dbname={d} password={pw}"),
|
||||
tokio_postgres::NoTls,
|
||||
).await.expect("connect to test PG");
|
||||
let handle = tokio::spawn(async move { let _ = conn.await; });
|
||||
let mut pm = ConnectionPoolManager::new();
|
||||
let id = "test-conn".to_string();
|
||||
pm.register(&id, DbHandle::Postgresql(client, handle));
|
||||
(tokio::sync::Mutex::new(pm), id)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn run_maintenance_analyze_succeeds() {
|
||||
let (pm, id) = pool().await;
|
||||
let res = crate::commands::maintenance::run_maintenance_inner(&pm, &id, "public", "pg_type", "analyze").await.unwrap();
|
||||
assert!(res.duration_ms >= 0);
|
||||
assert!(res.message.to_lowercase().contains("analyze"));
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
pub mod backup;
|
||||
pub mod connections;
|
||||
pub mod db_viewer;
|
||||
pub mod demo;
|
||||
pub mod folders;
|
||||
pub mod import_export;
|
||||
pub mod keychain;
|
||||
pub mod maintenance;
|
||||
pub mod objects;
|
||||
pub mod query;
|
||||
pub mod schema_graph;
|
||||
pub mod settings;
|
||||
pub mod ssh;
|
||||
pub mod tags;
|
||||
pub mod test_connection;
|
||||
@@ -0,0 +1,587 @@
|
||||
use tauri::State;
|
||||
use crate::db::pool::{ConnectionPoolManager, DbHandle};
|
||||
use crate::db::object_crud::{build_ddl, rebuild_script, RebuildConstraint, RebuildFk, RebuildFkIn, RebuildGrant, RebuildIndex, RebuildInput, RebuildOwnedSequence, TableColumn};
|
||||
use crate::db::object_ddl::*;
|
||||
use crate::models::db_viewer::{ObjectSearchHit, DependencyInfo, ExtensionInfo};
|
||||
|
||||
fn sanitize(e: &str) -> String { crate::commands::db_viewer::sanitize_error(e) }
|
||||
|
||||
async fn exec_sql(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, sql: String) -> Result<(), String> {
|
||||
let mut pm = pm.lock().await;
|
||||
match pm.get(connection_id) {
|
||||
Some(DbHandle::Postgresql(client, _)) => client.execute(&sql, &[]).await.map(|_| ()).map_err(|e| sanitize(&e.to_string())),
|
||||
Some(_) => Err("Schema CRUD is PostgreSQL-only".into()),
|
||||
None => Err("Connection not found".into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn create_schema_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, name: &str) -> Result<(), String> {
|
||||
exec_sql(pm, connection_id, create_schema_sql(name)?).await
|
||||
}
|
||||
pub(crate) async fn rename_schema_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, old: &str, new: &str) -> Result<(), String> {
|
||||
exec_sql(pm, connection_id, rename_schema_sql(old, new)?).await
|
||||
}
|
||||
pub(crate) async fn drop_schema_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, name: &str, cascade: bool) -> Result<(), String> {
|
||||
exec_sql(pm, connection_id, drop_schema_sql(name, cascade)?).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_schema(connection_id: String, name: String, state: State<'_, crate::AppState>) -> Result<(), String> {
|
||||
create_schema_inner(&state.pool_manager, &connection_id, &name).await
|
||||
}
|
||||
#[tauri::command]
|
||||
pub async fn rename_schema(connection_id: String, old_name: String, new_name: String, state: State<'_, crate::AppState>) -> Result<(), String> {
|
||||
rename_schema_inner(&state.pool_manager, &connection_id, &old_name, &new_name).await
|
||||
}
|
||||
#[tauri::command]
|
||||
pub async fn drop_schema(connection_id: String, name: String, cascade: bool, state: State<'_, crate::AppState>) -> Result<(), String> {
|
||||
drop_schema_inner(&state.pool_manager, &connection_id, &name, cascade).await
|
||||
}
|
||||
|
||||
pub(crate) async fn search_objects_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, needle: &str) -> Result<Vec<ObjectSearchHit>, String> {
|
||||
let sql = pg_object_search_query();
|
||||
let mut pm = pm.lock().await;
|
||||
match pm.get(connection_id) {
|
||||
Some(DbHandle::Postgresql(client, _)) => {
|
||||
let rows = client.query(&sql, &[&needle, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||
Ok(rows.iter().map(|r| ObjectSearchHit {
|
||||
name: r.get(0), schema: r.get(1), object_type: r.get(2),
|
||||
}).collect())
|
||||
}
|
||||
Some(DbHandle::Sqlite(_)) | Some(DbHandle::MySql(_)) => Ok(vec![]),
|
||||
None => Err("Connection not found".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn search_objects(connection_id: String, schema: String, query: String, state: State<'_, crate::AppState>) -> Result<Vec<ObjectSearchHit>, String> {
|
||||
search_objects_inner(&state.pool_manager, &connection_id, &schema, &query).await
|
||||
}
|
||||
|
||||
pub(crate) async fn get_object_ddl_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, object_type: &str, name: &str) -> Result<String, String> {
|
||||
let mut pm = pm.lock().await;
|
||||
let client = match pm.get(connection_id) {
|
||||
Some(DbHandle::Postgresql(c, _)) => c,
|
||||
Some(_) => return Err("Copy-as-DDL is PostgreSQL-only".into()),
|
||||
None => return Err("Connection not found".into()),
|
||||
};
|
||||
match object_type {
|
||||
"function" | "procedure" => {
|
||||
let row = client.query_one("SELECT pg_get_functiondef(p.oid) FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE p.proname=$1 AND n.nspname=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||
Ok(row.get::<_, String>(0))
|
||||
}
|
||||
"trigger" => {
|
||||
let row = client.query_one("SELECT pg_get_triggerdef(t.oid) FROM pg_trigger t JOIN pg_class c ON t.tgrelid=c.oid JOIN pg_namespace n ON c.relnamespace=n.oid WHERE t.tgname=$1 AND n.nspname=$2 AND NOT t.tgisinternal LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||
Ok(row.get::<_, String>(0))
|
||||
}
|
||||
"index" => {
|
||||
let row = client.query_one("SELECT pg_get_indexdef(ix.indexrelid) FROM pg_index ix JOIN pg_class i ON i.oid=ix.indexrelid JOIN pg_class t ON t.oid=ix.indrelid JOIN pg_namespace n ON t.relnamespace=n.oid WHERE i.relname=$1 AND n.nspname=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||
Ok(row.get::<_, String>(0))
|
||||
}
|
||||
"constraint" => {
|
||||
let row = client.query_one("SELECT c.conname, ns.nspname, cl.relname, pg_get_constraintdef(c.oid) FROM pg_constraint c JOIN pg_class cl ON c.conrelid=cl.oid JOIN pg_namespace ns ON cl.relnamespace=ns.oid WHERE c.conname=$1 AND ns.nspname=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||
Ok(constraint_ddl(&crate::models::db_viewer::ConstraintInfo {
|
||||
name: row.get(0), schema: row.get(1), table: row.get(2), contype: "CHECK".into(),
|
||||
definition: row.get(3), deferrable: false, validated: true, columns: vec![] }))
|
||||
}
|
||||
"view" => {
|
||||
let row = client.query_one("SELECT pg_get_viewdef(c.oid, true) FROM pg_class c JOIN pg_namespace n ON c.relnamespace=n.oid WHERE c.relname=$1 AND n.nspname=$2 AND c.relkind='v' LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||
Ok(view_ddl(schema, name, &row.get::<_, String>(0)))
|
||||
}
|
||||
"materialized view" => {
|
||||
let row = client.query_one("SELECT definition FROM pg_matviews WHERE matviewname=$1 AND schemaname=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||
Ok(matview_ddl(schema, name, &row.get::<_, String>(0)))
|
||||
}
|
||||
"sequence" => {
|
||||
let row = client.query_one("SELECT sequence_name, sequence_schema, start_value::text, minimum_value::text, maximum_value::text, increment::text, COALESCE(pg_catalog.pg_sequence_last_value(sequence_name::regclass)::text,'0'), cycle_option::text FROM information_schema.sequences WHERE sequence_name=$1 AND sequence_schema=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||
Ok(sequence_ddl(&crate::models::db_viewer::SequenceInfo { name: row.get(0), schema: row.get(1), start_value: row.get(2), min_value: row.get(3), max_value: row.get(4), increment: row.get(5), current_value: row.get(6), cycle: row.get::<_, String>(7).eq_ignore_ascii_case("YES") }))
|
||||
}
|
||||
"enum" => {
|
||||
let row = client.query_one("SELECT t.typname, n.nspname, ARRAY(SELECT e.enumlabel FROM pg_enum e WHERE e.enumtypid=t.oid ORDER BY e.enumsortorder) FROM pg_type t JOIN pg_namespace n ON t.typnamespace=n.oid WHERE t.typname=$1 AND n.nspname=$2 AND t.typtype='e' LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||
Ok(enum_ddl(&crate::models::db_viewer::EnumInfo { name: row.get(0), schema: row.get(1), labels: row.get::<_, Vec<String>>(2) }))
|
||||
}
|
||||
"extension" => {
|
||||
let row = client.query_one("SELECT e.extname, n.nspname, e.extversion::text FROM pg_extension e JOIN pg_namespace n ON e.extnamespace=n.oid WHERE e.extname=$1 AND n.nspname=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||
Ok(extension_ddl(&crate::models::db_viewer::ExtensionInfo { name: row.get(0), schema: row.get(1), version: row.get(2), comment: None }))
|
||||
}
|
||||
"table" => {
|
||||
// Tables reuse the pg_dump path (bundled-aware); the command wrapper resolves the tool path via AppHandle.
|
||||
Err("table DDL uses get_table_ddl".into())
|
||||
}
|
||||
other => Err(format!("Unsupported object type for DDL: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_object_ddl(connection_id: String, schema: String, object_type: String, name: String, state: State<'_, crate::AppState>, app: tauri::AppHandle) -> Result<String, String> {
|
||||
if object_type == "table" || object_type == "TABLE" {
|
||||
return crate::commands::db_viewer::get_table_ddl(connection_id, schema, name, state, app).await;
|
||||
}
|
||||
get_object_ddl_inner(&state.pool_manager, &connection_id, &schema, &object_type.to_lowercase(), &name).await
|
||||
}
|
||||
|
||||
pub(crate) async fn get_object_dependencies_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, object_type: &str, name: &str) -> Result<Vec<DependencyInfo>, String> {
|
||||
let mut pm = pm.lock().await;
|
||||
let client = match pm.get(connection_id) {
|
||||
Some(DbHandle::Postgresql(c, _)) => c,
|
||||
Some(_) => return Err("Dependencies are PostgreSQL-only".into()),
|
||||
None => return Err("Connection not found".into()),
|
||||
};
|
||||
if object_type.eq_ignore_ascii_case("schema") {
|
||||
let rows = client.query(&pg_schema_contents_query(), &[&name]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||
return Ok(rows.iter().map(|r| DependencyInfo {
|
||||
deptype: "n".into(), class: format!("pg_class: {}", r.get::<_, String>(1)), name: r.get(0),
|
||||
}).collect());
|
||||
}
|
||||
let oid_sql = pg_object_oid_query(&object_type.to_lowercase());
|
||||
if oid_sql.is_empty() { return Err(format!("Unsupported object type: {object_type}")); }
|
||||
let oid: tokio_postgres::types::Oid = client.query_one(&oid_sql, &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?.get(0);
|
||||
let rows = client.query(&pg_depend_query(), &[&oid]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||
Ok(rows.iter().map(|r| DependencyInfo {
|
||||
deptype: r.get(0), class: r.get(1), name: r.get::<_, String>(2),
|
||||
}).collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_object_dependencies(connection_id: String, schema: String, object_type: String, name: String, state: State<'_, crate::AppState>) -> Result<Vec<DependencyInfo>, String> {
|
||||
get_object_dependencies_inner(&state.pool_manager, &connection_id, &schema, &object_type, &name).await
|
||||
}
|
||||
|
||||
/// Introspect the live columns of a SQLite table (for the edit/rebuild diff).
|
||||
/// Mirrors the `PRAGMA table_info` + `PRAGMA index_list`/`index_info` reads used
|
||||
/// elsewhere in db_viewer; `auto_increment` requires an INTEGER PK whose stored
|
||||
/// DDL (sqlite_master) actually says AUTOINCREMENT, and `unique` means a
|
||||
/// single-column unique index (excluding the PK autoindex).
|
||||
fn sqlite_live_columns(conn: &rusqlite::Connection, table: &str) -> Result<Vec<SqliteColumn>, String> {
|
||||
let pragma_query = format!("PRAGMA table_info('{}')", table);
|
||||
let mut stmt = conn.prepare(&pragma_query).map_err(|e| e.to_string())?;
|
||||
let col_meta: Vec<(String, String, bool, bool, Option<String>)> = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(1)?, // name
|
||||
row.get::<_, String>(2)?, // type
|
||||
row.get::<_, bool>(3)?, // notnull
|
||||
row.get::<_, bool>(5)?, // pk
|
||||
row.get::<_, Option<String>>(4)?, // dflt_value
|
||||
))
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
// AUTOINCREMENT only appears in the stored DDL of an INTEGER PK table.
|
||||
let autoinc = conn
|
||||
.query_row(
|
||||
"SELECT sql FROM sqlite_master WHERE type='table' AND name=?1",
|
||||
[table],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.map(|sql| sql.to_uppercase().contains("AUTOINCREMENT"))
|
||||
.unwrap_or(false);
|
||||
// Columns covered by a single-column unique index (origin != 'pk').
|
||||
let mut unique_cols: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
if let Ok(mut idx_stmt) = conn.prepare(&format!("PRAGMA index_list('{}')", table)) {
|
||||
let indexes: Vec<(String, bool, String)> = idx_stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(1)?, // name
|
||||
row.get::<_, bool>(2)?, // unique
|
||||
row.get::<_, String>(3)?, // origin
|
||||
))
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
for (idx_name, is_unique, origin) in indexes {
|
||||
if !is_unique || origin == "pk" {
|
||||
continue;
|
||||
}
|
||||
if let Ok(mut info_stmt) =
|
||||
conn.prepare(&format!("PRAGMA index_info('{}')", idx_name.replace('\'', "''")))
|
||||
{
|
||||
let cols: Vec<String> = info_stmt
|
||||
.query_map([], |row| row.get::<_, String>(2))
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
if cols.len() == 1 {
|
||||
unique_cols.insert(cols[0].clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(col_meta
|
||||
.iter()
|
||||
.map(|(name, dtype, notnull, is_pk, default)| SqliteColumn {
|
||||
name: name.clone(),
|
||||
type_: dtype.clone(),
|
||||
nullable: !notnull,
|
||||
default: default.clone(),
|
||||
is_pk: *is_pk,
|
||||
auto_increment: autoinc && *is_pk && dtype.trim().eq_ignore_ascii_case("INTEGER"),
|
||||
unique: unique_cols.contains(name),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Build SQL for an object CRUD operation. The pool is resolved only to enforce
|
||||
/// PostgreSQL-only / present-connection; the SQL itself is built by the pure
|
||||
/// `crate::db::object_crud::build_ddl` dispatcher (one statement per String).
|
||||
/// SQLite routes through the table-editor builders in `crate::db::object_ddl`
|
||||
/// (create / edit / rebuild ops on the `table` kind).
|
||||
pub(crate) async fn build_object_ddl_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, kind: &str, params: serde_json::Value) -> Result<Vec<String>, String> {
|
||||
let mut pm = pm.lock().await;
|
||||
match pm.get(connection_id) {
|
||||
Some(DbHandle::Postgresql(_, _)) => build_ddl(kind, params),
|
||||
Some(DbHandle::Sqlite(conn)) => {
|
||||
// params: { schema, name, action: { op, columns: [...] } }
|
||||
let action = params.get("action").ok_or("missing action")?;
|
||||
let op = action
|
||||
.get("op")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("missing op")?;
|
||||
let cols: Vec<SqliteColumn> = match action.get("columns") {
|
||||
Some(v) => serde_json::from_value(v.clone()).map_err(|e| e.to_string())?,
|
||||
None => vec![],
|
||||
};
|
||||
let table = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("missing name")?
|
||||
.to_string();
|
||||
match op {
|
||||
"create" => Ok(vec![sqlite_create_table_sql(&table, &cols, &[])?]),
|
||||
"edit" => {
|
||||
let old = sqlite_live_columns(conn, &table)?;
|
||||
sqlite_column_diff_sql(&table, &old, &cols)
|
||||
}
|
||||
"rebuild" => {
|
||||
let old = sqlite_live_columns(conn, &table)?;
|
||||
sqlite_rebuild_script(&table, &old, &cols)
|
||||
}
|
||||
_ => Err(format!("unknown op {op}")),
|
||||
}
|
||||
}
|
||||
Some(_) => Err("Object management is PostgreSQL-only".into()),
|
||||
None => Err("Connection not found".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn build_object_ddl(connection_id: String, kind: String, params: serde_json::Value, state: State<'_, crate::AppState>) -> Result<Vec<String>, String> {
|
||||
build_object_ddl_inner(&state.pool_manager, &connection_id, &kind, params).await
|
||||
}
|
||||
|
||||
/// List extensions installable on this server (`pg_available_extensions`):
|
||||
/// name + default version + comment. The picker uses name/version only;
|
||||
/// `schema` is left empty (available extensions are schema-wide).
|
||||
pub(crate) async fn get_available_extensions_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str) -> Result<Vec<ExtensionInfo>, String> {
|
||||
let mut pm = pm.lock().await;
|
||||
let client = match pm.get(connection_id) {
|
||||
Some(DbHandle::Postgresql(c, _)) => c,
|
||||
Some(_) => return Err("Extensions are PostgreSQL-only".into()),
|
||||
None => return Err("Connection not found".into()),
|
||||
};
|
||||
let rows = client.query(&crate::db::introspection::pg_available_extensions_query(), &[]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||
Ok(rows.iter().map(|r| ExtensionInfo {
|
||||
name: r.get(0),
|
||||
schema: String::new(),
|
||||
version: r.get(1),
|
||||
comment: r.get(2),
|
||||
}).collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_available_extensions(connection_id: String, state: State<'_, crate::AppState>) -> Result<Vec<ExtensionInfo>, String> {
|
||||
get_available_extensions_inner(&state.pool_manager, &connection_id).await
|
||||
}
|
||||
|
||||
/// Build a reorder-only table rebuild script (executed transactionally via
|
||||
/// `execute_change`'s `RebuildTable` arm). Headless inner: locks the pool once,
|
||||
/// validates the new column list against the live snapshot (names + types must
|
||||
/// be preserved), assembles a `RebuildInput` from live introspection, and
|
||||
/// delegates to `rebuild_script`.
|
||||
pub(crate) async fn build_rebuild_script_inner(
|
||||
pm: &tokio::sync::Mutex<ConnectionPoolManager>,
|
||||
connection_id: &str,
|
||||
schema: &str,
|
||||
table: &str,
|
||||
new_columns: serde_json::Value,
|
||||
) -> Result<String, String> {
|
||||
let mut pm = pm.lock().await;
|
||||
match pm.get(connection_id) {
|
||||
Some(DbHandle::Sqlite(conn)) => {
|
||||
let new_cols: Vec<SqliteColumn> =
|
||||
serde_json::from_value(new_columns).map_err(|e| e.to_string())?;
|
||||
let live = sqlite_live_columns(conn, table)?;
|
||||
if let Some(reason) = sqlite_rebuild_refusal(&live) {
|
||||
return Err(reason);
|
||||
}
|
||||
return Ok(sqlite_rebuild_script(table, &live, &new_cols)?.join(";\n"));
|
||||
}
|
||||
Some(DbHandle::Postgresql(client, _)) => {
|
||||
let new_cols: Vec<TableColumn> =
|
||||
serde_json::from_value(new_columns).map_err(|e| e.to_string())?;
|
||||
|
||||
// 1. live columns — validate reorder-only: the (name,type) multiset must be
|
||||
// unchanged (attribute edits belong in the diff path, not the rebuild).
|
||||
let live = client
|
||||
.query(&crate::db::introspection::pg_columns_query(schema, table), &[])
|
||||
.await
|
||||
.map_err(|e| sanitize(&e.to_string()))?;
|
||||
let mut live_pairs: Vec<(String, String)> = live
|
||||
.iter()
|
||||
.map(|r| (r.get::<_, String>(0), r.get::<_, String>(1).trim().to_string()))
|
||||
.collect();
|
||||
let mut new_pairs: Vec<(String, String)> = new_cols
|
||||
.iter()
|
||||
.map(|c| (c.name.clone(), c.type_.trim().to_string()))
|
||||
.collect();
|
||||
live_pairs.sort();
|
||||
new_pairs.sort();
|
||||
if live_pairs != new_pairs {
|
||||
return Err(
|
||||
"Reorder must preserve column names and types; undo attribute changes or stage a diff"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
// 2. assemble RebuildInput from live introspection (one client, all sub-queries).
|
||||
let fk_out_rows = client
|
||||
.query(&crate::db::introspection::pg_table_fk_out_query(), &[&schema, &table])
|
||||
.await
|
||||
.map_err(|e| sanitize(&e.to_string()))?;
|
||||
let fk_in_rows = client
|
||||
.query(&crate::db::introspection::pg_table_fk_in_query(), &[&schema, &table])
|
||||
.await
|
||||
.map_err(|e| sanitize(&e.to_string()))?;
|
||||
let grant_rows = client
|
||||
.query(&crate::db::introspection::pg_table_grants_query(), &[&schema, &table])
|
||||
.await
|
||||
.map_err(|e| sanitize(&e.to_string()))?;
|
||||
let seq_rows = client
|
||||
.query(&crate::db::introspection::pg_table_owned_sequences_query(), &[&schema, &table])
|
||||
.await
|
||||
.map_err(|e| sanitize(&e.to_string()))?;
|
||||
let index_rows = client
|
||||
.query(&crate::db::introspection::pg_indexes_query(schema), &[&schema])
|
||||
.await
|
||||
.map_err(|e| sanitize(&e.to_string()))?;
|
||||
// PK/UNIQUE/CHECK (contype p/u/c) scoped to this table; FKs are carried
|
||||
// separately as fks_out/fks_in so they are not double-applied.
|
||||
let constraint_rows = client
|
||||
.query(
|
||||
"SELECT c.conname AS name, ns.nspname AS schema, cl.relname AS table_name, \
|
||||
c.contype::text, pg_get_constraintdef(c.oid) AS definition \
|
||||
FROM pg_constraint c \
|
||||
JOIN pg_class cl ON c.conrelid = cl.oid \
|
||||
JOIN pg_namespace ns ON cl.relnamespace = ns.oid \
|
||||
WHERE ns.nspname = $1 AND cl.relname = $2 AND c.contype IN ('p','u','c') \
|
||||
ORDER BY c.conname",
|
||||
&[&schema, &table],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| sanitize(&e.to_string()))?;
|
||||
|
||||
let input = RebuildInput {
|
||||
schema: schema.to_string(),
|
||||
name: table.to_string(),
|
||||
constraints: constraint_rows
|
||||
.iter()
|
||||
.map(|r| RebuildConstraint {
|
||||
name: r.get(0),
|
||||
definition: r.get(4),
|
||||
})
|
||||
.collect(),
|
||||
indexes: index_rows
|
||||
.iter()
|
||||
.filter(|r| r.get::<_, String>(2) == table)
|
||||
.map(|r| RebuildIndex {
|
||||
name: r.get(0),
|
||||
definition: r.get(3),
|
||||
})
|
||||
.collect(),
|
||||
fks_out: fk_out_rows
|
||||
.iter()
|
||||
.map(|r| RebuildFk {
|
||||
name: r.get(0),
|
||||
definition: r.get(1),
|
||||
})
|
||||
.collect(),
|
||||
fks_in: fk_in_rows
|
||||
.iter()
|
||||
.map(|r| RebuildFkIn {
|
||||
name: r.get(0),
|
||||
own_schema: r.get(1),
|
||||
own_table: r.get(2),
|
||||
definition: r.get(3),
|
||||
})
|
||||
.collect(),
|
||||
grants: grant_rows
|
||||
.iter()
|
||||
.map(|r| RebuildGrant {
|
||||
grantee: r.get(0),
|
||||
privileges: r.get(1),
|
||||
grantable: r.get(2),
|
||||
})
|
||||
.collect(),
|
||||
owned_sequences: seq_rows
|
||||
.iter()
|
||||
.map(|r| RebuildOwnedSequence {
|
||||
seq_schema: r.get(0),
|
||||
seq_name: r.get(1),
|
||||
column: r.get(2),
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
rebuild_script(&input, &new_cols)
|
||||
}
|
||||
Some(_) => return Err("Rebuild is PostgreSQL-only".into()),
|
||||
None => return Err("Connection not found".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn build_rebuild_script(
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
new_columns: serde_json::Value,
|
||||
state: State<'_, crate::AppState>,
|
||||
) -> Result<String, String> {
|
||||
build_rebuild_script_inner(&state.pool_manager, &connection_id, &schema, &table, new_columns)
|
||||
.await
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Roles, privileges, rebuild readiness, tablespaces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// List non-system roles with all attributes, memberships grouped by member role.
|
||||
pub(crate) async fn get_roles_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str) -> Result<Vec<crate::models::RoleInfo>, String> {
|
||||
let mut pm = pm.lock().await;
|
||||
let client = match pm.get(connection_id) {
|
||||
Some(DbHandle::Postgresql(c, _)) => c,
|
||||
Some(_) => return Err("Roles are PostgreSQL-only".into()),
|
||||
None => return Err("Connection not found".into()),
|
||||
};
|
||||
let roles = client.query(&crate::db::introspection::pg_roles_query(), &[]).await
|
||||
.map_err(|e| sanitize(&e.to_string()))?;
|
||||
let mems = client.query(&crate::db::introspection::pg_role_memberships_query(), &[]).await
|
||||
.map_err(|e| sanitize(&e.to_string()))?;
|
||||
let mut by_name: std::collections::HashMap<String, crate::models::RoleInfo> = std::collections::HashMap::new();
|
||||
for r in roles {
|
||||
let name: String = r.get("rolname");
|
||||
by_name.insert(name.clone(), crate::models::RoleInfo {
|
||||
name, superuser: r.get("rolsuper"), inherit: r.get("rolinherit"),
|
||||
create_db: r.get("rolcreatedb"), create_role: r.get("rolcreaterole"),
|
||||
can_login: r.get("rolcanlogin"), replication: r.get("rolreplication"),
|
||||
bypass_rls: r.get("rolbypassrls"), connection_limit: r.get("rolconnlimit"),
|
||||
valid_until: { let v: String = r.get("rolvaliduntil"); if v.is_empty() { None } else { Some(v) } },
|
||||
memberships: vec![],
|
||||
});
|
||||
}
|
||||
for m in mems {
|
||||
let member: String = m.get("member");
|
||||
if let Some(ri) = by_name.get_mut(&member) {
|
||||
ri.memberships.push(crate::models::RoleMembership {
|
||||
role: m.get("role"), member, grantor: m.get("grantor"), admin_option: m.get("admin_option"),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(by_name.into_values().collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_roles(connection_id: String, state: State<'_, crate::AppState>) -> Result<Vec<crate::models::RoleInfo>, String> {
|
||||
get_roles_inner(&state.pool_manager, &connection_id).await
|
||||
}
|
||||
|
||||
/// All privilege grants for a role across tables, sequences, routines, schemas, and databases.
|
||||
pub(crate) async fn get_role_privileges_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, role: &str) -> Result<Vec<crate::models::PrivilegeEntry>, String> {
|
||||
let mut pm = pm.lock().await;
|
||||
let client = match pm.get(connection_id) {
|
||||
Some(DbHandle::Postgresql(c, _)) => c,
|
||||
Some(_) => return Err("Privileges are PostgreSQL-only".into()),
|
||||
None => return Err("Connection not found".into()),
|
||||
};
|
||||
validate_object_name(role)?; // role is interpolated into the privilege queries
|
||||
let mut out: Vec<crate::models::PrivilegeEntry> = Vec::new();
|
||||
let push = |out: &mut Vec<crate::models::PrivilegeEntry>, class: &str, schema: Option<String>, name: String, privileges: Vec<String>, grantable: bool| {
|
||||
out.push(crate::models::PrivilegeEntry { object_class: class.into(), schema, name, privileges, grantable });
|
||||
};
|
||||
for row in client.query(&crate::db::introspection::pg_table_privileges_query(role), &[]).await.map_err(|e| sanitize(&e.to_string()))? {
|
||||
push(&mut out, "table", Some(row.get("schema")), row.get("name"), row.get("privileges"), row.get("grantable"));
|
||||
}
|
||||
for row in client.query(&crate::db::introspection::pg_sequence_privileges_query(role), &[]).await.map_err(|e| sanitize(&e.to_string()))? {
|
||||
push(&mut out, "sequence", Some(row.get("schema")), row.get("name"), row.get("privileges"), row.get("grantable"));
|
||||
}
|
||||
for row in client.query(&crate::db::introspection::pg_routine_privileges_query(role), &[]).await.map_err(|e| sanitize(&e.to_string()))? {
|
||||
push(&mut out, "routine", Some(row.get("schema")), row.get("name"), row.get("privileges"), row.get("grantable"));
|
||||
}
|
||||
for row in client.query(&crate::db::introspection::pg_schema_privileges_query(role), &[]).await.map_err(|e| sanitize(&e.to_string()))? {
|
||||
push(&mut out, "schema", None, row.get("name"), row.get("privileges"), row.get("grantable"));
|
||||
}
|
||||
for row in client.query(&crate::db::introspection::pg_database_privileges_query(role), &[]).await.map_err(|e| sanitize(&e.to_string()))? {
|
||||
push(&mut out, "database", None, row.get("name"), row.get("privileges"), row.get("grantable"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_role_privileges(connection_id: String, role: String, state: State<'_, crate::AppState>) -> Result<Vec<crate::models::PrivilegeEntry>, String> {
|
||||
get_role_privileges_inner(&state.pool_manager, &connection_id, &role).await
|
||||
}
|
||||
|
||||
/// Check whether a table can be rebuilt (no triggers, policies, inheritance, partitioning, generated columns).
|
||||
pub(crate) async fn get_table_rebuild_readiness_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, table: &str) -> Result<crate::models::RebuildReadiness, String> {
|
||||
let mut pm = pm.lock().await;
|
||||
match pm.get(connection_id) {
|
||||
Some(DbHandle::Sqlite(conn)) => {
|
||||
let live = sqlite_live_columns(conn, table)?;
|
||||
Ok(match sqlite_rebuild_refusal(&live) {
|
||||
Some(reason) => crate::models::RebuildReadiness { ok: false, reasons: vec![reason] },
|
||||
None => crate::models::RebuildReadiness { ok: true, reasons: vec![] },
|
||||
})
|
||||
}
|
||||
Some(DbHandle::Postgresql(client, _)) => {
|
||||
let row = client.query_one(&crate::db::introspection::pg_rebuild_readiness_query(), &[&schema, &table]).await
|
||||
.map_err(|e| sanitize(&e.to_string()))?;
|
||||
let mut reasons = Vec::new();
|
||||
if row.get::<_, bool>("has_triggers") { reasons.push("table has triggers".into()); }
|
||||
if row.get::<_, bool>("has_policies") { reasons.push("table has RLS policies".into()); }
|
||||
if row.get::<_, bool>("is_inherits") { reasons.push("table participates in inheritance".into()); }
|
||||
if row.get::<_, bool>("is_partitioned") { reasons.push("table is partitioned".into()); }
|
||||
if row.get::<_, bool>("has_generated") { reasons.push("table has generated/identity columns".into()); }
|
||||
Ok(crate::models::RebuildReadiness { ok: reasons.is_empty(), reasons })
|
||||
}
|
||||
Some(_) => return Err("Rebuild is PostgreSQL-only".into()),
|
||||
None => return Err("Connection not found".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_table_rebuild_readiness(connection_id: String, schema: String, table: String, state: State<'_, crate::AppState>) -> Result<crate::models::RebuildReadiness, String> {
|
||||
get_table_rebuild_readiness_inner(&state.pool_manager, &connection_id, &schema, &table).await
|
||||
}
|
||||
|
||||
/// List non-system tablespaces for the table-options picker.
|
||||
pub(crate) async fn get_tablespaces_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str) -> Result<Vec<crate::models::TablespaceInfo>, String> {
|
||||
let mut pm = pm.lock().await;
|
||||
let client = match pm.get(connection_id) {
|
||||
Some(DbHandle::Postgresql(c, _)) => c,
|
||||
Some(_) => return Err("Tablespaces are PostgreSQL-only".into()),
|
||||
None => return Err("Connection not found".into()),
|
||||
};
|
||||
let rows = client.query(&crate::db::introspection::pg_tablespaces_query(), &[]).await
|
||||
.map_err(|e| sanitize(&e.to_string()))?;
|
||||
Ok(rows.into_iter().map(|r| crate::models::TablespaceInfo { name: r.get("spcname") }).collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_tablespaces(connection_id: String, state: State<'_, crate::AppState>) -> Result<Vec<crate::models::TablespaceInfo>, String> {
|
||||
get_tablespaces_inner(&state.pool_manager, &connection_id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "objects.test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,212 @@
|
||||
use super::*;
|
||||
use crate::db::pool::{ConnectionPoolManager, DbHandle};
|
||||
|
||||
/// Connect directly via tokio-postgres (no tunnel) so tests are Tauri-free.
|
||||
async fn pool() -> (tokio::sync::Mutex<ConnectionPoolManager>, String) {
|
||||
let h = std::env::var("GRIDLINE_TEST_PG_HOST").expect("set GRIDLINE_TEST_PG_HOST");
|
||||
let p: u16 = std::env::var("GRIDLINE_TEST_PG_PORT").unwrap_or_else(|_| "5432".into()).parse().unwrap();
|
||||
let u = std::env::var("GRIDLINE_TEST_PG_USER").expect("set GRIDLINE_TEST_PG_USER");
|
||||
let d = std::env::var("GRIDLINE_TEST_PG_DB").expect("set GRIDLINE_TEST_PG_DB");
|
||||
let pw = std::env::var("GRIDLINE_TEST_PG_PASSWORD").unwrap_or_default();
|
||||
let (client, conn) = tokio_postgres::connect(
|
||||
&format!("host={h} port={p} user={u} dbname={d} password={pw}"),
|
||||
tokio_postgres::NoTls,
|
||||
).await.expect("connect to test PG");
|
||||
let handle = tokio::spawn(async move { let _ = conn.await; });
|
||||
let mut pm = ConnectionPoolManager::new();
|
||||
let id = "test-conn".to_string();
|
||||
pm.register(&id, DbHandle::Postgresql(client, handle));
|
||||
(tokio::sync::Mutex::new(pm), id)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn schema_crud_create_rename_drop() {
|
||||
let (pm, id) = pool().await;
|
||||
let name = "gridline_test_schema";
|
||||
create_schema_inner(&pm, &id, name).await.unwrap();
|
||||
assert!(create_schema_inner(&pm, &id, name).await.is_err(), "duplicate should error");
|
||||
rename_schema_inner(&pm, &id, name, "gridline_test_schema2").await.unwrap();
|
||||
drop_schema_inner(&pm, &id, "gridline_test_schema2", false).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn search_objects_finds_table_and_function() {
|
||||
let (pm, id) = pool().await;
|
||||
let hits = search_objects_inner(&pm, &id, "public", "users").await.unwrap();
|
||||
assert!(hits.iter().any(|h| h.name == "users" && h.object_type == "TABLE"), "demo has a users table");
|
||||
let fns = search_objects_inner(&pm, &id, "public", "get").await.unwrap();
|
||||
// substring match across types; assert it returns a Vec<ObjectSearchHit>
|
||||
assert!(fns.iter().all(|h| h.object_type != ""));
|
||||
// empty needle returns nothing matched by position('' in name) > 0 is always true — so empty returns all (capped at 100)
|
||||
let all = search_objects_inner(&pm, &id, "public", "").await.unwrap();
|
||||
assert!(all.len() <= 100);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn object_ddl_for_sequence_enum_function() {
|
||||
let (pm, id) = pool().await;
|
||||
// demo has users_id_seq, an enum, and a function
|
||||
let seq = get_object_ddl_inner(&pm, &id, "public", "sequence", "users_id_seq").await.unwrap();
|
||||
assert!(seq.starts_with("CREATE SEQUENCE"), "{seq}");
|
||||
// function: pg_get_functiondef passthrough
|
||||
let f = get_object_ddl_inner(&pm, &id, "public", "function", "audit_log").await; // name per demo
|
||||
assert!(f.is_ok());
|
||||
assert!(f.clone().unwrap().contains("CREATE FUNCTION") || f.unwrap().contains("CREATE OR REPLACE FUNCTION"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_object_ddl_inner_guards_missing_connection_and_rejects_unknown_op() {
|
||||
let pm = tokio::sync::Mutex::new(ConnectionPoolManager::new());
|
||||
// Missing connection -> Connection not found
|
||||
let err = build_object_ddl_inner(&pm, "missing", "sequence", serde_json::json!({
|
||||
"schema": "public", "name": "s", "action": { "op": "drop" }
|
||||
})).await.unwrap_err();
|
||||
assert!(err.contains("Connection not found"), "{err}");
|
||||
// SQLite now dispatches to the SQLite table builders; non-table ops are rejected.
|
||||
pm.lock().await.register("sqlite", DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()));
|
||||
let err = build_object_ddl_inner(&pm, "sqlite", "sequence", serde_json::json!({
|
||||
"schema": "public", "name": "s", "action": { "op": "drop" }
|
||||
})).await.unwrap_err();
|
||||
assert!(err.contains("unknown op"), "{err}");
|
||||
}
|
||||
|
||||
/// In-memory SQLite pool registered under id "c" (no Tauri, no live PG).
|
||||
fn fresh_pool_with_sqlite() -> ConnectionPoolManager {
|
||||
let mut pm = ConnectionPoolManager::new();
|
||||
pm.register("c", DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()));
|
||||
pm
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_object_ddl_sqlite_create_yields_sqlite_sql() {
|
||||
let pm = fresh_pool_with_sqlite();
|
||||
let params = serde_json::json!({
|
||||
"schema": "main", "name": "users",
|
||||
"action": { "op": "create", "columns": [
|
||||
{ "name": "id", "type": "integer", "nullable": false, "default": null, "is_pk": true, "auto_increment": true, "unique": false },
|
||||
{ "name": "name", "type": "text", "nullable": true, "default": null, "is_pk": false, "auto_increment": false, "unique": false }
|
||||
] }
|
||||
});
|
||||
let sqls = build_object_ddl_inner(&tokio::sync::Mutex::new(pm), "c", "table", params).await.unwrap();
|
||||
assert!(sqls.iter().any(|s| s.contains("INTEGER PRIMARY KEY AUTOINCREMENT")), "{sqls:?}");
|
||||
assert!(sqls.iter().all(|s| !s.contains("serial")), "{sqls:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_change_sqlite_ddl_runs_create() {
|
||||
let pm = tokio::sync::Mutex::new(fresh_pool_with_sqlite());
|
||||
let change = crate::models::db_viewer::Change::Ddl { id: "x".into(), sql: "CREATE TABLE u(id INTEGER)".into() };
|
||||
let r = crate::commands::db_viewer::execute_change_inner(&pm, "c", change).await;
|
||||
assert!(r.is_ok(), "{r:?}");
|
||||
// the table really landed on the live connection
|
||||
{
|
||||
let mut g = pm.lock().await;
|
||||
match g.get("c").unwrap() {
|
||||
crate::db::pool::DbHandle::Sqlite(conn) => {
|
||||
let n: i64 = conn
|
||||
.query_row("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='u'", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(n, 1);
|
||||
}
|
||||
_ => panic!("expected a sqlite handle"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn build_object_ddl_inner_on_postgresql_pool() {
|
||||
let (pm, id) = pool().await;
|
||||
let sql = build_object_ddl_inner(&pm, &id, "sequence", serde_json::json!({
|
||||
"schema": "public", "name": "s", "action": { "op": "drop" }
|
||||
})).await.unwrap();
|
||||
assert_eq!(sql, vec!["DROP SEQUENCE \"public\".\"s\""]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_available_extensions_inner_guards_postgresql_only() {
|
||||
let pm = tokio::sync::Mutex::new(ConnectionPoolManager::new());
|
||||
// Missing connection -> Connection not found
|
||||
let err = get_available_extensions_inner(&pm, "missing").await.unwrap_err();
|
||||
assert!(err.contains("Connection not found"), "{err}");
|
||||
// Non-PostgreSQL handle -> PostgreSQL-only error
|
||||
pm.lock().await.register("sqlite", DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()));
|
||||
let err = get_available_extensions_inner(&pm, "sqlite").await.unwrap_err();
|
||||
assert!(err.contains("PostgreSQL-only"), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn get_available_extensions_on_postgresql_pool() {
|
||||
let (pm, id) = pool().await;
|
||||
let exts = get_available_extensions_inner(&pm, &id).await.unwrap();
|
||||
assert!(!exts.is_empty(), "pg_available_extensions should list built-ins");
|
||||
assert!(exts.iter().all(|e| !e.name.is_empty() && !e.version.is_empty()), "every extension needs name + default version: {exts:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn object_dependencies_for_table_includes_view() {
|
||||
let (pm, id) = pool().await;
|
||||
// demo has order_summary VIEW depending on orders — drop would break it
|
||||
let deps = get_object_dependencies_inner(&pm, &id, "public", "table", "orders").await.unwrap();
|
||||
assert!(deps.iter().any(|d| d.class.contains("pg_class") && d.name.contains("order_summary")), "view depending on orders should surface: {deps:?}");
|
||||
// schema contents path
|
||||
let contents = get_object_dependencies_inner(&pm, &id, "public", "schema", "public").await.unwrap();
|
||||
assert!(!contents.is_empty(), "public schema should list contents");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn get_roles_returns_current_role_and_memberships() {
|
||||
let (pm, id) = pool().await;
|
||||
let roles = crate::commands::objects::get_roles_inner(&pm, &id).await.unwrap();
|
||||
assert!(roles.iter().any(|r| r.can_login), "at least one login role (the test user)");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn rebuild_table_rolls_back_on_failure() {
|
||||
let (pm, id) = pool().await;
|
||||
// setup: a table with a PK + one row
|
||||
{
|
||||
let mut g = pm.lock().await;
|
||||
if let crate::db::pool::DbHandle::Postgresql(c, _) = g.get(&id).unwrap() {
|
||||
c.batch_execute("DROP TABLE IF EXISTS rebuild_t; CREATE TABLE rebuild_t (id int PRIMARY KEY, v text); INSERT INTO rebuild_t VALUES (1,'a');").await.unwrap();
|
||||
}
|
||||
}
|
||||
// build a rebuild script whose final statement intentionally fails (syntax error)
|
||||
// so the whole transaction rolls back and rebuild_t keeps its row.
|
||||
let bad_script = "CREATE TABLE _gridline_rb_rebuild_t (id int PRIMARY KEY, v text); \
|
||||
INSERT INTO _gridline_rb_rebuild_t (id, v) SELECT id, v FROM rebuild_t; \
|
||||
DROP TABLE rebuild_t; \
|
||||
ALTER TABLE _gridline_rb_rebuild_t RENAME TO rebuild_t; \
|
||||
THIS IS NOT SQL;";
|
||||
let change = crate::models::db_viewer::Change::RebuildTable { id: "rb".into(), sql: bad_script.to_string() };
|
||||
let res = crate::commands::db_viewer::execute_change_inner(&pm, &id, change).await;
|
||||
assert!(res.is_err(), "expected rollback (transaction should fail on bad SQL)");
|
||||
// table still intact
|
||||
let mut g = pm.lock().await;
|
||||
if let crate::db::pool::DbHandle::Postgresql(c, _) = g.get(&id).unwrap() {
|
||||
let row = c.query_one("SELECT count(*) FROM rebuild_t", &[]).await.unwrap();
|
||||
assert_eq!(row.get::<_, i64>(0), 1, "rollback must preserve the original table");
|
||||
c.batch_execute("DROP TABLE rebuild_t").await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires live PG"]
|
||||
async fn get_role_privileges_returns_grants_across_object_classes() {
|
||||
let (pm, id) = pool().await;
|
||||
let out = crate::commands::objects::get_role_privileges_inner(&pm, &id, "read_only").await
|
||||
.unwrap_or_else(|e| panic!("privileges command failed: {e}"));
|
||||
let classes: std::collections::HashSet<String> =
|
||||
out.iter().map(|p| p.object_class.clone()).collect();
|
||||
assert!(classes.contains("table"), "expected a table grant: {out:?}");
|
||||
assert!(classes.contains("routine"), "expected a routine grant: {out:?}");
|
||||
assert!(classes.contains("schema"), "expected a schema grant: {out:?}");
|
||||
assert!(classes.contains("database"), "expected a database grant: {out:?}");
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
#[allow(unused_imports)]
|
||||
use crate::db::pool::DbHandle;
|
||||
use crate::models::db_viewer::{GraphColumn, Relationship, SchemaGraph, TableNode};
|
||||
use std::collections::HashMap;
|
||||
use tauri::State;
|
||||
|
||||
/// Validate a schema name for safe use in parameterized queries.
|
||||
/// Rejects empty strings and names containing SQL metacharacters.
|
||||
pub fn validate_schema_name(name: &str) -> Result<(), String> {
|
||||
if name.is_empty() {
|
||||
return Err("Schema name cannot be empty".into());
|
||||
}
|
||||
if name.contains(';')
|
||||
|| name.contains("--")
|
||||
|| name.contains("/*")
|
||||
|| name.contains('\'')
|
||||
|| name.contains('"')
|
||||
|| name.contains('\\')
|
||||
{
|
||||
return Err(format!("Invalid schema name: {}", name));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build a parameterized query that fetches all tables, columns, and
|
||||
/// PK/FK/UNIQUE metadata for a PostgreSQL schema in a single round-trip.
|
||||
pub fn build_pg_schema_graph_query(_schema: &str) -> String {
|
||||
// Uses pg_catalog directly instead of information_schema views.
|
||||
// information_schema views are extremely slow on some servers (remote/
|
||||
// cloud) because they scan all databases' catalogs. pg_catalog with
|
||||
// LATERAL joins is typically 500x+ faster (~200ms vs 120s for 55 tables).
|
||||
r#"SELECT
|
||||
c.relname AS table_name,
|
||||
n.nspname AS table_schema,
|
||||
CASE WHEN c.relkind = 'v' THEN 'VIEW' ELSE 'BASE TABLE' END AS table_type,
|
||||
a.attname AS column_name,
|
||||
pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type,
|
||||
NOT a.attnotnull AS is_nullable,
|
||||
a.attnum AS ordinal_position,
|
||||
COALESCE(pk.is_pk, false) AS is_pk,
|
||||
COALESCE(fk.is_fk, false) AS is_fk,
|
||||
fk.foreign_table_schema,
|
||||
fk.foreign_table_name,
|
||||
fk.foreign_column_name,
|
||||
COALESCE(uq.is_unique, false) AS is_unique
|
||||
FROM pg_catalog.pg_class c
|
||||
JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid
|
||||
JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT true AS is_pk
|
||||
FROM pg_catalog.pg_constraint pk2
|
||||
WHERE pk2.conrelid = c.oid AND pk2.contype = 'p' AND a.attnum = ANY(pk2.conkey)
|
||||
LIMIT 1
|
||||
) pk ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT true AS is_fk,
|
||||
ref_n.nspname AS foreign_table_schema,
|
||||
ref_c.relname AS foreign_table_name,
|
||||
ref_a.attname AS foreign_column_name
|
||||
FROM pg_catalog.pg_constraint fk2
|
||||
JOIN pg_catalog.pg_class ref_c ON fk2.confrelid = ref_c.oid
|
||||
JOIN pg_catalog.pg_namespace ref_n ON ref_c.relnamespace = ref_n.oid
|
||||
JOIN pg_catalog.pg_attribute ref_a
|
||||
ON ref_a.attrelid = ref_c.oid AND ref_a.attnum = ANY(fk2.confkey)
|
||||
WHERE fk2.conrelid = c.oid AND fk2.contype = 'f'
|
||||
AND a.attnum = ANY(fk2.conkey)
|
||||
LIMIT 1
|
||||
) fk ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT true AS is_unique
|
||||
FROM pg_catalog.pg_constraint uq2
|
||||
WHERE uq2.conrelid = c.oid AND uq2.contype = 'u' AND a.attnum = ANY(uq2.conkey)
|
||||
LIMIT 1
|
||||
) uq ON true
|
||||
WHERE n.nspname = $1
|
||||
AND c.relkind IN ('r', 'v', 'p')
|
||||
AND a.attnum > 0
|
||||
AND NOT a.attisdropped
|
||||
ORDER BY c.relname, a.attnum"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Infer relationship cardinality from constraint metadata.
|
||||
///
|
||||
/// - `is_pk`: the FK column is also part of the primary key
|
||||
/// - `is_unique`: the FK column has a UNIQUE constraint
|
||||
/// - `is_nullable`: the FK column allows NULL values
|
||||
/// - `is_join_table_fk`: this FK belongs to a join table
|
||||
pub fn infer_cardinality(
|
||||
is_pk: bool,
|
||||
is_unique: bool,
|
||||
is_nullable: bool,
|
||||
is_join_table_fk: bool,
|
||||
) -> String {
|
||||
if is_join_table_fk {
|
||||
return "N:M".into();
|
||||
}
|
||||
let one_side = is_pk || is_unique;
|
||||
match (one_side, is_nullable) {
|
||||
(true, false) => "1:1".into(),
|
||||
(true, true) => "0..1:0..1".into(),
|
||||
(false, false) => "1:N".into(),
|
||||
(false, true) => "0..N".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_pg_schema_rows(
|
||||
rows: &[Vec<serde_json::Value>],
|
||||
) -> (Vec<TableNode>, Vec<Relationship>) {
|
||||
let mut table_map: HashMap<(String, String), (String, Vec<GraphColumn>)> = HashMap::new();
|
||||
let mut relationships: Vec<Relationship> = Vec::new();
|
||||
|
||||
for row in rows {
|
||||
let table_name = row[0].as_str().unwrap_or_default().to_string();
|
||||
let table_schema = row[1].as_str().unwrap_or_default().to_string();
|
||||
let table_type = row[2].as_str().unwrap_or_default().to_string();
|
||||
let col_name = row[3].as_str().unwrap_or_default().to_string();
|
||||
let data_type = row[4].as_str().unwrap_or_default().to_string();
|
||||
let is_nullable = row[5].as_bool().unwrap_or(false);
|
||||
let is_pk = row[7].as_bool().unwrap_or(false);
|
||||
let is_fk = row[8].as_bool().unwrap_or(false);
|
||||
let fk_schema = row[9].as_str().map(String::from);
|
||||
let fk_table = row[10].as_str().map(String::from);
|
||||
let fk_column = row[11].as_str().map(String::from);
|
||||
let is_unique = row[12].as_bool().unwrap_or(false);
|
||||
|
||||
let fk_ref = if is_fk {
|
||||
match (&fk_schema, &fk_table, &fk_column) {
|
||||
(Some(s), Some(t), Some(c)) => Some((s.clone(), t.clone(), c.clone())),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let col = GraphColumn {
|
||||
name: col_name.clone(),
|
||||
data_type,
|
||||
is_pk,
|
||||
is_fk,
|
||||
is_unique: is_unique || is_pk,
|
||||
is_nullable,
|
||||
fk_ref: fk_ref.clone(),
|
||||
};
|
||||
|
||||
let key = (table_schema.clone(), table_name.clone());
|
||||
table_map
|
||||
.entry(key)
|
||||
.or_insert_with(|| (table_type.clone(), Vec::new()))
|
||||
.1
|
||||
.push(col);
|
||||
|
||||
if let Some((ref_schema, ref_table, ref_column)) = fk_ref {
|
||||
relationships.push(Relationship {
|
||||
source_schema: table_schema.clone(),
|
||||
source_table: table_name.clone(),
|
||||
source_column: col_name,
|
||||
target_schema: ref_schema,
|
||||
target_table: ref_table,
|
||||
target_column: ref_column,
|
||||
cardinality: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Detect N:M join tables: tables where ALL PK columns are also FK columns
|
||||
let join_table_keys: Vec<(String, String)> = table_map
|
||||
.iter()
|
||||
.filter(|(_, (_, cols))| {
|
||||
let pk_cols: Vec<&GraphColumn> = cols.iter().filter(|c| c.is_pk).collect();
|
||||
!pk_cols.is_empty() && pk_cols.iter().all(|c| c.is_fk)
|
||||
})
|
||||
.map(|(k, _)| k.clone())
|
||||
.collect();
|
||||
|
||||
// Assign cardinality to each relationship
|
||||
for rel in &mut relationships {
|
||||
let source_key = (rel.source_schema.clone(), rel.source_table.clone());
|
||||
let is_join = join_table_keys.contains(&source_key);
|
||||
let (is_pk_or_unique, is_nullable) = table_map
|
||||
.get(&source_key)
|
||||
.and_then(|(_, cols)| cols.iter().find(|c| c.name == rel.source_column))
|
||||
.map(|c| (c.is_pk || c.is_unique, c.is_nullable))
|
||||
.unwrap_or((false, false));
|
||||
rel.cardinality = infer_cardinality(is_pk_or_unique, is_pk_or_unique, is_nullable, is_join);
|
||||
}
|
||||
|
||||
let mut tables: Vec<TableNode> = table_map
|
||||
.into_iter()
|
||||
.map(|((schema, name), (table_type, columns))| TableNode {
|
||||
name,
|
||||
schema,
|
||||
table_type,
|
||||
columns,
|
||||
})
|
||||
.collect();
|
||||
tables.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
|
||||
(tables, relationships)
|
||||
}
|
||||
|
||||
fn build_sqlite_schema_graph(
|
||||
conn: &rusqlite::Connection,
|
||||
schema: &str,
|
||||
) -> Result<SchemaGraph, String> {
|
||||
if schema != "main" {
|
||||
return Err(format!(
|
||||
"SQLite only supports schema 'main', got: {}",
|
||||
schema
|
||||
));
|
||||
}
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let table_rows: Vec<(String, String)> = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
let mut tables: Vec<TableNode> = Vec::new();
|
||||
let mut relationships: Vec<Relationship> = Vec::new();
|
||||
|
||||
for (table_name, table_type) in &table_rows {
|
||||
let pragma_sql = format!("PRAGMA table_info('{}')", table_name);
|
||||
let mut ps = conn.prepare(&pragma_sql).map_err(|e| e.to_string())?;
|
||||
let col_meta: Vec<(String, String, bool, bool)> = ps
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, bool>(3)?,
|
||||
row.get::<_, bool>(5)?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
let fk_sql = format!("PRAGMA foreign_key_list('{}')", table_name);
|
||||
let fk_cols: HashMap<String, (String, String)> = if let Ok(mut fs) = conn.prepare(&fk_sql) {
|
||||
fs.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(3)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(4)?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|r| r.ok())
|
||||
.map(|(col, ref_t, ref_c)| (col, (ref_t, ref_c)))
|
||||
.collect()
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
|
||||
let columns: Vec<GraphColumn> = col_meta
|
||||
.iter()
|
||||
.map(|(name, dtype, _nn, is_pk)| {
|
||||
let fk = fk_cols.get(name);
|
||||
let is_fk = fk.is_some();
|
||||
let fk_ref = fk.map(|(t, c)| ("main".into(), t.clone(), c.clone()));
|
||||
if let Some((ref_t, ref_c)) = fk {
|
||||
relationships.push(Relationship {
|
||||
source_schema: "main".into(),
|
||||
source_table: table_name.clone(),
|
||||
source_column: name.clone(),
|
||||
target_schema: "main".into(),
|
||||
target_table: ref_t.clone(),
|
||||
target_column: ref_c.clone(),
|
||||
cardinality: infer_cardinality(*is_pk, false, !_nn, false),
|
||||
});
|
||||
}
|
||||
GraphColumn {
|
||||
name: name.clone(),
|
||||
data_type: if dtype.is_empty() {
|
||||
"TEXT".into()
|
||||
} else {
|
||||
dtype.clone()
|
||||
},
|
||||
is_pk: *is_pk,
|
||||
is_fk,
|
||||
is_unique: *is_pk,
|
||||
is_nullable: !_nn,
|
||||
fk_ref: fk_ref.map(|(s, t, c)| (s, t, c)),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
tables.push(TableNode {
|
||||
name: table_name.clone(),
|
||||
schema: "main".into(),
|
||||
table_type: table_type.to_uppercase(),
|
||||
columns,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(SchemaGraph {
|
||||
tables,
|
||||
relationships,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_schema_graph(
|
||||
connection_id: String,
|
||||
schema: Option<String>,
|
||||
state: State<'_, crate::AppState>,
|
||||
) -> Result<SchemaGraph, String> {
|
||||
let schema = schema.unwrap_or_else(|| "public".to_string());
|
||||
validate_schema_name(&schema)?;
|
||||
|
||||
let mut pm = state.pool_manager.lock().await;
|
||||
match pm.get(&connection_id) {
|
||||
Some(DbHandle::Postgresql(client, _)) => {
|
||||
let query = build_pg_schema_graph_query(&schema);
|
||||
let rows = client
|
||||
.query(&query, &[&schema])
|
||||
.await
|
||||
.map_err(|e| crate::commands::db_viewer::pg_error_message(&e))?;
|
||||
|
||||
let json_rows: Vec<Vec<serde_json::Value>> = rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
(0..row.len())
|
||||
.map(|i| crate::commands::db_viewer::pg_value_to_json(row, i))
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let (tables, relationships) = parse_pg_schema_rows(&json_rows);
|
||||
Ok(SchemaGraph {
|
||||
tables,
|
||||
relationships,
|
||||
})
|
||||
}
|
||||
Some(DbHandle::Sqlite(conn)) => build_sqlite_schema_graph(conn, &schema),
|
||||
Some(DbHandle::MySql(_)) => Err("Schema visualizer not supported for MySQL".to_string()),
|
||||
None => Err("Connection not found".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validate_schema_name_rejects_empty() {
|
||||
assert!(validate_schema_name("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_schema_name_rejects_semicolon() {
|
||||
assert!(validate_schema_name("public; DROP TABLE users").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_schema_name_rejects_sql_comment() {
|
||||
assert!(validate_schema_name("public--comment").is_err());
|
||||
assert!(validate_schema_name("public/*comment*/").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_schema_name_rejects_quotes() {
|
||||
assert!(validate_schema_name("pub'lic").is_err());
|
||||
assert!(validate_schema_name("pub\"lic").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_schema_name_rejects_backslash() {
|
||||
assert!(validate_schema_name("public\\schema").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_schema_name_accepts_valid_names() {
|
||||
assert!(validate_schema_name("public").is_ok());
|
||||
assert!(validate_schema_name("my_schema").is_ok());
|
||||
assert!(validate_schema_name("schema123").is_ok());
|
||||
assert!(validate_schema_name("auth").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_pg_schema_graph_query_is_parameterized() {
|
||||
let sql = build_pg_schema_graph_query("public");
|
||||
// Must use $1 for schema parameter (parameterized)
|
||||
assert!(
|
||||
sql.contains("$1"),
|
||||
"query should use $1 placeholder; got: {}",
|
||||
sql
|
||||
);
|
||||
// Must not interpolate schema name directly in a potentially unsafe way
|
||||
assert!(
|
||||
!sql.contains("'public'"),
|
||||
"query should not use literal 'public'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_pg_schema_graph_query_queries_columns() {
|
||||
let sql = build_pg_schema_graph_query("myschema");
|
||||
assert!(sql.contains("pg_catalog.pg_class"), "should query pg_class");
|
||||
assert!(
|
||||
sql.contains("pg_catalog.pg_attribute"),
|
||||
"should query pg_attribute"
|
||||
);
|
||||
assert!(
|
||||
sql.contains("pg_catalog.pg_constraint"),
|
||||
"should include constraint info"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infer_cardinality_one_to_one_pk() {
|
||||
assert_eq!(infer_cardinality(true, false, false, false), "1:1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infer_cardinality_zero_or_one() {
|
||||
// UNIQUE + nullable → 0..1:0..1
|
||||
assert_eq!(infer_cardinality(false, true, true, false), "0..1:0..1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infer_cardinality_one_to_many() {
|
||||
assert_eq!(infer_cardinality(false, false, false, false), "1:N");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infer_cardinality_zero_or_many() {
|
||||
// not PK, not UNIQUE, nullable → 0..N
|
||||
assert_eq!(infer_cardinality(false, false, true, false), "0..N");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infer_cardinality_many_to_many() {
|
||||
assert_eq!(infer_cardinality(false, false, false, true), "N:M");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pg_schema_rows_builds_correct_graph() {
|
||||
let rows: Vec<Vec<serde_json::Value>> = vec![
|
||||
// users.id (PK)
|
||||
vec![
|
||||
serde_json::json!("users"),
|
||||
serde_json::json!("public"),
|
||||
serde_json::json!("BASE TABLE"),
|
||||
serde_json::json!("id"),
|
||||
serde_json::json!("integer"),
|
||||
serde_json::json!("NO"),
|
||||
serde_json::json!(1),
|
||||
serde_json::json!(true),
|
||||
serde_json::json!(false),
|
||||
serde_json::Value::Null,
|
||||
serde_json::Value::Null,
|
||||
serde_json::Value::Null,
|
||||
serde_json::json!(true),
|
||||
],
|
||||
// users.email (non-key)
|
||||
vec![
|
||||
serde_json::json!("users"),
|
||||
serde_json::json!("public"),
|
||||
serde_json::json!("BASE TABLE"),
|
||||
serde_json::json!("email"),
|
||||
serde_json::json!("text"),
|
||||
serde_json::json!("NO"),
|
||||
serde_json::json!(2),
|
||||
serde_json::json!(false),
|
||||
serde_json::json!(false),
|
||||
serde_json::Value::Null,
|
||||
serde_json::Value::Null,
|
||||
serde_json::Value::Null,
|
||||
serde_json::json!(true),
|
||||
],
|
||||
// orders.id (PK)
|
||||
vec![
|
||||
serde_json::json!("orders"),
|
||||
serde_json::json!("public"),
|
||||
serde_json::json!("BASE TABLE"),
|
||||
serde_json::json!("id"),
|
||||
serde_json::json!("integer"),
|
||||
serde_json::json!("NO"),
|
||||
serde_json::json!(1),
|
||||
serde_json::json!(true),
|
||||
serde_json::json!(false),
|
||||
serde_json::Value::Null,
|
||||
serde_json::Value::Null,
|
||||
serde_json::Value::Null,
|
||||
serde_json::json!(true),
|
||||
],
|
||||
// orders.user_id (FK → users.id)
|
||||
vec![
|
||||
serde_json::json!("orders"),
|
||||
serde_json::json!("public"),
|
||||
serde_json::json!("BASE TABLE"),
|
||||
serde_json::json!("user_id"),
|
||||
serde_json::json!("integer"),
|
||||
serde_json::json!("NO"),
|
||||
serde_json::json!(2),
|
||||
serde_json::json!(false),
|
||||
serde_json::json!(true),
|
||||
serde_json::json!("public"),
|
||||
serde_json::json!("users"),
|
||||
serde_json::json!("id"),
|
||||
serde_json::json!(false),
|
||||
],
|
||||
];
|
||||
|
||||
let (tables, relationships) = parse_pg_schema_rows(&rows);
|
||||
|
||||
assert_eq!(tables.len(), 2, "should have 2 tables");
|
||||
assert_eq!(relationships.len(), 1, "should have 1 relationship");
|
||||
|
||||
let users = tables.iter().find(|t| t.name == "users").unwrap();
|
||||
assert_eq!(users.columns.len(), 2);
|
||||
assert!(users.columns[0].is_pk);
|
||||
|
||||
let orders = tables.iter().find(|t| t.name == "orders").unwrap();
|
||||
assert_eq!(orders.columns.len(), 2);
|
||||
|
||||
let rel = &relationships[0];
|
||||
assert_eq!(rel.source_table, "orders");
|
||||
assert_eq!(rel.target_table, "users");
|
||||
assert_eq!(rel.source_column, "user_id");
|
||||
assert_eq!(rel.target_column, "id");
|
||||
assert_eq!(rel.cardinality, "1:N");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pg_schema_rows_empty_yields_empty_graph() {
|
||||
let rows: Vec<Vec<serde_json::Value>> = vec![];
|
||||
let (tables, relationships) = parse_pg_schema_rows(&rows);
|
||||
assert!(tables.is_empty());
|
||||
assert!(relationships.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
use crate::models::Settings;
|
||||
use crate::store::Store;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
pub fn get_settings_inner(state: &Mutex<Store>) -> Result<Settings, String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.get_settings()
|
||||
}
|
||||
|
||||
pub fn update_setting_inner(state: &Mutex<Store>, key: &str, value: &str) -> Result<(), String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.update_setting(key, value)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_settings(state: tauri::State<crate::AppState>) -> Result<Settings, String> {
|
||||
get_settings_inner(&state.db_store)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_setting(
|
||||
state: tauri::State<crate::AppState>,
|
||||
key: String,
|
||||
value: String,
|
||||
) -> Result<(), String> {
|
||||
update_setting_inner(&state.db_store, &key, &value)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings export / import (v0.7.8)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Flatten a `Settings` struct into the store's key/value map. Keys and value
|
||||
/// formats must round-trip through `Store::get_settings` (e.g. a `None`
|
||||
/// `default_folder_id` is stored as the literal `"null"` sentinel, which
|
||||
/// `get_settings` filters back to `None`).
|
||||
fn settings_to_kv(s: &crate::models::Settings) -> HashMap<String, String> {
|
||||
let mut m = HashMap::new();
|
||||
m.insert("confirm_before_delete".into(), s.confirm_before_delete.to_string());
|
||||
if let Some(f) = &s.default_folder_id {
|
||||
m.insert("default_folder_id".into(), f.clone());
|
||||
} else {
|
||||
m.insert("default_folder_id".into(), "null".into());
|
||||
}
|
||||
m.insert("theme".into(), s.theme.clone());
|
||||
m.insert("font_size".into(), s.font_size.clone());
|
||||
m.insert("accent_color".into(), s.accent_color.clone());
|
||||
m.insert("table_refresh_rate".into(), s.table_refresh_rate.to_string());
|
||||
m.insert("table_page_size".into(), s.table_page_size.to_string());
|
||||
m.insert("editor_font_size".into(), s.editor_font_size.to_string());
|
||||
m.insert("editor_font_family".into(), s.editor_font_family.clone());
|
||||
m.insert("editor_word_wrap".into(), s.editor_word_wrap.clone());
|
||||
m.insert("editor_minimap".into(), s.editor_minimap.to_string());
|
||||
m.insert("editor_tab_size".into(), s.editor_tab_size.to_string());
|
||||
if let Some(o) = &s.tag_order {
|
||||
m.insert("tag_order".into(), o.clone());
|
||||
}
|
||||
m.insert(
|
||||
"default_ports".into(),
|
||||
serde_json::to_string(&s.default_ports).unwrap_or_default(),
|
||||
);
|
||||
m.insert(
|
||||
"shortcuts".into(),
|
||||
serde_json::to_string(&s.shortcuts).unwrap_or_default(),
|
||||
);
|
||||
m
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn export_settings(state: tauri::State<crate::AppState>) -> Result<String, String> {
|
||||
let s = get_settings_inner(&state.db_store)?;
|
||||
serde_json::to_string(&crate::models::settings::SettingsExport {
|
||||
schema_version: 1,
|
||||
settings: s,
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn import_settings(
|
||||
json: String,
|
||||
state: tauri::State<crate::AppState>,
|
||||
) -> Result<(), String> {
|
||||
let env: crate::models::settings::SettingsExport =
|
||||
serde_json::from_str(&json).map_err(|e| format!("invalid settings file: {e}"))?;
|
||||
let map = settings_to_kv(&env.settings);
|
||||
let store = state.db_store.lock().map_err(|e| e.to_string())?;
|
||||
store.apply_settings(&map)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::store::Store;
|
||||
|
||||
fn state() -> std::sync::Mutex<Store> {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
crate::store::migrations::run_migrations(&conn).unwrap();
|
||||
std::sync::Mutex::new(Store::from_connection(conn))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_settings_returns_defaults() {
|
||||
let st = state();
|
||||
let s = get_settings_inner(&st).unwrap();
|
||||
assert_eq!(s.theme, "system");
|
||||
assert_eq!(s.font_size, "medium");
|
||||
assert_eq!(s.accent_color, "#2563EB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_setting_persists() {
|
||||
let st = state();
|
||||
update_setting_inner(&st, "theme", "light").unwrap();
|
||||
assert_eq!(get_settings_inner(&st).unwrap().theme, "light");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_accent_color_persists() {
|
||||
let st = state();
|
||||
update_setting_inner(&st, "accent_color", "#EF4444").unwrap();
|
||||
assert_eq!(get_settings_inner(&st).unwrap().accent_color, "#EF4444");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_export_envelope_camel_case() {
|
||||
use std::collections::HashMap;
|
||||
let env = crate::models::settings::SettingsExport { schema_version: 1, settings: crate::models::Settings {
|
||||
confirm_before_delete: true, default_folder_id: None, theme: "dark".into(), font_size: "medium".into(),
|
||||
default_ports: HashMap::new(), tag_order: None, table_refresh_rate: 5, table_page_size: 50,
|
||||
shortcuts: HashMap::new(), accent_color: "#2563EB".into(), editor_font_size: 14,
|
||||
editor_font_family: "Menlo".into(), editor_word_wrap: "off".into(), editor_minimap: true, editor_tab_size: 2,
|
||||
}};
|
||||
let json = serde_json::to_string(&env).unwrap();
|
||||
assert!(json.contains("\"schemaVersion\":1"));
|
||||
assert!(json.contains("\"settings\":"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_apply_settings_writes_all_keys() {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
crate::store::migrations::run_migrations(&conn).unwrap();
|
||||
let store = crate::store::Store::from_connection(conn);
|
||||
let mut map = std::collections::HashMap::new();
|
||||
map.insert("theme".to_string(), "light".to_string());
|
||||
map.insert("accent_color".to_string(), "#EF4444".to_string());
|
||||
store.apply_settings(&map).unwrap();
|
||||
assert_eq!(store.get_settings().unwrap().theme, "light");
|
||||
assert_eq!(store.get_settings().unwrap().accent_color, "#EF4444");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
use crate::db::tls::TlsDecision;
|
||||
use crate::models::SshConfig;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Through a tunnel the TLS peer is loopback (`127.0.0.1`), so certificate
|
||||
/// verification is meaningless: `verify-ca`/`verify-full` degrade to
|
||||
/// encrypt-only `require`. A direct (non-tunneled) connection honors the
|
||||
/// user's mode unchanged.
|
||||
pub fn effective_tls_decision(d: TlsDecision, via_tunnel: bool) -> TlsDecision {
|
||||
if via_tunnel && matches!(d, TlsDecision::Verify) {
|
||||
TlsDecision::Require
|
||||
} else {
|
||||
d
|
||||
}
|
||||
}
|
||||
|
||||
/// A live tunnel handle. `closer` drops the listener + ssh session when called.
|
||||
pub struct Tunnel {
|
||||
pub local_port: u16,
|
||||
closer: Option<Box<dyn FnOnce() + Send>>,
|
||||
}
|
||||
|
||||
impl Tunnel {
|
||||
/// Create a tunnel handle with no resources to clean up (test backend).
|
||||
pub fn fake(port: u16) -> Self {
|
||||
Tunnel {
|
||||
local_port: port,
|
||||
closer: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Backend that actually establishes SSH tunnels.
|
||||
///
|
||||
/// The manager only does bookkeeping; opening/closing the OS-level tunnel is
|
||||
/// delegated here so it can be faked in tests.
|
||||
pub trait TunnelBackend: Send + Sync {
|
||||
/// Open a tunnel to `remote_host:remote_port` via `cfg` and return a
|
||||
/// handle exposing the bound local port.
|
||||
fn open(
|
||||
&self,
|
||||
key: &str,
|
||||
cfg: &SshConfig,
|
||||
remote_host: &str,
|
||||
remote_port: u16,
|
||||
password: Option<&str>,
|
||||
passphrase: Option<&str>,
|
||||
) -> Result<Tunnel, String>;
|
||||
}
|
||||
|
||||
/// Manages SSH tunnels, mapping connection keys to active tunnels.
|
||||
///
|
||||
/// Bookkeeping only: validation, key->tunnel map, and lifecycle hooks.
|
||||
/// The actual SSH connectivity is delegated to a `TunnelBackend` so the
|
||||
/// manager's behavior is unit-testable with a fake backend.
|
||||
pub struct SshTunnelManager {
|
||||
tunnels: HashMap<String, Tunnel>,
|
||||
backend: Arc<dyn TunnelBackend>,
|
||||
}
|
||||
|
||||
impl SshTunnelManager {
|
||||
/// Create a new tunnel manager backed by `backend`.
|
||||
pub fn new(backend: Arc<dyn TunnelBackend>) -> Self {
|
||||
SshTunnelManager {
|
||||
tunnels: HashMap::new(),
|
||||
backend,
|
||||
}
|
||||
}
|
||||
|
||||
/// Open an SSH tunnel for the given config.
|
||||
///
|
||||
/// Returns the local port on success. Replaces any existing tunnel for
|
||||
/// the same key (closing the old one).
|
||||
pub fn open_tunnel(
|
||||
&mut self,
|
||||
key: &str,
|
||||
cfg: &SshConfig,
|
||||
remote_host: &str,
|
||||
remote_port: u16,
|
||||
password: Option<&str>,
|
||||
passphrase: Option<&str>,
|
||||
) -> Result<u16, String> {
|
||||
if !cfg.is_valid() {
|
||||
return Err("invalid SSH configuration".to_string());
|
||||
}
|
||||
let tunnel = self
|
||||
.backend
|
||||
.open(key, cfg, remote_host, remote_port, password, passphrase)?;
|
||||
let port = tunnel.local_port;
|
||||
if let Some(old) = self.tunnels.insert(key.to_string(), tunnel) {
|
||||
drop(old.closer);
|
||||
}
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
/// Close and remove the SSH tunnel for the given key.
|
||||
pub fn close_tunnel(&mut self, key: &str) {
|
||||
if let Some(t) = self.tunnels.remove(key) {
|
||||
drop(t.closer);
|
||||
}
|
||||
}
|
||||
|
||||
/// Close all active SSH tunnels.
|
||||
pub fn close_all(&mut self) {
|
||||
let tunnels = std::mem::take(&mut self.tunnels);
|
||||
for (_, t) in tunnels {
|
||||
drop(t.closer);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the local port for an active tunnel, if any.
|
||||
pub fn get_local_port(&self, key: &str) -> Option<u16> {
|
||||
self.tunnels.get(key).map(|t| t.local_port)
|
||||
}
|
||||
|
||||
/// Clone of the active backend, for handing into `spawn_blocking` so the
|
||||
/// blocking ssh2 work never blocks an async runtime thread.
|
||||
pub fn backend_clone(&self) -> Arc<dyn TunnelBackend> {
|
||||
self.backend.clone()
|
||||
}
|
||||
|
||||
/// Insert an already-opened tunnel under `key`, closing any previous one.
|
||||
pub fn insert_tunnel(&mut self, key: String, tunnel: Tunnel) {
|
||||
if let Some(old) = self.tunnels.insert(key, tunnel) {
|
||||
drop(old.closer);
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the number of active tunnels.
|
||||
pub fn active_count(&self) -> usize {
|
||||
self.tunnels.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Real ssh2 backend: binds a loopback listener, authenticates to the SSH
|
||||
/// host over a blocking socket, and pumps data between the local client and
|
||||
/// the remote DB over an SSH direct-tcpip channel.
|
||||
///
|
||||
/// The whole `open` runs inside `tokio::task::spawn_blocking` at the call
|
||||
/// sites because `ssh2::Session` is purely blocking.
|
||||
pub struct Ssh2Backend;
|
||||
|
||||
impl TunnelBackend for Ssh2Backend {
|
||||
fn open(
|
||||
&self,
|
||||
_key: &str,
|
||||
cfg: &SshConfig,
|
||||
remote_host: &str,
|
||||
remote_port: u16,
|
||||
password: Option<&str>,
|
||||
passphrase: Option<&str>,
|
||||
) -> Result<Tunnel, String> {
|
||||
use ssh2::Session;
|
||||
|
||||
// Loopback-only listener with an ephemeral port.
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0")
|
||||
.map_err(|e| format!("bind local tunnel port: {e}"))?;
|
||||
let local_port = listener
|
||||
.local_addr()
|
||||
.map_err(|e| format!("local tunnel address: {e}"))?
|
||||
.port();
|
||||
|
||||
let tcp = std::net::TcpStream::connect((cfg.host.as_str(), cfg.port))
|
||||
.map_err(|e| format!("connect ssh host: {e}"))?;
|
||||
let mut session = Session::new().map_err(|e| format!("ssh session: {e}"))?;
|
||||
session.set_tcp_stream(tcp);
|
||||
session
|
||||
.handshake()
|
||||
.map_err(|e| format!("ssh handshake: {e}"))?;
|
||||
|
||||
match cfg.auth_method.as_str() {
|
||||
"key" => {
|
||||
let path = cfg
|
||||
.private_key_path
|
||||
.as_deref()
|
||||
.ok_or_else(|| "private_key_path required for key auth".to_string())?;
|
||||
session
|
||||
.userauth_pubkey_file(&cfg.user, None, std::path::Path::new(path), passphrase)
|
||||
.map_err(|e| format!("ssh key auth: {e}"))?;
|
||||
}
|
||||
_ => session
|
||||
.userauth_password(&cfg.user, password.unwrap_or(""))
|
||||
.map_err(|e| format!("ssh password auth: {e}"))?,
|
||||
}
|
||||
if !session.authenticated() {
|
||||
return Err("SSH authentication failed".into());
|
||||
}
|
||||
|
||||
let remote_host = remote_host.to_string();
|
||||
let session = Arc::new(std::sync::Mutex::new(session));
|
||||
let (closer_tx, closer_rx) = std::sync::mpsc::channel::<()>();
|
||||
std::thread::spawn(move || {
|
||||
if let Ok((mut local, _)) = listener.accept() {
|
||||
// Open the direct-tcpip channel to the remote DB. `Channel` is
|
||||
// cloneable (Arc-shared inner), so one clone per direction
|
||||
// lets two pump threads copy data in parallel.
|
||||
let mut channel = match session.lock().unwrap().channel_direct_tcpip(
|
||||
&remote_host,
|
||||
remote_port as u16,
|
||||
None,
|
||||
) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
// The accepted socket stays owned by this thread; when the
|
||||
// tunnel is closed the closer wakes us, we drop `local` and
|
||||
// the pumps end on EOF/broken pipe.
|
||||
let mut upstream = channel.clone();
|
||||
let down = local.try_clone();
|
||||
let pump = match down {
|
||||
Ok(down) => Some(std::thread::spawn(move || {
|
||||
let mut down = down;
|
||||
// client -> remote DB
|
||||
let _ = std::io::copy(&mut down, &mut upstream);
|
||||
})),
|
||||
Err(_) => None,
|
||||
};
|
||||
// remote DB -> client (this thread)
|
||||
let _ = std::io::copy(&mut channel, &mut local);
|
||||
if let Some(p) = pump {
|
||||
let _ = p.join();
|
||||
}
|
||||
}
|
||||
let _ = closer_rx.recv();
|
||||
});
|
||||
Ok(Tunnel {
|
||||
local_port,
|
||||
closer: Some(Box::new(move || {
|
||||
let _ = closer_tx.send(());
|
||||
})),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// SshConfig validation
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn ssh_config_validation() {
|
||||
// Invalid: empty host
|
||||
let config = SshConfig::new(
|
||||
"".to_string(),
|
||||
22,
|
||||
"user".to_string(),
|
||||
"password".to_string(),
|
||||
);
|
||||
assert!(!config.is_valid(), "empty host should be invalid");
|
||||
|
||||
// Invalid: empty user
|
||||
let config = SshConfig::new(
|
||||
"host.example.com".to_string(),
|
||||
22,
|
||||
"".to_string(),
|
||||
"password".to_string(),
|
||||
);
|
||||
assert!(!config.is_valid(), "empty user should be invalid");
|
||||
|
||||
// Valid: all required fields present
|
||||
let config = SshConfig::new(
|
||||
"host.example.com".to_string(),
|
||||
2222,
|
||||
"tunnel".to_string(),
|
||||
"key".to_string(),
|
||||
);
|
||||
assert!(config.is_valid(), "valid config should be accepted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_config_rejects_non_standard_ports() {
|
||||
// Port 0 is invalid
|
||||
let config = SshConfig::new(
|
||||
"host.example.com".to_string(),
|
||||
0,
|
||||
"user".to_string(),
|
||||
"password".to_string(),
|
||||
);
|
||||
assert!(!config.is_valid(), "port 0 should be invalid");
|
||||
|
||||
// Port 1 is valid (boundary)
|
||||
let config = SshConfig::new(
|
||||
"host.example.com".to_string(),
|
||||
1,
|
||||
"user".to_string(),
|
||||
"password".to_string(),
|
||||
);
|
||||
assert!(config.is_valid(), "port 1 should be valid");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Tunnel manager tests (fake backend)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct FakeBackend {
|
||||
opens: std::sync::Mutex<Vec<String>>,
|
||||
next_port: u16,
|
||||
}
|
||||
|
||||
impl Clone for FakeBackend {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
opens: std::sync::Mutex::new(self.opens.lock().unwrap().clone()),
|
||||
next_port: self.next_port,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TunnelBackend for FakeBackend {
|
||||
fn open(
|
||||
&self,
|
||||
key: &str,
|
||||
_cfg: &crate::models::SshConfig,
|
||||
_remote_host: &str,
|
||||
_remote_port: u16,
|
||||
_pw: Option<&str>,
|
||||
_pp: Option<&str>,
|
||||
) -> Result<Tunnel, String> {
|
||||
self.opens.lock().unwrap().push(key.to_string());
|
||||
let p = self.next_port;
|
||||
Ok(Tunnel::fake(p))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_open_and_get_port() {
|
||||
let backend = Arc::new(FakeBackend {
|
||||
next_port: 22222,
|
||||
..Default::default()
|
||||
});
|
||||
let mut mgr = SshTunnelManager::new(backend.clone());
|
||||
let cfg = crate::models::SshConfig::new("h".into(), 22, "u".into(), "password".into());
|
||||
let port = mgr
|
||||
.open_tunnel("c1", &cfg, "db.host", 5432, None, None)
|
||||
.unwrap();
|
||||
assert_eq!(port, 22222);
|
||||
assert_eq!(mgr.get_local_port("c1"), Some(22222));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_invalid_config_errors() {
|
||||
let backend = Arc::new(FakeBackend::default());
|
||||
let mut mgr = SshTunnelManager::new(backend);
|
||||
let cfg = crate::models::SshConfig::new("".into(), 22, "u".into(), "password".into());
|
||||
assert!(mgr
|
||||
.open_tunnel("c1", &cfg, "db.host", 5432, None, None)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_close_removes_tunnel() {
|
||||
let backend = Arc::new(FakeBackend {
|
||||
next_port: 1,
|
||||
..Default::default()
|
||||
});
|
||||
let mut mgr = SshTunnelManager::new(backend);
|
||||
let cfg = crate::models::SshConfig::new("h".into(), 22, "u".into(), "password".into());
|
||||
mgr.open_tunnel("c1", &cfg, "db.host", 5432, None, None)
|
||||
.unwrap();
|
||||
mgr.close_tunnel("c1");
|
||||
assert_eq!(mgr.get_local_port("c1"), None);
|
||||
assert_eq!(mgr.active_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tunneled_tls_is_downgraded_to_require() {
|
||||
// verify-full through a tunnel degrades to encrypt-only `require`
|
||||
assert_eq!(
|
||||
effective_tls_decision(crate::db::tls::tls_decision(Some("verify-full")), true),
|
||||
crate::db::tls::TlsDecision::Require
|
||||
);
|
||||
// direct (non-tunneled) connection keeps the user's mode
|
||||
assert_eq!(
|
||||
effective_tls_decision(crate::db::tls::tls_decision(Some("verify-full")), false),
|
||||
crate::db::tls::TlsDecision::Verify
|
||||
);
|
||||
// disable stays disabled regardless of tunneling
|
||||
assert_eq!(
|
||||
effective_tls_decision(crate::db::tls::tls_decision(Some("disable")), true),
|
||||
crate::db::tls::TlsDecision::Disable
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_backend_clone_returns_backend() {
|
||||
let backend = Arc::new(FakeBackend {
|
||||
next_port: 7,
|
||||
..Default::default()
|
||||
});
|
||||
let mgr = SshTunnelManager::new(backend.clone());
|
||||
// The cloned Arc points at the same fake backend.
|
||||
let cloned = mgr.backend_clone();
|
||||
let cfg = crate::models::SshConfig::new("h".into(), 22, "u".into(), "password".into());
|
||||
let tunnel = cloned
|
||||
.open("c1", &cfg, "db.host", 5432, None, None)
|
||||
.unwrap();
|
||||
assert_eq!(tunnel.local_port, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_insert_tunnel_replaces_and_closes_old() {
|
||||
let mut mgr = SshTunnelManager::new(Arc::new(FakeBackend::default()));
|
||||
mgr.insert_tunnel("c1".to_string(), Tunnel::fake(1111));
|
||||
assert_eq!(mgr.get_local_port("c1"), Some(1111));
|
||||
// Re-inserting under the same key replaces the old tunnel.
|
||||
mgr.insert_tunnel("c1".to_string(), Tunnel::fake(2222));
|
||||
assert_eq!(mgr.get_local_port("c1"), Some(2222));
|
||||
assert_eq!(mgr.active_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_close_all() {
|
||||
let backend = Arc::new(FakeBackend::default());
|
||||
let mut mgr = SshTunnelManager::new(backend);
|
||||
let cfg = crate::models::SshConfig::new("h".into(), 22, "u".into(), "password".into());
|
||||
mgr.open_tunnel("a", &cfg, "db", 5432, None, None).ok();
|
||||
mgr.open_tunnel("b", &cfg, "db", 5432, None, None).ok();
|
||||
mgr.close_all();
|
||||
assert_eq!(mgr.active_count(), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
use crate::models::{Tag, TagInput};
|
||||
use crate::store::Store;
|
||||
use std::sync::Mutex;
|
||||
|
||||
fn validate(input: &TagInput) -> Result<(), String> {
|
||||
if input.name.is_empty() || input.name.chars().count() > 50 {
|
||||
return Err("name is required and must be 50 chars or fewer".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_tags_inner(state: &Mutex<Store>) -> Result<Vec<Tag>, String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.get_tags()
|
||||
}
|
||||
|
||||
pub fn create_tag_inner(state: &Mutex<Store>, input: TagInput) -> Result<Tag, String> {
|
||||
validate(&input)?;
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.create_tag(input)
|
||||
}
|
||||
|
||||
pub fn delete_tag_inner(state: &Mutex<Store>, id: &str) -> Result<(), String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.delete_tag(id)
|
||||
}
|
||||
|
||||
pub fn update_tag_inner(state: &Mutex<Store>, id: String, input: TagInput) -> Result<Tag, String> {
|
||||
validate(&input)?;
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.update_tag(&id, input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_tags(state: tauri::State<crate::AppState>) -> Result<Vec<Tag>, String> {
|
||||
get_tags_inner(&state.db_store)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_tag(state: tauri::State<crate::AppState>, input: TagInput) -> Result<Tag, String> {
|
||||
create_tag_inner(&state.db_store, input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_tag(state: tauri::State<crate::AppState>, id: String) -> Result<(), String> {
|
||||
delete_tag_inner(&state.db_store, &id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_tag(
|
||||
state: tauri::State<crate::AppState>,
|
||||
id: String,
|
||||
input: TagInput,
|
||||
) -> Result<Tag, String> {
|
||||
update_tag_inner(&state.db_store, id, input)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::TagInput;
|
||||
use crate::store::Store;
|
||||
|
||||
fn state() -> std::sync::Mutex<Store> {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
crate::store::migrations::run_migrations(&conn).unwrap();
|
||||
std::sync::Mutex::new(Store::from_connection(conn))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_tag_command_works() {
|
||||
let st = state();
|
||||
let tag = create_tag_inner(
|
||||
&st,
|
||||
TagInput {
|
||||
name: "prod".into(),
|
||||
color: "#ef4444".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(get_tags_inner(&st).unwrap().len(), 1);
|
||||
assert_eq!(tag.name, "prod");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_tag_rejects_long_name() {
|
||||
let st = state();
|
||||
let result = create_tag_inner(
|
||||
&st,
|
||||
TagInput {
|
||||
name: "x".repeat(51),
|
||||
color: "#fff".into(),
|
||||
},
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,726 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
use crate::commands::ssh::SshTunnelManager;
|
||||
use crate::db::pool::DbConfig;
|
||||
|
||||
/// The SSH tunnel manager behind a mutex (as stored in `AppState`).
|
||||
type SshManager = std::sync::Mutex<SshTunnelManager>;
|
||||
|
||||
/// Result of a test database connection attempt.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TestConnectionResult {
|
||||
pub ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub server_version: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub latency_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// Strip credentials and sensitive information from error messages while
|
||||
/// preserving the useful diagnostic detail (severity, message, SQLSTATE).
|
||||
///
|
||||
/// Redacts `password=...`, `user=...`, `postgresql://user:pwd@host` URLs,
|
||||
/// and `@host` credential fragments rather than discarding the whole
|
||||
/// message — so the user can still see e.g. "password authentication
|
||||
/// failed for user 'foo'" without leaking the password itself.
|
||||
pub fn sanitize_error(msg: &str) -> String {
|
||||
let mut out = String::with_capacity(msg.len());
|
||||
let bytes = msg.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
let lower = msg[i..].to_lowercase();
|
||||
if let Some(scheme_end) = url_scheme_end(msg, i) {
|
||||
out.push_str("[redacted-url://");
|
||||
let rest = &msg[scheme_end..];
|
||||
let end = match rest.find(['/', '?']) {
|
||||
Some(pos) => scheme_end + pos,
|
||||
None => msg.len(),
|
||||
};
|
||||
i = end;
|
||||
} else if lower.starts_with("password=") {
|
||||
out.push_str("[redacted]");
|
||||
let rest = &msg[i + "password=".len()..];
|
||||
let skip = rest.find(char::is_whitespace).unwrap_or(rest.len());
|
||||
i += "password=".len() + skip;
|
||||
} else if lower.starts_with("user=") {
|
||||
out.push_str("[redacted]");
|
||||
let rest = &msg[i + "user=".len()..];
|
||||
let skip = rest.find(char::is_whitespace).unwrap_or(rest.len());
|
||||
i += "user=".len() + skip;
|
||||
} else if lower.starts_with("secret") {
|
||||
out.push_str("secret=[redacted]");
|
||||
let rest = &msg[i + "secret".len()..];
|
||||
let skip = rest.find(char::is_whitespace).unwrap_or(rest.len());
|
||||
i += "secret".len() + skip;
|
||||
} else {
|
||||
let ch = msg[i..].chars().next().unwrap();
|
||||
out.push(ch);
|
||||
i += ch.len_utf8();
|
||||
}
|
||||
}
|
||||
// Truncate at 300 characters for safety.
|
||||
if out.len() > 300 {
|
||||
format!("{}...", &out[..297])
|
||||
} else {
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// If `msg[i..]` begins a `scheme://authority` URL — a 1-16 char scheme of
|
||||
/// alphanumerics/`+`/`-`/`.` preceded by a non-word boundary — return the byte
|
||||
/// index just past the `://`. This redacts embedded credentials for any scheme
|
||||
/// (`postgres://`, `redis://`, `mysql://`, ...) without matching non-URL text.
|
||||
fn url_scheme_end(msg: &str, i: usize) -> Option<usize> {
|
||||
let rest = &msg[i..];
|
||||
let colon = rest.find("://")?;
|
||||
if colon == 0 || colon > 16 {
|
||||
return None;
|
||||
}
|
||||
let scheme = &rest[..colon];
|
||||
if !scheme
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || "+-.".contains(c))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// Require a boundary before the scheme so mid-word text is not a URL.
|
||||
if let Some(c) = msg[..i].chars().next_back() {
|
||||
if c.is_ascii_alphanumeric() || c == '_' {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(i + colon + 3)
|
||||
}
|
||||
|
||||
/// Validate `DbConfig` before attempting a connection test.
|
||||
///
|
||||
/// Returns `Some(error_message)` if the config is invalid, or `None` if valid.
|
||||
///
|
||||
/// Validation rules:
|
||||
/// - `db_type` must be one of: `postgresql`, `mysql`, `sqlite`, `redis`
|
||||
/// - For `postgresql`, `mysql`, `redis`: `host` must not be empty, `port` must
|
||||
/// be `Some(1..=65535)`
|
||||
/// - For `sqlite`: `host` (file path) must not be empty
|
||||
pub fn validate_test_input(config: &DbConfig) -> Option<String> {
|
||||
let db_type = config.db_type.to_lowercase();
|
||||
|
||||
let valid_types = ["postgresql", "mysql", "sqlite", "redis"];
|
||||
if !valid_types.contains(&db_type.as_str()) {
|
||||
return Some(format!(
|
||||
"unsupported database type: {}. Supported types: {}",
|
||||
config.db_type,
|
||||
valid_types.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
if config.host.is_empty() {
|
||||
return Some("host must not be empty".to_string());
|
||||
}
|
||||
|
||||
// SQLite does not require a port (host is the file path)
|
||||
if db_type != "sqlite" {
|
||||
match config.port {
|
||||
Some(p) if (1..=65535).contains(&p) => {}
|
||||
_ => {
|
||||
return Some(
|
||||
"port must be an integer between 1 and 65535 for this db_type".to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Effective connect target after optional SSH tunnel resolution.
|
||||
struct ConnectTarget {
|
||||
host: String,
|
||||
port: u16,
|
||||
via_tunnel: bool,
|
||||
/// Key of the opened tunnel, if any — must be closed after the probe.
|
||||
tunnel_key: Option<String>,
|
||||
}
|
||||
|
||||
/// Open an SSH tunnel if `config` has one configured, returning the loopback
|
||||
/// target to connect through. The blocking ssh2 handshake runs in
|
||||
/// `spawn_blocking` so it never blocks the async runtime.
|
||||
///
|
||||
/// The caller must close the tunnel (via `tunnel_key`) after the probe, on
|
||||
/// every path.
|
||||
async fn resolve_connect_target(
|
||||
config: &DbConfig,
|
||||
ssh: &SshManager,
|
||||
default_port: u16,
|
||||
) -> Result<ConnectTarget, String> {
|
||||
match config.ssh_config() {
|
||||
Some(ssh_cfg) => {
|
||||
let key = format!("test-{}", uuid::Uuid::new_v4());
|
||||
let open_key = key.clone();
|
||||
let remote_host = config.host.clone();
|
||||
let remote_port = config.port.unwrap_or(default_port as i64) as u16;
|
||||
let pw = config.ssh_password.clone();
|
||||
let pp = config.ssh_passphrase.clone();
|
||||
let backend = ssh.lock().unwrap().backend_clone();
|
||||
let tunnel = tokio::task::spawn_blocking(move || {
|
||||
backend.open(
|
||||
&open_key,
|
||||
&ssh_cfg,
|
||||
&remote_host,
|
||||
remote_port,
|
||||
pw.as_deref(),
|
||||
pp.as_deref(),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
let lp = tunnel.local_port;
|
||||
ssh.lock().unwrap().insert_tunnel(key.clone(), tunnel);
|
||||
Ok(ConnectTarget {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: lp,
|
||||
via_tunnel: true,
|
||||
tunnel_key: Some(key),
|
||||
})
|
||||
}
|
||||
None => Ok(ConnectTarget {
|
||||
host: config.host.clone(),
|
||||
port: config.port.unwrap_or(default_port as i64) as u16,
|
||||
via_tunnel: false,
|
||||
tunnel_key: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the probe tunnel if one was opened.
|
||||
fn close_probe_tunnel(ssh: &SshManager, key: Option<&str>) {
|
||||
if let Some(k) = key {
|
||||
ssh.lock().unwrap().close_tunnel(k);
|
||||
}
|
||||
}
|
||||
|
||||
/// Test a database connection for the given configuration.
|
||||
///
|
||||
/// Dispatches to the appropriate type-specific connection test based on
|
||||
/// `config.db_type`. Returns a `TestConnectionResult` indicating success
|
||||
/// or failure with a sanitized error message.
|
||||
pub async fn test_database_connection(config: &DbConfig, ssh: &SshManager) -> TestConnectionResult {
|
||||
// Validate input first
|
||||
if let Some(err) = validate_test_input(config) {
|
||||
return TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some(err),
|
||||
server_version: None,
|
||||
latency_ms: None,
|
||||
};
|
||||
}
|
||||
|
||||
let result = match config.db_type.to_lowercase().as_str() {
|
||||
"postgresql" => test_pg_connection(config, ssh).await,
|
||||
"mysql" => test_mysql_connection(config, ssh).await,
|
||||
"sqlite" => test_sqlite_connection(config),
|
||||
"redis" => test_redis_connection(config, ssh).await,
|
||||
other => TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some(format!("unsupported database type: {other}")),
|
||||
server_version: None,
|
||||
latency_ms: None,
|
||||
},
|
||||
};
|
||||
|
||||
TestConnectionResult {
|
||||
ok: result.ok,
|
||||
error: result.error.map(|e| sanitize_error(&e)),
|
||||
server_version: result.server_version,
|
||||
latency_ms: result.latency_ms,
|
||||
}
|
||||
}
|
||||
|
||||
/// Test a PostgreSQL connection using `tokio-postgres`.
|
||||
///
|
||||
/// Connects through an SSH tunnel when configured, with TLS selected from
|
||||
/// `ssl_mode` (downgraded to encrypt-only through a tunnel). The connection
|
||||
/// handler is spawned and immediately dropped after confirming the
|
||||
/// connection is alive; any probe tunnel is closed in every path.
|
||||
async fn test_pg_connection(config: &DbConfig, ssh: &SshManager) -> TestConnectionResult {
|
||||
let user = config.username.as_deref().unwrap_or("postgres");
|
||||
let dbname = config.database.as_deref().unwrap_or("postgres");
|
||||
let password = config.password.as_deref().unwrap_or("");
|
||||
|
||||
let target = match resolve_connect_target(config, ssh, 5432).await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
return TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some(e),
|
||||
server_version: None,
|
||||
latency_ms: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
|
||||
// degrade to encrypt-only `require`. Direct connections honor the mode.
|
||||
let decision = crate::commands::ssh::effective_tls_decision(
|
||||
crate::db::tls::tls_decision(config.ssl_mode.as_deref()),
|
||||
target.via_tunnel,
|
||||
);
|
||||
let tls = match crate::db::tls::build_tls_config(
|
||||
decision,
|
||||
config.ssl_ca_path.as_deref(),
|
||||
config.ssl_cert_path.as_deref(),
|
||||
config.ssl_key_path.as_deref(),
|
||||
) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
||||
return TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some(e),
|
||||
server_version: None,
|
||||
latency_ms: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Config builder: user/password/dbname are sent as-is (no URL
|
||||
// percent-encoding needed), and the TLS connector is chosen explicitly.
|
||||
let mut pgconfig = tokio_postgres::Config::new();
|
||||
pgconfig
|
||||
.host(target.host)
|
||||
.port(target.port)
|
||||
.user(user)
|
||||
.password(password)
|
||||
.dbname(dbname)
|
||||
.connect_timeout(std::time::Duration::from_secs(10));
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let result = match tls {
|
||||
None => crate::commands::db_viewer::connect_pg_with(&pgconfig, tokio_postgres::NoTls).await,
|
||||
Some(cc) => {
|
||||
let connector = tokio_postgres_rustls::MakeRustlsConnect::new((*cc).clone());
|
||||
crate::commands::db_viewer::connect_pg_with(&pgconfig, connector).await
|
||||
}
|
||||
};
|
||||
let latency_ms = Some(start.elapsed().as_millis() as u64);
|
||||
|
||||
match result {
|
||||
Ok((client, _handle)) => {
|
||||
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
||||
// Best-effort server version from the live client; None if the
|
||||
// query fails. The driver task is already spawned inside
|
||||
// `connect_pg_with`, so the client is fully usable here.
|
||||
let server_version = client
|
||||
.query_one("SELECT current_setting('server_version')", &[])
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|row| row.try_get::<_, String>(0).ok());
|
||||
TestConnectionResult {
|
||||
ok: true,
|
||||
error: None,
|
||||
server_version,
|
||||
latency_ms,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
||||
TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some(e.to_string()),
|
||||
server_version: None,
|
||||
latency_ms: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Test a MySQL connection using `sqlx`.
|
||||
///
|
||||
/// Uses `MySqlPoolOptions` with a pool size of 1 and a 10-second
|
||||
/// `acquire_timeout`, connecting through an SSH tunnel when configured and
|
||||
/// mapping `ssl_mode` onto `MySqlSslMode` (downgraded to encrypt-only
|
||||
/// through a tunnel). Any probe tunnel is closed in every path.
|
||||
async fn test_mysql_connection(config: &DbConfig, ssh: &SshManager) -> TestConnectionResult {
|
||||
use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions, MySqlSslMode};
|
||||
|
||||
let target = match resolve_connect_target(config, ssh, 3306).await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
return TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some(e),
|
||||
server_version: None,
|
||||
latency_ms: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut opts = MySqlConnectOptions::new()
|
||||
.host(&target.host)
|
||||
.port(target.port)
|
||||
.username(config.username.as_deref().unwrap_or("root"))
|
||||
.password(config.password.as_deref().unwrap_or(""))
|
||||
.database(config.database.as_deref().unwrap_or("mysql"));
|
||||
|
||||
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
|
||||
// degrade to encrypt-only `require`. Direct connections honor the mode.
|
||||
let decision = crate::commands::ssh::effective_tls_decision(
|
||||
crate::db::tls::tls_decision(config.ssl_mode.as_deref()),
|
||||
target.via_tunnel,
|
||||
);
|
||||
match decision {
|
||||
crate::db::tls::TlsDecision::Disable => {
|
||||
opts = opts.ssl_mode(MySqlSslMode::Disabled);
|
||||
}
|
||||
crate::db::tls::TlsDecision::Require => {
|
||||
opts = opts.ssl_mode(MySqlSslMode::Required);
|
||||
}
|
||||
crate::db::tls::TlsDecision::Verify => {
|
||||
// sqlx 0.8 has no VerifyFull: verify-ca -> VerifyCa (chain only),
|
||||
// verify-full -> VerifyIdentity (chain + hostname).
|
||||
match config.ssl_mode.as_deref() {
|
||||
Some("verify-ca") => opts = opts.ssl_mode(MySqlSslMode::VerifyCa),
|
||||
_ => opts = opts.ssl_mode(MySqlSslMode::VerifyIdentity),
|
||||
}
|
||||
if let Some(ca) = config.ssl_ca_path.as_deref() {
|
||||
opts = opts.ssl_ca(ca);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
match MySqlPoolOptions::new()
|
||||
.max_connections(1)
|
||||
.acquire_timeout(std::time::Duration::from_secs(10))
|
||||
.connect_with(opts)
|
||||
.await
|
||||
{
|
||||
Ok(pool) => {
|
||||
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
||||
// Best-effort server version; None if the query fails.
|
||||
let server_version = sqlx::query_scalar::<_, String>("SELECT VERSION()")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.ok();
|
||||
let latency_ms = Some(start.elapsed().as_millis() as u64);
|
||||
pool.close().await;
|
||||
TestConnectionResult {
|
||||
ok: true,
|
||||
error: None,
|
||||
server_version,
|
||||
latency_ms,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
||||
TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some(e.to_string()),
|
||||
server_version: None,
|
||||
latency_ms: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Test a SQLite connection using `rusqlite`.
|
||||
///
|
||||
/// Opens the database file at `config.host`. Returns success if the file
|
||||
/// can be opened as a valid SQLite database.
|
||||
fn test_sqlite_connection(config: &DbConfig) -> TestConnectionResult {
|
||||
let start = std::time::Instant::now();
|
||||
match rusqlite::Connection::open(&config.host) {
|
||||
Ok(conn) => {
|
||||
// Best-effort server version; None if the query fails.
|
||||
let server_version = conn
|
||||
.query_row("SELECT sqlite_version()", [], |r| r.get::<_, String>(0))
|
||||
.ok();
|
||||
TestConnectionResult {
|
||||
ok: true,
|
||||
error: None,
|
||||
server_version,
|
||||
latency_ms: Some(start.elapsed().as_millis() as u64),
|
||||
}
|
||||
}
|
||||
Err(e) => TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some(e.to_string()),
|
||||
server_version: None,
|
||||
latency_ms: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Test a Redis connection using the `redis` crate.
|
||||
///
|
||||
/// Uses `redis::Client::open` followed by `get_async_connection` with a
|
||||
/// 10-second timeout via `tokio::time::timeout`. Connects through an SSH
|
||||
/// tunnel when configured; any probe tunnel is closed in every path.
|
||||
async fn test_redis_connection(config: &DbConfig, ssh: &SshManager) -> TestConnectionResult {
|
||||
use tokio::time::timeout;
|
||||
|
||||
let target = match resolve_connect_target(config, ssh, 6379).await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
return TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some(e),
|
||||
server_version: None,
|
||||
latency_ms: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
let password = config.password.as_deref();
|
||||
|
||||
let conn_str = if let Some(pwd) = password {
|
||||
format!("redis://:{}@{}:{}/", pwd, target.host, target.port)
|
||||
} else {
|
||||
format!("redis://{}:{}/", target.host, target.port)
|
||||
};
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
match redis::Client::open(conn_str.as_str()) {
|
||||
Ok(client) => {
|
||||
match timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
client.get_multiplexed_async_connection(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(_conn)) => {
|
||||
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
||||
TestConnectionResult {
|
||||
ok: true,
|
||||
error: None,
|
||||
server_version: None,
|
||||
latency_ms: Some(start.elapsed().as_millis() as u64),
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
||||
TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some(e.to_string()),
|
||||
server_version: None,
|
||||
latency_ms: None,
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
||||
TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some("connection timed out after 10 seconds".to_string()),
|
||||
server_version: None,
|
||||
latency_ms: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
||||
TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some(e.to_string()),
|
||||
server_version: None,
|
||||
latency_ms: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tauri command to test a database connection.
|
||||
///
|
||||
/// Calls `test_database_connection` and returns the result. `state` is
|
||||
/// auto-injected; the frontend only passes `config`.
|
||||
#[tauri::command]
|
||||
pub async fn test_connection(
|
||||
config: DbConfig,
|
||||
state: State<'_, crate::AppState>,
|
||||
) -> Result<TestConnectionResult, String> {
|
||||
Ok(test_database_connection(&config, &state.ssh_manager).await)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TestConnectionResult serialization
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_connection_result_serialization() {
|
||||
// ok=true result with server_version/latency serializes all fields
|
||||
let result = TestConnectionResult {
|
||||
ok: true,
|
||||
error: None,
|
||||
server_version: Some("15.2".to_string()),
|
||||
latency_ms: Some(12),
|
||||
};
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
assert!(
|
||||
json.contains("\"ok\":true"),
|
||||
"ok=true should appear in JSON: {json}"
|
||||
);
|
||||
assert!(
|
||||
json.contains("\"server_version\":\"15.2\""),
|
||||
"server_version should appear in JSON: {json}"
|
||||
);
|
||||
assert!(
|
||||
json.contains("\"latency_ms\":12"),
|
||||
"latency_ms should appear in JSON: {json}"
|
||||
);
|
||||
|
||||
// error result includes the error message; None fields are skipped
|
||||
let result = TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some("connection refused".to_string()),
|
||||
server_version: None,
|
||||
latency_ms: None,
|
||||
};
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
assert!(
|
||||
json.contains("\"connection refused\""),
|
||||
"error message should appear in JSON: {json}"
|
||||
);
|
||||
assert!(
|
||||
!json.contains("server_version"),
|
||||
"None server_version should be skipped: {json}"
|
||||
);
|
||||
assert!(
|
||||
!json.contains("latency_ms"),
|
||||
"None latency_ms should be skipped: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// sanitize_error
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_connection_sanitizes_error() {
|
||||
let msg = "connection failed: password=secret123 user=admin";
|
||||
let sanitized = sanitize_error(msg);
|
||||
assert!(
|
||||
!sanitized.contains("secret123"),
|
||||
"should not leak password value"
|
||||
);
|
||||
assert!(
|
||||
!sanitized.contains("admin"),
|
||||
"should not leak username value"
|
||||
);
|
||||
assert!(
|
||||
!sanitized.contains("password="),
|
||||
"should remove password= pattern"
|
||||
);
|
||||
assert!(!sanitized.contains("user="), "should remove user= pattern");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_error_redacts_tunnel_style_urls() {
|
||||
// Tunnel connect errors can carry a URL with embedded credentials, e.g.
|
||||
// the redis:// string built for SSH-tunneled connections.
|
||||
let msg = "SSH tunnel connect failed: redis://:hunter2@127.0.0.1:6379/";
|
||||
let sanitized = sanitize_error(msg);
|
||||
assert!(
|
||||
!sanitized.contains("hunter2"),
|
||||
"must redact URL password: {sanitized}"
|
||||
);
|
||||
assert!(
|
||||
sanitized.contains("SSH tunnel connect failed"),
|
||||
"must keep the diagnostic prefix: {sanitized}"
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// validate_test_input rejection
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn validate_test_input_rejects_invalid() {
|
||||
// Unsupported db type
|
||||
let config = DbConfig {
|
||||
db_type: "mongodb".to_string(),
|
||||
host: "localhost".to_string(),
|
||||
port: Some(27017),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
validate_test_input(&config).is_some(),
|
||||
"mongodb should be rejected"
|
||||
);
|
||||
|
||||
// Empty host
|
||||
let config = DbConfig {
|
||||
db_type: "postgresql".to_string(),
|
||||
host: "".to_string(),
|
||||
port: Some(5432),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
validate_test_input(&config).is_some(),
|
||||
"empty host should be rejected"
|
||||
);
|
||||
|
||||
// Port 0
|
||||
let config = DbConfig {
|
||||
db_type: "postgresql".to_string(),
|
||||
host: "localhost".to_string(),
|
||||
port: Some(0),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
validate_test_input(&config).is_some(),
|
||||
"port 0 should be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// validate_test_input acceptance
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn validate_test_input_accepts_valid() {
|
||||
let config = DbConfig {
|
||||
db_type: "postgresql".to_string(),
|
||||
host: "localhost".to_string(),
|
||||
port: Some(5432),
|
||||
username: Some("user".to_string()),
|
||||
database: Some("mydb".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
validate_test_input(&config).is_none(),
|
||||
"valid postgresql config should be accepted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_accepts_no_port() {
|
||||
// SQLite does not require a port
|
||||
let config = DbConfig {
|
||||
db_type: "sqlite".to_string(),
|
||||
host: "/tmp/test.db".to_string(),
|
||||
port: None,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
validate_test_input(&config).is_none(),
|
||||
"sqlite without port should be accepted"
|
||||
);
|
||||
|
||||
// SQLite should also accept a config with any port (port is ignored)
|
||||
let config = DbConfig {
|
||||
db_type: "sqlite".to_string(),
|
||||
host: "/tmp/test.db".to_string(),
|
||||
port: Some(9999),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
validate_test_input(&config).is_none(),
|
||||
"sqlite with any port should be accepted"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,889 @@
|
||||
//! Schema introspection query builders.
|
||||
//!
|
||||
//! This module provides pure functions that generate SQL query strings
|
||||
//! for database schema introspection. No actual DB connections are needed
|
||||
//! for testing — all functions are deterministic string builders.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PostgreSQL
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Approximate row count for a table via `pg_class.reltuples`.
|
||||
pub fn pg_reltuples_query(schema: &str, table: &str) -> String {
|
||||
format!(
|
||||
"SELECT reltuples::bigint AS count FROM pg_class \
|
||||
WHERE relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = '{}') \
|
||||
AND relname = '{}'",
|
||||
schema, table
|
||||
)
|
||||
}
|
||||
|
||||
/// List tables and views in a schema (or all non-system schemata).
|
||||
///
|
||||
/// When `schema` is `None` all schemata except the built-in system schemata
|
||||
/// (`pg_catalog`, `information_schema`) are included.
|
||||
pub fn pg_tables_query(schema: Option<&str>) -> String {
|
||||
match schema {
|
||||
Some(s) => format!(
|
||||
"SELECT table_name, table_type FROM information_schema.tables \
|
||||
WHERE table_schema = '{}' \
|
||||
UNION ALL \
|
||||
SELECT matviewname AS table_name, 'MATERIALIZED VIEW' AS table_type \
|
||||
FROM pg_matviews WHERE schemaname = '{}' \
|
||||
ORDER BY table_name",
|
||||
s, s
|
||||
),
|
||||
None => {
|
||||
"SELECT table_name, table_type, table_schema FROM information_schema.tables \
|
||||
WHERE table_schema NOT IN ('pg_catalog', 'information_schema') \
|
||||
UNION ALL \
|
||||
SELECT matviewname AS table_name, 'MATERIALIZED VIEW' AS table_type, schemaname AS table_schema \
|
||||
FROM pg_matviews WHERE schemaname NOT IN ('pg_catalog', 'information_schema') \
|
||||
ORDER BY table_schema, table_name"
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// List all non-system schemata.
|
||||
pub fn pg_schemas_query() -> String {
|
||||
"SELECT schema_name FROM information_schema.schemata \
|
||||
WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast') \
|
||||
ORDER BY schema_name"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// List non-template databases.
|
||||
pub fn pg_databases_query() -> String {
|
||||
"SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname".to_string()
|
||||
}
|
||||
|
||||
/// Column details with primary-key and foreign-key annotations.
|
||||
///
|
||||
/// Joins `information_schema.columns` with constraint metadata so that
|
||||
/// each row includes PK / FK information when applicable.
|
||||
pub fn pg_columns_query(schema: &str, table: &str) -> String {
|
||||
format!(
|
||||
r#"SELECT
|
||||
c.column_name,
|
||||
CASE WHEN c.data_type = 'USER-DEFINED' THEN c.udt_name ELSE c.data_type END AS data_type,
|
||||
c.is_nullable,
|
||||
c.character_maximum_length,
|
||||
c.numeric_precision,
|
||||
c.numeric_scale,
|
||||
c.column_default,
|
||||
c.ordinal_position,
|
||||
pk.constraint_type,
|
||||
fk.foreign_table_schema,
|
||||
fk.foreign_table_name,
|
||||
fk.foreign_column_name
|
||||
FROM information_schema.columns c
|
||||
LEFT JOIN (
|
||||
SELECT kcu.column_name, kcu.table_schema, kcu.table_name, 'PRIMARY KEY' AS constraint_type
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_catalog = kcu.constraint_catalog
|
||||
AND tc.constraint_schema = kcu.constraint_schema
|
||||
AND tc.constraint_name = kcu.constraint_name
|
||||
WHERE tc.constraint_type = 'PRIMARY KEY'
|
||||
) pk ON c.table_schema = pk.table_schema AND c.table_name = pk.table_name AND c.column_name = pk.column_name
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
kcu.column_name,
|
||||
kcu.table_schema,
|
||||
kcu.table_name,
|
||||
ccu.table_schema AS foreign_table_schema,
|
||||
ccu.table_name AS foreign_table_name,
|
||||
ccu.column_name AS foreign_column_name
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_catalog = kcu.constraint_catalog
|
||||
AND tc.constraint_schema = kcu.constraint_schema
|
||||
AND tc.constraint_name = kcu.constraint_name
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON tc.constraint_catalog = ccu.constraint_catalog
|
||||
AND tc.constraint_schema = ccu.constraint_schema
|
||||
AND tc.constraint_name = ccu.constraint_name
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||
) fk ON c.table_schema = fk.table_schema AND c.table_name = fk.table_name AND c.column_name = fk.column_name
|
||||
WHERE c.table_schema = '{}' AND c.table_name = '{}'
|
||||
ORDER BY c.ordinal_position"#,
|
||||
schema, table
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MySQL
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// List tables (and views) in the given schema.
|
||||
///
|
||||
/// When `schema` is `None` all non-system schemata are included (excluding
|
||||
/// `information_schema`, `performance_schema`, `mysql`, and `sys`).
|
||||
pub fn mysql_tables_query(schema: Option<&str>) -> String {
|
||||
match schema {
|
||||
Some(s) => format!(
|
||||
"SELECT table_name, table_type FROM information_schema.tables \
|
||||
WHERE table_schema = '{}' ORDER BY table_name",
|
||||
s
|
||||
),
|
||||
None => {
|
||||
"SELECT table_name, table_type, table_schema FROM information_schema.tables \
|
||||
WHERE table_schema NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys') \
|
||||
ORDER BY table_schema, table_name"
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SQLite
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// List tables and views from `sqlite_master`.
|
||||
pub fn sqlite_tables_query() -> String {
|
||||
"SELECT name AS table_name, type AS table_type FROM sqlite_master \
|
||||
WHERE type IN ('table', 'view') ORDER BY name"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Column metadata via `PRAGMA table_info`.
|
||||
pub fn sqlite_columns_query(table: &str) -> String {
|
||||
format!("PRAGMA table_info('{}')", table)
|
||||
}
|
||||
|
||||
/// Foreign-key metadata via `PRAGMA foreign_key_list`.
|
||||
pub fn sqlite_foreign_keys_query(table: &str) -> String {
|
||||
format!("PRAGMA foreign_key_list('{}')", table)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a paginated `SELECT` query.
|
||||
///
|
||||
/// Returns the SQL string (with `$1` / `$2` placeholders for `LIMIT` and
|
||||
/// `OFFSET`) together with a vector of the corresponding `i64` parameter
|
||||
/// values `[page_size, page * page_size]`.
|
||||
///
|
||||
/// When `columns` is empty the query uses `*`.
|
||||
pub fn build_select_query(
|
||||
schema: &str,
|
||||
table: &str,
|
||||
columns: &[String],
|
||||
page: i64,
|
||||
page_size: i64,
|
||||
) -> (String, Vec<i64>) {
|
||||
let cols = if columns.is_empty() {
|
||||
"*".to_string()
|
||||
} else {
|
||||
let mut buf = String::new();
|
||||
for (i, col) in columns.iter().enumerate() {
|
||||
if i > 0 {
|
||||
buf.push_str(", ");
|
||||
}
|
||||
buf.push('"');
|
||||
buf.push_str(col);
|
||||
buf.push('"');
|
||||
}
|
||||
buf
|
||||
};
|
||||
|
||||
let sql = format!(
|
||||
"SELECT {} FROM \"{}\".\"{}\" LIMIT $1 OFFSET $2",
|
||||
cols, schema, table
|
||||
);
|
||||
|
||||
let params = vec![page_size, page * page_size];
|
||||
|
||||
(sql, params)
|
||||
}
|
||||
|
||||
/// Build a `COUNT(*)` query.
|
||||
pub fn build_count_query(schema: &str, table: &str) -> String {
|
||||
format!("SELECT COUNT(*) FROM \"{}\".\"{}\"", schema, table)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Object introspection (functions, triggers, sequences, enums, extensions)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Query functions and procedures in a schema.
|
||||
pub fn pg_functions_query(_schema: &str) -> String {
|
||||
format!(
|
||||
"SELECT p.proname, n.nspname, \
|
||||
pg_catalog.format_type(p.prorettype, NULL) AS return_type, \
|
||||
ARRAY(SELECT unnest(p.proargtypes::regtype[]::text[])) AS arg_types, \
|
||||
ARRAY(SELECT unnest(p.proargnames::text[])) AS arg_names, \
|
||||
ARRAY(SELECT unnest(p.proargmodes::text[])) AS arg_modes, \
|
||||
l.lanname, pg_get_functiondef(p.oid) AS source, \
|
||||
p.prokind::text \
|
||||
FROM pg_proc p \
|
||||
JOIN pg_namespace n ON p.pronamespace = n.oid \
|
||||
JOIN pg_language l ON p.prolang = l.oid \
|
||||
WHERE n.nspname = $1 \
|
||||
AND p.prokind IN ('f', 'p') \
|
||||
ORDER BY p.proname"
|
||||
)
|
||||
}
|
||||
|
||||
/// Query triggers in a schema.
|
||||
pub fn pg_triggers_query(_schema: &str) -> String {
|
||||
format!(
|
||||
"SELECT t.tgname, tn.nspname AS trigger_schema, \
|
||||
cn.nspname AS table_schema, c.relname AS table_name, \
|
||||
CASE \
|
||||
WHEN t.tgtype::int2 & 4 = 4 THEN 'INSERT' \
|
||||
WHEN t.tgtype::int2 & 8 = 8 THEN 'DELETE' \
|
||||
WHEN t.tgtype::int2 & 16 = 16 THEN 'UPDATE' \
|
||||
WHEN t.tgtype::int2 & 32 = 32 THEN 'TRUNCATE' \
|
||||
ELSE 'UNKNOWN' END AS event, \
|
||||
CASE WHEN t.tgtype::int2 & 2 = 2 THEN 'BEFORE' ELSE 'AFTER' END AS timing, \
|
||||
CASE WHEN t.tgtype::int2 & 1 = 1 THEN 'ROW' ELSE 'STATEMENT' END AS orientation, \
|
||||
pg_get_triggerdef(t.oid) AS definition, \
|
||||
t.tgenabled::text \
|
||||
FROM pg_trigger t \
|
||||
JOIN pg_class c ON t.tgrelid = c.oid \
|
||||
JOIN pg_namespace cn ON c.relnamespace = cn.oid \
|
||||
CROSS JOIN LATERAL (SELECT nspname FROM pg_namespace WHERE oid = (SELECT pronamespace FROM pg_proc WHERE oid = t.tgfoid)) tn \
|
||||
WHERE cn.nspname = $1 AND NOT t.tgisinternal \
|
||||
ORDER BY t.tgname"
|
||||
)
|
||||
}
|
||||
|
||||
/// Query sequences in a schema via information_schema.
|
||||
pub fn pg_sequences_query(schema: &str) -> String {
|
||||
format!(
|
||||
"SELECT sequence_name, '{}' AS schema, \
|
||||
COALESCE(start_value::text, '1'), \
|
||||
COALESCE(minimum_value::text, '1'), \
|
||||
COALESCE(maximum_value::text, '9223372036854775807'), \
|
||||
COALESCE(increment::text, '1'), \
|
||||
COALESCE(pg_catalog.pg_sequence_last_value(sequence_name::regclass)::text, '0'), \
|
||||
COALESCE(cycle_option::text, 'NO') \
|
||||
FROM information_schema.sequences \
|
||||
WHERE sequence_schema = $1 \
|
||||
ORDER BY sequence_name",
|
||||
schema
|
||||
)
|
||||
}
|
||||
|
||||
/// Query enums in a schema.
|
||||
pub fn pg_enums_query(_schema: &str) -> String {
|
||||
format!(
|
||||
"SELECT t.typname, n.nspname, \
|
||||
ARRAY(SELECT e.enumlabel FROM pg_enum e \
|
||||
WHERE e.enumtypid = t.oid ORDER BY e.enumsortorder) AS labels \
|
||||
FROM pg_type t \
|
||||
JOIN pg_namespace n ON t.typnamespace = n.oid \
|
||||
WHERE t.typtype = 'e' AND n.nspname = $1 \
|
||||
ORDER BY t.typname"
|
||||
)
|
||||
}
|
||||
|
||||
/// Query installed extensions.
|
||||
pub fn pg_extensions_query() -> String {
|
||||
"SELECT e.extname, n.nspname, e.extversion::text, \
|
||||
pg_catalog.obj_description(e.oid, 'pg_extension') AS comment \
|
||||
FROM pg_extension e \
|
||||
JOIN pg_namespace n ON e.extnamespace = n.oid \
|
||||
ORDER BY e.extname"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Query available (installable) extensions with default version + comment.
|
||||
/// Schema-wide: `pg_available_extensions` is not schema-scoped.
|
||||
pub fn pg_available_extensions_query() -> String {
|
||||
"SELECT name, default_version::text AS version, comment \
|
||||
FROM pg_available_extensions ORDER BY name"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Query indexes in a schema.
|
||||
///
|
||||
/// Returns index name, schema, table, definition (`pg_get_indexdef`),
|
||||
/// uniqueness, access method, columns CSV, size in bytes, and tablespace.
|
||||
pub fn pg_indexes_query(_schema: &str) -> String {
|
||||
format!(
|
||||
"SELECT \
|
||||
i.relname AS index_name, \
|
||||
ns.nspname AS schema, \
|
||||
t.relname AS table_name, \
|
||||
pg_get_indexdef(ix.indexrelid) AS definition, \
|
||||
ix.indisunique AS is_unique, \
|
||||
am.amname AS method, \
|
||||
(SELECT string_agg(a.attname, ', ' ORDER BY ord.ord) \
|
||||
FROM unnest(ix.indkey) WITH ORDINALITY AS ord(attnum, ord) \
|
||||
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ord.attnum) AS columns, \
|
||||
pg_relation_size(i.oid) AS size_bytes, \
|
||||
ts.spcname AS tablespace \
|
||||
FROM pg_index ix \
|
||||
JOIN pg_class i ON i.oid = ix.indexrelid \
|
||||
JOIN pg_class t ON t.oid = ix.indrelid \
|
||||
JOIN pg_namespace ns ON t.relnamespace = ns.oid \
|
||||
JOIN pg_am am ON i.relam = am.oid \
|
||||
LEFT JOIN pg_tablespace ts ON i.reltablespace = ts.oid \
|
||||
WHERE ns.nspname = $1 \
|
||||
ORDER BY i.relname"
|
||||
)
|
||||
}
|
||||
|
||||
/// Query CHECK / UNIQUE / EXCLUSION constraints in a schema.
|
||||
///
|
||||
/// Primary and foreign keys are intentionally excluded — they surface in the
|
||||
/// table grid. Returns name, schema, table, contype, definition
|
||||
/// (`pg_get_constraintdef`), deferrability, validation, and columns CSV.
|
||||
pub fn pg_constraints_query(_schema: &str) -> String {
|
||||
format!(
|
||||
"SELECT \
|
||||
c.conname AS name, \
|
||||
ns.nspname AS schema, \
|
||||
cl.relname AS table_name, \
|
||||
c.contype::text, \
|
||||
pg_get_constraintdef(c.oid) AS definition, \
|
||||
c.condeferrable, \
|
||||
c.convalidated, \
|
||||
(SELECT string_agg(a.attname, ', ' ORDER BY ord.ord) \
|
||||
FROM unnest(c.conkey) WITH ORDINALITY AS ord(attnum, ord) \
|
||||
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ord.attnum) AS columns \
|
||||
FROM pg_constraint c \
|
||||
JOIN pg_class cl ON c.conrelid = cl.oid \
|
||||
JOIN pg_namespace ns ON cl.relnamespace = ns.oid \
|
||||
WHERE ns.nspname = $1 AND c.contype IN ('c', 'u', 'x') \
|
||||
ORDER BY c.conname"
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Roles, privileges, tablespaces, rebuild readiness
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// List non-system roles with all attributes used by the role form.
|
||||
pub fn pg_roles_query() -> String {
|
||||
"SELECT rolname, rolsuper, rolinherit, rolcreatedb, rolcreaterole, rolcanlogin, \
|
||||
rolreplication, rolbypassrls, rolconnlimit::int8, COALESCE(rolvaliduntil::text, '') AS rolvaliduntil \
|
||||
FROM pg_roles WHERE rolname !~ '^pg_' ORDER BY rolname"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// All role-to-role memberships (member/admin/grantor).
|
||||
pub fn pg_role_memberships_query() -> String {
|
||||
"SELECT roleid::regrole::text AS role, member::regrole::text AS member, \
|
||||
grantor::regrole::text AS grantor, admin_option \
|
||||
FROM pg_auth_members ORDER BY role"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Table/view/matview privileges for a grantee, grouped one row per object.
|
||||
pub fn pg_table_privileges_query(role: &str) -> String {
|
||||
format!(
|
||||
"SELECT table_schema AS schema, table_name AS name, \
|
||||
array_agg(privilege_type::text) AS privileges, \
|
||||
bool_or(is_grantable = 'YES') AS grantable \
|
||||
FROM information_schema.table_privileges \
|
||||
WHERE grantee = '{}' AND table_schema NOT IN ('pg_catalog','information_schema') \
|
||||
GROUP BY table_schema, table_name \
|
||||
ORDER BY table_schema, table_name",
|
||||
role
|
||||
)
|
||||
}
|
||||
|
||||
/// Sequence privileges for a grantee, grouped one row per sequence.
|
||||
/// aclexplode-based (role_sequence_grants was removed in PG 15).
|
||||
pub fn pg_sequence_privileges_query(role: &str) -> String {
|
||||
format!(
|
||||
"SELECT n.nspname AS schema, c.relname AS name, \
|
||||
array_agg(p.privilege_type::text) AS privileges, \
|
||||
bool_or(p.is_grantable) AS grantable \
|
||||
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace, \
|
||||
LATERAL aclexplode(c.relacl) p \
|
||||
WHERE c.relkind = 'S' AND n.nspname NOT IN ('pg_catalog','information_schema') \
|
||||
AND p.grantee = (SELECT oid FROM pg_roles WHERE rolname = '{}') \
|
||||
GROUP BY n.nspname, c.relname \
|
||||
ORDER BY n.nspname, c.relname",
|
||||
role
|
||||
)
|
||||
}
|
||||
|
||||
/// Routine (function/procedure) privileges for a grantee.
|
||||
pub fn pg_routine_privileges_query(role: &str) -> String {
|
||||
format!(
|
||||
"SELECT routine_schema AS schema, routine_name AS name, \
|
||||
array_agg(privilege_type::text) AS privileges, \
|
||||
bool_or(is_grantable = 'YES') AS grantable \
|
||||
FROM information_schema.routine_privileges \
|
||||
WHERE grantee = '{}' \
|
||||
GROUP BY routine_schema, routine_name \
|
||||
ORDER BY routine_schema, routine_name",
|
||||
role
|
||||
)
|
||||
}
|
||||
|
||||
/// Schema privileges for a grantee (USAGE/CREATE). aclexplode-based —
|
||||
/// information_schema has no schema_privileges view.
|
||||
pub fn pg_schema_privileges_query(role: &str) -> String {
|
||||
format!(
|
||||
"SELECT n.nspname AS name, \
|
||||
array_agg(p.privilege_type::text) AS privileges, \
|
||||
bool_or(p.is_grantable) AS grantable \
|
||||
FROM pg_namespace n, LATERAL aclexplode(n.nspacl) p \
|
||||
WHERE n.nspname NOT IN ('pg_catalog','information_schema') \
|
||||
AND p.grantee = (SELECT oid FROM pg_roles WHERE rolname = '{}') \
|
||||
GROUP BY n.nspname ORDER BY n.nspname",
|
||||
role
|
||||
)
|
||||
}
|
||||
|
||||
/// Database privileges for a grantee via aclexplode (broadly compatible; avoids PG15-only view).
|
||||
pub fn pg_database_privileges_query(role: &str) -> String {
|
||||
format!(
|
||||
"SELECT d.datname AS name, array_agg(a.privilege_type) AS privileges, \
|
||||
bool_or(a.is_grantable) AS grantable \
|
||||
FROM pg_database d, LATERAL aclexplode(d.datacl) a \
|
||||
WHERE a.grantee = (SELECT oid FROM pg_roles WHERE rolname = '{}') \
|
||||
AND d.datistemplate = false \
|
||||
GROUP BY d.datname \
|
||||
ORDER BY d.datname",
|
||||
role
|
||||
)
|
||||
}
|
||||
|
||||
/// Non-system tablespaces for the table-options picker.
|
||||
pub fn pg_tablespaces_query() -> String {
|
||||
"SELECT spcname FROM pg_tablespace WHERE spcname !~ '^pg_' ORDER BY spcname".to_string()
|
||||
}
|
||||
|
||||
/// Single query returning boolean blockers for a table rebuild (triggers, policies,
|
||||
/// inheritance, partitioning, generated/identity columns). Parameterized via $1/$2.
|
||||
pub fn pg_rebuild_readiness_query() -> String {
|
||||
"SELECT \
|
||||
EXISTS(SELECT 1 FROM pg_trigger t JOIN pg_class c ON t.tgrelid = c.oid \
|
||||
JOIN pg_namespace n ON c.relnamespace = n.oid \
|
||||
WHERE n.nspname = $1 AND c.relname = $2 AND NOT t.tgisinternal) AS has_triggers, \
|
||||
EXISTS(SELECT 1 FROM pg_policy p JOIN pg_class c ON p.polrelid = c.oid \
|
||||
JOIN pg_namespace n ON c.relnamespace = n.oid \
|
||||
WHERE n.nspname = $1 AND c.relname = $2) AS has_policies, \
|
||||
EXISTS(SELECT 1 FROM pg_inherits i JOIN pg_class c ON i.inhrelid = c.oid \
|
||||
JOIN pg_namespace n ON c.relnamespace = n.oid \
|
||||
WHERE n.nspname = $1 AND c.relname = $2) AS is_inherits, \
|
||||
EXISTS(SELECT 1 FROM pg_partitioned_table pt JOIN pg_class c ON pt.partrelid = c.oid \
|
||||
JOIN pg_namespace n ON c.relnamespace = n.oid \
|
||||
WHERE n.nspname = $1 AND c.relname = $2) AS is_partitioned, \
|
||||
EXISTS(SELECT 1 FROM information_schema.columns \
|
||||
WHERE table_schema = $1 AND table_name = $2 AND is_generated <> '') AS has_generated"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// FKs owned by this table (contype='f'). Parameterized $1 schema, $2 table.
|
||||
pub fn pg_table_fk_out_query() -> String {
|
||||
"SELECT c.conname, pg_get_constraintdef(c.oid) AS definition \
|
||||
FROM pg_constraint c JOIN pg_class cl ON c.conrelid = cl.oid \
|
||||
JOIN pg_namespace n ON cl.relnamespace = n.oid \
|
||||
WHERE n.nspname = $1 AND cl.relname = $2 AND c.contype = 'f'".to_string()
|
||||
}
|
||||
|
||||
/// FKs from other tables referencing this table. Parameterized $1 schema, $2 table.
|
||||
pub fn pg_table_fk_in_query() -> String {
|
||||
"SELECT c.conname, cn.nspname AS own_schema, cl.relname AS own_table, \
|
||||
pg_get_constraintdef(c.oid) AS definition \
|
||||
FROM pg_constraint c JOIN pg_class cl ON c.conrelid = cl.oid \
|
||||
JOIN pg_namespace cn ON cl.relnamespace = cn.oid \
|
||||
JOIN pg_class r ON c.confrelid = r.oid \
|
||||
JOIN pg_namespace rn ON r.relnamespace = rn.oid \
|
||||
WHERE rn.nspname = $1 AND r.relname = $2 AND c.contype = 'f'".to_string()
|
||||
}
|
||||
|
||||
/// Grants on this table (all grantees), grouped. Parameterized $1 schema, $2 table.
|
||||
pub fn pg_table_grants_query() -> String {
|
||||
"SELECT grantee, array_agg(privilege_type::text) AS privileges, \
|
||||
bool_or(is_grantable = 'YES') AS grantable \
|
||||
FROM information_schema.table_privileges \
|
||||
WHERE table_schema = $1 AND table_name = $2 AND grantee <> 'PUBLIC' \
|
||||
GROUP BY grantee".to_string()
|
||||
}
|
||||
|
||||
/// Sequences owned by this table's columns (via pg_depend). Parameterized $1 schema, $2 table.
|
||||
pub fn pg_table_owned_sequences_query() -> String {
|
||||
"SELECT sn.nspname AS seq_schema, s.relname AS seq_name, a.attname AS column \
|
||||
FROM pg_depend d JOIN pg_class s ON d.objid = s.oid \
|
||||
JOIN pg_namespace sn ON s.relnamespace = sn.oid \
|
||||
JOIN pg_class t ON d.refobjid = t.oid \
|
||||
JOIN pg_namespace tn ON t.relnamespace = tn.oid \
|
||||
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.refobjsubid \
|
||||
WHERE tn.nspname = $1 AND t.relname = $2 AND d.classid = 'pg_class'::regclass AND s.relkind = 'S'".to_string()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// PostgreSQL
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn pg_count_approximation_query_is_valid() {
|
||||
let sql = pg_reltuples_query("public", "users");
|
||||
assert!(
|
||||
sql.contains("pg_class"),
|
||||
"should query pg_class for row estimates; got: {}",
|
||||
sql
|
||||
);
|
||||
assert!(sql.contains("public"), "should contain schema name");
|
||||
assert!(sql.contains("users"), "should contain table name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_table_list_query_is_valid() {
|
||||
let sql = pg_tables_query(Some("public"));
|
||||
assert!(
|
||||
sql.contains("information_schema.tables"),
|
||||
"should query information_schema.tables; got: {}",
|
||||
sql
|
||||
);
|
||||
assert!(sql.contains("public"), "should contain the given schema");
|
||||
|
||||
// Without schema filter — should exclude system schemata
|
||||
let all_sql = pg_tables_query(None);
|
||||
assert!(
|
||||
all_sql.contains("information_schema.tables"),
|
||||
"should query information_schema.tables"
|
||||
);
|
||||
assert!(
|
||||
all_sql.contains("pg_catalog"),
|
||||
"should exclude pg_catalog via NOT IN"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_column_query_is_valid() {
|
||||
let sql = pg_columns_query("public", "orders");
|
||||
assert!(
|
||||
sql.contains("information_schema.columns"),
|
||||
"should query information_schema.columns; got: {}",
|
||||
sql
|
||||
);
|
||||
assert!(sql.contains("public"), "should contain schema name");
|
||||
assert!(sql.contains("orders"), "should contain table name");
|
||||
assert!(
|
||||
sql.contains("FOREIGN KEY"),
|
||||
"should include FK constraint metadata"
|
||||
);
|
||||
assert!(
|
||||
sql.contains("PRIMARY KEY"),
|
||||
"should include PK constraint metadata"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_schemas_query_is_valid() {
|
||||
let sql = pg_schemas_query();
|
||||
assert!(sql.contains("information_schema.schemata"));
|
||||
assert!(sql.contains("pg_catalog"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_databases_query_is_valid() {
|
||||
let sql = pg_databases_query();
|
||||
assert!(sql.contains("pg_database"));
|
||||
assert!(sql.contains("datistemplate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_indexes_query_is_parameterized_and_joins() {
|
||||
let sql = pg_indexes_query("public");
|
||||
assert!(
|
||||
sql.contains("$1"),
|
||||
"schema must be parameterized; got: {}",
|
||||
sql
|
||||
);
|
||||
assert!(
|
||||
sql.contains("pg_indexes") || sql.contains("pg_index"),
|
||||
"should query pg_index; got: {}",
|
||||
sql
|
||||
);
|
||||
assert!(
|
||||
sql.contains("pg_get_indexdef"),
|
||||
"should include index definition"
|
||||
);
|
||||
assert!(sql.contains("indisunique"), "should include uniqueness");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_constraints_query_filters_check_unique_exclusion() {
|
||||
let sql = pg_constraints_query("public");
|
||||
assert!(
|
||||
sql.contains("$1"),
|
||||
"schema must be parameterized; got: {}",
|
||||
sql
|
||||
);
|
||||
assert!(
|
||||
sql.contains("pg_constraint"),
|
||||
"should query pg_constraint; got: {}",
|
||||
sql
|
||||
);
|
||||
assert!(sql.contains("contype"), "should select contype");
|
||||
assert!(sql.contains("'c'"), "should filter CHECK ('c')");
|
||||
assert!(sql.contains("'u'"), "should filter UNIQUE ('u')");
|
||||
assert!(sql.contains("'x'"), "should filter EXCLUSION ('x')");
|
||||
assert!(
|
||||
sql.contains("pg_get_constraintdef"),
|
||||
"should include definition"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_tables_query_includes_materialized_views() {
|
||||
let sql = pg_tables_query(Some("public"));
|
||||
assert!(
|
||||
sql.contains("pg_matviews"),
|
||||
"matview UNION must source pg_matviews; got: {}",
|
||||
sql,
|
||||
);
|
||||
assert!(
|
||||
sql.contains("MATERIALIZED VIEW"),
|
||||
"should label materialized views"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// MySQL
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn mysql_table_list_query_is_valid() {
|
||||
let sql = mysql_tables_query(Some("mydb"));
|
||||
assert!(
|
||||
sql.contains("information_schema.tables"),
|
||||
"should query information_schema.tables; got: {}",
|
||||
sql
|
||||
);
|
||||
assert!(sql.contains("mydb"), "should contain the given schema");
|
||||
|
||||
let all_sql = mysql_tables_query(None);
|
||||
assert!(all_sql.contains("information_schema.tables"));
|
||||
assert!(all_sql.contains("performance_schema"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// SQLite
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn sqlite_table_list_query_is_valid() {
|
||||
let sql = sqlite_tables_query();
|
||||
assert!(
|
||||
sql.contains("sqlite_master"),
|
||||
"should query sqlite_master; got: {}",
|
||||
sql
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_columns_query_is_valid() {
|
||||
let sql = sqlite_columns_query("users");
|
||||
assert!(
|
||||
sql.contains("PRAGMA table_info"),
|
||||
"should use PRAGMA table_info; got: {}",
|
||||
sql
|
||||
);
|
||||
assert!(sql.contains("users"), "should contain table name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_foreign_keys_query_is_valid() {
|
||||
let sql = sqlite_foreign_keys_query("orders");
|
||||
assert!(
|
||||
sql.contains("PRAGMA foreign_key_list"),
|
||||
"should use PRAGMA foreign_key_list; got: {}",
|
||||
sql
|
||||
);
|
||||
assert!(sql.contains("orders"), "should contain table name");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Generic helpers
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn build_paginated_query_with_limits() {
|
||||
let columns = vec!["id".to_string(), "name".to_string()];
|
||||
let (sql, params) = build_select_query("public", "users", &columns, 2, 25);
|
||||
|
||||
assert!(
|
||||
sql.contains("LIMIT"),
|
||||
"should contain LIMIT clause; got: {}",
|
||||
sql
|
||||
);
|
||||
assert!(
|
||||
sql.contains("OFFSET"),
|
||||
"should contain OFFSET clause; got: {}",
|
||||
sql
|
||||
);
|
||||
assert!(sql.contains("public"), "should contain schema name");
|
||||
assert!(sql.contains("users"), "should contain table name");
|
||||
assert!(sql.contains("\"id\""), "should quote column names");
|
||||
assert!(sql.contains("\"name\""), "should quote column names");
|
||||
|
||||
// page=2, page_size=25 => offset = 50
|
||||
assert_eq!(params, vec![25, 50], "params should be [page_size, offset]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_paginated_query_empty_columns_uses_star() {
|
||||
let (sql, _) = build_select_query("public", "users", &[], 0, 10);
|
||||
assert!(
|
||||
sql.contains('*'),
|
||||
"empty columns should produce SELECT *; got: {}",
|
||||
sql
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_count_query_is_valid() {
|
||||
let sql = build_count_query("public", "orders");
|
||||
assert!(
|
||||
sql.contains("COUNT(*)"),
|
||||
"should contain COUNT(*); got: {}",
|
||||
sql
|
||||
);
|
||||
assert!(sql.contains("public"), "should contain schema name");
|
||||
assert!(sql.contains("orders"), "should contain table name");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Object introspection
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn pg_functions_query_has_expected_columns() {
|
||||
let sql = pg_functions_query("public");
|
||||
assert!(sql.contains("pg_proc"));
|
||||
assert!(sql.contains("proname"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_triggers_query_has_expected_columns() {
|
||||
let sql = pg_triggers_query("public");
|
||||
assert!(sql.contains("pg_trigger"));
|
||||
assert!(sql.contains("tgname"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_sequences_query_filters_by_schema() {
|
||||
let sql = pg_sequences_query("myschema");
|
||||
assert!(sql.contains("myschema"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_enums_query_has_typtype_e() {
|
||||
let sql = pg_enums_query("public");
|
||||
assert!(sql.contains("typtype = 'e'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_extensions_query_selects_from_pg_extension() {
|
||||
let sql = pg_extensions_query();
|
||||
assert!(sql.contains("pg_extension"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_available_extensions_query_has_name_default_version_comment() {
|
||||
let sql = pg_available_extensions_query();
|
||||
assert!(sql.contains("pg_available_extensions"));
|
||||
assert!(sql.contains("default_version"), "should select default version; got: {sql}");
|
||||
assert!(sql.contains("comment"), "should select comment; got: {sql}");
|
||||
assert!(sql.contains("ORDER BY name"), "should order by name; got: {sql}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Roles, privileges, tablespaces, rebuild readiness
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn pg_roles_query_filters_pg_prefix() {
|
||||
let sql = pg_roles_query();
|
||||
assert!(sql.contains("pg_roles"));
|
||||
assert!(sql.contains("rolname !~ '^pg_'"));
|
||||
assert!(sql.contains("rolcanlogin"));
|
||||
assert!(sql.contains("rolconnlimit::int8"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_role_memberships_query_uses_pg_auth_members() {
|
||||
let sql = pg_role_memberships_query();
|
||||
assert!(sql.contains("pg_auth_members"));
|
||||
assert!(sql.contains("admin_option"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_table_privileges_query_filters_grantee_and_aggregates() {
|
||||
let sql = pg_table_privileges_query("appuser");
|
||||
assert!(sql.contains("information_schema.table_privileges"));
|
||||
assert!(sql.contains("grantee = 'appuser'"));
|
||||
assert!(sql.contains("array_agg"));
|
||||
assert!(sql.contains("GROUP BY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_database_privileges_query_uses_aclexplode() {
|
||||
let sql = pg_database_privileges_query("appuser");
|
||||
assert!(sql.contains("aclexplode"));
|
||||
assert!(sql.contains("pg_database"));
|
||||
assert!(sql.contains("appuser"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_rebuild_readiness_query_checks_blockers() {
|
||||
let sql = pg_rebuild_readiness_query();
|
||||
assert!(sql.contains("pg_trigger"));
|
||||
assert!(sql.contains("pg_policy"));
|
||||
assert!(sql.contains("pg_inherits"));
|
||||
assert!(sql.contains("pg_partitioned_table"));
|
||||
assert!(sql.contains("is_generated"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_tablespaces_query_filters_pg_prefix() {
|
||||
let sql = pg_tablespaces_query();
|
||||
assert!(sql.contains("pg_tablespace"));
|
||||
assert!(sql.contains("spcname !~ '^pg_'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_table_fk_out_query_uses_pg_constraint_f() {
|
||||
let sql = pg_table_fk_out_query();
|
||||
assert!(sql.contains("pg_constraint"));
|
||||
assert!(sql.contains("contype = 'f'"));
|
||||
assert!(sql.contains("$1") && sql.contains("$2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_table_fk_in_query_finds_referencing_tables() {
|
||||
let sql = pg_table_fk_in_query();
|
||||
assert!(sql.contains("pg_constraint"));
|
||||
assert!(sql.contains("confrel"));
|
||||
assert!(sql.contains("$1") && sql.contains("$2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_table_grants_query_uses_table_privileges() {
|
||||
let sql = pg_table_grants_query();
|
||||
assert!(sql.contains("information_schema.table_privileges"));
|
||||
assert!(sql.contains("$1") && sql.contains("$2"));
|
||||
assert!(sql.contains("array_agg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_table_owned_sequences_query_uses_pg_depend() {
|
||||
let sql = pg_table_owned_sequences_query();
|
||||
assert!(sql.contains("pg_depend"));
|
||||
assert!(sql.contains("pg_class"));
|
||||
assert!(sql.contains("$1") && sql.contains("$2"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
pub mod introspection;
|
||||
pub mod mysql;
|
||||
pub mod object_crud;
|
||||
pub mod object_ddl;
|
||||
pub mod pool;
|
||||
pub mod tls;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use pool::{ConnectionPoolManager, DbConfig, DbHandle};
|
||||
@@ -0,0 +1,326 @@
|
||||
//! Pure MySQL SQL builders for the DB viewer. Identifiers are backtick-quoted
|
||||
//! (never string-concatenated); values are bound via `?` placeholders at the
|
||||
//! call site. Mirrors the PG builders in `commands/db_viewer.rs` but with
|
||||
//! MySQL quoting and `LIMIT 1` on single-row UPDATE/DELETE.
|
||||
use crate::models::db_viewer::{FilterRule, SortRule};
|
||||
use sqlx::Row;
|
||||
|
||||
/// `SHOW DATABASES` — the browsing branches filter system DBs client-side
|
||||
/// (see [`MYSQL_SYSTEM_DBS`]).
|
||||
pub fn mysql_databases_query() -> String {
|
||||
"SHOW DATABASES".to_string()
|
||||
}
|
||||
|
||||
/// System databases hidden from the DB viewer's database/schema selector.
|
||||
pub const MYSQL_SYSTEM_DBS: [&str; 4] = ["information_schema", "mysql", "performance_schema", "sys"];
|
||||
|
||||
/// Quote a MySQL identifier with backticks, doubling any embedded backticks.
|
||||
pub fn mysql_quote_ident(name: &str) -> String {
|
||||
format!("`{}`", name.replace('`', "``"))
|
||||
}
|
||||
|
||||
/// Decode a MySQL row cell as a String. `information_schema` / `SHOW`
|
||||
/// metadata columns can surface as VARBINARY (bytes) depending on the
|
||||
/// connection charset, so fall back from String to a UTF-8 lossy decode.
|
||||
pub fn mysql_row_string(row: &sqlx::mysql::MySqlRow, i: usize) -> String {
|
||||
if let Ok(s) = row.try_get::<String, _>(i) {
|
||||
return s;
|
||||
}
|
||||
if let Ok(b) = row.try_get::<Vec<u8>, _>(i) {
|
||||
return String::from_utf8_lossy(&b).into_owned();
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// information_schema.columns query for a table — returns column metadata in
|
||||
/// the column order the grid expects (name, data_type, is_nullable, column_key,
|
||||
/// default, extra). Caller maps these into `ColumnInfo`.
|
||||
pub fn mysql_columns_query(schema: &str, table: &str) -> String {
|
||||
format!(
|
||||
"SELECT column_name, data_type, is_nullable, column_key, column_default, extra \
|
||||
FROM information_schema.columns \
|
||||
WHERE table_schema = '{}' AND table_name = '{}' \
|
||||
ORDER BY ordinal_position",
|
||||
schema.replace('\'', "''"),
|
||||
table.replace('\'', "''")
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a `SELECT ... FROM \`schema\`.\`table\` [WHERE ...] [ORDER BY ...] LIMIT ? OFFSET ?`.
|
||||
/// `filters` produce `?` placeholders (values bound by the caller); `sorts`
|
||||
/// are quoted identifiers. `default_sort` is used when `sorts` is empty.
|
||||
pub fn mysql_select_data_query(
|
||||
schema: &str,
|
||||
table: &str,
|
||||
filters: &[FilterRule],
|
||||
sorts: &[SortRule],
|
||||
default_sort: &str,
|
||||
) -> String {
|
||||
let mut where_parts: Vec<String> = Vec::new();
|
||||
for f in filters {
|
||||
let col = mysql_quote_ident(&f.column);
|
||||
let op = match f.operator.as_str() {
|
||||
"eq" => format!("{} = ?", col),
|
||||
"neq" => format!("{} <> ?", col),
|
||||
"contains" => format!("{} LIKE CONCAT('%', ?, '%')", col),
|
||||
"starts" => format!("{} LIKE CONCAT(?, '%')", col),
|
||||
"ends" => format!("{} LIKE CONCAT('%', ?)", col),
|
||||
"gt" => format!("{} > ?", col),
|
||||
"lt" => format!("{} < ?", col),
|
||||
"null" => format!("{} IS NULL", col),
|
||||
"notnull" => format!("{} IS NOT NULL", col),
|
||||
_ => format!("{} = ?", col),
|
||||
};
|
||||
where_parts.push(op);
|
||||
}
|
||||
let where_clause = if where_parts.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" WHERE {}", where_parts.join(" AND "))
|
||||
};
|
||||
|
||||
let order_cols: Vec<String> = sorts
|
||||
.iter()
|
||||
.map(|s| format!("{} {}", mysql_quote_ident(&s.column), if s.order.eq_ignore_ascii_case("desc") { "DESC" } else { "ASC" }))
|
||||
.collect();
|
||||
let order_clause = if order_cols.is_empty() {
|
||||
if default_sort.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" ORDER BY {}", mysql_quote_ident(default_sort))
|
||||
}
|
||||
} else {
|
||||
format!(" ORDER BY {}", order_cols.join(", "))
|
||||
};
|
||||
|
||||
format!(
|
||||
"SELECT * FROM {}{}{} LIMIT ? OFFSET ?",
|
||||
qualified(schema, table),
|
||||
where_clause,
|
||||
order_clause
|
||||
)
|
||||
}
|
||||
|
||||
pub fn mysql_count_query(schema: &str, table: &str) -> String {
|
||||
format!("SELECT COUNT(*) FROM {}", qualified(schema, table))
|
||||
}
|
||||
|
||||
pub fn mysql_ddl_query(schema: &str, table: &str) -> String {
|
||||
format!("SHOW CREATE TABLE {}", qualified(schema, table))
|
||||
}
|
||||
|
||||
/// Foreign-key columns for a table (referenced table/column).
|
||||
pub fn mysql_fk_query(schema: &str, table: &str) -> String {
|
||||
format!(
|
||||
"SELECT column_name, referenced_table_schema, referenced_table_name, referenced_column_name \
|
||||
FROM information_schema.key_column_usage \
|
||||
WHERE table_schema = '{}' AND table_name = '{}' AND referenced_table_name IS NOT NULL",
|
||||
schema.replace('\'', "''"),
|
||||
table.replace('\'', "''")
|
||||
)
|
||||
}
|
||||
|
||||
/// Choose a default sort column: prefer an `id`-like column, else the first.
|
||||
pub fn mysql_default_sort(columns: &[String]) -> &str {
|
||||
columns.iter().find(|c| c.as_str() == "id").map(|s| s.as_str()).unwrap_or_else(|| {
|
||||
columns.first().map(|s| s.as_str()).unwrap_or("")
|
||||
})
|
||||
}
|
||||
|
||||
// ── Change-SQL builders ──────────────────────────────────────────────
|
||||
pub fn mysql_build_update_sql(
|
||||
schema: &str,
|
||||
table: &str,
|
||||
primary_key: &[(String, serde_json::Value)],
|
||||
new_data: &[(String, serde_json::Value)],
|
||||
) -> Result<(String, Vec<serde_json::Value>), String> {
|
||||
if primary_key.is_empty() {
|
||||
return Err("cannot update a row without a primary key (MySQL has no ctid)".to_string());
|
||||
}
|
||||
let mut params: Vec<serde_json::Value> = Vec::new();
|
||||
let set_clause: Vec<String> = new_data
|
||||
.iter()
|
||||
.map(|(col, val)| { params.push(val.clone()); format!("{} = ?", mysql_quote_ident(col)) })
|
||||
.collect();
|
||||
let where_clause: Vec<String> = primary_key
|
||||
.iter()
|
||||
.map(|(col, val)| { params.push(val.clone()); format!("{} = ?", mysql_quote_ident(col)) })
|
||||
.collect();
|
||||
Ok((
|
||||
format!(
|
||||
"UPDATE {} SET {} WHERE {} LIMIT 1",
|
||||
qualified(schema, table),
|
||||
set_clause.join(", "),
|
||||
where_clause.join(" AND ")
|
||||
),
|
||||
params,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn mysql_build_delete_sql(
|
||||
schema: &str,
|
||||
table: &str,
|
||||
primary_key: &[(String, serde_json::Value)],
|
||||
) -> Result<(String, Vec<serde_json::Value>), String> {
|
||||
if primary_key.is_empty() {
|
||||
return Err("cannot delete a row without a primary key (MySQL has no ctid)".to_string());
|
||||
}
|
||||
let mut params: Vec<serde_json::Value> = Vec::new();
|
||||
let where_clause: Vec<String> = primary_key
|
||||
.iter()
|
||||
.map(|(col, val)| { params.push(val.clone()); format!("{} = ?", mysql_quote_ident(col)) })
|
||||
.collect();
|
||||
Ok((
|
||||
format!("DELETE FROM {} WHERE {} LIMIT 1", qualified(schema, table), where_clause.join(" AND ")),
|
||||
params,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn mysql_build_insert_sql(
|
||||
schema: &str,
|
||||
table: &str,
|
||||
pairs: &[(String, serde_json::Value)],
|
||||
) -> (String, Vec<serde_json::Value>) {
|
||||
let cols: Vec<String> = pairs.iter().map(|(c, _)| mysql_quote_ident(c)).collect();
|
||||
let placeholders: Vec<&str> = pairs.iter().map(|_| "?").collect();
|
||||
let params: Vec<serde_json::Value> = pairs.iter().map(|(_, v)| v.clone()).collect();
|
||||
(
|
||||
format!(
|
||||
"INSERT INTO {} ({}) VALUES ({})",
|
||||
qualified(schema, table),
|
||||
cols.join(", "),
|
||||
placeholders.join(", ")
|
||||
),
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn mysql_build_bulk_insert_sql(schema: &str, table: &str, columns: &[String], row_count: usize) -> String {
|
||||
let cols: Vec<String> = columns.iter().map(|c| mysql_quote_ident(c)).collect();
|
||||
let one_row = format!("({})", columns.iter().map(|_| "?").collect::<Vec<_>>().join(", "));
|
||||
let rows = vec![one_row; row_count].join(", ");
|
||||
format!("INSERT INTO {} ({}) VALUES {}", qualified(schema, table), cols.join(", "), rows)
|
||||
}
|
||||
|
||||
pub fn mysql_build_drop_sql(schema: &str, table: &str) -> String {
|
||||
format!("DROP TABLE {}", qualified(schema, table))
|
||||
}
|
||||
|
||||
pub fn mysql_build_empty_sql(schema: &str, table: &str) -> String {
|
||||
format!("DELETE FROM {}", qualified(schema, table))
|
||||
}
|
||||
|
||||
fn qualified(schema: &str, table: &str) -> String {
|
||||
format!("{}.{}", mysql_quote_ident(schema), mysql_quote_ident(table))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::db_viewer::FilterRule;
|
||||
|
||||
#[test]
|
||||
fn quote_ident_backticks_and_escapes_embedded_backticks() {
|
||||
assert_eq!(mysql_quote_ident("name"), "`name`");
|
||||
assert_eq!(mysql_quote_ident("o`d`d"), "`o``d``d`");
|
||||
assert_eq!(mysql_quote_ident("select"), "`select`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_query_targets_information_schema() {
|
||||
let q = mysql_columns_query("shop", "orders");
|
||||
assert!(q.contains("FROM information_schema.columns"));
|
||||
assert!(q.contains("table_schema = 'shop'"));
|
||||
assert!(q.contains("table_name = 'orders'"));
|
||||
assert!(q.contains("ORDER BY ordinal_position"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_data_quotes_schema_table_and_applies_limit_offset() {
|
||||
let q = mysql_select_data_query("shop", "orders", &[], &[], "id");
|
||||
assert!(q.contains("SELECT * FROM `shop`.`orders`"));
|
||||
assert!(q.contains("ORDER BY `id`"));
|
||||
assert!(q.contains("LIMIT ? OFFSET ?"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_data_adds_where_for_filters_with_question_placeholders() {
|
||||
let filters = vec![FilterRule { id: "f1".into(), column: "status".into(), operator: "eq".into(), value: "paid".into() }];
|
||||
let q = mysql_select_data_query("shop", "orders", &filters, &[], "id");
|
||||
assert!(q.contains("WHERE `status` = ?"));
|
||||
assert!(q.contains("LIMIT ? OFFSET ?"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_query_uses_quoted_table() {
|
||||
let q = mysql_count_query("shop", "orders");
|
||||
assert_eq!(q, "SELECT COUNT(*) FROM `shop`.`orders`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ddl_query_uses_show_create_table() {
|
||||
let q = mysql_ddl_query("shop", "orders");
|
||||
assert_eq!(q, "SHOW CREATE TABLE `shop`.`orders`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fk_query_targets_key_column_usage() {
|
||||
let q = mysql_fk_query("shop", "orders");
|
||||
assert!(q.contains("FROM information_schema.key_column_usage"));
|
||||
assert!(q.contains("referenced_table_name IS NOT NULL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_update_uses_backticks_question_and_limit_one() {
|
||||
let pk = vec![("id".to_string(), serde_json::json!(1))];
|
||||
let data = vec![("status".to_string(), serde_json::json!("paid"))];
|
||||
let (sql, params) = mysql_build_update_sql("shop", "orders", &pk, &data).unwrap();
|
||||
assert_eq!(sql, "UPDATE `shop`.`orders` SET `status` = ? WHERE `id` = ? LIMIT 1");
|
||||
assert_eq!(params, vec![serde_json::json!("paid"), serde_json::json!(1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_update_rejects_empty_primary_key() {
|
||||
let r = mysql_build_update_sql("shop", "orders", &[], &[]);
|
||||
assert!(r.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_delete_uses_backticks_question_and_limit_one() {
|
||||
let pk = vec![("id".to_string(), serde_json::json!(1))];
|
||||
let (sql, params) = mysql_build_delete_sql("shop", "orders", &pk).unwrap();
|
||||
assert_eq!(sql, "DELETE FROM `shop`.`orders` WHERE `id` = ? LIMIT 1");
|
||||
assert_eq!(params, vec![serde_json::json!(1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_insert_emits_columns_and_question_placeholders() {
|
||||
let pairs = vec![
|
||||
("a".to_string(), serde_json::json!(1)),
|
||||
("b".to_string(), serde_json::json!("x")),
|
||||
];
|
||||
let (sql, params) = mysql_build_insert_sql("shop", "orders", &pairs);
|
||||
assert_eq!(sql, "INSERT INTO `shop`.`orders` (`a`, `b`) VALUES (?, ?)");
|
||||
assert_eq!(params, vec![serde_json::json!(1), serde_json::json!("x")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_drop_and_empty_table_quoted() {
|
||||
assert_eq!(mysql_build_drop_sql("shop", "orders"), "DROP TABLE `shop`.`orders`");
|
||||
assert_eq!(mysql_build_empty_sql("shop", "orders"), "DELETE FROM `shop`.`orders`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_bulk_insert_columns_and_placeholders() {
|
||||
let cols = vec!["a".to_string(), "b".to_string()];
|
||||
let sql = mysql_build_bulk_insert_sql("shop", "orders", &cols, 2);
|
||||
assert_eq!(sql, "INSERT INTO `shop`.`orders` (`a`, `b`) VALUES (?, ?), (?, ?)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_sort_picks_id_then_first_column() {
|
||||
assert_eq!(mysql_default_sort(&["updated_at".into(), "id".into()]), "id");
|
||||
assert_eq!(mysql_default_sort(&["name".into()]), "name");
|
||||
assert_eq!(mysql_default_sort(&[]), "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
//! Pure builders for schema DDL, cross-object search, pg_depend lookups,
|
||||
//! and synthesized object DDL. No DB I/O — deterministic string builders.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::models::db_viewer::{SequenceInfo, EnumInfo, ExtensionInfo, ConstraintInfo};
|
||||
|
||||
/// Column model for the SQLite table editor. Mirrors the frontend payload.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SqliteColumn {
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub type_: String,
|
||||
pub nullable: bool,
|
||||
pub default: Option<String>,
|
||||
pub is_pk: bool,
|
||||
pub auto_increment: bool,
|
||||
pub unique: bool,
|
||||
}
|
||||
|
||||
/// Double-quote an identifier, doubling embedded quotes.
|
||||
pub fn quote_ident(name: &str) -> String {
|
||||
format!("\"{}\"", name.replace('"', "\"\""))
|
||||
}
|
||||
|
||||
/// Reject empty / SQL-injection-prone names (mirrors schema_graph::validate_schema_name).
|
||||
pub fn validate_object_name(name: &str) -> Result<(), String> {
|
||||
if name.trim().is_empty() {
|
||||
return Err("Name must not be empty".into());
|
||||
}
|
||||
for bad in [";", "--", "/*", "'", "\"", "\\"] {
|
||||
if name.contains(bad) {
|
||||
return Err(format!("Name contains forbidden character(s): {bad}"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn create_schema_sql(name: &str) -> Result<String, String> {
|
||||
validate_object_name(name)?;
|
||||
Ok(format!("CREATE SCHEMA {}", quote_ident(name)))
|
||||
}
|
||||
pub fn rename_schema_sql(old: &str, new: &str) -> Result<String, String> {
|
||||
validate_object_name(old)?;
|
||||
validate_object_name(new)?;
|
||||
Ok(format!("ALTER SCHEMA {} RENAME TO {}", quote_ident(old), quote_ident(new)))
|
||||
}
|
||||
pub fn drop_schema_sql(name: &str, cascade: bool) -> Result<String, String> {
|
||||
validate_object_name(name)?;
|
||||
Ok(format!("DROP SCHEMA {}{}", quote_ident(name), if cascade { " CASCADE" } else { "" }))
|
||||
}
|
||||
|
||||
/// UNION ALL of every browsable object type in `$2` (schema), substring-matching `$1` (needle)
|
||||
/// via position() — case-insensitive, no wildcard-escaping pitfalls. LIMIT 100.
|
||||
pub fn pg_object_search_query() -> String {
|
||||
"SELECT name, schema, type FROM (
|
||||
SELECT table_name AS name, table_schema AS schema, CASE WHEN table_type = 'VIEW' THEN 'VIEW' ELSE 'TABLE' END AS type FROM information_schema.tables WHERE table_schema=$2 AND position(lower($1) in lower(table_name))>0
|
||||
UNION ALL
|
||||
SELECT matviewname, schemaname, 'MATERIALIZED VIEW' FROM pg_matviews WHERE schemaname=$2 AND position(lower($1) in lower(matviewname))>0
|
||||
UNION ALL
|
||||
SELECT p.proname, n.nspname, CASE p.prokind WHEN 'f' THEN 'FUNCTION' WHEN 'p' THEN 'PROCEDURE' END FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE n.nspname=$2 AND position(lower($1) in lower(p.proname))>0
|
||||
UNION ALL
|
||||
SELECT t.tgname, cn.nspname, 'TRIGGER' FROM pg_trigger t JOIN pg_class c ON t.tgrelid=c.oid JOIN pg_namespace cn ON c.relnamespace=cn.oid WHERE cn.nspname=$2 AND NOT t.tgisinternal AND position(lower($1) in lower(t.tgname))>0
|
||||
UNION ALL
|
||||
SELECT sequence_name, sequence_schema, 'SEQUENCE' FROM information_schema.sequences WHERE sequence_schema=$2 AND position(lower($1) in lower(sequence_name))>0
|
||||
UNION ALL
|
||||
SELECT t.typname, n.nspname, 'ENUM' FROM pg_type t JOIN pg_namespace n ON t.typnamespace=n.oid WHERE t.typtype='e' AND n.nspname=$2 AND position(lower($1) in lower(t.typname))>0
|
||||
UNION ALL
|
||||
SELECT e.extname, n.nspname, 'EXTENSION' FROM pg_extension e JOIN pg_namespace n ON e.extnamespace=n.oid WHERE n.nspname=$2 AND position(lower($1) in lower(e.extname))>0
|
||||
UNION ALL
|
||||
SELECT i.relname, ns.nspname, 'INDEX' FROM pg_index ix JOIN pg_class i ON i.oid=ix.indexrelid JOIN pg_class t ON t.oid=ix.indrelid JOIN pg_namespace ns ON t.relnamespace=ns.oid WHERE ns.nspname=$2 AND position(lower($1) in lower(i.relname))>0
|
||||
UNION ALL
|
||||
SELECT c.conname, ns.nspname, 'CONSTRAINT' FROM pg_constraint c JOIN pg_class cl ON c.conrelid=cl.oid JOIN pg_namespace ns ON cl.relnamespace=ns.oid WHERE ns.nspname=$2 AND c.contype IN ('c','u','x') AND position(lower($1) in lower(c.conname))>0
|
||||
) AS hits ORDER BY type, name LIMIT 100".to_string()
|
||||
}
|
||||
|
||||
/// Resolve a single object oid by type. $1=name, $2=schema. (Overloads: first match — see spec open questions.)
|
||||
pub fn pg_object_oid_query(object_type: &str) -> String {
|
||||
match object_type {
|
||||
"table" | "view" | "materialized view" | "sequence" | "index" =>
|
||||
"SELECT c.oid FROM pg_class c JOIN pg_namespace n ON c.relnamespace=n.oid WHERE c.relname=$1 AND n.nspname=$2 LIMIT 1".to_string(),
|
||||
"function" | "procedure" =>
|
||||
"SELECT p.oid FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE p.proname=$1 AND n.nspname=$2 LIMIT 1".to_string(),
|
||||
"trigger" =>
|
||||
"SELECT t.oid FROM pg_trigger t JOIN pg_class c ON t.tgrelid=c.oid JOIN pg_namespace n ON c.relnamespace=n.oid WHERE t.tgname=$1 AND n.nspname=$2 AND NOT t.tgisinternal LIMIT 1".to_string(),
|
||||
"enum" =>
|
||||
"SELECT t.oid FROM pg_type t JOIN pg_namespace n ON t.typnamespace=n.oid WHERE t.typname=$1 AND n.nspname=$2 AND t.typtype='e' LIMIT 1".to_string(),
|
||||
"extension" =>
|
||||
"SELECT e.oid FROM pg_extension e JOIN pg_namespace n ON e.extnamespace=n.oid WHERE e.extname=$1 AND n.nspname=$2 LIMIT 1".to_string(),
|
||||
"constraint" =>
|
||||
"SELECT c.oid FROM pg_constraint c JOIN pg_class cl ON c.conrelid=cl.oid JOIN pg_namespace n ON cl.relnamespace=n.oid WHERE c.conname=$1 AND n.nspname=$2 LIMIT 1".to_string(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Objects that depend on `$1` (the target oid). Excludes internal/pinned deps; resolves readable name per classid.
|
||||
pub fn pg_depend_query() -> String {
|
||||
"SELECT d.deptype::text AS deptype,
|
||||
CASE WHEN d.classid = 'pg_rewrite'::regclass THEN 'pg_class'
|
||||
ELSE d.classid::regclass::text END AS class,
|
||||
CASE
|
||||
WHEN d.classid='pg_class'::regclass THEN (SELECT relname FROM pg_class WHERE oid=d.objid)
|
||||
WHEN d.classid='pg_proc'::regclass THEN (SELECT proname FROM pg_proc WHERE oid=d.objid)
|
||||
WHEN d.classid='pg_trigger'::regclass THEN (SELECT tgname FROM pg_trigger WHERE oid=d.objid)
|
||||
WHEN d.classid='pg_type'::regclass THEN (SELECT typname FROM pg_type WHERE oid=d.objid)
|
||||
WHEN d.classid='pg_constraint'::regclass THEN (SELECT conname FROM pg_constraint WHERE oid=d.objid)
|
||||
WHEN d.classid='pg_rewrite'::regclass THEN (SELECT ev_class::regclass::text FROM pg_rewrite WHERE oid=d.objid)
|
||||
ELSE ''
|
||||
END AS name
|
||||
FROM pg_depend d
|
||||
WHERE d.refobjid = $1 AND d.deptype IN ('n', 'a')
|
||||
ORDER BY d.deptype, name".to_string()
|
||||
}
|
||||
|
||||
/// Objects contained in a schema (for the schema-drop dependency warning). $1=schema.
|
||||
pub fn pg_schema_contents_query() -> String {
|
||||
"SELECT c.relname, c.relkind::text FROM pg_class c JOIN pg_namespace n ON c.relnamespace=n.oid WHERE n.nspname=$1 AND c.relkind IN ('r','v','m','S','i','c') ORDER BY c.relkind, c.relname".to_string()
|
||||
}
|
||||
|
||||
// --- Synthesized DDL ---
|
||||
pub fn sequence_ddl(s: &SequenceInfo) -> String {
|
||||
format!("CREATE SEQUENCE {}.{}\n INCREMENT BY {}\n MINVALUE {}\n MAXVALUE {}\n START WITH {}\n {}",
|
||||
quote_ident(&s.schema), quote_ident(&s.name), s.increment, s.min_value, s.max_value, s.start_value,
|
||||
if s.cycle { "CYCLE" } else { "NO CYCLE" })
|
||||
}
|
||||
pub fn enum_ddl(e: &EnumInfo) -> String {
|
||||
let labels: Vec<String> = e.labels.iter().map(|l| format!("'{}'", l.replace('\'', "''"))).collect();
|
||||
format!("CREATE TYPE {}.{} AS ENUM ({});", quote_ident(&e.schema), quote_ident(&e.name), labels.join(", "))
|
||||
}
|
||||
pub fn extension_ddl(x: &ExtensionInfo) -> String {
|
||||
format!("CREATE EXTENSION IF NOT EXISTS {} WITH SCHEMA {} VERSION '{}';", quote_ident(&x.name), quote_ident(&x.schema), x.version.replace('\'', "''"))
|
||||
}
|
||||
pub fn view_ddl(schema: &str, name: &str, selectdef: &str) -> String {
|
||||
format!("CREATE OR REPLACE VIEW {}.{} AS\n{}", quote_ident(schema), quote_ident(name), selectdef)
|
||||
}
|
||||
pub fn matview_ddl(schema: &str, name: &str, selectdef: &str) -> String {
|
||||
format!("CREATE MATERIALIZED VIEW {}.{} AS\n{}", quote_ident(schema), quote_ident(name), selectdef)
|
||||
}
|
||||
pub fn constraint_ddl(c: &ConstraintInfo) -> String {
|
||||
format!("ALTER TABLE {}.{} ADD CONSTRAINT {} {}",
|
||||
quote_ident(&c.schema), quote_ident(&c.table), quote_ident(&c.name), c.definition)
|
||||
}
|
||||
|
||||
// --- SQLite table editor ---
|
||||
|
||||
fn validate_type_fragment(t: &str) -> Result<(), String> {
|
||||
let l = t.trim().to_lowercase();
|
||||
if l.is_empty() { return Err("column type is required".into()); }
|
||||
if l.contains(';') || l.contains("--") || l.contains("/*") { return Err("invalid characters in type".into()); }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// CREATE TABLE for SQLite. PK inline for AUTOINCREMENT; single non-AUTOINCREMENT
|
||||
/// PKs get a table-level PRIMARY KEY clause; FKs appended inline (SQLite grammar).
|
||||
pub fn sqlite_create_table_sql(table: &str, cols: &[SqliteColumn], fks: &[(&str, &str)]) -> Result<String, String> {
|
||||
validate_object_name(table)?;
|
||||
let mut defs: Vec<String> = vec![];
|
||||
let mut pk_cols: Vec<String> = vec![];
|
||||
for c in cols {
|
||||
validate_object_name(&c.name)?;
|
||||
validate_type_fragment(&c.type_)?;
|
||||
let mut d = format!("{} {}", quote_ident(&c.name), c.type_.trim());
|
||||
if c.auto_increment && c.is_pk && c.type_.trim().eq_ignore_ascii_case("INTEGER") {
|
||||
d = format!("{} INTEGER PRIMARY KEY AUTOINCREMENT", quote_ident(&c.name));
|
||||
} else {
|
||||
if !c.nullable { d.push_str(" NOT NULL"); }
|
||||
if let Some(def) = &c.default { d.push_str(&format!(" DEFAULT {def}")); }
|
||||
if c.unique && !c.is_pk { d.push_str(" UNIQUE"); }
|
||||
if c.is_pk { pk_cols.push(quote_ident(&c.name)); }
|
||||
}
|
||||
defs.push(d);
|
||||
}
|
||||
// Always emit a table-level PRIMARY KEY when there are non-AUTOINCREMENT PK
|
||||
// columns (single or composite) — the plan draft's `len > 1` condition would
|
||||
// silently drop a single-column PK constraint.
|
||||
if !pk_cols.is_empty() {
|
||||
defs.push(format!("PRIMARY KEY ({})", pk_cols.join(", ")));
|
||||
}
|
||||
for (lc, refc) in fks {
|
||||
defs.push(format!("FOREIGN KEY ({}) REFERENCES {}", quote_ident(lc), refc));
|
||||
}
|
||||
Ok(format!("CREATE TABLE \"main\".{} ({})", quote_ident(table), defs.join(", ")))
|
||||
}
|
||||
|
||||
/// Emit one statement per needed edit; falls back to a rebuild script (multi-stmt)
|
||||
/// for edits SQLite's ALTER TABLE can't express.
|
||||
pub fn sqlite_column_diff_sql(table: &str, old: &[SqliteColumn], new: &[SqliteColumn]) -> Result<Vec<String>, String> {
|
||||
// rename detection: same position+type+nullable+default, name changed
|
||||
for (i, n) in new.iter().enumerate() {
|
||||
if let Some(o) = old.get(i) {
|
||||
if o.name != n.name && o.type_ == n.type_ && o.default == n.default && o.nullable == n.nullable {
|
||||
return Ok(vec![format!("ALTER TABLE \"main\".{} RENAME COLUMN {} TO {}", quote_ident(table), quote_ident(&o.name), quote_ident(&n.name))]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// add column (new tail column, safe only if nullable or defaulted)
|
||||
if new.len() > old.len() {
|
||||
if let Some(c) = new.last() {
|
||||
if c.nullable || c.default.is_some() {
|
||||
validate_object_name(&c.name)?;
|
||||
validate_type_fragment(&c.type_)?;
|
||||
let mut d = format!("ALTER TABLE \"main\".{} ADD COLUMN {} {}", quote_ident(table), quote_ident(&c.name), c.type_.trim());
|
||||
if !c.nullable {
|
||||
d.push_str(&format!(" DEFAULT {}", c.default.as_deref().unwrap_or("''")));
|
||||
}
|
||||
return Ok(vec![d]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// otherwise: full rebuild (type change, NOT NULL, drop, PK/UNIQUE/FK add, reorder)
|
||||
sqlite_rebuild_script(table, old, new)
|
||||
}
|
||||
|
||||
pub fn sqlite_rebuild_script(table: &str, _old: &[SqliteColumn], new: &[SqliteColumn]) -> Result<Vec<String>, String> {
|
||||
validate_object_name(table)?;
|
||||
let tmp = format!("_gl_{}_tmp", table);
|
||||
let create = sqlite_create_table_sql(&tmp, new, &[])?;
|
||||
let cols = new.iter().map(|c| quote_ident(&c.name)).collect::<Vec<_>>().join(", ");
|
||||
Ok(vec![
|
||||
create,
|
||||
format!("INSERT INTO \"main\".{} ({}) SELECT * FROM \"main\".{}", quote_ident(&tmp), cols, quote_ident(table)),
|
||||
format!("DROP TABLE \"main\".{}", quote_ident(table)),
|
||||
format!("ALTER TABLE \"main\".{} RENAME TO {}", quote_ident(&tmp), quote_ident(table)),
|
||||
])
|
||||
}
|
||||
|
||||
/// Fail-closed readiness reason, or None if rebuild is safe.
|
||||
/// AUTOINCREMENT tables are refused in v0.7.8 (rowid counter would be lost).
|
||||
pub fn sqlite_rebuild_refusal(old: &[SqliteColumn]) -> Option<String> {
|
||||
if old.iter().any(|c| c.auto_increment) {
|
||||
return Some("rebuild is not supported for AUTOINCREMENT tables in v0.7.8 (rowid counter would be lost)".into());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn quote_ident_doubles_embedded_quotes() {
|
||||
assert_eq!(quote_ident("public"), "\"public\"");
|
||||
assert_eq!(quote_ident("a\"b"), "\"a\"\"b\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_object_name_rejects_dangerous_chars() {
|
||||
assert!(validate_object_name("public").is_ok());
|
||||
assert!(validate_object_name("").is_err());
|
||||
assert!(validate_object_name("a; DROP").is_err());
|
||||
assert!(validate_object_name("a--b").is_err());
|
||||
assert!(validate_object_name("a'b").is_err());
|
||||
assert!(validate_object_name("a\"b").is_err());
|
||||
assert!(validate_object_name("a\\b").is_err());
|
||||
assert!(validate_object_name("/*x*/").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_schema_sql_quotes_name() {
|
||||
assert_eq!(create_schema_sql("my_schema").unwrap(), "CREATE SCHEMA \"my_schema\"");
|
||||
assert!(create_schema_sql("bad; name").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_schema_sql_quotes_both() {
|
||||
assert_eq!(
|
||||
rename_schema_sql("old", "new").unwrap(),
|
||||
"ALTER SCHEMA \"old\" RENAME TO \"new\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_schema_sql_cascade_flag() {
|
||||
assert_eq!(drop_schema_sql("s", false).unwrap(), "DROP SCHEMA \"s\"");
|
||||
assert_eq!(drop_schema_sql("s", true).unwrap(), "DROP SCHEMA \"s\" CASCADE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_search_query_unions_types_and_uses_position_match() {
|
||||
let sql = pg_object_search_query();
|
||||
assert!(sql.contains("position(lower($1) in lower("), "case-insensitive substring, no wildcard escaping: {sql}");
|
||||
assert!(sql.contains("$2"), "schema is $2");
|
||||
// covers every browsable type
|
||||
for t in ["'TABLE'", "'VIEW'", "'MATERIALIZED VIEW'", "'FUNCTION'", "'PROCEDURE'", "'TRIGGER'", "'SEQUENCE'", "'ENUM'", "'EXTENSION'", "'INDEX'", "'CONSTRAINT'"] {
|
||||
assert!(sql.contains(t), "search must cover {t}");
|
||||
}
|
||||
assert!(sql.contains("LIMIT 100"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_oid_query_branches_per_type() {
|
||||
assert!(pg_object_oid_query("table").contains("pg_class"));
|
||||
assert!(pg_object_oid_query("function").contains("pg_proc"));
|
||||
assert!(pg_object_oid_query("trigger").contains("pg_trigger"));
|
||||
assert!(pg_object_oid_query("enum").contains("pg_type"));
|
||||
assert!(pg_object_oid_query("extension").contains("pg_extension"));
|
||||
assert!(pg_object_oid_query("constraint").contains("pg_constraint"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depend_query_filters_internal_and_resolves_names() {
|
||||
let sql = pg_depend_query();
|
||||
assert!(sql.contains("refobjid = $1"));
|
||||
assert!(sql.contains("deptype IN ('n', 'a')"), "exclude internal 'i' / pinned 'p'");
|
||||
assert!(sql.contains("pg_proc'::regclass"));
|
||||
assert!(sql.contains("pg_trigger'::regclass"));
|
||||
assert!(sql.contains("pg_constraint'::regclass"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depend_query_maps_rewrite_rules_to_the_view_class() {
|
||||
// View dependencies surface in pg_depend as rewrite-rule rows
|
||||
// (classid = pg_rewrite). The dependent object a user cares about is
|
||||
// the VIEW itself, so the class must be reported as pg_class and the
|
||||
// name resolved through ev_class (the view's relation).
|
||||
let sql = pg_depend_query();
|
||||
assert!(
|
||||
sql.contains("WHEN d.classid = 'pg_rewrite'::regclass THEN 'pg_class'"),
|
||||
"pg_rewrite rows must be reported as pg_class: {sql}"
|
||||
);
|
||||
assert!(
|
||||
sql.contains("pg_rewrite'::regclass THEN (SELECT ev_class::regclass::text FROM pg_rewrite WHERE oid=d.objid)"),
|
||||
"pg_rewrite name resolves through ev_class: {sql}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_ddl_is_synthesized() {
|
||||
let s = SequenceInfo { name: "users_id_seq".into(), schema: "public".into(),
|
||||
start_value: "1".into(), min_value: "1".into(), max_value: "9".into(),
|
||||
increment: "1".into(), current_value: "5".into(), cycle: false };
|
||||
let ddl = sequence_ddl(&s);
|
||||
assert!(ddl.starts_with("CREATE SEQUENCE \"public\".\"users_id_seq\""));
|
||||
assert!(ddl.contains("INCREMENT BY 1"));
|
||||
assert!(ddl.contains("NO CYCLE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enum_ddl_lists_labels_quoted() {
|
||||
let e = EnumInfo { name: "role".into(), schema: "public".into(), labels: vec!["admin".into(), "user's".into()] };
|
||||
let ddl = enum_ddl(&e);
|
||||
assert!(ddl.starts_with("CREATE TYPE \"public\".\"role\" AS ENUM ("));
|
||||
assert!(ddl.contains("'admin'"));
|
||||
assert!(ddl.contains("'user''s'"), "single quotes doubled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_ddl_is_synthesized() {
|
||||
let x = ExtensionInfo { name: "pgcrypto".into(), schema: "public".into(), version: "1.3".into(), comment: None };
|
||||
let ddl = extension_ddl(&x);
|
||||
assert!(ddl.contains("CREATE EXTENSION IF NOT EXISTS \"pgcrypto\""));
|
||||
assert!(ddl.contains("WITH SCHEMA \"public\""));
|
||||
assert!(ddl.contains("VERSION '1.3'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_ddl_wraps_selectdef() {
|
||||
assert_eq!(view_ddl("public", "v_users", "SELECT * FROM users"),
|
||||
"CREATE OR REPLACE VIEW \"public\".\"v_users\" AS\nSELECT * FROM users");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constraint_ddl_wraps_definition() {
|
||||
let c = ConstraintInfo { name: "ck_pos".into(), schema: "public".into(), table: "orders".into(),
|
||||
contype: "CHECK".into(), definition: "CHECK (amount > 0)".into(), deferrable: false, validated: true, columns: vec!["amount".into()] };
|
||||
let ddl = constraint_ddl(&c);
|
||||
assert_eq!(ddl, "ALTER TABLE \"public\".\"orders\" ADD CONSTRAINT \"ck_pos\" CHECK (amount > 0)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_create_with_autoincrement_pk() {
|
||||
let cols = vec![
|
||||
SqliteColumn { name: "id".into(), type_: "INTEGER".into(), nullable: false, default: None, is_pk: true, auto_increment: true, unique: false },
|
||||
SqliteColumn { name: "name".into(), type_: "TEXT".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false },
|
||||
];
|
||||
let sql = sqlite_create_table_sql("users", &cols, &[]).unwrap();
|
||||
assert!(sql.contains("\"id\" INTEGER PRIMARY KEY AUTOINCREMENT"));
|
||||
assert!(sql.contains("\"name\" TEXT"));
|
||||
assert!(sql.starts_with("CREATE TABLE \"main\".\"users\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_create_rejects_bad_identifier() {
|
||||
let cols = vec![SqliteColumn { name: "a;b".into(), type_: "INTEGER".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false }];
|
||||
assert!(sqlite_create_table_sql("bad name", &cols, &[]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_edit_add_column_when_safe() {
|
||||
let old = vec![SqliteColumn { name: "id".into(), type_: "INTEGER".into(), nullable: false, default: None, is_pk: true, auto_increment: true, unique: false }];
|
||||
let new = vec![
|
||||
old[0].clone(),
|
||||
SqliteColumn { name: "email".into(), type_: "TEXT".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false },
|
||||
];
|
||||
let stmts = sqlite_column_diff_sql("users", &old, &new).unwrap();
|
||||
assert_eq!(stmts.len(), 1);
|
||||
assert!(stmts[0].contains("ALTER TABLE \"main\".\"users\" ADD COLUMN \"email\" TEXT"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_edit_rename_column() {
|
||||
let old = vec![SqliteColumn { name: "id".into(), type_: "INTEGER".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false }];
|
||||
let new = vec![SqliteColumn { name: "id2".into(), type_: "INTEGER".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false }];
|
||||
let stmts = sqlite_column_diff_sql("t", &old, &new).unwrap();
|
||||
assert!(stmts.iter().any(|s| s.contains("RENAME COLUMN \"id\" TO \"id2\"")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_edit_typechange_requires_rebuild() {
|
||||
let old = vec![SqliteColumn { name: "v".into(), type_: "TEXT".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false }];
|
||||
let new = vec![SqliteColumn { name: "v".into(), type_: "INTEGER".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false }];
|
||||
let stmts = sqlite_column_diff_sql("t", &old, &new).unwrap();
|
||||
assert!(stmts.iter().any(|s| s.contains("CREATE TABLE \"main\".\"_gl_t_tmp\"")), "type change must rebuild; got {stmts:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Instant;
|
||||
|
||||
/// Configuration for establishing a database connection.
|
||||
///
|
||||
/// Fields map to connection parameters. For SQLite, `host` stores the
|
||||
/// file path and `port` is always `None`.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct DbConfig {
|
||||
pub db_type: String,
|
||||
pub host: String,
|
||||
pub port: Option<i64>,
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub database: Option<String>,
|
||||
pub ssl_mode: Option<String>,
|
||||
pub ssl_ca_path: Option<String>,
|
||||
pub ssl_cert_path: Option<String>,
|
||||
pub ssl_key_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ssh_host: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ssh_port: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub ssh_user: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ssh_auth_method: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ssh_password: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ssh_private_key_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ssh_passphrase: Option<String>,
|
||||
}
|
||||
|
||||
impl DbConfig {
|
||||
/// Create a `DbConfig` for a SQLite database at `path`.
|
||||
///
|
||||
/// `host` is set to the file path; all other optional fields are `None`.
|
||||
pub fn sqlite(path: &str) -> Self {
|
||||
Self {
|
||||
db_type: "SQLite".into(),
|
||||
host: path.into(),
|
||||
port: None,
|
||||
username: None,
|
||||
password: None,
|
||||
database: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_password: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_passphrase: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an `SshConfig` from the flat SSH fields, or `None` if no SSH host is set.
|
||||
pub fn ssh_config(&self) -> Option<crate::models::SshConfig> {
|
||||
let host = self.ssh_host.clone()?;
|
||||
if host.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(crate::models::SshConfig {
|
||||
host,
|
||||
port: self.ssh_port.unwrap_or(22) as u16,
|
||||
user: self.ssh_user.clone().unwrap_or_default(),
|
||||
auth_method: self
|
||||
.ssh_auth_method
|
||||
.clone()
|
||||
.unwrap_or_else(|| "password".to_string()),
|
||||
password: self.ssh_password.clone(),
|
||||
private_key_path: self.ssh_private_key_path.clone(),
|
||||
passphrase: self.ssh_passphrase.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A handle to an active database connection.
|
||||
///
|
||||
/// Supports `Sqlite` (synchronous via `rusqlite`), `Postgresql` (async via
|
||||
/// `tokio-postgres`), and `MySql` (async via `sqlx`). Redis has no DB-viewer
|
||||
/// support. Eviction relies on each variant's `Drop`: `MySqlPool` closes its
|
||||
/// connections when dropped (mirroring `Postgresql`'s `JoinHandle` abort).
|
||||
#[derive(Debug)]
|
||||
pub enum DbHandle {
|
||||
/// A synchronous SQLite connection via `rusqlite`.
|
||||
Sqlite(rusqlite::Connection),
|
||||
/// An asynchronous PostgreSQL connection via `tokio-postgres`.
|
||||
/// Stores the client handle and the background connection task.
|
||||
Postgresql(tokio_postgres::Client, tokio::task::JoinHandle<()>),
|
||||
/// An asynchronous MySQL connection pool via `sqlx`.
|
||||
MySql(sqlx::MySqlPool),
|
||||
}
|
||||
|
||||
/// Internal entry stored in the pool manager.
|
||||
///
|
||||
/// Tracks the database handle and the last time it was accessed for LRU
|
||||
/// eviction.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct DbPoolEntry {
|
||||
pub(crate) handle: DbHandle,
|
||||
pub(crate) last_accessed: Instant,
|
||||
}
|
||||
|
||||
/// A connection pool manager with LRU eviction.
|
||||
///
|
||||
/// Manages a set of active database handles keyed by a user-defined
|
||||
/// identifier. When the number of registered pools exceeds `max_pools`,
|
||||
/// the least-recently-used entry (i.e. the pool whose handle was accessed
|
||||
/// furthest in the past) is evicted.
|
||||
///
|
||||
/// Default `max_pools` is 5.
|
||||
pub struct ConnectionPoolManager {
|
||||
pools: indexmap::IndexMap<String, DbPoolEntry>,
|
||||
max_pools: usize,
|
||||
/// Invoked with the id of every pool that gets evicted (LRU overflow in
|
||||
/// `register` or shrinkage in `set_max_pools`). Lets callers free
|
||||
/// associated resources (e.g. SSH tunnels).
|
||||
on_evict: Option<Box<dyn Fn(&str) + Send + Sync>>,
|
||||
}
|
||||
|
||||
impl ConnectionPoolManager {
|
||||
/// Create a new manager with a maximum of 5 pools.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
pools: indexmap::IndexMap::new(),
|
||||
max_pools: 5,
|
||||
on_evict: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a callback invoked with the id of every evicted pool.
|
||||
pub fn set_on_evict(&mut self, cb: Box<dyn Fn(&str) + Send + Sync>) {
|
||||
self.on_evict = Some(cb);
|
||||
}
|
||||
|
||||
/// Set the maximum number of pools before LRU eviction kicks in.
|
||||
///
|
||||
/// If the current pool count exceeds the new maximum, the oldest
|
||||
/// entries are evicted immediately.
|
||||
pub fn set_max_pools(&mut self, max: usize) {
|
||||
self.max_pools = max;
|
||||
while self.pools.len() > self.max_pools {
|
||||
if let Some((evicted_id, _)) = self.pools.shift_remove_index(0) {
|
||||
if let Some(cb) = &self.on_evict {
|
||||
cb(&evicted_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a new database handle under `id`.
|
||||
///
|
||||
/// * If `id` already exists the old entry is removed first.
|
||||
/// * The new entry is inserted as the most-recently-used.
|
||||
/// * If the total pool count exceeds `max_pools` the least-recently-used
|
||||
/// (oldest) entry is evicted.
|
||||
pub fn register(&mut self, id: &str, handle: DbHandle) {
|
||||
// Remove existing entry if present
|
||||
self.pools.shift_remove(id);
|
||||
|
||||
let entry = DbPoolEntry {
|
||||
handle,
|
||||
last_accessed: Instant::now(),
|
||||
};
|
||||
self.pools.insert(id.to_string(), entry);
|
||||
|
||||
// LRU eviction: remove oldest (front) entries until within capacity
|
||||
while self.pools.len() > self.max_pools {
|
||||
if let Some((evicted_id, _)) = self.pools.shift_remove_index(0) {
|
||||
if let Some(cb) = &self.on_evict {
|
||||
cb(&evicted_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a mutable reference to the handle for `id`, or `None`.
|
||||
///
|
||||
/// Updates the last-accessed timestamp and re-orders the entry to
|
||||
/// mark it as most-recently-used.
|
||||
pub fn get(&mut self, id: &str) -> Option<&mut DbHandle> {
|
||||
if let Some((key, mut entry)) = self.pools.shift_remove_entry(id) {
|
||||
entry.last_accessed = Instant::now();
|
||||
self.pools.insert(key, entry);
|
||||
// The newly inserted entry is at the end (MRU position)
|
||||
self.pools.last_mut().map(|(_, e)| &mut e.handle)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the pool with `id` from the manager.
|
||||
pub fn remove(&mut self, id: &str) {
|
||||
self.pools.shift_remove(id);
|
||||
}
|
||||
|
||||
/// Return a reference to the underlying pool map.
|
||||
pub(crate) fn pools(&self) -> &indexmap::IndexMap<String, DbPoolEntry> {
|
||||
&self.pools
|
||||
}
|
||||
|
||||
/// Return `true` if a pool with `id` is registered.
|
||||
pub fn contains(&self, id: &str) -> bool {
|
||||
self.pools.contains_key(id)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// DbConfig tests
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn create_pg_pool_with_minimal_config() {
|
||||
let cfg = DbConfig {
|
||||
db_type: "PostgreSQL".into(),
|
||||
host: "pg.example.com".into(),
|
||||
port: Some(5432),
|
||||
username: Some("admin".into()),
|
||||
password: Some("secret".into()),
|
||||
database: Some("mydb".into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(cfg.db_type, "PostgreSQL");
|
||||
assert_eq!(cfg.host, "pg.example.com");
|
||||
assert_eq!(cfg.port, Some(5432));
|
||||
assert_eq!(cfg.username.as_deref(), Some("admin"));
|
||||
assert_eq!(cfg.password.as_deref(), Some("secret"));
|
||||
assert_eq!(cfg.database.as_deref(), Some("mydb"));
|
||||
assert!(cfg.ssl_mode.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_config_for_sqlite_has_no_port() {
|
||||
let cfg = DbConfig::sqlite("/tmp/test.db");
|
||||
|
||||
assert_eq!(cfg.db_type, "SQLite");
|
||||
assert_eq!(cfg.host, "/tmp/test.db");
|
||||
assert!(cfg.port.is_none());
|
||||
assert!(cfg.username.is_none());
|
||||
assert!(cfg.database.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_config_ssh_config_is_none_when_no_host() {
|
||||
let cfg = DbConfig {
|
||||
db_type: "PostgreSQL".into(),
|
||||
host: "h".into(),
|
||||
port: Some(5432),
|
||||
username: None,
|
||||
password: None,
|
||||
database: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_password: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_passphrase: None,
|
||||
};
|
||||
assert!(cfg.ssh_config().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_config_ssh_config_builds_from_flat_fields() {
|
||||
let cfg = DbConfig {
|
||||
db_type: "PostgreSQL".into(),
|
||||
host: "db".into(),
|
||||
port: Some(5432),
|
||||
username: None,
|
||||
password: None,
|
||||
database: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
ssh_host: Some("jump".into()),
|
||||
ssh_port: Some(2222),
|
||||
ssh_user: Some("u".into()),
|
||||
ssh_auth_method: Some("password".into()),
|
||||
ssh_password: Some("pw".into()),
|
||||
ssh_private_key_path: None,
|
||||
ssh_passphrase: None,
|
||||
};
|
||||
let s = cfg.ssh_config().expect("ssh config present");
|
||||
assert_eq!(s.host, "jump");
|
||||
assert_eq!(s.port, 2222);
|
||||
assert_eq!(s.user, "u");
|
||||
assert_eq!(s.auth_method, "password");
|
||||
assert_eq!(s.password.as_deref(), Some("pw"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// ConnectionPoolManager tests
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn pool_manager_starts_empty() {
|
||||
let manager = ConnectionPoolManager::new();
|
||||
assert_eq!(manager.pools().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_manager_register_and_evict() {
|
||||
let mut manager = ConnectionPoolManager::new();
|
||||
manager.set_max_pools(2);
|
||||
|
||||
let conn_a = rusqlite::Connection::open_in_memory().unwrap();
|
||||
let conn_b = rusqlite::Connection::open_in_memory().unwrap();
|
||||
let conn_c = rusqlite::Connection::open_in_memory().unwrap();
|
||||
|
||||
// Register A, B, then C with max=2 -- A should be evicted (LRU)
|
||||
manager.register("a", DbHandle::Sqlite(conn_a));
|
||||
manager.register("b", DbHandle::Sqlite(conn_b));
|
||||
manager.register("c", DbHandle::Sqlite(conn_c));
|
||||
|
||||
assert_eq!(manager.pools().len(), 2);
|
||||
assert!(!manager.contains("a"), "'a' should have been evicted (LRU)");
|
||||
assert!(manager.contains("b"));
|
||||
assert!(manager.contains("c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_manager_remove_closes_pool() {
|
||||
let mut manager = ConnectionPoolManager::new();
|
||||
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
manager.register("tmp", DbHandle::Sqlite(conn));
|
||||
assert!(manager.contains("tmp"));
|
||||
|
||||
manager.remove("tmp");
|
||||
assert!(!manager.contains("tmp"));
|
||||
assert_eq!(manager.pools().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_manager_get_updates_access_time() {
|
||||
let mut manager = ConnectionPoolManager::new();
|
||||
manager.set_max_pools(3);
|
||||
|
||||
let conn_a = rusqlite::Connection::open_in_memory().unwrap();
|
||||
let conn_b = rusqlite::Connection::open_in_memory().unwrap();
|
||||
let conn_c = rusqlite::Connection::open_in_memory().unwrap();
|
||||
|
||||
manager.register("a", DbHandle::Sqlite(conn_a));
|
||||
manager.register("b", DbHandle::Sqlite(conn_b));
|
||||
manager.register("c", DbHandle::Sqlite(conn_c));
|
||||
|
||||
// Access "a" -- makes it MRU
|
||||
let _handle = manager.get("a").unwrap();
|
||||
|
||||
// Register "d" with max=3 -- "b" (now LRU) should be evicted, not "a"
|
||||
let conn_d = rusqlite::Connection::open_in_memory().unwrap();
|
||||
manager.register("d", DbHandle::Sqlite(conn_d));
|
||||
|
||||
assert_eq!(manager.pools().len(), 3);
|
||||
assert!(
|
||||
manager.contains("a"),
|
||||
"'a' was recently accessed, should survive"
|
||||
);
|
||||
assert!(!manager.contains("b"), "'b' is LRU and should be evicted");
|
||||
assert!(manager.contains("c"));
|
||||
assert!(manager.contains("d"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_invokes_on_evict_with_evicted_id() {
|
||||
let mut manager = ConnectionPoolManager::new();
|
||||
manager.set_max_pools(1);
|
||||
let evicted: std::sync::Arc<std::sync::Mutex<Vec<String>>> = std::sync::Arc::default();
|
||||
let evicted_cb = evicted.clone();
|
||||
manager.set_on_evict(Box::new(move |id: &str| {
|
||||
evicted_cb.lock().unwrap().push(id.to_string());
|
||||
}));
|
||||
manager.register(
|
||||
"a",
|
||||
DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()),
|
||||
);
|
||||
manager.register(
|
||||
"b",
|
||||
DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()),
|
||||
);
|
||||
assert_eq!(evicted.lock().unwrap().as_slice(), ["a".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_invokes_on_evict_on_max_pools_shrink() {
|
||||
let mut manager = ConnectionPoolManager::new();
|
||||
let evicted: std::sync::Arc<std::sync::Mutex<Vec<String>>> = std::sync::Arc::default();
|
||||
let evicted_cb = evicted.clone();
|
||||
manager.set_on_evict(Box::new(move |id: &str| {
|
||||
evicted_cb.lock().unwrap().push(id.to_string());
|
||||
}));
|
||||
manager.register(
|
||||
"a",
|
||||
DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()),
|
||||
);
|
||||
manager.register(
|
||||
"b",
|
||||
DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()),
|
||||
);
|
||||
// Shrinking max_pools below the current count evicts oldest first.
|
||||
manager.set_max_pools(1);
|
||||
assert_eq!(evicted.lock().unwrap().as_slice(), ["a".to_string()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mysql_handle_can_be_registered_and_evicted() {
|
||||
// Lazy pool: parses the URL without connecting (no network touch).
|
||||
let pool = sqlx::mysql::MySqlPoolOptions::new()
|
||||
.connect_lazy("mysql://__gridline_test__:3306/__none__")
|
||||
.expect("lazy pool parses url without connecting");
|
||||
let mut manager = ConnectionPoolManager::new();
|
||||
manager.set_max_pools(1);
|
||||
manager.register("mysql-conn", DbHandle::MySql(pool));
|
||||
assert!(matches!(
|
||||
manager.get("mysql-conn"),
|
||||
Some(DbHandle::MySql(_))
|
||||
));
|
||||
// Registering a second connection evicts the first (max_pools=1).
|
||||
let sqlite = rusqlite::Connection::open_in_memory().unwrap();
|
||||
manager.register("sqlite-conn", DbHandle::Sqlite(sqlite));
|
||||
assert!(manager.get("mysql-conn").is_none());
|
||||
assert!(matches!(
|
||||
manager.get("sqlite-conn"),
|
||||
Some(DbHandle::Sqlite(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
//! TLS connector factory for tokio-postgres.
|
||||
//!
|
||||
//! Maps the user-facing SSL modes to rustls `ClientConfig` values:
|
||||
//! - `disable` -> no TLS (returns `None`)
|
||||
//! - `require` -> encrypt without verifying the server certificate (custom `NoVerifier`)
|
||||
//! - `verify-ca` / `verify-full` -> standard rustls webpki verification (chain AND
|
||||
//! hostname; `verify-ca` is intentionally identical to `verify-full` in v1)
|
||||
//!
|
||||
//! Client certificates are supported via optional `cert_path` / `key_path` pair.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
|
||||
use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime};
|
||||
use rustls::{ClientConfig, DigitallySignedStruct, RootCertStore};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TlsDecision {
|
||||
Disable,
|
||||
Require,
|
||||
Verify,
|
||||
}
|
||||
|
||||
pub fn tls_decision(ssl_mode: Option<&str>) -> TlsDecision {
|
||||
match ssl_mode {
|
||||
Some("require") => TlsDecision::Require,
|
||||
Some("verify-ca") | Some("verify-full") => TlsDecision::Verify,
|
||||
_ => TlsDecision::Disable,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a rustls `ClientConfig` for tokio-postgres, or `None` for disable.
|
||||
/// `ca_path` is required for Verify; `cert_path`/`key_path` are optional client auth.
|
||||
pub fn build_tls_config(
|
||||
decision: TlsDecision,
|
||||
ca_path: Option<&str>,
|
||||
cert_path: Option<&str>,
|
||||
key_path: Option<&str>,
|
||||
) -> Result<Option<Arc<ClientConfig>>, String> {
|
||||
if matches!(decision, TlsDecision::Disable) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let client_auth = match (cert_path, key_path) {
|
||||
(Some(c), Some(k)) => Some(load_client_identity(c, k)?),
|
||||
(Some(_), None) | (None, Some(_)) => {
|
||||
return Err("both ssl_cert_path and ssl_key_path must be set for client auth".into())
|
||||
}
|
||||
(None, None) => None,
|
||||
};
|
||||
|
||||
let config = match decision {
|
||||
TlsDecision::Require => {
|
||||
// Encrypt without verifying the server certificate.
|
||||
let builder = ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(NoVerifier));
|
||||
match client_auth {
|
||||
Some((certs, key)) => builder
|
||||
.with_client_auth_cert(certs, key)
|
||||
.map_err(|e| format!("client cert: {e}"))?,
|
||||
None => builder.with_no_client_auth(),
|
||||
}
|
||||
}
|
||||
TlsDecision::Verify => {
|
||||
let mut roots = RootCertStore::empty();
|
||||
if let Some(ca) = ca_path {
|
||||
add_ca_file(&mut roots, ca)?;
|
||||
} else {
|
||||
return Err("ssl_ca_path is required for verify-ca / verify-full".into());
|
||||
}
|
||||
for ta in rustls_native_certs::load_native_certs()
|
||||
.map_err(|e| format!("native certs: {e}"))?
|
||||
{
|
||||
let _ = roots.add(ta);
|
||||
}
|
||||
let builder = ClientConfig::builder().with_root_certificates(roots);
|
||||
match client_auth {
|
||||
Some((certs, key)) => builder
|
||||
.with_client_auth_cert(certs, key)
|
||||
.map_err(|e| format!("client cert: {e}"))?,
|
||||
None => builder.with_no_client_auth(),
|
||||
}
|
||||
}
|
||||
TlsDecision::Disable => unreachable!(),
|
||||
};
|
||||
Ok(Some(Arc::new(config)))
|
||||
}
|
||||
|
||||
fn add_ca_file(roots: &mut RootCertStore, path: &str) -> Result<(), String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("failed to read CA file {path}: {e}"))?;
|
||||
let mut reader = std::io::BufReader::new(bytes.as_slice());
|
||||
let parsed = rustls_pemfile::certs(&mut reader)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| format!("parse CA: {e}"))?;
|
||||
let added = parsed.into_iter().filter_map(|c| roots.add(c).ok()).count();
|
||||
if added == 0 {
|
||||
return Err("no usable CA certificates found".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_client_identity(
|
||||
cert_path: &str,
|
||||
key_path: &str,
|
||||
) -> Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>), String> {
|
||||
// rustls-pemfile cannot decrypt PKCS#8-encrypted keys, so reject them up
|
||||
// front with a clear message before touching the certificate file.
|
||||
let kb = std::fs::read(key_path).map_err(|e| format!("read key: {e}"))?;
|
||||
if String::from_utf8_lossy(&kb).contains("ENCRYPTED PRIVATE KEY") {
|
||||
return Err(
|
||||
"encrypted client keys are not supported in v1; use an unencrypted PEM key".into(),
|
||||
);
|
||||
}
|
||||
let cb = std::fs::read(cert_path).map_err(|e| format!("read cert: {e}"))?;
|
||||
let certs: Vec<CertificateDer<'static>> =
|
||||
rustls_pemfile::certs(&mut std::io::BufReader::new(cb.as_slice()))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| format!("parse cert: {e}"))?
|
||||
.into_iter()
|
||||
.map(|c| c.into_owned())
|
||||
.collect();
|
||||
if certs.is_empty() {
|
||||
return Err("no client certificates parsed".into());
|
||||
}
|
||||
let key = rustls_pemfile::private_key(&mut std::io::BufReader::new(kb.as_slice()))
|
||||
.map_err(|e| format!("parse key: {e}"))?
|
||||
.ok_or_else(|| "no private key parsed".to_string())?
|
||||
.clone_key();
|
||||
Ok((certs, key))
|
||||
}
|
||||
|
||||
/// Accepts every certificate: TLS encryption without authentication (`require` mode).
|
||||
#[derive(Debug)]
|
||||
struct NoVerifier;
|
||||
|
||||
impl ServerCertVerifier for NoVerifier {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
_ee: &CertificateDer<'_>,
|
||||
_ic: &[CertificateDer<'_>],
|
||||
_n: &ServerName<'_>,
|
||||
_ocsp: &[u8],
|
||||
_now: UnixTime,
|
||||
) -> Result<ServerCertVerified, rustls::Error> {
|
||||
Ok(ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
rustls::crypto::ring::default_provider()
|
||||
.signature_verification_algorithms
|
||||
.supported_schemes()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tls_decision_maps_modes() {
|
||||
assert!(matches!(tls_decision(None), TlsDecision::Disable));
|
||||
assert!(matches!(
|
||||
tls_decision(Some("disable")),
|
||||
TlsDecision::Disable
|
||||
));
|
||||
assert!(matches!(
|
||||
tls_decision(Some("require")),
|
||||
TlsDecision::Require
|
||||
));
|
||||
assert!(matches!(
|
||||
tls_decision(Some("verify-ca")),
|
||||
TlsDecision::Verify
|
||||
));
|
||||
assert!(matches!(
|
||||
tls_decision(Some("verify-full")),
|
||||
TlsDecision::Verify
|
||||
));
|
||||
assert!(matches!(tls_decision(Some("bogus")), TlsDecision::Disable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tls_disable_returns_none() {
|
||||
assert!(build_tls_config(TlsDecision::Disable, None, None, None)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tls_require_returns_some_without_files() {
|
||||
assert!(build_tls_config(TlsDecision::Require, None, None, None)
|
||||
.unwrap()
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tls_verify_missing_ca_errors() {
|
||||
let err = build_tls_config(TlsDecision::Verify, Some("/nonexistent/ca.pem"), None, None)
|
||||
.unwrap_err();
|
||||
assert!(err.to_lowercase().contains("ca"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tls_client_cert_missing_key_errors() {
|
||||
// cert set without key
|
||||
let err = build_tls_config(
|
||||
TlsDecision::Require,
|
||||
None,
|
||||
Some("/nonexistent/cert.pem"),
|
||||
None,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.to_lowercase().contains("cert") || err.to_lowercase().contains("key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tls_rejects_encrypted_key_marker() {
|
||||
// rustls-pemfile cannot decrypt PKCS#8-encrypted keys, so an ENCRYPTED
|
||||
// PRIVATE KEY header must be rejected with a clear error. The cert file
|
||||
// is a dummy: the key check fires before the cert is read.
|
||||
let dir = std::env::temp_dir();
|
||||
let cert_path = dir.join("gl_tls_cert_dummy.pem");
|
||||
let key_path = dir.join("gl_tls_enc_key.pem");
|
||||
std::fs::write(
|
||||
&cert_path,
|
||||
"-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
&key_path,
|
||||
"-----BEGIN ENCRYPTED PRIVATE KEY-----\nabc\n-----END ENCRYPTED PRIVATE KEY-----\n",
|
||||
)
|
||||
.unwrap();
|
||||
let r = build_tls_config(
|
||||
TlsDecision::Require,
|
||||
None,
|
||||
Some(cert_path.to_str().unwrap()),
|
||||
Some(key_path.to_str().unwrap()),
|
||||
);
|
||||
assert!(r.is_err());
|
||||
assert!(
|
||||
r.unwrap_err().to_lowercase().contains("encrypt"),
|
||||
"must mention encryption"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// Infrastructure modules: types, introspection, and DB viewer commands are built ahead
|
||||
// of runtime usage, producing expected dead_code/unused warnings during development.
|
||||
#![allow(dead_code)]
|
||||
|
||||
mod cancel;
|
||||
mod commands;
|
||||
mod db;
|
||||
mod models;
|
||||
mod store;
|
||||
|
||||
use commands::ssh::{Ssh2Backend, SshTunnelManager};
|
||||
use db::pool::ConnectionPoolManager;
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
use store::Store;
|
||||
use tauri::Manager;
|
||||
|
||||
pub struct AppState {
|
||||
pub db_store: StdMutex<Store>,
|
||||
pub pool_manager: tokio::sync::Mutex<ConnectionPoolManager>,
|
||||
pub ssh_manager: StdMutex<SshTunnelManager>,
|
||||
pub cancel_registry: crate::cancel::CancelRegistry,
|
||||
}
|
||||
|
||||
use commands::{
|
||||
backup, connections, db_viewer, demo, folders, import_export, keychain, maintenance, objects, query,
|
||||
schema_graph, settings, tags,
|
||||
};
|
||||
|
||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||
#[tauri::command]
|
||||
fn greet(name: &str) -> String {
|
||||
format!("Hello, {}! You've been greeted from Rust!", name)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
// Install the ring crypto provider so rustls `ClientConfig::builder()` works (no-op if
|
||||
// another provider is already installed).
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_keyring_store::init())
|
||||
.setup(move |app| {
|
||||
// Open the local store under the OS app-data directory. When the
|
||||
// app is launched from Finder/LaunchServices the working directory
|
||||
// is `/`, so a relative "gridline.db" path panics ("failed to
|
||||
// open db", exit 101) before the UI ever starts. The demo DB uses
|
||||
// the same directory (see commands/demo.rs).
|
||||
let data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| format!("failed to resolve app data dir: {e}"))?;
|
||||
std::fs::create_dir_all(&data_dir)
|
||||
.map_err(|e| format!("failed to create app data dir: {e}"))?;
|
||||
let store =
|
||||
Store::open(&data_dir.join("gridline.db").to_string_lossy()).expect("failed to open db");
|
||||
|
||||
app.manage(AppState {
|
||||
db_store: StdMutex::new(store),
|
||||
pool_manager: tokio::sync::Mutex::new(ConnectionPoolManager::new()),
|
||||
ssh_manager: StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend))),
|
||||
cancel_registry: crate::cancel::CancelRegistry::default(),
|
||||
});
|
||||
|
||||
let state = app.state::<AppState>();
|
||||
demo::ensure_demo_db(app.handle(), &state.db_store)
|
||||
.map_err(|e| {
|
||||
eprintln!("Failed to set up demo DB: {e}");
|
||||
})
|
||||
.ok();
|
||||
|
||||
// Close the SSH tunnel for a connection when its pool is evicted
|
||||
// (LRU overflow or max-pool shrink). The hook captures a clone of
|
||||
// the app handle and resolves AppState through the manager.
|
||||
let handle = app.handle().clone();
|
||||
state
|
||||
.pool_manager
|
||||
.blocking_lock()
|
||||
.set_on_evict(Box::new(move |id: &str| {
|
||||
if let Some(s) = handle.try_state::<AppState>() {
|
||||
if let Ok(mut mgr) = s.ssh_manager.lock() {
|
||||
mgr.close_tunnel(id);
|
||||
}
|
||||
// Drop the cancel handles for the evicted connection
|
||||
// (tokens/interrupts outlive the pool otherwise).
|
||||
s.cancel_registry.remove(id);
|
||||
}
|
||||
}));
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
greet,
|
||||
connections::get_connections,
|
||||
connections::create_connection,
|
||||
connections::update_connection,
|
||||
connections::delete_connection,
|
||||
connections::add_connection_tags,
|
||||
connections::set_connection_favorite,
|
||||
connections::record_recent_connection,
|
||||
connections::get_recent_connections,
|
||||
connections::clear_recent_connections,
|
||||
folders::get_folders,
|
||||
folders::create_folder,
|
||||
folders::delete_folder,
|
||||
folders::update_folder,
|
||||
folders::add_folder_tags,
|
||||
tags::get_tags,
|
||||
tags::create_tag,
|
||||
tags::delete_tag,
|
||||
tags::update_tag,
|
||||
settings::get_settings,
|
||||
settings::update_setting,
|
||||
import_export::import_connections,
|
||||
import_export::export_connections,
|
||||
commands::test_connection::test_connection,
|
||||
db_viewer::db_connect,
|
||||
db_viewer::db_disconnect,
|
||||
db_viewer::get_databases,
|
||||
db_viewer::get_schemas,
|
||||
db_viewer::get_tables,
|
||||
db_viewer::get_table_data,
|
||||
db_viewer::get_table_columns,
|
||||
db_viewer::get_fk_preview,
|
||||
db_viewer::execute_change,
|
||||
db_viewer::get_table_ddl,
|
||||
db_viewer::refresh_connection,
|
||||
db_viewer::get_functions,
|
||||
db_viewer::get_triggers,
|
||||
db_viewer::get_sequences,
|
||||
db_viewer::get_enums,
|
||||
db_viewer::get_extensions,
|
||||
db_viewer::get_indexes,
|
||||
db_viewer::get_constraints,
|
||||
objects::create_schema,
|
||||
objects::rename_schema,
|
||||
objects::drop_schema,
|
||||
objects::search_objects,
|
||||
objects::get_object_ddl,
|
||||
objects::get_object_dependencies,
|
||||
objects::build_object_ddl,
|
||||
objects::build_rebuild_script,
|
||||
objects::get_available_extensions,
|
||||
objects::get_roles, objects::get_role_privileges, objects::get_table_rebuild_readiness, objects::get_tablespaces,
|
||||
maintenance::run_maintenance,
|
||||
keychain::save_connection_password,
|
||||
keychain::get_connection_password,
|
||||
keychain::delete_connection_password,
|
||||
keychain::save_connection_ssh_password,
|
||||
keychain::get_connection_ssh_password,
|
||||
keychain::delete_connection_ssh_password,
|
||||
keychain::save_connection_ssh_passphrase,
|
||||
keychain::get_connection_ssh_passphrase,
|
||||
keychain::delete_connection_ssh_passphrase,
|
||||
demo::recreate_demo_db,
|
||||
demo::regenerate_demo_db,
|
||||
backup::detect_pg_tools,
|
||||
backup::pg_dump,
|
||||
backup::pg_restore,
|
||||
backup::db_sync,
|
||||
backup::detect_mysql_tools,
|
||||
backup::mysql_dump,
|
||||
backup::mysql_restore,
|
||||
backup::mysql_sync,
|
||||
backup::sqlite_dump,
|
||||
backup::sqlite_restore,
|
||||
backup::sqlite_sync,
|
||||
settings::export_settings,
|
||||
settings::import_settings,
|
||||
schema_graph::get_schema_graph,
|
||||
query::execute_query,
|
||||
query::cancel_query,
|
||||
query::get_query_history,
|
||||
query::clear_query_history,
|
||||
query::set_history_favorite,
|
||||
query::save_query,
|
||||
query::get_saved_queries,
|
||||
query::update_saved_query,
|
||||
query::delete_saved_query,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app_handle, event| {
|
||||
// Close all SSH tunnels on exit: ExitRequested fires before the
|
||||
// event loop ends, Exit fires after it has.
|
||||
if matches!(
|
||||
event,
|
||||
tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit
|
||||
) {
|
||||
if let Ok(mut mgr) = app_handle.state::<AppState>().ssh_manager.lock() {
|
||||
mgr.close_all();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
gridline_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BackupOptions {
|
||||
pub format: String, // "plain" | "custom" | "tar" | "directory"
|
||||
pub file_path: String,
|
||||
pub schema: Option<String>,
|
||||
pub tables: Option<Vec<String>>,
|
||||
pub no_owner: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RestoreOptions {
|
||||
pub format: String,
|
||||
pub file_path: String,
|
||||
pub clean: bool,
|
||||
pub schema: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncOptions {
|
||||
pub source_connection_id: String,
|
||||
pub target_connection_id: String,
|
||||
pub schema: Option<String>,
|
||||
pub tables: Option<Vec<String>>,
|
||||
#[serde(default = "default_sync_db_type")]
|
||||
pub db_type: String,
|
||||
}
|
||||
|
||||
fn default_sync_db_type() -> String {
|
||||
"postgresql".into()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PgToolStatus {
|
||||
pub pg_dump_found: bool,
|
||||
pub pg_restore_found: bool,
|
||||
pub pg_dump_version: Option<String>,
|
||||
pub pg_restore_version: Option<String>,
|
||||
pub pg_dump_source: Option<String>, // "system" | "bundled" | None
|
||||
pub pg_restore_source: Option<String>,
|
||||
}
|
||||
|
||||
/// Resolved on-disk paths for the three client tools (system-first, bundled-fallback).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PgToolPaths {
|
||||
pub pg_dump: String,
|
||||
pub pg_restore: String,
|
||||
pub psql: String,
|
||||
}
|
||||
|
||||
/// Connection params for a MySQL server (decoupled from store/keychain so the
|
||||
/// dump/restore/sync core stays headless-testable). Mirrors `PgConnParams`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MySqlConnParams {
|
||||
pub host: String,
|
||||
pub port: i64,
|
||||
pub username: String,
|
||||
pub database: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl MySqlConnParams {
|
||||
pub fn new(host: String, port: i64, username: String, database: String, password: String) -> Self {
|
||||
Self { host, port, username, database, password }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MySqlBackupOptions {
|
||||
pub database: String,
|
||||
pub file_path: String,
|
||||
pub single_transaction: bool,
|
||||
pub no_data: bool,
|
||||
pub routines: bool,
|
||||
pub triggers: bool,
|
||||
pub events: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MySqlRestoreOptions {
|
||||
pub database: String,
|
||||
pub file_path: String,
|
||||
pub clean: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SqliteBackupOptions {
|
||||
pub file_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SqliteRestoreOptions {
|
||||
pub file_path: String,
|
||||
pub clean: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MySqlToolStatus {
|
||||
pub mysqldump_found: bool,
|
||||
pub mysql_found: bool,
|
||||
pub mysqldump_version: Option<String>,
|
||||
pub mysql_version: Option<String>,
|
||||
pub mysqldump_source: Option<String>,
|
||||
pub mysql_source: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MySqlToolPaths {
|
||||
pub mysqldump: String,
|
||||
pub mysql: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BackupJob {
|
||||
pub id: String,
|
||||
pub connection_id: String,
|
||||
pub r#type: String, // "dump" | "restore" | "sync"
|
||||
pub format: Option<String>,
|
||||
pub file_path: Option<String>,
|
||||
pub source_connection_id: Option<String>,
|
||||
pub status: String,
|
||||
pub error_message: Option<String>,
|
||||
pub size_bytes: Option<i64>,
|
||||
pub started_at: String,
|
||||
pub completed_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct BackupProgressEvent {
|
||||
pub job_id: String,
|
||||
pub status: String,
|
||||
pub progress: Option<f64>,
|
||||
pub output_line: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn backup_options_serialization() {
|
||||
let opts = BackupOptions {
|
||||
format: "plain".into(),
|
||||
file_path: "/tmp/dump.sql".into(),
|
||||
schema: Some("public".into()),
|
||||
tables: None,
|
||||
no_owner: true,
|
||||
};
|
||||
let json = serde_json::to_string(&opts).unwrap();
|
||||
assert!(json.contains("plain"));
|
||||
assert!(json.contains("noOwner"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_job_serialization() {
|
||||
let job = BackupJob {
|
||||
id: "job-1".into(),
|
||||
connection_id: "conn-1".into(),
|
||||
r#type: "dump".into(),
|
||||
format: Some("plain".into()),
|
||||
file_path: Some("/tmp/dump.sql".into()),
|
||||
source_connection_id: None,
|
||||
status: "completed".into(),
|
||||
error_message: None,
|
||||
size_bytes: Some(1024),
|
||||
started_at: "2025-07-28T10:00:00Z".into(),
|
||||
completed_at: Some("2025-07-28T10:01:00Z".into()),
|
||||
};
|
||||
let json = serde_json::to_string(&job).unwrap();
|
||||
assert!(json.contains("dump"));
|
||||
assert!(json.contains("completed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_tool_status_reports_source() {
|
||||
let s = PgToolStatus { pg_dump_found: true, pg_restore_found: true,
|
||||
pg_dump_version: Some("pg_dump 16".into()), pg_restore_version: Some("pg_restore 16".into()),
|
||||
pg_dump_source: Some("system".into()), pg_restore_source: Some("bundled".into()) };
|
||||
let json = serde_json::to_string(&s).unwrap();
|
||||
assert!(json.contains("\"pg_dump_source\":\"system\""));
|
||||
assert!(json.contains("\"pg_restore_source\":\"bundled\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_backup_options_serialize_camel_case() {
|
||||
let opts = MySqlBackupOptions {
|
||||
database: "shop".into(),
|
||||
file_path: "/tmp/dump.sql".into(),
|
||||
single_transaction: true,
|
||||
no_data: false,
|
||||
routines: true,
|
||||
triggers: true,
|
||||
events: false,
|
||||
};
|
||||
let json = serde_json::to_string(&opts).unwrap();
|
||||
assert!(json.contains("\"singleTransaction\":true"));
|
||||
assert!(json.contains("\"filePath\":\"/tmp/dump.sql\""));
|
||||
assert!(!json.contains("no_owner"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_restore_options_serialize_camel_case() {
|
||||
let opts = SqliteRestoreOptions { file_path: "/tmp/in.sql".into(), clean: true };
|
||||
let json = serde_json::to_string(&opts).unwrap();
|
||||
assert!(json.contains("\"filePath\":\"/tmp/in.sql\""));
|
||||
assert!(json.contains("\"clean\":true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_tool_status_reports_source() {
|
||||
let s = MySqlToolStatus {
|
||||
mysqldump_found: true,
|
||||
mysql_found: true,
|
||||
mysqldump_version: Some("mariadb-dump 10.6".into()),
|
||||
mysql_version: Some("mariadb 10.6".into()),
|
||||
mysqldump_source: Some("bundled".into()),
|
||||
mysql_source: Some("system".into()),
|
||||
};
|
||||
let json = serde_json::to_string(&s).unwrap();
|
||||
assert!(json.contains("\"mysqldumpSource\":\"bundled\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_options_carry_db_type() {
|
||||
let s = SyncOptions {
|
||||
source_connection_id: "a".into(),
|
||||
target_connection_id: "b".into(),
|
||||
schema: None,
|
||||
tables: None,
|
||||
db_type: "mysql".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&s).unwrap();
|
||||
assert!(json.contains("\"dbType\":\"mysql\""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Connection {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub db_type: String,
|
||||
pub host: String,
|
||||
pub port: Option<i64>,
|
||||
pub username: Option<String>,
|
||||
pub database: Option<String>,
|
||||
pub folder_id: Option<String>,
|
||||
pub keychain_ref: Option<String>,
|
||||
pub environment: Option<String>,
|
||||
pub favorite: bool,
|
||||
pub ssh_host: Option<String>,
|
||||
pub ssh_port: Option<i64>,
|
||||
pub ssh_user: Option<String>,
|
||||
pub ssh_auth_method: Option<String>,
|
||||
pub ssh_private_key_path: Option<String>,
|
||||
pub ssl_mode: Option<String>,
|
||||
pub ssl_ca_path: Option<String>,
|
||||
pub ssl_cert_path: Option<String>,
|
||||
pub ssl_key_path: Option<String>,
|
||||
pub tag_ids: Vec<String>,
|
||||
pub use_keychain: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConnectionInput {
|
||||
pub name: String,
|
||||
pub db_type: String,
|
||||
pub host: String,
|
||||
pub port: Option<i64>,
|
||||
pub username: Option<String>,
|
||||
pub folder_id: Option<String>,
|
||||
pub tag_ids: Vec<String>,
|
||||
pub password: Option<String>,
|
||||
pub database: Option<String>,
|
||||
pub environment: Option<String>,
|
||||
pub ssh_host: Option<String>,
|
||||
pub ssh_port: Option<i64>,
|
||||
pub ssh_user: Option<String>,
|
||||
pub ssh_auth_method: Option<String>,
|
||||
pub ssh_private_key_path: Option<String>,
|
||||
pub ssh_password: Option<String>,
|
||||
pub ssh_passphrase: Option<String>,
|
||||
pub ssl_mode: Option<String>,
|
||||
pub ssl_ca_path: Option<String>,
|
||||
pub ssl_cert_path: Option<String>,
|
||||
pub ssl_key_path: Option<String>,
|
||||
#[serde(default = "default_true")]
|
||||
pub use_keychain: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn connection_input_roundtrip_with_all_fields() {
|
||||
let input = ConnectionInput {
|
||||
name: "Test DB".to_string(),
|
||||
db_type: "PostgreSQL".to_string(),
|
||||
host: "db.example.com".to_string(),
|
||||
port: Some(5432),
|
||||
username: Some("admin".to_string()),
|
||||
folder_id: Some("folder1".to_string()),
|
||||
tag_ids: vec!["tag1".to_string(), "tag2".to_string()],
|
||||
use_keychain: true,
|
||||
password: Some("secret123".to_string()),
|
||||
database: Some("mydb".to_string()),
|
||||
ssh_host: Some("jumphost.example.com".to_string()),
|
||||
ssh_port: Some(2222),
|
||||
ssh_user: Some("tunnel".to_string()),
|
||||
ssh_auth_method: Some("Key".to_string()),
|
||||
ssh_private_key_path: Some("/path/to/key".to_string()),
|
||||
ssh_password: Some("ssh-pw".to_string()),
|
||||
ssh_passphrase: Some("passphrase".to_string()),
|
||||
ssl_mode: Some("require".to_string()),
|
||||
ssl_ca_path: Some("/path/to/ca".to_string()),
|
||||
ssl_cert_path: Some("/path/to/cert".to_string()),
|
||||
ssl_key_path: Some("/path/to/key".to_string()),
|
||||
environment: Some("production".to_string()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&input).unwrap();
|
||||
let deserialized: ConnectionInput = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(deserialized.name, "Test DB");
|
||||
assert_eq!(deserialized.db_type, "PostgreSQL");
|
||||
assert_eq!(deserialized.host, "db.example.com");
|
||||
assert_eq!(deserialized.port, Some(5432));
|
||||
assert_eq!(deserialized.username, Some("admin".to_string()));
|
||||
assert_eq!(deserialized.folder_id, Some("folder1".to_string()));
|
||||
assert_eq!(
|
||||
deserialized.tag_ids,
|
||||
vec!["tag1".to_string(), "tag2".to_string()]
|
||||
);
|
||||
assert_eq!(deserialized.password, Some("secret123".to_string()));
|
||||
assert_eq!(deserialized.database, Some("mydb".to_string()));
|
||||
assert_eq!(
|
||||
deserialized.ssh_host,
|
||||
Some("jumphost.example.com".to_string())
|
||||
);
|
||||
assert_eq!(deserialized.ssh_port, Some(2222));
|
||||
assert_eq!(deserialized.ssh_user, Some("tunnel".to_string()));
|
||||
assert_eq!(deserialized.ssh_auth_method, Some("Key".to_string()));
|
||||
assert_eq!(
|
||||
deserialized.ssh_private_key_path,
|
||||
Some("/path/to/key".to_string())
|
||||
);
|
||||
assert_eq!(deserialized.ssh_password, Some("ssh-pw".to_string()));
|
||||
assert_eq!(deserialized.ssh_passphrase, Some("passphrase".to_string()));
|
||||
assert_eq!(deserialized.ssl_mode, Some("require".to_string()));
|
||||
assert_eq!(deserialized.ssl_ca_path, Some("/path/to/ca".to_string()));
|
||||
assert_eq!(
|
||||
deserialized.ssl_cert_path,
|
||||
Some("/path/to/cert".to_string())
|
||||
);
|
||||
assert_eq!(deserialized.ssl_key_path, Some("/path/to/key".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_persisted_does_not_include_password() {
|
||||
let conn = Connection {
|
||||
id: "test-id".to_string(),
|
||||
name: "Test".to_string(),
|
||||
db_type: "PostgreSQL".to_string(),
|
||||
host: "localhost".to_string(),
|
||||
port: Some(5432),
|
||||
username: Some("user".to_string()),
|
||||
folder_id: Some("folder".to_string()),
|
||||
keychain_ref: Some("keychain-ref".to_string()),
|
||||
environment: None,
|
||||
favorite: false,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
created_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
database: Some("mydb".to_string()),
|
||||
ssh_host: Some("ssh-host".to_string()),
|
||||
ssh_port: Some(2222),
|
||||
ssh_user: Some("ssh-user".to_string()),
|
||||
ssh_auth_method: Some("Key".to_string()),
|
||||
ssh_private_key_path: Some("/path/to/key".to_string()),
|
||||
ssl_mode: Some("require".to_string()),
|
||||
ssl_ca_path: Some("/path/to/ca".to_string()),
|
||||
ssl_cert_path: Some("/path/to/cert".to_string()),
|
||||
ssl_key_path: Some("/path/to/key".to_string()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&conn).unwrap();
|
||||
assert!(
|
||||
!json.contains("password"),
|
||||
"Connection JSON should not contain password field"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_serializes_favorite_field() {
|
||||
let conn = Connection {
|
||||
id: "x".into(),
|
||||
name: "n".into(),
|
||||
db_type: "postgresql".into(),
|
||||
host: "h".into(),
|
||||
port: Some(5432),
|
||||
username: None,
|
||||
database: None,
|
||||
folder_id: None,
|
||||
keychain_ref: None,
|
||||
environment: None,
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
favorite: true,
|
||||
created_at: "2024-01-01T00:00:00Z".into(),
|
||||
updated_at: "2024-01-01T00:00:00Z".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&conn).unwrap();
|
||||
assert!(json.contains("\"favorite\":true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_input_use_keychain_defaults_true_when_absent() {
|
||||
let json = r#"{"name":"n","db_type":"postgresql","host":"h","port":5432,"tag_ids":[]}"#;
|
||||
let input: ConnectionInput = serde_json::from_str(json).unwrap();
|
||||
assert!(input.use_keychain, "absent use_keychain defaults to true (opt-out)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_input_use_keychain_preserves_false() {
|
||||
let json = r#"{"name":"n","db_type":"postgresql","host":"h","port":5432,"tag_ids":[],"use_keychain":false}"#;
|
||||
let input: ConnectionInput = serde_json::from_str(json).unwrap();
|
||||
assert!(!input.use_keychain);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,733 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A single filter rule sent from the frontend.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FilterRule {
|
||||
pub id: String,
|
||||
pub column: String,
|
||||
pub operator: String, // "eq" | "neq" | "contains" | "starts" | "ends" | "gt" | "lt" | "null" | "notnull"
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// A single sort rule sent from the frontend.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SortRule {
|
||||
pub id: String,
|
||||
pub column: String,
|
||||
pub order: String, // "asc" | "desc"
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TableInfo {
|
||||
pub name: String,
|
||||
pub schema: String,
|
||||
pub table_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ColumnInfo {
|
||||
pub name: String,
|
||||
pub data_type: String,
|
||||
pub is_nullable: bool,
|
||||
pub is_pk: bool,
|
||||
pub is_fk: bool,
|
||||
pub fk_ref: Option<(String, String)>,
|
||||
pub default_value: Option<String>,
|
||||
pub editable: bool,
|
||||
pub is_generated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IndexInfo {
|
||||
pub name: String,
|
||||
pub schema: String,
|
||||
pub table: String,
|
||||
pub definition: String,
|
||||
pub is_unique: bool,
|
||||
pub method: String,
|
||||
pub columns: Vec<String>,
|
||||
pub size_bytes: Option<i64>,
|
||||
pub tablespace: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConstraintInfo {
|
||||
pub name: String,
|
||||
pub schema: String,
|
||||
pub table: String,
|
||||
pub contype: String, // "CHECK" | "UNIQUE" | "EXCLUSION"
|
||||
pub definition: String,
|
||||
pub deferrable: bool,
|
||||
pub validated: bool,
|
||||
pub columns: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QueryResult {
|
||||
pub columns: Vec<ColumnInfo>,
|
||||
pub rows: Vec<Vec<serde_json::Value>>,
|
||||
pub total_rows: i64,
|
||||
pub page: i64,
|
||||
pub page_size: i64,
|
||||
pub execution_time_ms: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Pagination {
|
||||
pub page: i64,
|
||||
pub page_size: i64,
|
||||
pub total_rows: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FunctionInfo {
|
||||
pub name: String,
|
||||
pub schema: String,
|
||||
pub return_type: String,
|
||||
pub argument_types: Vec<String>,
|
||||
pub argument_names: Vec<String>,
|
||||
pub argument_modes: Vec<String>,
|
||||
pub language: String,
|
||||
pub source: Option<String>,
|
||||
pub kind: String, // 'f' = function, 'p' = procedure
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TriggerInfo {
|
||||
pub name: String,
|
||||
pub schema: String,
|
||||
pub table_schema: String,
|
||||
pub table_name: String,
|
||||
pub event_manipulation: String,
|
||||
pub action_timing: String,
|
||||
pub action_orientation: String,
|
||||
pub action_statement: String,
|
||||
pub enabled: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SequenceInfo {
|
||||
pub name: String,
|
||||
pub schema: String,
|
||||
pub start_value: String,
|
||||
pub min_value: String,
|
||||
pub max_value: String,
|
||||
pub increment: String,
|
||||
pub current_value: String,
|
||||
pub cycle: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EnumInfo {
|
||||
pub name: String,
|
||||
pub schema: String,
|
||||
pub labels: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExtensionInfo {
|
||||
pub name: String,
|
||||
pub schema: String,
|
||||
pub version: String,
|
||||
pub comment: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ObjectSearchHit {
|
||||
pub name: String,
|
||||
pub schema: String,
|
||||
pub object_type: String, // TABLE | VIEW | MATERIALIZED VIEW | FUNCTION | PROCEDURE | TRIGGER | SEQUENCE | ENUM | EXTENSION | INDEX | CONSTRAINT
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DependencyInfo {
|
||||
pub deptype: String, // "n" (normal) | "a" (auto)
|
||||
pub class: String, // pg_class | pg_proc | pg_trigger | pg_type | pg_constraint | pg_rewrite
|
||||
pub name: String, // resolved dependent object name
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RoleMembership {
|
||||
pub role: String,
|
||||
pub member: String,
|
||||
pub grantor: String,
|
||||
pub admin_option: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RoleInfo {
|
||||
pub name: String,
|
||||
pub superuser: bool,
|
||||
pub inherit: bool,
|
||||
pub create_db: bool,
|
||||
pub create_role: bool,
|
||||
pub can_login: bool,
|
||||
pub replication: bool,
|
||||
pub bypass_rls: bool,
|
||||
pub connection_limit: i64,
|
||||
pub valid_until: Option<String>,
|
||||
pub memberships: Vec<RoleMembership>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PrivilegeEntry {
|
||||
pub object_class: String, // "table" | "sequence" | "routine" | "schema" | "database"
|
||||
pub schema: Option<String>,
|
||||
pub name: String,
|
||||
pub privileges: Vec<String>,
|
||||
pub grantable: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RebuildReadiness {
|
||||
pub ok: bool,
|
||||
pub reasons: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MaintenanceResult {
|
||||
pub duration_ms: i64,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TablespaceInfo {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Change {
|
||||
Update {
|
||||
id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
primary_key: String,
|
||||
old_data: String,
|
||||
new_data: String,
|
||||
},
|
||||
Insert {
|
||||
id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
data: String,
|
||||
},
|
||||
Delete {
|
||||
id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
primary_key: String,
|
||||
},
|
||||
AlterTable {
|
||||
id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
sql: String,
|
||||
rollback_sql: String,
|
||||
},
|
||||
BulkInsert {
|
||||
id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
columns: Vec<String>,
|
||||
rows: Vec<Vec<serde_json::Value>>,
|
||||
},
|
||||
DropTable {
|
||||
id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
},
|
||||
EmptyTable {
|
||||
id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
},
|
||||
Ddl {
|
||||
id: String,
|
||||
sql: String,
|
||||
},
|
||||
RebuildTable {
|
||||
id: String,
|
||||
sql: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Change {
|
||||
pub fn id(&self) -> &str {
|
||||
match self {
|
||||
Change::Update { id, .. }
|
||||
| Change::Insert { id, .. }
|
||||
| Change::Delete { id, .. }
|
||||
| Change::AlterTable { id, .. }
|
||||
| Change::BulkInsert { id, .. }
|
||||
| Change::DropTable { id, .. }
|
||||
| Change::EmptyTable { id, .. }
|
||||
| Change::Ddl { id, .. }
|
||||
| Change::RebuildTable { id, .. } => id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete schema graph for the ER diagram visualizer.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SchemaGraph {
|
||||
pub tables: Vec<TableNode>,
|
||||
pub relationships: Vec<Relationship>,
|
||||
}
|
||||
|
||||
/// A table node in the schema graph, including all columns.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TableNode {
|
||||
pub name: String,
|
||||
pub schema: String,
|
||||
pub table_type: String,
|
||||
pub columns: Vec<GraphColumn>,
|
||||
}
|
||||
|
||||
/// Column metadata for schema graph visualization.
|
||||
///
|
||||
/// Includes PK/FK/UNIQUE flags and an optional foreign-key reference
|
||||
/// (referenced_schema, referenced_table, referenced_column).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GraphColumn {
|
||||
pub name: String,
|
||||
pub data_type: String,
|
||||
pub is_pk: bool,
|
||||
pub is_fk: bool,
|
||||
pub is_unique: bool,
|
||||
pub is_nullable: bool,
|
||||
pub fk_ref: Option<(String, String, String)>,
|
||||
}
|
||||
|
||||
/// A foreign-key relationship between two tables.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Relationship {
|
||||
pub source_schema: String,
|
||||
pub source_table: String,
|
||||
pub source_column: String,
|
||||
pub target_schema: String,
|
||||
pub target_table: String,
|
||||
pub target_column: String,
|
||||
/// Inferred cardinality: "1:1", "1:N", or "N:M"
|
||||
pub cardinality: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn table_info_serialization() {
|
||||
let info = TableInfo {
|
||||
name: "users".to_string(),
|
||||
schema: "public".to_string(),
|
||||
table_type: "TABLE".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&info).unwrap();
|
||||
assert!(json.contains("users"));
|
||||
assert!(json.contains("public"));
|
||||
assert!(json.contains("TABLE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_result_can_be_empty() {
|
||||
let result = QueryResult {
|
||||
columns: vec![],
|
||||
rows: vec![],
|
||||
total_rows: 0,
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
execution_time_ms: None,
|
||||
};
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
assert!(json.contains(r#""rows":[]"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_id_method() {
|
||||
let update = Change::Update {
|
||||
id: "chg-1".to_string(),
|
||||
schema: "public".to_string(),
|
||||
table: "users".to_string(),
|
||||
primary_key: "{\"id\": 1}".to_string(),
|
||||
old_data: "{\"name\": \"old\"}".to_string(),
|
||||
new_data: "{\"name\": \"new\"}".to_string(),
|
||||
};
|
||||
assert_eq!(update.id(), "chg-1");
|
||||
|
||||
let inserted = Change::Insert {
|
||||
id: "chg-2".to_string(),
|
||||
schema: "public".to_string(),
|
||||
table: "users".to_string(),
|
||||
data: "{\"name\": \"alice\"}".to_string(),
|
||||
};
|
||||
assert_eq!(inserted.id(), "chg-2");
|
||||
|
||||
let deleted = Change::Delete {
|
||||
id: "chg-3".to_string(),
|
||||
schema: "public".to_string(),
|
||||
table: "users".to_string(),
|
||||
primary_key: "{\"id\": 2}".to_string(),
|
||||
};
|
||||
assert_eq!(deleted.id(), "chg-3");
|
||||
|
||||
let alter = Change::AlterTable {
|
||||
id: "chg-4".to_string(),
|
||||
schema: "public".to_string(),
|
||||
table: "users".to_string(),
|
||||
sql: "ALTER TABLE users ADD COLUMN age INT".to_string(),
|
||||
rollback_sql: "ALTER TABLE users DROP COLUMN age".to_string(),
|
||||
};
|
||||
assert_eq!(alter.id(), "chg-4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_serde_tag() {
|
||||
let update = Change::Update {
|
||||
id: "chg-1".to_string(),
|
||||
schema: "public".to_string(),
|
||||
table: "users".to_string(),
|
||||
primary_key: "{\"id\": 1}".to_string(),
|
||||
old_data: "{\"name\": \"old\"}".to_string(),
|
||||
new_data: "{\"name\": \"new\"}".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(
|
||||
json.contains(r#""type":"update""#),
|
||||
"serialized Change::Update should use snake_case tag 'update'; got: {}",
|
||||
json
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_bulk_insert_roundtrip() {
|
||||
let json = serde_json::json!({
|
||||
"type": "bulk_insert", "id": "x", "schema": "public", "table": "t",
|
||||
"columns": ["a", "b"],
|
||||
"rows": [[1, "y"], [2, "z"]]
|
||||
});
|
||||
let c: Change = serde_json::from_value(json).unwrap();
|
||||
match c {
|
||||
Change::BulkInsert { columns, rows, .. } => {
|
||||
assert_eq!(columns, vec!["a".to_string(), "b".to_string()]);
|
||||
assert_eq!(rows.len(), 2);
|
||||
}
|
||||
_ => panic!("expected BulkInsert"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_drop_and_empty_roundtrip() {
|
||||
let drop: Change = serde_json::from_value(serde_json::json!({
|
||||
"type": "drop_table", "id": "d", "schema": "public", "table": "t"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(drop.id(), "d");
|
||||
let empty: Change = serde_json::from_value(serde_json::json!({
|
||||
"type": "empty_table", "id": "e", "schema": "public", "table": "t"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(empty.id(), "e");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_info_fk_ref() {
|
||||
let col = ColumnInfo {
|
||||
name: "user_id".to_string(),
|
||||
data_type: "integer".to_string(),
|
||||
is_nullable: true,
|
||||
is_pk: false,
|
||||
is_fk: true,
|
||||
fk_ref: Some(("users".to_string(), "id".to_string())),
|
||||
default_value: None,
|
||||
editable: true,
|
||||
is_generated: false,
|
||||
};
|
||||
let json = serde_json::to_string(&col).unwrap();
|
||||
assert!(json.contains("user_id"));
|
||||
assert!(json.contains("users"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_info_has_editability_fields() {
|
||||
let c = ColumnInfo {
|
||||
name: "id".into(),
|
||||
data_type: "integer".into(),
|
||||
is_nullable: false,
|
||||
is_pk: true,
|
||||
is_fk: false,
|
||||
fk_ref: None,
|
||||
default_value: None,
|
||||
editable: false,
|
||||
is_generated: false,
|
||||
};
|
||||
let json = serde_json::to_string(&c).unwrap();
|
||||
assert!(json.contains("\"editable\":false"));
|
||||
assert!(json.contains("\"is_generated\":false"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pagination_serialization() {
|
||||
let pagination = Pagination {
|
||||
page: 2,
|
||||
page_size: 50,
|
||||
total_rows: 250,
|
||||
};
|
||||
let json = serde_json::to_string(&pagination).unwrap();
|
||||
assert!(json.contains(r#""page":2"#));
|
||||
assert!(json.contains(r#""page_size":50"#));
|
||||
assert!(json.contains(r#""total_rows":250"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_info_serialization() {
|
||||
let info = FunctionInfo {
|
||||
name: "get_user".into(),
|
||||
schema: "public".into(),
|
||||
return_type: "TABLE(id integer, name text)".into(),
|
||||
argument_types: vec!["integer".into()],
|
||||
argument_names: vec!["p_id".into()],
|
||||
argument_modes: vec!["IN".into()],
|
||||
language: "plpgsql".into(),
|
||||
source: Some("BEGIN RETURN; END;".into()),
|
||||
kind: "f".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&info).unwrap();
|
||||
assert!(json.contains("get_user"));
|
||||
assert!(json.contains("plpgsql"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trigger_info_serialization() {
|
||||
let info = TriggerInfo {
|
||||
name: "trg_audit".into(),
|
||||
schema: "public".into(),
|
||||
table_schema: "public".into(),
|
||||
table_name: "users".into(),
|
||||
event_manipulation: "INSERT".into(),
|
||||
action_timing: "AFTER".into(),
|
||||
action_orientation: "ROW".into(),
|
||||
action_statement: "EXECUTE FUNCTION audit_log()".into(),
|
||||
enabled: "O".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&info).unwrap();
|
||||
assert!(json.contains("trg_audit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_info_serialization() {
|
||||
let info = SequenceInfo {
|
||||
name: "users_id_seq".into(),
|
||||
schema: "public".into(),
|
||||
start_value: "1".into(),
|
||||
min_value: "1".into(),
|
||||
max_value: "9223372036854775807".into(),
|
||||
increment: "1".into(),
|
||||
current_value: "42".into(),
|
||||
cycle: false,
|
||||
};
|
||||
let json = serde_json::to_string(&info).unwrap();
|
||||
assert!(json.contains("users_id_seq"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enum_info_serialization() {
|
||||
let info = EnumInfo {
|
||||
name: "user_role".into(),
|
||||
schema: "public".into(),
|
||||
labels: vec!["admin".into(), "editor".into(), "viewer".into()],
|
||||
};
|
||||
let json = serde_json::to_string(&info).unwrap();
|
||||
assert!(json.contains("admin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_info_serialization() {
|
||||
let info = ExtensionInfo {
|
||||
name: "pg_stat_statements".into(),
|
||||
schema: "public".into(),
|
||||
version: "1.10".into(),
|
||||
comment: Some("track SQL statistics".into()),
|
||||
};
|
||||
let json = serde_json::to_string(&info).unwrap();
|
||||
assert!(json.contains("pg_stat_statements"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_graph_serialization() {
|
||||
let graph = SchemaGraph {
|
||||
tables: vec![TableNode {
|
||||
name: "users".into(),
|
||||
schema: "public".into(),
|
||||
table_type: "TABLE".into(),
|
||||
columns: vec![
|
||||
GraphColumn {
|
||||
name: "id".into(),
|
||||
data_type: "integer".into(),
|
||||
is_pk: true,
|
||||
is_fk: false,
|
||||
is_unique: true,
|
||||
is_nullable: false,
|
||||
fk_ref: None,
|
||||
},
|
||||
GraphColumn {
|
||||
name: "email".into(),
|
||||
data_type: "text".into(),
|
||||
is_pk: false,
|
||||
is_fk: false,
|
||||
is_unique: true,
|
||||
is_nullable: false,
|
||||
fk_ref: None,
|
||||
},
|
||||
],
|
||||
}],
|
||||
relationships: vec![Relationship {
|
||||
source_schema: "public".into(),
|
||||
source_table: "orders".into(),
|
||||
source_column: "user_id".into(),
|
||||
target_schema: "public".into(),
|
||||
target_table: "users".into(),
|
||||
target_column: "id".into(),
|
||||
cardinality: "1:N".into(),
|
||||
}],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&graph).unwrap();
|
||||
assert!(json.contains("users"), "should contain table name");
|
||||
assert!(
|
||||
json.contains("orders"),
|
||||
"should contain relationship source table"
|
||||
);
|
||||
assert!(json.contains("1:N"), "should contain cardinality");
|
||||
assert!(json.contains("is_pk"), "should contain is_pk field");
|
||||
assert!(json.contains("is_fk"), "should contain is_fk field");
|
||||
assert!(json.contains("is_unique"), "should contain is_unique field");
|
||||
|
||||
// Round-trip deserialization
|
||||
let parsed: SchemaGraph = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.tables.len(), 1);
|
||||
assert_eq!(parsed.tables[0].columns.len(), 2);
|
||||
assert_eq!(parsed.relationships.len(), 1);
|
||||
assert_eq!(parsed.relationships[0].cardinality, "1:N");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_graph_empty_is_valid() {
|
||||
let graph = SchemaGraph {
|
||||
tables: vec![],
|
||||
relationships: vec![],
|
||||
};
|
||||
let json = serde_json::to_string(&graph).unwrap();
|
||||
let parsed: SchemaGraph = serde_json::from_str(&json).unwrap();
|
||||
assert!(parsed.tables.is_empty());
|
||||
assert!(parsed.relationships.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_column_fk_ref_serialization() {
|
||||
// fk_ref = None
|
||||
let col_none = GraphColumn {
|
||||
name: "name".into(),
|
||||
data_type: "text".into(),
|
||||
is_pk: false,
|
||||
is_fk: false,
|
||||
is_unique: false,
|
||||
is_nullable: false,
|
||||
fk_ref: None,
|
||||
};
|
||||
let json = serde_json::to_string(&col_none).unwrap();
|
||||
assert!(
|
||||
json.contains("null"),
|
||||
"fk_ref=None should serialize as null"
|
||||
);
|
||||
|
||||
// fk_ref = Some(...)
|
||||
let col_some = GraphColumn {
|
||||
name: "user_id".into(),
|
||||
data_type: "integer".into(),
|
||||
is_pk: false,
|
||||
is_fk: true,
|
||||
is_unique: false,
|
||||
is_nullable: false,
|
||||
fk_ref: Some(("public".into(), "users".into(), "id".into())),
|
||||
};
|
||||
let json = serde_json::to_string(&col_some).unwrap();
|
||||
assert!(json.contains("public"), "should contain referenced schema");
|
||||
assert!(json.contains("users"), "should contain referenced table");
|
||||
assert!(json.contains("id"), "should contain referenced column");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_search_hit_tagged_roundtrip() {
|
||||
let hit = ObjectSearchHit { name: "users".into(), schema: "public".into(), object_type: "TABLE".into() };
|
||||
let json = serde_json::to_string(&hit).unwrap();
|
||||
assert!(json.contains("\"object_type\":\"TABLE\""));
|
||||
let back: ObjectSearchHit = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.name, "users");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_info_roundtrip() {
|
||||
let d = DependencyInfo { deptype: "n".into(), class: "pg_class".into(), name: "v_users".into() };
|
||||
let json = serde_json::to_string(&d).unwrap();
|
||||
assert!(json.contains("\"deptype\":\"n\""));
|
||||
assert!(json.contains("v_users"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_rebuild_table_serializes_with_snake_case_tag() {
|
||||
let change = Change::RebuildTable {
|
||||
id: "chg-rb1".to_string(),
|
||||
sql: "CREATE TABLE _t ...".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&change).unwrap();
|
||||
assert!(
|
||||
json.contains(r#""type":"rebuild_table""#),
|
||||
"RebuildTable should serialize with tag 'rebuild_table'; got: {json}"
|
||||
);
|
||||
assert_eq!(change.id(), "chg-rb1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_rebuild_table_roundtrip() {
|
||||
let json = serde_json::json!({
|
||||
"type": "rebuild_table", "id": "rb", "sql": "SELECT 1"
|
||||
});
|
||||
let c: Change = serde_json::from_value(json).unwrap();
|
||||
match c {
|
||||
Change::RebuildTable { sql, .. } => assert_eq!(sql, "SELECT 1"),
|
||||
_ => panic!("expected RebuildTable"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_info_roundtrip() {
|
||||
let r = RoleInfo {
|
||||
name: "app".into(), superuser: false, inherit: true, create_db: false,
|
||||
create_role: false, can_login: true, replication: false, bypass_rls: false,
|
||||
connection_limit: -1, valid_until: None, memberships: vec![RoleMembership {
|
||||
role: "parent".into(), member: "app".into(), grantor: "admin".into(), admin_option: false,
|
||||
}],
|
||||
};
|
||||
let json = serde_json::to_string(&r).unwrap();
|
||||
assert!(json.contains(r#""name":"app""#));
|
||||
assert!(json.contains(r#""can_login":true"#));
|
||||
let back: RoleInfo = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.memberships.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privilege_entry_and_readiness_roundtrip() {
|
||||
let pe = PrivilegeEntry {
|
||||
object_class: "table".into(), schema: Some("public".into()), name: "users".into(),
|
||||
privileges: vec!["SELECT".into(), "INSERT".into()], grantable: false,
|
||||
};
|
||||
assert!(serde_json::to_string(&pe).unwrap().contains(r#""object_class":"table""#));
|
||||
let rr = RebuildReadiness { ok: false, reasons: vec!["has triggers".into()] };
|
||||
assert!(serde_json::to_string(&rr).unwrap().contains(r#""ok":false"#));
|
||||
let mr = MaintenanceResult { duration_ms: 42, message: "ok".into() };
|
||||
assert!(serde_json::to_string(&mr).unwrap().contains(r#""duration_ms":42"#));
|
||||
let ts = TablespaceInfo { name: "pg_default".into() };
|
||||
assert!(serde_json::to_string(&ts).unwrap().contains("pg_default"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Folder {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub parent_id: Option<String>,
|
||||
pub tag_ids: Vec<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FolderInput {
|
||||
pub name: String,
|
||||
pub parent_id: Option<String>,
|
||||
pub tag_ids: Option<Vec<String>>,
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
pub mod backup;
|
||||
pub mod connection;
|
||||
pub mod db_viewer;
|
||||
pub mod folder;
|
||||
pub mod recent;
|
||||
pub mod settings;
|
||||
pub mod ssh;
|
||||
pub mod tag;
|
||||
|
||||
pub use connection::{Connection, ConnectionInput};
|
||||
#[allow(unused_imports)]
|
||||
pub use db_viewer::{Change, ColumnInfo, FilterRule, MaintenanceResult, Pagination, PrivilegeEntry, QueryResult, RebuildReadiness, RoleInfo, RoleMembership, SortRule, TableInfo, TablespaceInfo};
|
||||
pub use folder::{Folder, FolderInput};
|
||||
pub use recent::RecentConnection;
|
||||
pub use settings::Settings;
|
||||
pub use ssh::SshConfig;
|
||||
pub use tag::{Tag, TagInput};
|
||||
@@ -0,0 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RecentConnection {
|
||||
pub connection_id: String,
|
||||
pub opened_at: String,
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
pub confirm_before_delete: bool,
|
||||
pub default_folder_id: Option<String>,
|
||||
pub theme: String,
|
||||
pub font_size: String,
|
||||
pub default_ports: HashMap<String, Option<i64>>,
|
||||
pub tag_order: Option<String>,
|
||||
pub table_refresh_rate: i64,
|
||||
pub table_page_size: i64,
|
||||
pub shortcuts: HashMap<String, String>,
|
||||
pub accent_color: String,
|
||||
// Editor (Plan A)
|
||||
pub editor_font_size: i64,
|
||||
pub editor_font_family: String,
|
||||
pub editor_word_wrap: String,
|
||||
pub editor_minimap: bool,
|
||||
pub editor_tab_size: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SettingsExport {
|
||||
pub schema_version: u32,
|
||||
pub settings: Settings,
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// SSH tunnel configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SshConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub user: String,
|
||||
/// "password" or "key"
|
||||
pub auth_method: String,
|
||||
pub password: Option<String>,
|
||||
pub private_key_path: Option<String>,
|
||||
pub passphrase: Option<String>,
|
||||
}
|
||||
|
||||
impl SshConfig {
|
||||
/// Create a new `SshConfig` with the required fields.
|
||||
pub fn new(host: String, port: u16, user: String, auth_method: String) -> Self {
|
||||
SshConfig {
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
auth_method,
|
||||
password: None,
|
||||
private_key_path: None,
|
||||
passphrase: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate SSH configuration.
|
||||
///
|
||||
/// Returns `true` if:
|
||||
/// - `host` is not empty
|
||||
/// - `port` is in range 1..=65535 (u16 guarantees <= 65535)
|
||||
/// - `user` is not empty
|
||||
pub fn is_valid(&self) -> bool {
|
||||
!self.host.is_empty() && self.port >= 1 && !self.user.is_empty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Tag {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub color: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TagInput {
|
||||
pub name: String,
|
||||
pub color: String,
|
||||
}
|
||||
@@ -0,0 +1,618 @@
|
||||
use rusqlite::Connection;
|
||||
|
||||
/// All new-column additions for the connections table since version 1.
|
||||
const CONNECTION_COLUMNS_V2: &[(&str, &str)] = &[
|
||||
("database", "TEXT"),
|
||||
("ssh_host", "TEXT"),
|
||||
("ssh_port", "INTEGER"),
|
||||
("ssh_user", "TEXT"),
|
||||
("ssh_auth_method", "TEXT"),
|
||||
("ssh_private_key_path", "TEXT"),
|
||||
("ssl_mode", "TEXT"),
|
||||
("ssl_ca_path", "TEXT"),
|
||||
("ssl_cert_path", "TEXT"),
|
||||
("ssl_key_path", "TEXT"),
|
||||
];
|
||||
|
||||
/// New columns added since version 2.
|
||||
const CONNECTION_COLUMNS_V3: &[(&str, &str)] = &[("environment", "TEXT")];
|
||||
|
||||
/// New columns added in version 7.
|
||||
const CONNECTION_COLUMNS_V7: &[(&str, &str)] = &[("favorite", "INTEGER NOT NULL DEFAULT 0")];
|
||||
|
||||
pub fn run_migrations(conn: &Connection) -> Result<(), String> {
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY);
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
parent_id TEXT REFERENCES folders(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS connections (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
db_type TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER,
|
||||
username TEXT,
|
||||
database TEXT,
|
||||
folder_id TEXT REFERENCES folders(id) ON DELETE SET NULL,
|
||||
keychain_ref TEXT,
|
||||
ssh_host TEXT,
|
||||
ssh_port INTEGER,
|
||||
ssh_user TEXT,
|
||||
ssh_auth_method TEXT,
|
||||
ssh_private_key_path TEXT,
|
||||
ssl_mode TEXT,
|
||||
ssl_ca_path TEXT,
|
||||
ssl_cert_path TEXT,
|
||||
ssl_key_path TEXT,
|
||||
environment TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
color TEXT NOT NULL DEFAULT '#8b5cf6',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS connection_tags (
|
||||
connection_id TEXT NOT NULL REFERENCES connections(id) ON DELETE CASCADE,
|
||||
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (connection_id, tag_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS folder_tags (
|
||||
folder_id TEXT NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
|
||||
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (folder_id, tag_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// --- Version-specific migrations ----------------------------------------
|
||||
|
||||
let current_ver: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COALESCE(MAX(version), 0) FROM schema_version",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
|
||||
if current_ver < 2 {
|
||||
// Discover which columns the connections table already has.
|
||||
let existing: Vec<String> = {
|
||||
let mut stmt = conn
|
||||
.prepare("PRAGMA table_info(connections)")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| row.get::<_, String>(1))
|
||||
.map_err(|e| e.to_string())?;
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
for (col_name, col_type) in CONNECTION_COLUMNS_V2 {
|
||||
if !existing.contains(&col_name.to_string()) {
|
||||
let sql = format!(
|
||||
"ALTER TABLE connections ADD COLUMN {} {}",
|
||||
col_name, col_type
|
||||
);
|
||||
conn.execute(&sql, []).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
// Record the migration.
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (2)", [])
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
if current_ver < 3 {
|
||||
let existing: Vec<String> = {
|
||||
let mut stmt = conn
|
||||
.prepare("PRAGMA table_info(connections)")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| row.get::<_, String>(1))
|
||||
.map_err(|e| e.to_string())?;
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
for (col_name, col_type) in CONNECTION_COLUMNS_V3 {
|
||||
if !existing.contains(&col_name.to_string()) {
|
||||
let sql = format!(
|
||||
"ALTER TABLE connections ADD COLUMN {} {}",
|
||||
col_name, col_type
|
||||
);
|
||||
conn.execute(&sql, []).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (3)", [])
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// v4: backup_history
|
||||
if current_ver < 4 {
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS backup_history (
|
||||
id TEXT PRIMARY KEY,
|
||||
connection_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('dump', 'restore', 'sync')),
|
||||
format TEXT,
|
||||
file_path TEXT,
|
||||
source_connection_id TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'running'
|
||||
CHECK(status IN ('running', 'completed', 'failed', 'cancelled')),
|
||||
error_message TEXT,
|
||||
size_bytes INTEGER,
|
||||
started_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
);",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (4)", [])
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// v5: query_history
|
||||
if current_ver < 5 {
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS query_history (
|
||||
id TEXT PRIMARY KEY,
|
||||
connection_id TEXT NOT NULL,
|
||||
query_text TEXT NOT NULL,
|
||||
execution_time_ms INTEGER,
|
||||
row_count INTEGER,
|
||||
status TEXT NOT NULL CHECK(status IN ('success', 'error')),
|
||||
error_message TEXT,
|
||||
executed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (connection_id) REFERENCES connections(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_query_history_connection
|
||||
ON query_history(connection_id, executed_at DESC);",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (5)", [])
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// v6: query history favorites + saved queries
|
||||
if current_ver < 6 {
|
||||
conn.execute_batch(
|
||||
"ALTER TABLE query_history ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0;
|
||||
CREATE TABLE IF NOT EXISTS queries (
|
||||
id TEXT PRIMARY KEY,
|
||||
connection_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
query_text TEXT NOT NULL,
|
||||
folder TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (connection_id) REFERENCES connections(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_queries_connection ON queries(connection_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_queries_folder ON queries(folder);",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (6)", [])
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// v7: connection favorites + recent_connections
|
||||
if current_ver < 7 {
|
||||
let existing: Vec<String> = {
|
||||
let mut stmt = conn
|
||||
.prepare("PRAGMA table_info(connections)")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| row.get::<_, String>(1))
|
||||
.map_err(|e| e.to_string())?;
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
for (col_name, col_type) in CONNECTION_COLUMNS_V7 {
|
||||
if !existing.contains(&col_name.to_string()) {
|
||||
let sql = format!(
|
||||
"ALTER TABLE connections ADD COLUMN {} {}",
|
||||
col_name, col_type
|
||||
);
|
||||
conn.execute(&sql, []).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS recent_connections (
|
||||
connection_id TEXT PRIMARY KEY REFERENCES connections(id) ON DELETE CASCADE,
|
||||
opened_at TEXT NOT NULL
|
||||
);",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (7)", [])
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// v8: use_keychain opt-out flag on connections
|
||||
if current_ver < 8 {
|
||||
let existing: Vec<String> = {
|
||||
let mut stmt = conn
|
||||
.prepare("PRAGMA table_info(connections)")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| row.get::<_, String>(1))
|
||||
.map_err(|e| e.to_string())?;
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
if !existing.iter().any(|c| c == "use_keychain") {
|
||||
conn.execute(
|
||||
"ALTER TABLE connections ADD COLUMN use_keychain INTEGER NOT NULL DEFAULT 1",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (8)", [])
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::Connection;
|
||||
|
||||
fn fresh_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrations_create_all_tables() {
|
||||
let conn = fresh_db();
|
||||
let tables: Vec<String> = conn
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
||||
.unwrap()
|
||||
.query_map([], |row| row.get(0))
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
assert!(tables.contains(&"folders".to_string()));
|
||||
assert!(tables.contains(&"connections".to_string()));
|
||||
assert!(tables.contains(&"tags".to_string()));
|
||||
assert!(tables.contains(&"connection_tags".to_string()));
|
||||
assert!(tables.contains(&"settings".to_string()));
|
||||
assert!(tables.contains(&"schema_version".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_creates_backup_history_table() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM backup_history", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrations_are_idempotent() {
|
||||
let conn = fresh_db();
|
||||
// Running again must not error
|
||||
run_migrations(&conn).unwrap();
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM schema_version", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_v8_adds_use_keychain_column_default_1() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
// v7 baseline
|
||||
run_migrations(&conn).unwrap();
|
||||
// simulate an existing connection row (pre-v8 shape had no use_keychain)
|
||||
conn.execute(
|
||||
"INSERT INTO connections (id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, created_at, updated_at) VALUES ('c1','n','postgresql','h',5432,'u','d',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'t','t','t')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
let uses: i64 = conn
|
||||
.query_row("SELECT use_keychain FROM connections WHERE id='c1'", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(uses, 1, "existing connections default to use_keychain=1 (ON)");
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM schema_version WHERE version=8", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 1, "schema_version row 8 inserted exactly once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_v8_is_idempotent() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
let v8: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM schema_version WHERE version=8", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(v8, 1);
|
||||
// PRAGMA confirms exactly one use_keychain column
|
||||
let cols: Vec<String> = conn
|
||||
.prepare("PRAGMA table_info(connections)")
|
||||
.unwrap()
|
||||
.query_map([], |r| r.get::<_, String>(1))
|
||||
.unwrap()
|
||||
.filter_map(Result::ok)
|
||||
.collect();
|
||||
assert_eq!(cols.iter().filter(|c| c == &"use_keychain").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v5_creates_query_history_table() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
// Verify the table exists
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM query_history", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
// Verify columns via PRAGMA
|
||||
let columns: Vec<String> = {
|
||||
let mut stmt = conn.prepare("PRAGMA table_info(query_history)").unwrap();
|
||||
let rows = stmt.query_map([], |row| row.get::<_, String>(1)).unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
assert!(columns.contains(&"id".to_string()));
|
||||
assert!(columns.contains(&"connection_id".to_string()));
|
||||
assert!(columns.contains(&"query_text".to_string()));
|
||||
assert!(columns.contains(&"execution_time_ms".to_string()));
|
||||
assert!(columns.contains(&"row_count".to_string()));
|
||||
assert!(columns.contains(&"status".to_string()));
|
||||
assert!(columns.contains(&"error_message".to_string()));
|
||||
assert!(columns.contains(&"executed_at".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_history_cascades_on_connection_delete() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
// Insert a connection
|
||||
let conn_id = "test-conn-id";
|
||||
conn.execute(
|
||||
"INSERT INTO connections (id, name, db_type, host, port, created_at, updated_at) VALUES (?1, 't', 'postgresql', 'h', 5432, datetime('now'), datetime('now'))",
|
||||
rusqlite::params![conn_id],
|
||||
).unwrap();
|
||||
// Insert query history entry
|
||||
conn.execute(
|
||||
"INSERT INTO query_history (id, connection_id, query_text, status, executed_at) VALUES ('qh1', ?1, 'SELECT 1', 'success', datetime('now'))",
|
||||
rusqlite::params![conn_id],
|
||||
).unwrap();
|
||||
// Delete connection — should cascade
|
||||
conn.execute(
|
||||
"DELETE FROM connections WHERE id = ?1",
|
||||
rusqlite::params![conn_id],
|
||||
)
|
||||
.unwrap();
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM query_history WHERE connection_id = ?1",
|
||||
rusqlite::params![conn_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v6_adds_favorite_column_to_query_history() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
// Verify the favorite column exists via PRAGMA
|
||||
let columns: Vec<String> = {
|
||||
let mut stmt = conn.prepare("PRAGMA table_info(query_history)").unwrap();
|
||||
let rows = stmt.query_map([], |row| row.get::<_, String>(1)).unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
assert!(
|
||||
columns.contains(&"favorite".to_string()),
|
||||
"Expected query_history to have a 'favorite' column after v6 migration"
|
||||
);
|
||||
// Existing rows default to 0
|
||||
let fav: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM query_history WHERE favorite != 0",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(fav, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v6_creates_queries_table() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
// Table exists
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM queries", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
// Column check
|
||||
let columns: Vec<String> = {
|
||||
let mut stmt = conn.prepare("PRAGMA table_info(queries)").unwrap();
|
||||
let rows = stmt.query_map([], |row| row.get::<_, String>(1)).unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
for c in &[
|
||||
"id",
|
||||
"connection_id",
|
||||
"name",
|
||||
"query_text",
|
||||
"folder",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
] {
|
||||
assert!(
|
||||
columns.contains(&c.to_string()),
|
||||
"Expected queries table to have column: {}",
|
||||
c
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v6_queries_cascade_on_connection_delete() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
// Insert a connection
|
||||
let conn_id = "test-qc-conn";
|
||||
conn.execute(
|
||||
"INSERT INTO connections (id, name, db_type, host, port, created_at, updated_at) VALUES (?1, 't', 'postgresql', 'h', 5432, datetime('now'), datetime('now'))",
|
||||
rusqlite::params![conn_id],
|
||||
).unwrap();
|
||||
// Insert a saved query for that connection
|
||||
conn.execute(
|
||||
"INSERT INTO queries (id, connection_id, name, query_text, folder) VALUES ('q1', ?1, 'my query', 'SELECT 1', '')",
|
||||
rusqlite::params![conn_id],
|
||||
).unwrap();
|
||||
// Insert a global saved query (connection_id NULL)
|
||||
conn.execute(
|
||||
"INSERT INTO queries (id, connection_id, name, query_text, folder) VALUES ('q2', NULL, 'global query', 'SELECT 2', '')",
|
||||
[],
|
||||
).unwrap();
|
||||
// Delete connection — should cascade the non-NULL row
|
||||
conn.execute(
|
||||
"DELETE FROM connections WHERE id = ?1",
|
||||
rusqlite::params![conn_id],
|
||||
)
|
||||
.unwrap();
|
||||
let count_scoped: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM queries WHERE connection_id = ?1",
|
||||
rusqlite::params![conn_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
count_scoped, 0,
|
||||
"Scoped saved query should be cascade-deleted"
|
||||
);
|
||||
// NULL-saved query survives
|
||||
let count_global: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM queries WHERE id = 'q2'", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
count_global, 1,
|
||||
"Global saved query (connection_id NULL) should survive"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v6_bumps_schema_version_to_6() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM schema_version WHERE version = 6",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
count, 1,
|
||||
"Schema version 6 should be recorded after v6 migration"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v7_adds_favorite_column_to_connections() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
let columns: Vec<String> = {
|
||||
let mut stmt = conn.prepare("PRAGMA table_info(connections)").unwrap();
|
||||
let rows = stmt.query_map([], |row| row.get::<_, String>(1)).unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
assert!(
|
||||
columns.contains(&"favorite".to_string()),
|
||||
"Expected connections to have a 'favorite' column after v7"
|
||||
);
|
||||
let conn_id = "fav-test";
|
||||
conn.execute(
|
||||
"INSERT INTO connections (id, name, db_type, host, port, created_at, updated_at) VALUES (?1, 't', 'postgresql', 'h', 5432, datetime('now'), datetime('now'))",
|
||||
rusqlite::params![conn_id],
|
||||
).unwrap();
|
||||
let fav: i64 = conn
|
||||
.query_row(
|
||||
"SELECT favorite FROM connections WHERE id = ?1",
|
||||
rusqlite::params![conn_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(fav, 0, "favorite defaults to 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v7_creates_recent_connections_table() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM recent_connections", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v7_recent_connections_cascade_on_connection_delete() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
let conn_id = "rc-cascade";
|
||||
conn.execute(
|
||||
"INSERT INTO connections (id, name, db_type, host, port, created_at, updated_at) VALUES (?1, 't', 'postgresql', 'h', 5432, datetime('now'), datetime('now'))",
|
||||
rusqlite::params![conn_id],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO recent_connections (connection_id, opened_at) VALUES (?1, datetime('now'))",
|
||||
rusqlite::params![conn_id],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"DELETE FROM connections WHERE id = ?1",
|
||||
rusqlite::params![conn_id],
|
||||
)
|
||||
.unwrap();
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM recent_connections WHERE connection_id = ?1",
|
||||
rusqlite::params![conn_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v8_bumps_schema_version_to_8() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
let ver: i64 = conn
|
||||
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(ver, 8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Gridline",
|
||||
"version": "0.7.10",
|
||||
"identifier": "com.adrianbonpin.gridline",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "bun run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Gridline",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"backgroundColor": "#0A0A0B",
|
||||
"titleBarStyle": "Overlay"
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"resources": ["resources/pg_tools/*", "resources/mysql_tools/*"],
|
||||
"macOS": {
|
||||
"signingIdentity": "-",
|
||||
"hardenedRuntime": false
|
||||
},
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
},
|
||||
"plugins": {}
|
||||
}
|
||||