feat(redis): search keys by value

This commit is contained in:
t8y2 2026-05-18 21:27:47 +08:00
parent da00405e10
commit ea5e15612f
14 changed files with 260 additions and 12 deletions

View File

@ -41,6 +41,8 @@ const { t } = useI18n();
const connectionStore = useConnectionStore();
const settingsStore = useSettingsStore();
type RedisSearchMode = "key" | "value";
const props = defineProps<{
connectionId: string;
db: number;
@ -52,6 +54,7 @@ const loading = ref(false);
const loadingMore = ref(false);
const rootRef = ref<HTMLElement>();
const searchPattern = ref("*");
const searchMode = ref<RedisSearchMode>("key");
const selectedKeyRaw = ref<string | null>(null);
const hasMore = ref(false);
const scanCursor = ref(0);
@ -73,8 +76,14 @@ const keyGridStyle = {
gridTemplateColumns: "minmax(12rem, 0.35fr) 80px 1fr 60px 60px",
};
const effectivePattern = computed(() => searchPattern.value.trim() || "*");
const isSearchMode = computed(() => effectivePattern.value !== "*");
const valueQuery = computed(() => searchPattern.value.trim());
const effectivePattern = computed(() => (searchMode.value === "key" ? searchPattern.value.trim() || "*" : "*"));
const isSearchMode = computed(() =>
searchMode.value === "key" ? effectivePattern.value !== "*" : valueQuery.value !== "",
);
const searchPlaceholder = computed(() =>
searchMode.value === "key" ? t("redis.pattern") : t("redis.valueSearchPlaceholder"),
);
const selectedKey = computed(() => flatKeys.value.find((key) => key.key_raw === selectedKeyRaw.value) ?? null);
const dangerDetails = computed(() => {
if (!pendingDanger.value) return "";
@ -125,13 +134,11 @@ function rebuildTree(expandAll = false) {
}
async function scanNextPage() {
const result = await api.redisScanKeys(
props.connectionId,
props.db,
scanCursor.value,
effectivePattern.value,
settingsStore.editorSettings.redisScanPageSize,
);
const pageSize = settingsStore.editorSettings.redisScanPageSize;
const result =
searchMode.value === "value"
? await api.redisScanValues(props.connectionId, props.db, scanCursor.value, "*", valueQuery.value, pageSize)
: await api.redisScanKeys(props.connectionId, props.db, scanCursor.value, effectivePattern.value, pageSize);
const existingKeys = new Set(flatKeys.value.map((key) => key.key_raw));
flatKeys.value = [...flatKeys.value, ...result.keys.filter((key) => !existingKeys.has(key.key_raw))];
scanCursor.value = result.cursor;
@ -146,10 +153,16 @@ async function scanNextPage() {
async function loadKeys() {
loading.value = true;
flatKeys.value = [];
treeKeys.value = [];
selectedKeyRaw.value = null;
checkedKeys.value = new Set();
expandedGroupIds.value = new Set();
scanCursor.value = 0;
try {
if (searchMode.value === "value" && !valueQuery.value) {
hasMore.value = false;
return;
}
await scanNextPage();
} finally {
loading.value = false;
@ -341,6 +354,15 @@ function onSearchInput() {
searchTimer = setTimeout(loadKeys, 400);
}
function setSearchMode(mode: RedisSearchMode) {
if (searchMode.value === mode) return;
searchMode.value = mode;
if (mode === "key" && !searchPattern.value.trim()) {
searchPattern.value = "*";
}
void loadKeys();
}
function getSearchInput(): HTMLInputElement | null {
return rootRef.value?.querySelector<HTMLInputElement>("[data-redis-search-input]") ?? null;
}
@ -360,7 +382,7 @@ function onSearchKeydown(event: KeyboardEvent) {
}
if (!isCancelSearchShortcut(event)) return;
event.preventDefault();
searchPattern.value = "*";
searchPattern.value = searchMode.value === "key" ? "*" : "";
void loadKeys();
}
@ -381,11 +403,37 @@ defineExpose({ focusSearch });
<!-- Toolbar -->
<div class="h-9 flex items-center gap-1 px-2 border-b shrink-0">
<Search class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<div class="h-6 flex rounded-md border bg-muted/30 p-0.5 shrink-0" role="group">
<button
type="button"
class="h-5 px-2 text-xs rounded-sm transition-colors"
:class="
searchMode === 'key'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
"
@click="setSearchMode('key')"
>
{{ t("redis.searchByKey") }}
</button>
<button
type="button"
class="h-5 px-2 text-xs rounded-sm transition-colors"
:class="
searchMode === 'value'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
"
@click="setSearchMode('value')"
>
{{ t("redis.searchByValue") }}
</button>
</div>
<Input
v-model="searchPattern"
data-redis-search-input
class="h-6 text-xs border-0 shadow-none focus-visible:ring-0"
:placeholder="t('redis.pattern')"
:placeholder="searchPlaceholder"
@input="onSearchInput"
@keydown="onSearchKeydown"
/>

View File

@ -842,6 +842,9 @@ export default {
selectKey: "Select a key to view its value",
noKeys: "No keys found",
pattern: "pattern (e.g. user:*)",
valueSearchPlaceholder: "value contains...",
searchByKey: "Key",
searchByValue: "Value",
keys: "{count} keys",
loadingKeys: "Loading keys...",
loadMoreKeys: "Load more keys",

View File

@ -747,6 +747,9 @@ export default {
selectKey: "Selecciona una clave para ver su valor",
noKeys: "No se encontraron claves",
pattern: "patrón (p. ej. usuario:*)",
valueSearchPlaceholder: "el valor contiene...",
searchByKey: "Clave",
searchByValue: "Valor",
keys: "{count} claves",
loadingKeys: "Cargando claves...",
loadMoreKeys: "Cargar más claves",

View File

@ -822,6 +822,9 @@ export default {
selectKey: "选择一个 key 查看值",
noKeys: "未找到 key",
pattern: "匹配模式 (如 user:*)",
valueSearchPlaceholder: "按值内容搜索...",
searchByKey: "键",
searchByValue: "值",
keys: "{count} 个 key",
loadingKeys: "正在加载 key...",
loadMoreKeys: "加载更多",

View File

@ -120,6 +120,7 @@ export const cancelDatabaseExport = forward("cancelDatabaseExport");
// Redis
export const redisListDatabases = forward("redisListDatabases");
export const redisScanKeys = forward("redisScanKeys");
export const redisScanValues = forward("redisScanValues");
export const redisGetValue = forward("redisGetValue");
export const redisSetString = forward("redisSetString");
export const redisDeleteKey = forward("redisDeleteKey");

View File

@ -638,6 +638,17 @@ export async function redisScanKeys(
return post("/api/redis/scan-keys", { connectionId, db, cursor, pattern, count });
}
export async function redisScanValues(
connectionId: string,
db: number,
cursor: number,
pattern: string,
query: string,
count: number,
): Promise<RedisScanResult> {
return post("/api/redis/scan-values", { connectionId, db, cursor, pattern, query, count });
}
export async function redisGetValue(connectionId: string, db: number, keyRaw: string): Promise<RedisValue> {
return post("/api/redis/get-value", { connectionId, db, keyRaw });
}

View File

@ -491,6 +491,17 @@ export async function redisScanKeys(
return invoke("redis_scan_keys", { connectionId, db, cursor, pattern, count });
}
export async function redisScanValues(
connectionId: string,
db: number,
cursor: number,
pattern: string,
query: string,
count: number,
): Promise<RedisScanResult> {
return invoke("redis_scan_values", { connectionId, db, cursor, pattern, query, count });
}
export async function redisGetValue(connectionId: string, db: number, keyRaw: string): Promise<RedisValue> {
return invoke("redis_get_value", { connectionId, db, keyRaw });
}

View File

@ -354,6 +354,52 @@ pub async fn scan_keys_page(
Ok(RedisScanResult { cursor: next_cursor, keys: result, total_keys })
}
pub async fn scan_values_page(
con: &mut redis::aio::MultiplexedConnection,
cursor: u64,
pattern: &str,
query: &str,
count: usize,
) -> Result<RedisScanResult, String> {
let raw: RedisRawValue = redis::cmd("SCAN")
.arg(cursor)
.arg("MATCH")
.arg(pattern)
.arg("COUNT")
.arg(count)
.query_async(con)
.await
.map_err(|e| e.to_string())?;
let (next_cursor, keys) = parse_scan_keys(raw)?;
let total_keys: u64 = redis::cmd("DBSIZE").query_async(con).await.unwrap_or(0);
if keys.is_empty() || query.trim().is_empty() {
return Ok(RedisScanResult { cursor: next_cursor, keys: Vec::new(), total_keys });
}
let mut result = Vec::new();
for key in keys {
let Ok(value) = get_value(con, &key).await else {
continue;
};
if !redis_value_matches_query(&value.value, query) {
continue;
}
let value_preview = redis_search_value_preview(&value.value);
result.push(RedisKeyInfo {
key_display: value.key_display,
key_raw: value.key_raw,
key_type: value.key_type,
ttl: value.ttl,
size: redis_search_value_size(&value.value, value.total),
value_preview,
});
}
Ok(RedisScanResult { cursor: next_cursor, keys: result, total_keys })
}
pub async fn get_value(con: &mut redis::aio::MultiplexedConnection, key: &[u8]) -> Result<RedisValue, String> {
let key_type: String = redis::cmd("TYPE").arg(key).query_async(con).await.map_err(|e| e.to_string())?;
@ -412,6 +458,42 @@ pub async fn get_value(con: &mut redis::aio::MultiplexedConnection, key: &[u8])
})
}
fn redis_value_matches_query(value: &serde_json::Value, query: &str) -> bool {
let query = query.trim();
if query.is_empty() {
return false;
}
redis_search_value_text(value).to_lowercase().contains(&query.to_lowercase())
}
fn redis_search_value_text(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(text) => text.clone(),
other => serde_json::to_string(other).unwrap_or_else(|_| other.to_string()),
}
}
fn redis_search_value_preview(value: &serde_json::Value) -> String {
const MAX_PREVIEW_LEN: usize = 160;
let text = redis_search_value_text(value);
if text.chars().count() <= MAX_PREVIEW_LEN {
return text;
}
let mut preview = text.chars().take(MAX_PREVIEW_LEN).collect::<String>();
preview.push('…');
preview
}
fn redis_search_value_size(value: &serde_json::Value, total: Option<u64>) -> u64 {
if let Some(total) = total {
return total;
}
match value {
serde_json::Value::String(text) => text.len() as u64,
_ => 0,
}
}
async fn get_stream_entries(
con: &mut redis::aio::MultiplexedConnection,
key: &[u8],
@ -819,7 +901,7 @@ mod tests {
classify_command, is_redis_json_type, parse_command_argv, parse_database_count, parse_scan_keys,
parse_stream_entries, redis_command_raw_to_json, redis_json_raw_to_json, redis_json_value_preview,
redis_key_bytes_to_display, redis_key_bytes_to_raw, redis_key_raw_to_bytes, redis_key_value_preview,
redis_raw_to_json, redis_value_contains_binary, RedisCommandSafety, RedisRawValue,
redis_raw_to_json, redis_value_contains_binary, redis_value_matches_query, RedisCommandSafety, RedisRawValue,
};
fn bulk(value: &str) -> RedisRawValue {
@ -950,6 +1032,14 @@ mod tests {
assert_eq!(parse_command_argv(" ").unwrap_err(), "Redis command is empty");
}
#[test]
fn matches_redis_values_case_insensitively() {
assert!(redis_value_matches_query(&serde_json::json!("Hello Redis"), "redis"));
assert!(redis_value_matches_query(&serde_json::json!({"field": "Ada Lovelace"}), "lovelace"));
assert!(!redis_value_matches_query(&serde_json::json!("Hello Redis"), ""));
assert!(!redis_value_matches_query(&serde_json::json!("Hello Redis"), "mysql"));
}
#[test]
fn classifies_safe_confirmed_and_blocked_commands() {
assert_eq!(classify_command("GET"), RedisCommandSafety::Allowed);

View File

@ -36,6 +36,27 @@ pub async fn redis_scan_keys_core(
}
}
pub async fn redis_scan_values_core(
state: &AppState,
connection_id: &str,
db: u32,
cursor: u64,
pattern: &str,
query: &str,
count: usize,
) -> Result<RedisScanResult, String> {
let connections = state.connections.read().await;
let pool = connections.get(connection_id).ok_or("Connection not found")?;
match pool {
PoolKind::Redis(con) => {
let mut con = con.lock().await;
redis_driver::select_db(&mut con, db).await?;
redis_driver::scan_values_page(&mut con, cursor, pattern, query, count).await
}
_ => Err("Not a Redis connection".to_string()),
}
}
pub async fn redis_get_value_core(state: &AppState, connection_id: &str, key: &str) -> Result<RedisValue, String> {
redis_get_value_in_db_core(state, connection_id, 0, key).await
}

View File

@ -123,6 +123,7 @@ async fn main() {
// Redis
.route("/redis/list-databases", post(routes::redis::list_databases))
.route("/redis/scan-keys", post(routes::redis::scan_keys))
.route("/redis/scan-values", post(routes::redis::scan_values))
.route("/redis/get-value", post(routes::redis::get_value))
.route("/redis/set-string", post(routes::redis::set_string))
.route("/redis/delete-key", post(routes::redis::delete_key))

View File

@ -23,6 +23,17 @@ pub struct RedisScanRequest {
pub count: usize,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RedisValueScanRequest {
pub connection_id: String,
pub db: u32,
pub cursor: u64,
pub pattern: String,
pub query: String,
pub count: usize,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RedisKeyRequest {
@ -119,6 +130,24 @@ pub async fn scan_keys(
Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?))
}
pub async fn scan_values(
State(state): State<Arc<WebState>>,
Json(req): Json<RedisValueScanRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
let result = dbx_core::redis_ops::redis_scan_values_core(
&state.app,
&req.connection_id,
req.db,
req.cursor,
&req.pattern,
&req.query,
req.count,
)
.await
.map_err(AppError)?;
Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?))
}
pub async fn get_value(
State(state): State<Arc<WebState>>,
Json(req): Json<RedisKeyRequest>,

View File

@ -0,0 +1,13 @@
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import test from "node:test";
test("Redis browser exposes key/value search modes", () => {
const source = readFileSync("apps/desktop/src/components/redis/RedisKeyBrowser.vue", "utf8");
assert.match(source, /type RedisSearchMode = "key" \| "value"/);
assert.match(source, /searchMode\s*=\s*ref<RedisSearchMode>\("key"\)/);
assert.match(source, /redisScanValues/);
assert.match(source, /redis\.searchByKey/);
assert.match(source, /redis\.searchByValue/);
});

View File

@ -24,6 +24,19 @@ pub async fn redis_scan_keys(
dbx_core::redis_ops::redis_scan_keys_core(&state, &connection_id, db, cursor, &pattern, count).await
}
#[tauri::command]
pub async fn redis_scan_values(
state: State<'_, Arc<AppState>>,
connection_id: String,
db: u32,
cursor: u64,
pattern: String,
query: String,
count: usize,
) -> Result<RedisScanResult, String> {
dbx_core::redis_ops::redis_scan_values_core(&state, &connection_id, db, cursor, &pattern, &query, count).await
}
#[tauri::command]
pub async fn redis_get_value(
state: State<'_, Arc<AppState>>,

View File

@ -202,6 +202,7 @@ pub fn run() {
commands::table_import::cancel_table_import,
commands::redis_cmd::redis_list_databases,
commands::redis_cmd::redis_scan_keys,
commands::redis_cmd::redis_scan_values,
commands::redis_cmd::redis_get_value,
commands::redis_cmd::redis_set_string,
commands::redis_cmd::redis_delete_key,