feat(redis): add fetch-all keys button and incremental namespace tree merging
- Add "Fetch All" button alongside "Load More" to continuously SCAN until all keys loaded - Add stop button and scan progress display during fetch-all - Implement mergeKeysIntoRedisKeyTree for incremental namespace tree building - Add toast notifications for empty form validation in key detail panel - Add i18n translations for new UI elements (zh-CN, zh-TW, en, es) - Add unit tests for incremental tree merge covering 6 scenarios
This commit is contained in:
parent
e780e66e41
commit
b4a207fc6b
|
|
@ -36,6 +36,7 @@ import {
|
|||
collectExpandedGroupIds,
|
||||
collectRedisGroupKeyRaws,
|
||||
flattenVisibleRedisKeyTree,
|
||||
mergeKeysIntoRedisKeyTree,
|
||||
type RedisKeyTreeNode,
|
||||
} from "@/lib/redisKeyTree";
|
||||
import { classifyRedisCommandSafety } from "@/lib/redisCommandSafety";
|
||||
|
|
@ -43,9 +44,11 @@ import { isRedisClearScreenCommand, nextRedisCommandDb, redisKeyTextToRaw } from
|
|||
import { formatRedisCommandResult, formatRedisStringValue } from "@/lib/redisValuePresentation";
|
||||
import { isCancelSearchShortcut } from "@/lib/keyboardShortcuts";
|
||||
import { useEditorFontFamilyStyle } from "@/composables/useEditorFontFamilyStyle";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { redisKeySearchPattern } from "@/lib/redisKeyPattern";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const connectionStore = useConnectionStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const editorFontFamilyStyle = useEditorFontFamilyStyle();
|
||||
|
|
@ -70,6 +73,7 @@ const flatKeys = ref<RedisKeyInfo[]>([]);
|
|||
const treeKeys = ref<RedisKeyTreeNode[]>([]);
|
||||
const loading = ref(false);
|
||||
const loadingMore = ref(false);
|
||||
const isFetchingAll = ref(false);
|
||||
const rootRef = ref<HTMLElement>();
|
||||
const commandTerminalRef = ref<HTMLElement>();
|
||||
const searchPattern = ref("");
|
||||
|
|
@ -118,6 +122,14 @@ const searchPlaceholder = computed(() =>
|
|||
const loadingEmptyText = computed(() =>
|
||||
searchMode.value === "value" && valueQuery.value ? t("redis.searchingValues") : t("redis.loadingKeys"),
|
||||
);
|
||||
const lastTotalKeys = ref(0);
|
||||
const fetchAllProgressText = computed(() => {
|
||||
if (!isFetchingAll.value) return "";
|
||||
if (lastTotalKeys.value > 0) {
|
||||
return t("redis.fetchAllProgress", { loaded: flatKeys.value.length, total: lastTotalKeys.value });
|
||||
}
|
||||
return t("redis.fetchAllProgressUnknown", { loaded: flatKeys.value.length });
|
||||
});
|
||||
const selectedKey = computed(() => flatKeys.value.find((key) => key.key_raw === selectedKeyRaw.value) ?? null);
|
||||
const dangerDetails = computed(() => {
|
||||
if (!pendingDanger.value) return "";
|
||||
|
|
@ -174,6 +186,22 @@ function rebuildTree(expandAll = false) {
|
|||
}
|
||||
}
|
||||
|
||||
function mergeTree(newKeys: RedisKeyInfo[]) {
|
||||
if (newKeys.length === 0) return;
|
||||
treeKeys.value = mergeKeysIntoRedisKeyTree(treeKeys.value, newKeys, props.db);
|
||||
|
||||
const availableExpanded = collectExpandedGroupIds(treeKeys.value);
|
||||
const nextExpanded = new Set<string>();
|
||||
for (const id of expandedGroupIds.value) {
|
||||
if (availableExpanded.has(id)) nextExpanded.add(id);
|
||||
}
|
||||
expandedGroupIds.value = nextExpanded;
|
||||
|
||||
if (selectedKeyRaw.value && !flatKeys.value.some((key) => key.key_raw === selectedKeyRaw.value)) {
|
||||
selectedKeyRaw.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchScanPage(): Promise<RedisScanResult> {
|
||||
const pageSize = settingsStore.editorSettings.redisScanPageSize;
|
||||
return searchMode.value === "value"
|
||||
|
|
@ -183,10 +211,18 @@ async function fetchScanPage(): Promise<RedisScanResult> {
|
|||
|
||||
function appendScanResult(result: RedisScanResult) {
|
||||
const existingKeys = new Set(flatKeys.value.map((key) => key.key_raw));
|
||||
flatKeys.value = [...flatKeys.value, ...result.keys.filter((key) => !existingKeys.has(key.key_raw))];
|
||||
const newKeys = result.keys.filter((key) => !existingKeys.has(key.key_raw));
|
||||
flatKeys.value = [...flatKeys.value, ...newKeys];
|
||||
scanCursor.value = result.cursor;
|
||||
hasMore.value = result.cursor !== 0;
|
||||
rebuildTree(isSearchMode.value);
|
||||
lastTotalKeys.value = result.total_keys;
|
||||
|
||||
if (treeKeys.value.length === 0) {
|
||||
rebuildTree(isSearchMode.value);
|
||||
} else {
|
||||
mergeTree(newKeys);
|
||||
}
|
||||
|
||||
connectionStore.updateRedisDbKeyStats(props.connectionId, props.db, {
|
||||
loaded: isSearchMode.value ? undefined : flatKeys.value.length,
|
||||
total: result.total_keys,
|
||||
|
|
@ -229,6 +265,7 @@ async function fillInitialKeyBatch(requestId: number) {
|
|||
async function loadKeys() {
|
||||
if (!redisBrowserIsActive) return;
|
||||
const requestId = ++searchRequestId;
|
||||
isFetchingAll.value = false;
|
||||
loading.value = true;
|
||||
flatKeys.value = [];
|
||||
treeKeys.value = [];
|
||||
|
|
@ -267,6 +304,26 @@ async function loadMore() {
|
|||
}
|
||||
}
|
||||
|
||||
async function fetchAll() {
|
||||
if (!hasMore.value || isFetchingAll.value) return;
|
||||
const requestId = searchRequestId;
|
||||
isFetchingAll.value = true;
|
||||
try {
|
||||
while (requestId === searchRequestId && isFetchingAll.value && hasMore.value) {
|
||||
const applied = await scanNextPage(requestId);
|
||||
if (!applied) break;
|
||||
}
|
||||
} finally {
|
||||
if (requestId === searchRequestId) {
|
||||
isFetchingAll.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stopFetchAll() {
|
||||
isFetchingAll.value = false;
|
||||
}
|
||||
|
||||
function toggleGroup(groupId: string) {
|
||||
const next = new Set(expandedGroupIds.value);
|
||||
if (next.has(groupId)) next.delete(groupId);
|
||||
|
|
@ -438,15 +495,18 @@ async function createRedisKey() {
|
|||
const keyName = createKeyName.value.trim();
|
||||
if (!keyName) {
|
||||
createKeyError.value = t("redis.createKeyNameRequired");
|
||||
toast(t("redis.createKeyNameRequired"), 3000);
|
||||
return;
|
||||
}
|
||||
if (createKeyType.value === "hash" && !createKeyField.value.trim()) {
|
||||
createKeyError.value = t("redis.createFieldRequired");
|
||||
toast(t("redis.createFieldRequired"), 3000);
|
||||
return;
|
||||
}
|
||||
const score = Number.parseFloat(createKeyScore.value || "0");
|
||||
if (createKeyType.value === "zset" && Number.isNaN(score)) {
|
||||
createKeyError.value = t("redis.createScoreInvalid");
|
||||
toast(t("redis.createScoreInvalid"), 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -612,6 +672,7 @@ function unregisterRedisDbFlushedListener() {
|
|||
function pauseRedisBrowserBackgroundWork() {
|
||||
redisBrowserIsActive = false;
|
||||
searchRequestId++;
|
||||
isFetchingAll.value = false;
|
||||
loading.value = false;
|
||||
loadingMore.value = false;
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
|
|
@ -813,17 +874,34 @@ defineExpose({ focusSearch });
|
|||
</div>
|
||||
</template>
|
||||
</RecycleScroller>
|
||||
<div v-if="hasMore" class="shrink-0 border-t px-2 py-1.5 flex items-center justify-center">
|
||||
<div v-if="hasMore && !isFetchingAll" class="shrink-0 border-t px-2 py-1.5 flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs w-full"
|
||||
class="h-7 text-xs flex-1"
|
||||
:disabled="loadingMore || loading"
|
||||
@click="loadMore"
|
||||
>
|
||||
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
|
||||
{{ t("redis.loadMoreKeys") }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs flex-1"
|
||||
:disabled="loading || !hasMore"
|
||||
@click="fetchAll"
|
||||
>
|
||||
{{ t("redis.fetchAllKeys") }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="isFetchingAll" class="shrink-0 border-t px-2 py-1.5 space-y-1">
|
||||
<div class="text-xs text-muted-foreground text-center">
|
||||
{{ fetchAllProgressText }}
|
||||
</div>
|
||||
<Button variant="destructive" size="sm" class="h-7 text-xs w-full" @click="stopFetchAll">
|
||||
{{ t("redis.stopFetchAll") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Pane>
|
||||
|
|
|
|||
|
|
@ -594,7 +594,10 @@ function startEditTtl() {
|
|||
async function saveTtl() {
|
||||
const val = ttlInput.value.trim();
|
||||
const ttl = val === "" || val === "-1" ? -1 : parseInt(val, 10);
|
||||
if (isNaN(ttl)) return;
|
||||
if (isNaN(ttl)) {
|
||||
toast(t("redis.ttlInvalid"), 3000);
|
||||
return;
|
||||
}
|
||||
await api.redisSetTtl(props.connectionId, props.db, props.keyRaw, ttl);
|
||||
editingTtl.value = false;
|
||||
await load();
|
||||
|
|
@ -606,7 +609,10 @@ function cancelEditTtl() {
|
|||
|
||||
// Hash
|
||||
async function hashSet() {
|
||||
if (!newField.value) return;
|
||||
if (!newField.value.trim()) {
|
||||
toast(t("redis.fieldRequired"), 3000);
|
||||
return;
|
||||
}
|
||||
await api.redisHashSet(props.connectionId, props.db, props.keyRaw, newField.value, newValue.value);
|
||||
newField.value = "";
|
||||
newValue.value = "";
|
||||
|
|
@ -623,7 +629,10 @@ function requestHashDel(field: string) {
|
|||
|
||||
// List
|
||||
async function listPush() {
|
||||
if (!newValue.value) return;
|
||||
if (!newValue.value.trim()) {
|
||||
toast(t("redis.valueRequired"), 3000);
|
||||
return;
|
||||
}
|
||||
await api.redisListPush(props.connectionId, props.db, props.keyRaw, newValue.value);
|
||||
newValue.value = "";
|
||||
await load();
|
||||
|
|
@ -639,7 +648,10 @@ function requestListRemove(index: number) {
|
|||
|
||||
// Set
|
||||
async function setAdd() {
|
||||
if (!newValue.value) return;
|
||||
if (!newValue.value.trim()) {
|
||||
toast(t("redis.memberRequired"), 3000);
|
||||
return;
|
||||
}
|
||||
await api.redisSetAdd(props.connectionId, props.db, props.keyRaw, newValue.value);
|
||||
newValue.value = "";
|
||||
await load();
|
||||
|
|
@ -655,7 +667,10 @@ function requestSetRemove(member: string) {
|
|||
|
||||
// ZSet
|
||||
async function zsetAdd() {
|
||||
if (!newValue.value) return;
|
||||
if (!newValue.value.trim()) {
|
||||
toast(t("redis.memberRequired"), 3000);
|
||||
return;
|
||||
}
|
||||
const score = parseFloat(newScore.value || "0");
|
||||
await api.redisZadd(props.connectionId, props.db, props.keyRaw, newValue.value, score);
|
||||
newValue.value = "";
|
||||
|
|
|
|||
|
|
@ -1243,6 +1243,10 @@ export default {
|
|||
loadingKeys: "Loading keys...",
|
||||
searchingValues: "Searching values...",
|
||||
loadMoreKeys: "Load more keys",
|
||||
fetchAllKeys: "Fetch all",
|
||||
stopFetchAll: "Stop",
|
||||
fetchAllProgress: "{loaded} of {total} keys loaded",
|
||||
fetchAllProgressUnknown: "{loaded} keys loaded",
|
||||
items: "{count} items",
|
||||
fields: "{count} fields",
|
||||
members: "{count} members",
|
||||
|
|
@ -1291,6 +1295,10 @@ export default {
|
|||
createKeyNameRequired: "Enter a key name",
|
||||
createFieldRequired: "Enter a hash field",
|
||||
createScoreInvalid: "Enter a valid score",
|
||||
fieldRequired: "Enter a field name",
|
||||
valueRequired: "Enter a value",
|
||||
memberRequired: "Enter a member",
|
||||
ttlInvalid: "Enter a valid TTL in seconds (-1 for no expiry)",
|
||||
member: "Member",
|
||||
memberDetail: "Member detail",
|
||||
viewMember: "View full value",
|
||||
|
|
|
|||
|
|
@ -1135,6 +1135,10 @@ export default {
|
|||
loadingKeys: "Cargando claves...",
|
||||
searchingValues: "Buscando por valor...",
|
||||
loadMoreKeys: "Cargar más claves",
|
||||
fetchAllKeys: "Cargar todas",
|
||||
stopFetchAll: "Detener",
|
||||
fetchAllProgress: "{loaded} de {total} claves cargadas",
|
||||
fetchAllProgressUnknown: "{loaded} claves cargadas",
|
||||
items: "{count} elementos",
|
||||
fields: "{count} campos",
|
||||
members: "{count} miembros",
|
||||
|
|
@ -1184,6 +1188,10 @@ export default {
|
|||
createKeyNameRequired: "Ingresa una clave",
|
||||
createFieldRequired: "Ingresa un campo hash",
|
||||
createScoreInvalid: "Ingresa una puntuación válida",
|
||||
fieldRequired: "Ingresa un nombre de campo",
|
||||
valueRequired: "Ingresa un valor",
|
||||
memberRequired: "Ingresa un miembro",
|
||||
ttlInvalid: "Ingresa un TTL válido en segundos (-1 para sin caducidad)",
|
||||
member: "Miembro",
|
||||
memberDetail: "Detalle del miembro",
|
||||
viewMember: "Ver valor completo",
|
||||
|
|
|
|||
|
|
@ -1219,6 +1219,10 @@ export default {
|
|||
loadingKeys: "正在加载 key...",
|
||||
searchingValues: "正在按值搜索...",
|
||||
loadMoreKeys: "加载更多",
|
||||
fetchAllKeys: "获取全部",
|
||||
stopFetchAll: "停止",
|
||||
fetchAllProgress: "已加载 {loaded} / 共 {total} 条 key",
|
||||
fetchAllProgressUnknown: "已加载 {loaded} 条 key",
|
||||
items: "{count} 个元素",
|
||||
fields: "{count} 个字段",
|
||||
members: "{count} 个成员",
|
||||
|
|
@ -1266,6 +1270,10 @@ export default {
|
|||
createKeyNameRequired: "请输入 key 名称",
|
||||
createFieldRequired: "请输入 hash 字段",
|
||||
createScoreInvalid: "请输入有效分数",
|
||||
fieldRequired: "请输入字段名",
|
||||
valueRequired: "请输入值",
|
||||
memberRequired: "请输入成员",
|
||||
ttlInvalid: "请输入有效的 TTL(秒数,-1 表示永不过期)",
|
||||
member: "成员",
|
||||
memberDetail: "成员详情",
|
||||
viewMember: "查看完整内容",
|
||||
|
|
|
|||
|
|
@ -1199,6 +1199,10 @@ export default {
|
|||
loadingKeys: "正在載入 key……",
|
||||
searchingValues: "正在按值搜尋……",
|
||||
loadMoreKeys: "載入更多",
|
||||
fetchAllKeys: "取得全部",
|
||||
stopFetchAll: "停止",
|
||||
fetchAllProgress: "已載入 {loaded} / 共 {total} 條 key",
|
||||
fetchAllProgressUnknown: "已載入 {loaded} 條 key",
|
||||
items: "{count} 個元素",
|
||||
fields: "{count} 個欄位",
|
||||
members: "{count} 個成員",
|
||||
|
|
@ -1246,6 +1250,10 @@ export default {
|
|||
createKeyNameRequired: "請輸入 key 名稱",
|
||||
createFieldRequired: "請輸入 hash 欄位",
|
||||
createScoreInvalid: "請輸入有效分數",
|
||||
fieldRequired: "請輸入欄位名稱",
|
||||
valueRequired: "請輸入值",
|
||||
memberRequired: "請輸入成員",
|
||||
ttlInvalid: "請輸入有效的 TTL(秒數,-1 表示永不過期)",
|
||||
member: "成員",
|
||||
memberDetail: "成員詳情",
|
||||
viewMember: "檢視完整內容",
|
||||
|
|
|
|||
|
|
@ -58,48 +58,24 @@ export function buildRedisKeyTree(keys: RedisKeyInfo[], db: number): RedisKeyTre
|
|||
const groupMap = new Map<string, RedisKeyTreeGroupNode>();
|
||||
|
||||
for (const key of keys) {
|
||||
const pathSegments = key.key_display.split(":");
|
||||
if (pathSegments.length === 1) {
|
||||
root.push({
|
||||
kind: "leaf",
|
||||
id: buildLeafId(db, key.key_raw),
|
||||
label: pathSegments[0],
|
||||
fullKeyDisplay: key.key_display,
|
||||
keyRaw: key.key_raw,
|
||||
db,
|
||||
keyType: key.key_type,
|
||||
ttl: key.ttl,
|
||||
size: key.size,
|
||||
valuePreview: key.value_preview,
|
||||
pathSegments,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
insertKeyIntoTree(root, groupMap, key, db);
|
||||
}
|
||||
|
||||
let currentLevel = root;
|
||||
const groupSegments: string[] = [];
|
||||
for (const segment of pathSegments.slice(0, -1)) {
|
||||
groupSegments.push(segment);
|
||||
const groupId = buildGroupId(db, groupSegments);
|
||||
let group = groupMap.get(groupId);
|
||||
if (!group) {
|
||||
group = {
|
||||
kind: "group",
|
||||
id: groupId,
|
||||
label: segment,
|
||||
pathSegments: [...groupSegments],
|
||||
children: [],
|
||||
};
|
||||
groupMap.set(groupId, group);
|
||||
currentLevel.push(group);
|
||||
}
|
||||
currentLevel = group.children;
|
||||
}
|
||||
return sortRedisTreeNodes(root);
|
||||
}
|
||||
|
||||
currentLevel.push({
|
||||
function insertKeyIntoTree(
|
||||
root: RedisKeyTreeNode[],
|
||||
groupMap: Map<string, RedisKeyTreeGroupNode>,
|
||||
key: RedisKeyInfo,
|
||||
db: number,
|
||||
): void {
|
||||
const pathSegments = key.key_display.split(":");
|
||||
if (pathSegments.length === 1) {
|
||||
root.push({
|
||||
kind: "leaf",
|
||||
id: buildLeafId(db, key.key_raw),
|
||||
label: pathSegments[pathSegments.length - 1],
|
||||
label: pathSegments[0],
|
||||
fullKeyDisplay: key.key_display,
|
||||
keyRaw: key.key_raw,
|
||||
db,
|
||||
|
|
@ -109,9 +85,86 @@ export function buildRedisKeyTree(keys: RedisKeyInfo[], db: number): RedisKeyTre
|
|||
valuePreview: key.value_preview,
|
||||
pathSegments,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
return sortRedisTreeNodes(root);
|
||||
let currentLevel = root;
|
||||
const groupSegments: string[] = [];
|
||||
for (const segment of pathSegments.slice(0, -1)) {
|
||||
groupSegments.push(segment);
|
||||
const groupId = buildGroupId(db, groupSegments);
|
||||
let group = groupMap.get(groupId);
|
||||
if (!group) {
|
||||
group = {
|
||||
kind: "group",
|
||||
id: groupId,
|
||||
label: segment,
|
||||
pathSegments: [...groupSegments],
|
||||
children: [],
|
||||
};
|
||||
groupMap.set(groupId, group);
|
||||
currentLevel.push(group);
|
||||
}
|
||||
currentLevel = group.children;
|
||||
}
|
||||
|
||||
currentLevel.push({
|
||||
kind: "leaf",
|
||||
id: buildLeafId(db, key.key_raw),
|
||||
label: pathSegments[pathSegments.length - 1],
|
||||
fullKeyDisplay: key.key_display,
|
||||
keyRaw: key.key_raw,
|
||||
db,
|
||||
keyType: key.key_type,
|
||||
ttl: key.ttl,
|
||||
size: key.size,
|
||||
valuePreview: key.value_preview,
|
||||
pathSegments,
|
||||
});
|
||||
}
|
||||
|
||||
function rebuildGroupMap(tree: RedisKeyTreeNode[]): Map<string, RedisKeyTreeGroupNode> {
|
||||
const groupMap = new Map<string, RedisKeyTreeGroupNode>();
|
||||
|
||||
const walk = (nodes: RedisKeyTreeNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if (node.kind === "group") {
|
||||
groupMap.set(node.id, node);
|
||||
walk(node.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(tree);
|
||||
return groupMap;
|
||||
}
|
||||
|
||||
export function mergeKeysIntoRedisKeyTree(
|
||||
existingTree: RedisKeyTreeNode[],
|
||||
newKeys: RedisKeyInfo[],
|
||||
db: number,
|
||||
): RedisKeyTreeNode[] {
|
||||
if (existingTree.length === 0) return buildRedisKeyTree(newKeys, db);
|
||||
|
||||
const groupMap = rebuildGroupMap(existingTree);
|
||||
const existingKeyIds = new Set<string>();
|
||||
const collectKeys = (nodes: RedisKeyTreeNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if (node.kind === "leaf") {
|
||||
existingKeyIds.add(node.keyRaw);
|
||||
} else {
|
||||
collectKeys(node.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
collectKeys(existingTree);
|
||||
|
||||
for (const key of newKeys) {
|
||||
if (existingKeyIds.has(key.key_raw)) continue;
|
||||
insertKeyIntoTree(existingTree, groupMap, key, db);
|
||||
}
|
||||
|
||||
return sortRedisTreeNodes(existingTree);
|
||||
}
|
||||
|
||||
export function collectExpandedGroupIds(nodes: RedisKeyTreeNode[]): Set<string> {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
collectRedisGroupKeyRaws,
|
||||
collectExpandedGroupIds,
|
||||
flattenVisibleRedisKeyTree,
|
||||
mergeKeysIntoRedisKeyTree,
|
||||
type RedisKeyTreeNode,
|
||||
} from "../../apps/desktop/src/lib/redisKeyTree.ts";
|
||||
import type { RedisKeyInfo } from "../../apps/desktop/src/lib/api.ts";
|
||||
|
|
@ -94,3 +95,84 @@ test("collectRedisGroupKeyRaws returns every leaf key under a group", () => {
|
|||
|
||||
assert.deepEqual(collectRedisGroupKeyRaws(userGroup), ["k2", "k1", "k3"]);
|
||||
});
|
||||
|
||||
test("mergeKeysIntoRedisKeyTree adds new leaf to existing group", () => {
|
||||
const tree = buildRedisKeyTree([makeKey("user:profile:name", "k1")], 0);
|
||||
const merged = mergeKeysIntoRedisKeyTree(tree, [makeKey("user:profile:email", "k2")], 0);
|
||||
|
||||
const userGroup = merged.find((node) => node.kind === "group" && node.label === "user");
|
||||
assert.ok(userGroup);
|
||||
if (!userGroup || userGroup.kind !== "group") return;
|
||||
|
||||
const profileGroup = userGroup.children.find((node) => node.kind === "group" && node.label === "profile");
|
||||
assert.ok(profileGroup);
|
||||
if (!profileGroup || profileGroup.kind !== "group") return;
|
||||
|
||||
assert.equal(profileGroup.children.length, 2);
|
||||
const labels = profileGroup.children.map((node) => (node.kind === "leaf" ? node.label : ""));
|
||||
assert.deepEqual(labels, ["email", "name"]);
|
||||
});
|
||||
|
||||
test("mergeKeysIntoRedisKeyTree creates new group for new prefix", () => {
|
||||
const tree = buildRedisKeyTree([makeKey("user:profile:name", "k1")], 0);
|
||||
const merged = mergeKeysIntoRedisKeyTree(tree, [makeKey("session:1", "k2")], 0);
|
||||
|
||||
assert.equal(merged.length, 2);
|
||||
const labels = merged.map((node) => node.label);
|
||||
assert.deepEqual(labels, ["session", "user"]);
|
||||
});
|
||||
|
||||
test("mergeKeysIntoRedisKeyTree handles root-level keys", () => {
|
||||
const tree = buildRedisKeyTree([makeKey("user:profile:name", "k1")], 0);
|
||||
const merged = mergeKeysIntoRedisKeyTree(tree, [makeKey("standalone", "k2")], 0);
|
||||
|
||||
assert.equal(merged.length, 2);
|
||||
const rootLeaf = merged.find((node) => node.kind === "leaf");
|
||||
assert.ok(rootLeaf);
|
||||
if (!rootLeaf || rootLeaf.kind !== "leaf") return;
|
||||
assert.equal(rootLeaf.label, "standalone");
|
||||
});
|
||||
|
||||
test("mergeKeysIntoRedisKeyTree returns same result as full build", () => {
|
||||
const batch1 = [makeKey("a:b:c", "k1"), makeKey("a:d", "k2")];
|
||||
const batch2 = [makeKey("a:b:e", "k3"), makeKey("x", "k4")];
|
||||
|
||||
const tree = buildRedisKeyTree(batch1, 0);
|
||||
const merged = mergeKeysIntoRedisKeyTree(tree, batch2, 0);
|
||||
const full = buildRedisKeyTree([...batch1, ...batch2], 0);
|
||||
|
||||
const toStr = (nodes: RedisKeyTreeNode[]): string =>
|
||||
JSON.stringify(
|
||||
nodes.map((node) => {
|
||||
if (node.kind === "leaf") return { l: node.label, id: node.id };
|
||||
return { g: node.label, id: node.id, c: toStr(node.children) };
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(toStr(merged), toStr(full));
|
||||
});
|
||||
|
||||
test("mergeKeysIntoRedisKeyTree skips duplicate keys", () => {
|
||||
const tree = buildRedisKeyTree([makeKey("user:profile:name", "k1")], 0);
|
||||
const merged = mergeKeysIntoRedisKeyTree(tree, [makeKey("user:profile:name", "k1")], 0);
|
||||
|
||||
const userGroup = merged[0];
|
||||
assert.ok(userGroup && userGroup.kind === "group");
|
||||
if (!userGroup || userGroup.kind !== "group") return;
|
||||
|
||||
const profileGroup = userGroup.children[0];
|
||||
assert.ok(profileGroup && profileGroup.kind === "group");
|
||||
if (!profileGroup || profileGroup.kind !== "group") return;
|
||||
|
||||
assert.equal(profileGroup.children.length, 1);
|
||||
});
|
||||
|
||||
test("mergeKeysIntoRedisKeyTree into empty tree falls back to full build", () => {
|
||||
const merged = mergeKeysIntoRedisKeyTree([], [makeKey("a:b:c", "k1"), makeKey("x", "k2")], 0);
|
||||
const full = buildRedisKeyTree([makeKey("a:b:c", "k1"), makeKey("x", "k2")], 0);
|
||||
|
||||
const toStr = (nodes: RedisKeyTreeNode[]): string =>
|
||||
JSON.stringify(nodes.map((node) => node.label));
|
||||
|
||||
assert.equal(toStr(merged), toStr(full));
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue