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..71c1221f 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, RedisKeyMeta, RedisKeyView, RedisScanPage, SchemaObject, + SchemaObjectKind, SqlObjectKind, SqlObjectNode, SqliteOverview, TableSchema, }; use crate::profiles::{ConnectionProfile, ProfileStore, ProfileStoreError}; use crate::secrets::SharedSecretStore; @@ -27,6 +27,20 @@ pub struct ConnectPostgresRequest { /// Same shape as Postgres — separate type so Tauri command names stay clear. pub type ConnectMysqlRequest = ConnectPostgresRequest; +/// Redis save+connect args (`database` is the logical DB index as a string). +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectRedisRequest { + pub name: String, + pub host: String, + pub port: u16, + pub database: Option, + pub username: Option, + pub password: Option, + pub folder_id: Option, + pub id: Option, +} + #[tauri::command] pub fn db_connect_sqlite( path: String, @@ -76,6 +90,88 @@ 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 redis_key_metas( + id: ConnectionId, + keys: Vec, + registry: State<'_, ConnectionRegistry>, +) -> Result, ConnectionError> { + registry.with_redis(&id, |s| s.types_and_ttls(&keys)) +} + +#[tauri::command] +pub fn redis_read_key( + id: ConnectionId, + key: String, + registry: State<'_, ConnectionRegistry>, +) -> Result { + registry.with_redis(&id, |s| s.read_key(&key)) +} + #[tauri::command] pub fn profiles_list( store: State<'_, ProfileStore>, @@ -362,6 +458,110 @@ pub fn profiles_connect_mysql( Ok(profile) } +/// Save Redis profile metadata (no password) and open a live session. +#[tauri::command] +pub fn profiles_connect_redis( + request: ConnectRedisRequest, + store: State<'_, ProfileStore>, + secrets: State<'_, SharedSecretStore>, + registry: State<'_, ConnectionRegistry>, +) -> Result { + let ConnectRedisRequest { + name, + host, + port, + database, + username, + password, + folder_id, + id, + } = request; + + let db: u8 = database + .as_deref() + .unwrap_or("0") + .trim() + .parse() + .unwrap_or(0); + let user = username + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned); + + let display_name = { + let t = name.trim(); + if t.is_empty() { + format!("{host}:{port}/{db}") + } else { + t.to_owned() + } + }; + + let mut profile = match id.filter(|s| !s.trim().is_empty()) { + Some(existing_id) => store + .get(&existing_id) + .map_err(|e| ConnectionError::io(e.to_string()))? + .map(|mut p| { + p.name = display_name.clone(); + p.host = Some(host.clone()); + p.port = Some(i64::from(port)); + p.database_name = Some(db.to_string()); + p.username = user.clone(); + p.folder_id = folder_id.clone(); + p.driver = "redis".into(); + p.path = None; + p + }) + .unwrap_or_else(|| { + let mut p = ConnectionProfile::new_redis( + display_name.clone(), + host.clone(), + port, + db, + user.clone(), + ); + p.id = existing_id; + p.folder_id = folder_id.clone(); + p + }), + None => { + let mut p = + ConnectionProfile::new_redis(display_name, host.clone(), port, db, user.clone()); + p.folder_id = folder_id; + p + } + }; + profile.touch(); + store + .upsert(&profile) + .map_err(|e| ConnectionError::io(e.to_string()))?; + + let password = match password { + Some(p) => { + secrets + .set_password(&profile.id, &p) + .map_err(|e| ConnectionError::io(format!("keyring write: {e}")))?; + Some(p) + } + None => secrets + .get_password(&profile.id) + .map_err(|e| ConnectionError::io(format!("keyring read: {e}")))?, + }; + + if !registry.is_connected(&ConnectionId::from(profile.id.as_str())) { + registry.connect(ConnectionConfig::Redis { + host, + port, + password, + username: user, + db: Some(db), + id: Some(profile.id.clone()), + })?; + } + Ok(profile) +} + #[tauri::command] pub fn db_disconnect( id: ConnectionId, 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..d0f8302c 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::{RedisKeyMeta, RedisKeyView, 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..be678469 --- /dev/null +++ b/apps/tauri/src-tauri/src/connection/redis.rs @@ -0,0 +1,535 @@ +//! 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())) + }) + } + + pub fn key_type(&self, key: &str) -> ConnectionResult { + self.with_connection(|c| { + redis::cmd("TYPE") + .arg(key) + .query::(c) + .map_err(|e| ConnectionError::driver(DriverKind::Redis, e.to_string())) + }) + } + + pub fn ttl(&self, key: &str) -> ConnectionResult { + self.with_connection(|c| { + redis::cmd("TTL") + .arg(key) + .query::(c) + .map_err(|e| ConnectionError::driver(DriverKind::Redis, e.to_string())) + }) + } + + /// Flutter `typesAndTtls` subset — TYPE (+ TTL) for a key page. + pub fn types_and_ttls(&self, keys: &[String]) -> ConnectionResult> { + self.with_connection(|c| { + let mut out = Vec::with_capacity(keys.len()); + for key in keys { + let key_type: String = redis::cmd("TYPE") + .arg(key) + .query(c) + .map_err(|e| ConnectionError::driver(DriverKind::Redis, e.to_string()))?; + let ttl: i64 = redis::cmd("TTL") + .arg(key) + .query(c) + .map_err(|e| ConnectionError::driver(DriverKind::Redis, e.to_string()))?; + out.push(RedisKeyMeta { + key: key.clone(), + key_type, + ttl, + }); + } + Ok(out) + }) + } + + /// Load key contents by TYPE (Flutter `RedisKeyEditor._load` parity). + pub fn read_key(&self, key: &str) -> ConnectionResult { + let key_type = self.key_type(key)?; + let ttl = self.ttl(key)?; + match key_type.as_str() { + "none" => Ok(RedisKeyView { + key: key.to_owned(), + key_type, + ttl, + string_value: None, + hash_fields: vec![], + list_values: vec![], + set_members: vec![], + zset_members: vec![], + }), + "string" => { + let string_value = self.get(key)?; + Ok(RedisKeyView { + key: key.to_owned(), + key_type, + ttl, + string_value, + hash_fields: vec![], + list_values: vec![], + set_members: vec![], + zset_members: vec![], + }) + } + "hash" => self.with_connection(|c| { + let map: std::collections::HashMap = redis::cmd("HGETALL") + .arg(key) + .query(c) + .map_err(|e| ConnectionError::driver(DriverKind::Redis, e.to_string()))?; + let mut hash_fields: Vec = map + .into_iter() + .map(|(field, value)| RedisHashField { field, value }) + .collect(); + hash_fields.sort_by(|a, b| a.field.cmp(&b.field)); + Ok(RedisKeyView { + key: key.to_owned(), + key_type, + ttl, + string_value: None, + hash_fields, + list_values: vec![], + set_members: vec![], + zset_members: vec![], + }) + }), + "list" => self.with_connection(|c| { + let list_values: Vec = redis::cmd("LRANGE") + .arg(key) + .arg(0) + .arg(199) + .query(c) + .map_err(|e| ConnectionError::driver(DriverKind::Redis, e.to_string()))?; + Ok(RedisKeyView { + key: key.to_owned(), + key_type, + ttl, + string_value: None, + hash_fields: vec![], + list_values, + set_members: vec![], + zset_members: vec![], + }) + }), + "set" => self.with_connection(|c| { + let mut set_members: Vec = redis::cmd("SMEMBERS") + .arg(key) + .query(c) + .map_err(|e| ConnectionError::driver(DriverKind::Redis, e.to_string()))?; + set_members.sort(); + Ok(RedisKeyView { + key: key.to_owned(), + key_type, + ttl, + string_value: None, + hash_fields: vec![], + list_values: vec![], + set_members, + zset_members: vec![], + }) + }), + "zset" => self.with_connection(|c| { + let raw: Vec<(String, f64)> = redis::cmd("ZRANGE") + .arg(key) + .arg(0) + .arg(199) + .arg("WITHSCORES") + .query(c) + .map_err(|e| ConnectionError::driver(DriverKind::Redis, e.to_string()))?; + let zset_members = raw + .into_iter() + .map(|(member, score)| RedisZsetMember { member, score }) + .collect(); + Ok(RedisKeyView { + key: key.to_owned(), + key_type, + ttl, + string_value: None, + hash_fields: vec![], + list_values: vec![], + set_members: vec![], + zset_members, + }) + }), + other => Ok(RedisKeyView { + key: key.to_owned(), + key_type: other.to_owned(), + ttl, + string_value: None, + hash_fields: vec![], + list_values: vec![], + set_members: vec![], + zset_members: vec![], + }), + } + } +} + +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, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RedisKeyMeta { + pub key: String, + pub key_type: String, + pub ttl: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RedisHashField { + pub field: String, + pub value: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RedisZsetMember { + pub member: String, + pub score: f64, +} + +/// Typed key payload for the browser (Flutter `RedisKeyEditor` load). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RedisKeyView { + pub key: String, + pub key_type: String, + pub ttl: i64, + pub string_value: Option, + pub hash_fields: Vec, + pub list_values: Vec, + pub set_members: Vec, + pub zset_members: 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(()) + }); + + let hash_key = format!("querya:test:hash:{}", uuid::Uuid::new_v4()); + session + .with_connection(|c| { + let _: () = redis::cmd("HSET") + .arg(&hash_key) + .arg("name") + .arg("Ada") + .arg("role") + .arg("admin") + .query(c) + .map_err(|e| ConnectionError::driver(DriverKind::Redis, e.to_string()))?; + Ok(()) + }) + .unwrap(); + let view = session.read_key(&hash_key).unwrap(); + assert_eq!(view.key_type, "hash"); + assert!(view.hash_fields.iter().any(|f| f.field == "name" && f.value == "Ada")); + let _ = session.with_connection(|c| { + let _: () = redis::cmd("DEL") + .arg(&hash_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..b67a28ee 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, RedisKeyMeta, RedisKeyView, RedisScanPage, + SchemaObject, SchemaObjectKind, SqlObjectKind, SqlObjectNode, SqliteOverview, TableSchema, }; pub use profiles::{ConnectionProfile, ProfileStore, ProfileStoreError}; pub use secrets::{KeyringSecretStore, MemorySecretStore, SecretError, SecretStore, SharedSecretStore}; @@ -20,11 +20,12 @@ 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_redis, + profiles_connect_sqlite, profiles_delete, profiles_list, profiles_upsert, redis_get, + redis_key_metas, redis_ping, redis_read_key, redis_scan, redis_set, }; #[tauri::command] @@ -95,6 +96,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,12 +109,19 @@ pub fn run() { db_erd_schema, db_execute_query, db_apply_staging, + redis_ping, + redis_scan, + redis_get, + redis_set, + redis_key_metas, + redis_read_key, profiles_list, profiles_upsert, profiles_delete, profiles_connect_sqlite, profiles_connect_postgres, profiles_connect_mysql, + profiles_connect_redis, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/apps/tauri/src-tauri/src/profiles/store.rs b/apps/tauri/src-tauri/src/profiles/store.rs index 0ba1ab1c..86f2bcee 100644 --- a/apps/tauri/src-tauri/src/profiles/store.rs +++ b/apps/tauri/src-tauri/src/profiles/store.rs @@ -13,7 +13,7 @@ const SCHEMA_VERSION: i32 = 1; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ConnectionProfile { pub id: String, - /// Driver family: `sqlite` | `postgres` | `mysql` (… later). + /// Driver family: `sqlite` | `postgres` | `mysql` | `redis` (… later). pub driver: String, pub name: String, /// SQLite file path (absolute). @@ -102,6 +102,31 @@ impl ConnectionProfile { } } + /// Redis profile — `database_name` stores the logical DB index as a string. + pub fn new_redis( + name: impl Into, + host: impl Into, + port: u16, + db: u8, + username: Option, + ) -> Self { + let now = now_rfc3339(); + Self { + id: Uuid::new_v4().to_string(), + driver: "redis".into(), + name: name.into(), + path: None, + host: Some(host.into()), + port: Some(i64::from(port)), + username, + database_name: Some(db.to_string()), + folder_id: None, + sort_order: 0, + created_at: now.clone(), + updated_at: now, + } + } + pub fn touch(&mut self) { self.updated_at = now_rfc3339(); } 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) diff --git a/apps/tauri/src/App.vue b/apps/tauri/src/App.vue index 072270f0..10bf36f0 100644 --- a/apps/tauri/src/App.vue +++ b/apps/tauri/src/App.vue @@ -29,6 +29,7 @@ import NewFolderDialog from "./ui/NewFolderDialog.vue"; import NewConnectionDialog, { type MysqlConnectPayload, type PostgresConnectPayload, + type RedisConnectPayload, type SqliteConnectPayload, } from "./ui/NewConnectionDialog.vue"; @@ -115,6 +116,21 @@ async function onConnectMysqlFromDialog(payload: MysqlConnectPayload) { } } +async function onConnectRedisFromDialog(payload: RedisConnectPayload) { + try { + await connections.connectRedis({ + name: payload.name, + host: payload.host, + port: payload.port, + database: payload.database, + username: payload.username || null, + password: payload.password || null, + }); + } catch { + // status already set in store + } +} + function onNewFolder() { newFolderOpen.value = true; } @@ -300,6 +316,7 @@ onUnmounted(() => { @connect-sqlite="onConnectSqliteFromDialog" @connect-postgres="onConnectPostgresFromDialog" @connect-mysql="onConnectMysqlFromDialog" + @connect-redis="onConnectRedisFromDialog" /> diff --git a/apps/tauri/src/api/redis.ts b/apps/tauri/src/api/redis.ts new file mode 100644 index 00000000..eefc077a --- /dev/null +++ b/apps/tauri/src/api/redis.ts @@ -0,0 +1,121 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { ConnectionProfile } from "./connection"; + +export type RedisScanPage = { + nextCursor: number; + keys: string[]; +}; + +export type RedisKeyMeta = { + key: string; + keyType: string; + ttl: number; +}; + +export type RedisHashField = { + field: string; + value: string; +}; + +export type RedisZsetMember = { + member: string; + score: number; +}; + +export type RedisKeyView = { + key: string; + keyType: string; + ttl: number; + stringValue: string | null; + hashFields: RedisHashField[]; + listValues: string[]; + setMembers: string[]; + zsetMembers: RedisZsetMember[]; +}; + +export async function dbConnectRedis(opts: { + host: string; + port: number; + password?: string | null; + username?: string | null; + db?: number | null; + id?: string | null; +}): Promise { + return invoke("db_connect_redis", { + host: opts.host, + port: opts.port, + password: opts.password ?? null, + username: opts.username ?? null, + db: opts.db ?? null, + id: opts.id ?? null, + }); +} + +export async function profilesConnectRedis(opts: { + name: string; + host: string; + port: number; + database?: string | null; + username?: string | null; + password?: string | null; + folderId?: string | null; + id?: string | null; +}): Promise { + return invoke("profiles_connect_redis", { + request: { + name: opts.name, + host: opts.host, + port: opts.port, + database: opts.database ?? null, + username: opts.username ?? null, + password: opts.password ?? null, + folderId: opts.folderId ?? null, + id: opts.id ?? null, + }, + }); +} + +export async function redisPing(id: string): Promise { + return invoke("redis_ping", { id }); +} + +export async function redisScan( + id: string, + opts?: { cursor?: number; pattern?: string | null; count?: number }, +): Promise { + return invoke("redis_scan", { + id, + cursor: opts?.cursor ?? 0, + pattern: opts?.pattern ?? null, + count: opts?.count ?? 100, + }); +} + +export async function redisGet( + id: string, + key: string, +): Promise { + return invoke("redis_get", { id, key }); +} + +export async function redisSet( + id: string, + key: string, + value: string, +): Promise { + await invoke("redis_set", { id, key, value }); +} + +export async function redisKeyMetas( + id: string, + keys: string[], +): Promise { + return invoke("redis_key_metas", { id, keys }); +} + +export async function redisReadKey( + id: string, + key: string, +): Promise { + return invoke("redis_read_key", { id, key }); +} diff --git a/apps/tauri/src/stores/connections.ts b/apps/tauri/src/stores/connections.ts index 1ccdf4df..4538dca3 100644 --- a/apps/tauri/src/stores/connections.ts +++ b/apps/tauri/src/stores/connections.ts @@ -11,6 +11,7 @@ import { profilesUpsert, type ConnectionProfile, } from "../api/connection"; +import { profilesConnectRedis } from "../api/redis"; import { DEMO_CONNECTION } from "../mock/demoCatalog"; import { useFoldersStore } from "./folders"; import { useShellNavStore } from "./shellNav"; @@ -18,7 +19,7 @@ import { useShellStatusStore } from "./shellStatus"; export type LiveConnection = { id: string; - driver: "sqlite" | "postgres" | "mysql"; + driver: "sqlite" | "postgres" | "mysql" | "redis"; name: string; /** SQLite file path. */ path?: string; @@ -47,8 +48,9 @@ function endpointLabel(conn: LiveConnection): string { if (conn.driver === "sqlite") return conn.path ?? ""; const host = conn.host ?? "localhost"; const port = - conn.port ?? (conn.driver === "mysql" ? 3306 : 5432); - const db = conn.database ?? ""; + conn.port ?? + (conn.driver === "mysql" ? 3306 : conn.driver === "redis" ? 6379 : 5432); + const db = conn.database ?? (conn.driver === "redis" ? "0" : ""); return `${host}:${port}/${db}`; } @@ -83,6 +85,21 @@ function fromProfile(p: ConnectionProfile, connected = false): LiveConnection | connected, }; } + if (p.driver === "redis") { + const host = p.host?.trim(); + if (!host) return null; + return { + id: p.id, + driver: "redis", + name: p.name, + host, + port: p.port != null ? Number(p.port) : 6379, + database: p.database_name?.trim() || "0", + user: p.username?.trim() || undefined, + folderId: p.folder_id ?? null, + connected, + }; + } return null; } @@ -103,6 +120,21 @@ function toProfile(conn: LiveConnection): ConnectionProfile { updated_at: now, }; } + if (conn.driver === "redis") { + return { + id: conn.id, + driver: "redis", + name: conn.name, + host: conn.host ?? null, + port: conn.port ?? 6379, + username: conn.user ?? null, + database_name: conn.database ?? "0", + folder_id: conn.folderId ?? null, + sort_order: 0, + created_at: now, + updated_at: now, + }; + } return { id: conn.id, driver: "sqlite", @@ -255,6 +287,63 @@ export const useConnectionsStore = defineStore("connections", () => { return connectServerDriver("mysql", opts); } + async function connectRedis(opts: { + name: string; + host: string; + port: number; + database?: string | null; + username?: string | null; + password?: string | null; + folderId?: string | null; + id?: string | null; + }) { + const status = useShellStatusStore(); + const nav = useShellNavStore(); + const name = + opts.name.trim() || `${opts.host}:${opts.port}/${opts.database ?? "0"}`; + status.setQueryMetrics({ + busy: true, + message: `Connecting Redis ${opts.host}:${opts.port}…`, + }); + try { + const profile = await profilesConnectRedis({ + name, + host: opts.host, + port: opts.port, + database: opts.database ?? "0", + username: opts.username ?? null, + password: opts.password ?? null, + folderId: opts.folderId ?? null, + id: opts.id ?? null, + }); + if (opts.password) sessionPasswords.set(profile.id, opts.password); + const conn: LiveConnection = { + id: profile.id, + driver: "redis", + name: profile.name, + host: profile.host ?? opts.host, + port: profile.port != null ? Number(profile.port) : opts.port, + database: profile.database_name ?? opts.database ?? "0", + user: profile.username ?? opts.username ?? undefined, + folderId: profile.folder_id ?? opts.folderId ?? null, + connected: true, + }; + upsert(conn); + nav.setExpanded(conn.id, true); + nav.selectConnection(conn.id); + status.setConnection(conn.name, endpointLabel(conn)); + status.setQueryMetrics({ + busy: false, + message: `Connected · ${conn.name}`, + }); + return conn; + } catch (err) { + const msg = formatConnectionError(err); + status.setQueryMetrics({ busy: false, message: msg }); + throw err; + } + } + async function connectServerDriver( driver: "postgres" | "mysql", opts: { @@ -341,6 +430,18 @@ export const useConnectionsStore = defineStore("connections", () => { } // Prefer in-memory cache; otherwise Rust loads from OS keyring (#1878). const password = sessionPasswords.get(id) ?? null; + if (conn.driver === "redis") { + return connectRedis({ + name: conn.name, + host: conn.host ?? "localhost", + port: conn.port ?? 6379, + database: conn.database ?? "0", + username: conn.user ?? null, + password, + folderId: conn.folderId, + id: conn.id, + }); + } const opts = { name: conn.name, host: conn.host ?? "localhost", @@ -472,6 +573,7 @@ export const useConnectionsStore = defineStore("connections", () => { connectSqlite, connectPostgres, connectMysql, + connectRedis, ensureConnected, disconnect, removeConnection, diff --git a/apps/tauri/src/stores/objectTree.ts b/apps/tauri/src/stores/objectTree.ts index 5a9e85a9..1ab34e63 100644 --- a/apps/tauri/src/stores/objectTree.ts +++ b/apps/tauri/src/stores/objectTree.ts @@ -109,11 +109,16 @@ export const useObjectTreeStore = defineStore("objectTree", () => { return Boolean(useConnectionsStore().byId.get(id)?.mock); } + function isRedis(id: string): boolean { + return useConnectionsStore().byId.get(id)?.driver === "redis"; + } + async function fetchChildren( connId: string, parentId: string | null, ): Promise { if (isMock(connId)) return demoListChildren(parentId); + if (isRedis(connId)) return []; return dbListChildren(connId, parentId); } @@ -139,6 +144,12 @@ export const useObjectTreeStore = defineStore("objectTree", () => { st.error = null; try { await useConnectionsStore().ensureConnected(connId); + // Redis uses RedisBrowserView — no SQL object tree. + if (isRedis(connId)) { + st.roots = []; + st.loaded = true; + return; + } st.roots = toUi(await fetchChildren(connId, null)); const [only] = st.roots; if (st.roots.length === 1 && only?.expandable) { diff --git a/apps/tauri/src/ui/ConnectionHome.vue b/apps/tauri/src/ui/ConnectionHome.vue index 31f92ffd..4bae6037 100644 --- a/apps/tauri/src/ui/ConnectionHome.vue +++ b/apps/tauri/src/ui/ConnectionHome.vue @@ -11,6 +11,7 @@ import { useShellNavStore } from "../stores/shellNav"; import { useObjectTreeStore } from "../stores/objectTree"; import QueryaButton from "./QueryaButton.vue"; import SqlWorkspace from "./SqlWorkspace.vue"; +import RedisBrowserView from "./RedisBrowserView.vue"; import { QueryaIcon } from "./icons"; import ErdView from "./erd/ErdView.vue"; @@ -99,6 +100,7 @@ async function loadOverview() { } const isSqlite = computed(() => conn.value?.driver === "sqlite"); +const isRedis = computed(() => conn.value?.driver === "redis"); const isServerSql = computed( () => conn.value?.driver === "postgres" || conn.value?.driver === "mysql", @@ -108,14 +110,17 @@ const driverLabel = computed(() => { if (d === "sqlite") return "SQLite"; if (d === "postgres") return "PostgreSQL"; if (d === "mysql") return "MySQL"; + if (d === "redis") return "Redis"; return d ?? ""; }); const endpointLabel = computed(() => { const c = conn.value; if (!c) return ""; if (c.driver === "sqlite") return c.path ?? ""; - const port = c.port ?? (c.driver === "mysql" ? 3306 : 5432); - return `${c.host ?? "localhost"}:${port}/${c.database ?? ""}`; + const port = + c.port ?? + (c.driver === "mysql" ? 3306 : c.driver === "redis" ? 6379 : 5432); + return `${c.host ?? "localhost"}:${port}/${c.database ?? (c.driver === "redis" ? "0" : "")}`; }); watch( @@ -146,7 +151,27 @@ function openRelation(o: { id: string; name: string; kind: string; schema?: stri