diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 30daf2e9fb..a7de81f529 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -119,6 +119,7 @@ const defaultForm = (): ConnectionForm => ({ redis_sentinel_password: "", redis_sentinel_tls: false, redis_cluster_nodes: "", + redis_key_separator: ":", etcd_endpoints: "", read_only: false, visible_databases: undefined, @@ -545,6 +546,7 @@ watch( redis_sentinel_password: config.redis_sentinel_password || "", redis_sentinel_tls: config.redis_sentinel_tls || false, redis_cluster_nodes: config.redis_cluster_nodes || "", + redis_key_separator: config.redis_key_separator || ":", etcd_endpoints: config.etcd_endpoints || "", read_only: config.read_only || false, visible_databases: config.visible_databases, @@ -1057,6 +1059,7 @@ function connectionConfigForSubmit(id: string): ConnectionConfig { config.redis_sentinel_password = undefined; config.redis_sentinel_tls = undefined; config.redis_cluster_nodes = undefined; + config.redis_key_separator = undefined; } else if (config.redis_connection_mode === "sentinel") { config.redis_sentinel_master = config.redis_sentinel_master?.trim() || ""; config.redis_sentinel_nodes = normalizeRedisSentinelNodes(config.redis_sentinel_nodes || ""); @@ -1088,6 +1091,9 @@ function connectionConfigForSubmit(id: string): ConnectionConfig { config.redis_sentinel_tls = undefined; config.redis_cluster_nodes = undefined; } + if (config.db_type === "redis") { + config.redis_key_separator = config.redis_key_separator?.trim() || ":"; + } if (config.db_type === "etcd") { config.etcd_endpoints = normalizeEndpointLines(config.etcd_endpoints || ""); const firstEndpoint = firstEtcdEndpoint(config.etcd_endpoints); @@ -2376,6 +2382,10 @@ function openExternalUrl(url: string) { +
+ + +
diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.vue b/apps/desktop/src/components/redis/RedisKeyBrowser.vue index 578e57a5d4..20fcea0705 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.vue +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.vue @@ -104,6 +104,7 @@ const effectivePattern = computed(() => (searchMode.value === "key" ? redisKeySe const isSearchMode = computed(() => (searchMode.value === "key" ? effectivePattern.value !== "*" : valueQuery.value !== "")); const searchPlaceholder = computed(() => (searchMode.value === "key" ? (fuzzyKeySearch.value ? t("redis.fuzzyPattern") : t("redis.pattern")) : t("redis.valueSearchPlaceholder"))); const loadingEmptyText = computed(() => (searchMode.value === "value" && valueQuery.value ? t("redis.searchingValues") : t("redis.loadingKeys"))); +const redisKeySeparator = computed(() => connectionStore.getConfig(props.connectionId)?.redis_key_separator || ":"); const lastTotalKeys = ref(0); const fetchAllProgressText = computed(() => { if (!isFetchingAll.value) return ""; @@ -151,7 +152,7 @@ function countLeaves(node: RedisKeyTreeNode): number { } function rebuildTree(expandAll = false) { - const nextTree = buildRedisKeyTree(flatKeys.value, props.db); + const nextTree = buildRedisKeyTree(flatKeys.value, props.db, redisKeySeparator.value); treeKeys.value = nextTree; const nextExpanded = new Set(); @@ -172,7 +173,7 @@ function rebuildTree(expandAll = false) { function mergeTree(newKeys: RedisKeyInfo[]) { if (newKeys.length === 0) return; - treeKeys.value = mergeKeysIntoRedisKeyTree(treeKeys.value, newKeys, props.db); + treeKeys.value = mergeKeysIntoRedisKeyTree(treeKeys.value, newKeys, props.db, redisKeySeparator.value); const availableExpanded = collectExpandedGroupIds(treeKeys.value); const nextExpanded = new Set(); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index e0fa0d8d18..259747b07f 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -222,6 +222,7 @@ redisSentinelPassword: "Sentinel Password", redisSentinelTls: "Sentinel TLS", redisSentinelTlsHint: "Use TLS when connecting to Sentinel nodes", + redisKeySeparator: "Key Namespace Separator", etcdEndpoints: "Endpoints", etcdEndpointsHint: "One endpoint per line. Leave blank to use the host and port above.", etcdCaCertPlaceholder: "/path/to/ca.crt", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 716ad6c10e..39af900392 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -215,6 +215,7 @@ redisSentinelPassword: "Contraseña Sentinel", redisSentinelTls: "TLS Sentinel", redisSentinelTlsHint: "Usar TLS al conectar con nodos Sentinel", + redisKeySeparator: "Separador de espacio de nombres de clave", searchDatabasePlaceholder: "Buscar tipos de bases de datos", iconView: "Vista de íconos", listView: "Vista de lista", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 7a9929714b..108cfac69f 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -220,6 +220,7 @@ redisSentinelPassword: "Password Sentinel", redisSentinelTls: "TLS Sentinel", redisSentinelTlsHint: "Usa TLS quando ti connetti ai nodi Sentinel", + redisKeySeparator: "Separatore namespace chiavi", searchDatabasePlaceholder: "Cerca tipi di database", iconView: "Vista icone", listView: "Vista elenco", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 75dc933e70..db5f2c99d6 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -220,6 +220,7 @@ redisSentinelPassword: "Senha do Sentinel", redisSentinelTls: "TLS do Sentinel", redisSentinelTlsHint: "Usar TLS ao conectar aos nós Sentinel", + redisKeySeparator: "Separador de namespace de chave", searchDatabasePlaceholder: "Pesquisar tipos de banco de dados", iconView: "Visualização em ícones", listView: "Visualização em lista", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 2484fa068f..96ee1ca863 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -223,6 +223,7 @@ redisSentinelPassword: "哨兵密码", redisSentinelTls: "哨兵 TLS", redisSentinelTlsHint: "连接 Sentinel 节点时使用 TLS", + redisKeySeparator: "键名命名空间分隔符", etcdEndpoints: "Endpoints", etcdEndpointsHint: "每行一个 endpoint。留空时使用上面的 host 和端口。", etcdCaCertPlaceholder: "/path/to/ca.crt", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index af9c5882ec..648505702d 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -220,6 +220,7 @@ redisSentinelPassword: "Sentinel 密碼", redisSentinelTls: "Sentinel TLS", redisSentinelTlsHint: "連線 Sentinel 節點時使用 TLS", + redisKeySeparator: "鍵名命名空間分隔符", searchDatabasePlaceholder: "搜尋資料庫類型", iconView: "圖示檢視", listView: "清單檢視", diff --git a/apps/desktop/src/lib/redisKeyTree.ts b/apps/desktop/src/lib/redisKeyTree.ts index fb3892e4db..3eb3ec8017 100644 --- a/apps/desktop/src/lib/redisKeyTree.ts +++ b/apps/desktop/src/lib/redisKeyTree.ts @@ -53,19 +53,19 @@ function sortRedisTreeNodes(nodes: RedisKeyTreeNode[]): RedisKeyTreeNode[] { ); } -export function buildRedisKeyTree(keys: RedisKeyInfo[], db: number): RedisKeyTreeNode[] { +export function buildRedisKeyTree(keys: RedisKeyInfo[], db: number, separator = ":"): RedisKeyTreeNode[] { const root: RedisKeyTreeNode[] = []; const groupMap = new Map(); for (const key of keys) { - insertKeyIntoTree(root, groupMap, key, db); + insertKeyIntoTree(root, groupMap, key, db, separator); } return sortRedisTreeNodes(root); } -function insertKeyIntoTree(root: RedisKeyTreeNode[], groupMap: Map, key: RedisKeyInfo, db: number): void { - const pathSegments = key.key_display.split(":"); +function insertKeyIntoTree(root: RedisKeyTreeNode[], groupMap: Map, key: RedisKeyInfo, db: number, separator: string): void { + const pathSegments = key.key_display.split(separator); if (pathSegments.length === 1) { root.push({ kind: "leaf", @@ -134,8 +134,8 @@ function rebuildGroupMap(tree: RedisKeyTreeNode[]): Map(); @@ -152,7 +152,7 @@ export function mergeKeysIntoRedisKeyTree(existingTree: RedisKeyTreeNode[], newK for (const key of newKeys) { if (existingKeyIds.has(key.key_raw)) continue; - insertKeyIntoTree(existingTree, groupMap, key, db); + insertKeyIntoTree(existingTree, groupMap, key, db, separator); } return sortRedisTreeNodes(existingTree); diff --git a/crates/dbx-core/src/agent_connection.rs b/crates/dbx-core/src/agent_connection.rs index 8b12743fe9..ed191afd4b 100644 --- a/crates/dbx-core/src/agent_connection.rs +++ b/crates/dbx-core/src/agent_connection.rs @@ -1,6 +1,6 @@ use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC}; -use crate::models::connection::{ConnectionConfig, DatabaseType}; +use crate::models::connection::{default_redis_key_separator, ConnectionConfig, DatabaseType}; use crate::path_utils::expand_tilde; pub fn agent_connect_params(config: &ConnectionConfig, host: &str, port: u16, database: &str) -> serde_json::Value { @@ -428,6 +428,7 @@ mod tests { redis_sentinel_password: String::new(), redis_sentinel_tls: false, redis_cluster_nodes: String::new(), + redis_key_separator: default_redis_key_separator(), etcd_endpoints: String::new(), external_config: None, jdbc_driver_class: None, diff --git a/crates/dbx-core/src/cloud_sync.rs b/crates/dbx-core/src/cloud_sync.rs index b624b733a2..742f655a73 100644 --- a/crates/dbx-core/src/cloud_sync.rs +++ b/crates/dbx-core/src/cloud_sync.rs @@ -530,7 +530,9 @@ mod tests { decrypt_sensitive_payload, encrypt_sensitive_payload, normalized_remote_path, parent_collection_paths, scrub_connection_secrets, ConnectionSecretSnapshot, SensitiveSyncPayload, }; - use crate::models::connection::{ConnectionConfig, DatabaseType, TransportLayerConfig}; + use crate::models::connection::{ + default_redis_key_separator, ConnectionConfig, DatabaseType, TransportLayerConfig, + }; #[test] fn normalizes_empty_remote_path_to_default() { @@ -591,6 +593,7 @@ mod tests { redis_sentinel_password: "sentinel".to_string(), redis_sentinel_tls: false, redis_cluster_nodes: String::new(), + redis_key_separator: default_redis_key_separator(), etcd_endpoints: String::new(), external_config: None, jdbc_driver_class: None, diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index 8037a4dd93..fd755df22d 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -19,7 +19,8 @@ use crate::db::proxy_tunnel::ProxyTunnelManager; use crate::db::ssh_tunnel::TunnelManager; use crate::external; use crate::models::connection::{ - parse_jdbc_host_port, parse_mongo_first_host, rewrite_jdbc_url_host, ConnectionConfig, DatabaseType, + default_redis_key_separator, parse_jdbc_host_port, parse_mongo_first_host, rewrite_jdbc_url_host, ConnectionConfig, + DatabaseType, }; use crate::path_utils::expand_tilde; use crate::plugins::{PluginDriverSession, PluginRegistry, PluginRuntimeEnv}; @@ -1205,8 +1206,8 @@ mod tests { use crate::agent_manager::{AgentState, JavaRuntimeConfig, JavaRuntimeMode, DEFAULT_JRE_KEY}; use crate::db; use crate::models::connection::{ - default_connect_timeout_secs, ConnectionConfig, DatabaseType, ProxyTunnelConfig, ProxyType, - TransportLayerConfig, + default_connect_timeout_secs, default_redis_key_separator, ConnectionConfig, DatabaseType, ProxyTunnelConfig, + ProxyType, TransportLayerConfig, }; use crate::query; use crate::schema; @@ -1246,6 +1247,7 @@ mod tests { redis_sentinel_password: String::new(), redis_sentinel_tls: false, redis_cluster_nodes: String::new(), + redis_key_separator: default_redis_key_separator(), etcd_endpoints: String::new(), external_config: None, jdbc_driver_class: None, diff --git a/crates/dbx-core/src/connection_secrets.rs b/crates/dbx-core/src/connection_secrets.rs index a807907b42..a140a202da 100644 --- a/crates/dbx-core/src/connection_secrets.rs +++ b/crates/dbx-core/src/connection_secrets.rs @@ -506,6 +506,7 @@ mod tests { redis_sentinel_password: String::new(), redis_sentinel_tls: false, redis_cluster_nodes: String::new(), + redis_key_separator: crate::models::connection::default_redis_key_separator(), etcd_endpoints: String::new(), external_config: None, jdbc_driver_class: None, diff --git a/crates/dbx-core/src/models/connection.rs b/crates/dbx-core/src/models/connection.rs index 37bd4f726e..60302c9023 100644 --- a/crates/dbx-core/src/models/connection.rs +++ b/crates/dbx-core/src/models/connection.rs @@ -60,6 +60,8 @@ pub struct ConnectionConfig { pub redis_sentinel_tls: bool, #[serde(default, skip_serializing_if = "String::is_empty")] pub redis_cluster_nodes: String, + #[serde(default = "default_redis_key_separator", skip_serializing_if = "is_default_redis_separator")] + pub redis_key_separator: String, #[serde(default, skip_serializing_if = "String::is_empty")] pub etcd_endpoints: String, /// Typed configuration for external tabular sources. @@ -196,6 +198,14 @@ fn is_false(value: &bool) -> bool { !*value } +pub fn default_redis_key_separator() -> String { + ":".to_string() +} + +fn is_default_redis_separator(value: &str) -> bool { + value == ":" +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] #[derive(Default)] @@ -337,6 +347,8 @@ struct ConnectionConfigData { pub redis_sentinel_tls: bool, #[serde(default)] pub redis_cluster_nodes: String, + #[serde(default = "default_redis_key_separator")] + pub redis_key_separator: String, #[serde(default)] pub etcd_endpoints: String, #[serde(default)] @@ -386,6 +398,7 @@ impl From for ConnectionConfig { redis_sentinel_password: data.redis_sentinel_password, redis_sentinel_tls: data.redis_sentinel_tls, redis_cluster_nodes: data.redis_cluster_nodes, + redis_key_separator: data.redis_key_separator, etcd_endpoints: data.etcd_endpoints, external_config: data.external_config, jdbc_driver_class: data.jdbc_driver_class, @@ -1337,8 +1350,8 @@ fn bracket_ipv6(host: &str) -> String { #[cfg(test)] mod tests { use super::{ - default_query_timeout_secs, default_ssh_connect_timeout_secs, ConnectionConfig, DatabaseType, - ProxyTunnelConfig, ProxyType, TransportLayerConfig, + default_query_timeout_secs, default_redis_key_separator, default_ssh_connect_timeout_secs, ConnectionConfig, + DatabaseType, ProxyTunnelConfig, ProxyType, TransportLayerConfig, }; use std::str::FromStr; @@ -1376,6 +1389,7 @@ mod tests { redis_sentinel_password: String::new(), redis_sentinel_tls: false, redis_cluster_nodes: String::new(), + redis_key_separator: default_redis_key_separator(), etcd_endpoints: String::new(), external_config: None, jdbc_driver_class: None, diff --git a/crates/dbx-core/src/query.rs b/crates/dbx-core/src/query.rs index bed6b92646..9117b51a15 100644 --- a/crates/dbx-core/src/query.rs +++ b/crates/dbx-core/src/query.rs @@ -8,7 +8,7 @@ use tokio_util::sync::CancellationToken; use crate::connection::{AppState, PoolKind}; use crate::db; -use crate::models::connection::DatabaseType; +use crate::models::connection::{default_redis_key_separator, DatabaseType}; use crate::sql::{split_sql_batches, split_sql_statements, starts_with_duckdb_result_sql_keyword}; pub const QUERY_TIMEOUT: Duration = Duration::from_secs(30); @@ -1848,6 +1848,7 @@ mod tests { redis_sentinel_password: String::new(), redis_sentinel_tls: false, redis_cluster_nodes: String::new(), + redis_key_separator: default_redis_key_separator(), etcd_endpoints: String::new(), external_config: None, jdbc_driver_class: None, diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index d5a670b91c..4345a8529e 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -1,6 +1,6 @@ use crate::connection::{connection_url_for_endpoint, database_connection_config, AppState, MysqlMode, PoolKind}; use crate::db; -use crate::models::connection::{ConnectionConfig, DatabaseType}; +use crate::models::connection::{default_redis_key_separator, ConnectionConfig, DatabaseType}; use crate::query::{agent_execute_query_params, QueryExecutionOptions}; use std::collections::HashSet; use std::future::Future; @@ -614,7 +614,7 @@ mod tests { duckdb_query_tables_in_database, filter_objects_by_types, filter_yashandb_recyclebin_objects, filter_yashandb_recyclebin_tables, is_agent_postgres_metadata_fallback_config, }; - use crate::models::connection::{ConnectionConfig, DatabaseType}; + use crate::models::connection::{default_redis_key_separator, ConnectionConfig, DatabaseType}; fn test_column(name: &str, comment: Option<&str>, is_primary_key: bool) -> super::db::ColumnInfo { super::db::ColumnInfo { @@ -665,6 +665,7 @@ mod tests { redis_sentinel_password: String::new(), redis_sentinel_tls: false, redis_cluster_nodes: String::new(), + redis_key_separator: default_redis_key_separator(), etcd_endpoints: String::new(), external_config: None, jdbc_driver_class: None, diff --git a/crates/dbx-core/src/transfer.rs b/crates/dbx-core/src/transfer.rs index 37722708aa..6ab4cec83a 100644 --- a/crates/dbx-core/src/transfer.rs +++ b/crates/dbx-core/src/transfer.rs @@ -6,7 +6,7 @@ use tokio::sync::RwLock; use crate::connection::{AppState, PoolKind}; use crate::db; use crate::db::mongo_driver::MongoDocumentResult; -use crate::models::connection::DatabaseType; +use crate::models::connection::{default_redis_key_separator, DatabaseType}; use crate::object_source_sql::{build_executable_object_source_statements, EditableObjectSourceSqlInput}; use crate::query::{agent_execute_query_params, QueryExecutionOptions}; use crate::sql::starts_with_executable_sql_keyword; @@ -3161,6 +3161,7 @@ mod tests { redis_sentinel_password: String::new(), redis_sentinel_tls: false, redis_cluster_nodes: String::new(), + redis_key_separator: default_redis_key_separator(), etcd_endpoints: String::new(), external_config: None, jdbc_driver_class: None, diff --git a/crates/dbx-core/tests/live_postgres_transfer.rs b/crates/dbx-core/tests/live_postgres_transfer.rs index 26d1cbdb8b..0dfd4208d9 100644 --- a/crates/dbx-core/tests/live_postgres_transfer.rs +++ b/crates/dbx-core/tests/live_postgres_transfer.rs @@ -42,6 +42,7 @@ fn postgres_test_config(id: &str, database: &str) -> ConnectionConfig { redis_sentinel_password: String::new(), redis_sentinel_tls: false, redis_cluster_nodes: String::new(), + redis_key_separator: dbx_core::models::connection::default_redis_key_separator(), etcd_endpoints: String::new(), external_config: None, jdbc_driver_class: None, diff --git a/crates/dbx-web/src/routes/connection.rs b/crates/dbx-web/src/routes/connection.rs index ee9bd0162d..aa998a0e24 100644 --- a/crates/dbx-web/src/routes/connection.rs +++ b/crates/dbx-web/src/routes/connection.rs @@ -197,6 +197,7 @@ mod tests { redis_sentinel_password: String::new(), redis_sentinel_tls: false, redis_cluster_nodes: String::new(), + redis_key_separator: dbx_core::models::connection::default_redis_key_separator(), etcd_endpoints: String::new(), external_config: None, jdbc_driver_class: None, diff --git a/packages/node-core/src/connections.ts b/packages/node-core/src/connections.ts index 7d645a2d3f..1ce9587ce9 100644 --- a/packages/node-core/src/connections.ts +++ b/packages/node-core/src/connections.ts @@ -25,6 +25,7 @@ export interface ConnectionConfig { redis_sentinel_username?: string; redis_sentinel_password?: string; redis_sentinel_tls?: boolean; + redis_key_separator?: string; read_only?: boolean; }