diff --git a/pkg/commands/geo_search.test.ts b/pkg/commands/geo_search.test.ts new file mode 100644 index 00000000..2c370774 --- /dev/null +++ b/pkg/commands/geo_search.test.ts @@ -0,0 +1,178 @@ +import { describe, test, expect, afterAll } from "bun:test"; +import { keygen, newHttpClient } from "../test-utils.ts"; + +import { GeoAddCommand } from "./geo_add.ts"; +import { GeoSearchCommand } from "./geo_search.ts"; + +const client = newHttpClient(); +const { newKey, cleanup } = keygen(); +afterAll(cleanup); + +describe("GEOSEARCH tests", () => { + test("should return distance successfully in meters", async () => { + const key = newKey(); + await new GeoAddCommand([ + key, + { longitude: 13.361389, latitude: 38.115556, member: "Palermo" }, + { longitude: 15.087269, latitude: 37.502669, member: "Catania" }, + ]).exec(client); + + const res = await new GeoSearchCommand([ + key, + { type: "FROMLONLAT", coordinate: { lon: 15, lat: 37 } }, + { type: "BYRADIUS", radius: 200, radiusType: "KM" }, + "ASC", + ]).exec(client); + + expect(res).toEqual([{ member: "Catania" }, { member: "Palermo" }]); + }); + + test("should return members within the specified box", async () => { + const key = newKey(); + + await new GeoAddCommand([ + key, + { longitude: 13.361389, latitude: 38.115556, member: "Palermo" }, + { longitude: 15.087269, latitude: 37.502669, member: "Catania" }, + ]).exec(client); + + const res = await new GeoSearchCommand([ + key, + { type: "FROMLONLAT", coordinate: { lon: 14, lat: 37.5 } }, + { type: "BYBOX", rect: { width: 200, height: 200 }, rectType: "KM" }, + "ASC", + ]).exec(client); + + expect(res).toEqual([{ member: "Palermo" }, { member: "Catania" }]); + }); + + test("should return members with coordinates, distances, and hashes", async () => { + const key = newKey(); + + await new GeoAddCommand([ + key, + { longitude: 13.361389, latitude: 38.115556, member: "Palermo" }, + { longitude: 15.087269, latitude: 37.502669, member: "Catania" }, + ]).exec(client); + + const res = await new GeoSearchCommand([ + key, + { type: "FROMLONLAT", coordinate: { lon: 14, lat: 37.5 } }, + { type: "BYRADIUS", radius: 200, radiusType: "KM" }, + "ASC", + { withHash: true, withCoord: true, withDist: true }, + ]).exec(client); + + expect(res).toEqual([ + { + member: "Palermo", + dist: 88.526, + hash: "3479099956230698", + coord: { + long: 13.361389338970184, + lat: 38.1155563954963, + }, + }, + { + member: "Catania", + dist: 95.9406, + hash: "3479447370796909", + coord: { + long: 15.087267458438873, + lat: 37.50266842333162, + }, + }, + ]); + }); + + test("should return members with distances, and hashes", async () => { + const key = newKey(); + + await new GeoAddCommand([ + key, + { longitude: 13.361389, latitude: 38.115556, member: "Palermo" }, + { longitude: 15.087269, latitude: 37.502669, member: "Catania" }, + ]).exec(client); + + const res = await new GeoSearchCommand([ + key, + { type: "FROMLONLAT", coordinate: { lon: 14, lat: 37.5 } }, + { type: "BYRADIUS", radius: 200, radiusType: "KM" }, + "ASC", + { withHash: true, withDist: true }, + ]).exec(client); + + expect(res).toEqual([ + { + member: "Palermo", + dist: 88.526, + hash: "3479099956230698", + }, + { + member: "Catania", + dist: 95.9406, + hash: "3479447370796909", + }, + ]); + }); + + test("should return members with and coordinates", async () => { + const key = newKey(); + + await new GeoAddCommand([ + key, + { longitude: 13.361389, latitude: 38.115556, member: "Palermo" }, + { longitude: 15.087269, latitude: 37.502669, member: "Catania" }, + ]).exec(client); + + const res = await new GeoSearchCommand([ + key, + { type: "FROMLONLAT", coordinate: { lon: 14, lat: 37.5 } }, + { type: "BYRADIUS", radius: 200, radiusType: "KM" }, + "ASC", + { withCoord: true }, + ]).exec(client); + + expect(res).toEqual([ + { + member: "Palermo", + coord: { long: 13.361389338970184, lat: 38.1155563954963 }, + }, + { + member: "Catania", + coord: { long: 15.087267458438873, lat: 37.50266842333162 }, + }, + ]); + }); + + test("should return members with coordinates, and hashes", async () => { + const key = newKey(); + + await new GeoAddCommand([ + key, + { longitude: 13.361389, latitude: 38.115556, member: "Palermo" }, + { longitude: 15.087269, latitude: 37.502669, member: "Catania" }, + ]).exec(client); + + const res = await new GeoSearchCommand([ + key, + { type: "FROMLONLAT", coordinate: { lon: 14, lat: 37.5 } }, + { type: "BYRADIUS", radius: 200, radiusType: "KM" }, + "ASC", + { withHash: true, withCoord: true }, + ]).exec(client); + + expect(res).toEqual([ + { + member: "Palermo", + hash: "3479099956230698", + coord: { long: 13.361389338970184, lat: 38.1155563954963 }, + }, + { + member: "Catania", + hash: "3479447370796909", + coord: { long: 15.087267458438873, lat: 37.50266842333162 }, + }, + ]); + }); +}); diff --git a/pkg/commands/geo_search.ts b/pkg/commands/geo_search.ts new file mode 100644 index 00000000..647c01bc --- /dev/null +++ b/pkg/commands/geo_search.ts @@ -0,0 +1,139 @@ +import { Command, CommandOptions } from "./command.ts"; + +type RadiusOptions = "M" | "KM" | "FT" | "MI"; +type CenterPoint = + | { + type: "FROMMEMBER" | "frommember"; + member: TMemberType; + } + | { + type: "FROMLONLAT" | "fromlonlat"; + coordinate: { lon: number; lat: number }; + }; + +type Shape = + | { type: "BYRADIUS" | "byradius"; radius: number; radiusType: RadiusOptions } + | { + type: "BYBOX" | "bybox"; + rect: { width: number; height: number }; + rectType: RadiusOptions; + }; + +type GeoSearchCommandOptions = { + count?: { limit: number; any?: boolean }; + withCoord?: boolean; + withDist?: boolean; + withHash?: boolean; +}; + +type OptionMappings = { + withHash: "hash"; + withCoord: "coord"; + withDist: "dist"; +}; + +type GeoSearchOptions = { + [K in keyof TOptions as K extends keyof OptionMappings + ? OptionMappings[K] + : never]: K extends "withHash" + ? string + : K extends "withCoord" + ? { long: number; lat: number } + : K extends "withDist" + ? number + : never; +}; + +type GeoSearchResponse = ({ + member: TMemberType; +} & GeoSearchOptions)[]; + +/** + * @see https://redis.io/commands/geosearch + */ +export class GeoSearchCommand< + TMemberType = string, + TOptions extends GeoSearchCommandOptions = GeoSearchCommandOptions +> extends Command> { + constructor( + [key, centerPoint, shape, order, opts]: [ + key: string, + centerPoint: CenterPoint, + shape: Shape, + order: "ASC" | "DESC" | "asc" | "desc", + opts?: TOptions + ], + commandOptions?: CommandOptions> + ) { + const command: unknown[] = ["GEOSEARCH", key]; + + if (centerPoint.type === "FROMMEMBER" || centerPoint.type === "frommember") { + command.push(centerPoint.type, centerPoint.member); + } + if (centerPoint.type === "FROMLONLAT" || centerPoint.type === "fromlonlat") { + command.push(centerPoint.type, centerPoint.coordinate.lon, centerPoint.coordinate.lat); + } + + if (shape.type === "BYRADIUS" || shape.type === "byradius") { + command.push(shape.type, shape.radius, shape.radiusType); + } + if (shape.type === "BYBOX" || shape.type === "bybox") { + command.push(shape.type, shape.rect.width, shape.rect.height, shape.rectType); + } + command.push(order); + + if (opts?.count) { + command.push(opts.count.limit, ...(opts.count.any ? ["ANY"] : [])); + } + + const transform = (result: string[] | string[][]) => { + if (!opts?.withCoord && !opts?.withDist && !opts?.withHash) { + return result.map((member) => { + try { + return { member: JSON.parse(member as string) }; + } catch { + return { member }; + } + }); + } else { + return result.map((members) => { + let counter = 1; + const obj = {} as any; + + try { + obj.member = JSON.parse(members[0] as string); + } catch { + obj.member = members[0]; + } + + if (opts.withDist) { + obj.dist = parseFloat(members[counter++]); + } + if (opts.withHash) { + obj.hash = members[counter++].toString(); + } + if (opts.withCoord) { + obj.coord = { + long: parseFloat(members[counter][0]), + lat: parseFloat(members[counter][1]), + }; + } + return obj; + }); + } + }; + + super( + [ + ...command, + ...(opts?.withCoord ? ["WITHCOORD"] : []), + ...(opts?.withDist ? ["WITHDIST"] : []), + ...(opts?.withHash ? ["WITHHASH"] : []), + ], + { + ...commandOptions, + deserialize: transform, + } + ); + } +} diff --git a/pkg/commands/mod.ts b/pkg/commands/mod.ts index a83ae536..c5ca006a 100644 --- a/pkg/commands/mod.ts +++ b/pkg/commands/mod.ts @@ -19,6 +19,7 @@ export * from "./geo_add"; export * from "./geo_dist"; export * from "./geo_pos"; export * from "./geo_hash"; +export * from "./geo_search"; export * from "./get"; export * from "./getbit"; export * from "./getdel"; diff --git a/pkg/pipeline.ts b/pkg/pipeline.ts index 32e3e559..7e32ca4d 100644 --- a/pkg/pipeline.ts +++ b/pkg/pipeline.ts @@ -20,6 +20,7 @@ import { GeoHashCommand, GeoAddCommand, GeoDistCommand, + GeoSearchCommand, GeoPosCommand, GetBitCommand, GetCommand, @@ -1131,6 +1132,12 @@ export class Pipeline[] = []> { geohash: (...args: CommandArgs) => new GeoHashCommand(args, this.commandOptions).exec(this.client), + /** + * @see https://redis.io/commands/geosearch + */ + geosearch: (...args: CommandArgs) => + new GeoSearchCommand(args, this.commandOptions).exec(this.client), + /** * @see https://redis.io/commands/json.get */ diff --git a/pkg/redis.ts b/pkg/redis.ts index 02f2d193..7bc9b163 100644 --- a/pkg/redis.ts +++ b/pkg/redis.ts @@ -18,6 +18,7 @@ import { FlushDBCommand, GeoAddCommand, GeoDistCommand, + GeoSearchCommand, GeoHashCommand, GeoPosCommand, GetBitCommand, @@ -269,6 +270,12 @@ export class Redis { geohash: (...args: CommandArgs) => new GeoHashCommand(args, this.opts).exec(this.client), + /** + * @see https://redis.io/commands/geosearch + */ + geosearch: (...args: CommandArgs) => + new GeoSearchCommand(args, this.opts).exec(this.client), + /** * @see https://redis.io/commands/json.get */