feat(redis): add hash field search

This commit is contained in:
lexmin0412 2026-07-05 19:37:49 +08:00 committed by GitHub
parent 2c33570351
commit d502b4ce6c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 176 additions and 23 deletions

View File

@ -3,7 +3,7 @@ import { computed, ref, nextTick, onBeforeUnmount, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import { onClickOutside } from "@vueuse/core";
import { DynamicScroller, DynamicScrollerItem, RecycleScroller } from "vue-virtual-scroller";
import { Braces, Copy, Eye, FileText, Terminal, Trash2, Save, RefreshCw, Plus, Loader2, Pencil, WrapText, IndentIncrease, IndentDecrease, ArrowUp, ArrowDown, ArrowUpDown } from "@lucide/vue";
import { Braces, Copy, Eye, FileText, Terminal, Trash2, Save, RefreshCw, Plus, Loader2, Pencil, WrapText, IndentIncrease, IndentDecrease, ArrowUp, ArrowDown, ArrowUpDown, Search } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@ -78,6 +78,9 @@ const redisJsonWordWrap = ref(readRedisJsonWordWrap());
const redisJsonHighlighter = ref<RedisJsonHighlighter>();
const hashSortBy = ref<"field" | "value" | null>(null);
const hashSortDir = ref<"asc" | "desc">("asc");
const hashSearchQuery = ref("");
const activeHashSearchQuery = ref("");
const searchLoading = ref(false);
function toggleHashSort(column: "field" | "value") {
if (hashSortBy.value === column && hashSortDir.value === "desc") {
@ -111,6 +114,55 @@ 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;
function onHashSearchInput() {
if (hashSearchTimer) clearTimeout(hashSearchTimer);
hashSearchTimer = setTimeout(() => void onHashSearch(), 400);
}
function onHashSearchKeydown(event: KeyboardEvent) {
if (event.key === "Enter") {
if (hashSearchTimer) clearTimeout(hashSearchTimer);
hashSearchTimer = null;
void onHashSearch();
return;
}
if (event.key === "Escape") {
if (hashSearchTimer) clearTimeout(hashSearchTimer);
hashSearchTimer = null;
hashSearchQuery.value = "";
void onHashSearch();
}
}
async function onHashSearch() {
const query = hashSearchQuery.value.trim();
if (!data.value) return;
const requestId = ++hashSearchRequestId;
searchLoading.value = true;
try {
const result = await api.redisLoadMore(props.connectionId, props.db, props.keyRaw, "hash", 0, 200, hashSearchPattern(query));
if (requestId !== hashSearchRequestId) return;
const items = Array.isArray(result.value) ? result.value : [];
activeHashSearchQuery.value = query;
collectionItems.value = items;
scanCursor.value = result.scan_cursor ?? undefined;
clearSelectedMember();
} finally {
if (requestId === hashSearchRequestId) searchLoading.value = false;
}
}
const selectedMemberDetail = computed(() => formatRedisMemberDetail(selectedMemberRaw.value));
const selectedMemberJsonDetail = computed(() => selectedMemberDetail.value.json ?? null);
const stringValueDetail = computed(() => (data.value?.key_type === "string" ? formatRedisMemberDetail(data.value.value) : null));
@ -241,6 +293,12 @@ function collectionCountLabel(kind: "items" | "fields" | "members", loaded: numb
async function load(options: { selectDefaultMember?: boolean } = {}) {
const shouldSelectDefaultMember = options.selectDefaultMember ?? true;
if (hashSearchTimer) clearTimeout(hashSearchTimer);
hashSearchTimer = null;
hashSearchRequestId++;
hashSearchQuery.value = "";
activeHashSearchQuery.value = "";
searchLoading.value = false;
loading.value = true;
try {
const loadedValue = await api.redisGetValue(props.connectionId, props.db, props.keyRaw);
@ -266,10 +324,14 @@ async function load(options: { selectDefaultMember?: boolean } = {}) {
}
async function loadMore() {
if (!data.value || !hasMore.value || loadingMore.value) return;
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 requestId = hashSearchRequestId;
loadingMore.value = true;
try {
const result = await api.redisLoadMore(props.connectionId, props.db, props.keyRaw, data.value.key_type, scanCursor.value!, 200);
const result = await api.redisLoadMore(props.connectionId, props.db, props.keyRaw, keyType, scanCursor.value!, 200, hashFilter);
if (keyType === "hash" && requestId !== hashSearchRequestId) return;
const newItems = Array.isArray(result.value) ? result.value : [];
collectionItems.value = [...collectionItems.value, ...newItems];
scanCursor.value = result.scan_cursor ?? undefined;
@ -803,6 +865,7 @@ onBeforeUnmount(() => {
stopResizeMemberSheet();
stopResizeHashColumns();
stopResizeZsetColumns();
if (hashSearchTimer) clearTimeout(hashSearchTimer);
});
</script>
@ -969,7 +1032,11 @@ onBeforeUnmount(() => {
<!-- Hash -->
<div v-else-if="data.key_type === 'hash'" ref="hashTableRef" class="flex-1 flex flex-col overflow-hidden">
<div class="flex items-center gap-2 px-4 py-1.5 border-b shrink-0">
<span class="text-xs text-muted-foreground">{{ collectionCountLabel("fields", collectionItems.length, data.total) }}</span>
<span class="text-xs text-muted-foreground shrink-0">{{ collectionCountLabel("fields", collectionItems.length, activeHashSearchQuery ? null : data.total) }}</span>
<div class="relative flex-1 max-w-60">
<Search class="pointer-events-none absolute left-1.5 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground/80" />
<Input v-model="hashSearchQuery" class="h-6 w-full pl-5 pr-2 text-xs" :placeholder="t('redis.searchFields')" @input="onHashSearchInput" @keydown="onHashSearchKeydown" />
</div>
<span class="flex-1" />
<Input v-model="newField" class="h-6 w-24 text-xs" placeholder="field" />
<Input v-model="newValue" class="h-6 w-32 text-xs" placeholder="value" @keydown.enter="hashSet" />
@ -1028,7 +1095,7 @@ onBeforeUnmount(() => {
</template>
<template #after>
<div v-if="hasMore" class="p-2">
<Button variant="outline" size="sm" class="w-full h-7 text-xs" :disabled="loadingMore" @click="loadMore">
<Button variant="outline" size="sm" class="w-full h-7 text-xs" :disabled="loadingMore || searchLoading" @click="loadMore">
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
{{ t("redis.loadMoreKeys") }}
</Button>

View File

@ -1839,6 +1839,7 @@ export default {
searchByKey: "Key",
searchByValue: "Value",
searchByAll: "All",
searchFields: "Search fields",
keys: "{count} keys",
loadedKeys: "{loaded} / {total} keys loaded",
loadingKeys: "Loading keys...",

View File

@ -1795,6 +1795,7 @@ export default withEnglishFallback({
searchByKey: "Clave",
searchByValue: "Valor",
searchByAll: "Todo",
searchFields: "Buscar campos",
keys: "{count} claves",
loadedKeys: "{loaded} / {total} claves cargadas",
loadingKeys: "Cargando claves...",

View File

@ -1793,6 +1793,7 @@ export default withEnglishFallback({
searchByKey: "Chiave",
searchByValue: "Valore",
searchByAll: "Tutto",
searchFields: "Cerca campi",
keys: "{count} chiavi",
loadedKeys: "{loaded} / {total} chiavi caricate",
loadingKeys: "Caricamento chiavi...",

View File

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

View File

@ -1794,6 +1794,7 @@ export default withEnglishFallback({
searchByKey: "Chave",
searchByValue: "Valor",
searchByAll: "Tudo",
searchFields: "Pesquisar campos",
keys: "{count} chaves",
loadedKeys: "{loaded} / {total} chaves carregadas",
loadingKeys: "Carregando chaves...",

View File

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

View File

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

View File

@ -1681,8 +1681,8 @@ export async function redisExecuteCommand(connectionId: string, db: number, comm
return post("/api/redis/execute-command", { connectionId, db, command, skipSafetyCheck: skipSafetyCheck ?? false });
}
export async function redisLoadMore(connectionId: string, db: number, keyRaw: string, keyType: string, cursor: number, count: number): Promise<RedisValue> {
return post("/api/redis/load-more", { connectionId, db, keyRaw, keyType, cursor, count });
export async function redisLoadMore(connectionId: string, db: number, keyRaw: string, keyType: string, cursor: number, count: number, filter?: string): Promise<RedisValue> {
return post("/api/redis/load-more", { connectionId, db, keyRaw, keyType, cursor, count, filter });
}
export async function redisPubSubPublish(connectionId: string, db: number, channel: string, message: string): Promise<{ subscribers: number }> {

View File

@ -1403,8 +1403,8 @@ export async function redisExecuteCommand(connectionId: string, db: number, comm
return invoke("redis_execute_command", { connectionId, db, command, skipSafetyCheck: skipSafetyCheck ?? false });
}
export async function redisLoadMore(connectionId: string, db: number, keyRaw: string, keyType: string, cursor: number, count: number): Promise<RedisValue> {
return invoke("redis_load_more", { connectionId, db, keyRaw, keyType, cursor, count });
export async function redisLoadMore(connectionId: string, db: number, keyRaw: string, keyType: string, cursor: number, count: number, filter?: string): Promise<RedisValue> {
return invoke("redis_load_more", { connectionId, db, keyRaw, keyType, cursor, count, filter });
}
export async function redisPubSubPublish(connectionId: string, db: number, channel: string, message: string): Promise<{ subscribers: number }> {

View File

@ -15,6 +15,7 @@ use super::json_value_for_js;
const STREAM_ENTRY_LIMIT: usize = 100;
const COLLECTION_PAGE_SIZE: usize = 200;
const HASH_FILTER_SCAN_MAX_ITERATIONS: usize = 10;
const DEFAULT_REDIS_DATABASES: u32 = 16;
const CLUSTER_CURSOR_NODE_BITS: u64 = 16;
const CLUSTER_CURSOR_NODE_MASK: u64 = (1 << CLUSTER_CURSOR_NODE_BITS) - 1;
@ -1758,7 +1759,7 @@ where
}
"hash" => {
let len: u64 = redis::cmd("HLEN").arg(key).query_async(con).await.unwrap_or(0);
let (next_cursor, items) = hscan_page_raw(con, key, 0, COLLECTION_PAGE_SIZE).await?;
let (next_cursor, items) = hscan_page_raw(con, key, 0, COLLECTION_PAGE_SIZE, None).await?;
let cursor = if next_cursor > 0 { Some(next_cursor) } else { None };
(serde_json::Value::Array(items), false, Some(len), cursor)
}
@ -2204,6 +2205,7 @@ pub async fn load_more_collection<C>(
key_type: &str,
cursor: u64,
count: usize,
match_pattern: Option<&str>,
) -> Result<RedisValue, String>
where
C: ConnectionLike + Send + Sync + Unpin,
@ -2230,7 +2232,11 @@ where
(serde_json::Value::Array(items), cursor)
}
"hash" => {
let (next, items) = hscan_page_raw(con, key, cursor, count).await?;
let (next, items) = if let Some(pattern) = match_pattern {
hscan_matching_page_raw(con, key, cursor, count, pattern).await?
} else {
hscan_page_raw(con, key, cursor, count, None).await?
};
let cursor = if next > 0 { Some(next) } else { None };
(serde_json::Value::Array(items), cursor)
}
@ -2254,21 +2260,48 @@ async fn hscan_page_raw<C>(
key: &[u8],
cursor: u64,
count: usize,
match_pattern: Option<&str>,
) -> Result<(u64, Vec<serde_json::Value>), String>
where
C: ConnectionLike + Send + Sync + Unpin,
{
let raw: RedisRawValue = redis::cmd("HSCAN")
.arg(key)
.arg(cursor)
.arg("COUNT")
.arg(count)
.query_async(con)
.await
.map_err(|e| e.to_string())?;
let mut cmd = redis::cmd("HSCAN");
cmd.arg(key).arg(cursor).arg("COUNT").arg(count);
if let Some(pattern) = match_pattern {
cmd.arg("MATCH").arg(pattern);
}
let raw: RedisRawValue = cmd.query_async(con).await.map_err(|e| e.to_string())?;
parse_scan_pairs(raw, "hash")
}
async fn hscan_matching_page_raw<C>(
con: &mut C,
key: &[u8],
cursor: u64,
count: usize,
pattern: &str,
) -> Result<(u64, Vec<serde_json::Value>), String>
where
C: ConnectionLike + Send + Sync + Unpin,
{
let mut cur = cursor;
let mut items = Vec::new();
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);
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.
if cur == 0 || items.len() >= target {
break;
}
}
Ok((cur, items))
}
async fn sscan_page_raw<C>(
con: &mut C,
key: &[u8],
@ -2430,6 +2463,11 @@ mod tests {
])
}
fn hscan_response(cursor: &str, pairs: Vec<(&str, &str)>) -> RedisRawValue {
let entries = pairs.into_iter().flat_map(|(field, value)| [bulk(field), bulk(value)]).collect();
RedisRawValue::Array(vec![bulk(cursor), RedisRawValue::Array(entries)])
}
#[test]
fn parses_stream_entries() {
let raw = RedisRawValue::Array(vec![RedisRawValue::Array(vec![
@ -2560,6 +2598,36 @@ mod tests {
assert_eq!(con.command_count("SCAN"), 2);
}
#[tokio::test]
async fn filtered_hash_load_more_keeps_scan_cursor_instead_of_full_iteration() {
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();
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"));
}
#[tokio::test]
async fn filtered_hash_load_more_caps_sparse_scan_iterations() {
let responses = (1..=super::HASH_FILTER_SCAN_MAX_ITERATIONS + 1)
.map(|cursor| hscan_response(&cursor.to_string(), vec![]))
.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();
assert_eq!(result.scan_cursor, Some(super::HASH_FILTER_SCAN_MAX_ITERATIONS as u64));
assert_eq!(result.value, serde_json::json!([]));
assert_eq!(con.command_count("HSCAN"), super::HASH_FILTER_SCAN_MAX_ITERATIONS);
}
#[test]
fn formats_binary_string_values_like_rdm() {
let raw = RedisRawValue::BulkString(vec![0xAC, 0xED, 0x00, 0x05, b's', b'r']);

View File

@ -789,6 +789,7 @@ pub async fn redis_load_more_in_db_core(
key_type: &str,
cursor: u64,
count: usize,
filter: Option<&str>,
) -> Result<redis_driver::RedisValue, String> {
ensure_redis_pool(state, connection_id).await?;
let connections = state.connections.read().await;
@ -799,12 +800,12 @@ pub async fn redis_load_more_in_db_core(
RedisConnection::Direct(con) => {
let mut con = con.lock().await;
redis_driver::select_db(&mut *con, db).await?;
redis_driver::load_more_collection(&mut *con, &key, key_type, cursor, count).await
redis_driver::load_more_collection(&mut *con, &key, key_type, cursor, count, filter).await
}
RedisConnection::Cluster(cluster) => {
redis_driver::ensure_cluster_db(db)?;
let mut con = redis_driver::cluster_key_connection(cluster, &key).await?;
redis_driver::load_more_collection(&mut con, &key, key_type, cursor, count).await
redis_driver::load_more_collection(&mut con, &key, key_type, cursor, count, filter).await
}
}
}

View File

@ -324,9 +324,19 @@ pub async fn redis_load_more(
key_type: String,
cursor: u64,
count: usize,
filter: Option<String>,
) -> Result<RedisValue, String> {
dbx_core::redis_ops::redis_load_more_in_db_core(&state, &connection_id, db, &key_raw, &key_type, cursor, count)
.await
dbx_core::redis_ops::redis_load_more_in_db_core(
&state,
&connection_id,
db,
&key_raw,
&key_type,
cursor,
count,
filter.as_deref(),
)
.await
}
#[tauri::command]