feat(redis): add hash field search
This commit is contained in:
parent
2c33570351
commit
d502b4ce6c
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -1792,6 +1792,7 @@ export default withEnglishFallback({
|
|||
searchByKey: "キー",
|
||||
searchByValue: "値",
|
||||
searchByAll: "すべて",
|
||||
searchFields: "フィールド検索",
|
||||
keys: "{count}キー",
|
||||
loadedKeys: "{loaded}/{total}キー読み込み完了",
|
||||
loadingKeys: "キーを読み込み中...",
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -1839,6 +1839,7 @@ export default withEnglishFallback({
|
|||
searchByKey: "键",
|
||||
searchByValue: "值",
|
||||
searchByAll: "全部",
|
||||
searchFields: "搜索 field",
|
||||
keys: "{count} 个 key",
|
||||
loadedKeys: "已加载 {loaded} / 共 {total} 个 key",
|
||||
loadingKeys: "正在加载 key...",
|
||||
|
|
|
|||
|
|
@ -1697,6 +1697,7 @@ export default withEnglishFallback({
|
|||
searchByKey: "鍵",
|
||||
searchByValue: "值",
|
||||
searchByAll: "全部",
|
||||
searchFields: "搜尋 field",
|
||||
keys: "{count} 個 key",
|
||||
loadedKeys: "已載入 {loaded} / 共 {total} 個 key",
|
||||
loadingKeys: "正在載入 key……",
|
||||
|
|
|
|||
|
|
@ -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 }> {
|
||||
|
|
|
|||
|
|
@ -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 }> {
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in New Issue