feat(redis): search hash fields and values
This commit is contained in:
parent
9cc7244dab
commit
069dcc479d
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -1802,7 +1802,7 @@ export default withEnglishFallback({
|
|||
searchByKey: "キー",
|
||||
searchByValue: "値",
|
||||
searchByAll: "すべて",
|
||||
searchFields: "フィールド検索",
|
||||
searchFields: "フィールドまたは値を検索",
|
||||
keys: "{count}キー",
|
||||
loadedKeys: "{loaded}/{total}キー読み込み完了",
|
||||
loadingKeys: "キーを読み込み中...",
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -1861,7 +1861,7 @@ export default withEnglishFallback({
|
|||
searchByKey: "键",
|
||||
searchByValue: "值",
|
||||
searchByAll: "全部",
|
||||
searchFields: "搜索 field",
|
||||
searchFields: "搜索字段或值",
|
||||
keys: "{count} 个 key",
|
||||
loadedKeys: "已加载 {loaded} / 共 {total} 个 key",
|
||||
loadingKeys: "正在加载 key...",
|
||||
|
|
|
|||
|
|
@ -1707,7 +1707,7 @@ export default withEnglishFallback({
|
|||
searchByKey: "鍵",
|
||||
searchByValue: "值",
|
||||
searchByAll: "全部",
|
||||
searchFields: "搜尋 field",
|
||||
searchFields: "搜尋欄位或值",
|
||||
keys: "{count} 個 key",
|
||||
loadedKeys: "已載入 {loaded} / 共 {total} 個 key",
|
||||
loadingKeys: "正在載入 key……",
|
||||
|
|
|
|||
|
|
@ -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!([]));
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
Loading…
Reference in New Issue