Skip to content

Commit 34e526a

Browse files
authored
feat(client): add VRANGE command for vector sets (redis#3158)
Implement the VRANGE command which returns elements in a lexicographical range from a vector set. This provides a stateless iterator for elements inside a vector set. The command supports: - Lexicographical range with inclusive ([) and exclusive (() prefixes - Special range markers: - (minimum) and + (maximum) - Optional count parameter to limit results (negative returns all) Added comprehensive tests including parseCommand unit tests and integration tests for both RESP2 and RESP3 protocols.
1 parent 3faad10 commit 34e526a

File tree

3 files changed

+261
-0
lines changed

3 files changed

+261
-0
lines changed
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import { strict as assert } from 'node:assert';
2+
import testUtils, { GLOBAL } from '../test-utils';
3+
import VRANGE from './VRANGE';
4+
import { BasicCommandParser } from '../client/parser';
5+
6+
describe('VRANGE', () => {
7+
describe('parseCommand', () => {
8+
it('without count', () => {
9+
const parser = new BasicCommandParser();
10+
VRANGE.parseCommand(parser, 'key', '-', '+');
11+
assert.deepEqual(
12+
parser.redisArgs,
13+
['VRANGE', 'key', '-', '+']
14+
);
15+
});
16+
17+
it('with count', () => {
18+
const parser = new BasicCommandParser();
19+
VRANGE.parseCommand(parser, 'key', '-', '+', 10);
20+
assert.deepEqual(
21+
parser.redisArgs,
22+
['VRANGE', 'key', '-', '+', '10']
23+
);
24+
});
25+
26+
it('with inclusive start', () => {
27+
const parser = new BasicCommandParser();
28+
VRANGE.parseCommand(parser, 'key', '[abc', '+', 5);
29+
assert.deepEqual(
30+
parser.redisArgs,
31+
['VRANGE', 'key', '[abc', '+', '5']
32+
);
33+
});
34+
35+
it('with exclusive start', () => {
36+
const parser = new BasicCommandParser();
37+
VRANGE.parseCommand(parser, 'key', '(abc', '+', 5);
38+
assert.deepEqual(
39+
parser.redisArgs,
40+
['VRANGE', 'key', '(abc', '+', '5']
41+
);
42+
});
43+
44+
it('with negative count (return all)', () => {
45+
const parser = new BasicCommandParser();
46+
VRANGE.parseCommand(parser, 'key', '-', '+', -1);
47+
assert.deepEqual(
48+
parser.redisArgs,
49+
['VRANGE', 'key', '-', '+', '-1']
50+
);
51+
});
52+
});
53+
54+
describe('RESP2 tests', () => {
55+
testUtils.testAll('vRange - returns all elements with - +', async client => {
56+
await client.vAdd('key', [1.0, 2.0, 3.0], 'alpha');
57+
await client.vAdd('key', [4.0, 5.0, 6.0], 'beta');
58+
await client.vAdd('key', [7.0, 8.0, 9.0], 'gamma');
59+
60+
const result = await client.vRange('key', '-', '+');
61+
assert.ok(Array.isArray(result));
62+
assert.equal(result.length, 3);
63+
// Results should be in lexicographical order
64+
assert.deepEqual(result, ['alpha', 'beta', 'gamma']);
65+
}, {
66+
client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 4] },
67+
cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 4] }
68+
});
69+
70+
testUtils.testAll('vRange - with count limit', async client => {
71+
await client.vAdd('key', [1.0, 2.0, 3.0], 'alpha');
72+
await client.vAdd('key', [4.0, 5.0, 6.0], 'beta');
73+
await client.vAdd('key', [7.0, 8.0, 9.0], 'gamma');
74+
75+
const result = await client.vRange('key', '-', '+', 2);
76+
assert.ok(Array.isArray(result));
77+
assert.equal(result.length, 2);
78+
assert.deepEqual(result, ['alpha', 'beta']);
79+
}, {
80+
client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 4] },
81+
cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 4] }
82+
});
83+
84+
testUtils.testAll('vRange - with inclusive start', async client => {
85+
await client.vAdd('key', [1.0, 2.0, 3.0], 'alpha');
86+
await client.vAdd('key', [4.0, 5.0, 6.0], 'beta');
87+
await client.vAdd('key', [7.0, 8.0, 9.0], 'gamma');
88+
89+
const result = await client.vRange('key', '[beta', '+');
90+
assert.ok(Array.isArray(result));
91+
assert.equal(result.length, 2);
92+
assert.deepEqual(result, ['beta', 'gamma']);
93+
}, {
94+
client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 4] },
95+
cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 4] }
96+
});
97+
98+
testUtils.testAll('vRange - with exclusive start', async client => {
99+
await client.vAdd('key', [1.0, 2.0, 3.0], 'alpha');
100+
await client.vAdd('key', [4.0, 5.0, 6.0], 'beta');
101+
await client.vAdd('key', [7.0, 8.0, 9.0], 'gamma');
102+
103+
const result = await client.vRange('key', '(alpha', '+');
104+
assert.ok(Array.isArray(result));
105+
assert.equal(result.length, 2);
106+
assert.deepEqual(result, ['beta', 'gamma']);
107+
}, {
108+
client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 4] },
109+
cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 4] }
110+
});
111+
112+
testUtils.testAll('vRange - on non-existent key returns empty array', async client => {
113+
const result = await client.vRange('nonexistent', '-', '+');
114+
assert.ok(Array.isArray(result));
115+
assert.equal(result.length, 0);
116+
}, {
117+
client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 4] },
118+
cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 4] }
119+
});
120+
121+
testUtils.testAll('vRange - stateless iterator pattern', async client => {
122+
// VRANGE is a "stateless iterator" - unlike SCAN which uses a cursor,
123+
// VRANGE uses the last returned element as the starting point for the next call.
124+
// The client controls iteration state, not the server.
125+
await client.vAdd('key', [1.0, 2.0, 3.0], 'a1');
126+
await client.vAdd('key', [4.0, 5.0, 6.0], 'a2');
127+
await client.vAdd('key', [7.0, 8.0, 9.0], 'a3');
128+
await client.vAdd('key', [1.0, 1.0, 1.0], 'b1');
129+
await client.vAdd('key', [2.0, 2.0, 2.0], 'b2');
130+
131+
// First batch: start from minimum (-), get 2 elements
132+
const batch1 = await client.vRange('key', '-', '+', 2);
133+
assert.deepEqual(batch1, ['a1', 'a2']);
134+
135+
// Second batch: use last element with exclusive prefix '(' to continue
136+
// No cursor needed - the element name itself is the "cursor"
137+
const lastFromBatch1 = batch1[batch1.length - 1];
138+
const batch2 = await client.vRange('key', `(${lastFromBatch1}`, '+', 2);
139+
assert.deepEqual(batch2, ['a3', 'b1']);
140+
141+
// Third batch: continue from last element of batch2
142+
const lastFromBatch2 = batch2[batch2.length - 1];
143+
const batch3 = await client.vRange('key', `(${lastFromBatch2}`, '+', 2);
144+
assert.deepEqual(batch3, ['b2']);
145+
146+
// Verify we've seen all elements exactly once
147+
const allElements = [...batch1, ...batch2, ...batch3];
148+
assert.deepEqual(allElements, ['a1', 'a2', 'a3', 'b1', 'b2']);
149+
}, {
150+
client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 4] },
151+
cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 4] }
152+
});
153+
});
154+
155+
describe('RESP3 tests', () => {
156+
testUtils.testWithClient('vRange - returns all elements with - +', async client => {
157+
await client.vAdd('resp3-key', [1.0, 2.0, 3.0], 'alpha');
158+
await client.vAdd('resp3-key', [4.0, 5.0, 6.0], 'beta');
159+
await client.vAdd('resp3-key', [7.0, 8.0, 9.0], 'gamma');
160+
161+
const result = await client.vRange('resp3-key', '-', '+');
162+
assert.ok(Array.isArray(result));
163+
assert.equal(result.length, 3);
164+
assert.deepEqual(result, ['alpha', 'beta', 'gamma']);
165+
}, {
166+
...GLOBAL.SERVERS.OPEN,
167+
clientOptions: {
168+
RESP: 3
169+
},
170+
minimumDockerVersion: [8, 4]
171+
});
172+
173+
testUtils.testWithClient('vRange - with count limit', async client => {
174+
await client.vAdd('resp3-key', [1.0, 2.0, 3.0], 'alpha');
175+
await client.vAdd('resp3-key', [4.0, 5.0, 6.0], 'beta');
176+
await client.vAdd('resp3-key', [7.0, 8.0, 9.0], 'gamma');
177+
178+
const result = await client.vRange('resp3-key', '-', '+', 2);
179+
assert.ok(Array.isArray(result));
180+
assert.equal(result.length, 2);
181+
assert.deepEqual(result, ['alpha', 'beta']);
182+
}, {
183+
...GLOBAL.SERVERS.OPEN,
184+
clientOptions: {
185+
RESP: 3
186+
},
187+
minimumDockerVersion: [8, 4]
188+
});
189+
190+
testUtils.testWithClient('vRange - with exclusive start for pagination', async client => {
191+
await client.vAdd('resp3-key', [1.0, 2.0, 3.0], 'item1');
192+
await client.vAdd('resp3-key', [4.0, 5.0, 6.0], 'item2');
193+
await client.vAdd('resp3-key', [7.0, 8.0, 9.0], 'item3');
194+
195+
const result = await client.vRange('resp3-key', '(item1', '+');
196+
assert.ok(Array.isArray(result));
197+
assert.equal(result.length, 2);
198+
assert.deepEqual(result, ['item2', 'item3']);
199+
}, {
200+
...GLOBAL.SERVERS.OPEN,
201+
clientOptions: {
202+
RESP: 3
203+
},
204+
minimumDockerVersion: [8, 4]
205+
});
206+
207+
testUtils.testWithClient('vRange - on non-existent key returns empty array', async client => {
208+
const result = await client.vRange('resp3-nonexistent', '-', '+');
209+
assert.ok(Array.isArray(result));
210+
assert.equal(result.length, 0);
211+
}, {
212+
...GLOBAL.SERVERS.OPEN,
213+
clientOptions: {
214+
RESP: 3
215+
},
216+
minimumDockerVersion: [8, 4]
217+
});
218+
});
219+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { CommandParser } from '../client/parser';
2+
import { RedisArgument, ArrayReply, BlobStringReply, Command } from '../RESP/types';
3+
4+
export default {
5+
IS_READ_ONLY: true,
6+
/**
7+
* Returns elements in a lexicographical range from a vector set.
8+
* Provides a stateless iterator for elements inside a vector set.
9+
*
10+
* @param parser - The command parser
11+
* @param key - The key of the vector set
12+
* @param start - The starting point of the lexicographical range.
13+
* Can be a string prefixed with `[` for inclusive (e.g., `[Redis`),
14+
* `(` for exclusive (e.g., `(a7`), or `-` for the minimum element.
15+
* @param end - The ending point of the lexicographical range.
16+
* Can be a string prefixed with `[` for inclusive,
17+
* `(` for exclusive, or `+` for the maximum element.
18+
* @param count - Optional maximum number of elements to return.
19+
* If negative, returns all elements in the specified range.
20+
* @see https://redis.io/commands/vrange/
21+
*/
22+
parseCommand(
23+
parser: CommandParser,
24+
key: RedisArgument,
25+
start: RedisArgument,
26+
end: RedisArgument,
27+
count?: number
28+
) {
29+
parser.push('VRANGE');
30+
parser.pushKey(key);
31+
parser.push(start, end);
32+
33+
if (count !== undefined) {
34+
parser.push(count.toString());
35+
}
36+
},
37+
transformReply: undefined as unknown as () => ArrayReply<BlobStringReply>
38+
} as const satisfies Command;
39+

packages/client/lib/commands/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,7 @@ import VINFO from './VINFO';
360360
import VLINKS from './VLINKS';
361361
import VLINKS_WITHSCORES from './VLINKS_WITHSCORES';
362362
import VRANDMEMBER from './VRANDMEMBER';
363+
import VRANGE from './VRANGE';
363364
import VREM from './VREM';
364365
import VSETATTR from './VSETATTR';
365366
import VSIM from './VSIM';
@@ -1103,6 +1104,8 @@ export default {
11031104
vLinksWithScores: VLINKS_WITHSCORES,
11041105
VRANDMEMBER,
11051106
vRandMember: VRANDMEMBER,
1107+
VRANGE,
1108+
vRange: VRANGE,
11061109
VREM,
11071110
vRem: VREM,
11081111
VSETATTR,

0 commit comments

Comments
 (0)