diff --git a/apps/desktop/src/components/redis/RedisValueViewer.vue b/apps/desktop/src/components/redis/RedisValueViewer.vue index ba9d2ad06..a0e520c2e 100644 --- a/apps/desktop/src/components/redis/RedisValueViewer.vue +++ b/apps/desktop/src/components/redis/RedisValueViewer.vue @@ -114,14 +114,6 @@ const hashCollectionRows = computed(() => })), ); -function redisGlobEscape(s: string): string { - return s.replace(/[*?[\]\\]/g, "\\$&"); -} - -function hashSearchPattern(query: string): string | undefined { - return query ? `*${redisGlobEscape(query)}*` : undefined; -} - let hashSearchTimer: ReturnType | null = null; let hashSearchRequestId = 0; @@ -151,7 +143,7 @@ async function onHashSearch() { const requestId = ++hashSearchRequestId; searchLoading.value = true; try { - const result = await api.redisLoadMore(props.connectionId, props.db, props.keyRaw, "hash", 0, 200, hashSearchPattern(query)); + const result = await api.redisLoadMore(props.connectionId, props.db, props.keyRaw, "hash", 0, 200, query || undefined); if (requestId !== hashSearchRequestId) return; const items = Array.isArray(result.value) ? result.value : []; activeHashSearchQuery.value = query; @@ -326,7 +318,7 @@ async function load(options: { selectDefaultMember?: boolean } = {}) { async function loadMore() { if (!data.value || !hasMore.value || loadingMore.value || (data.value.key_type === "hash" && searchLoading.value)) return; const keyType = data.value.key_type; - const hashFilter = keyType === "hash" ? hashSearchPattern(activeHashSearchQuery.value) : undefined; + const hashFilter = keyType === "hash" ? activeHashSearchQuery.value || undefined : undefined; const requestId = hashSearchRequestId; loadingMore.value = true; try { diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index a4ca93fe8..80fd82015 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1861,7 +1861,7 @@ export default { searchByKey: "Key", searchByValue: "Value", searchByAll: "All", - searchFields: "Search fields", + searchFields: "Search fields and values", keys: "{count} keys", loadedKeys: "{loaded} / {total} keys loaded", loadingKeys: "Loading keys...", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 0ad26ff6b..60b9f59da 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1805,7 +1805,7 @@ export default withEnglishFallback({ searchByKey: "Clave", searchByValue: "Valor", searchByAll: "Todo", - searchFields: "Buscar campos", + searchFields: "Buscar campos y valores", keys: "{count} claves", loadedKeys: "{loaded} / {total} claves cargadas", loadingKeys: "Cargando claves...", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index c187a9a2b..2a17a2a8d 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1803,7 +1803,7 @@ export default withEnglishFallback({ searchByKey: "Chiave", searchByValue: "Valore", searchByAll: "Tutto", - searchFields: "Cerca campi", + searchFields: "Cerca campi e valori", keys: "{count} chiavi", loadedKeys: "{loaded} / {total} chiavi caricate", loadingKeys: "Caricamento chiavi...", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 63a9122e4..5ccc31515 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1802,7 +1802,7 @@ export default withEnglishFallback({ searchByKey: "キー", searchByValue: "値", searchByAll: "すべて", - searchFields: "フィールド検索", + searchFields: "フィールドまたは値を検索", keys: "{count}キー", loadedKeys: "{loaded}/{total}キー読み込み完了", loadingKeys: "キーを読み込み中...", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index a13ef17f9..d3c400895 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1804,7 +1804,7 @@ export default withEnglishFallback({ searchByKey: "Chave", searchByValue: "Valor", searchByAll: "Tudo", - searchFields: "Pesquisar campos", + searchFields: "Pesquisar campos e valores", keys: "{count} chaves", loadedKeys: "{loaded} / {total} chaves carregadas", loadingKeys: "Carregando chaves...", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 4e4e6761a..6b9cc1be9 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1861,7 +1861,7 @@ export default withEnglishFallback({ searchByKey: "键", searchByValue: "值", searchByAll: "全部", - searchFields: "搜索 field", + searchFields: "搜索字段或值", keys: "{count} 个 key", loadedKeys: "已加载 {loaded} / 共 {total} 个 key", loadingKeys: "正在加载 key...", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index b5e9bf309..194452b00 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1707,7 +1707,7 @@ export default withEnglishFallback({ searchByKey: "鍵", searchByValue: "值", searchByAll: "全部", - searchFields: "搜尋 field", + searchFields: "搜尋欄位或值", keys: "{count} 個 key", loadedKeys: "已載入 {loaded} / 共 {total} 個 key", loadingKeys: "正在載入 key……", diff --git a/crates/dbx-core/src/db/redis_driver.rs b/crates/dbx-core/src/db/redis_driver.rs index 112f91fe0..ee2f5aa2d 100644 --- a/crates/dbx-core/src/db/redis_driver.rs +++ b/crates/dbx-core/src/db/redis_driver.rs @@ -2205,7 +2205,7 @@ pub async fn load_more_collection( key_type: &str, cursor: u64, count: usize, - match_pattern: Option<&str>, + filter_query: Option<&str>, ) -> Result where C: ConnectionLike + Send + Sync + Unpin, @@ -2232,8 +2232,8 @@ where (serde_json::Value::Array(items), cursor) } "hash" => { - let (next, items) = if let Some(pattern) = match_pattern { - hscan_matching_page_raw(con, key, cursor, count, pattern).await? + let (next, items) = if let Some(query) = filter_query.filter(|query| !query.is_empty()) { + hscan_filtered_page_raw(con, key, cursor, count, query).await? } else { hscan_page_raw(con, key, cursor, count, None).await? }; @@ -2274,12 +2274,12 @@ where parse_scan_pairs(raw, "hash") } -async fn hscan_matching_page_raw( +async fn hscan_filtered_page_raw( con: &mut C, key: &[u8], cursor: u64, count: usize, - pattern: &str, + query: &str, ) -> Result<(u64, Vec), String> where C: ConnectionLike + Send + Sync + Unpin, @@ -2289,11 +2289,11 @@ where let target = count.max(1); for _ in 0..HASH_FILTER_SCAN_MAX_ITERATIONS { - let (next, page) = hscan_page_raw(con, key, cur, target, Some(pattern)).await?; - items.extend(page); + let (next, page) = hscan_page_raw(con, key, cur, target, None).await?; + items.extend(page.into_iter().filter(|item| hash_entry_matches_query(item, query))); cur = next; - // Redis applies MATCH during incremental scans and may return empty pages. - // Stop only after a bounded number of chunks so sparse matches progress without an unbounded full-hash scan. + // HSCAN MATCH only checks field names, so value search has to filter returned pairs client-side. + // Keep a hard scan bound so sparse value matches cannot turn one UI search into a full hash walk. if cur == 0 || items.len() >= target { break; } @@ -2302,6 +2302,16 @@ where Ok((cur, items)) } +fn hash_entry_matches_query(item: &serde_json::Value, query: &str) -> bool { + let query = query.to_lowercase(); + if query.is_empty() { + return true; + } + let field = item.get("field").and_then(serde_json::Value::as_str).unwrap_or_default(); + let value = item.get("value").and_then(serde_json::Value::as_str).unwrap_or_default(); + field.to_lowercase().contains(&query) || value.to_lowercase().contains(&query) +} + async fn sscan_page_raw( con: &mut C, key: &[u8], @@ -2599,18 +2609,31 @@ mod tests { } #[tokio::test] - async fn filtered_hash_load_more_keeps_scan_cursor_instead_of_full_iteration() { + async fn filtered_hash_load_more_matches_fields_and_keeps_scan_cursor() { let mut con = FakeRedisConnection::new(vec![ hscan_response("512", vec![("user:1", "Ada")]), hscan_response("0", vec![("user:2", "Bob")]), ]); - let result = super::load_more_collection(&mut con, b"hash-key", "hash", 0, 1, Some("*user*")).await.unwrap(); + let result = super::load_more_collection(&mut con, b"hash-key", "hash", 0, 1, Some("user")).await.unwrap(); assert_eq!(result.scan_cursor, Some(512)); assert_eq!(result.value, serde_json::json!([{ "field": "user:1", "value": "Ada" }])); assert_eq!(con.command_count("HSCAN"), 1); - assert!(con.commands[0].contains("\r\nMATCH\r\n")); + assert!(!con.commands[0].contains("\r\nMATCH\r\n")); + } + + #[tokio::test] + async fn filtered_hash_load_more_matches_values() { + let mut con = + FakeRedisConnection::new(vec![hscan_response("0", vec![("status", "Ada Lovelace"), ("name", "Bob")])]); + + let result = super::load_more_collection(&mut con, b"hash-key", "hash", 0, 20, Some("lovelace")).await.unwrap(); + + assert_eq!(result.scan_cursor, None); + assert_eq!(result.value, serde_json::json!([{ "field": "status", "value": "Ada Lovelace" }])); + assert_eq!(con.command_count("HSCAN"), 1); + assert!(!con.commands[0].contains("\r\nMATCH\r\n")); } #[tokio::test] @@ -2620,8 +2643,7 @@ mod tests { .collect(); let mut con = FakeRedisConnection::new(responses); - let result = - super::load_more_collection(&mut con, b"hash-key", "hash", 0, 20, Some("*missing*")).await.unwrap(); + let result = super::load_more_collection(&mut con, b"hash-key", "hash", 0, 20, Some("missing")).await.unwrap(); assert_eq!(result.scan_cursor, Some(super::HASH_FILTER_SCAN_MAX_ITERATIONS as u64)); assert_eq!(result.value, serde_json::json!([])); diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index b5b15e047..1ce0f5038 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -395,6 +395,7 @@ async fn main() { .route("/redis/scan-keys-batch", post(routes::redis::scan_keys_batch)) .route("/redis/scan-values", post(routes::redis::scan_values)) .route("/redis/get-value", post(routes::redis::get_value)) + .route("/redis/load-more", post(routes::redis::load_more)) .route("/redis/set-string", post(routes::redis::set_string)) .route("/redis/delete-key", post(routes::redis::delete_key)) .route("/redis/hash-set", post(routes::redis::hash_set)) diff --git a/crates/dbx-web/src/routes/redis.rs b/crates/dbx-web/src/routes/redis.rs index eceb090fa..d8d33251c 100644 --- a/crates/dbx-web/src/routes/redis.rs +++ b/crates/dbx-web/src/routes/redis.rs @@ -75,6 +75,18 @@ pub struct RedisKeyRequest { pub key_raw: String, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RedisLoadMoreRequest { + pub connection_id: String, + pub db: u32, + pub key_raw: String, + pub key_type: String, + pub cursor: u64, + pub count: usize, + pub filter: Option, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct RedisSetStringRequest { @@ -271,6 +283,25 @@ pub async fn get_value( Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) } +pub async fn load_more( + State(state): State>, + Json(req): Json, +) -> Result, AppError> { + let result = dbx_core::redis_ops::redis_load_more_in_db_core( + &state.app, + &req.connection_id, + req.db, + &req.key_raw, + &req.key_type, + req.cursor, + req.count, + req.filter.as_deref(), + ) + .await + .map_err(AppError)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) +} + pub async fn set_string( State(state): State>, Json(req): Json,