feat: initial release

Multi-database management tool built with Tauri + Vue 3.
Supports MySQL, PostgreSQL, SQLite, Redis, MongoDB, DuckDB, ClickHouse, SQL Server.
This commit is contained in:
t8y2
2026-04-29 18:09:23 +08:00
commit b2e6dfad7e
156 changed files with 21979 additions and 0 deletions
+285
View File
@@ -0,0 +1,285 @@
use std::collections::HashMap;
use std::sync::Arc;
use tauri::{AppHandle, Manager, State};
use tokio::sync::Mutex;
use crate::db;
use crate::db::ssh_tunnel::TunnelManager;
use crate::models::connection::{ConnectionConfig, DatabaseType};
pub enum PoolKind {
Mysql(sqlx::mysql::MySqlPool),
Postgres(sqlx::postgres::PgPool),
Sqlite(sqlx::sqlite::SqlitePool),
Redis(tokio::sync::Mutex<redis::aio::MultiplexedConnection>),
DuckDb(std::sync::Arc<std::sync::Mutex<duckdb::Connection>>),
MongoDb(mongodb::Client),
ClickHouse(db::clickhouse_driver::ChClient),
SqlServer(std::sync::Arc<tokio::sync::Mutex<db::sqlserver::SqlServerClient>>),
}
pub struct AppState {
pub connections: Mutex<HashMap<String, PoolKind>>,
pub configs: Mutex<HashMap<String, ConnectionConfig>>,
pub tunnels: TunnelManager,
}
impl AppState {
pub fn new() -> Self {
Self {
connections: Mutex::new(HashMap::new()),
configs: Mutex::new(HashMap::new()),
tunnels: TunnelManager::new(),
}
}
pub async fn get_or_create_pool(
&self,
connection_id: &str,
database: Option<&str>,
) -> Result<String, String> {
let is_embedded = {
let configs = self.configs.lock().await;
configs.get(connection_id)
.map(|c| c.db_type == DatabaseType::Sqlite || c.db_type == DatabaseType::DuckDb)
.unwrap_or(false)
};
if is_embedded {
return Ok(connection_id.to_string());
}
let pool_key = match database {
Some(db) => format!("{connection_id}:{db}"),
None => connection_id.to_string(),
};
let conns = self.connections.lock().await;
if conns.contains_key(&pool_key) {
return Ok(pool_key);
}
drop(conns);
let configs = self.configs.lock().await;
let config = configs
.get(connection_id)
.ok_or("Connection config not found")?
.clone();
drop(configs);
let mut db_config = config.clone();
if let Some(db) = database {
db_config.database = Some(db.to_string());
}
let url = db_config.connection_url();
let pool = match db_config.db_type {
DatabaseType::Mysql => PoolKind::Mysql(db::mysql::connect(&url).await?),
DatabaseType::Postgres => PoolKind::Postgres(db::postgres::connect(&url).await?),
DatabaseType::Sqlite => PoolKind::Sqlite(db::sqlite::connect(&url).await?),
DatabaseType::Redis => {
let con = db::redis_driver::connect(&url).await?;
PoolKind::Redis(tokio::sync::Mutex::new(con))
}
DatabaseType::DuckDb => {
let con = duckdb::Connection::open(&db_config.host).map_err(|e| e.to_string())?;
PoolKind::DuckDb(std::sync::Arc::new(std::sync::Mutex::new(con)))
}
DatabaseType::MongoDb => {
let client = mongodb::Client::with_uri_str(&url).await.map_err(|e| e.to_string())?;
PoolKind::MongoDb(client)
}
DatabaseType::ClickHouse => {
let client = db::clickhouse_driver::ChClient::new(&url);
db::clickhouse_driver::test_connection(&client).await?;
PoolKind::ClickHouse(client)
}
DatabaseType::SqlServer => {
let client = db::sqlserver::connect(
&db_config.host, db_config.port,
&db_config.username, &db_config.password,
db_config.database.as_deref(),
).await?;
PoolKind::SqlServer(std::sync::Arc::new(tokio::sync::Mutex::new(client)))
}
};
self.connections.lock().await.insert(pool_key.clone(), pool);
Ok(pool_key)
}
pub async fn reconnect_pool(
&self,
connection_id: &str,
database: Option<&str>,
) -> Result<String, String> {
let pool_key = match database {
Some(db) => format!("{connection_id}:{db}"),
None => connection_id.to_string(),
};
self.connections.lock().await.remove(&pool_key);
self.get_or_create_pool(connection_id, database).await
}
}
fn connections_file(app: &AppHandle) -> Result<std::path::PathBuf, String> {
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(dir.join("connections.json"))
}
#[tauri::command]
pub async fn save_connections(
app: AppHandle,
configs: Vec<ConnectionConfig>,
) -> Result<(), String> {
let path = connections_file(&app)?;
let json = serde_json::to_string_pretty(&configs).map_err(|e| e.to_string())?;
std::fs::write(path, json).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn load_connections(app: AppHandle) -> Result<Vec<ConnectionConfig>, String> {
let path = connections_file(&app)?;
if !path.exists() {
return Ok(vec![]);
}
let json = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
let configs: Vec<ConnectionConfig> =
serde_json::from_str(&json).map_err(|e| e.to_string())?;
Ok(configs)
}
#[tauri::command]
pub async fn test_connection(config: ConnectionConfig) -> Result<String, String> {
let url = config.connection_url();
match config.db_type {
DatabaseType::Mysql => {
let pool = db::mysql::connect(&url).await?;
pool.close().await;
Ok("Connection successful".to_string())
}
DatabaseType::Postgres => {
let pool = db::postgres::connect(&url).await?;
pool.close().await;
Ok("Connection successful".to_string())
}
DatabaseType::Sqlite => {
let pool = db::sqlite::connect(&url).await?;
pool.close().await;
Ok("Connection successful".to_string())
}
DatabaseType::Redis => {
let _con = db::redis_driver::connect(&url).await?;
Ok("Connection successful".to_string())
}
DatabaseType::DuckDb => {
let _con = duckdb::Connection::open(&config.host).map_err(|e| e.to_string())?;
Ok("Connection successful".to_string())
}
DatabaseType::MongoDb => {
let client = mongodb::Client::with_uri_str(&url).await.map_err(|e| e.to_string())?;
client.list_database_names().await.map_err(|e| e.to_string())?;
Ok("Connection successful".to_string())
}
DatabaseType::ClickHouse => {
let client = db::clickhouse_driver::ChClient::new(&url);
db::clickhouse_driver::test_connection(&client).await?;
Ok("Connection successful".to_string())
}
DatabaseType::SqlServer => {
let _client = db::sqlserver::connect(
&config.host, config.port,
&config.username, &config.password,
config.database.as_deref(),
).await?;
Ok("Connection successful".to_string())
}
}
}
#[tauri::command]
pub async fn connect_db(
state: State<'_, Arc<AppState>>,
config: ConnectionConfig,
) -> Result<String, String> {
let id = config.id.clone();
let url = if config.ssh_enabled && !config.ssh_host.is_empty() {
let local_port = state.tunnels.start_tunnel(
&id, &config.ssh_host, config.ssh_port,
&config.ssh_user, &config.ssh_key_path,
&config.host, config.port,
).await?;
config.connection_url_with_host("127.0.0.1", local_port)
} else {
config.connection_url()
};
let pool = match config.db_type {
DatabaseType::Mysql => PoolKind::Mysql(db::mysql::connect(&url).await?),
DatabaseType::Postgres => PoolKind::Postgres(db::postgres::connect(&url).await?),
DatabaseType::Sqlite => PoolKind::Sqlite(db::sqlite::connect(&url).await?),
DatabaseType::Redis => {
let con = db::redis_driver::connect(&url).await?;
PoolKind::Redis(tokio::sync::Mutex::new(con))
}
DatabaseType::DuckDb => {
let con = duckdb::Connection::open(&config.host).map_err(|e| e.to_string())?;
PoolKind::DuckDb(std::sync::Arc::new(std::sync::Mutex::new(con)))
}
DatabaseType::MongoDb => {
let client = mongodb::Client::with_uri_str(&url).await.map_err(|e| e.to_string())?;
PoolKind::MongoDb(client)
}
DatabaseType::ClickHouse => {
let client = db::clickhouse_driver::ChClient::new(&url);
db::clickhouse_driver::test_connection(&client).await?;
PoolKind::ClickHouse(client)
}
DatabaseType::SqlServer => {
let client = db::sqlserver::connect(
&config.host, config.port,
&config.username, &config.password,
config.database.as_deref(),
).await?;
PoolKind::SqlServer(std::sync::Arc::new(tokio::sync::Mutex::new(client))) }
};
state.connections.lock().await.insert(id.clone(), pool);
state.configs.lock().await.insert(id.clone(), config);
Ok(id)
}
#[tauri::command]
pub async fn disconnect_db(
state: State<'_, Arc<AppState>>,
connection_id: String,
) -> Result<(), String> {
let mut conns = state.connections.lock().await;
let keys_to_remove: Vec<String> = conns
.keys()
.filter(|k| *k == &connection_id || k.starts_with(&format!("{connection_id}:")))
.cloned()
.collect();
for key in keys_to_remove {
if let Some(pool) = conns.remove(&key) {
match pool {
PoolKind::Mysql(p) => p.close().await,
PoolKind::Postgres(p) => p.close().await,
PoolKind::Sqlite(p) => p.close().await,
PoolKind::Redis(_) => {},
PoolKind::DuckDb(_) => {},
PoolKind::MongoDb(_) => {},
PoolKind::ClickHouse(_) => {},
PoolKind::SqlServer(_) => {},
}
}
}
drop(conns);
state.configs.lock().await.remove(&connection_id);
state.tunnels.stop_tunnel(&connection_id).await;
Ok(())
}
+69
View File
@@ -0,0 +1,69 @@
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Manager};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoryEntry {
pub id: String,
pub connection_name: String,
pub database: String,
pub sql: String,
pub executed_at: String,
pub execution_time_ms: u128,
pub success: bool,
pub error: Option<String>,
}
fn history_file(app: &AppHandle) -> Result<std::path::PathBuf, String> {
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(dir.join("query_history.json"))
}
fn read_all(app: &AppHandle) -> Result<Vec<HistoryEntry>, String> {
let path = history_file(app)?;
if !path.exists() {
return Ok(vec![]);
}
let json = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
serde_json::from_str(&json).map_err(|e| e.to_string())
}
fn write_all(app: &AppHandle, entries: &[HistoryEntry]) -> Result<(), String> {
let path = history_file(app)?;
let json = serde_json::to_string(entries).map_err(|e| e.to_string())?;
std::fs::write(path, json).map_err(|e| e.to_string())
}
const MAX_HISTORY: usize = 1000;
#[tauri::command]
pub async fn save_history(app: AppHandle, entry: HistoryEntry) -> Result<(), String> {
let mut entries = read_all(&app)?;
entries.insert(0, entry);
entries.truncate(MAX_HISTORY);
write_all(&app, &entries)
}
#[tauri::command]
pub async fn load_history(
app: AppHandle,
limit: usize,
offset: usize,
) -> Result<Vec<HistoryEntry>, String> {
let entries = read_all(&app)?;
Ok(entries.into_iter().skip(offset).take(limit).collect())
}
#[tauri::command]
pub async fn clear_history(app: AppHandle) -> Result<(), String> {
write_all(&app, &[])
}
#[tauri::command]
pub async fn delete_history_entry(app: AppHandle, id: String) -> Result<(), String> {
let entries: Vec<HistoryEntry> = read_all(&app)?
.into_iter()
.filter(|e| e.id != id)
.collect();
write_all(&app, &entries)
}
+6
View File
@@ -0,0 +1,6 @@
pub mod connection;
pub mod history;
pub mod mongo_cmd;
pub mod query;
pub mod redis_cmd;
pub mod schema;
+100
View File
@@ -0,0 +1,100 @@
use std::sync::Arc;
use tauri::State;
use crate::commands::connection::{AppState, PoolKind};
use crate::db::mongo_driver::{self, MongoDocumentResult};
#[tauri::command]
pub async fn mongo_list_databases(
state: State<'_, Arc<AppState>>,
connection_id: String,
) -> Result<Vec<String>, String> {
let connections = state.connections.lock().await;
match connections.get(&connection_id).ok_or("Not found")? {
PoolKind::MongoDb(client) => mongo_driver::list_databases(client).await,
_ => Err("Not a MongoDB connection".to_string()),
}
}
#[tauri::command]
pub async fn mongo_list_collections(
state: State<'_, Arc<AppState>>,
connection_id: String,
database: String,
) -> Result<Vec<String>, String> {
let connections = state.connections.lock().await;
match connections.get(&connection_id).ok_or("Not found")? {
PoolKind::MongoDb(client) => mongo_driver::list_collections(client, &database).await,
_ => Err("Not a MongoDB connection".to_string()),
}
}
#[tauri::command]
pub async fn mongo_find_documents(
state: State<'_, Arc<AppState>>,
connection_id: String,
database: String,
collection: String,
skip: u64,
limit: i64,
) -> Result<MongoDocumentResult, String> {
let connections = state.connections.lock().await;
match connections.get(&connection_id).ok_or("Not found")? {
PoolKind::MongoDb(client) => {
mongo_driver::find_documents(client, &database, &collection, skip, limit).await
}
_ => Err("Not a MongoDB connection".to_string()),
}
}
#[tauri::command]
pub async fn mongo_insert_document(
state: State<'_, Arc<AppState>>,
connection_id: String,
database: String,
collection: String,
doc_json: String,
) -> Result<String, String> {
let connections = state.connections.lock().await;
match connections.get(&connection_id).ok_or("Not found")? {
PoolKind::MongoDb(client) => {
mongo_driver::insert_document(client, &database, &collection, &doc_json).await
}
_ => Err("Not a MongoDB connection".to_string()),
}
}
#[tauri::command]
pub async fn mongo_update_document(
state: State<'_, Arc<AppState>>,
connection_id: String,
database: String,
collection: String,
id: String,
doc_json: String,
) -> Result<u64, String> {
let connections = state.connections.lock().await;
match connections.get(&connection_id).ok_or("Not found")? {
PoolKind::MongoDb(client) => {
mongo_driver::update_document(client, &database, &collection, &id, &doc_json).await
}
_ => Err("Not a MongoDB connection".to_string()),
}
}
#[tauri::command]
pub async fn mongo_delete_document(
state: State<'_, Arc<AppState>>,
connection_id: String,
database: String,
collection: String,
id: String,
) -> Result<u64, String> {
let connections = state.connections.lock().await;
match connections.get(&connection_id).ok_or("Not found")? {
PoolKind::MongoDb(client) => {
mongo_driver::delete_document(client, &database, &collection, &id).await
}
_ => Err("Not a MongoDB connection".to_string()),
}
}
+163
View File
@@ -0,0 +1,163 @@
use std::sync::Arc;
use std::time::Duration;
use tauri::State;
use tokio::time::timeout;
use crate::commands::connection::{AppState, PoolKind};
use crate::db;
const QUERY_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_ROWS: usize = 10000;
fn duckdb_execute(con: &duckdb::Connection, sql: &str) -> Result<db::QueryResult, String> {
let start = std::time::Instant::now();
let trimmed = sql.trim().to_uppercase();
if trimmed.starts_with("SELECT") || trimmed.starts_with("SHOW") || trimmed.starts_with("DESCRIBE")
|| trimmed.starts_with("EXPLAIN") || trimmed.starts_with("WITH") || trimmed.starts_with("PRAGMA")
{
let mut stmt = con.prepare(sql).map_err(|e| e.to_string())?;
let mut rows = stmt.query([]).map_err(|e| e.to_string())?;
let stmt_ref = rows.as_ref().ok_or("DuckDB statement unavailable")?;
let col_count = stmt_ref.column_count();
let columns: Vec<String> = (0..col_count)
.map(|i| stmt_ref.column_name(i).map(|s| s.to_string()).unwrap_or_else(|_| "?".to_string()))
.collect();
let mut result_rows = Vec::new();
while let Some(row) = rows.next().map_err(|e| e.to_string())? {
if result_rows.len() >= MAX_ROWS { break; }
let vals: Vec<serde_json::Value> = (0..col_count).map(|i| {
row.get::<_, String>(i)
.map(serde_json::Value::String)
.or_else(|_| row.get::<_, i64>(i).map(|v| serde_json::Value::Number(v.into())))
.or_else(|_| row.get::<_, f64>(i).map(|v| {
serde_json::Number::from_f64(v)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null)
}))
.or_else(|_| row.get::<_, bool>(i).map(serde_json::Value::Bool))
.unwrap_or(serde_json::Value::Null)
}).collect();
result_rows.push(vals);
}
let truncated = result_rows.len() >= MAX_ROWS;
Ok(db::QueryResult { columns, rows: result_rows, affected_rows: 0, execution_time_ms: start.elapsed().as_millis(), truncated })
} else {
let affected = con.execute(sql, []).map_err(|e| e.to_string())?;
Ok(db::QueryResult { columns: vec![], rows: vec![], affected_rows: affected as u64, execution_time_ms: start.elapsed().as_millis(), truncated: false })
}
}
fn truncate_result(mut result: db::QueryResult) -> db::QueryResult {
if result.rows.len() > MAX_ROWS {
result.rows.truncate(MAX_ROWS);
result.truncated = true;
}
result
}
fn is_connection_error(err: &str) -> bool {
let lower = err.to_lowercase();
lower.contains("connection")
|| lower.contains("broken pipe")
|| lower.contains("reset by peer")
|| lower.contains("timed out")
|| lower.contains("closed")
|| lower.contains("eof")
}
async fn do_execute(
state: &AppState,
pool_key: &str,
sql: &str,
) -> Result<db::QueryResult, String> {
let connections = state.connections.lock().await;
let pool = connections.get(pool_key).ok_or("Connection not found")?;
match pool {
PoolKind::DuckDb(con) => {
let con = con.clone();
let sql = sql.to_string();
drop(connections);
let task = tokio::task::spawn_blocking(move || {
let con = con.lock().map_err(|e| e.to_string())?;
duckdb_execute(&con, &sql)
});
timeout(QUERY_TIMEOUT, task)
.await
.map_err(|_| format!("Query timed out after {} seconds", QUERY_TIMEOUT.as_secs()))?
.map_err(|e| e.to_string())?
}
PoolKind::Mysql(p) => {
let p = p.clone();
drop(connections);
timeout(QUERY_TIMEOUT, db::mysql::execute_query(&p, sql))
.await
.map_err(|_| format!("Query timed out after {} seconds", QUERY_TIMEOUT.as_secs()))?
.map(truncate_result)
}
PoolKind::Postgres(p) => {
let p = p.clone();
drop(connections);
timeout(QUERY_TIMEOUT, db::postgres::execute_query(&p, sql))
.await
.map_err(|_| format!("Query timed out after {} seconds", QUERY_TIMEOUT.as_secs()))?
.map(truncate_result)
}
PoolKind::Sqlite(p) => {
let p = p.clone();
drop(connections);
timeout(QUERY_TIMEOUT, db::sqlite::execute_query(&p, sql))
.await
.map_err(|_| format!("Query timed out after {} seconds", QUERY_TIMEOUT.as_secs()))?
.map(truncate_result)
}
PoolKind::ClickHouse(client) => {
let client = client.clone();
let database = pool_key.split(':').nth(1).unwrap_or("default").to_string();
drop(connections);
timeout(QUERY_TIMEOUT, db::clickhouse_driver::execute_query(&client, &database, sql))
.await
.map_err(|_| format!("Query timed out after {} seconds", QUERY_TIMEOUT.as_secs()))?
.map(truncate_result)
}
PoolKind::SqlServer(client) => {
let client = client.clone();
drop(connections);
let mut client = client.lock().await;
timeout(QUERY_TIMEOUT, db::sqlserver::execute_query(&mut client, sql))
.await
.map_err(|_| format!("Query timed out after {} seconds", QUERY_TIMEOUT.as_secs()))?
.map(truncate_result)
}
PoolKind::Redis(_) => Err("Use Redis-specific commands".to_string()),
PoolKind::MongoDb(_) => Err("Use MongoDB-specific commands".to_string()),
}
}
#[tauri::command]
pub async fn execute_query(
state: State<'_, Arc<AppState>>,
connection_id: String,
database: String,
sql: String,
) -> Result<db::QueryResult, String> {
let pool_key = if database.is_empty() {
connection_id.clone()
} else {
state.get_or_create_pool(&connection_id, Some(&database)).await?
};
let result = do_execute(&state, &pool_key, &sql).await;
match &result {
Err(e) if is_connection_error(e) => {
let db_opt = if database.is_empty() { None } else { Some(database.as_str()) };
let new_key = state.reconnect_pool(&connection_id, db_opt).await?;
do_execute(&state, &new_key, &sql).await
}
_ => result,
}
}
+166
View File
@@ -0,0 +1,166 @@
use std::sync::Arc;
use tauri::State;
use crate::commands::connection::{AppState, PoolKind};
use crate::db::redis_driver::{self, RedisKeyInfo, RedisValue};
#[tauri::command]
pub async fn redis_list_databases(
state: State<'_, Arc<AppState>>,
connection_id: String,
) -> Result<Vec<u32>, String> {
let connections = state.connections.lock().await;
let pool = connections.get(&connection_id).ok_or("Connection not found")?;
match pool {
PoolKind::Redis(con) => {
let mut con = con.lock().await;
redis_driver::list_databases(&mut con).await
}
_ => Err("Not a Redis connection".to_string()),
}
}
#[tauri::command]
pub async fn redis_scan_keys(
state: State<'_, Arc<AppState>>,
connection_id: String,
db: u32,
pattern: String,
count: usize,
) -> Result<Vec<RedisKeyInfo>, String> {
let connections = state.connections.lock().await;
let pool = connections.get(&connection_id).ok_or("Connection not found")?;
match pool {
PoolKind::Redis(con) => {
let mut con = con.lock().await;
redis_driver::select_db(&mut con, db).await?;
redis_driver::scan_keys(&mut con, &pattern, count).await
}
_ => Err("Not a Redis connection".to_string()),
}
}
#[tauri::command]
pub async fn redis_get_value(
state: State<'_, Arc<AppState>>,
connection_id: String,
key: String,
) -> Result<RedisValue, String> {
let connections = state.connections.lock().await;
let pool = connections.get(&connection_id).ok_or("Connection not found")?;
match pool {
PoolKind::Redis(con) => {
let mut con = con.lock().await;
redis_driver::get_value(&mut con, &key).await
}
_ => Err("Not a Redis connection".to_string()),
}
}
#[tauri::command]
pub async fn redis_set_string(
state: State<'_, Arc<AppState>>,
connection_id: String,
key: String,
value: String,
ttl: Option<i64>,
) -> Result<(), String> {
let connections = state.connections.lock().await;
let pool = connections.get(&connection_id).ok_or("Connection not found")?;
match pool {
PoolKind::Redis(con) => {
let mut con = con.lock().await;
redis_driver::set_string(&mut con, &key, &value, ttl).await
}
_ => Err("Not a Redis connection".to_string()),
}
}
#[tauri::command]
pub async fn redis_delete_key(
state: State<'_, Arc<AppState>>,
connection_id: String,
key: String,
) -> Result<(), String> {
let connections = state.connections.lock().await;
let pool = connections.get(&connection_id).ok_or("Connection not found")?;
match pool {
PoolKind::Redis(con) => {
let mut con = con.lock().await;
redis_driver::delete_key(&mut con, &key).await
}
_ => Err("Not a Redis connection".to_string()),
}
}
#[tauri::command]
pub async fn redis_hash_set(
state: State<'_, Arc<AppState>>,
connection_id: String, key: String, field: String, value: String,
) -> Result<(), String> {
let connections = state.connections.lock().await;
match connections.get(&connection_id).ok_or("Not found")? {
PoolKind::Redis(con) => redis_driver::hash_set(&mut *con.lock().await, &key, &field, &value).await,
_ => Err("Not a Redis connection".to_string()),
}
}
#[tauri::command]
pub async fn redis_hash_del(
state: State<'_, Arc<AppState>>,
connection_id: String, key: String, field: String,
) -> Result<(), String> {
let connections = state.connections.lock().await;
match connections.get(&connection_id).ok_or("Not found")? {
PoolKind::Redis(con) => redis_driver::hash_del(&mut *con.lock().await, &key, &field).await,
_ => Err("Not a Redis connection".to_string()),
}
}
#[tauri::command]
pub async fn redis_list_push(
state: State<'_, Arc<AppState>>,
connection_id: String, key: String, value: String,
) -> Result<(), String> {
let connections = state.connections.lock().await;
match connections.get(&connection_id).ok_or("Not found")? {
PoolKind::Redis(con) => redis_driver::list_push(&mut *con.lock().await, &key, &value).await,
_ => Err("Not a Redis connection".to_string()),
}
}
#[tauri::command]
pub async fn redis_list_remove(
state: State<'_, Arc<AppState>>,
connection_id: String, key: String, index: i64,
) -> Result<(), String> {
let connections = state.connections.lock().await;
match connections.get(&connection_id).ok_or("Not found")? {
PoolKind::Redis(con) => redis_driver::list_remove(&mut *con.lock().await, &key, index).await,
_ => Err("Not a Redis connection".to_string()),
}
}
#[tauri::command]
pub async fn redis_set_add(
state: State<'_, Arc<AppState>>,
connection_id: String, key: String, member: String,
) -> Result<(), String> {
let connections = state.connections.lock().await;
match connections.get(&connection_id).ok_or("Not found")? {
PoolKind::Redis(con) => redis_driver::set_add(&mut *con.lock().await, &key, &member).await,
_ => Err("Not a Redis connection".to_string()),
}
}
#[tauri::command]
pub async fn redis_set_remove(
state: State<'_, Arc<AppState>>,
connection_id: String, key: String, member: String,
) -> Result<(), String> {
let connections = state.connections.lock().await;
match connections.get(&connection_id).ok_or("Not found")? {
PoolKind::Redis(con) => redis_driver::set_remove(&mut *con.lock().await, &key, &member).await,
_ => Err("Not a Redis connection".to_string()),
}
}
+299
View File
@@ -0,0 +1,299 @@
use std::sync::Arc;
use tauri::State;
use crate::commands::connection::{AppState, PoolKind};
use crate::db;
fn duckdb_query_tables(con: &duckdb::Connection) -> Result<Vec<db::TableInfo>, String> {
let mut stmt = con.prepare(
"SELECT table_name, table_type FROM information_schema.tables WHERE table_schema = 'main' ORDER BY table_name"
).map_err(|e| e.to_string())?;
let rows = stmt.query_map([], |row| {
Ok(db::TableInfo {
name: row.get::<_, String>(0)?,
table_type: row.get::<_, String>(1)?,
})
}).map_err(|e| e.to_string())?;
Ok(rows.filter_map(|r| r.ok()).collect())
}
fn duckdb_query_columns(con: &duckdb::Connection, table: &str) -> Result<Vec<db::ColumnInfo>, String> {
let mut pk_stmt = con.prepare(
"SELECT kcu.column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema
AND tc.table_name = kcu.table_name
WHERE tc.constraint_type = 'PRIMARY KEY'
AND tc.table_schema = 'main'
AND tc.table_name = ?
ORDER BY kcu.ordinal_position"
).map_err(|e| e.to_string())?;
let pk_rows = pk_stmt.query_map([table], |row| row.get::<_, String>(0))
.map_err(|e| e.to_string())?;
let primary_keys: std::collections::HashSet<String> = pk_rows.filter_map(|r| r.ok()).collect();
let mut stmt = con.prepare(
"SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema = 'main' AND table_name = ?
ORDER BY ordinal_position"
).map_err(|e| e.to_string())?;
let rows = stmt.query_map([table], |row| {
let name = row.get::<_, String>(0)?;
Ok(db::ColumnInfo {
is_primary_key: primary_keys.contains(&name),
name,
data_type: row.get::<_, String>(1)?,
is_nullable: row.get::<_, String>(2).unwrap_or_default() == "YES",
column_default: row.get::<_, Option<String>>(3)?,
extra: None,
})
}).map_err(|e| e.to_string())?;
Ok(rows.filter_map(|r| r.ok()).collect())
}
fn extract_duckdb(connections: &std::collections::HashMap<String, PoolKind>, key: &str) -> Option<std::sync::Arc<std::sync::Mutex<duckdb::Connection>>> {
match connections.get(key)? {
PoolKind::DuckDb(con) => Some(con.clone()),
_ => None,
}
}
fn extract_sqlserver(connections: &std::collections::HashMap<String, PoolKind>, key: &str) -> Option<std::sync::Arc<tokio::sync::Mutex<db::sqlserver::SqlServerClient>>> {
match connections.get(key)? {
PoolKind::SqlServer(client) => Some(client.clone()),
_ => None,
}
}
fn extract_clickhouse(connections: &std::collections::HashMap<String, PoolKind>, key: &str) -> Option<db::clickhouse_driver::ChClient> {
match connections.get(key)? {
PoolKind::ClickHouse(client) => Some(client.clone()),
_ => None,
}
}
#[tauri::command]
pub async fn list_databases(
state: State<'_, Arc<AppState>>,
connection_id: String,
) -> Result<Vec<db::DatabaseInfo>, String> {
{
let connections = state.connections.lock().await;
if let Some(client) = extract_clickhouse(&connections, &connection_id) {
drop(connections);
return db::clickhouse_driver::list_databases(&client).await;
}
if let Some(client) = extract_sqlserver(&connections, &connection_id) {
drop(connections);
let mut client = client.lock().await;
return db::sqlserver::list_databases(&mut client).await;
}
}
let connections = state.connections.lock().await;
let pool = connections.get(&connection_id).ok_or("Connection not found")?;
match pool {
PoolKind::Mysql(p) => db::mysql::list_databases(p).await,
PoolKind::Postgres(p) => db::postgres::list_databases(p).await,
PoolKind::Sqlite(p) => db::sqlite::list_databases(p).await,
PoolKind::DuckDb(_) => Ok(vec![db::DatabaseInfo { name: "main".to_string() }]),
_ => Ok(vec![]),
}
}
#[tauri::command]
pub async fn list_schemas(
state: State<'_, Arc<AppState>>,
connection_id: String,
database: String,
) -> Result<Vec<String>, String> {
let pool_key = state.get_or_create_pool(&connection_id, Some(&database)).await?;
{
let connections = state.connections.lock().await;
if let Some(client) = extract_sqlserver(&connections, &pool_key) {
drop(connections);
let mut client = client.lock().await;
return db::sqlserver::list_schemas(&mut client).await;
}
}
let connections = state.connections.lock().await;
let pool = connections.get(&pool_key).ok_or("Pool not found")?;
match pool {
PoolKind::Postgres(p) => db::postgres::list_schemas(p).await,
_ => Ok(vec![]),
}
}
#[tauri::command]
pub async fn list_tables(
state: State<'_, Arc<AppState>>,
connection_id: String,
database: String,
schema: String,
) -> Result<Vec<db::TableInfo>, String> {
let pool_key = state.get_or_create_pool(&connection_id, Some(&database)).await?;
{
let connections = state.connections.lock().await;
if let Some(con) = extract_duckdb(&connections, &pool_key) {
drop(connections);
let con = con.lock().map_err(|e| e.to_string())?;
return duckdb_query_tables(&con);
}
if let Some(client) = extract_clickhouse(&connections, &pool_key) {
drop(connections);
return db::clickhouse_driver::list_tables(&client, &database).await;
}
if let Some(client) = extract_sqlserver(&connections, &pool_key) {
drop(connections);
let mut client = client.lock().await;
return db::sqlserver::list_tables(&mut client, &schema).await;
}
}
let connections = state.connections.lock().await;
let pool = connections.get(&pool_key).ok_or("Pool not found")?;
match pool {
PoolKind::Mysql(p) => db::mysql::list_tables(p, &schema).await,
PoolKind::Postgres(p) => db::postgres::list_tables(p, &schema).await,
PoolKind::Sqlite(p) => db::sqlite::list_tables(p, &schema).await,
_ => Ok(vec![]),
}
}
#[tauri::command]
pub async fn get_columns(
state: State<'_, Arc<AppState>>,
connection_id: String,
database: String,
schema: String,
table: String,
) -> Result<Vec<db::ColumnInfo>, String> {
let pool_key = state.get_or_create_pool(&connection_id, Some(&database)).await?;
{
let connections = state.connections.lock().await;
if let Some(con) = extract_duckdb(&connections, &pool_key) {
drop(connections);
let con = con.lock().map_err(|e| e.to_string())?;
return duckdb_query_columns(&con, &table);
}
if let Some(client) = extract_clickhouse(&connections, &pool_key) {
drop(connections);
return db::clickhouse_driver::get_columns(&client, &database, &table).await;
}
if let Some(client) = extract_sqlserver(&connections, &pool_key) {
drop(connections);
let mut client = client.lock().await;
return db::sqlserver::get_columns(&mut client, &schema, &table).await;
}
}
let connections = state.connections.lock().await;
let pool = connections.get(&pool_key).ok_or("Pool not found")?;
match pool {
PoolKind::Mysql(p) => db::mysql::get_columns(p, &schema, &table).await,
PoolKind::Postgres(p) => db::postgres::get_columns(p, &schema, &table).await,
PoolKind::Sqlite(p) => db::sqlite::get_columns(p, &schema, &table).await,
_ => Ok(vec![]),
}
}
#[tauri::command]
pub async fn list_indexes(
state: State<'_, Arc<AppState>>,
connection_id: String,
database: String,
schema: String,
table: String,
) -> Result<Vec<db::IndexInfo>, String> {
let pool_key = state.get_or_create_pool(&connection_id, Some(&database)).await?;
{
let connections = state.connections.lock().await;
if let Some(client) = extract_sqlserver(&connections, &pool_key) {
drop(connections);
let mut client = client.lock().await;
return db::sqlserver::list_indexes(&mut client, &schema, &table).await;
}
}
let connections = state.connections.lock().await;
let pool = connections.get(&pool_key).ok_or("Pool not found")?;
match pool {
PoolKind::Mysql(p) => db::mysql::list_indexes(p, &schema, &table).await,
PoolKind::Postgres(p) => db::postgres::list_indexes(p, &schema, &table).await,
PoolKind::Sqlite(p) => db::sqlite::list_indexes(p, &schema, &table).await,
_ => Ok(vec![]),
}
}
#[tauri::command]
pub async fn list_foreign_keys(
state: State<'_, Arc<AppState>>,
connection_id: String,
database: String,
schema: String,
table: String,
) -> Result<Vec<db::ForeignKeyInfo>, String> {
let pool_key = state.get_or_create_pool(&connection_id, Some(&database)).await?;
{
let connections = state.connections.lock().await;
if let Some(client) = extract_sqlserver(&connections, &pool_key) {
drop(connections);
let mut client = client.lock().await;
return db::sqlserver::list_foreign_keys(&mut client, &schema, &table).await;
}
}
let connections = state.connections.lock().await;
let pool = connections.get(&pool_key).ok_or("Pool not found")?;
match pool {
PoolKind::Mysql(p) => db::mysql::list_foreign_keys(p, &schema, &table).await,
PoolKind::Postgres(p) => db::postgres::list_foreign_keys(p, &schema, &table).await,
PoolKind::Sqlite(p) => db::sqlite::list_foreign_keys(p, &schema, &table).await,
_ => Ok(vec![]),
}
}
#[tauri::command]
pub async fn list_triggers(
state: State<'_, Arc<AppState>>,
connection_id: String,
database: String,
schema: String,
table: String,
) -> Result<Vec<db::TriggerInfo>, String> {
let pool_key = state.get_or_create_pool(&connection_id, Some(&database)).await?;
{
let connections = state.connections.lock().await;
if let Some(client) = extract_sqlserver(&connections, &pool_key) {
drop(connections);
let mut client = client.lock().await;
return db::sqlserver::list_triggers(&mut client, &schema, &table).await;
}
}
let connections = state.connections.lock().await;
let pool = connections.get(&pool_key).ok_or("Pool not found")?;
match pool {
PoolKind::Mysql(p) => db::mysql::list_triggers(p, &schema, &table).await,
PoolKind::Postgres(p) => db::postgres::list_triggers(p, &schema, &table).await,
PoolKind::Sqlite(p) => db::sqlite::list_triggers(p, &schema, &table).await,
_ => Ok(vec![]),
}
}
+157
View File
@@ -0,0 +1,157 @@
use reqwest::Client as HttpClient;
use serde::Deserialize;
use std::time::Instant;
use super::{ColumnInfo, DatabaseInfo, QueryResult, TableInfo};
pub struct ChClient {
http: HttpClient,
base_url: String,
}
impl ChClient {
pub fn new(url: &str) -> Self {
Self {
http: HttpClient::new(),
base_url: url.trim_end_matches('/').to_string(),
}
}
}
impl Clone for ChClient {
fn clone(&self) -> Self {
Self {
http: self.http.clone(),
base_url: self.base_url.clone(),
}
}
}
#[derive(Deserialize)]
struct ChJsonResult {
meta: Vec<ChColumn>,
data: Vec<Vec<serde_json::Value>>,
#[serde(default)]
#[allow(dead_code)]
rows: usize,
}
#[derive(Deserialize)]
struct ChColumn {
name: String,
#[serde(rename = "type")]
_type: String,
}
async fn ch_query(client: &ChClient, sql: &str, database: Option<&str>) -> Result<ChJsonResult, String> {
let mut url = format!("{}/?default_format=JSONCompact", client.base_url);
if let Some(db) = database {
url.push_str(&format!("&database={}", db));
}
let resp = client.http.post(&url)
.body(sql.to_string())
.send()
.await
.map_err(|e| format!("ClickHouse request failed: {e}"))?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("ClickHouse error: {body}"));
}
resp.json::<ChJsonResult>().await.map_err(|e| format!("ClickHouse parse error: {e}"))
}
pub async fn test_connection(client: &ChClient) -> Result<(), String> {
let url = format!("{}/ping", client.base_url);
client.http.get(&url).send().await
.map_err(|e| format!("ClickHouse connection failed: {e}"))?;
Ok(())
}
pub async fn list_databases(client: &ChClient) -> Result<Vec<DatabaseInfo>, String> {
let result = ch_query(client, "SELECT name FROM system.databases ORDER BY name", None).await?;
Ok(result.data.iter().map(|row| {
DatabaseInfo { name: row[0].as_str().unwrap_or("").to_string() }
}).collect())
}
pub async fn list_tables(client: &ChClient, database: &str) -> Result<Vec<TableInfo>, String> {
let sql = format!(
"SELECT name, engine FROM system.tables WHERE database = '{}' ORDER BY name",
database.replace('\'', "\\'")
);
let result = ch_query(client, &sql, Some(database)).await?;
Ok(result.data.iter().map(|row| {
let engine = row.get(1).and_then(|v| v.as_str()).unwrap_or("");
let table_type = if engine.contains("View") { "VIEW" } else { "BASE TABLE" };
TableInfo {
name: row[0].as_str().unwrap_or("").to_string(),
table_type: table_type.to_string(),
}
}).collect())
}
pub async fn get_columns(client: &ChClient, database: &str, table: &str) -> Result<Vec<ColumnInfo>, String> {
let sql = format!(
"SELECT name, type, default_kind, default_expression, is_in_primary_key \
FROM system.columns WHERE database = '{}' AND table = '{}' ORDER BY position",
database.replace('\'', "\\'"),
table.replace('\'', "\\'")
);
let result = ch_query(client, &sql, Some(database)).await?;
Ok(result.data.iter().map(|row| {
let data_type = row.get(1).and_then(|v| v.as_str()).unwrap_or("").to_string();
let is_nullable = data_type.starts_with("Nullable");
let is_pk = row.get(4).and_then(|v| v.as_u64()).unwrap_or(0) == 1;
let default_kind = row.get(2).and_then(|v| v.as_str()).unwrap_or("");
let default_expr = row.get(3).and_then(|v| v.as_str()).unwrap_or("");
let column_default = if default_kind.is_empty() { None } else { Some(default_expr.to_string()) };
ColumnInfo {
name: row[0].as_str().unwrap_or("").to_string(),
data_type,
is_nullable,
column_default,
is_primary_key: is_pk,
extra: None,
}
}).collect())
}
pub async fn execute_query(client: &ChClient, database: &str, sql: &str) -> Result<QueryResult, String> {
let start = Instant::now();
let trimmed = sql.trim().to_uppercase();
if trimmed.starts_with("SELECT")
|| trimmed.starts_with("SHOW")
|| trimmed.starts_with("DESCRIBE")
|| trimmed.starts_with("EXPLAIN")
|| trimmed.starts_with("WITH")
{
let result = ch_query(client, sql, Some(database)).await?;
let columns: Vec<String> = result.meta.iter().map(|c| c.name.clone()).collect();
Ok(QueryResult {
columns,
rows: result.data,
affected_rows: 0,
execution_time_ms: start.elapsed().as_millis(),
truncated: false,
})
} else {
let url = format!("{}/?default_format=JSONCompact&database={}", client.base_url, database);
let resp = client.http.post(&url)
.body(sql.to_string())
.send()
.await
.map_err(|e| format!("ClickHouse request failed: {e}"))?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("ClickHouse error: {body}"));
}
Ok(QueryResult {
columns: vec![],
rows: vec![],
affected_rows: 0,
execution_time_ms: start.elapsed().as_millis(),
truncated: false,
})
}
}
+64
View File
@@ -0,0 +1,64 @@
pub mod clickhouse_driver;
pub mod mongo_driver;
pub mod mysql;
pub mod postgres;
pub mod redis_driver;
pub mod sqlite;
pub mod sqlserver;
pub mod ssh_tunnel;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DatabaseInfo {
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableInfo {
pub name: String,
pub table_type: String, // "TABLE" or "VIEW"
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ColumnInfo {
pub name: String,
pub data_type: String,
pub is_nullable: bool,
pub column_default: Option<String>,
pub is_primary_key: bool,
pub extra: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueryResult {
pub columns: Vec<String>,
pub rows: Vec<Vec<serde_json::Value>>,
pub affected_rows: u64,
pub execution_time_ms: u128,
#[serde(default)]
pub truncated: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexInfo {
pub name: String,
pub columns: Vec<String>,
pub is_unique: bool,
pub is_primary: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ForeignKeyInfo {
pub name: String,
pub column: String,
pub ref_table: String,
pub ref_column: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TriggerInfo {
pub name: String,
pub event: String,
pub timing: String,
}
+121
View File
@@ -0,0 +1,121 @@
use mongodb::{bson::{doc, Document, Bson}, Client};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MongoDocumentResult {
pub documents: Vec<serde_json::Value>,
pub total: u64,
}
pub async fn list_databases(client: &Client) -> Result<Vec<String>, String> {
client
.list_database_names()
.await
.map_err(|e| e.to_string())
}
pub async fn list_collections(client: &Client, database: &str) -> Result<Vec<String>, String> {
client
.database(database)
.list_collection_names()
.await
.map_err(|e| e.to_string())
}
pub async fn find_documents(
client: &Client,
database: &str,
collection: &str,
skip: u64,
limit: i64,
) -> Result<MongoDocumentResult, String> {
let col = client.database(database).collection::<Document>(collection);
let total = col.count_documents(doc! {}).await.map_err(|e| e.to_string())?;
let mut cursor = col
.find(doc! {})
.skip(skip)
.limit(limit)
.await
.map_err(|e| e.to_string())?;
let mut documents = Vec::new();
while cursor.advance().await.map_err(|e| e.to_string())? {
let doc = cursor.deserialize_current().map_err(|e| e.to_string())?;
let json = bson_to_json(&Bson::Document(doc));
documents.push(json);
}
Ok(MongoDocumentResult { documents, total })
}
pub async fn insert_document(
client: &Client,
database: &str,
collection: &str,
doc_json: &str,
) -> Result<String, String> {
let doc: Document = serde_json::from_str(doc_json)
.map_err(|e| format!("Invalid JSON: {e}"))?;
let col = client.database(database).collection::<Document>(collection);
let result = col.insert_one(doc).await.map_err(|e| e.to_string())?;
Ok(format!("{}", result.inserted_id))
}
pub async fn update_document(
client: &Client,
database: &str,
collection: &str,
id: &str,
doc_json: &str,
) -> Result<u64, String> {
let oid = mongodb::bson::oid::ObjectId::parse_str(id)
.map_err(|e| format!("Invalid ObjectId: {e}"))?;
let new_doc: Document = serde_json::from_str(doc_json)
.map_err(|e| format!("Invalid JSON: {e}"))?;
let col = client.database(database).collection::<Document>(collection);
let result = col
.replace_one(doc! { "_id": oid }, new_doc)
.await
.map_err(|e| e.to_string())?;
Ok(result.modified_count)
}
pub async fn delete_document(
client: &Client,
database: &str,
collection: &str,
id: &str,
) -> Result<u64, String> {
let oid = mongodb::bson::oid::ObjectId::parse_str(id)
.map_err(|e| format!("Invalid ObjectId: {e}"))?;
let col = client.database(database).collection::<Document>(collection);
let result = col
.delete_one(doc! { "_id": oid })
.await
.map_err(|e| e.to_string())?;
Ok(result.deleted_count)
}
fn bson_to_json(bson: &Bson) -> serde_json::Value {
match bson {
Bson::Double(v) => serde_json::json!(v),
Bson::String(v) => serde_json::Value::String(v.clone()),
Bson::Boolean(v) => serde_json::Value::Bool(*v),
Bson::Null => serde_json::Value::Null,
Bson::Int32(v) => serde_json::json!(v),
Bson::Int64(v) => serde_json::json!(v),
Bson::ObjectId(oid) => serde_json::Value::String(oid.to_hex()),
Bson::DateTime(dt) => serde_json::Value::String(dt.to_string()),
Bson::Array(arr) => serde_json::Value::Array(arr.iter().map(bson_to_json).collect()),
Bson::Document(doc) => {
let mut map = serde_json::Map::new();
for (k, v) in doc {
map.insert(k.clone(), bson_to_json(v));
}
serde_json::Value::Object(map)
}
_ => serde_json::Value::String(format!("{bson}")),
}
}
+217
View File
@@ -0,0 +1,217 @@
use sqlx::mysql::{MySqlPool, MySqlPoolOptions, MySqlRow};
use sqlx::{Column, Executor, Row};
use std::time::{Duration, Instant};
use super::{ColumnInfo, DatabaseInfo, ForeignKeyInfo, IndexInfo, QueryResult, TableInfo, TriggerInfo};
pub async fn connect(url: &str) -> Result<MySqlPool, String> {
MySqlPoolOptions::new()
.max_connections(5)
.acquire_timeout(Duration::from_secs(10))
.idle_timeout(Duration::from_secs(300))
.connect(url)
.await
.map_err(|e| format!("MySQL connection failed: {e}"))
}
pub async fn list_databases(pool: &MySqlPool) -> Result<Vec<DatabaseInfo>, String> {
let rows: Vec<MySqlRow> = sqlx::query("SHOW DATABASES")
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|row| DatabaseInfo {
name: row.get::<String, _>(0),
})
.collect())
}
pub async fn list_tables(pool: &MySqlPool, database: &str) -> Result<Vec<TableInfo>, String> {
let rows: Vec<MySqlRow> = sqlx::query(
"SELECT TABLE_NAME, TABLE_TYPE FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME",
)
.bind(database)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|row| TableInfo {
name: row.get::<String, _>("TABLE_NAME"),
table_type: row.get::<String, _>("TABLE_TYPE"),
})
.collect())
}
pub async fn get_columns(
pool: &MySqlPool,
database: &str,
table: &str,
) -> Result<Vec<ColumnInfo>, String> {
let rows: Vec<MySqlRow> = sqlx::query(
"SELECT c.COLUMN_NAME, c.DATA_TYPE, c.IS_NULLABLE, c.COLUMN_DEFAULT, c.EXTRA, \
CASE WHEN kcu.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END AS IS_PK \
FROM information_schema.COLUMNS c \
LEFT JOIN information_schema.KEY_COLUMN_USAGE kcu \
ON c.TABLE_SCHEMA = kcu.TABLE_SCHEMA \
AND c.TABLE_NAME = kcu.TABLE_NAME \
AND c.COLUMN_NAME = kcu.COLUMN_NAME \
AND kcu.CONSTRAINT_NAME = 'PRIMARY' \
WHERE c.TABLE_SCHEMA = ? AND c.TABLE_NAME = ? \
ORDER BY c.ORDINAL_POSITION",
)
.bind(database)
.bind(table)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|row| ColumnInfo {
name: row.get::<String, _>("COLUMN_NAME"),
data_type: row.get::<String, _>("DATA_TYPE"),
is_nullable: row.get::<String, _>("IS_NULLABLE") == "YES",
column_default: row.get::<Option<String>, _>("COLUMN_DEFAULT"),
is_primary_key: row.get::<i32, _>("IS_PK") == 1,
extra: row.get::<Option<String>, _>("EXTRA"),
})
.collect())
}
pub async fn execute_query(pool: &MySqlPool, sql: &str) -> Result<QueryResult, String> {
let start = Instant::now();
let trimmed = sql.trim().to_uppercase();
if trimmed.starts_with("SELECT") || trimmed.starts_with("SHOW") || trimmed.starts_with("DESCRIBE") || trimmed.starts_with("EXPLAIN") {
let desc = pool.describe(sql).await.map_err(|e| e.to_string())?;
let columns: Vec<String> = desc.columns().iter().map(|c| c.name().to_string()).collect();
let rows: Vec<MySqlRow> = sqlx::query(sql)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
let result_rows: Vec<Vec<serde_json::Value>> = rows
.iter()
.map(|row| {
(0..row.len())
.map(|i| {
row.try_get::<String, _>(i)
.map(serde_json::Value::String)
.or_else(|_| row.try_get::<i64, _>(i).map(|v| serde_json::Value::Number(v.into())))
.or_else(|_| row.try_get::<f64, _>(i).map(|v| {
serde_json::Number::from_f64(v)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null)
}))
.or_else(|_| row.try_get::<bool, _>(i).map(serde_json::Value::Bool))
.unwrap_or(serde_json::Value::Null)
})
.collect()
})
.collect();
Ok(QueryResult {
columns,
rows: result_rows,
affected_rows: 0,
execution_time_ms: start.elapsed().as_millis(),
truncated: false,
})
} else {
let result = sqlx::query(sql)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
Ok(QueryResult {
columns: vec![],
rows: vec![],
affected_rows: result.rows_affected(),
execution_time_ms: start.elapsed().as_millis(),
truncated: false,
})
}
}
pub async fn list_indexes(pool: &MySqlPool, database: &str, table: &str) -> Result<Vec<IndexInfo>, String> {
let rows: Vec<MySqlRow> = sqlx::query(
"SELECT INDEX_NAME, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS columns, \
NOT NON_UNIQUE AS is_unique, INDEX_NAME = 'PRIMARY' AS is_primary \
FROM information_schema.STATISTICS \
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? \
GROUP BY INDEX_NAME, NON_UNIQUE \
ORDER BY INDEX_NAME",
)
.bind(database)
.bind(table)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|row| {
let cols_str: String = row.get("columns");
IndexInfo {
name: row.get::<String, _>("INDEX_NAME"),
columns: cols_str.split(',').map(|s| s.to_string()).collect(),
is_unique: row.get::<bool, _>("is_unique"),
is_primary: row.get::<bool, _>("is_primary"),
}
})
.collect())
}
pub async fn list_foreign_keys(pool: &MySqlPool, database: &str, table: &str) -> Result<Vec<ForeignKeyInfo>, String> {
let rows: Vec<MySqlRow> = sqlx::query(
"SELECT kcu.CONSTRAINT_NAME, kcu.COLUMN_NAME, \
kcu.REFERENCED_TABLE_NAME, kcu.REFERENCED_COLUMN_NAME \
FROM information_schema.KEY_COLUMN_USAGE kcu \
WHERE kcu.TABLE_SCHEMA = ? AND kcu.TABLE_NAME = ? \
AND kcu.REFERENCED_TABLE_NAME IS NOT NULL \
ORDER BY kcu.CONSTRAINT_NAME",
)
.bind(database)
.bind(table)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|row| ForeignKeyInfo {
name: row.get::<String, _>("CONSTRAINT_NAME"),
column: row.get::<String, _>("COLUMN_NAME"),
ref_table: row.get::<String, _>("REFERENCED_TABLE_NAME"),
ref_column: row.get::<String, _>("REFERENCED_COLUMN_NAME"),
})
.collect())
}
pub async fn list_triggers(pool: &MySqlPool, database: &str, table: &str) -> Result<Vec<TriggerInfo>, String> {
let rows: Vec<MySqlRow> = sqlx::query(
"SELECT TRIGGER_NAME, EVENT_MANIPULATION, ACTION_TIMING \
FROM information_schema.TRIGGERS \
WHERE TRIGGER_SCHEMA = ? AND EVENT_OBJECT_TABLE = ? \
ORDER BY TRIGGER_NAME",
)
.bind(database)
.bind(table)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|row| TriggerInfo {
name: row.get::<String, _>("TRIGGER_NAME"),
event: row.get::<String, _>("EVENT_MANIPULATION"),
timing: row.get::<String, _>("ACTION_TIMING"),
})
.collect())
}
+261
View File
@@ -0,0 +1,261 @@
use sqlx::postgres::{PgPool, PgPoolOptions, PgRow};
use sqlx::{Column, Executor, Row};
use std::time::{Duration, Instant};
use super::{ColumnInfo, DatabaseInfo, ForeignKeyInfo, IndexInfo, QueryResult, TableInfo, TriggerInfo};
pub async fn connect(url: &str) -> Result<PgPool, String> {
PgPoolOptions::new()
.max_connections(5)
.acquire_timeout(Duration::from_secs(10))
.idle_timeout(Duration::from_secs(300))
.connect(url)
.await
.map_err(|e| format!("PostgreSQL connection failed: {e}"))
}
pub async fn list_databases(pool: &PgPool) -> Result<Vec<DatabaseInfo>, String> {
let rows: Vec<PgRow> =
sqlx::query("SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname")
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|row| DatabaseInfo {
name: row.get::<String, _>("datname"),
})
.collect())
}
pub async fn list_tables(pool: &PgPool, schema: &str) -> Result<Vec<TableInfo>, String> {
let rows: Vec<PgRow> = sqlx::query(
"SELECT table_name, table_type FROM information_schema.tables \
WHERE table_schema = $1 ORDER BY table_name",
)
.bind(schema)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|row| TableInfo {
name: row.get::<String, _>("table_name"),
table_type: row.get::<String, _>("table_type"),
})
.collect())
}
pub async fn list_schemas(pool: &PgPool) -> Result<Vec<String>, String> {
let rows: Vec<PgRow> = sqlx::query(
"SELECT schema_name FROM information_schema.schemata \
WHERE schema_name NOT IN ('information_schema', 'pg_catalog', 'pg_toast') \
ORDER BY schema_name",
)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|row| row.get::<String, _>("schema_name"))
.collect())
}
pub async fn get_columns(
pool: &PgPool,
schema: &str,
table: &str,
) -> Result<Vec<ColumnInfo>, String> {
let rows: Vec<PgRow> = sqlx::query(
"SELECT c.column_name, c.data_type, c.is_nullable, c.column_default, \
CASE WHEN tc.constraint_type = 'PRIMARY KEY' THEN true ELSE false END AS is_pk \
FROM information_schema.columns c \
LEFT JOIN information_schema.key_column_usage kcu \
ON c.table_schema = kcu.table_schema \
AND c.table_name = kcu.table_name \
AND c.column_name = kcu.column_name \
LEFT JOIN information_schema.table_constraints tc \
ON kcu.constraint_name = tc.constraint_name \
AND kcu.table_schema = tc.table_schema \
AND tc.constraint_type = 'PRIMARY KEY' \
WHERE c.table_schema = $1 AND c.table_name = $2 \
ORDER BY c.ordinal_position",
)
.bind(schema)
.bind(table)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|row| ColumnInfo {
name: row.get::<String, _>("column_name"),
data_type: row.get::<String, _>("data_type"),
is_nullable: row.get::<String, _>("is_nullable") == "YES",
column_default: row.get::<Option<String>, _>("column_default"),
is_primary_key: row.get::<bool, _>("is_pk"),
extra: None,
})
.collect())
}
pub async fn execute_query(pool: &PgPool, sql: &str) -> Result<QueryResult, String> {
let start = Instant::now();
let trimmed = sql.trim().to_uppercase();
if trimmed.starts_with("SELECT")
|| trimmed.starts_with("SHOW")
|| trimmed.starts_with("EXPLAIN")
|| trimmed.starts_with("WITH")
|| trimmed.starts_with("TABLE")
{
let desc = pool.describe(sql).await.map_err(|e| e.to_string())?;
let columns: Vec<String> = desc.columns().iter().map(|c| c.name().to_string()).collect();
let rows: Vec<PgRow> = sqlx::query(sql)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
let result_rows: Vec<Vec<serde_json::Value>> = rows
.iter()
.map(|row| {
(0..row.len())
.map(|i| {
row.try_get::<String, _>(i)
.map(serde_json::Value::String)
.or_else(|_| {
row.try_get::<i64, _>(i)
.map(|v| serde_json::Value::Number(v.into()))
})
.or_else(|_| {
row.try_get::<i32, _>(i)
.map(|v| serde_json::Value::Number(v.into()))
})
.or_else(|_| {
row.try_get::<f64, _>(i).map(|v| {
serde_json::Number::from_f64(v)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null)
})
})
.or_else(|_| row.try_get::<bool, _>(i).map(serde_json::Value::Bool))
.unwrap_or(serde_json::Value::Null)
})
.collect()
})
.collect();
Ok(QueryResult {
columns,
rows: result_rows,
affected_rows: 0,
execution_time_ms: start.elapsed().as_millis(),
truncated: false,
})
} else {
let result = sqlx::query(sql)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
Ok(QueryResult {
columns: vec![],
rows: vec![],
affected_rows: result.rows_affected(),
execution_time_ms: start.elapsed().as_millis(),
truncated: false,
})
}
}
pub async fn list_indexes(pool: &PgPool, schema: &str, table: &str) -> Result<Vec<IndexInfo>, String> {
let rows: Vec<PgRow> = sqlx::query(
"SELECT i.relname AS index_name, \
array_agg(a.attname ORDER BY k.n) AS columns, \
ix.indisunique AS is_unique, \
ix.indisprimary AS is_primary \
FROM pg_index ix \
JOIN pg_class t ON t.oid = ix.indrelid \
JOIN pg_class i ON i.oid = ix.indexrelid \
JOIN pg_namespace n ON n.oid = t.relnamespace \
JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(attnum, n) ON true \
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum \
WHERE n.nspname = $1 AND t.relname = $2 \
GROUP BY i.relname, ix.indisunique, ix.indisprimary \
ORDER BY i.relname",
)
.bind(schema)
.bind(table)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|row| IndexInfo {
name: row.get::<String, _>("index_name"),
columns: row.get::<Vec<String>, _>("columns"),
is_unique: row.get::<bool, _>("is_unique"),
is_primary: row.get::<bool, _>("is_primary"),
})
.collect())
}
pub async fn list_foreign_keys(pool: &PgPool, schema: &str, table: &str) -> Result<Vec<ForeignKeyInfo>, String> {
let rows: Vec<PgRow> = sqlx::query(
"SELECT kcu.constraint_name, kcu.column_name, \
ccu.table_name AS ref_table, ccu.column_name AS ref_column \
FROM information_schema.key_column_usage kcu \
JOIN information_schema.referential_constraints rc \
ON kcu.constraint_name = rc.constraint_name \
AND kcu.constraint_schema = rc.constraint_schema \
JOIN information_schema.constraint_column_usage ccu \
ON rc.unique_constraint_name = ccu.constraint_name \
AND rc.unique_constraint_schema = ccu.constraint_schema \
WHERE kcu.table_schema = $1 AND kcu.table_name = $2 \
ORDER BY kcu.constraint_name",
)
.bind(schema)
.bind(table)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|row| ForeignKeyInfo {
name: row.get::<String, _>("constraint_name"),
column: row.get::<String, _>("column_name"),
ref_table: row.get::<String, _>("ref_table"),
ref_column: row.get::<String, _>("ref_column"),
})
.collect())
}
pub async fn list_triggers(pool: &PgPool, schema: &str, table: &str) -> Result<Vec<TriggerInfo>, String> {
let rows: Vec<PgRow> = sqlx::query(
"SELECT trigger_name, event_manipulation, action_timing \
FROM information_schema.triggers \
WHERE trigger_schema = $1 AND event_object_table = $2 \
ORDER BY trigger_name",
)
.bind(schema)
.bind(table)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|row| TriggerInfo {
name: row.get::<String, _>("trigger_name"),
event: row.get::<String, _>("event_manipulation"),
timing: row.get::<String, _>("action_timing"),
})
.collect())
}
+246
View File
@@ -0,0 +1,246 @@
use redis::AsyncCommands;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RedisKeyInfo {
pub key: String,
pub key_type: String,
pub ttl: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RedisValue {
pub key: String,
pub key_type: String,
pub ttl: i64,
pub value: serde_json::Value,
}
pub async fn connect(url: &str) -> Result<redis::aio::MultiplexedConnection, String> {
let client = redis::Client::open(url).map_err(|e| format!("Redis connection failed: {e}"))?;
client
.get_multiplexed_async_connection()
.await
.map_err(|e| format!("Redis connection failed: {e}"))
}
pub async fn list_databases(con: &mut redis::aio::MultiplexedConnection) -> Result<Vec<u32>, String> {
let info: String = redis::cmd("INFO")
.arg("keyspace")
.query_async(con)
.await
.map_err(|e| e.to_string())?;
let mut dbs: Vec<u32> = Vec::new();
for line in info.lines() {
if line.starts_with("db") {
if let Some(num) = line.strip_prefix("db").and_then(|s| s.split(':').next()) {
if let Ok(n) = num.parse::<u32>() {
dbs.push(n);
}
}
}
}
if dbs.is_empty() {
dbs.push(0);
}
Ok(dbs)
}
pub async fn select_db(con: &mut redis::aio::MultiplexedConnection, db: u32) -> Result<(), String> {
redis::cmd("SELECT")
.arg(db)
.query_async(con)
.await
.map_err(|e| e.to_string())
}
pub async fn scan_keys(
con: &mut redis::aio::MultiplexedConnection,
pattern: &str,
count: usize,
) -> Result<Vec<RedisKeyInfo>, String> {
let mut cursor: u64 = 0;
let mut all_keys: Vec<String> = Vec::new();
loop {
let (new_cursor, keys): (u64, Vec<String>) = redis::cmd("SCAN")
.arg(cursor)
.arg("MATCH")
.arg(pattern)
.arg("COUNT")
.arg(100)
.query_async(con)
.await
.map_err(|e| e.to_string())?;
all_keys.extend(keys);
cursor = new_cursor;
if cursor == 0 || all_keys.len() >= count {
break;
}
}
all_keys.truncate(count);
let mut result = Vec::new();
for key in &all_keys {
let key_type: String = redis::cmd("TYPE")
.arg(key.as_str())
.query_async(con)
.await
.unwrap_or_else(|_| "unknown".to_string());
let ttl: i64 = con.ttl(key.as_str()).await.unwrap_or(-1);
result.push(RedisKeyInfo {
key: key.clone(),
key_type,
ttl,
});
}
Ok(result)
}
pub async fn get_value(
con: &mut redis::aio::MultiplexedConnection,
key: &str,
) -> Result<RedisValue, String> {
let key_type: String = redis::cmd("TYPE")
.arg(key)
.query_async(con)
.await
.map_err(|e| e.to_string())?;
let ttl: i64 = con.ttl(key).await.unwrap_or(-1);
let value = match key_type.as_str() {
"string" => {
let v: String = con.get(key).await.map_err(|e| e.to_string())?;
serde_json::Value::String(v)
}
"list" => {
let v: Vec<String> = con.lrange(key, 0, -1).await.map_err(|e| e.to_string())?;
serde_json::json!(v)
}
"set" => {
let v: Vec<String> = con.smembers(key).await.map_err(|e| e.to_string())?;
serde_json::json!(v)
}
"zset" => {
let v: Vec<(String, f64)> = con
.zrange_withscores(key, 0, -1)
.await
.map_err(|e| e.to_string())?;
serde_json::json!(v.iter().map(|(m, s)| serde_json::json!({"member": m, "score": s})).collect::<Vec<_>>())
}
"hash" => {
let v: Vec<(String, String)> = con.hgetall(key).await.map_err(|e| e.to_string())?;
let map: serde_json::Map<String, serde_json::Value> = v
.into_iter()
.map(|(k, v)| (k, serde_json::Value::String(v)))
.collect();
serde_json::Value::Object(map)
}
_ => serde_json::Value::Null,
};
Ok(RedisValue {
key: key.to_string(),
key_type,
ttl,
value,
})
}
pub async fn set_string(
con: &mut redis::aio::MultiplexedConnection,
key: &str,
value: &str,
ttl: Option<i64>,
) -> Result<(), String> {
con.set::<_, _, ()>(key, value)
.await
.map_err(|e| e.to_string())?;
if let Some(t) = ttl {
if t > 0 {
con.expire::<_, ()>(key, t)
.await
.map_err(|e| e.to_string())?;
}
}
Ok(())
}
pub async fn delete_key(
con: &mut redis::aio::MultiplexedConnection,
key: &str,
) -> Result<(), String> {
con.del::<_, ()>(key).await.map_err(|e| e.to_string())
}
pub async fn hash_set(
con: &mut redis::aio::MultiplexedConnection,
key: &str,
field: &str,
value: &str,
) -> Result<(), String> {
con.hset::<_, _, _, ()>(key, field, value)
.await
.map_err(|e| e.to_string())
}
pub async fn hash_del(
con: &mut redis::aio::MultiplexedConnection,
key: &str,
field: &str,
) -> Result<(), String> {
con.hdel::<_, _, ()>(key, field)
.await
.map_err(|e| e.to_string())
}
pub async fn list_push(
con: &mut redis::aio::MultiplexedConnection,
key: &str,
value: &str,
) -> Result<(), String> {
con.rpush::<_, _, ()>(key, value)
.await
.map_err(|e| e.to_string())
}
pub async fn list_remove(
con: &mut redis::aio::MultiplexedConnection,
key: &str,
index: i64,
) -> Result<(), String> {
let placeholder = "__DELETED_PLACEHOLDER__";
redis::cmd("LSET")
.arg(key)
.arg(index)
.arg(placeholder)
.query_async::<()>(con)
.await
.map_err(|e| e.to_string())?;
con.lrem::<_, _, ()>(key, 1, placeholder)
.await
.map_err(|e| e.to_string())
}
pub async fn set_add(
con: &mut redis::aio::MultiplexedConnection,
key: &str,
member: &str,
) -> Result<(), String> {
con.sadd::<_, _, ()>(key, member)
.await
.map_err(|e| e.to_string())
}
pub async fn set_remove(
con: &mut redis::aio::MultiplexedConnection,
key: &str,
member: &str,
) -> Result<(), String> {
con.srem::<_, _, ()>(key, member)
.await
.map_err(|e| e.to_string())
}
+188
View File
@@ -0,0 +1,188 @@
use sqlx::sqlite::{SqlitePool, SqlitePoolOptions, SqliteRow};
use sqlx::{Column, Executor, Row};
use std::time::{Duration, Instant};
use super::{ColumnInfo, DatabaseInfo, ForeignKeyInfo, IndexInfo, QueryResult, TableInfo, TriggerInfo};
pub async fn connect(url: &str) -> Result<SqlitePool, String> {
SqlitePoolOptions::new()
.max_connections(5)
.acquire_timeout(Duration::from_secs(10))
.idle_timeout(Duration::from_secs(300))
.connect(url)
.await
.map_err(|e| format!("SQLite connection failed: {e}"))
}
pub async fn list_databases(_pool: &SqlitePool) -> Result<Vec<DatabaseInfo>, String> {
Ok(vec![DatabaseInfo { name: "main".to_string() }])
}
pub async fn list_tables(pool: &SqlitePool, _schema: &str) -> Result<Vec<TableInfo>, String> {
let rows: Vec<SqliteRow> = sqlx::query(
"SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' ORDER BY name",
)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|row| {
let t: String = row.get("type");
TableInfo {
name: row.get::<String, _>("name"),
table_type: if t == "view" { "VIEW".to_string() } else { "BASE TABLE".to_string() },
}
})
.collect())
}
pub async fn get_columns(pool: &SqlitePool, _schema: &str, table: &str) -> Result<Vec<ColumnInfo>, String> {
let rows: Vec<SqliteRow> = sqlx::query(&format!("PRAGMA table_info(\"{}\")", table))
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|row| ColumnInfo {
name: row.get::<String, _>("name"),
data_type: row.get::<String, _>("type"),
is_nullable: row.get::<i32, _>("notnull") == 0,
column_default: row.get::<Option<String>, _>("dflt_value"),
is_primary_key: row.get::<i32, _>("pk") > 0,
extra: None,
})
.collect())
}
pub async fn list_indexes(pool: &SqlitePool, _schema: &str, table: &str) -> Result<Vec<IndexInfo>, String> {
let idx_rows: Vec<SqliteRow> = sqlx::query(&format!("PRAGMA index_list(\"{}\")", table))
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
let mut indexes = Vec::new();
for idx_row in &idx_rows {
let name: String = idx_row.get("name");
let is_unique: bool = idx_row.get::<i32, _>("unique") != 0;
let col_rows: Vec<SqliteRow> = sqlx::query(&format!("PRAGMA index_info(\"{}\")", name))
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
let columns: Vec<String> = col_rows.iter().map(|r| r.get::<String, _>("name")).collect();
indexes.push(IndexInfo {
name,
columns,
is_unique,
is_primary: false,
});
}
Ok(indexes)
}
pub async fn list_foreign_keys(pool: &SqlitePool, _schema: &str, table: &str) -> Result<Vec<ForeignKeyInfo>, String> {
let rows: Vec<SqliteRow> = sqlx::query(&format!("PRAGMA foreign_key_list(\"{}\")", table))
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|row| ForeignKeyInfo {
name: format!("fk_{}", row.get::<i32, _>("id")),
column: row.get::<String, _>("from"),
ref_table: row.get::<String, _>("table"),
ref_column: row.get::<String, _>("to"),
})
.collect())
}
pub async fn list_triggers(pool: &SqlitePool, _schema: &str, table: &str) -> Result<Vec<TriggerInfo>, String> {
let rows: Vec<SqliteRow> = sqlx::query(
"SELECT name, sql FROM sqlite_master WHERE type = 'trigger' AND tbl_name = ? ORDER BY name",
)
.bind(table)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|row| {
let sql_text: String = row.get::<Option<String>, _>("sql").unwrap_or_default();
let upper = sql_text.to_uppercase();
let timing = if upper.contains("BEFORE") { "BEFORE" } else if upper.contains("AFTER") { "AFTER" } else { "INSTEAD OF" };
let event = if upper.contains("INSERT") { "INSERT" } else if upper.contains("UPDATE") { "UPDATE" } else { "DELETE" };
TriggerInfo {
name: row.get::<String, _>("name"),
event: event.to_string(),
timing: timing.to_string(),
}
})
.collect())
}
pub async fn execute_query(pool: &SqlitePool, sql: &str) -> Result<QueryResult, String> {
let start = Instant::now();
let trimmed = sql.trim().to_uppercase();
if trimmed.starts_with("SELECT")
|| trimmed.starts_with("PRAGMA")
|| trimmed.starts_with("EXPLAIN")
|| trimmed.starts_with("WITH")
{
let desc = pool.describe(sql).await.map_err(|e| e.to_string())?;
let columns: Vec<String> = desc.columns().iter().map(|c| c.name().to_string()).collect();
let rows: Vec<SqliteRow> = sqlx::query(sql)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
let result_rows: Vec<Vec<serde_json::Value>> = rows
.iter()
.map(|row| {
(0..row.len())
.map(|i| {
row.try_get::<String, _>(i)
.map(serde_json::Value::String)
.or_else(|_| row.try_get::<i64, _>(i).map(|v| serde_json::Value::Number(v.into())))
.or_else(|_| row.try_get::<f64, _>(i).map(|v| {
serde_json::Number::from_f64(v)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null)
}))
.or_else(|_| row.try_get::<bool, _>(i).map(serde_json::Value::Bool))
.unwrap_or(serde_json::Value::Null)
})
.collect()
})
.collect();
Ok(QueryResult {
columns,
rows: result_rows,
affected_rows: 0,
execution_time_ms: start.elapsed().as_millis(),
truncated: false,
})
} else {
let result = sqlx::query(sql)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
Ok(QueryResult {
columns: vec![],
rows: vec![],
affected_rows: result.rows_affected(),
execution_time_ms: start.elapsed().as_millis(),
truncated: false,
})
}
}
+212
View File
@@ -0,0 +1,212 @@
use tiberius::{AuthMethod, Client, Config};
use tokio::net::TcpStream;
use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt};
use std::time::Instant;
use super::{ColumnInfo, DatabaseInfo, ForeignKeyInfo, IndexInfo, QueryResult, TableInfo, TriggerInfo};
pub type SqlServerClient = Client<Compat<TcpStream>>;
pub async fn connect(host: &str, port: u16, user: &str, pass: &str, database: Option<&str>) -> Result<SqlServerClient, String> {
let mut config = Config::new();
config.host(host);
config.port(port);
config.authentication(AuthMethod::sql_server(user, pass));
if let Some(db) = database {
config.database(db);
}
config.trust_cert();
let tcp = TcpStream::connect(config.get_addr())
.await
.map_err(|e| format!("SQL Server connection failed: {e}"))?;
Client::connect(config, tcp.compat_write())
.await
.map_err(|e| format!("SQL Server connection failed: {e}"))
}
fn row_to_json(row: &tiberius::Row) -> Vec<serde_json::Value> {
(0..row.len()).map(|i| {
if let Some(v) = row.try_get::<&str, _>(i).ok().flatten() {
serde_json::Value::String(v.to_string())
} else if let Some(v) = row.try_get::<i32, _>(i).ok().flatten() {
serde_json::Value::Number(v.into())
} else if let Some(v) = row.try_get::<i64, _>(i).ok().flatten() {
serde_json::Value::Number(v.into())
} else if let Some(v) = row.try_get::<f64, _>(i).ok().flatten() {
serde_json::Number::from_f64(v).map(serde_json::Value::Number).unwrap_or(serde_json::Value::Null)
} else if let Some(v) = row.try_get::<bool, _>(i).ok().flatten() {
serde_json::Value::Bool(v)
} else {
serde_json::Value::Null
}
}).collect()
}
pub async fn list_databases(client: &mut SqlServerClient) -> Result<Vec<DatabaseInfo>, String> {
let stream = client.query("SELECT name FROM sys.databases ORDER BY name", &[])
.await.map_err(|e| e.to_string())?;
let rows = stream.into_first_result().await.map_err(|e| e.to_string())?;
Ok(rows.iter().map(|row| {
DatabaseInfo { name: row.get::<&str, _>(0).unwrap_or("").to_string() }
}).collect())
}
pub async fn list_schemas(client: &mut SqlServerClient) -> Result<Vec<String>, String> {
let stream = client.query(
"SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA \
WHERE SCHEMA_NAME NOT IN ('guest','INFORMATION_SCHEMA','sys') \
ORDER BY SCHEMA_NAME",
&[],
).await.map_err(|e| e.to_string())?;
let rows = stream.into_first_result().await.map_err(|e| e.to_string())?;
Ok(rows.iter().map(|row| {
row.get::<&str, _>(0).unwrap_or("").to_string()
}).collect())
}
pub async fn list_tables(client: &mut SqlServerClient, schema: &str) -> Result<Vec<TableInfo>, String> {
let sql = format!(
"SELECT TABLE_NAME, TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = '{}' ORDER BY TABLE_NAME",
schema.replace('\'', "''")
);
let stream = client.query(&*sql, &[]).await.map_err(|e| e.to_string())?;
let rows = stream.into_first_result().await.map_err(|e| e.to_string())?;
Ok(rows.iter().map(|row| {
TableInfo {
name: row.get::<&str, _>(0).unwrap_or("").to_string(),
table_type: row.get::<&str, _>(1).unwrap_or("BASE TABLE").to_string(),
}
}).collect())
}
pub async fn get_columns(client: &mut SqlServerClient, schema: &str, table: &str) -> Result<Vec<ColumnInfo>, String> {
let sql = format!(
"SELECT c.COLUMN_NAME, c.DATA_TYPE, c.IS_NULLABLE, c.COLUMN_DEFAULT, \
CASE WHEN kcu.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END AS IS_PK \
FROM INFORMATION_SCHEMA.COLUMNS c \
LEFT JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu \
ON c.TABLE_SCHEMA = kcu.TABLE_SCHEMA AND c.TABLE_NAME = kcu.TABLE_NAME AND c.COLUMN_NAME = kcu.COLUMN_NAME \
AND kcu.CONSTRAINT_NAME IN (SELECT CONSTRAINT_NAME FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_TYPE = 'PRIMARY KEY' AND TABLE_SCHEMA = '{s}' AND TABLE_NAME = '{t}') \
WHERE c.TABLE_SCHEMA = '{s}' AND c.TABLE_NAME = '{t}' \
ORDER BY c.ORDINAL_POSITION",
s = schema.replace('\'', "''"), t = table.replace('\'', "''")
);
let stream = client.query(&*sql, &[]).await.map_err(|e| e.to_string())?;
let rows = stream.into_first_result().await.map_err(|e| e.to_string())?;
Ok(rows.iter().map(|row| {
ColumnInfo {
name: row.get::<&str, _>(0).unwrap_or("").to_string(),
data_type: row.get::<&str, _>(1).unwrap_or("").to_string(),
is_nullable: row.get::<&str, _>(2).unwrap_or("NO") == "YES",
column_default: row.get::<&str, _>(3).map(|s| s.to_string()),
is_primary_key: row.get::<i32, _>(4).unwrap_or(0) == 1,
extra: None,
}
}).collect())
}
pub async fn list_indexes(client: &mut SqlServerClient, schema: &str, table: &str) -> Result<Vec<IndexInfo>, String> {
let sql = format!(
"SELECT i.name, STRING_AGG(c.name, ',') WITHIN GROUP (ORDER BY ic.key_ordinal) AS columns, \
i.is_unique, i.is_primary_key \
FROM sys.indexes i \
JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id \
JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id \
WHERE i.object_id = OBJECT_ID('{s}.{t}') AND i.name IS NOT NULL \
GROUP BY i.name, i.is_unique, i.is_primary_key \
ORDER BY i.name",
s = schema.replace('\'', "''"), t = table.replace('\'', "''")
);
let stream = client.query(&*sql, &[]).await.map_err(|e| e.to_string())?;
let rows = stream.into_first_result().await.map_err(|e| e.to_string())?;
Ok(rows.iter().map(|row| {
let cols_str = row.get::<&str, _>(1).unwrap_or("");
IndexInfo {
name: row.get::<&str, _>(0).unwrap_or("").to_string(),
columns: cols_str.split(',').map(|s| s.to_string()).collect(),
is_unique: row.get::<bool, _>(2).unwrap_or(false),
is_primary: row.get::<bool, _>(3).unwrap_or(false),
}
}).collect())
}
pub async fn list_foreign_keys(client: &mut SqlServerClient, schema: &str, table: &str) -> Result<Vec<ForeignKeyInfo>, String> {
let sql = format!(
"SELECT fk.name, c.name, rt.name, rc.name \
FROM sys.foreign_keys fk \
JOIN sys.foreign_key_columns fkc ON fk.object_id = fkc.constraint_object_id \
JOIN sys.columns c ON fkc.parent_object_id = c.object_id AND fkc.parent_column_id = c.column_id \
JOIN sys.tables rt ON fkc.referenced_object_id = rt.object_id \
JOIN sys.columns rc ON fkc.referenced_object_id = rc.object_id AND fkc.referenced_column_id = rc.column_id \
WHERE fk.parent_object_id = OBJECT_ID('{s}.{t}') \
ORDER BY fk.name",
s = schema.replace('\'', "''"), t = table.replace('\'', "''")
);
let stream = client.query(&*sql, &[]).await.map_err(|e| e.to_string())?;
let rows = stream.into_first_result().await.map_err(|e| e.to_string())?;
Ok(rows.iter().map(|row| {
ForeignKeyInfo {
name: row.get::<&str, _>(0).unwrap_or("").to_string(),
column: row.get::<&str, _>(1).unwrap_or("").to_string(),
ref_table: row.get::<&str, _>(2).unwrap_or("").to_string(),
ref_column: row.get::<&str, _>(3).unwrap_or("").to_string(),
}
}).collect())
}
pub async fn list_triggers(client: &mut SqlServerClient, schema: &str, table: &str) -> Result<Vec<TriggerInfo>, String> {
let sql = format!(
"SELECT t.name, te.type_desc, CASE WHEN t.is_instead_of_trigger = 1 THEN 'INSTEAD OF' ELSE 'AFTER' END \
FROM sys.triggers t \
JOIN sys.trigger_events te ON t.object_id = te.object_id \
WHERE t.parent_id = OBJECT_ID('{s}.{t}') \
ORDER BY t.name",
s = schema.replace('\'', "''"), t = table.replace('\'', "''")
);
let stream = client.query(&*sql, &[]).await.map_err(|e| e.to_string())?;
let rows = stream.into_first_result().await.map_err(|e| e.to_string())?;
Ok(rows.iter().map(|row| {
TriggerInfo {
name: row.get::<&str, _>(0).unwrap_or("").to_string(),
event: row.get::<&str, _>(1).unwrap_or("").to_string(),
timing: row.get::<&str, _>(2).unwrap_or("AFTER").to_string(),
}
}).collect())
}
pub async fn execute_query(client: &mut SqlServerClient, sql: &str) -> Result<QueryResult, String> {
let start = Instant::now();
let trimmed = sql.trim().to_uppercase();
if trimmed.starts_with("SELECT")
|| trimmed.starts_with("EXEC")
|| trimmed.starts_with("WITH")
|| trimmed.starts_with("TABLE")
{
let mut stream = client.query(sql, &[]).await.map_err(|e| e.to_string())?;
let columns_meta = stream.columns().await.map_err(|e| e.to_string())?
.map(|cols| cols.iter().map(|c| c.name().to_string()).collect::<Vec<_>>())
.unwrap_or_default();
let rows = stream.into_first_result().await.map_err(|e| e.to_string())?;
let result_rows: Vec<Vec<serde_json::Value>> = rows.iter().map(|row| row_to_json(row)).collect();
Ok(QueryResult {
columns: columns_meta,
rows: result_rows,
affected_rows: 0,
execution_time_ms: start.elapsed().as_millis(),
truncated: false,
})
} else {
let result = client.execute(sql, &[]).await.map_err(|e| e.to_string())?;
Ok(QueryResult {
columns: vec![],
rows: vec![],
affected_rows: result.rows_affected().iter().sum::<u64>(),
execution_time_ms: start.elapsed().as_millis(),
truncated: false,
})
}
}
+68
View File
@@ -0,0 +1,68 @@
use std::collections::HashMap;
use tokio::process::{Child, Command};
use tokio::sync::Mutex;
pub struct TunnelManager {
tunnels: Mutex<HashMap<String, (Child, u16)>>,
}
impl TunnelManager {
pub fn new() -> Self {
Self {
tunnels: Mutex::new(HashMap::new()),
}
}
pub async fn start_tunnel(
&self,
connection_id: &str,
ssh_host: &str,
ssh_port: u16,
ssh_user: &str,
ssh_key_path: &str,
remote_host: &str,
remote_port: u16,
) -> Result<u16, String> {
let local_port = portpicker::pick_unused_port().ok_or("No available port")?;
let mut args = vec![
"-N".to_string(),
"-o".to_string(), "StrictHostKeyChecking=no".to_string(),
"-o".to_string(), "ServerAliveInterval=60".to_string(),
"-L".to_string(), format!("{local_port}:{remote_host}:{remote_port}"),
"-p".to_string(), ssh_port.to_string(),
];
if !ssh_key_path.is_empty() {
args.push("-i".to_string());
args.push(ssh_key_path.to_string());
}
args.push(format!("{ssh_user}@{ssh_host}"));
let child = Command::new("ssh")
.args(&args)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true)
.spawn()
.map_err(|e| format!("Failed to start SSH tunnel: {e}"))?;
tokio::time::sleep(tokio::time::Duration::from_millis(1500)).await;
self.tunnels
.lock()
.await
.insert(connection_id.to_string(), (child, local_port));
Ok(local_port)
}
pub async fn stop_tunnel(&self, connection_id: &str) {
if let Some((mut child, _)) = self.tunnels.lock().await.remove(connection_id) {
let _ = child.kill().await;
}
}
}
+71
View File
@@ -0,0 +1,71 @@
mod commands;
mod db;
mod models;
use commands::connection::AppState;
use std::sync::Arc;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let state = Arc::new(AppState::new());
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.manage(state)
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
}
Ok(())
})
.on_window_event(|window, event| {
#[cfg(target_os = "macos")]
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
window.hide().unwrap();
api.prevent_close();
}
})
.invoke_handler(tauri::generate_handler![
commands::connection::test_connection,
commands::connection::connect_db,
commands::connection::disconnect_db,
commands::connection::save_connections,
commands::connection::load_connections,
commands::schema::list_databases,
commands::schema::list_tables,
commands::schema::list_schemas,
commands::schema::get_columns,
commands::schema::list_indexes,
commands::schema::list_foreign_keys,
commands::schema::list_triggers,
commands::query::execute_query,
commands::redis_cmd::redis_list_databases,
commands::redis_cmd::redis_scan_keys,
commands::redis_cmd::redis_get_value,
commands::redis_cmd::redis_set_string,
commands::redis_cmd::redis_delete_key,
commands::redis_cmd::redis_hash_set,
commands::redis_cmd::redis_hash_del,
commands::redis_cmd::redis_list_push,
commands::redis_cmd::redis_list_remove,
commands::redis_cmd::redis_set_add,
commands::redis_cmd::redis_set_remove,
commands::mongo_cmd::mongo_list_databases,
commands::mongo_cmd::mongo_list_collections,
commands::mongo_cmd::mongo_find_documents,
commands::mongo_cmd::mongo_insert_document,
commands::mongo_cmd::mongo_update_document,
commands::mongo_cmd::mongo_delete_document,
commands::history::save_history,
commands::history::load_history,
commands::history::clear_history,
commands::history::delete_history_entry,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+6
View File
@@ -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() {
dbx_lib::run();
}
+87
View File
@@ -0,0 +1,87 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectionConfig {
pub id: String,
pub name: String,
pub db_type: DatabaseType,
pub host: String,
pub port: u16,
pub username: String,
pub password: String,
pub database: Option<String>,
#[serde(default)]
pub ssh_enabled: bool,
#[serde(default)]
pub ssh_host: String,
#[serde(default = "default_ssh_port")]
pub ssh_port: u16,
#[serde(default)]
pub ssh_user: String,
#[serde(default)]
pub ssh_key_path: String,
}
fn default_ssh_port() -> u16 { 22 }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum DatabaseType {
Mysql,
Postgres,
Sqlite,
Redis,
#[serde(rename = "duckdb")]
DuckDb,
#[serde(rename = "clickhouse")]
ClickHouse,
#[serde(rename = "sqlserver")]
SqlServer,
#[serde(rename = "mongodb")]
MongoDb,
}
impl ConnectionConfig {
pub fn connection_url(&self) -> String {
self.connection_url_with_host(&self.host, self.port)
}
pub fn connection_url_with_host(&self, host: &str, port: u16) -> String {
let db_part = self.database.as_deref().map(|d| format!("/{d}")).unwrap_or_default();
match self.db_type {
DatabaseType::Sqlite | DatabaseType::DuckDb => {
format!("{}?mode=rwc", self.host)
}
DatabaseType::Redis => {
if self.password.is_empty() {
format!("redis://{host}:{port}/")
} else {
format!("redis://:{}@{host}:{port}/", self.password)
}
}
DatabaseType::Mysql => format!(
"mysql://{}:{}@{host}:{port}{db_part}",
self.username, self.password
),
DatabaseType::Postgres => format!(
"postgres://{}:{}@{host}:{port}{db_part}",
self.username, self.password
),
DatabaseType::ClickHouse => format!(
"http://{host}:{port}{db_part}"
),
DatabaseType::SqlServer => format!(
"server=tcp:{host},{port};user={};password={};database={}",
self.username, self.password, self.database.as_deref().unwrap_or("master")
),
DatabaseType::MongoDb => {
if self.username.is_empty() {
format!("mongodb://{host}:{port}{db_part}")
} else {
format!("mongodb://{}:{}@{host}:{port}{db_part}", self.username, self.password)
}
}
}
}
}
+1
View File
@@ -0,0 +1 @@
pub mod connection;