feat(redis): search hash fields and values

This commit is contained in:
t8y2 2026-07-05 22:02:24 +08:00
parent 9cc7244dab
commit 069dcc479d
11 changed files with 77 additions and 31 deletions

View File

@ -114,14 +114,6 @@ const hashCollectionRows = computed<RedisCollectionRow[]>(() =>
})),
);
function redisGlobEscape(s: string): string {
return s.replace(/[*?[\]\\]/g, "\\$&");
}
function hashSearchPattern(query: string): string | undefined {
return query ? `*${redisGlobEscape(query)}*` : undefined;
}
let hashSearchTimer: ReturnType<typeof setTimeout> | 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 {

View File

@ -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...",

View File

@ -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...",

View File

@ -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...",

View File

@ -1802,7 +1802,7 @@ export default withEnglishFallback({
searchByKey: "キー",
searchByValue: "値",
searchByAll: "すべて",
searchFields: "フィールド検索",
searchFields: "フィールドまたは値を検索",
keys: "{count}キー",
loadedKeys: "{loaded}/{total}キー読み込み完了",
loadingKeys: "キーを読み込み中...",

View File

@ -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...",

View File

@ -1861,7 +1861,7 @@ export default withEnglishFallback({
searchByKey: "键",
searchByValue: "值",
searchByAll: "全部",
searchFields: "搜索 field",
searchFields: "搜索字段或值",
keys: "{count} 个 key",
loadedKeys: "已加载 {loaded} / 共 {total} 个 key",
loadingKeys: "正在加载 key...",

View File

@ -1707,7 +1707,7 @@ export default withEnglishFallback({
searchByKey: "鍵",
searchByValue: "值",
searchByAll: "全部",
searchFields: "搜尋 field",
searchFields: "搜尋欄位或值",
keys: "{count} 個 key",
loadedKeys: "已載入 {loaded} / 共 {total} 個 key",
loadingKeys: "正在載入 key……",

View File

@ -2205,7 +2205,7 @@ pub async fn load_more_collection<C>(
key_type: &str,
cursor: u64,
count: usize,
match_pattern: Option<&str>,
filter_query: Option<&str>,
) -> Result<RedisValue, String>
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<C>(
async fn hscan_filtered_page_raw<C>(
con: &mut C,
key: &[u8],
cursor: u64,
count: usize,
pattern: &str,
query: &str,
) -> Result<(u64, Vec<serde_json::Value>), 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<C>(
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!([]));

View File

@ -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))

View File

@ -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<String>,
}
#[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<Arc<WebState>>,
Json(req): Json<RedisLoadMoreRequest>,
) -> Result<Json<serde_json::Value>, 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<Arc<WebState>>,
Json(req): Json<RedisSetStringRequest>,