diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c4fb488db..45f5f19a31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,6 +103,7 @@ * Python: Added transaction supports for DUMP, RESTORE, FUNCTION DUMP and FUNCTION RESTORE ([#1814](https://github.com/valkey-io/valkey-glide/pull/1814)) * Node: Added FlushAll command ([#1958](https://github.com/valkey-io/valkey-glide/pull/1958)) * Node: Added DBSize command ([#1932](https://github.com/valkey-io/valkey-glide/pull/1932)) +* Node: Added GeoAdd command ([#1980](https://github.com/valkey-io/valkey-glide/pull/1980)) #### Breaking Changes * Node: Update XREAD to return a Map of Map ([#1494](https://github.com/valkey-io/valkey-glide/pull/1494)) diff --git a/node/src/BaseClient.ts b/node/src/BaseClient.ts index fc6a5af580..b27bc1ba8a 100644 --- a/node/src/BaseClient.ts +++ b/node/src/BaseClient.ts @@ -33,6 +33,7 @@ import { createExists, createExpire, createExpireAt, + createGeoAdd, createGet, createGetDel, createHDel, @@ -135,6 +136,8 @@ import { connection_request, response, } from "./ProtobufMessage"; +import { GeospatialData } from "./commands/geospatial/GeospatialData"; +import { GeoAddOptions } from "./commands/geospatial/GeoAddOptions"; /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ type PromiseFunction = (value?: any) => void; @@ -3206,6 +3209,37 @@ export class BaseClient { return this.createWritePromise(createLPos(key, element, options)); } + /** + * Adds geospatial members with their positions to the specified sorted set stored at `key`. + * If a member is already a part of the sorted set, its position is updated. + * + * See https://valkey.io/commands/geoadd/ for more details. + * + * @param key - The key of the sorted set. + * @param membersToGeospatialData - A mapping of member names to their corresponding positions - see + * {@link GeospatialData}. The command will report an error when the user attempts to index + * coordinates outside the specified ranges. + * @param options - The GeoAdd options - see {@link GeoAddOptions}. + * @returns The number of elements added to the sorted set. If `changed` is set to + * `true` in the options, returns the number of elements updated in the sorted set. + * + * @example + * ```typescript + * const options = new GeoAddOptions({updateMode: ConditionalChange.ONLY_IF_EXISTS, changed: true}); + * const num = await client.geoadd("mySortedSet", {"Palermo", new GeospatialData(13.361389, 38.115556)}, options); + * console.log(num); // Output: 1 - Indicates that the position of an existing member in the sorted set "mySortedSet" has been updated. + * ``` + */ + public geoadd( + key: string, + membersToGeospatialData: Map, + options?: GeoAddOptions, + ): Promise { + return this.createWritePromise( + createGeoAdd(key, membersToGeospatialData, options), + ); + } + /** * @internal */ diff --git a/node/src/Commands.ts b/node/src/Commands.ts index f36e4949e6..d20427fcc6 100644 --- a/node/src/Commands.ts +++ b/node/src/Commands.ts @@ -7,6 +7,8 @@ import Long from "long"; import { LPosOptions } from "./commands/LPosOptions"; import { command_request } from "./ProtobufMessage"; +import { GeospatialData } from "./commands/geospatial/GeospatialData"; +import { GeoAddOptions } from "./commands/geospatial/GeoAddOptions"; import RequestType = command_request.RequestType; @@ -1752,3 +1754,25 @@ export function createLPos( export function createDBSize(): command_request.Command { return createCommand(RequestType.DBSize, []); } + +/** + * @internal + */ +export function createGeoAdd( + key: string, + membersToGeospatialData: Map, + options?: GeoAddOptions, +): command_request.Command { + let args: string[] = [key]; + + if (options) { + args = args.concat(options.toArgs()); + } + + membersToGeospatialData.forEach((coord, member) => { + args = args.concat(coord.toArgs()); + args.push(member); + }); + + return createCommand(RequestType.GeoAdd, args); +} diff --git a/node/src/Transaction.ts b/node/src/Transaction.ts index faea761508..4951f1397d 100644 --- a/node/src/Transaction.ts +++ b/node/src/Transaction.ts @@ -127,9 +127,12 @@ import { createZRemRangeByRank, createZRemRangeByScore, createZScore, + createGeoAdd, createFunctionLoad, } from "./Commands"; import { command_request } from "./ProtobufMessage"; +import { GeoAddOptions } from "./commands/geospatial/GeoAddOptions"; +import { GeospatialData } from "./commands/geospatial/GeospatialData"; /** * Base class encompassing shared commands for both standalone and cluster mode implementations in a transaction. @@ -1797,6 +1800,31 @@ export class BaseTransaction> { public dbsize(): T { return this.addAndReturn(createDBSize()); } + + /** + * Adds geospatial members with their positions to the specified sorted set stored at `key`. + * If a member is already a part of the sorted set, its position is updated. + * + * See https://valkey.io/commands/geoadd/ for more details. + * + * @param key - The key of the sorted set. + * @param membersToGeospatialData - A mapping of member names to their corresponding positions - see + * {@link GeospatialData}. The command will report an error when the user attempts to index + * coordinates outside the specified ranges. + * @param options - The GeoAdd options - see {@link GeoAddOptions}. + * + * Command Response - The number of elements added to the sorted set. If `changed` is set to + * `true` in the options, returns the number of elements updated in the sorted set. + */ + public geoadd( + key: string, + membersToGeospatialData: Map, + options?: GeoAddOptions, + ): T { + return this.addAndReturn( + createGeoAdd(key, membersToGeospatialData, options), + ); + } } /** diff --git a/node/src/commands/ConditionalChange.ts b/node/src/commands/ConditionalChange.ts new file mode 100644 index 0000000000..5904f90d32 --- /dev/null +++ b/node/src/commands/ConditionalChange.ts @@ -0,0 +1,21 @@ +/** + * Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0 + */ + +/* eslint-disable-next-line @typescript-eslint/no-unused-vars */ +import { BaseClient } from "src/BaseClient"; + +/** + * An optional condition to the {@link BaseClient.geoadd} command. + */ +export enum ConditionalChange { + /** + * Only update elements that already exist. Don't add new elements. Equivalent to `XX` in the Valkey API. + */ + ONLY_IF_EXISTS = "XX", + + /** + * Only add new elements. Don't update already existing elements. Equivalent to `NX` in the Valkey API. + * */ + ONLY_IF_DOES_NOT_EXIST = "NX", +} diff --git a/node/src/commands/geospatial/GeoAddOptions.ts b/node/src/commands/geospatial/GeoAddOptions.ts new file mode 100644 index 0000000000..220dff8e19 --- /dev/null +++ b/node/src/commands/geospatial/GeoAddOptions.ts @@ -0,0 +1,52 @@ +/** + * Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0 + */ + +import { ConditionalChange } from "../ConditionalChange"; + +/** + * Optional arguments for the GeoAdd command. + * + * See https://valkey.io/commands/geoadd/ for more details. + */ +export class GeoAddOptions { + /** Valkey API keyword use to modify the return value from the number of new elements added, to the total number of elements changed. */ + public static CHANGED_VALKEY_API = "CH"; + + private updateMode?: ConditionalChange; + + private changed?: boolean; + + /** + * Default constructor for GeoAddOptions. + * + * @param updateMode - Options for handling existing members. See {@link ConditionalChange}. + * @param latitude - If `true`, returns the count of changed elements instead of new elements added. + */ + constructor(options: { + updateMode?: ConditionalChange; + changed?: boolean; + }) { + this.updateMode = options.updateMode; + this.changed = options.changed; + } + + /** + * Converts GeoAddOptions into a string[]. + * + * @returns string[] + */ + public toArgs(): string[] { + const args: string[] = []; + + if (this.updateMode) { + args.push(this.updateMode); + } + + if (this.changed) { + args.push(GeoAddOptions.CHANGED_VALKEY_API); + } + + return args; + } +} diff --git a/node/src/commands/geospatial/GeospatialData.ts b/node/src/commands/geospatial/GeospatialData.ts new file mode 100644 index 0000000000..63c15bdff0 --- /dev/null +++ b/node/src/commands/geospatial/GeospatialData.ts @@ -0,0 +1,37 @@ +/** + * Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0 + */ + +/** + * Represents a geographic position defined by longitude and latitude. + * The exact limits, as specified by `EPSG:900913 / EPSG:3785 / OSGEO:41001` are the + * following: + * + * Valid longitudes are from `-180` to `180` degrees. + * Valid latitudes are from `-85.05112878` to `85.05112878` degrees. + */ +export class GeospatialData { + private longitude: number; + + private latitude: number; + + /** + * Default constructor for GeospatialData. + * + * @param longitude - The longitude coordinate. + * @param latitude - The latitude coordinate. + */ + constructor(longitude: number, latitude: number) { + this.longitude = longitude; + this.latitude = latitude; + } + + /** + * Converts GeospatialData into a string[]. + * + * @returns string[] + */ + public toArgs(): string[] { + return [this.longitude.toString(), this.latitude.toString()]; + } +} diff --git a/node/tests/SharedTests.ts b/node/tests/SharedTests.ts index 0ae1c7b4bd..8ef7084f90 100644 --- a/node/tests/SharedTests.ts +++ b/node/tests/SharedTests.ts @@ -29,6 +29,9 @@ import { } from "./TestUtilities"; import { SingleNodeRoute } from "../build-ts/src/GlideClusterClient"; import { LPosOptions } from "../build-ts/src/commands/LPosOptions"; +import { GeospatialData } from "../build-ts/src/commands/geospatial/GeospatialData"; +import { GeoAddOptions } from "../build-ts/src/commands/geospatial/GeoAddOptions"; +import { ConditionalChange } from "../build-ts/src/commands/ConditionalChange"; async function getVersion(): Promise<[number, number, number]> { const versionString = await new Promise((resolve, reject) => { @@ -4110,6 +4113,116 @@ export function runBaseTests(config: { }, config.timeout, ); + + it.each([ProtocolVersion.RESP2, ProtocolVersion.RESP3])( + `geoadd test_%p`, + async (protocol) => { + await runTest(async (client: BaseClient) => { + const key1 = uuidv4(); + const key2 = uuidv4(); + const membersToCoordinates = new Map(); + membersToCoordinates.set( + "Palermo", + new GeospatialData(13.361389, 38.115556), + ); + membersToCoordinates.set( + "Catania", + new GeospatialData(15.087269, 37.502669), + ); + + // default geoadd + expect(await client.geoadd(key1, membersToCoordinates)).toBe(2); + + // with update mode options + membersToCoordinates.set( + "Catania", + new GeospatialData(15.087269, 39), + ); + expect( + await client.geoadd( + key1, + membersToCoordinates, + new GeoAddOptions({ + updateMode: + ConditionalChange.ONLY_IF_DOES_NOT_EXIST, + }), + ), + ).toBe(0); + expect( + await client.geoadd( + key1, + membersToCoordinates, + new GeoAddOptions({ + updateMode: ConditionalChange.ONLY_IF_EXISTS, + }), + ), + ).toBe(0); + + // with changed option + membersToCoordinates.set( + "Catania", + new GeospatialData(15.087269, 40), + ); + membersToCoordinates.set( + "Tel-Aviv", + new GeospatialData(32.0853, 34.7818), + ); + expect( + await client.geoadd( + key1, + membersToCoordinates, + new GeoAddOptions({ changed: true }), + ), + ).toBe(2); + + // key exists but holding non-zset value + expect(await client.set(key2, "foo")).toBe("OK"); + await expect( + client.geoadd(key2, membersToCoordinates), + ).rejects.toThrow(); + }, protocol); + }, + config.timeout, + ); + + it.each([ProtocolVersion.RESP2, ProtocolVersion.RESP3])( + `geoadd invalid args test_%p`, + async (protocol) => { + await runTest(async (client: BaseClient) => { + const key = uuidv4(); + + // empty coordinate map + await expect(client.geoadd(key, new Map())).rejects.toThrow(); + + // coordinate out of bound + await expect( + client.geoadd( + key, + new Map([["Place", new GeospatialData(-181, 0)]]), + ), + ).rejects.toThrow(); + await expect( + client.geoadd( + key, + new Map([["Place", new GeospatialData(181, 0)]]), + ), + ).rejects.toThrow(); + await expect( + client.geoadd( + key, + new Map([["Place", new GeospatialData(0, 86)]]), + ), + ).rejects.toThrow(); + await expect( + client.geoadd( + key, + new Map([["Place", new GeospatialData(0, -86)]]), + ), + ).rejects.toThrow(); + }, protocol); + }, + config.timeout, + ); } export function runCommonTests(config: { diff --git a/node/tests/TestUtilities.ts b/node/tests/TestUtilities.ts index 2724615b6d..9d3c12d376 100644 --- a/node/tests/TestUtilities.ts +++ b/node/tests/TestUtilities.ts @@ -20,6 +20,7 @@ import { } from ".."; import { checkIfServerVersionLessThan } from "./SharedTests"; import { LPosOptions } from "../build-ts/src/commands/LPosOptions"; +import { GeospatialData } from "../build-ts/src/commands/geospatial/GeospatialData"; beforeAll(() => { Logger.init("info"); @@ -350,6 +351,7 @@ export async function transactionTest( const key14 = "{key}" + uuidv4(); // sorted set const key15 = "{key}" + uuidv4(); // list const key16 = "{key}" + uuidv4(); // list + const key17 = "{key}" + uuidv4(); // Geospatial Data/ZSET const field = uuidv4(); const value = uuidv4(); const args: ReturnType[] = []; @@ -608,6 +610,14 @@ export async function transactionTest( args.push(1); baseTransaction.pfcount([key11]); args.push(3); + baseTransaction.geoadd( + key17, + new Map([ + ["Palermo", new GeospatialData(13.361389, 38.115556)], + ["Catania", new GeospatialData(15.087269, 37.502669)], + ]), + ); + args.push(2); const libName = "mylib1C" + uuidv4().replaceAll("-", ""); const funcName = "myfunc1c" + uuidv4().replaceAll("-", "");