diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c129c304c..ffc5134eef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ #### Changes +* Node: Added GEOSEARCH command ([#2007](https://github.com/valkey-io/valkey-glide/pull/2007)) * Node: Added LMOVE command ([#2002](https://github.com/valkey-io/valkey-glide/pull/2002)) * Node: Added GEOPOS command ([#1991](https://github.com/valkey-io/valkey-glide/pull/1991)) * Node: Added BITCOUNT command ([#1982](https://github.com/valkey-io/valkey-glide/pull/1982)) diff --git a/node/npm/glide/index.ts b/node/npm/glide/index.ts index 16585715f3..5c52a70f06 100644 --- a/node/npm/glide/index.ts +++ b/node/npm/glide/index.ts @@ -79,6 +79,15 @@ function initialize() { BitwiseOperation, ConditionalChange, GeoAddOptions, + CoordOrigin, + MemberOrigin, + SearchOrigin, + GeoBoxShape, + GeoCircleShape, + GeoSearchShape, + GeoSearchResultOptions, + SortOrder, + GeoUnit, GeospatialData, GlideClient, GlideClusterClient, @@ -136,6 +145,15 @@ function initialize() { BitwiseOperation, ConditionalChange, GeoAddOptions, + CoordOrigin, + MemberOrigin, + SearchOrigin, + GeoBoxShape, + GeoCircleShape, + GeoSearchShape, + GeoSearchResultOptions, + SortOrder, + GeoUnit, GeospatialData, GlideClient, GlideClusterClient, diff --git a/node/src/BaseClient.ts b/node/src/BaseClient.ts index 636b352825..bdf192a33a 100644 --- a/node/src/BaseClient.ts +++ b/node/src/BaseClient.ts @@ -15,12 +15,18 @@ import { BitmapIndexType, BitOffsetOptions, BitwiseOperation, + CoordOrigin, // eslint-disable-line @typescript-eslint/no-unused-vars ExpireOptions, GeoAddOptions, + GeoBoxShape, // eslint-disable-line @typescript-eslint/no-unused-vars + GeoCircleShape, // eslint-disable-line @typescript-eslint/no-unused-vars + GeoSearchResultOptions, + GeoSearchShape, GeospatialData, GeoUnit, InsertPosition, KeyWeight, + MemberOrigin, // eslint-disable-line @typescript-eslint/no-unused-vars LPosOptions, ListDirection, RangeByIndex, @@ -28,6 +34,7 @@ import { RangeByScore, ScoreBoundary, ScoreFilter, + SearchOrigin, SetOptions, StreamAddOptions, StreamReadOptions, @@ -51,6 +58,7 @@ import { createGeoDist, createGeoHash, createGeoPos, + createGeoSearch, createGet, createGetBit, createGetDel, @@ -3660,6 +3668,94 @@ export class BaseClient { ); } + /** + * Returns the members of a sorted set populated with geospatial information using {@link geoadd}, + * which are within the borders of the area specified by a given shape. + * + * See https://valkey.io/commands/geosearch/ for more details. + * + * since - Valkey 6.2.0 and above. + * + * @param key - The key of the sorted set. + * @param searchFrom - The query's center point options, could be one of: + * + * - {@link MemberOrigin} to use the position of the given existing member in the sorted set. + * + * - {@link CoordOrigin} to use the given longitude and latitude coordinates. + * + * @param searchBy - The query's shape options, could be one of: + * + * - {@link GeoCircleShape} to search inside circular area according to given radius. + * + * - {@link GeoBoxShape} to search inside an axis-aligned rectangle, determined by height and width. + * + * @param resultOptions - The optional inputs to request additional information and configure sorting/limiting the results, see {@link GeoSearchResultOptions}. + * @returns By default, returns an `Array` of members (locations) names. + * If any of `withCoord`, `withDist` or `withHash` are set to `true` in {@link GeoSearchResultOptions}, a 2D `Array` returned, + * 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 + * ```typescript + * const data = new Map([["Palermo", { longitude: 13.361389, latitude: 38.115556 }], ["Catania", { longitude: 15.087269, latitude: 37.502669 }]]); + * await client.geoadd("mySortedSet", data); + * // search for locations within 200 km circle around stored member named 'Palermo' + * const result1 = await client.geosearch("mySortedSet", { member: "Palermo" }, { radius: 200, unit: GeoUnit.KILOMETERS }); + * console.log(result1); // Output: ['Palermo', 'Catania'] + * + * // search for locations in 200x300 mi rectangle centered at coordinate (15, 37), requesting additional info, + * // limiting results by 2 best matches, ordered by ascending distance from the search area center + * const result2 = await client.geosearch( + * "mySortedSet", + * { position: { longitude: 15, latitude: 37 } }, + * { width: 200, height: 300, unit: GeoUnit.MILES }, + * { + * sortOrder: SortOrder.ASC, + * count: 2, + * withCoord: true, + * withDist: true, + * withHash: true, + * }, + * ); + * console.log(result2); // Output: + * // [ + * // [ + * // 'Catania', // location name + * // [ + * // 56.4413, // distance + * // 3479447370796909, // geohash of the location + * // [15.087267458438873, 37.50266842333162], // coordinates of the location + * // ], + * // ], + * // [ + * // 'Palermo', + * // [ + * // 190.4424, + * // 3479099956230698, + * // [13.361389338970184, 38.1155563954963], + * // ], + * // ], + * // ] + * ``` + */ + public async geosearch( + key: string, + searchFrom: SearchOrigin, + searchBy: GeoSearchShape, + resultOptions?: GeoSearchResultOptions, + ): Promise<(Buffer | (number | number[])[])[]> { + return this.createWritePromise( + createGeoSearch(key, searchFrom, searchBy, resultOptions), + ); + } + /** * Returns the positions (longitude, latitude) of all the specified `members` of the * geospatial index represented by the sorted set at `key`. diff --git a/node/src/Commands.ts b/node/src/Commands.ts index d67e6031f9..95d51f6905 100644 --- a/node/src/Commands.ts +++ b/node/src/Commands.ts @@ -2096,28 +2096,23 @@ export function createGeoAdd( } membersToGeospatialData.forEach((coord, member) => { - args = args.concat([ + args = args.concat( coord.longitude.toString(), coord.latitude.toString(), - ]); - args.push(member); + member, + ); }); return createCommand(RequestType.GeoAdd, args); } -/** - * Enumeration representing distance units options for the {@link geodist} command. - */ +/** Enumeration representing distance units options. */ export enum GeoUnit { /** Represents distance in meters. */ METERS = "m", - /** Represents distance in kilometers. */ KILOMETERS = "km", - /** Represents distance in miles. */ MILES = "mi", - /** Represents distance in feet. */ FEET = "ft", } @@ -2161,6 +2156,123 @@ export function createGeoHash( return createCommand(RequestType.GeoHash, args); } +/** + * Optional parameters for {@link BaseClient.geosearch|geosearch} command which defines what should be included in the + * search results and how results should be ordered and limited. + */ +export type GeoSearchResultOptions = { + /** Include the coordinate of the returned items. */ + withCoord?: boolean; + /** + * Include the distance of the returned items from the specified center point. + * The distance is returned in the same unit as specified for the `searchBy` argument. + */ + withDist?: boolean; + /** Include the geohash of the returned items. */ + withHash?: boolean; + /** Indicates the order the result should be sorted in. */ + sortOrder?: SortOrder; + /** Indicates the number of matches the result should be limited to. */ + count?: number; + /** Whether to allow returning as enough matches are found. This requires `count` parameter to be set. */ + isAny?: boolean; +}; + +/** Defines the sort order for nested results. */ +export enum SortOrder { + /** Sort by ascending order. */ + ASC = "ASC", + /** Sort by descending order. */ + DESC = "DESC", +} + +export type GeoSearchShape = GeoCircleShape | GeoBoxShape; + +/** Circle search shape defined by the radius value and measurement unit. */ +export type GeoCircleShape = { + /** The radius to search by. */ + radius: number; + /** The measurement unit of the radius. */ + unit: GeoUnit; +}; + +/** Rectangle search shape defined by the width and height and measurement unit. */ +export type GeoBoxShape = { + /** The width of the rectangle to search by. */ + width: number; + /** The height of the rectangle to search by. */ + height: number; + /** The measurement unit of the width and height. */ + unit: GeoUnit; +}; + +export type SearchOrigin = CoordOrigin | MemberOrigin; + +/** The search origin represented by a {@link GeospatialData} position. */ +export type CoordOrigin = { + /** The pivot location to search from. */ + position: GeospatialData; +}; + +/** The search origin represented by an existing member. */ +export type MemberOrigin = { + /** Member (location) name stored in the sorted set to use as a search pivot. */ + member: string; +}; + +/** + * @internal + */ +export function createGeoSearch( + key: string, + searchFrom: SearchOrigin, + searchBy: GeoSearchShape, + resultOptions?: GeoSearchResultOptions, +): command_request.Command { + let args: string[] = [key]; + + if ("position" in searchFrom) { + args = args.concat( + "FROMLONLAT", + searchFrom.position.longitude.toString(), + searchFrom.position.latitude.toString(), + ); + } else { + args = args.concat("FROMMEMBER", searchFrom.member); + } + + if ("radius" in searchBy) { + args = args.concat( + "BYRADIUS", + searchBy.radius.toString(), + searchBy.unit, + ); + } else { + args = args.concat( + "BYBOX", + searchBy.width.toString(), + searchBy.height.toString(), + searchBy.unit, + ); + } + + if (resultOptions) { + if (resultOptions.withCoord) args.push("WITHCOORD"); + if (resultOptions.withDist) args.push("WITHDIST"); + if (resultOptions.withHash) args.push("WITHHASH"); + + if (resultOptions.count) { + args.push("COUNT", resultOptions.count?.toString()); + + if (resultOptions.isAny) args.push("ANY"); + } + + if (resultOptions.sortOrder) args.push(resultOptions.sortOrder); + } + + return createCommand(RequestType.GeoSearch, args); +} + /** * @internal */ diff --git a/node/src/Transaction.ts b/node/src/Transaction.ts index 8c8f391b24..9c7854b846 100644 --- a/node/src/Transaction.ts +++ b/node/src/Transaction.ts @@ -7,9 +7,14 @@ import { BitOffsetOptions, BitmapIndexType, BitwiseOperation, + CoordOrigin, // eslint-disable-line @typescript-eslint/no-unused-vars ExpireOptions, FlushMode, GeoAddOptions, + GeoBoxShape, // eslint-disable-line @typescript-eslint/no-unused-vars + GeoCircleShape, // eslint-disable-line @typescript-eslint/no-unused-vars + GeoSearchResultOptions, + GeoSearchShape, GeospatialData, GeoUnit, InfoOptions, @@ -18,11 +23,13 @@ import { LPosOptions, ListDirection, LolwutOptions, + MemberOrigin, // eslint-disable-line @typescript-eslint/no-unused-vars RangeByIndex, RangeByLex, RangeByScore, ScoreBoundary, ScoreFilter, + SearchOrigin, SetOptions, StreamAddOptions, StreamReadOptions, @@ -60,6 +67,7 @@ import { createGeoDist, createGeoHash, createGeoPos, + createGeoSearch, createGet, createGetBit, createGetDel, @@ -2160,6 +2168,52 @@ export class BaseTransaction> { ); } + /** + * Returns the members of a sorted set populated with geospatial information using {@link geoadd}, + * which are within the borders of the area specified by a given shape. + * + * See https://valkey.io/commands/geosearch/ for more details. + * + * since - Valkey 6.2.0 and above. + * + * @param key - The key of the sorted set. + * @param searchFrom - The query's center point options, could be one of: + * + * - {@link MemberOrigin} to use the position of the given existing member in the sorted set. + * + * - {@link CoordOrigin} to use the given longitude and latitude coordinates. + * + * @param searchBy - The query's shape options, could be one of: + * + * - {@link GeoCircleShape} to search inside circular area according to given radius. + * + * - {@link GeoBoxShape} to search inside an axis-aligned rectangle, determined by height and width. + * + * @param resultOptions - The optional inputs to request additional information and configure sorting/limiting the results, see {@link GeoSearchResultOptions}. + * + * Command Response - By default, returns an `Array` of members (locations) names. + * If any of `withCoord`, `withDist` or `withHash` are set to `true` in {@link GeoSearchResultOptions}, a 2D `Array` returned, + * 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( + key: string, + searchFrom: SearchOrigin, + searchBy: GeoSearchShape, + resultOptions?: GeoSearchResultOptions, + ): T { + return this.addAndReturn( + createGeoSearch(key, searchFrom, searchBy, resultOptions), + ); + } + /** * Returns the positions (longitude, latitude) of all the specified `members` of the * geospatial index represented by the sorted set at `key`. diff --git a/node/tests/SharedTests.ts b/node/tests/SharedTests.ts index 3e1fba7147..203136507b 100644 --- a/node/tests/SharedTests.ts +++ b/node/tests/SharedTests.ts @@ -27,6 +27,7 @@ import { RequestError, ScoreFilter, Script, + SortOrder, UpdateByScore, parseInfoResponse, } from "../"; @@ -4893,6 +4894,282 @@ export function runBaseTests(config: { config.timeout, ); + it.each([ProtocolVersion.RESP2, ProtocolVersion.RESP3])( + `geosearch test_%p`, + async (protocol) => { + await runTest(async (client: BaseClient, cluster) => { + if (cluster.checkIfServerVersionLessThan("6.2.0")) return; + + const key = uuidv4(); + + const members: string[] = [ + "Catania", + "Palermo", + "edge2", + "edge1", + ]; + const membersSet: Set = new Set(members); + const membersCoordinates: [number, number][] = [ + [15.087269, 37.502669], + [13.361389, 38.115556], + [17.24151, 38.788135], + [12.758489, 38.788135], + ]; + + const membersGeoData: GeospatialData[] = []; + + for (const [lon, lat] of membersCoordinates) { + membersGeoData.push({ longitude: lon, latitude: lat }); + } + + const membersToCoordinates = new Map(); + + for (let i = 0; i < members.length; i++) { + membersToCoordinates.set(members[i], membersGeoData[i]); + } + + const expectedResult = [ + [ + members[0], + [56.4413, 3479447370796909, membersCoordinates[0]], + ], + [ + members[1], + [190.4424, 3479099956230698, membersCoordinates[1]], + ], + [ + members[2], + [279.7403, 3481342659049484, membersCoordinates[2]], + ], + [ + members[3], + [279.7405, 3479273021651468, membersCoordinates[3]], + ], + ]; + + // geoadd + expect(await client.geoadd(key, membersToCoordinates)).toBe( + members.length, + ); + + let searchResult = await client.geosearch( + key, + { position: { longitude: 15, latitude: 37 } }, + { width: 400, height: 400, unit: GeoUnit.KILOMETERS }, + ); + // using set to compare, because results are reordrered + checkSimple(new Set(searchResult)).toEqual(membersSet); + + // order search result + searchResult = await client.geosearch( + key, + { position: { longitude: 15, latitude: 37 } }, + { width: 400, height: 400, unit: GeoUnit.KILOMETERS }, + { sortOrder: SortOrder.ASC }, + ); + checkSimple(searchResult).toEqual(members); + + // order and query all extra data + searchResult = await client.geosearch( + key, + { position: { longitude: 15, latitude: 37 } }, + { width: 400, height: 400, unit: GeoUnit.KILOMETERS }, + { + sortOrder: SortOrder.ASC, + withCoord: true, + withDist: true, + withHash: true, + }, + ); + checkSimple(searchResult).toEqual(expectedResult); + + // order, query and limit by 1 + searchResult = await client.geosearch( + key, + { position: { longitude: 15, latitude: 37 } }, + { width: 400, height: 400, unit: GeoUnit.KILOMETERS }, + { + sortOrder: SortOrder.ASC, + withCoord: true, + withDist: true, + withHash: true, + count: 1, + }, + ); + checkSimple(searchResult).toEqual(expectedResult.slice(0, 1)); + + // test search by box, unit: meters, from member, with distance + const meters = 400 * 1000; + searchResult = await client.geosearch( + key, + { member: "Catania" }, + { width: meters, height: meters, unit: GeoUnit.METERS }, + { + withDist: true, + withCoord: false, + sortOrder: SortOrder.DESC, + }, + ); + checkSimple(searchResult).toEqual([ + ["edge2", [236529.1799]], + ["Palermo", [166274.1516]], + ["Catania", [0.0]], + ]); + + // test search by box, unit: feet, from member, with limited count 2, with hash + const feet = 400 * 3280.8399; + searchResult = await client.geosearch( + key, + { member: "Palermo" }, + { width: feet, height: feet, unit: GeoUnit.FEET }, + { + withDist: false, + withCoord: false, + withHash: true, + sortOrder: SortOrder.ASC, + count: 2, + }, + ); + checkSimple(searchResult).toEqual([ + ["Palermo", [3479099956230698]], + ["edge1", [3479273021651468]], + ]); + + // test search by box, unit: miles, from geospatial position, with limited ANY count to 1 + const miles = 250; + searchResult = await client.geosearch( + key, + { position: { longitude: 15, latitude: 37 } }, + { width: miles, height: miles, unit: GeoUnit.MILES }, + { count: 1, isAny: true }, + ); + expect(members.map((m) => Buffer.from(m))).toContainEqual( + searchResult[0], + ); + + // test search by radius, units: feet, from member + const feetRadius = 200 * 3280.8399; + searchResult = await client.geosearch( + key, + { member: "Catania" }, + { radius: feetRadius, unit: GeoUnit.FEET }, + { sortOrder: SortOrder.ASC }, + ); + checkSimple(searchResult).toEqual(["Catania", "Palermo"]); + + // Test search by radius, unit: meters, from member + const metersRadius = 200 * 1000; + searchResult = await client.geosearch( + key, + { member: "Catania" }, + { radius: metersRadius, unit: GeoUnit.METERS }, + { sortOrder: SortOrder.DESC }, + ); + checkSimple(searchResult).toEqual(["Palermo", "Catania"]); + + searchResult = await client.geosearch( + key, + { member: "Catania" }, + { radius: metersRadius, unit: GeoUnit.METERS }, + { + sortOrder: SortOrder.DESC, + withHash: true, + }, + ); + checkSimple(searchResult).toEqual([ + ["Palermo", [3479099956230698]], + ["Catania", [3479447370796909]], + ]); + + // Test search by radius, unit: miles, from geospatial data + searchResult = await client.geosearch( + key, + { position: { longitude: 15, latitude: 37 } }, + { radius: 175, unit: GeoUnit.MILES }, + { sortOrder: SortOrder.DESC }, + ); + checkSimple(searchResult).toEqual([ + "edge1", + "edge2", + "Palermo", + "Catania", + ]); + + // Test search by radius, unit: kilometers, from a geospatial data, with limited count to 2 + searchResult = await client.geosearch( + key, + { position: { longitude: 15, latitude: 37 } }, + { radius: 200, unit: GeoUnit.KILOMETERS }, + { + sortOrder: SortOrder.ASC, + count: 2, + withHash: true, + withCoord: true, + withDist: true, + }, + ); + checkSimple(searchResult).toEqual(expectedResult.slice(0, 2)); + + // Test search by radius, unit: kilometers, from a geospatial data, with limited ANY count to 1 + searchResult = await client.geosearch( + key, + { position: { longitude: 15, latitude: 37 } }, + { radius: 200, unit: GeoUnit.KILOMETERS }, + { + sortOrder: SortOrder.ASC, + count: 1, + isAny: true, + withCoord: true, + withDist: true, + withHash: true, + }, + ); + expect(members.map((m) => Buffer.from(m))).toContainEqual( + searchResult[0][0], + ); + + // no members within the area + searchResult = await client.geosearch( + key, + { position: { longitude: 15, latitude: 37 } }, + { width: 50, height: 50, unit: GeoUnit.METERS }, + { sortOrder: SortOrder.ASC }, + ); + expect(searchResult).toEqual([]); + + // no members within the area + searchResult = await client.geosearch( + key, + { position: { longitude: 15, latitude: 37 } }, + { radius: 5, unit: GeoUnit.METERS }, + { sortOrder: SortOrder.ASC }, + ); + expect(searchResult).toEqual([]); + + // member does not exist + await expect( + client.geosearch( + key, + { member: "non-existing-member" }, + { radius: 100, unit: GeoUnit.METERS }, + ), + ).rejects.toThrow(RequestError); + + // key exists but holds a non-ZSET value + const key2 = uuidv4(); + expect(await client.set(key2, uuidv4())).toEqual("OK"); + await expect( + client.geosearch( + key2, + { position: { longitude: 15, latitude: 37 } }, + { radius: 100, unit: GeoUnit.METERS }, + ), + ).rejects.toThrow(RequestError); + }, protocol); + }, + config.timeout, + ); + it.each([ProtocolVersion.RESP2, ProtocolVersion.RESP3])( `zmpop test_%p`, async (protocol) => { diff --git a/node/tests/TestUtilities.ts b/node/tests/TestUtilities.ts index e589213359..02c9eef147 100644 --- a/node/tests/TestUtilities.ts +++ b/node/tests/TestUtilities.ts @@ -24,6 +24,7 @@ import { ProtocolVersion, ReturnType, ScoreFilter, + SortOrder, Transaction, } from ".."; @@ -810,6 +811,95 @@ export async function transactionTest( ["sqc8b49rny0", "sqdtr74hyu0", null], ]); + if (gte("6.2.0", version)) { + baseTransaction + .geosearch( + key18, + { member: "Palermo" }, + { radius: 200, unit: GeoUnit.KILOMETERS }, + { sortOrder: SortOrder.ASC }, + ) + .geosearch( + key18, + { position: { longitude: 15, latitude: 37 } }, + { width: 400, height: 400, unit: GeoUnit.KILOMETERS }, + ) + .geosearch( + key18, + { member: "Palermo" }, + { radius: 200, unit: GeoUnit.KILOMETERS }, + { + sortOrder: SortOrder.ASC, + count: 2, + withCoord: true, + withDist: true, + withHash: true, + }, + ) + .geosearch( + key18, + { position: { longitude: 15, latitude: 37 } }, + { width: 400, height: 400, unit: GeoUnit.KILOMETERS }, + { + sortOrder: SortOrder.ASC, + count: 2, + withCoord: true, + withDist: true, + withHash: true, + }, + ); + responseData.push([ + 'geosearch(key18, "Palermo", R200 KM, ASC)', + ["Palermo", "Catania"], + ]); + responseData.push([ + "geosearch(key18, (15, 37), 400x400 KM, ASC)", + ["Palermo", "Catania"], + ]); + responseData.push([ + 'geosearch(key18, "Palermo", R200 KM, ASC 2 3x true)', + [ + [ + "Palermo", + [ + 0.0, + 3479099956230698, + [13.361389338970184, 38.1155563954963], + ], + ], + [ + "Catania", + [ + 166.2742, + 3479447370796909, + [15.087267458438873, 37.50266842333162], + ], + ], + ], + ]); + responseData.push([ + "geosearch(key18, (15, 37), 400x400 KM, ASC 2 3x true)", + [ + [ + "Catania", + [ + 56.4413, + 3479447370796909, + [15.087267458438873, 37.50266842333162], + ], + ], + [ + "Palermo", + [ + 190.4424, + 3479099956230698, + [13.361389338970184, 38.1155563954963], + ], + ], + ], + ]); + } + const libName = "mylib1C" + uuidv4().replaceAll("-", ""); const funcName = "myfunc1c" + uuidv4().replaceAll("-", ""); const code = generateLuaLibCode(