v0.7.0: New Connection screen revamp + full MySQL DB viewer (#10)
* docs: correct competitor comparison for DB Pro, Beekeeper, TablePlus Research-verified the 'Why Gridline vs the alternatives' claims against vendor docs, pricing pages, GitHub, and release notes (May 2026): - DB Pro is an Electron app (founder-confirmed), not native; add TablePlus column to the comparison table - Fix wrong cells: DB Pro has query/dashboard folders + table tags and CSV/JSON export on the free tier; object-explorer depth corrected for DB Pro (tables/views/indexes/enums) and Beekeeper (tables/views/routines/triggers) - Reframe differentiators: unlimited-everything framing dropped for Beekeeper (free tier is already unlimited on tabs/connections/queries); keep DB-to-DB sync as the genuinely unique feature - Add a dated 'Competitor reality check' section to AGENTS.md so future edits don't re-assert inaccurate claims * docs: add project roadmap, link it from README and AGENTS New ROADMAP.md is the source of truth for planned work, reflecting the in-flight v0.7.0 connection-screen-revamp spec (new-connection flow, full MySQL DB viewer, capability gating, Supabase/Neon presets, SQLite path mode, tag overflow scroll, styling sweep). Next-up scope: PostgreSQL object management CRUD with companion features (schema CRUD, global object search, copy-as-DDL, object dependencies) and an admin follow-up (users/roles/grants, VACUUM/ANALYZE/REINDEX). MySQL Objects view explicitly deferred. Queue: Redis browsing, MariaDB/TimescaleDB, PlanetScale/Turso, query workbench upgrades (multiple result sets, query cancel, result streaming, visual query builder), schema/data tooling, SQLite .dump, schema diff, more export formats. Planned: BYOK AI, website & docs, rolling UI/UX polish (incl. onboarding tour, settings import/export, SSH key management). README roadmap section now links to ROADMAP.md; AGENTS.md Related Documents + Implementation Status reference it and the v0.7.0 spec. * docs: release notes reference prod as the production branch The repo's production branch is prod (feature branches merge back to prod), not main. Update the release-cut instructions in the README and the trigger comment in release.yml. * docs: add robust bug report issue template Structured .github/ISSUE_TEMPLATE/bug_report.md covering environment (OS, Gridline version, install type, DB type/version, hosted provider, connection method incl. SSH/TLS/socket), steps to reproduce, expected vs actual, screenshots, logs, impact, and workarounds — plus a duplicate checklist and secrets-redaction note. Referenced from the README Contributing section. * docs: drop in-flight branch mention from roadmap; remove unused starter assets - ROADMAP.md no longer references the in-flight feature branch/spec (removed at the end anyway when the branch PRs into prod) - Remove unused Vite/Tauri starter SVGs from public/ (no favicon or asset references anywhere in the app) * test: fix stale README comparison-table regex in docs-coverage (5-col table) * feat: shared INPUT_ROUNDING constant + bump to v0.7.0 (Task 1.1) * fix: map SQLite file path to host field + provider host detection (Task 1.2) * test: bump version expectation to 0.7.0 (Task 1.1 follow-up) * feat: db capability matrix for DB viewer gating (Task 1.3) * feat: provider tab definitions, Supabase/Neon icons + setup guides (Task 1.4) * feat(rust): MySQL SQL builders + identifier quoting (Task 2.1) * chore(rust): sync Cargo.lock to gridline 0.7.0 * feat(rust): MySQL db_connect (SSL + SSH tunnel) + pool variant (Tasks 2.2-2.3) * feat(rust): MySQL execute_query with wrapped pagination + raw fallback (Task 2.4) * feat(rust): MySQL introspection + changes-queue editing + DDL (Task 2.5) * fix(ui): show table toolbar immediately while tab is still loading first data * feat: gate DB viewer sidebar nav by db capabilities (Task 3.1) * feat: guard DB viewer views by capability + Redis unsupported state (Task 3.2) * feat(ui): 2-column provider tab grid (Task 4.1) * feat(ui): collapsible Supabase/Neon setup guide (Task 4.2) * feat(ui): SQLite file-path input with Browse (Task 4.3) * feat(ui): connection metadata row (label + tags/env/folder) (Task 4.4) * feat(ui): rework GeneralTab (URI + OR + manual) + reduce Detailed form tabs (Task 4.5) * feat(ui): NewConnectionScreen two-stage flow; remove SimpleConnectionForm (Task 5.1) * feat(ui): scroll connection-card tag row past 3 tags (Task 5.2) * style: sweep form controls from rounded-full to rounded-lg (Task 5.3) * feat(ui): EditConnectionModal parity + managed-preset SSL hint (Task 5.4) * fix(rust): decode MySQL VARBINARY metadata columns (information_schema/SHOW) as strings * test: full suite green for v0.7.0 connection revamp (Task 5.5) * feat(ui): schema dropdown + tables tree loading state while schema tree fetches * docs: update AGENTS/README/ROADMAP for v0.7.0 (connection revamp, MySQL viewer, gating)
This commit is contained in:
@@ -3,12 +3,13 @@
|
||||
//! This module provides pure SQL builder functions, pagination helpers,
|
||||
//! and Tauri commands for the database viewer.
|
||||
|
||||
use crate::db::pool::{DbConfig, DbHandle};
|
||||
use crate::db::pool::{ConnectionPoolManager, DbConfig, DbHandle};
|
||||
use crate::models::db_viewer::{
|
||||
Change, ColumnInfo, ConstraintInfo, EnumInfo, ExtensionInfo, FunctionInfo, IndexInfo,
|
||||
QueryResult, SequenceInfo, TableInfo, TriggerInfo,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use sqlx::Row;
|
||||
use tauri::State;
|
||||
use tokio_postgres::types::ToSql;
|
||||
|
||||
@@ -728,7 +729,7 @@ pub async fn apply_bulk_insert_pg(
|
||||
|
||||
/// Sanitize a raw error string before it crosses the IPC boundary: redact
|
||||
/// credential-like fragments (connection URLs, `password=...`) and cap length.
|
||||
fn sanitize_error(e: &str) -> String {
|
||||
pub(crate) fn sanitize_error(e: &str) -> String {
|
||||
truncate(&redact_secrets(e), 400)
|
||||
}
|
||||
|
||||
@@ -878,6 +879,117 @@ where
|
||||
Ok((client, handle))
|
||||
}
|
||||
|
||||
/// Headless MySQL connect (no Tauri `State`). Opens an SSH tunnel when
|
||||
/// configured (binding 127.0.0.1 only), maps SSL modes, and registers a
|
||||
/// `DbHandle::MySql` pool. Errors are sanitized so no `mysql://user:pass@host`
|
||||
/// text leaks across the IPC boundary.
|
||||
pub(crate) async fn run_mysql_connect(
|
||||
connection_id: &str,
|
||||
config: &crate::db::pool::DbConfig,
|
||||
ssh_manager: &std::sync::Mutex<crate::commands::ssh::SshTunnelManager>,
|
||||
pool_manager: &tokio::sync::Mutex<crate::db::pool::ConnectionPoolManager>,
|
||||
) -> Result<(), String> {
|
||||
use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions, MySqlSslMode};
|
||||
if config.host.trim().is_empty() {
|
||||
return Err("host is required".to_string());
|
||||
}
|
||||
|
||||
let target_host: String;
|
||||
let target_port: u16;
|
||||
let via_tunnel: bool;
|
||||
|
||||
if let Some(ssh_cfg) = config.ssh_config() {
|
||||
let key = connection_id.to_string();
|
||||
let remote_host = config.host.clone();
|
||||
let remote_port = config.port.unwrap_or(3306) as u16;
|
||||
let pw = config.ssh_password.clone();
|
||||
let pp = config.ssh_passphrase.clone();
|
||||
let backend = ssh_manager.lock().unwrap().backend_clone();
|
||||
let tunnel = tokio::task::spawn_blocking(move || {
|
||||
backend.open(
|
||||
&key,
|
||||
&ssh_cfg,
|
||||
&remote_host,
|
||||
remote_port,
|
||||
pw.as_deref(),
|
||||
pp.as_deref(),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Connection failed: {e}"))?
|
||||
.map_err(|e| sanitize_error(&e))?;
|
||||
let lp = tunnel.local_port;
|
||||
ssh_manager
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert_tunnel(connection_id.to_string(), tunnel);
|
||||
target_host = "127.0.0.1".to_string();
|
||||
target_port = lp;
|
||||
via_tunnel = true;
|
||||
} else {
|
||||
target_host = config.host.clone();
|
||||
target_port = config.port.unwrap_or(3306) as u16;
|
||||
via_tunnel = false;
|
||||
}
|
||||
|
||||
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()),
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match MySqlPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.acquire_timeout(std::time::Duration::from_secs(10))
|
||||
.connect_with(opts)
|
||||
.await
|
||||
{
|
||||
Ok(pool) => {
|
||||
pool_manager
|
||||
.lock()
|
||||
.await
|
||||
.register(connection_id, crate::db::pool::DbHandle::MySql(pool));
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
if via_tunnel {
|
||||
ssh_manager.lock().unwrap().close_tunnel(connection_id);
|
||||
}
|
||||
Err(format!(
|
||||
"Connection failed: {}",
|
||||
sanitize_error(&format!("{e}"))
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn db_connect(
|
||||
connection_id: String,
|
||||
@@ -992,6 +1104,14 @@ pub async fn db_connect(
|
||||
}
|
||||
Err(e) => Err(format!("Connection failed: {}", e)),
|
||||
}
|
||||
} else if config.db_type == "mysql" {
|
||||
run_mysql_connect(
|
||||
&connection_id,
|
||||
&config,
|
||||
&state.ssh_manager,
|
||||
&state.pool_manager,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
Err(format!(
|
||||
"Database type '{}' not yet supported for DB viewer",
|
||||
@@ -1031,6 +1151,19 @@ pub async fn get_databases(
|
||||
// SQLite has a single database per file; expose the catalog name.
|
||||
Ok(vec!["main".to_string()])
|
||||
}
|
||||
Some(crate::db::pool::DbHandle::MySql(pool)) => {
|
||||
let pool = &*pool; // Executor is implemented for &Pool, not &mut Pool
|
||||
let rows = sqlx::query(&crate::db::mysql::mysql_databases_query())
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||
let mut dbs: Vec<String> = rows
|
||||
.iter()
|
||||
.map(|r| crate::db::mysql::mysql_row_string(r, 0))
|
||||
.collect();
|
||||
dbs.retain(|d| !crate::db::mysql::MYSQL_SYSTEM_DBS.contains(&d.as_str()));
|
||||
Ok(dbs)
|
||||
}
|
||||
None => Err("Connection not found".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -1061,20 +1194,36 @@ pub async fn get_schemas(
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(rows.filter_map(|r| r.ok()).collect())
|
||||
}
|
||||
Some(crate::db::pool::DbHandle::MySql(pool)) => {
|
||||
let pool = &*pool; // Executor is implemented for &Pool, not &mut Pool
|
||||
// MySQL has no separate schema layer — databases play the role of
|
||||
// schemas, so the schema selector mirrors the database list.
|
||||
let rows = sqlx::query(&crate::db::mysql::mysql_databases_query())
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||
let mut dbs: Vec<String> = rows
|
||||
.iter()
|
||||
.map(|r| crate::db::mysql::mysql_row_string(r, 0))
|
||||
.collect();
|
||||
dbs.retain(|d| !crate::db::mysql::MYSQL_SYSTEM_DBS.contains(&d.as_str()));
|
||||
Ok(dbs)
|
||||
}
|
||||
None => Err("Connection not found".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_tables(
|
||||
connection_id: String,
|
||||
schema: Option<String>,
|
||||
state: State<'_, crate::AppState>,
|
||||
/// Headless table listing shared by the `get_tables` command and integration
|
||||
/// tests (thin-command principle — no Tauri `State`).
|
||||
pub(crate) async fn get_tables_inner(
|
||||
pool_manager: &tokio::sync::Mutex<ConnectionPoolManager>,
|
||||
connection_id: &str,
|
||||
schema: Option<&str>,
|
||||
) -> Result<Vec<TableInfo>, String> {
|
||||
let mut pm = state.pool_manager.lock().await;
|
||||
match pm.get(&connection_id) {
|
||||
let mut pm = pool_manager.lock().await;
|
||||
match pm.get(connection_id) {
|
||||
Some(crate::db::pool::DbHandle::Postgresql(client, _)) => {
|
||||
let schema_filter = schema.unwrap_or_else(|| "public".to_string());
|
||||
let schema_filter = schema.unwrap_or("public").to_string();
|
||||
let rows = client
|
||||
.query(
|
||||
"SELECT table_name, table_schema, table_type FROM information_schema.tables WHERE table_schema = $1 AND table_type IN ('BASE TABLE', 'VIEW') ORDER BY table_name",
|
||||
@@ -1109,20 +1258,151 @@ pub async fn get_tables(
|
||||
rows.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
Some(crate::db::pool::DbHandle::MySql(pool)) => {
|
||||
let pool = &*pool; // Executor is implemented for &Pool, not &mut Pool
|
||||
// 2 columns (name, type) when a schema is given; 3 (+ schema)
|
||||
// when not — hence sqlx::query + try_get, not query_as.
|
||||
let query = crate::db::introspection::mysql_tables_query(schema);
|
||||
let rows = sqlx::query(&query)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let name = crate::db::mysql::mysql_row_string(r, 0);
|
||||
let raw_type = crate::db::mysql::mysql_row_string(r, 1);
|
||||
let schema_name: String = match schema {
|
||||
Some(s) => s.to_string(),
|
||||
None => crate::db::mysql::mysql_row_string(r, 2),
|
||||
};
|
||||
let table_type = if raw_type.eq_ignore_ascii_case("view") {
|
||||
"VIEW"
|
||||
} else {
|
||||
"TABLE"
|
||||
};
|
||||
TableInfo {
|
||||
name,
|
||||
schema: schema_name,
|
||||
table_type: table_type.to_string(),
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
None => Err("Connection not found".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_table_data(
|
||||
pub async fn get_tables(
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
schema: Option<String>,
|
||||
state: State<'_, crate::AppState>,
|
||||
) -> Result<Vec<TableInfo>, String> {
|
||||
get_tables_inner(&state.pool_manager, &connection_id, schema.as_deref()).await
|
||||
}
|
||||
|
||||
/// Load column metadata (name, type, nullability, PK, default, generated) for
|
||||
/// a MySQL table, then mark FK columns with their referenced (table, column).
|
||||
///
|
||||
/// PK and generated columns are read-only (`editable: false`), mirroring the
|
||||
/// PostgreSQL rule. `fk_ref` carries the referenced table + column only — the
|
||||
/// same contract the frontend expects (MySQL FKs are assumed to live in the
|
||||
/// same database, matching how PostgreSQL's `fk_ref` assumes the same schema).
|
||||
pub(crate) async fn mysql_load_columns(
|
||||
pool: &sqlx::MySqlPool,
|
||||
schema: &str,
|
||||
table: &str,
|
||||
) -> Result<Vec<ColumnInfo>, String> {
|
||||
let col_query = crate::db::mysql::mysql_columns_query(schema, table);
|
||||
let col_rows = sqlx::query(&col_query)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||
let mut columns: Vec<ColumnInfo> = col_rows
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let name = crate::db::mysql::mysql_row_string(r, 0);
|
||||
let data_type = crate::db::mysql::mysql_row_string(r, 1);
|
||||
let is_nullable = crate::db::mysql::mysql_row_string(r, 2);
|
||||
let column_key = crate::db::mysql::mysql_row_string(r, 3);
|
||||
let default_value: Option<String> = r
|
||||
.try_get::<String, _>(4)
|
||||
.ok()
|
||||
.or_else(|| r.try_get::<Vec<u8>, _>(4).ok().map(|b| String::from_utf8_lossy(&b).into_owned()));
|
||||
let extra = crate::db::mysql::mysql_row_string(r, 5);
|
||||
let is_pk = column_key == "PRI";
|
||||
let is_generated = extra.to_ascii_uppercase().contains("GENERATED");
|
||||
ColumnInfo {
|
||||
name: name.clone(),
|
||||
data_type,
|
||||
is_nullable: is_nullable == "YES",
|
||||
is_pk,
|
||||
is_fk: false,
|
||||
fk_ref: None,
|
||||
default_value,
|
||||
editable: !is_pk && !is_generated,
|
||||
is_generated,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// FK pass: mark is_fk + fk_ref for columns named in the FK metadata.
|
||||
let fk_query = crate::db::mysql::mysql_fk_query(schema, table);
|
||||
let fk_rows = sqlx::query(&fk_query)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||
for r in fk_rows {
|
||||
let col = crate::db::mysql::mysql_row_string(&r, 0);
|
||||
let ref_table = crate::db::mysql::mysql_row_string(&r, 2);
|
||||
let ref_col = crate::db::mysql::mysql_row_string(&r, 3);
|
||||
if let Some(c) = columns.iter_mut().find(|c| c.name == col) {
|
||||
c.is_fk = true;
|
||||
c.fk_ref = Some((ref_table, ref_col));
|
||||
}
|
||||
}
|
||||
Ok(columns)
|
||||
}
|
||||
|
||||
/// Bind `serde_json::Value` change params to a MySQL `?`-placeholder statement.
|
||||
/// Maps common JSON types to native MySQL-encodable values (NULL → SQL NULL).
|
||||
pub(crate) fn bind_mysql_params<'q>(
|
||||
q: sqlx::query::Query<'q, sqlx::MySql, sqlx::mysql::MySqlArguments>,
|
||||
params: &[serde_json::Value],
|
||||
) -> sqlx::query::Query<'q, sqlx::MySql, sqlx::mysql::MySqlArguments> {
|
||||
let mut q = q;
|
||||
for v in params {
|
||||
match v {
|
||||
serde_json::Value::Null => q = q.bind(Option::<String>::None),
|
||||
serde_json::Value::Bool(b) => q = q.bind(*b),
|
||||
serde_json::Value::Number(n) => {
|
||||
if let Some(i) = n.as_i64() {
|
||||
q = q.bind(i);
|
||||
} else if let Some(f) = n.as_f64() {
|
||||
q = q.bind(f);
|
||||
} else {
|
||||
q = q.bind(n.to_string());
|
||||
}
|
||||
}
|
||||
serde_json::Value::String(s) => q = q.bind(s.clone()),
|
||||
other => q = q.bind(other.to_string()),
|
||||
}
|
||||
}
|
||||
q
|
||||
}
|
||||
|
||||
/// Headless table-data fetch shared by the `get_table_data` command and
|
||||
/// integration tests (thin-command principle — no Tauri `State`).
|
||||
pub(crate) async fn get_table_data_inner(
|
||||
pool_manager: &tokio::sync::Mutex<ConnectionPoolManager>,
|
||||
connection_id: &str,
|
||||
schema: &str,
|
||||
table: &str,
|
||||
page: Option<i64>,
|
||||
page_size: Option<i64>,
|
||||
filters: Option<Vec<crate::models::db_viewer::FilterRule>>,
|
||||
sorts: Option<Vec<crate::models::db_viewer::SortRule>>,
|
||||
state: State<'_, crate::AppState>,
|
||||
) -> Result<QueryResult, String> {
|
||||
let p = page.unwrap_or(1);
|
||||
let ps = page_size.unwrap_or(50);
|
||||
@@ -1130,8 +1410,8 @@ pub async fn get_table_data(
|
||||
let filters = filters.unwrap_or_default();
|
||||
let sorts = sorts.unwrap_or_default();
|
||||
|
||||
let mut pm = state.pool_manager.lock().await;
|
||||
match pm.get(&connection_id) {
|
||||
let mut pm = pool_manager.lock().await;
|
||||
match pm.get(connection_id) {
|
||||
Some(crate::db::pool::DbHandle::Postgresql(client, _)) => {
|
||||
// Build filter clause (shared by COUNT and data queries)
|
||||
let mut pg_param_idx: usize = 0;
|
||||
@@ -1359,7 +1639,7 @@ ORDER BY c.ordinal_position"#;
|
||||
let is_view: bool = conn
|
||||
.query_row(
|
||||
"SELECT type = 'view' FROM sqlite_master WHERE name = ?1 AND type IN ('table', 'view')",
|
||||
[&table],
|
||||
rusqlite::params![table],
|
||||
|r| r.get::<_, bool>(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
@@ -1499,10 +1779,88 @@ ORDER BY c.ordinal_position"#;
|
||||
execution_time_ms: None,
|
||||
})
|
||||
}
|
||||
Some(crate::db::pool::DbHandle::MySql(pool)) => {
|
||||
let pool = &*pool; // Executor is implemented for &Pool, not &mut Pool
|
||||
let columns = mysql_load_columns(pool, schema, table).await?;
|
||||
|
||||
// Total count (filters do not affect the count — MySQL has no
|
||||
// per-filter count query, mirroring the select builder's scope).
|
||||
let total_rows: i64 =
|
||||
sqlx::query_scalar::<_, i64>(&crate::db::mysql::mysql_count_query(schema, table))
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||
|
||||
// mysql_select_data_query already embeds ORDER BY from `sorts`
|
||||
// (falling back to a smart default sort) — no shared order-clause
|
||||
// helper needed.
|
||||
let visible_names: Vec<String> = columns.iter().map(|c| c.name.clone()).collect();
|
||||
let default_sort = crate::db::mysql::mysql_default_sort(&visible_names).to_string();
|
||||
let data_query = crate::db::mysql::mysql_select_data_query(
|
||||
schema, table, &filters, &sorts, &default_sort,
|
||||
);
|
||||
|
||||
let mut q = sqlx::query(&data_query);
|
||||
for f in &filters {
|
||||
// null/notnull operators emit IS [NOT] NULL — no bound param.
|
||||
if f.operator == "null" || f.operator == "notnull" {
|
||||
continue;
|
||||
}
|
||||
q = q.bind(&f.value);
|
||||
}
|
||||
let data_rows = q
|
||||
.bind(ps)
|
||||
.bind(off)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||
|
||||
let rows: Vec<Vec<serde_json::Value>> = data_rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
(0..row.len())
|
||||
.map(|i| crate::commands::query::mysql_cell_to_json(row, i))
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(QueryResult {
|
||||
columns,
|
||||
rows,
|
||||
total_rows,
|
||||
page: p,
|
||||
page_size: ps,
|
||||
execution_time_ms: None,
|
||||
})
|
||||
}
|
||||
None => Err("Connection not found".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_table_data(
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
page: Option<i64>,
|
||||
page_size: Option<i64>,
|
||||
filters: Option<Vec<crate::models::db_viewer::FilterRule>>,
|
||||
sorts: Option<Vec<crate::models::db_viewer::SortRule>>,
|
||||
state: State<'_, crate::AppState>,
|
||||
) -> Result<QueryResult, String> {
|
||||
get_table_data_inner(
|
||||
&state.pool_manager,
|
||||
&connection_id,
|
||||
&schema,
|
||||
&table,
|
||||
page,
|
||||
page_size,
|
||||
filters,
|
||||
sorts,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_fk_preview(
|
||||
connection_id: String,
|
||||
@@ -1697,6 +2055,44 @@ ORDER BY c.ordinal_position"#;
|
||||
execution_time_ms: None,
|
||||
})
|
||||
}
|
||||
Some(crate::db::pool::DbHandle::MySql(pool)) => {
|
||||
let pool = &*pool; // Executor is implemented for &Pool, not &mut Pool
|
||||
// The request already resolves the referenced table/column (the
|
||||
// frontend reads `fk_ref`); fetch the referenced row directly.
|
||||
let columns = mysql_load_columns(pool, &schema, &table).await?;
|
||||
let qualified = format!(
|
||||
"{}.{}",
|
||||
crate::db::mysql::mysql_quote_ident(&schema),
|
||||
crate::db::mysql::mysql_quote_ident(&table)
|
||||
);
|
||||
let data_query = format!(
|
||||
"SELECT * FROM {} WHERE {} = ? LIMIT 1",
|
||||
qualified,
|
||||
crate::db::mysql::mysql_quote_ident(&column)
|
||||
);
|
||||
let data_rows = sqlx::query(&data_query)
|
||||
.bind(&value)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||
let rows: Vec<Vec<serde_json::Value>> = data_rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
(0..row.len())
|
||||
.map(|i| crate::commands::query::mysql_cell_to_json(row, i))
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let total_rows = rows.len() as i64;
|
||||
Ok(QueryResult {
|
||||
columns,
|
||||
rows,
|
||||
total_rows,
|
||||
page: 1,
|
||||
page_size: 1,
|
||||
execution_time_ms: None,
|
||||
})
|
||||
}
|
||||
None => Err("Connection not found".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -1880,6 +2276,97 @@ pub async fn execute_change(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Some(crate::db::pool::DbHandle::MySql(pool)) => {
|
||||
let pool = &*pool; // Executor is implemented for &Pool, not &mut Pool
|
||||
let (sql, params): (String, Vec<serde_json::Value>) = match &change {
|
||||
Change::Update {
|
||||
schema,
|
||||
table,
|
||||
primary_key,
|
||||
new_data,
|
||||
..
|
||||
} => {
|
||||
let pk = parse_json_pairs(primary_key)?;
|
||||
let data = parse_json_pairs(new_data)?;
|
||||
crate::db::mysql::mysql_build_update_sql(schema, table, &pk, &data)?
|
||||
}
|
||||
Change::Insert {
|
||||
schema,
|
||||
table,
|
||||
data,
|
||||
..
|
||||
} => {
|
||||
let pairs = parse_json_pairs(data)?;
|
||||
crate::db::mysql::mysql_build_insert_sql(schema, table, &pairs)
|
||||
}
|
||||
Change::Delete {
|
||||
schema,
|
||||
table,
|
||||
primary_key,
|
||||
..
|
||||
} => {
|
||||
let pk = parse_json_pairs(primary_key)?;
|
||||
crate::db::mysql::mysql_build_delete_sql(schema, table, &pk)?
|
||||
}
|
||||
Change::AlterTable { sql, .. } => {
|
||||
// Raw DDL, no bound parameters.
|
||||
sqlx::query(sql)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||
return Ok(());
|
||||
}
|
||||
Change::BulkInsert {
|
||||
schema,
|
||||
table,
|
||||
columns,
|
||||
rows,
|
||||
..
|
||||
} => {
|
||||
if columns.is_empty() || rows.is_empty() {
|
||||
return Err("bulk insert requires non-empty columns and rows".to_string());
|
||||
}
|
||||
let sql = crate::db::mysql::mysql_build_bulk_insert_sql(
|
||||
schema,
|
||||
table,
|
||||
columns,
|
||||
rows.len(),
|
||||
);
|
||||
let params: Vec<serde_json::Value> =
|
||||
rows.iter().flatten().cloned().collect();
|
||||
// Bulk insert affects many rows — skip the single-row
|
||||
// affected-count guard.
|
||||
bind_mysql_params(sqlx::query(&sql), ¶ms)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||
return Ok(());
|
||||
}
|
||||
Change::DropTable { schema, table, .. } => {
|
||||
sqlx::query(&crate::db::mysql::mysql_build_drop_sql(schema, table))
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||
return Ok(());
|
||||
}
|
||||
Change::EmptyTable { schema, table, .. } => {
|
||||
sqlx::query(&crate::db::mysql::mysql_build_empty_sql(schema, table))
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let result = bind_mysql_params(sqlx::query(&sql), ¶ms)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||
if let Some(msg) = affected_count_error(result.rows_affected()) {
|
||||
return Err(msg);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
None => Err("Connection not found".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -1906,6 +2393,16 @@ pub async fn refresh_connection(
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
Some(crate::db::pool::DbHandle::MySql(pool)) => {
|
||||
let pool = &*pool; // Executor is implemented for &Pool, not &mut Pool
|
||||
// Verify reachability (the sqlx pool reconnects transparently); the
|
||||
// frontend re-issues getDatabases/getSchemas/getTables afterwards.
|
||||
sqlx::query_scalar::<_, i64>("SELECT 1")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
None => Err("Connection not found".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -1941,6 +2438,7 @@ pub async fn get_functions(
|
||||
.collect())
|
||||
}
|
||||
Some(DbHandle::Sqlite(_)) => Ok(vec![]),
|
||||
Some(DbHandle::MySql(_)) => Err("MySQL functions not yet supported".to_string()),
|
||||
None => Err("Connection not found".into()),
|
||||
}
|
||||
}
|
||||
@@ -1976,6 +2474,7 @@ pub async fn get_indexes(
|
||||
.collect())
|
||||
}
|
||||
Some(DbHandle::Sqlite(_)) => Ok(vec![]),
|
||||
Some(DbHandle::MySql(_)) => Err("MySQL indexes not yet supported".to_string()),
|
||||
None => Err("Connection not found".into()),
|
||||
}
|
||||
}
|
||||
@@ -2023,6 +2522,7 @@ pub async fn get_constraints(
|
||||
.collect())
|
||||
}
|
||||
Some(DbHandle::Sqlite(_)) => Ok(vec![]),
|
||||
Some(DbHandle::MySql(_)) => Err("MySQL constraints not yet supported".to_string()),
|
||||
None => Err("Connection not found".into()),
|
||||
}
|
||||
}
|
||||
@@ -2058,6 +2558,7 @@ pub async fn get_triggers(
|
||||
.collect())
|
||||
}
|
||||
Some(DbHandle::Sqlite(_)) => Ok(vec![]),
|
||||
Some(DbHandle::MySql(_)) => Err("MySQL triggers not yet supported".to_string()),
|
||||
None => Err("Connection not found".into()),
|
||||
}
|
||||
}
|
||||
@@ -2095,6 +2596,7 @@ pub async fn get_sequences(
|
||||
.collect())
|
||||
}
|
||||
Some(DbHandle::Sqlite(_)) => Ok(vec![]),
|
||||
Some(DbHandle::MySql(_)) => Err("MySQL sequences not yet supported".to_string()),
|
||||
None => Err("Connection not found".into()),
|
||||
}
|
||||
}
|
||||
@@ -2124,6 +2626,7 @@ pub async fn get_enums(
|
||||
.collect())
|
||||
}
|
||||
Some(DbHandle::Sqlite(_)) => Ok(vec![]),
|
||||
Some(DbHandle::MySql(_)) => Err("MySQL enums not yet supported".to_string()),
|
||||
None => Err("Connection not found".into()),
|
||||
}
|
||||
}
|
||||
@@ -2149,6 +2652,7 @@ pub async fn get_extensions(
|
||||
.collect())
|
||||
}
|
||||
Some(DbHandle::Sqlite(_)) => Ok(vec![]),
|
||||
Some(DbHandle::MySql(_)) => Err("MySQL extensions not yet supported".to_string()),
|
||||
None => Err("Connection not found".into()),
|
||||
}
|
||||
}
|
||||
@@ -2169,6 +2673,15 @@ pub async fn get_table_ddl(
|
||||
let mut pm = state.pool_manager.lock().await;
|
||||
match pm.get(&connection_id) {
|
||||
Some(DbHandle::Sqlite(conn)) => get_sqlite_ddl(conn, &table),
|
||||
Some(DbHandle::MySql(pool)) => {
|
||||
let pool = &*pool; // Executor is implemented for &Pool, not &mut Pool
|
||||
// SHOW CREATE TABLE returns (Table, Create Table); take the DDL.
|
||||
let row = sqlx::query(&crate::db::mysql::mysql_ddl_query(&schema, &table))
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||
Ok(crate::db::mysql::mysql_row_string(&row, 1))
|
||||
}
|
||||
Some(DbHandle::Postgresql(_client, _)) => {
|
||||
// Pull connection metadata so pg_dump reaches the same server the
|
||||
// pool is connected to (host/port/user/dbname + keychain password).
|
||||
@@ -2223,7 +2736,10 @@ pub async fn get_table_ddl(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::commands::ssh::{Ssh2Backend, SshTunnelManager};
|
||||
use crate::db::pool::{ConnectionPoolManager, DbConfig};
|
||||
use crate::models::db_viewer::Change;
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
|
||||
#[test]
|
||||
fn split_columns_csv_handles_commas_and_trims() {
|
||||
@@ -2636,4 +3152,171 @@ mod tests {
|
||||
Some("ambiguous row match".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
async fn fresh_pool_manager() -> tokio::sync::Mutex<ConnectionPoolManager> {
|
||||
tokio::sync::Mutex::new(ConnectionPoolManager::new())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_mysql_connect_rejects_empty_host() {
|
||||
let cfg = DbConfig {
|
||||
db_type: "mysql".into(),
|
||||
host: "".into(),
|
||||
port: Some(3306),
|
||||
username: Some("root".into()),
|
||||
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,
|
||||
};
|
||||
let ssh = StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend)));
|
||||
let pm = fresh_pool_manager().await;
|
||||
let id = "mysql-empty-host";
|
||||
let res = run_mysql_connect(id, &cfg, &ssh, &pm).await;
|
||||
assert!(res.is_err(), "empty host must fail before any network call");
|
||||
let mut pmg = pm.lock().await;
|
||||
assert!(pmg.get(id).is_none(), "no handle registered on failure");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// MySQL helpers + builders (Task 2.5)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn mysql_databases_query_is_show_databases() {
|
||||
assert_eq!(crate::db::mysql::mysql_databases_query(), "SHOW DATABASES");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_system_dbs_are_filtered() {
|
||||
let all = vec!["information_schema", "mysql", "performance_schema", "sys", "shop"];
|
||||
let filtered: Vec<&str> = all
|
||||
.into_iter()
|
||||
.filter(|d| !crate::db::mysql::MYSQL_SYSTEM_DBS.contains(d))
|
||||
.collect();
|
||||
assert_eq!(filtered, vec!["shop"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_execute_change_builds_update_sql() {
|
||||
let pk = vec![("id".to_string(), serde_json::json!(1))];
|
||||
let data = vec![("name".to_string(), serde_json::json!("x"))];
|
||||
let (sql, _params) =
|
||||
crate::db::mysql::mysql_build_update_sql("shop", "orders", &pk, &data).unwrap();
|
||||
assert_eq!(sql, "UPDATE `shop`.`orders` SET `name` = ? WHERE `id` = ? LIMIT 1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_mysql_connect_registers_handle_when_lazy_url_parses() {
|
||||
// No live connection: a deliberately unreachable host with a short timeout.
|
||||
// We assert the function returns an Err (not a panic) and registers nothing.
|
||||
let cfg = DbConfig {
|
||||
db_type: "mysql".into(),
|
||||
host: "127.0.0.1".into(),
|
||||
port: Some(1),
|
||||
username: Some("root".into()),
|
||||
password: Some("x".into()),
|
||||
database: Some("mysql".into()),
|
||||
ssl_mode: Some("require".into()),
|
||||
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,
|
||||
};
|
||||
let ssh = StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend)));
|
||||
let pm = fresh_pool_manager().await;
|
||||
let id = "mysql-unreachable";
|
||||
let res = run_mysql_connect(id, &cfg, &ssh, &pm).await;
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"port 1 should refuse; must be a clean Err, not panic"
|
||||
);
|
||||
assert!(pm.lock().await.get(id).is_none());
|
||||
}
|
||||
|
||||
/// Live MySQL integration test — env-gated via `GRIDLINE_TEST_MYSQL_*`.
|
||||
/// Browsing + pagination over a real server; returns early (Ok) when the
|
||||
/// env vars are absent, so the `#[ignore]` gate is the only way it runs.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn mysql_integration_browse_and_edit() {
|
||||
let host = match std::env::var("GRIDLINE_TEST_MYSQL_HOST") {
|
||||
Ok(v) => v,
|
||||
Err(_) => return,
|
||||
};
|
||||
let port: i64 = std::env::var("GRIDLINE_TEST_MYSQL_PORT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(3306);
|
||||
let user = std::env::var("GRIDLINE_TEST_MYSQL_USER").unwrap_or_else(|_| "root".into());
|
||||
let pass = std::env::var("GRIDLINE_TEST_MYSQL_PASS").unwrap_or_default();
|
||||
let db = match std::env::var("GRIDLINE_TEST_MYSQL_DB") {
|
||||
Ok(v) => v,
|
||||
Err(_) => return,
|
||||
};
|
||||
let cfg = DbConfig {
|
||||
db_type: "mysql".into(),
|
||||
host,
|
||||
port: Some(port),
|
||||
username: Some(user),
|
||||
password: Some(pass),
|
||||
database: Some(db.clone()),
|
||||
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,
|
||||
};
|
||||
let ssh = StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend)));
|
||||
let pm = fresh_pool_manager().await;
|
||||
let id = format!("mysql-it-{}", uuid::Uuid::new_v4());
|
||||
run_mysql_connect(&id, &cfg, &ssh, &pm).await.expect("connect");
|
||||
let tables = get_tables_inner(&pm, &id, Some(&db)).await.expect("tables");
|
||||
assert!(!tables.is_empty(), "test DB must contain at least one table");
|
||||
let first = &tables[0];
|
||||
let data = get_table_data_inner(
|
||||
&pm,
|
||||
&id,
|
||||
&first.schema,
|
||||
&first.name,
|
||||
Some(1),
|
||||
Some(10),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("data");
|
||||
assert_eq!(data.page, 1);
|
||||
assert_eq!(data.page_size, 10);
|
||||
// Columns must be resolvable through the shared helper.
|
||||
let pool = match pm.lock().await.get(&id) {
|
||||
Some(crate::db::pool::DbHandle::MySql(p)) => p.clone(),
|
||||
_ => panic!("mysql handle missing"),
|
||||
};
|
||||
let cols = mysql_load_columns(&pool, &first.schema, &first.name)
|
||||
.await
|
||||
.expect("columns");
|
||||
assert_eq!(cols.len(), data.columns.len());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
use crate::db::pool::DbHandle;
|
||||
use crate::models::db_viewer::{ColumnInfo, QueryResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{Column, Row};
|
||||
use std::time::Instant;
|
||||
use tauri::State;
|
||||
use uuid::Uuid;
|
||||
@@ -100,6 +101,7 @@ pub(crate) async fn execute_query_inner(
|
||||
execute_pg_query(client, query, page, page_size).await
|
||||
}
|
||||
Some(DbHandle::Sqlite(conn)) => execute_sqlite_query(conn, query, page, page_size),
|
||||
Some(DbHandle::MySql(pool)) => execute_mysql_query(pool, query, page, page_size).await,
|
||||
None => {
|
||||
let elapsed = start.elapsed().as_millis() as i64;
|
||||
let err = "Connection not found".to_string();
|
||||
@@ -495,6 +497,164 @@ fn sqlite_value_to_json(row: &rusqlite::Row, i: usize) -> serde_json::Value {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MySQL execution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Wrap a user query for pagination: `SELECT * FROM (<q>) AS _gridline_data LIMIT ? OFFSET ?`.
|
||||
pub(crate) fn mysql_wrap_data(query: &str) -> String {
|
||||
format!(
|
||||
"SELECT * FROM ({}) AS _gridline_data LIMIT ? OFFSET ?",
|
||||
query.trim()
|
||||
)
|
||||
}
|
||||
|
||||
/// Wrap a user query for counting: `SELECT COUNT(*) FROM (<q>) AS _gridline_cnt`.
|
||||
pub(crate) fn mysql_wrap_count(query: &str) -> String {
|
||||
format!("SELECT COUNT(*) FROM ({}) AS _gridline_cnt", query.trim())
|
||||
}
|
||||
|
||||
/// Convert a sqlx MySql row cell to serde_json::Value (via the `json` feature).
|
||||
/// Shared with the DB-viewer commands (pub(crate)).
|
||||
pub(crate) fn mysql_cell_to_json(row: &sqlx::mysql::MySqlRow, i: usize) -> serde_json::Value {
|
||||
if let Ok(Some(v)) = row.try_get::<Option<serde_json::Value>, _>(i) {
|
||||
return v;
|
||||
}
|
||||
if let Ok(s) = row.try_get::<Option<String>, _>(i) {
|
||||
return s
|
||||
.map(|s| serde_json::Value::String(s))
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
}
|
||||
if let Ok(b) = row.try_get::<Option<Vec<u8>>, _>(i) {
|
||||
return b
|
||||
.map(|b| serde_json::Value::String(String::from_utf8_lossy(&b).into_owned()))
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
}
|
||||
serde_json::Value::Null
|
||||
}
|
||||
|
||||
async fn execute_mysql_query(
|
||||
pool: &sqlx::MySqlPool,
|
||||
query: &str,
|
||||
page: i64,
|
||||
page_size: i64,
|
||||
) -> Result<QueryResult, String> {
|
||||
let trimmed = query.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("Query cannot be empty".to_string());
|
||||
}
|
||||
let off = (page.saturating_sub(1).max(0)) * page_size;
|
||||
|
||||
// Try the wrapped count first; fall back to raw on failure.
|
||||
let total_rows: i64 = match sqlx::query_scalar::<_, i64>(&mysql_wrap_count(trimmed))
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
{
|
||||
Ok(n) => n,
|
||||
Err(_) => return execute_mysql_raw(pool, trimmed, page, page_size, off).await,
|
||||
};
|
||||
|
||||
let data_rows = match sqlx::query(&mysql_wrap_data(trimmed))
|
||||
.bind(page_size)
|
||||
.bind(off)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
{
|
||||
Ok(rows) => rows,
|
||||
Err(_) => return execute_mysql_raw(pool, trimmed, page, page_size, off).await,
|
||||
};
|
||||
|
||||
let columns: Vec<ColumnInfo> = match data_rows.first() {
|
||||
Some(first) => first
|
||||
.columns()
|
||||
.iter()
|
||||
.map(|c| ColumnInfo {
|
||||
name: c.name().to_string(),
|
||||
data_type: c.type_info().to_string(),
|
||||
is_nullable: true,
|
||||
is_pk: false,
|
||||
is_fk: false,
|
||||
fk_ref: None,
|
||||
default_value: None,
|
||||
editable: true,
|
||||
is_generated: false,
|
||||
})
|
||||
.collect(),
|
||||
None => return execute_mysql_raw(pool, trimmed, page, page_size, off).await,
|
||||
};
|
||||
|
||||
let rows: Vec<Vec<serde_json::Value>> = data_rows
|
||||
.iter()
|
||||
.map(|r| (0..r.len()).map(|i| mysql_cell_to_json(r, i)).collect())
|
||||
.collect();
|
||||
|
||||
Ok(QueryResult {
|
||||
columns,
|
||||
rows,
|
||||
total_rows,
|
||||
page,
|
||||
page_size,
|
||||
execution_time_ms: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Raw fallback (no wrapping). Runs the query as-is and slices client-side,
|
||||
/// mirroring the PG `simple_query` raw path. Used when wrapping fails
|
||||
/// (e.g., multi-statement or non-selectable SQL).
|
||||
async fn execute_mysql_raw(
|
||||
pool: &sqlx::MySqlPool,
|
||||
query: &str,
|
||||
page: i64,
|
||||
page_size: i64,
|
||||
off: i64,
|
||||
) -> Result<QueryResult, String> {
|
||||
let rows = sqlx::query(query)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
|
||||
|
||||
let columns: Vec<ColumnInfo> = match rows.first() {
|
||||
Some(first) => first
|
||||
.columns()
|
||||
.iter()
|
||||
.map(|c| ColumnInfo {
|
||||
name: c.name().to_string(),
|
||||
data_type: c.type_info().to_string(),
|
||||
is_nullable: true,
|
||||
is_pk: false,
|
||||
is_fk: false,
|
||||
fk_ref: None,
|
||||
default_value: None,
|
||||
editable: true,
|
||||
is_generated: false,
|
||||
})
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
let all_rows: Vec<Vec<serde_json::Value>> = rows
|
||||
.iter()
|
||||
.map(|r| (0..r.len()).map(|i| mysql_cell_to_json(r, i)).collect())
|
||||
.collect();
|
||||
let total_rows = all_rows.len() as i64;
|
||||
let uoff = off as usize;
|
||||
let ulimit = page_size as usize;
|
||||
let sliced: Vec<Vec<serde_json::Value>> = if uoff < all_rows.len() {
|
||||
all_rows.into_iter().skip(uoff).take(ulimit).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Ok(QueryResult {
|
||||
columns,
|
||||
rows: sliced,
|
||||
total_rows,
|
||||
page,
|
||||
page_size,
|
||||
execution_time_ms: None,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query history commands
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -959,4 +1119,22 @@ mod tests {
|
||||
_ => panic!("Expected Sqlite handle"),
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// MySQL wrapper SQL shapes
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn mysql_wrapped_query_shape_has_limit_offset_placeholders() {
|
||||
// The wrapped-data SQL must use MySQL `?` placeholders (not $1/$2).
|
||||
let q = mysql_wrap_data("SELECT * FROM t");
|
||||
assert!(q.contains("LIMIT ? OFFSET ?"));
|
||||
assert!(q.contains("AS _gridline_data"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_wrapped_count_shape_uses_subquery_alias() {
|
||||
let q = mysql_wrap_count("SELECT * FROM t");
|
||||
assert_eq!(q, "SELECT COUNT(*) FROM (SELECT * FROM t) AS _gridline_cnt");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,6 +338,7 @@ pub async fn get_schema_graph(
|
||||
})
|
||||
}
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod introspection;
|
||||
pub mod mysql;
|
||||
pub mod pool;
|
||||
pub mod tls;
|
||||
|
||||
|
||||
@@ -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(&[]), "");
|
||||
}
|
||||
}
|
||||
@@ -82,9 +82,10 @@ impl DbConfig {
|
||||
|
||||
/// A handle to an active database connection.
|
||||
///
|
||||
/// Supports `Sqlite` (synchronous via `rusqlite`) and
|
||||
/// `Postgresql` (async via `tokio-postgres`). MySQL and Redis
|
||||
/// variants will be added in later tasks.
|
||||
/// 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`.
|
||||
@@ -92,6 +93,8 @@ pub enum DbHandle {
|
||||
/// 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.
|
||||
@@ -414,4 +417,27 @@ mod tests {
|
||||
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(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user