diff --git a/apps/tauri/src-tauri/Cargo.lock b/apps/tauri/src-tauri/Cargo.lock index fba2763f..06e4b9c8 100644 --- a/apps/tauri/src-tauri/Cargo.lock +++ b/apps/tauri/src-tauri/Cargo.lock @@ -74,6 +74,15 @@ dependencies = [ "object", ] +[[package]] +name = "arc-swap" +version = "1.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84a22b5d51209f6de83d37a0b33d320acb8da88823e42a94c075140708f032a5" +dependencies = [ + "rustversion", +] + [[package]] name = "arrayvec" version = "0.7.8" @@ -578,7 +587,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" dependencies = [ "bytes", + "futures-core", "memchr", + "pin-project-lite", + "tokio", + "tokio-util", ] [[package]] @@ -1854,7 +1867,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.5", "tokio", "tower-service", "tracing", @@ -2070,6 +2083,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.19" @@ -2548,6 +2570,16 @@ dependencies = [ "memoffset", ] +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -3253,6 +3285,7 @@ dependencies = [ "ipnetwork", "keyring", "mac_address", + "redis", "rusqlite", "rust_decimal", "serde", @@ -3382,6 +3415,30 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "redis" +version = "0.27.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09d8f99a4090c89cc489a94833c901ead69bfbf3877b4867d5482e321ee875bc" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "combine", + "futures-util", + "itertools", + "itoa", + "num-bigint", + "percent-encoding", + "pin-project-lite", + "ryu", + "sha1_smol", + "socket2 0.5.10", + "tokio", + "tokio-util", + "url", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -3960,6 +4017,12 @@ dependencies = [ "digest", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -4024,6 +4087,16 @@ dependencies = [ "serde", ] +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.5" @@ -4957,7 +5030,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", - "socket2", + "socket2 0.6.5", "tokio-macros", "windows-sys 0.61.2", ] @@ -4993,6 +5066,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -5751,6 +5825,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" @@ -5799,6 +5882,22 @@ dependencies = [ "windows_x86_64_msvc 0.48.5", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + [[package]] name = "windows-targets" version = "0.53.5" @@ -5809,7 +5908,7 @@ dependencies = [ "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.53.1", "windows_i686_msvc 0.53.1", "windows_x86_64_gnu 0.53.1", "windows_x86_64_gnullvm 0.53.1", @@ -5846,6 +5945,12 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" @@ -5864,6 +5969,12 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_aarch64_msvc" version = "0.53.1" @@ -5882,12 +5993,24 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + [[package]] name = "windows_i686_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_gnullvm" version = "0.53.1" @@ -5906,6 +6029,12 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_i686_msvc" version = "0.53.1" @@ -5924,6 +6053,12 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnu" version = "0.53.1" @@ -5942,6 +6077,12 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_gnullvm" version = "0.53.1" @@ -5960,6 +6101,12 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "windows_x86_64_msvc" version = "0.53.1" diff --git a/apps/tauri/src-tauri/Cargo.toml b/apps/tauri/src-tauri/Cargo.toml index 899f8b1a..2291d69c 100644 --- a/apps/tauri/src-tauri/Cargo.toml +++ b/apps/tauri/src-tauri/Cargo.toml @@ -43,6 +43,7 @@ rust_decimal = { version = "1", features = ["serde"] } ipnetwork = "0.20" mac_address = "1.1" bit-vec = "0.6.3" +redis = { version = "0.27", features = ["aio", "tokio-comp"] } [profile.release] codegen-units = 1 diff --git a/apps/tauri/src-tauri/src/commands.rs b/apps/tauri/src-tauri/src/commands.rs index 6cfce4bb..94a61975 100644 --- a/apps/tauri/src-tauri/src/commands.rs +++ b/apps/tauri/src-tauri/src/commands.rs @@ -1,7 +1,7 @@ use crate::connection::{ ConnectionConfig, ConnectionError, ConnectionId, ConnectionManager, ConnectionRegistry, - ErdSchema, PostgresObjectDetail, SchemaObject, SchemaObjectKind, SqlObjectKind, SqlObjectNode, - SqliteOverview, TableSchema, + ErdSchema, PostgresObjectDetail, RedisScanPage, SchemaObject, SchemaObjectKind, SqlObjectKind, + SqlObjectNode, SqliteOverview, TableSchema, }; use crate::profiles::{ConnectionProfile, ProfileStore, ProfileStoreError}; use crate::secrets::SharedSecretStore; @@ -76,6 +76,70 @@ pub fn db_connect_mysql( }) } +#[tauri::command] +pub fn db_connect_redis( + host: String, + port: u16, + password: Option, + username: Option, + db: Option, + id: Option, + registry: State<'_, ConnectionRegistry>, +) -> Result { + registry.connect(ConnectionConfig::Redis { + host, + port, + password, + username, + db, + id, + }) +} + +#[tauri::command] +pub fn redis_ping( + id: ConnectionId, + registry: State<'_, ConnectionRegistry>, +) -> Result { + registry.with_redis(&id, |s| s.ping()) +} + +#[tauri::command] +pub fn redis_scan( + id: ConnectionId, + cursor: Option, + pattern: Option, + count: Option, + registry: State<'_, ConnectionRegistry>, +) -> Result { + registry.with_redis(&id, |s| { + s.scan( + cursor.unwrap_or(0), + pattern.as_deref(), + count.map(|n| n as usize), + ) + }) +} + +#[tauri::command] +pub fn redis_get( + id: ConnectionId, + key: String, + registry: State<'_, ConnectionRegistry>, +) -> Result, ConnectionError> { + registry.with_redis(&id, |s| s.get(&key)) +} + +#[tauri::command] +pub fn redis_set( + id: ConnectionId, + key: String, + value: String, + registry: State<'_, ConnectionRegistry>, +) -> Result<(), ConnectionError> { + registry.with_redis(&id, |s| s.set(&key, &value)) +} + #[tauri::command] pub fn profiles_list( store: State<'_, ProfileStore>, diff --git a/apps/tauri/src-tauri/src/connection/id.rs b/apps/tauri/src-tauri/src/connection/id.rs index 1679ce1e..94804e04 100644 --- a/apps/tauri/src-tauri/src/connection/id.rs +++ b/apps/tauri/src-tauri/src/connection/id.rs @@ -41,13 +41,14 @@ impl From<&str> for ConnectionId { } } -/// Driver family wired (or stubbed) in this crate. Redis/Mongo land with their epics. +/// Driver family wired (or stubbed) in this crate. Mongo lands with its epic. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum DriverKind { Sqlite, Postgres, Mysql, + Redis, } impl DriverKind { @@ -56,6 +57,7 @@ impl DriverKind { Self::Sqlite => "sqlite", Self::Postgres => "postgres", Self::Mysql => "mysql", + Self::Redis => "redis", } } } diff --git a/apps/tauri/src-tauri/src/connection/manager.rs b/apps/tauri/src-tauri/src/connection/manager.rs index d80a7b64..5a8d64c8 100644 --- a/apps/tauri/src-tauri/src/connection/manager.rs +++ b/apps/tauri/src-tauri/src/connection/manager.rs @@ -3,6 +3,7 @@ use super::id::{ConnectionId, DriverKind}; use super::mysql::MysqlSession; use super::postgres::PostgresSession; use super::query::QueryEvent; +use super::redis::RedisSession; use super::sqlite::SqliteSession; use serde::{Deserialize, Serialize}; use std::any::Any; @@ -42,6 +43,18 @@ pub enum ConnectionConfig { #[serde(default)] id: Option, }, + Redis { + host: String, + port: u16, + #[serde(default)] + password: Option, + #[serde(default)] + username: Option, + #[serde(default)] + db: Option, + #[serde(default)] + id: Option, + }, } impl ConnectionConfig { @@ -50,6 +63,7 @@ impl ConnectionConfig { Self::Sqlite { .. } => DriverKind::Sqlite, Self::Postgres { .. } => DriverKind::Postgres, Self::Mysql { .. } => DriverKind::Mysql, + Self::Redis { .. } => DriverKind::Redis, } } } @@ -91,7 +105,7 @@ impl ConnectionRegistry { Self::with_builtin_drivers() } - /// Registry with SQLite + Postgres + MySQL openers. + /// Registry with SQLite + Postgres + MySQL + Redis openers. pub fn with_builtin_drivers() -> Self { Self::with_opener(|config| match config { ConnectionConfig::Sqlite { path, id } => { @@ -139,6 +153,24 @@ impl ConnectionRegistry { })?; Ok(Arc::new(session) as SessionArc) } + ConnectionConfig::Redis { + host, + port, + password, + username, + db, + id, + } => { + let session = super::redis::open_redis(super::redis::RedisConnectParams { + host, + port, + password, + username, + db, + id: id.filter(|s| !s.trim().is_empty()).map(ConnectionId::from), + })?; + Ok(Arc::new(session) as SessionArc) + } }) } @@ -225,6 +257,21 @@ impl ConnectionRegistry { f(mysql) } + pub fn with_redis( + &self, + id: &ConnectionId, + f: impl FnOnce(&RedisSession) -> ConnectionResult, + ) -> ConnectionResult { + let session = self.session_arc(id)?; + let redis = session + .as_any() + .downcast_ref::() + .ok_or(ConnectionError::UnsupportedDriver { + driver: session.driver(), + })?; + f(redis) + } + /// Driver-aware query stream (SQLite + Postgres + MySQL). /// /// `confirmed` must be true for DROP / TRUNCATE / unconditional DELETE|UPDATE @@ -241,6 +288,9 @@ impl ConnectionRegistry { let driver = self .driver_of(id) .ok_or_else(|| ConnectionError::NotFound { id: id.clone() })?; + if matches!(driver, DriverKind::Redis) { + return Err(ConnectionError::UnsupportedDriver { driver }); + } super::sql_guard::assert_query_allowed(sql, driver, confirmed)?; match driver { DriverKind::Sqlite => self.execute_sqlite_query(id, sql, limit, emit), @@ -248,6 +298,7 @@ impl ConnectionRegistry { self.with_postgres(id, |s| s.execute_streaming(sql, limit, emit)) } DriverKind::Mysql => self.with_mysql(id, |s| s.execute_streaming(sql, limit, emit)), + DriverKind::Redis => Err(ConnectionError::UnsupportedDriver { driver }), } } @@ -302,6 +353,9 @@ impl ConnectionRegistry { mysql.clone_handle(), ))) } + DriverKind::Redis => Err(ConnectionError::UnsupportedDriver { + driver: DriverKind::Redis, + }), } } @@ -343,7 +397,12 @@ impl ConnectionRegistry { super::postgres_erd::load_schema(pool, schema, focus, depth) }) }), - other => Err(ConnectionError::UnsupportedDriver { driver: other }), + DriverKind::Mysql => Err(ConnectionError::UnsupportedDriver { + driver: DriverKind::Mysql, + }), + DriverKind::Redis => Err(ConnectionError::UnsupportedDriver { + driver: DriverKind::Redis, + }), } } } diff --git a/apps/tauri/src-tauri/src/connection/mod.rs b/apps/tauri/src-tauri/src/connection/mod.rs index fe3224e2..9e187ad3 100644 --- a/apps/tauri/src-tauri/src/connection/mod.rs +++ b/apps/tauri/src-tauri/src/connection/mod.rs @@ -15,6 +15,7 @@ mod postgres_object_detail; mod postgres_object_tree; pub(crate) mod postgres_query; mod query; +pub(crate) mod redis; pub(crate) mod runtime; mod smoke_slice; mod sql_guard; @@ -32,4 +33,5 @@ pub use manager::{ConnectionConfig, ConnectionManager, ConnectionRegistry}; pub use object_tree::{ObjectTreeProvider, SqlObjectKind, SqlObjectNode}; pub use postgres_object_detail::PostgresObjectDetail; pub use query::QueryEvent; +pub use redis::RedisScanPage; pub use sqlite_introspect::SqliteOverview; diff --git a/apps/tauri/src-tauri/src/connection/redis.rs b/apps/tauri/src-tauri/src/connection/redis.rs new file mode 100644 index 00000000..3dcc5483 --- /dev/null +++ b/apps/tauri/src-tauri/src/connection/redis.rs @@ -0,0 +1,312 @@ +//! Redis driver via the official `redis` crate (connect + SCAN / GET / SET). + +use super::error::{ConnectionError, ConnectionResult}; +use super::id::{ConnectionId, DriverKind}; +use super::manager::ConnectionSession; +use redis::{Client, Commands, Connection}; +use serde::{Deserialize, Serialize}; +use std::sync::{Arc, Mutex}; + +pub struct RedisSession { + id: ConnectionId, + host: String, + port: u16, + db: u8, + conn: Arc>>, +} + +impl RedisSession { + pub fn host(&self) -> &str { + &self.host + } + + pub fn port(&self) -> u16 { + self.port + } + + pub fn db(&self) -> u8 { + self.db + } + + pub fn clone_handle(&self) -> Self { + Self { + id: self.id.clone(), + host: self.host.clone(), + port: self.port, + db: self.db, + conn: Arc::clone(&self.conn), + } + } + + pub fn with_connection( + &self, + f: impl FnOnce(&mut Connection) -> ConnectionResult, + ) -> ConnectionResult { + let mut guard = self + .conn + .lock() + .map_err(|_| ConnectionError::io("redis connection lock poisoned"))?; + let conn = guard + .as_mut() + .ok_or_else(|| ConnectionError::Disconnected { + id: self.id.clone(), + })?; + f(conn) + } + + pub fn ping(&self) -> ConnectionResult { + self.with_connection(|c| { + redis::cmd("PING") + .query::(c) + .map_err(|e| ConnectionError::driver(DriverKind::Redis, e.to_string())) + }) + } + + /// SCAN subset — returns next cursor + key page (Flutter `scan` parity). + pub fn scan( + &self, + cursor: u64, + pattern: Option<&str>, + count: Option, + ) -> ConnectionResult { + self.with_connection(|c| { + let mut cmd = redis::cmd("SCAN"); + cmd.arg(cursor); + if let Some(pat) = pattern { + if !pat.is_empty() { + cmd.arg("MATCH").arg(pat); + } + } + if let Some(n) = count { + if n > 0 { + cmd.arg("COUNT").arg(n); + } + } + let (next_cursor, keys): (u64, Vec) = cmd + .query(c) + .map_err(|e| ConnectionError::driver(DriverKind::Redis, e.to_string()))?; + Ok(RedisScanPage { next_cursor, keys }) + }) + } + + pub fn get(&self, key: &str) -> ConnectionResult> { + self.with_connection(|c| { + c.get(key) + .map_err(|e| ConnectionError::driver(DriverKind::Redis, e.to_string())) + }) + } + + pub fn set(&self, key: &str, value: &str) -> ConnectionResult<()> { + self.with_connection(|c| { + c.set::<_, _, ()>(key, value) + .map_err(|e| ConnectionError::driver(DriverKind::Redis, e.to_string())) + }) + } +} + +impl ConnectionSession for RedisSession { + fn id(&self) -> &ConnectionId { + &self.id + } + + fn driver(&self) -> DriverKind { + DriverKind::Redis + } + + fn disconnect(&self) -> ConnectionResult<()> { + let mut guard = self + .conn + .lock() + .map_err(|_| ConnectionError::io("redis connection lock poisoned"))?; + *guard = None; + Ok(()) + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} + +#[derive(Debug, Clone)] +pub struct RedisConnectParams { + pub host: String, + pub port: u16, + pub password: Option, + pub username: Option, + pub db: Option, + pub id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RedisScanPage { + pub next_cursor: u64, + pub keys: Vec, +} + +/// Open a Redis TCP session and smoke with PING. +pub fn open_redis(params: RedisConnectParams) -> ConnectionResult { + let host = params.host.trim(); + if host.is_empty() { + return Err(ConnectionError::invalid_config("redis host is empty")); + } + if params.port == 0 { + return Err(ConnectionError::invalid_config("redis port is 0")); + } + let db = params.db.unwrap_or(0); + + let mut url = if let Some(pw) = params.password.as_deref().filter(|s| !s.is_empty()) { + let user = params + .username + .as_deref() + .filter(|s| !s.is_empty()) + .unwrap_or("default"); + // redis://user:password@host:port/db + format!( + "redis://{}:{}@{}:{}/{}", + urlencoding_user(user), + urlencoding_user(pw), + host, + params.port, + db + ) + } else { + format!("redis://{}:{}/{}", host, params.port, db) + }; + + // Allow full redis:// URLs in host (advanced paste). + if host.contains("://") { + url = host.to_owned(); + } + + let client = Client::open(url.as_str()) + .map_err(|e| ConnectionError::invalid_config(format!("invalid redis url: {e}")))?; + + let mut conn = client + .get_connection() + .map_err(|e| ConnectionError::driver(DriverKind::Redis, e.to_string()))?; + + let pong: String = redis::cmd("PING") + .query(&mut conn) + .map_err(|e| ConnectionError::driver(DriverKind::Redis, e.to_string()))?; + if !pong.eq_ignore_ascii_case("PONG") { + return Err(ConnectionError::driver( + DriverKind::Redis, + format!("unexpected PING reply: {pong}"), + )); + } + + let id = params + .id + .filter(|i| !i.as_str().trim().is_empty()) + .unwrap_or_default(); + + Ok(RedisSession { + id, + host: host.to_owned(), + port: params.port, + db, + conn: Arc::new(Mutex::new(Some(conn))), + }) +} + +fn urlencoding_user(s: &str) -> String { + // Minimal encode for password special chars in redis URL. + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char); + } + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn skip_live() -> bool { + if std::env::var_os("QUERYA_SKIP_REDIS").is_some() { + eprintln!("skip redis live test (QUERYA_SKIP_REDIS)"); + return true; + } + false + } + + fn live_params() -> RedisConnectParams { + let url = std::env::var("QUERYA_REDIS_URL").unwrap_or_else(|_| { + "redis://127.0.0.1:6379/15".into() + }); + // Parse host/port/db loosely from URL for open_redis host-url path. + RedisConnectParams { + host: url, + port: 6379, + password: None, + username: None, + db: Some(15), + id: None, + } + } + + #[test] + fn open_rejects_empty_host() { + let result = open_redis(RedisConnectParams { + host: " ".into(), + port: 6379, + password: None, + username: None, + db: None, + id: None, + }); + assert!(matches!(result, Err(ConnectionError::InvalidConfig { .. }))); + } + + #[test] + fn live_ping_set_get_scan() { + if skip_live() { + return; + } + let session = match open_redis(live_params()) { + Ok(s) => s, + Err(e) => { + eprintln!("skip redis live test (connect failed: {e})"); + return; + } + }; + assert_eq!(session.ping().unwrap().to_uppercase(), "PONG"); + + let key = format!("querya:test:{}", uuid::Uuid::new_v4()); + session.set(&key, "hello").unwrap(); + assert_eq!(session.get(&key).unwrap().as_deref(), Some("hello")); + + let mut cursor = 0u64; + let mut found = false; + for _ in 0..32 { + let page = session + .scan(cursor, Some("querya:test:*"), Some(50)) + .unwrap(); + if page.keys.iter().any(|k| k == &key) { + found = true; + break; + } + if page.next_cursor == 0 { + break; + } + cursor = page.next_cursor; + } + assert!(found, "SCAN did not return {key}"); + + // Cleanup + let _ = session.with_connection(|c| { + let _: () = redis::cmd("DEL") + .arg(&key) + .query(c) + .map_err(|e| ConnectionError::driver(DriverKind::Redis, e.to_string()))?; + Ok(()) + }); + session.disconnect().unwrap(); + } +} diff --git a/apps/tauri/src-tauri/src/connection/sql_guard.rs b/apps/tauri/src-tauri/src/connection/sql_guard.rs index 9ff3a3dc..6a45b02e 100644 --- a/apps/tauri/src-tauri/src/connection/sql_guard.rs +++ b/apps/tauri/src-tauri/src/connection/sql_guard.rs @@ -117,6 +117,11 @@ fn parse_statements(sql: &str, driver: DriverKind) -> ConnectionResult Parser::parse_sql(&PostgreSqlDialect {}, sql), DriverKind::Sqlite => Parser::parse_sql(&SQLiteDialect {}, sql), DriverKind::Mysql => Parser::parse_sql(&MySqlDialect {}, sql), + DriverKind::Redis => { + return Err(ConnectionError::UnsupportedDriver { + driver: DriverKind::Redis, + }); + } }; match primary { diff --git a/apps/tauri/src-tauri/src/lib.rs b/apps/tauri/src-tauri/src/lib.rs index c707ebc9..2becbd70 100644 --- a/apps/tauri/src-tauri/src/lib.rs +++ b/apps/tauri/src-tauri/src/lib.rs @@ -7,8 +7,8 @@ mod staging; pub use connection::{ ColumnMeta, ConnectionConfig, ConnectionError, ConnectionId, ConnectionManager, ConnectionRegistry, DriverKind, ErdColumn, ErdRelation, ErdSchema, ErdTable, - ObjectTreeProvider, PostgresObjectDetail, QueryEvent, SchemaObject, SchemaObjectKind, - SqlObjectKind, SqlObjectNode, SqliteOverview, TableSchema, + ObjectTreeProvider, PostgresObjectDetail, QueryEvent, RedisScanPage, SchemaObject, + SchemaObjectKind, SqlObjectKind, SqlObjectNode, SqliteOverview, TableSchema, }; pub use profiles::{ConnectionProfile, ProfileStore, ProfileStoreError}; pub use secrets::{KeyringSecretStore, MemorySecretStore, SecretError, SecretStore, SharedSecretStore}; @@ -20,11 +20,11 @@ pub use staging::{ }; use commands::{ - db_apply_staging, db_connect_mysql, db_connect_postgres, db_connect_sqlite, db_describe_object, - db_disconnect, db_erd_schema, db_execute_query, db_is_connected, db_list_children, - db_list_columns, db_list_schemas, db_list_tables, db_postgres_object_detail, db_sqlite_overview, - profiles_connect_mysql, profiles_connect_postgres, profiles_connect_sqlite, profiles_delete, - profiles_list, profiles_upsert, + db_apply_staging, db_connect_mysql, db_connect_postgres, db_connect_redis, db_connect_sqlite, + db_describe_object, db_disconnect, db_erd_schema, db_execute_query, db_is_connected, + db_list_children, db_list_columns, db_list_schemas, db_list_tables, db_postgres_object_detail, + db_sqlite_overview, profiles_connect_mysql, profiles_connect_postgres, profiles_connect_sqlite, + profiles_delete, profiles_list, profiles_upsert, redis_get, redis_ping, redis_scan, redis_set, }; #[tauri::command] @@ -95,6 +95,7 @@ pub fn run() { db_connect_sqlite, db_connect_postgres, db_connect_mysql, + db_connect_redis, db_disconnect, db_is_connected, db_list_schemas, @@ -107,6 +108,10 @@ pub fn run() { db_erd_schema, db_execute_query, db_apply_staging, + redis_ping, + redis_scan, + redis_get, + redis_set, profiles_list, profiles_upsert, profiles_delete, diff --git a/apps/tauri/src-tauri/src/staging/ipc.rs b/apps/tauri/src-tauri/src/staging/ipc.rs index a5edb6a5..8f947635 100644 --- a/apps/tauri/src-tauri/src/staging/ipc.rs +++ b/apps/tauri/src-tauri/src/staging/ipc.rs @@ -88,6 +88,9 @@ pub fn apply_staging_request( .map_err(map_apply_error) }) }), + DriverKind::Redis => Err(ConnectionError::UnsupportedDriver { + driver: DriverKind::Redis, + }), }?; Ok(result)