mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 10:28:44 +00:00
feat(redis): search list set and zset members
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick } from "vue";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
redisGetValue: vi.fn(),
|
||||
redisGetTtl: vi.fn(),
|
||||
redisSetTtl: vi.fn(),
|
||||
redisSetExpireAt: vi.fn(),
|
||||
redisLoadMore: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
redisGetValue: mocks.redisGetValue,
|
||||
redisGetTtl: mocks.redisGetTtl,
|
||||
redisSetTtl: mocks.redisSetTtl,
|
||||
redisSetExpireAt: mocks.redisSetExpireAt,
|
||||
redisLoadMore: mocks.redisLoadMore,
|
||||
}));
|
||||
|
||||
vi.mock("@/composables/useEditorFontFamilyStyle", () => ({
|
||||
useEditorFontFamilyStyle: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("@/composables/useToast", () => ({
|
||||
useToast: () => ({ toast: mocks.toast }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/common/shikiJsonHighlighter", () => ({
|
||||
createShikiJsonHighlighter: vi.fn().mockResolvedValue(() => ""),
|
||||
}));
|
||||
|
||||
import RedisValueViewer from "./RedisValueViewer.vue";
|
||||
|
||||
const mountedApps: Array<{ unmount: () => void; host: HTMLElement }> = [];
|
||||
|
||||
function createLocalStorage(): Storage {
|
||||
const entries = new Map<string, string>();
|
||||
return {
|
||||
get length() {
|
||||
return entries.size;
|
||||
},
|
||||
clear() {
|
||||
entries.clear();
|
||||
},
|
||||
getItem(key) {
|
||||
return entries.get(key) ?? null;
|
||||
},
|
||||
key(index) {
|
||||
return [...entries.keys()][index] ?? null;
|
||||
},
|
||||
removeItem(key) {
|
||||
entries.delete(key);
|
||||
},
|
||||
setItem(key, value) {
|
||||
entries.set(key, String(value));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const { unmount, host } of mountedApps.splice(0)) {
|
||||
unmount();
|
||||
host.remove();
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("localStorage", createLocalStorage());
|
||||
});
|
||||
|
||||
async function settle() {
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
function blob(text: string) {
|
||||
return { raw_base64: btoa(text), encoding: "utf8" as const };
|
||||
}
|
||||
|
||||
function zsetValue() {
|
||||
return {
|
||||
key_display: "rank:order",
|
||||
key_raw: "rank:order",
|
||||
ttl: -1,
|
||||
redis_type: "zset",
|
||||
data: {
|
||||
kind: "zset" as const,
|
||||
items: [
|
||||
{ score: "1", member: blob("2314099896:11842851748888") },
|
||||
{ score: "2", member: blob("2314101855:22903060973580") },
|
||||
],
|
||||
total: 100,
|
||||
scan_cursor: 2,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setValue() {
|
||||
return {
|
||||
key_display: "members",
|
||||
key_raw: "members",
|
||||
ttl: -1,
|
||||
redis_type: "set",
|
||||
data: { kind: "set" as const, items: [{ member: blob("alpha") }], total: 1, scan_cursor: undefined },
|
||||
};
|
||||
}
|
||||
|
||||
function listValue() {
|
||||
return {
|
||||
key_display: "queue",
|
||||
key_raw: "queue",
|
||||
ttl: -1,
|
||||
redis_type: "list",
|
||||
data: { kind: "list" as const, items: [{ index: 0, value: blob("first") }], total: 1, scan_cursor: undefined },
|
||||
};
|
||||
}
|
||||
|
||||
const testI18nMessages = {
|
||||
en: {
|
||||
redis: {
|
||||
searchFields: "Search fields and values",
|
||||
searchMembers: "Search members",
|
||||
searchItems: "Search items",
|
||||
members: "{count} members",
|
||||
items: "{count} items",
|
||||
loadedMembers: "{loaded} / {total} members loaded",
|
||||
loadedItems: "{loaded} / {total} items loaded",
|
||||
loadMoreKeys: "Load more",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function mountViewer() {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () => h(RedisValueViewer, { connectionId: "connection", db: 0, keyDisplay: "key", keyRaw: "key" });
|
||||
},
|
||||
}),
|
||||
);
|
||||
app.use(createI18n({ legacy: false, locale: "en", messages: testI18nMessages, missingWarn: false, fallbackWarn: false }));
|
||||
app.mount(host);
|
||||
mountedApps.push({ unmount: () => app.unmount(), host });
|
||||
}
|
||||
|
||||
function searchInput(): HTMLInputElement | null {
|
||||
return document.querySelector<HTMLInputElement>("input[placeholder='Search members'], input[placeholder='Search items']");
|
||||
}
|
||||
|
||||
function loadMoreButton(): HTMLButtonElement {
|
||||
const buttons = [...document.querySelectorAll<HTMLButtonElement>("button")];
|
||||
return buttons.find((button) => button.textContent?.includes("Load more"))!;
|
||||
}
|
||||
|
||||
async function typeSearch(query: string) {
|
||||
const input = searchInput()!;
|
||||
input.value = query;
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
// The Input wrapper syncs its v-model through a passive watcher, so the query only
|
||||
// reaches the viewer on the next tick — press Enter after it lands, like real typing.
|
||||
await nextTick();
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
await settle();
|
||||
}
|
||||
|
||||
describe("Redis collection member search", () => {
|
||||
it("offers a member search box for zset keys", async () => {
|
||||
mocks.redisGetValue.mockResolvedValue(zsetValue());
|
||||
mountViewer();
|
||||
await settle();
|
||||
|
||||
expect(searchInput()).not.toBeNull();
|
||||
});
|
||||
|
||||
it("offers a search box for set and list keys", async () => {
|
||||
mocks.redisGetValue.mockResolvedValue(setValue());
|
||||
mountViewer();
|
||||
await settle();
|
||||
expect(searchInput()?.placeholder).toBe("Search members");
|
||||
|
||||
for (const { unmount, host } of mountedApps.splice(0)) {
|
||||
unmount();
|
||||
host.remove();
|
||||
}
|
||||
|
||||
mocks.redisGetValue.mockResolvedValue(listValue());
|
||||
mountViewer();
|
||||
await settle();
|
||||
expect(searchInput()?.placeholder).toBe("Search items");
|
||||
});
|
||||
|
||||
it("filters zset members server-side and keeps the sort direction", async () => {
|
||||
mocks.redisGetValue.mockResolvedValue(zsetValue());
|
||||
mocks.redisLoadMore.mockResolvedValue({
|
||||
kind: "zset",
|
||||
items: [{ score: "2", member: blob("2314101855:22903060973580") }],
|
||||
scan_cursor: undefined,
|
||||
});
|
||||
mountViewer();
|
||||
await settle();
|
||||
|
||||
await typeSearch("22903060");
|
||||
|
||||
expect(mocks.redisLoadMore).toHaveBeenCalledWith("connection", 0, "key", "zset", 0, 200, "22903060", "asc");
|
||||
expect(document.body.textContent).toContain("2314101855:22903060973580");
|
||||
expect(document.body.textContent).not.toContain("2314099896:11842851748888");
|
||||
});
|
||||
|
||||
it("carries the active query into load-more paging", async () => {
|
||||
mocks.redisGetValue.mockResolvedValue(zsetValue());
|
||||
mocks.redisLoadMore.mockResolvedValue({
|
||||
kind: "zset",
|
||||
items: [{ score: "2", member: blob("2314101855:22903060973580") }],
|
||||
scan_cursor: 200,
|
||||
});
|
||||
mountViewer();
|
||||
await settle();
|
||||
await typeSearch("22903060");
|
||||
|
||||
mocks.redisLoadMore.mockClear();
|
||||
mocks.redisLoadMore.mockResolvedValue({ kind: "zset", items: [], scan_cursor: undefined });
|
||||
loadMoreButton().click();
|
||||
await settle();
|
||||
|
||||
expect(mocks.redisLoadMore).toHaveBeenCalledWith("connection", 0, "key", "zset", 200, 200, "22903060", "asc");
|
||||
});
|
||||
|
||||
it("keeps the query when the zset sort direction is toggled", async () => {
|
||||
mocks.redisGetValue.mockResolvedValue(zsetValue());
|
||||
mocks.redisLoadMore.mockResolvedValue({
|
||||
kind: "zset",
|
||||
items: [{ score: "2", member: blob("2314101855:22903060973580") }],
|
||||
scan_cursor: undefined,
|
||||
});
|
||||
mountViewer();
|
||||
await settle();
|
||||
await typeSearch("22903060");
|
||||
|
||||
mocks.redisLoadMore.mockClear();
|
||||
[...document.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.includes("Score"))!.click();
|
||||
await settle();
|
||||
|
||||
// Reloading for the new sort order must re-apply the query instead of silently dropping it.
|
||||
expect(mocks.redisLoadMore).toHaveBeenCalledWith("connection", 0, "key", "zset", 0, 200, "22903060", "desc");
|
||||
expect(searchInput()?.value).toBe("22903060");
|
||||
});
|
||||
|
||||
it("drops the total from the count label while a search is active", async () => {
|
||||
mocks.redisGetValue.mockResolvedValue(zsetValue());
|
||||
mocks.redisLoadMore.mockResolvedValue({
|
||||
kind: "zset",
|
||||
items: [{ score: "2", member: blob("2314101855:22903060973580") }],
|
||||
scan_cursor: undefined,
|
||||
});
|
||||
mountViewer();
|
||||
await settle();
|
||||
expect(document.body.textContent).toContain("2 / 100 members loaded");
|
||||
|
||||
await typeSearch("22903060");
|
||||
|
||||
expect(document.body.textContent).toContain("1 members");
|
||||
expect(document.body.textContent).not.toContain("/ 100 members loaded");
|
||||
});
|
||||
});
|
||||
@@ -398,8 +398,8 @@ const valueSearchOpen = ref(false);
|
||||
const valueSearchQuery = ref("");
|
||||
const valueSearchMatchIndex = ref(0);
|
||||
const valueSearchHasNavigated = ref(false);
|
||||
const hashSearchQuery = ref("");
|
||||
const activeHashSearchQuery = ref("");
|
||||
const collectionSearchQuery = ref("");
|
||||
const activeCollectionSearchQuery = ref("");
|
||||
const searchLoading = ref(false);
|
||||
const valueSearchBarRef = ref<{ focusInput: (select?: boolean) => void } | null>(null);
|
||||
type JsonEditorHandle = { openSearch: () => boolean; selectRange?: (from: number, to: number, options?: { focus?: boolean }) => boolean };
|
||||
@@ -426,7 +426,7 @@ async function toggleZsetSort() {
|
||||
const previousDirection = zsetSortDir.value;
|
||||
zsetSortDir.value = previousDirection === "asc" ? "desc" : "asc";
|
||||
try {
|
||||
await load({ notifyParent: false });
|
||||
await reloadPreservingCollectionSearch({ notifyParent: false });
|
||||
} catch (error) {
|
||||
zsetSortDir.value = previousDirection;
|
||||
toast(errorMessage(error), 3000);
|
||||
@@ -764,8 +764,8 @@ const canHighlightContentSearch = computed(() => valueSearchOpen.value && Boolea
|
||||
const canHighlightStringSurface = computed(() => canHighlightContentSearch.value && !showMemberDetail.value);
|
||||
const canHighlightMemberSurface = computed(() => canHighlightContentSearch.value && showMemberDetail.value);
|
||||
|
||||
let hashSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let hashSearchRequestId = 0;
|
||||
let collectionSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let collectionSearchRequestId = 0;
|
||||
let hashResizeStartX = 0;
|
||||
let hashResizeStartWidth = 0;
|
||||
let zsetResizeStartX = 0;
|
||||
@@ -774,7 +774,7 @@ let zsetResizeStartWidth = 0;
|
||||
function shouldPauseAutoValueRefresh(): boolean {
|
||||
const loadedPageSize = data.value ? redisValueCollectionItems(data.value).length : 0;
|
||||
const hasExpandedCollectionPage = collectionItems.value.length > loadedPageSize;
|
||||
return showMemberDetail.value || editingZsetMemberKey.value !== null || showHashFieldTtlDialog.value || valueSearchOpen.value || Boolean(hashSearchQuery.value.trim()) || Boolean(activeHashSearchQuery.value) || searchLoading.value || loadingMore.value || hasExpandedCollectionPage;
|
||||
return showMemberDetail.value || editingZsetMemberKey.value !== null || showHashFieldTtlDialog.value || valueSearchOpen.value || Boolean(collectionSearchQuery.value.trim()) || Boolean(activeCollectionSearchQuery.value) || searchLoading.value || loadingMore.value || hasExpandedCollectionPage;
|
||||
}
|
||||
|
||||
type PendingDelete = { kind: "key" } | { kind: "hash"; field: string } | { kind: "list"; index: number } | { kind: "set"; member: string } | { kind: "zset"; member: string };
|
||||
@@ -1092,40 +1092,55 @@ function collectionCountLabel(kind: "items" | "fields" | "members", loaded: numb
|
||||
return t(`redis.loaded${kind[0].toUpperCase()}${kind.slice(1)}`, { loaded, total });
|
||||
}
|
||||
|
||||
function onHashSearchInput() {
|
||||
if (hashSearchTimer) clearTimeout(hashSearchTimer);
|
||||
hashSearchTimer = setTimeout(() => void onHashSearch(), 400);
|
||||
/** True for the collection kinds whose toolbar offers a server-filtered member search. */
|
||||
const collectionSearchSupported = computed(() => redisKind.value === "hash" || redisKind.value === "list" || redisKind.value === "set" || redisKind.value === "zset");
|
||||
|
||||
function onCollectionSearchInput() {
|
||||
if (collectionSearchTimer) clearTimeout(collectionSearchTimer);
|
||||
collectionSearchTimer = setTimeout(() => void onCollectionSearch(), 400);
|
||||
}
|
||||
|
||||
function onHashSearchKeydown(event: KeyboardEvent) {
|
||||
function onCollectionSearchKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Enter") {
|
||||
if (hashSearchTimer) clearTimeout(hashSearchTimer);
|
||||
hashSearchTimer = null;
|
||||
void onHashSearch();
|
||||
if (collectionSearchTimer) clearTimeout(collectionSearchTimer);
|
||||
collectionSearchTimer = null;
|
||||
void onCollectionSearch();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
if (hashSearchTimer) clearTimeout(hashSearchTimer);
|
||||
hashSearchTimer = null;
|
||||
hashSearchQuery.value = "";
|
||||
void onHashSearch();
|
||||
if (collectionSearchTimer) clearTimeout(collectionSearchTimer);
|
||||
collectionSearchTimer = null;
|
||||
collectionSearchQuery.value = "";
|
||||
void onCollectionSearch();
|
||||
}
|
||||
}
|
||||
|
||||
async function onHashSearch() {
|
||||
const query = hashSearchQuery.value.trim();
|
||||
if (redisKind.value !== "hash") return;
|
||||
const requestId = ++hashSearchRequestId;
|
||||
async function onCollectionSearch() {
|
||||
const query = collectionSearchQuery.value.trim();
|
||||
const keyType = redisKind.value;
|
||||
if (!collectionSearchSupported.value) return;
|
||||
const requestId = ++collectionSearchRequestId;
|
||||
searchLoading.value = true;
|
||||
try {
|
||||
const result = await api.redisLoadMore(props.connectionId, props.db, props.keyRaw, "hash", 0, 200, query || undefined);
|
||||
if (requestId !== hashSearchRequestId || result.kind !== "hash") return;
|
||||
activeHashSearchQuery.value = query;
|
||||
const sortDirection = keyType === "zset" ? zsetSortDir.value : undefined;
|
||||
const result = await api.redisLoadMore(props.connectionId, props.db, props.keyRaw, keyType, 0, 200, query || undefined, sortDirection);
|
||||
if (requestId !== collectionSearchRequestId || result.kind !== keyType) return;
|
||||
activeCollectionSearchQuery.value = query;
|
||||
collectionItems.value = result.items;
|
||||
scanCursor.value = result.scan_cursor ?? undefined;
|
||||
if (!hasRetainedMemberDraft.value) clearSelectedMember();
|
||||
} finally {
|
||||
if (requestId === hashSearchRequestId) searchLoading.value = false;
|
||||
if (requestId === collectionSearchRequestId) searchLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** `load()` clears the search box, so anything that reloads mid-search has to re-apply the query. */
|
||||
async function reloadPreservingCollectionSearch(options: { notifyParent?: boolean } = {}) {
|
||||
const query = activeCollectionSearchQuery.value || collectionSearchQuery.value.trim();
|
||||
await load({ ...options, selectDefaultMember: false });
|
||||
if (query) {
|
||||
collectionSearchQuery.value = query;
|
||||
await onCollectionSearch();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1526,11 +1541,11 @@ async function load(options: { background?: boolean; notifyParent?: boolean; pre
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hashSearchTimer) clearTimeout(hashSearchTimer);
|
||||
hashSearchTimer = null;
|
||||
hashSearchRequestId++;
|
||||
hashSearchQuery.value = "";
|
||||
activeHashSearchQuery.value = "";
|
||||
if (collectionSearchTimer) clearTimeout(collectionSearchTimer);
|
||||
collectionSearchTimer = null;
|
||||
collectionSearchRequestId++;
|
||||
collectionSearchQuery.value = "";
|
||||
activeCollectionSearchQuery.value = "";
|
||||
searchLoading.value = false;
|
||||
resetValueSearch();
|
||||
data.value = loadedValue;
|
||||
@@ -1587,16 +1602,16 @@ async function load(options: { background?: boolean; notifyParent?: boolean; pre
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (!data.value || !hasMore.value || loadingMore.value || (redisKind.value === "hash" && searchLoading.value)) return;
|
||||
if (!(redisKind.value === "list" || redisKind.value === "set" || redisKind.value === "hash" || redisKind.value === "zset")) return;
|
||||
if (!data.value || !hasMore.value || loadingMore.value || searchLoading.value) return;
|
||||
if (!collectionSearchSupported.value) return;
|
||||
const keyType = redisKind.value;
|
||||
const hashFilter = keyType === "hash" ? activeHashSearchQuery.value || undefined : undefined;
|
||||
const requestId = hashSearchRequestId;
|
||||
const filter = activeCollectionSearchQuery.value || undefined;
|
||||
const requestId = collectionSearchRequestId;
|
||||
loadingMore.value = true;
|
||||
try {
|
||||
const sortDirection = keyType === "zset" ? zsetSortDir.value : undefined;
|
||||
const result = await api.redisLoadMore(props.connectionId, props.db, props.keyRaw, keyType, scanCursor.value!, 200, hashFilter, sortDirection);
|
||||
if (keyType === "hash" && requestId !== hashSearchRequestId) return;
|
||||
const result = await api.redisLoadMore(props.connectionId, props.db, props.keyRaw, keyType, scanCursor.value!, 200, filter, sortDirection);
|
||||
if (requestId !== collectionSearchRequestId) return;
|
||||
const newItems = redisCollectionPageItems(result);
|
||||
collectionItems.value = [...collectionItems.value, ...newItems];
|
||||
scanCursor.value = result.scan_cursor ?? undefined;
|
||||
@@ -2402,15 +2417,6 @@ function handleHashFieldTtlOpenChange(open: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadHashPreservingSearch() {
|
||||
const query = activeHashSearchQuery.value || hashSearchQuery.value.trim();
|
||||
await load({ selectDefaultMember: false });
|
||||
if (query) {
|
||||
hashSearchQuery.value = query;
|
||||
await onHashSearch();
|
||||
}
|
||||
}
|
||||
|
||||
async function saveHashFieldTtl() {
|
||||
if (savingHashFieldTtl.value || !editingHashField.value) return;
|
||||
const validation = validateRedisExpiry(hashFieldTtlMode.value, hashFieldTtlInput.value, hashFieldExpireAt.value);
|
||||
@@ -2430,7 +2436,7 @@ async function saveHashFieldTtl() {
|
||||
await api.redisHashFieldSetExpireAt(props.connectionId, props.db, props.keyRaw, field, validation.policy.expireAt);
|
||||
}
|
||||
cancelEditHashFieldTtl(true);
|
||||
await reloadHashPreservingSearch();
|
||||
await reloadPreservingCollectionSearch();
|
||||
} catch (error) {
|
||||
toast(errorMessage(error), 3000);
|
||||
} finally {
|
||||
@@ -2691,7 +2697,7 @@ onBeforeUnmount(() => {
|
||||
stopRefreshTimers();
|
||||
stopResizeHashColumns();
|
||||
stopResizeZsetColumns();
|
||||
if (hashSearchTimer) clearTimeout(hashSearchTimer);
|
||||
if (collectionSearchTimer) clearTimeout(collectionSearchTimer);
|
||||
});
|
||||
|
||||
defineExpose({ focusSearch });
|
||||
@@ -2954,7 +2960,11 @@ defineExpose({ focusSearch });
|
||||
<!-- List -->
|
||||
<div v-else-if="redisKind === 'list'" 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("items", listRows.length, collectionTotal) }}</span>
|
||||
<span class="text-xs text-muted-foreground shrink-0">{{ collectionCountLabel("items", listRows.length, activeCollectionSearchQuery ? null : collectionTotal) }}</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="collectionSearchQuery" class="h-6 w-full pl-5 pr-2 text-xs" :placeholder="t('redis.searchItems')" @input="onCollectionSearchInput" @keydown="onCollectionSearchKeydown" />
|
||||
</div>
|
||||
<span class="flex-1" />
|
||||
<Input v-model="newValue" class="h-6 w-40 text-xs" placeholder="value" @keydown.enter="listPush" />
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="listPush"><Plus class="w-3 h-3 mr-1" />Push</Button>
|
||||
@@ -2986,7 +2996,7 @@ defineExpose({ focusSearch });
|
||||
</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>
|
||||
@@ -2998,7 +3008,11 @@ defineExpose({ focusSearch });
|
||||
<!-- Set -->
|
||||
<div v-else-if="redisKind === 'set'" 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("items", setRows.length, collectionTotal) }}</span>
|
||||
<span class="text-xs text-muted-foreground shrink-0">{{ collectionCountLabel("items", setRows.length, activeCollectionSearchQuery ? null : collectionTotal) }}</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="collectionSearchQuery" class="h-6 w-full pl-5 pr-2 text-xs" :placeholder="t('redis.searchMembers')" @input="onCollectionSearchInput" @keydown="onCollectionSearchKeydown" />
|
||||
</div>
|
||||
<span class="flex-1" />
|
||||
<Input v-model="newValue" class="h-6 w-40 text-xs" placeholder="member" @keydown.enter="setAdd" />
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="setAdd"><Plus class="w-3 h-3 mr-1" />Add</Button>
|
||||
@@ -3033,7 +3047,7 @@ defineExpose({ focusSearch });
|
||||
</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>
|
||||
@@ -3045,10 +3059,10 @@ defineExpose({ focusSearch });
|
||||
<!-- Hash -->
|
||||
<div v-else-if="redisKind === '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 shrink-0">{{ collectionCountLabel("fields", hashCollectionRows.length, activeHashSearchQuery ? null : collectionTotal) }}</span>
|
||||
<span class="text-xs text-muted-foreground shrink-0">{{ collectionCountLabel("fields", hashCollectionRows.length, activeCollectionSearchQuery ? null : collectionTotal) }}</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" />
|
||||
<Input v-model="collectionSearchQuery" class="h-6 w-full pl-5 pr-2 text-xs" :placeholder="t('redis.searchFields')" @input="onCollectionSearchInput" @keydown="onCollectionSearchKeydown" />
|
||||
</div>
|
||||
<span class="flex-1" />
|
||||
<Input v-model="newField" class="h-6 w-24 text-xs" placeholder="field" />
|
||||
@@ -3126,7 +3140,11 @@ defineExpose({ focusSearch });
|
||||
<!-- Sorted Set -->
|
||||
<div v-else-if="redisKind === 'zset'" ref="zsetTableRef" 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("members", zsetRows.length, collectionTotal) }}</span>
|
||||
<span class="text-xs text-muted-foreground shrink-0">{{ collectionCountLabel("members", zsetRows.length, activeCollectionSearchQuery ? null : collectionTotal) }}</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="collectionSearchQuery" class="h-6 w-full pl-5 pr-2 text-xs" :placeholder="t('redis.searchMembers')" @input="onCollectionSearchInput" @keydown="onCollectionSearchKeydown" />
|
||||
</div>
|
||||
<span class="flex-1" />
|
||||
<Input v-model="newScore" class="h-6 w-20 text-xs" placeholder="score" />
|
||||
<Input v-model="newValue" class="h-6 w-32 text-xs" placeholder="member" @keydown.enter="zsetAdd" />
|
||||
@@ -3189,7 +3207,7 @@ defineExpose({ focusSearch });
|
||||
</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>
|
||||
|
||||
@@ -4759,6 +4759,8 @@ export default {
|
||||
searchByValue: "Value",
|
||||
searchByAll: "All",
|
||||
searchFields: "Search fields and values",
|
||||
searchMembers: "Search members",
|
||||
searchItems: "Search items",
|
||||
keys: "{count} keys",
|
||||
loadedKeys: "{loaded} / {total} keys loaded",
|
||||
loadingKeys: "Loading keys...",
|
||||
|
||||
@@ -4566,6 +4566,8 @@ export default withEnglishFallback({
|
||||
searchByValue: "Valor",
|
||||
searchByAll: "Todo",
|
||||
searchFields: "Buscar campos y valores",
|
||||
searchMembers: "Buscar miembros",
|
||||
searchItems: "Buscar elementos",
|
||||
keys: "{count} claves",
|
||||
loadedKeys: "{loaded} / {total} claves cargadas",
|
||||
loadingKeys: "Cargando claves...",
|
||||
|
||||
@@ -4564,6 +4564,8 @@ export default withEnglishFallback({
|
||||
searchByValue: "Valore",
|
||||
searchByAll: "Tutto",
|
||||
searchFields: "Cerca campi e valori",
|
||||
searchMembers: "Cerca membri",
|
||||
searchItems: "Cerca elementi",
|
||||
keys: "{count} chiavi",
|
||||
loadedKeys: "{loaded} / {total} chiavi caricate",
|
||||
loadingKeys: "Caricamento chiavi...",
|
||||
|
||||
@@ -4592,6 +4592,8 @@ export default withEnglishFallback({
|
||||
searchByValue: "値",
|
||||
searchByAll: "すべて",
|
||||
searchFields: "フィールドまたは値を検索",
|
||||
searchMembers: "メンバーを検索",
|
||||
searchItems: "要素を検索",
|
||||
keys: "{count}キー",
|
||||
loadedKeys: "{loaded}/{total}キー読み込み完了",
|
||||
loadingKeys: "キーを読み込み中...",
|
||||
|
||||
@@ -4181,6 +4181,8 @@ export default withEnglishFallback({
|
||||
searchByValue: "값",
|
||||
searchByAll: "전체",
|
||||
searchFields: "필드와 값 검색",
|
||||
searchMembers: "멤버 검색",
|
||||
searchItems: "항목 검색",
|
||||
keys: "키 {count}개",
|
||||
loadedKeys: "{total}개 중 {loaded}개 키 로드됨",
|
||||
loadingKeys: "키를 불러오는 중...",
|
||||
|
||||
@@ -4566,6 +4566,8 @@ export default withEnglishFallback({
|
||||
searchByValue: "Valor",
|
||||
searchByAll: "Tudo",
|
||||
searchFields: "Pesquisar campos e valores",
|
||||
searchMembers: "Pesquisar membros",
|
||||
searchItems: "Pesquisar itens",
|
||||
keys: "{count} chaves",
|
||||
loadedKeys: "{loaded} / {total} chaves carregadas",
|
||||
loadingKeys: "Carregando chaves...",
|
||||
|
||||
@@ -4743,6 +4743,8 @@ export default withEnglishFallback({
|
||||
searchByValue: "值",
|
||||
searchByAll: "全部",
|
||||
searchFields: "搜索字段或值",
|
||||
searchMembers: "搜索成员",
|
||||
searchItems: "搜索元素",
|
||||
keys: "{count} 个 key",
|
||||
loadedKeys: "已加载 {loaded} / 共 {total} 个 key",
|
||||
loadingKeys: "正在加载 key...",
|
||||
|
||||
@@ -3889,6 +3889,8 @@ export default withEnglishFallback({
|
||||
searchByValue: "值",
|
||||
searchByAll: "全部",
|
||||
searchFields: "搜尋欄位或值",
|
||||
searchMembers: "搜尋成員",
|
||||
searchItems: "搜尋元素",
|
||||
keys: "{count} 個 key",
|
||||
loadedKeys: "已載入 {loaded} / 共 {total} 個 key",
|
||||
loadingKeys: "正在載入 key……",
|
||||
|
||||
@@ -238,7 +238,7 @@ describe("native RedisJSON editor", () => {
|
||||
|
||||
it("keeps retained drafts out of background refresh and exposes word wrap for every JSON editor", () => {
|
||||
const refreshAutoValue = findFunction("refreshAutoValue");
|
||||
const hashSearch = findFunction("onHashSearch");
|
||||
const collectionSearch = findFunction("onCollectionSearch");
|
||||
const viewMember = findFunction("viewMember");
|
||||
const setMemberValueFormat = findFunction("setMemberValueFormat");
|
||||
const selectMember = findFunction("selectMember");
|
||||
@@ -254,7 +254,7 @@ describe("native RedisJSON editor", () => {
|
||||
const refreshButton = findTemplateElement((element) => element.tag === "Button" && element.props.some((prop) => prop.type === 6 && prop.name === "data-redis-value-refresh"));
|
||||
|
||||
expect(refreshAutoValue.getText().match(/hasUnsavedRedisDraft\.value/g)).toHaveLength(2);
|
||||
expect(hashSearch.getText()).toContain("if (!hasRetainedMemberDraft.value) clearSelectedMember();");
|
||||
expect(collectionSearch.getText()).toContain("if (!hasRetainedMemberDraft.value) clearSelectedMember();");
|
||||
expect(viewMember.getText()).toContain("hasRetainedMemberDraft.value");
|
||||
// Clean JSON → other format must clear memberDraftFormat so rawText is not compared to the pretty baseline.
|
||||
expect(setMemberValueFormat.getText()).toContain("memberDraftFormat.value = null");
|
||||
|
||||
@@ -18,7 +18,7 @@ const STREAM_ENTRY_PAGE_SIZE: usize = 50;
|
||||
const STREAM_PENDING_PAGE_SIZE: usize = 100;
|
||||
const COLLECTION_PAGE_SIZE: usize = 200;
|
||||
const STRING_PREVIEW_MAX_BYTES: usize = 64 * 1024;
|
||||
const HASH_FILTER_SCAN_MAX_ITERATIONS: usize = 10;
|
||||
const COLLECTION_FILTER_SCAN_MAX_ITERATIONS: usize = 10;
|
||||
const DEFAULT_REDIS_DATABASES: u32 = 16;
|
||||
const JS_MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
|
||||
const CLUSTER_CURSOR_NODE_BITS: u64 = 16;
|
||||
@@ -3536,13 +3536,18 @@ pub async fn load_more_collection<C>(
|
||||
where
|
||||
C: ConnectionLike + Send + Sync + Unpin,
|
||||
{
|
||||
let filter_query = filter_query.filter(|query| !query.is_empty());
|
||||
match key_type {
|
||||
"list" => {
|
||||
let len: u64 = redis::cmd("LLEN").arg(key).query_async(con).await.unwrap_or(0);
|
||||
if let Some(query) = filter_query {
|
||||
let (items, next) = lrange_filtered_page_raw(con, key, cursor, count, len, query).await?;
|
||||
return Ok(RedisCollectionPage::List { items, scan_cursor: (next < len).then_some(next) });
|
||||
}
|
||||
let start = cursor as i64;
|
||||
let end = start + count as i64 - 1;
|
||||
let v: RedisRawValue =
|
||||
redis::cmd("LRANGE").arg(key).arg(start).arg(end).query_async(con).await.map_err(|e| e.to_string())?;
|
||||
let len: u64 = redis::cmd("LLEN").arg(key).query_async(con).await.unwrap_or(0);
|
||||
let next = cursor + count as u64;
|
||||
Ok(RedisCollectionPage::List {
|
||||
items: redis_list_items_from_raw(v, cursor),
|
||||
@@ -3550,7 +3555,11 @@ where
|
||||
})
|
||||
}
|
||||
"set" => {
|
||||
let (next_cursor, items) = sscan_page_raw(con, key, cursor, count).await?;
|
||||
let (next_cursor, items) = if let Some(query) = filter_query {
|
||||
sscan_filtered_page_raw(con, key, cursor, count, query).await?
|
||||
} else {
|
||||
sscan_page_raw(con, key, cursor, count).await?
|
||||
};
|
||||
Ok(RedisCollectionPage::Set { items, scan_cursor: (next_cursor > 0).then_some(next_cursor) })
|
||||
}
|
||||
"zset" => {
|
||||
@@ -3559,6 +3568,11 @@ where
|
||||
"desc" => true,
|
||||
direction => return Err(format!("Invalid ZSet sort direction: {direction}")),
|
||||
};
|
||||
if let Some(query) = filter_query {
|
||||
let (items, next, has_more) =
|
||||
zrange_filtered_page_raw(con, key, cursor, count, descending, query).await?;
|
||||
return Ok(RedisCollectionPage::Zset { items, scan_cursor: has_more.then_some(next) });
|
||||
}
|
||||
let start = cursor as i64;
|
||||
let end = start + count as i64;
|
||||
let mut items = zrange_page_raw(con, key, start, end, descending).await?;
|
||||
@@ -3568,7 +3582,7 @@ where
|
||||
Ok(RedisCollectionPage::Zset { items, scan_cursor: has_more.then_some(next) })
|
||||
}
|
||||
"hash" => {
|
||||
let (next_cursor, mut items) = if let Some(query) = filter_query.filter(|query| !query.is_empty()) {
|
||||
let (next_cursor, mut items) = if let Some(query) = filter_query {
|
||||
hscan_filtered_page_raw(con, key, cursor, count, query).await?
|
||||
} else {
|
||||
hscan_page_raw(con, key, cursor, count, None).await?
|
||||
@@ -3612,10 +3626,11 @@ where
|
||||
let mut cur = cursor;
|
||||
let mut items = Vec::new();
|
||||
let target = count.max(1);
|
||||
let lowered = query.to_lowercase();
|
||||
|
||||
for _ in 0..HASH_FILTER_SCAN_MAX_ITERATIONS {
|
||||
for _ in 0..COLLECTION_FILTER_SCAN_MAX_ITERATIONS {
|
||||
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)));
|
||||
items.extend(page.into_iter().filter(|item| hash_entry_matches_query(item, &lowered)));
|
||||
cur = next;
|
||||
// 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.
|
||||
@@ -3627,6 +3642,118 @@ where
|
||||
Ok((cur, items))
|
||||
}
|
||||
|
||||
async fn sscan_filtered_page_raw<C>(
|
||||
con: &mut C,
|
||||
key: &[u8],
|
||||
cursor: u64,
|
||||
count: usize,
|
||||
query: &str,
|
||||
) -> Result<(u64, Vec<RedisSetItem>), String>
|
||||
where
|
||||
C: ConnectionLike + Send + Sync + Unpin,
|
||||
{
|
||||
let mut cur = cursor;
|
||||
let mut items = Vec::new();
|
||||
let target = count.max(1);
|
||||
let lowered = query.to_lowercase();
|
||||
|
||||
for _ in 0..COLLECTION_FILTER_SCAN_MAX_ITERATIONS {
|
||||
let (next, page) = sscan_page_raw(con, key, cur, target).await?;
|
||||
items.extend(page.into_iter().filter(|item| blob_matches_query(&item.member, &lowered)));
|
||||
cur = next;
|
||||
// SSCAN MATCH uses glob syntax over the raw member bytes, so substring search filters
|
||||
// returned members client-side. Keep the same hard scan bound the hash filter uses.
|
||||
if cur == 0 || items.len() >= target {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok((cur, items))
|
||||
}
|
||||
|
||||
/// Scans ZRANGE/ZREVRANGE pages so score ordering and the index-based cursor survive filtering.
|
||||
async fn zrange_filtered_page_raw<C>(
|
||||
con: &mut C,
|
||||
key: &[u8],
|
||||
cursor: u64,
|
||||
count: usize,
|
||||
descending: bool,
|
||||
query: &str,
|
||||
) -> Result<(Vec<RedisZsetItem>, u64, bool), String>
|
||||
where
|
||||
C: ConnectionLike + Send + Sync + Unpin,
|
||||
{
|
||||
let mut cur = cursor;
|
||||
let mut items = Vec::new();
|
||||
let target = count.max(1);
|
||||
let lowered = query.to_lowercase();
|
||||
let mut has_more = false;
|
||||
|
||||
for _ in 0..COLLECTION_FILTER_SCAN_MAX_ITERATIONS {
|
||||
let start = cur as i64;
|
||||
// Overshoot by one so a full page tells us more members remain past this window.
|
||||
let mut page = zrange_page_raw(con, key, start, start + target as i64, descending).await?;
|
||||
has_more = page.len() > target;
|
||||
page.truncate(target);
|
||||
let scanned = page.len();
|
||||
items.extend(page.into_iter().filter(|item| blob_matches_query(&item.member, &lowered)));
|
||||
cur += scanned as u64;
|
||||
if !has_more || items.len() >= target {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok((items, cur, has_more))
|
||||
}
|
||||
|
||||
/// Walks LRANGE windows and filters each one after indices are assigned, so the surviving
|
||||
/// items keep their real list positions (the UI deletes list entries by index).
|
||||
async fn lrange_filtered_page_raw<C>(
|
||||
con: &mut C,
|
||||
key: &[u8],
|
||||
cursor: u64,
|
||||
count: usize,
|
||||
len: u64,
|
||||
query: &str,
|
||||
) -> Result<(Vec<RedisListItem>, u64), String>
|
||||
where
|
||||
C: ConnectionLike + Send + Sync + Unpin,
|
||||
{
|
||||
let mut cur = cursor;
|
||||
let mut items = Vec::new();
|
||||
let target = count.max(1);
|
||||
let lowered = query.to_lowercase();
|
||||
|
||||
for _ in 0..COLLECTION_FILTER_SCAN_MAX_ITERATIONS {
|
||||
// LLEN is only a bound here: it falls back to 0 when the command is unavailable,
|
||||
// and an empty LRANGE window is what actually proves the walk is done.
|
||||
if len > 0 && cur >= len {
|
||||
break;
|
||||
}
|
||||
let start = cur as i64;
|
||||
let raw: RedisRawValue = redis::cmd("LRANGE")
|
||||
.arg(key)
|
||||
.arg(start)
|
||||
.arg(start + target as i64 - 1)
|
||||
.query_async(con)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let page = redis_list_items_from_raw(raw, cur);
|
||||
let scanned = page.len();
|
||||
items.extend(page.into_iter().filter(|item| blob_matches_query(&item.value, &lowered)));
|
||||
if scanned == 0 {
|
||||
cur = len;
|
||||
break;
|
||||
}
|
||||
cur += scanned as u64;
|
||||
if items.len() >= target {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok((items, cur))
|
||||
}
|
||||
|
||||
async fn attach_hash_field_ttls<C>(con: &mut C, key: &[u8], items: &mut [RedisHashItem]) -> Result<(), String>
|
||||
where
|
||||
C: ConnectionLike + Send + Sync + Unpin,
|
||||
@@ -3697,14 +3824,12 @@ fn is_optional_hash_field_expiry_error(error: &redis::RedisError) -> bool {
|
||||
|| detail.contains("no permission")
|
||||
}
|
||||
|
||||
fn hash_entry_matches_query(item: &RedisHashItem, query: &str) -> bool {
|
||||
let query = query.to_lowercase();
|
||||
if query.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let field = redis_blob_display_text(&item.field);
|
||||
let value = redis_blob_display_text(&item.value);
|
||||
field.to_lowercase().contains(&query) || value.to_lowercase().contains(&query)
|
||||
fn blob_matches_query(blob: &RedisBlob, lowered_query: &str) -> bool {
|
||||
redis_blob_display_text(blob).to_lowercase().contains(lowered_query)
|
||||
}
|
||||
|
||||
fn hash_entry_matches_query(item: &RedisHashItem, lowered_query: &str) -> bool {
|
||||
blob_matches_query(&item.field, lowered_query) || blob_matches_query(&item.value, lowered_query)
|
||||
}
|
||||
|
||||
async fn sscan_page_raw<C>(
|
||||
@@ -3846,8 +3971,9 @@ mod tests {
|
||||
redis_key_matches_query, redis_key_raw_to_bytes, redis_key_value_preview, redis_sentinel_master_endpoint,
|
||||
redis_value_matches_query, redis_value_to_bytes, standalone_connection_infos, RedisAuthCandidate, RedisBlob,
|
||||
RedisBlobEncoding, RedisClusterSlotRange, RedisCollectionPage, RedisCommandSafety, RedisHashItem,
|
||||
RedisNodeEndpoint, RedisNodeRoute, RedisRawValue, RedisSetItem, RedisStreamConsumer, RedisStreamEntry,
|
||||
RedisStreamField, RedisStreamGroup, RedisStreamPendingEntry, RedisValue, RedisValueData, RedisZsetItem,
|
||||
RedisListItem, RedisNodeEndpoint, RedisNodeRoute, RedisRawValue, RedisSetItem, RedisStreamConsumer,
|
||||
RedisStreamEntry, RedisStreamField, RedisStreamGroup, RedisStreamPendingEntry, RedisValue, RedisValueData,
|
||||
RedisZsetItem,
|
||||
};
|
||||
use crate::models::connection::ConnectionConfig;
|
||||
use redis::{aio::ConnectionLike, Cmd, ConnectionAddr, Pipeline, RedisFuture};
|
||||
@@ -5521,6 +5647,208 @@ mod tests {
|
||||
assert!(!con.commands[0].contains("\r\nMATCH\r\n"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn filtered_set_load_more_matches_members() {
|
||||
let mut con = FakeRedisConnection::new(vec![scan_response("0", vec!["Ada Lovelace", "Bob"])]);
|
||||
|
||||
let result =
|
||||
super::load_more_collection(&mut con, b"set-key", "set", 0, 20, Some("lovelace"), None).await.unwrap();
|
||||
|
||||
let RedisCollectionPage::Set { items, scan_cursor } = result else {
|
||||
panic!("expected set collection page");
|
||||
};
|
||||
assert_eq!(scan_cursor, None);
|
||||
assert_eq!(items, vec![RedisSetItem { member: text_blob("Ada Lovelace") }]);
|
||||
assert_eq!(con.command_count("SSCAN"), 1);
|
||||
// Substring search cannot be expressed as an SSCAN glob, so no MATCH is sent.
|
||||
assert!(!con.commands[0].contains("\r\nMATCH\r\n"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn filtered_set_load_more_keeps_scanning_until_the_page_fills() {
|
||||
let mut con =
|
||||
FakeRedisConnection::new(vec![scan_response("512", vec!["Bob"]), scan_response("0", vec!["Ada Lovelace"])]);
|
||||
|
||||
let result =
|
||||
super::load_more_collection(&mut con, b"set-key", "set", 0, 20, Some("lovelace"), None).await.unwrap();
|
||||
|
||||
let RedisCollectionPage::Set { items, scan_cursor } = result else {
|
||||
panic!("expected set collection page");
|
||||
};
|
||||
assert_eq!(scan_cursor, None);
|
||||
assert_eq!(items, vec![RedisSetItem { member: text_blob("Ada Lovelace") }]);
|
||||
assert_eq!(con.command_count("SSCAN"), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn filtered_set_load_more_caps_sparse_scan_iterations() {
|
||||
let responses = (1..=super::COLLECTION_FILTER_SCAN_MAX_ITERATIONS + 1)
|
||||
.map(|cursor| scan_response(&cursor.to_string(), vec![]))
|
||||
.collect();
|
||||
let mut con = FakeRedisConnection::new(responses);
|
||||
|
||||
let result =
|
||||
super::load_more_collection(&mut con, b"set-key", "set", 0, 20, Some("missing"), None).await.unwrap();
|
||||
|
||||
let RedisCollectionPage::Set { items, scan_cursor } = result else {
|
||||
panic!("expected set collection page");
|
||||
};
|
||||
assert_eq!(scan_cursor, Some(super::COLLECTION_FILTER_SCAN_MAX_ITERATIONS as u64));
|
||||
assert!(items.is_empty());
|
||||
assert_eq!(con.command_count("SSCAN"), super::COLLECTION_FILTER_SCAN_MAX_ITERATIONS);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn filtered_zset_load_more_matches_members_and_keeps_score_order() {
|
||||
let mut con = FakeRedisConnection::new(vec![zrange_response(vec![
|
||||
("rank:alice", "1"),
|
||||
("bob", "2"),
|
||||
("rank:carol", "3"),
|
||||
])]);
|
||||
|
||||
let result =
|
||||
super::load_more_collection(&mut con, b"scores", "zset", 0, 20, Some("RANK:"), Some("asc")).await.unwrap();
|
||||
|
||||
let RedisCollectionPage::Zset { items, scan_cursor } = result else {
|
||||
panic!("expected zset collection page");
|
||||
};
|
||||
assert_eq!(scan_cursor, None);
|
||||
assert_eq!(
|
||||
items,
|
||||
vec![
|
||||
RedisZsetItem { score: "1".to_string(), member: text_blob("rank:alice") },
|
||||
RedisZsetItem { score: "3".to_string(), member: text_blob("rank:carol") },
|
||||
]
|
||||
);
|
||||
// Ranked paging is kept so the score sort and the index cursor survive filtering.
|
||||
assert_eq!(con.command_count("ZRANGE"), 1);
|
||||
assert_eq!(con.command_count("ZSCAN"), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn filtered_zset_load_more_honors_descending_pages() {
|
||||
let mut con = FakeRedisConnection::new(vec![zrange_response(vec![
|
||||
("rank:carol", "3"),
|
||||
("bob", "2"),
|
||||
("rank:alice", "1"),
|
||||
])]);
|
||||
|
||||
let result =
|
||||
super::load_more_collection(&mut con, b"scores", "zset", 0, 20, Some("rank:"), Some("desc")).await.unwrap();
|
||||
|
||||
let RedisCollectionPage::Zset { items, scan_cursor } = result else {
|
||||
panic!("expected zset collection page");
|
||||
};
|
||||
assert_eq!(scan_cursor, None);
|
||||
assert_eq!(
|
||||
items,
|
||||
vec![
|
||||
RedisZsetItem { score: "3".to_string(), member: text_blob("rank:carol") },
|
||||
RedisZsetItem { score: "1".to_string(), member: text_blob("rank:alice") },
|
||||
]
|
||||
);
|
||||
assert_eq!(con.command_count("ZREVRANGE"), 1);
|
||||
assert_eq!(con.command_count("ZRANGE"), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn filtered_zset_load_more_advances_the_cursor_past_every_scanned_member() {
|
||||
let mut con = FakeRedisConnection::new(vec![
|
||||
zrange_response(vec![("bob", "2"), ("dave", "4"), ("erin", "5")]),
|
||||
zrange_response(vec![("rank:alice", "1"), ("rank:carol", "3"), ("grace", "7")]),
|
||||
]);
|
||||
|
||||
let result =
|
||||
super::load_more_collection(&mut con, b"scores", "zset", 0, 2, Some("rank:"), Some("asc")).await.unwrap();
|
||||
|
||||
let RedisCollectionPage::Zset { items, scan_cursor } = result else {
|
||||
panic!("expected zset collection page");
|
||||
};
|
||||
assert_eq!(
|
||||
items,
|
||||
vec![
|
||||
RedisZsetItem { score: "1".to_string(), member: text_blob("rank:alice") },
|
||||
RedisZsetItem { score: "3".to_string(), member: text_blob("rank:carol") },
|
||||
]
|
||||
);
|
||||
// Two windows of two were consumed, so resuming must not replay the first one.
|
||||
assert_eq!(scan_cursor, Some(4));
|
||||
assert_eq!(con.command_count("ZRANGE"), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn filtered_list_load_more_preserves_original_indices() {
|
||||
let mut con = FakeRedisConnection::new(vec![
|
||||
RedisRawValue::Int(8),
|
||||
RedisRawValue::Array(vec![
|
||||
bulk("alpha"),
|
||||
bulk("beta"),
|
||||
bulk("gamma"),
|
||||
bulk("target-one"),
|
||||
bulk("delta"),
|
||||
bulk("epsilon"),
|
||||
bulk("zeta"),
|
||||
bulk("target-two"),
|
||||
]),
|
||||
]);
|
||||
|
||||
let result =
|
||||
super::load_more_collection(&mut con, b"list-key", "list", 0, 20, Some("target-"), None).await.unwrap();
|
||||
|
||||
let RedisCollectionPage::List { items, scan_cursor } = result else {
|
||||
panic!("expected list collection page");
|
||||
};
|
||||
assert_eq!(scan_cursor, None);
|
||||
// Indices must stay absolute: the viewer deletes list entries by index.
|
||||
assert_eq!(
|
||||
items,
|
||||
vec![
|
||||
RedisListItem { index: 3, value: text_blob("target-one") },
|
||||
RedisListItem { index: 7, value: text_blob("target-two") },
|
||||
]
|
||||
);
|
||||
assert_eq!(con.command_count("LRANGE"), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn filtered_list_load_more_still_walks_when_llen_is_not_permitted() {
|
||||
let mut con = FakeRedisConnection::with_results(vec![
|
||||
Err(noperm("llen")),
|
||||
Ok(RedisRawValue::Array(vec![bulk("target-one"), bulk("beta")])),
|
||||
Ok(RedisRawValue::Array(vec![])),
|
||||
]);
|
||||
|
||||
let result =
|
||||
super::load_more_collection(&mut con, b"list-key", "list", 0, 2, Some("target-"), None).await.unwrap();
|
||||
|
||||
let RedisCollectionPage::List { items, scan_cursor } = result else {
|
||||
panic!("expected list collection page");
|
||||
};
|
||||
assert_eq!(items, vec![RedisListItem { index: 0, value: text_blob("target-one") }]);
|
||||
assert_eq!(scan_cursor, None);
|
||||
assert_eq!(con.command_count("LRANGE"), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn filtered_list_load_more_stops_at_the_list_length() {
|
||||
let mut con = FakeRedisConnection::new(vec![
|
||||
RedisRawValue::Int(3),
|
||||
RedisRawValue::Array(vec![bulk("alpha"), bulk("beta")]),
|
||||
RedisRawValue::Array(vec![bulk("gamma")]),
|
||||
]);
|
||||
|
||||
let result =
|
||||
super::load_more_collection(&mut con, b"list-key", "list", 0, 2, Some("missing"), None).await.unwrap();
|
||||
|
||||
let RedisCollectionPage::List { items, scan_cursor } = result else {
|
||||
panic!("expected list collection page");
|
||||
};
|
||||
assert!(items.is_empty());
|
||||
assert_eq!(scan_cursor, None);
|
||||
assert_eq!(con.command_count("LRANGE"), 2);
|
||||
assert_eq!(con.command_count("LLEN"), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hash_load_more_attaches_field_ttl_when_supported() {
|
||||
let mut con = FakeRedisConnection::new(vec![
|
||||
@@ -5764,7 +6092,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn filtered_hash_load_more_caps_sparse_scan_iterations() {
|
||||
let responses = (1..=super::HASH_FILTER_SCAN_MAX_ITERATIONS + 1)
|
||||
let responses = (1..=super::COLLECTION_FILTER_SCAN_MAX_ITERATIONS + 1)
|
||||
.map(|cursor| hscan_response(&cursor.to_string(), vec![]))
|
||||
.collect();
|
||||
let mut con = FakeRedisConnection::new(responses);
|
||||
@@ -5775,9 +6103,9 @@ mod tests {
|
||||
let RedisCollectionPage::Hash { items, scan_cursor } = result else {
|
||||
panic!("expected hash collection page");
|
||||
};
|
||||
assert_eq!(scan_cursor, Some(super::HASH_FILTER_SCAN_MAX_ITERATIONS as u64));
|
||||
assert_eq!(scan_cursor, Some(super::COLLECTION_FILTER_SCAN_MAX_ITERATIONS as u64));
|
||||
assert!(items.is_empty());
|
||||
assert_eq!(con.command_count("HSCAN"), super::HASH_FILTER_SCAN_MAX_ITERATIONS);
|
||||
assert_eq!(con.command_count("HSCAN"), super::COLLECTION_FILTER_SCAN_MAX_ITERATIONS);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
+2
-2
@@ -66,8 +66,8 @@ test("content find is for STRING + RedisJSON + member detail; hash toolbar searc
|
||||
// STRING, RedisJSON, or member detail.
|
||||
assert.match(viewer, /showMemberDetail\.value \|\| isStringLikeKind\.value \|\| redisKind\.value === "json"/);
|
||||
assert.match(viewer, /function openValueSearch/);
|
||||
assert.match(viewer, /function onHashSearch/);
|
||||
assert.match(viewer, /v-model="hashSearchQuery"/);
|
||||
assert.match(viewer, /function onCollectionSearch/);
|
||||
assert.match(viewer, /v-model="collectionSearchQuery"/);
|
||||
assert.match(viewer, /data-redis-member-detail/);
|
||||
assert.match(viewer, /v-if="valueSearchOpen && showMemberDetail"/);
|
||||
assert.match(viewer, /ref="redisJsonEditorRef"/);
|
||||
|
||||
Reference in New Issue
Block a user