Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Node: Add ZSCAN command #2061

Merged
merged 9 commits into from
Aug 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
* Node: Added PFMERGE command ([#2053](https://github.com/valkey-io/valkey-glide/pull/2053))
* Node: Added ZLEXCOUNT command ([#2022](https://github.com/valkey-io/valkey-glide/pull/2022))
* Node: Added ZREMRANGEBYLEX command ([#2025]((https://github.com/valkey-io/valkey-glide/pull/2025))
* Node: Added ZSCAN command ([#2061](https://github.com/valkey-io/valkey-glide/pull/2061))

#### Breaking Changes
* Node: (Refactor) Convert classes to types ([#2005](https://github.com/valkey-io/valkey-glide/pull/2005))
Expand Down
2 changes: 2 additions & 0 deletions node/npm/glide/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ function loadNativeBinding() {
function initialize() {
const nativeBinding = loadNativeBinding();
const {
BaseScanOptions,
BitEncoding,
BitFieldGet,
BitFieldIncrBy,
Expand Down Expand Up @@ -161,6 +162,7 @@ function initialize() {
} = nativeBinding;

module.exports = {
BaseScanOptions,
BitEncoding,
BitFieldGet,
BitFieldIncrBy,
Expand Down
49 changes: 49 additions & 0 deletions node/src/BaseClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import * as net from "net";
import { Buffer, BufferWriter, Reader, Writer } from "protobufjs";
import {
AggregationType,
BaseScanOptions,
BitFieldGet,
BitFieldIncrBy, // eslint-disable-line @typescript-eslint/no-unused-vars
BitFieldOverflow, // eslint-disable-line @typescript-eslint/no-unused-vars
Expand Down Expand Up @@ -168,6 +169,7 @@ import {
createZRevRank,
createZRevRankWithScore,
createZScore,
createZScan,
} from "./Commands";
import {
ClosingError,
Expand Down Expand Up @@ -4236,6 +4238,53 @@ export class BaseClient {
return this.createWritePromise(createZIncrBy(key, increment, member));
}

/**
* Iterates incrementally over a sorted set.
*
* See https://valkey.io/commands/zscan for more details.
*
* @param key - The key of the sorted set.
* @param cursor - The cursor that points to the next iteration of results. A value of `"0"` indicates the start of
* the search.
* @param options - (Optional) The zscan options.
* @returns An `Array` of the `cursor` and the subset of the sorted set held by `key`.
* The first element is always the `cursor` for the next iteration of results. `0` will be the `cursor`
* returned on the last iteration of the sorted set. The second element is always an `Array` of the subset
* of the sorted set held in `key`. The `Array` in the second element is always a flattened series of
* `String` pairs, where the value is at even indices and the score is at odd indices.
*
* @example
* ```typescript
* // Assume "key" contains a sorted set with multiple members
* let newCursor = "0";
* let result = [];
*
* do {
* result = await client.zscan(key1, newCursor, {
* match: "*",
* count: 5,
* });
* newCursor = result[0];
* console.log("Cursor: ", newCursor);
* console.log("Members: ", result[1]);
* } while (newCursor !== "0");
* // The output of the code above is something similar to:
* // Cursor: 123
* // Members: ['value 163', '163', 'value 114', '114', 'value 25', '25', 'value 82', '82', 'value 64', '64']
* // Cursor: 47
* // Members: ['value 39', '39', 'value 127', '127', 'value 43', '43', 'value 139', '139', 'value 211', '211']
* // Cursor: 0
* // Members: ['value 55', '55', 'value 24', '24', 'value 90', '90', 'value 113', '113']
* ```
*/
public async zscan(
key: string,
cursor: string,
options?: BaseScanOptions,
): Promise<[string, string[]]> {
return this.createWritePromise(createZScan(key, cursor, options));
}

/**
* Returns the distance between `member1` and `member2` saved in the geospatial index stored at `key`.
*
Expand Down
45 changes: 45 additions & 0 deletions node/src/Commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2999,3 +2999,48 @@ export function createTouch(keys: string[]): command_request.Command {
export function createRandomKey(): command_request.Command {
return createCommand(RequestType.RandomKey, []);
}

/**
* This base class represents the common set of optional arguments for the SCAN family of commands.
* Concrete implementations of this class are tied to specific SCAN commands (SCAN, HSCAN, SSCAN,
* and ZSCAN).
*/
export type BaseScanOptions = {
/**
* The match filter is applied to the result of the command and will only include
* strings that match the pattern specified. If the sorted set is large enough for scan commands to return
* only a subset of the sorted set then there could be a case where the result is empty although there are
* items that match the pattern specified. This is due to the default `COUNT` being `10` which indicates
* that it will only fetch and match `10` items from the list.
*/
readonly match?: string;
/**
* `COUNT` is a just a hint for the command for how many elements to fetch from the
* sorted set. `COUNT` could be ignored until the sorted set is large enough for the `SCAN` commands to
* represent the results as compact single-allocation packed encoding.
*/
readonly count?: number;
};

/**
* @internal
*/
export function createZScan(
key: string,
cursor: string,
options?: BaseScanOptions,
): command_request.Command {
let args: string[] = [key, cursor];

if (options) {
if (options.match) {
args = args.concat("MATCH", options.match);
}

if (options.count !== undefined) {
args = args.concat("COUNT", options.count.toString());
}
}

return createCommand(RequestType.ZScan, args);
}
22 changes: 22 additions & 0 deletions node/src/Transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {

import {
AggregationType,
BaseScanOptions,
BitFieldGet,
BitFieldIncrBy, // eslint-disable-line @typescript-eslint/no-unused-vars
BitFieldOverflow, // eslint-disable-line @typescript-eslint/no-unused-vars
Expand Down Expand Up @@ -197,6 +198,7 @@ import {
createZRemRangeByScore,
createZRevRank,
createZRevRankWithScore,
createZScan,
createZScore,
} from "./Commands";
import { command_request } from "./ProtobufMessage";
Expand Down Expand Up @@ -2537,6 +2539,26 @@ export class BaseTransaction<T extends BaseTransaction<T>> {
return this.addAndReturn(createZIncrBy(key, increment, member));
}

/**
* Iterates incrementally over a sorted set.
*
* See https://valkey.io/commands/zscan for more details.
*
* @param key - The key of the sorted set.
* @param cursor - The cursor that points to the next iteration of results. A value of `"0"` indicates the start of
* the search.
* @param options - (Optional) The zscan options.
*
* Command Response - An `Array` of the `cursor` and the subset of the sorted set held by `key`.
* The first element is always the `cursor` for the next iteration of results. `0` will be the `cursor`
* returned on the last iteration of the sorted set. The second element is always an `Array` of the subset
* of the sorted set held in `key`. The `Array` in the second element is always a flattened series of
* `String` pairs, where the value is at even indices and the score is at odd indices.
*/
public zscan(key: string, cursor: string, options?: BaseScanOptions): T {
return this.addAndReturn(createZScan(key, cursor, options));
}

/**
* Returns the distance between `member1` and `member2` saved in the geospatial index stored at `key`.
*
Expand Down
148 changes: 148 additions & 0 deletions node/tests/SharedTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5898,6 +5898,154 @@ export function runBaseTests<Context>(config: {
config.timeout,
);

it.each([ProtocolVersion.RESP2, ProtocolVersion.RESP3])(
`zscan test_%p`,
async (protocol) => {
await runTest(async (client: BaseClient) => {
const key1 = "{key}-1" + uuidv4();
const key2 = "{key}-2" + uuidv4();
const initialCursor = "0";
const defaultCount = 20;
const resultCursorIndex = 0;
const resultCollectionIndex = 1;

// Setup test data - use a large number of entries to force an iterative cursor.
const numberMap: Record<string, number> = {};
const expectedNumberMapArray: string[] = [];

for (let i = 0; i < 10000; i++) {
expectedNumberMapArray.push(i.toString());
expectedNumberMapArray.push(i.toString());
numberMap[i.toString()] = i;
}

const charMembers = ["a", "b", "c", "d", "e"];
const charMap: Record<string, number> = {};
const expectedCharMapArray: string[] = [];

for (let i = 0; i < charMembers.length; i++) {
expectedCharMapArray.push(charMembers[i]);
expectedCharMapArray.push(i.toString());
charMap[charMembers[i]] = i;
}

// Empty set
let result = await client.zscan(key1, initialCursor);
expect(result[resultCursorIndex]).toEqual(initialCursor);
expect(result[resultCollectionIndex]).toEqual([]);

// Negative cursor
result = await client.zscan(key1, "-1");
expect(result[resultCursorIndex]).toEqual(initialCursor);
expect(result[resultCollectionIndex]).toEqual([]);

// Result contains the whole set
expect(await client.zadd(key1, charMap)).toEqual(
charMembers.length,
);
result = await client.zscan(key1, initialCursor);
expect(result[resultCursorIndex]).toEqual(initialCursor);
expect(result[resultCollectionIndex].length).toEqual(
expectedCharMapArray.length,
);
expect(result[resultCollectionIndex]).toEqual(
expectedCharMapArray,
);

result = await client.zscan(key1, initialCursor, {
match: "a",
});
expect(result[resultCursorIndex]).toEqual(initialCursor);
expect(result[resultCollectionIndex]).toEqual(["a", "0"]);

// Result contains a subset of the key
expect(await client.zadd(key1, numberMap)).toEqual(
Object.keys(numberMap).length,
);

result = await client.zscan(key1, initialCursor);
let resultCursor = result[resultCursorIndex];
let resultIterationCollection = result[resultCollectionIndex];
let fullResultMapArray: string[] = resultIterationCollection;
let nextResult;
let nextResultCursor;

// 0 is returned for the cursor of the last iteration.
while (resultCursor != "0") {
nextResult = await client.zscan(key1, resultCursor);
nextResultCursor = nextResult[resultCursorIndex];
expect(nextResultCursor).not.toEqual(resultCursor);

expect(nextResult[resultCollectionIndex]).not.toEqual(
resultIterationCollection,
);
fullResultMapArray = fullResultMapArray.concat(
nextResult[resultCollectionIndex],
);
resultIterationCollection =
nextResult[resultCollectionIndex];
resultCursor = nextResultCursor;
}

// Fetching by cursor is randomized.
const expectedCombinedMapArray =
expectedNumberMapArray.concat(expectedCharMapArray);
expect(fullResultMapArray.length).toEqual(
expectedCombinedMapArray.length,
);

for (let i = 0; i < fullResultMapArray.length; i += 2) {
expect(fullResultMapArray).toContain(
expectedCombinedMapArray[i],
);
}

// Test match pattern
result = await client.zscan(key1, initialCursor, {
match: "*",
});
expect(result[resultCursorIndex]).not.toEqual(initialCursor);
expect(
result[resultCollectionIndex].length,
).toBeGreaterThanOrEqual(defaultCount);

// Test count
result = await client.zscan(key1, initialCursor, { count: 20 });
expect(result[resultCursorIndex]).not.toEqual("0");
expect(
result[resultCollectionIndex].length,
).toBeGreaterThanOrEqual(20);

// Test count with match returns a non-empty list
result = await client.zscan(key1, initialCursor, {
match: "1*",
count: 20,
});
expect(result[resultCursorIndex]).not.toEqual("0");
expect(result[resultCollectionIndex].length).toBeGreaterThan(0);

// Exceptions
// Non-set key
expect(await client.set(key2, "test")).toEqual("OK");
await expect(client.zscan(key2, initialCursor)).rejects.toThrow(
RequestError,
);
await expect(
client.zscan(key2, initialCursor, {
match: "test",
count: 20,
}),
).rejects.toThrow(RequestError);

// Negative count
await expect(
client.zscan(key2, initialCursor, { count: -1 }),
).rejects.toThrow(RequestError);
}, protocol);
},
config.timeout,
);

it.each([ProtocolVersion.RESP2, ProtocolVersion.RESP3])(
`bzmpop test_%p`,
async (protocol) => {
Expand Down
7 changes: 7 additions & 0 deletions node/tests/TestUtilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,13 @@ export async function transactionTest(
]);
baseTransaction.zadd(key12, { one: 1, two: 2 });
responseData.push(["zadd(key12, { one: 1, two: 2 })", 2]);
baseTransaction.zscan(key12, "0");
responseData.push(['zscan(key12, "0")', ["0", ["one", "1", "two", "2"]]]);
baseTransaction.zscan(key12, "0", { match: "*", count: 20 });
responseData.push([
'zscan(key12, "0", {match: "*", count: 20})',
["0", ["one", "1", "two", "2"]],
]);
baseTransaction.zadd(key13, { one: 1, two: 2, three: 3.5 });
responseData.push(["zadd(key13, { one: 1, two: 2, three: 3.5 })", 3]);

Expand Down
Loading