diff --git a/CHANGELOG.md b/CHANGELOG.md index b30c888b02..bf092e926d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ #### Changes +* Node: Added LCS command ([#2049](https://github.com/valkey-io/valkey-glide/pull/2049)) * Node: Added MSETNX command ([#2046](https://github.com/valkey-io/valkey-glide/pull/2046)) * Node: Added BLMOVE command ([#2027](https://github.com/valkey-io/valkey-glide/pull/2027)) * Node: Exported client configuration types ([#2023](https://github.com/valkey-io/valkey-glide/pull/2023)) diff --git a/node/npm/glide/index.ts b/node/npm/glide/index.ts index 08dcbb8021..23fd2c3caa 100644 --- a/node/npm/glide/index.ts +++ b/node/npm/glide/index.ts @@ -116,7 +116,6 @@ function initialize() { ListDirection, ExpireOptions, FlushMode, - GeoUnit, InfoOptions, InsertPosition, SetOptions, @@ -138,6 +137,7 @@ function initialize() { ConfigurationError, ExecAbortError, RedisError, + ReturnType, RequestError, TimeoutError, ConnectionError, @@ -199,7 +199,6 @@ function initialize() { ListDirection, ExpireOptions, FlushMode, - GeoUnit, InfoOptions, InsertPosition, SetOptions, @@ -221,6 +220,7 @@ function initialize() { ConfigurationError, ExecAbortError, RedisError, + ReturnType, RequestError, TimeoutError, ConnectionError, diff --git a/node/src/BaseClient.ts b/node/src/BaseClient.ts index 0dacbff526..1c69b15643 100644 --- a/node/src/BaseClient.ts +++ b/node/src/BaseClient.ts @@ -31,7 +31,7 @@ import { GeoUnit, GeospatialData, InsertPosition, - KeyWeight, + KeyWeight, // eslint-disable-line @typescript-eslint/no-unused-vars LPosOptions, ListDirection, MemberOrigin, // eslint-disable-line @typescript-eslint/no-unused-vars @@ -84,6 +84,7 @@ import { createIncr, createIncrBy, createIncrByFloat, + createLCS, createLIndex, createLInsert, createLLen, @@ -3940,11 +3941,8 @@ export class BaseClient { * where each sub-array represents a single item in the following order: * * - The member (location) name. - * * - The distance from the center as a floating point `number`, in the same unit specified for `searchBy`, if `withDist` is set to `true`. - * * - The geohash of the location as a integer `number`, if `withHash` is set to `true`. - * * - The coordinates as a two item `array` of floating point `number`s, if `withCoord` is set to `true`. * * @example @@ -4173,7 +4171,7 @@ export class BaseClient { * See https://valkey.io/commands/geohash/ for more details. * * @param key - The key of the sorted set. - * @param members - The array of members whose GeoHash strings are to be retrieved. + * @param members - The array of members whose `GeoHash` strings are to be retrieved. * @returns An array of `GeoHash` strings representing the positions of the specified members stored at `key`. * If a member does not exist in the sorted set, a `null` value is returned for that member. * @@ -4191,6 +4189,111 @@ export class BaseClient { ); } + /** + * Returns all the longest common subsequences combined between strings stored at `key1` and `key2`. + * + * since Valkey version 7.0.0. + * + * @remarks When in cluster mode, `key1` and `key2` must map to the same hash slot. + * + * See https://valkey.io/commands/lcs/ for more details. + * + * @param key1 - The key that stores the first string. + * @param key2 - The key that stores the second string. + * @returns A `String` containing all the longest common subsequence combined between the 2 strings. + * An empty `String` is returned if the keys do not exist or have no common subsequences. + * + * @example + * ```typescript + * await client.mset({"testKey1": "abcd", "testKey2": "axcd"}); + * const result = await client.lcs("testKey1", "testKey2"); + * console.log(result); // Output: 'cd' + * ``` + */ + public async lcs(key1: string, key2: string): Promise { + return this.createWritePromise(createLCS(key1, key2)); + } + + /** + * Returns the total length of all the longest common subsequences between strings stored at `key1` and `key2`. + * + * since Valkey version 7.0.0. + * + * @remarks When in cluster mode, `key1` and `key2` must map to the same hash slot. + * + * See https://valkey.io/commands/lcs/ for more details. + * + * @param key1 - The key that stores the first string. + * @param key2 - The key that stores the second string. + * @returns The total length of all the longest common subsequences between the 2 strings. + * + * @example + * ```typescript + * await client.mset({"testKey1": "abcd", "testKey2": "axcd"}); + * const result = await client.lcsLen("testKey1", "testKey2"); + * console.log(result); // Output: 2 + * ``` + */ + public async lcsLen(key1: string, key2: string): Promise { + return this.createWritePromise(createLCS(key1, key2, { len: true })); + } + + /** + * Returns the indices and lengths of the longest common subsequences between strings stored at + * `key1` and `key2`. + * + * since Valkey version 7.0.0. + * + * @remarks When in cluster mode, `key1` and `key2` must map to the same hash slot. + * + * See https://valkey.io/commands/lcs/ for more details. + * + * @param key1 - The key that stores the first string. + * @param key2 - The key that stores the second string. + * @param withMatchLen - (Optional) If `true`, include the length of the substring matched for the each match. + * @param minMatchLen - (Optional) The minimum length of matches to include in the result. + * @returns A `Record` containing the indices of the longest common subsequences between the + * 2 strings and the lengths of the longest common subsequences. The resulting map contains two + * keys, "matches" and "len": + * - `"len"` is mapped to the total length of the all longest common subsequences between the 2 strings + * stored as an integer. This value doesn't count towards the `minMatchLen` filter. + * - `"matches"` is mapped to a three dimensional array of integers that stores pairs + * of indices that represent the location of the common subsequences in the strings held + * by `key1` and `key2`. + * + * @example + * ```typescript + * await client.mset({"key1": "ohmytext", "key2": "mynewtext"}); + * const result = await client.lcsIdx("key1", "key2"); + * console.log(result); // Output: + * { + * "matches" : + * [ + * [ // first substring match is "text" + * [4, 7], // in `key1` it is located between indices 4 and 7 + * [5, 8], // and in `key2` - in between 5 and 8 + * 4 // the match length, returned if `withMatchLen` set to `true` + * ], + * [ // second substring match is "my" + * [2, 3], // in `key1` it is located between indices 2 and 3 + * [0, 1], // and in `key2` - in between 0 and 1 + * 2 // the match length, returned if `withMatchLen` set to `true` + * ] + * ], + * "len" : 6 // total length of the all matches found + * } + * ``` + */ + public async lcsIdx( + key1: string, + key2: string, + options?: { withMatchLen?: boolean; minMatchLen?: number }, + ): Promise> { + return this.createWritePromise( + createLCS(key1, key2, { idx: options ?? {} }), + ); + } + /** * @internal */ diff --git a/node/src/Commands.ts b/node/src/Commands.ts index 1805b2a7f8..44af182559 100644 --- a/node/src/Commands.ts +++ b/node/src/Commands.ts @@ -2776,3 +2776,27 @@ export function createZRandMember( return createCommand(RequestType.ZRandMember, args); } + +/** @internal */ +export function createLCS( + key1: string, + key2: string, + options?: { + len?: boolean; + idx?: { withMatchLen?: boolean; minMatchLen?: number }; + }, +): command_request.Command { + const args = [key1, key2]; + + if (options) { + if (options.len) args.push("LEN"); + else if (options.idx) { + args.push("IDX"); + if (options.idx.withMatchLen) args.push("WITHMATCHLEN"); + if (options.idx.minMatchLen !== undefined) + args.push("MINMATCHLEN", options.idx.minMatchLen.toString()); + } + } + + return createCommand(RequestType.LCS, args); +} diff --git a/node/src/Transaction.ts b/node/src/Transaction.ts index 36b93f3e87..de661be35a 100644 --- a/node/src/Transaction.ts +++ b/node/src/Transaction.ts @@ -99,6 +99,7 @@ import { createIncrBy, createIncrByFloat, createInfo, + createLCS, createLIndex, createLInsert, createLLen, @@ -2364,11 +2365,8 @@ export class BaseTransaction> { * where each sub-array represents a single item in the following order: * * - The member (location) name. - * * - The distance from the center as a floating point `number`, in the same unit specified for `searchBy`. - * * - The geohash of the location as a integer `number`. - * * - The coordinates as a two item `array` of floating point `number`s. */ public geosearch( @@ -2496,7 +2494,7 @@ export class BaseTransaction> { * See https://valkey.io/commands/geohash/ for more details. * * @param key - The key of the sorted set. - * @param members - The array of members whose GeoHash strings are to be retrieved. + * @param members - The array of members whose `GeoHash` strings are to be retrieved. * * Command Response - An array of `GeoHash` strings representing the positions of the specified members stored at `key`. * If a member does not exist in the sorted set, a `null` value is returned for that member. @@ -2504,6 +2502,69 @@ export class BaseTransaction> { public geohash(key: string, members: string[]): T { return this.addAndReturn(createGeoHash(key, members)); } + + /** + * Returns all the longest common subsequences combined between strings stored at `key1` and `key2`. + * + * since Valkey version 7.0.0. + * + * See https://valkey.io/commands/lcs/ for more details. + * + * @param key1 - The key that stores the first string. + * @param key2 - The key that stores the second string. + * + * Command Response - A `String` containing all the longest common subsequence combined between the 2 strings. + * An empty `String` is returned if the keys do not exist or have no common subsequences. + */ + public lcs(key1: string, key2: string): T { + return this.addAndReturn(createLCS(key1, key2)); + } + + /** + * Returns the total length of all the longest common subsequences between strings stored at `key1` and `key2`. + * + * since Valkey version 7.0.0. + * + * See https://valkey.io/commands/lcs/ for more details. + * + * @param key1 - The key that stores the first string. + * @param key2 - The key that stores the second string. + * + * Command Response - The total length of all the longest common subsequences between the 2 strings. + */ + public lcsLen(key1: string, key2: string): T { + return this.addAndReturn(createLCS(key1, key2, { len: true })); + } + + /** + * Returns the indices and lengths of the longest common subsequences between strings stored at + * `key1` and `key2`. + * + * since Valkey version 7.0.0. + * + * See https://valkey.io/commands/lcs/ for more details. + * + * @param key1 - The key that stores the first string. + * @param key2 - The key that stores the second string. + * @param withMatchLen - (Optional) If `true`, include the length of the substring matched for the each match. + * @param minMatchLen - (Optional) The minimum length of matches to include in the result. + * + * Command Response - A `Record` containing the indices of the longest common subsequences between the + * 2 strings and the lengths of the longest common subsequences. The resulting map contains two + * keys, "matches" and "len": + * - `"len"` is mapped to the total length of the all longest common subsequences between the 2 strings + * stored as an integer. This value doesn't count towards the `minMatchLen` filter. + * - `"matches"` is mapped to a three dimensional array of integers that stores pairs + * of indices that represent the location of the common subsequences in the strings held + * by `key1` and `key2`. + */ + public lcsIdx( + key1: string, + key2: string, + options?: { withMatchLen?: boolean; minMatchLen?: number }, + ): T { + return this.addAndReturn(createLCS(key1, key2, { idx: options ?? {} })); + } } /** diff --git a/node/tests/RedisClusterClient.test.ts b/node/tests/RedisClusterClient.test.ts index 00fe077201..02e6f74943 100644 --- a/node/tests/RedisClusterClient.test.ts +++ b/node/tests/RedisClusterClient.test.ts @@ -338,17 +338,14 @@ describe("GlideClusterClient", () => { client.zintercard(["abc", "zxy", "lkn"]), client.zmpop(["abc", "zxy", "lkn"], ScoreFilter.MAX), client.bzmpop(["abc", "zxy", "lkn"], ScoreFilter.MAX, 0.1), + client.lcs("abc", "xyz"), + client.lcsLen("abc", "xyz"), + client.lcsIdx("abc", "xyz"), ); } for (const promise of promises) { - try { - await promise; - } catch (e) { - expect((e as Error).message.toLowerCase()).toContain( - "crossslot", - ); - } + await expect(promise).rejects.toThrowError(/crossslot/i); } client.close(); diff --git a/node/tests/SharedTests.ts b/node/tests/SharedTests.ts index 1f62c7f1c8..2cfc25b4ca 100644 --- a/node/tests/SharedTests.ts +++ b/node/tests/SharedTests.ts @@ -5991,6 +5991,143 @@ export function runBaseTests(config: { }, config.timeout, ); + + it.each([ProtocolVersion.RESP2, ProtocolVersion.RESP3])( + `lcs %p`, + async (protocol) => { + await runTest(async (client: BaseClient, cluster) => { + if (cluster.checkIfServerVersionLessThan("7.0.0")) return; + + const key1 = "{lcs}" + uuidv4(); + const key2 = "{lcs}" + uuidv4(); + const key3 = "{lcs}" + uuidv4(); + const key4 = "{lcs}" + uuidv4(); + + // keys does not exist or is empty + expect(await client.lcs(key1, key2)).toEqual(""); + expect(await client.lcsLen(key1, key2)).toEqual(0); + expect(await client.lcsIdx(key1, key2)).toEqual({ + matches: [], + len: 0, + }); + + // LCS with some strings + expect( + await client.mset({ + [key1]: "abcdefghijk", + [key2]: "defjkjuighijk", + [key3]: "123", + }), + ).toEqual("OK"); + expect(await client.lcs(key1, key2)).toEqual("defghijk"); + expect(await client.lcsLen(key1, key2)).toEqual(8); + + // LCS with only IDX + expect(await client.lcsIdx(key1, key2)).toEqual({ + matches: [ + [ + [6, 10], + [8, 12], + ], + [ + [3, 5], + [0, 2], + ], + ], + len: 8, + }); + expect(await client.lcsIdx(key1, key2, {})).toEqual({ + matches: [ + [ + [6, 10], + [8, 12], + ], + [ + [3, 5], + [0, 2], + ], + ], + len: 8, + }); + expect( + await client.lcsIdx(key1, key2, { withMatchLen: false }), + ).toEqual({ + matches: [ + [ + [6, 10], + [8, 12], + ], + [ + [3, 5], + [0, 2], + ], + ], + len: 8, + }); + + // LCS with IDX and WITHMATCHLEN + expect( + await client.lcsIdx(key1, key2, { withMatchLen: true }), + ).toEqual({ + matches: [ + [[6, 10], [8, 12], 5], + [[3, 5], [0, 2], 3], + ], + len: 8, + }); + + // LCS with IDX and MINMATCHLEN + expect( + await client.lcsIdx(key1, key2, { minMatchLen: 4 }), + ).toEqual({ + matches: [ + [ + [6, 10], + [8, 12], + ], + ], + len: 8, + }); + // LCS with IDX and a negative MINMATCHLEN + expect( + await client.lcsIdx(key1, key2, { minMatchLen: -1 }), + ).toEqual({ + matches: [ + [ + [6, 10], + [8, 12], + ], + [ + [3, 5], + [0, 2], + ], + ], + len: 8, + }); + + // LCS with IDX, MINMATCHLEN, and WITHMATCHLEN + expect( + await client.lcsIdx(key1, key2, { + minMatchLen: 4, + withMatchLen: true, + }), + ).toEqual({ matches: [[[6, 10], [8, 12], 5]], len: 8 }); + + // non-string keys are used + expect(await client.sadd(key4, ["_"])).toEqual(1); + await expect(client.lcs(key1, key4)).rejects.toThrow( + RequestError, + ); + await expect(client.lcsLen(key1, key4)).rejects.toThrow( + RequestError, + ); + await expect(client.lcsIdx(key1, key4)).rejects.toThrow( + RequestError, + ); + }, protocol); + }, + config.timeout, + ); } export function runCommonTests(config: { diff --git a/node/tests/TestUtilities.ts b/node/tests/TestUtilities.ts index f7f189fc74..0267e02263 100644 --- a/node/tests/TestUtilities.ts +++ b/node/tests/TestUtilities.ts @@ -447,9 +447,9 @@ export async function transactionTest( baseTransaction: Transaction | ClusterTransaction, version: string, ): Promise<[string, ReturnType][]> { - const key1 = "{key}" + uuidv4(); - const key2 = "{key}" + uuidv4(); - const key3 = "{key}" + uuidv4(); + const key1 = "{key}" + uuidv4(); // string + const key2 = "{key}" + uuidv4(); // string + const key3 = "{key}" + uuidv4(); // string const key4 = "{key}" + uuidv4(); const key5 = "{key}" + uuidv4(); const key6 = "{key}" + uuidv4(); @@ -1046,6 +1046,59 @@ export async function transactionTest( withCode: true, }); responseData.push(["functionList({ libName, true})", []]); + + baseTransaction + .mset({ [key1]: "abcd", [key2]: "bcde", [key3]: "wxyz" }) + .lcs(key1, key2) + .lcs(key1, key3) + .lcsLen(key1, key2) + .lcsLen(key1, key3) + .lcsIdx(key1, key2) + .lcsIdx(key1, key2, { minMatchLen: 1 }) + .lcsIdx(key1, key2, { withMatchLen: true }) + .lcsIdx(key1, key2, { withMatchLen: true, minMatchLen: 1 }) + .del([key1, key2, key3]); + + responseData.push( + ['mset({[key1]: "abcd", [key2]: "bcde", [key3]: "wxyz"})', "OK"], + ["lcs(key1, key2)", "bcd"], + ["lcs(key1, key3)", ""], + ["lcsLen(key1, key2)", 3], + ["lcsLen(key1, key3)", 0], + [ + "lcsIdx(key1, key2)", + { + matches: [ + [ + [1, 3], + [0, 2], + ], + ], + len: 3, + }, + ], + [ + "lcsIdx(key1, key2, {minMatchLen: 1})", + { + matches: [ + [ + [1, 3], + [0, 2], + ], + ], + len: 3, + }, + ], + [ + "lcsIdx(key1, key2, {withMatchLen: true})", + { matches: [[[1, 3], [0, 2], 3]], len: 3 }, + ], + [ + "lcsIdx(key1, key2, {withMatchLen: true, minMatchLen: 1})", + { matches: [[[1, 3], [0, 2], 3]], len: 3 }, + ], + ["del([key1, key2, key3])", 3], + ); } return responseData;