diff --git a/apps/desktop/src/lib/redis/redisQueryResult.spec.ts b/apps/desktop/src/lib/redis/redisQueryResult.spec.ts new file mode 100644 index 0000000000..31cf21a06f --- /dev/null +++ b/apps/desktop/src/lib/redis/redisQueryResult.spec.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { redisCommandResultToQueryResult } from "@/lib/redis/redisQueryResult"; + +describe("redisCommandResultToQueryResult", () => { + it("pairs member/score rows for ZREVRANGE ... WITHSCORES instead of one row per array element", () => { + const flat = ["carol", "300", "bob", "200", "alice", "100"]; + const result = redisCommandResultToQueryResult(flat, 5, "ZREVRANGE issue7229_repro:zset 0 -1 WITHSCORES"); + expect(result.columns).toEqual(["member", "score"]); + expect(result.rows).toEqual([ + ["carol", "300"], + ["bob", "200"], + ["alice", "100"], + ]); + expect(result.affected_rows).toBe(3); + }); + + it("pairs member/score rows for ZRANGE ... WITHSCORES (lowercase modifier)", () => { + const flat = ["alice", "1.5"]; + const result = redisCommandResultToQueryResult(flat, 5, "zrange myset 0 -1 withscores"); + expect(result.columns).toEqual(["member", "score"]); + expect(result.rows).toEqual([["alice", "1.5"]]); + }); + + it("does not pair a plain ZRANGE result (no WITHSCORES modifier)", () => { + const flat = ["alice", "bob"]; + const result = redisCommandResultToQueryResult(flat, 5, "ZRANGE myset 0 -1"); + expect(result.columns).toEqual(["(index)", "value"]); + expect(result.rows).toEqual([ + [1, "alice"], + [2, "bob"], + ]); + }); + + it("still pairs field/value rows for HGETALL when given the full raw command text (not just the bare command head)", () => { + const flat = ["name", "alice", "age", "30"]; + const result = redisCommandResultToQueryResult(flat, 5, "HGETALL myhash"); + expect(result.columns).toEqual(["field", "value"]); + expect(result.rows).toEqual([ + ["name", "alice"], + ["age", "30"], + ]); + }); + + it("does not pair when a non-sorted-set command merely contains WITHSCORES in a key", () => { + const flat = ["a", "b", "c"]; + const result = redisCommandResultToQueryResult(flat, 5, "LRANGE my:WITHSCORES:list 0 -1"); + expect(result.columns).toEqual(["(index)", "value"]); + expect(result.rows).toEqual([ + [1, "a"], + [2, "b"], + [3, "c"], + ]); + }); + + it("does not pair SMEMBERS when WITHSCORES appears as an argument", () => { + const flat = ["x", "y"]; + const result = redisCommandResultToQueryResult(flat, 5, "SMEMBERS WITHSCORES"); + expect(result.columns).toEqual(["(index)", "value"]); + expect(result.rows).toEqual([ + [1, "x"], + [2, "y"], + ]); + }); + + it("pairs member/score rows for ZRANGEBYSCORE ... WITHSCORES", () => { + const flat = ["alice", "1", "bob", "2"]; + const result = redisCommandResultToQueryResult(flat, 5, "ZRANGEBYSCORE myzset -inf +inf WITHSCORES"); + expect(result.columns).toEqual(["member", "score"]); + expect(result.rows).toEqual([ + ["alice", "1"], + ["bob", "2"], + ]); + }); +}); diff --git a/apps/desktop/src/lib/redis/redisQueryResult.ts b/apps/desktop/src/lib/redis/redisQueryResult.ts index 50bff260eb..39022755cf 100644 --- a/apps/desktop/src/lib/redis/redisQueryResult.ts +++ b/apps/desktop/src/lib/redis/redisQueryResult.ts @@ -2,9 +2,24 @@ import type { QueryResult } from "@/types/database"; import { formatRedisCommandResult } from "@/lib/redis/redisValuePresentation"; const KEY_VALUE_COMMANDS = new Set(["HGETALL"]); +// Sorted-set commands whose WITHSCORES modifier returns a flat member/score array. +const WITHSCORES_COMMANDS = new Set(["ZRANGE", "ZREVRANGE", "ZRANGEBYSCORE", "ZREVRANGEBYSCORE", "ZRANDMEMBER", "ZDIFF", "ZINTER", "ZUNION"]); +const WITHSCORES_MODIFIER = /\bWITHSCORES\b/i; + +function commandHead(command: string): string { + return command.trim().split(/\s+/, 1)[0]?.toUpperCase() ?? ""; +} function isKeyValueCommand(command: string): boolean { - return KEY_VALUE_COMMANDS.has(command.toUpperCase().trim()); + return KEY_VALUE_COMMANDS.has(commandHead(command)); +} + +// ZRANGE/ZREVRANGE/ZRANGEBYSCORE/ZDIFF/ZINTER/ZUNION/... with a WITHSCORES modifier +// return a flat [member1, score1, member2, score2, ...] array — pair it up instead of +// dumping each element as its own row. Gate on the command head so a key or argument +// that merely contains the token WITHSCORES cannot trigger pairing. +function hasWithScoresModifier(command: string): boolean { + return WITHSCORES_COMMANDS.has(commandHead(command)) && WITHSCORES_MODIFIER.test(command); } export function redisCommandResultToQueryResult(value: unknown, elapsedMs: number, command?: string): QueryResult { @@ -20,6 +35,18 @@ export function redisCommandResultToQueryResult(value: unknown, elapsedMs: numbe execution_time_ms: Math.max(0, Math.round(elapsedMs)), }; } + if (Array.isArray(value) && command && hasWithScoresModifier(command)) { + const rows: (string | number | boolean | null)[][] = []; + for (let i = 0; i + 1 < value.length; i += 2) { + rows.push([formatRedisCommandResult(value[i]), formatRedisCommandResult(value[i + 1])]); + } + return { + columns: ["member", "score"], + rows, + affected_rows: rows.length, + execution_time_ms: Math.max(0, Math.round(elapsedMs)), + }; + } // INFO commands in cluster mode → [[node_addr, info_text], ...] pairs. // Render as a two-column table with the addr as the index and info text as the value. if (Array.isArray(value) && value.length > 0 && value.every((item) => Array.isArray(item) && item.length === 2 && typeof item[0] === "string" && typeof item[1] === "string")) { diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index d50601b5ab..79fa337c5c 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -4652,7 +4652,7 @@ export const useQueryStore = defineStore("query", () => { const sourceRange = commandRange && options?.sourceOffset !== undefined ? { from: options.sourceOffset + commandRange.from, to: options.sourceOffset + commandRange.to } : undefined; try { const result = await api.redisExecuteCommand(executionConnectionId, currentDb, command, skipSafety); - allResults.push(markQueryResultRowsRaw(annotateQueryResultSource(redisCommandResultToQueryResult(result.value, performance.now() - startedAt, result.command), command, undefined, undefined, sourceRange))); + allResults.push(markQueryResultRowsRaw(annotateQueryResultSource(redisCommandResultToQueryResult(result.value, performance.now() - startedAt, command), command, undefined, undefined, sourceRange))); // Track db switches from SELECT N so later commands in the same batch run on the right db. currentDb = nextRedisCommandDb(currentDb, command, result.value); // Write commands (SET/DEL/...) mutate the key set — drop the cached key-name completion