From 544f7ab56b33c365334e9cebf160d7125a4ef218 Mon Sep 17 00:00:00 2001 From: Frantz Kati Date: Fri, 23 Jun 2023 18:35:38 +0100 Subject: [PATCH 1/2] feat: add geoadd command: - add nx, xx, and ch options - add tests for no options scenario - add tests for nx, xx and ch option scenarios --- deno.lock | 4 + pkg/commands/geo_add.test.ts | 221 +++++++++++++++++++++++++++++++++++ pkg/commands/geo_add.ts | 67 +++++++++++ pkg/commands/mod.ts | 1 + pkg/redis.ts | 75 ++++++------ 5 files changed, 335 insertions(+), 33 deletions(-) create mode 100644 pkg/commands/geo_add.test.ts create mode 100644 pkg/commands/geo_add.ts diff --git a/deno.lock b/deno.lock index 3710e3b7..a7585d9b 100644 --- a/deno.lock +++ b/deno.lock @@ -97,6 +97,10 @@ "https://deno.land/std@0.177.0/testing/_test_suite.ts": "30f018feeb3835f12ab198d8a518f9089b1bcb2e8c838a8b615ab10d5005465c", "https://deno.land/std@0.177.0/testing/asserts.ts": "984ab0bfb3faeed92ffaa3a6b06536c66811185328c5dd146257c702c41b01ab", "https://deno.land/std@0.177.0/testing/bdd.ts": "c5ca6d85940dbcc19b4d2bc3608d49ab65d81470aa91306d5efa4b0d5c945731", + "https://deno.land/std@0.192.0/fmt/colors.ts": "d67e3cd9f472535241a8e410d33423980bec45047e343577554d3356e1f0ef4e", + "https://deno.land/std@0.192.0/testing/_diff.ts": "1a3c044aedf77647d6cac86b798c6417603361b66b54c53331b312caeb447aea", + "https://deno.land/std@0.192.0/testing/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7", + "https://deno.land/std@0.192.0/testing/asserts.ts": "e16d98b4d73ffc4ed498d717307a12500ae4f2cbe668f1a215632d19fcffc22f", "https://deno.land/x/base64@v0.2.1/base.ts": "47dc8d68f07dc91524bdd6db36eccbe59cf4d935b5fc09f27357a3944bb3ff7b", "https://deno.land/x/base64@v0.2.1/base64url.ts": "18bbf879b31f1f32cca8adaa2b6885ae325c2cec6a66c5817b684ca12c46ad5e", "https://deno.land/x/code_block_writer@11.0.0/comment_char.ts": "22b66890bbdf7a2d59777ffd8231710c1fda1c11fadada67632a596937a1a314", diff --git a/pkg/commands/geo_add.test.ts b/pkg/commands/geo_add.test.ts new file mode 100644 index 00000000..01011b1f --- /dev/null +++ b/pkg/commands/geo_add.test.ts @@ -0,0 +1,221 @@ +import { keygen, newHttpClient, randomID } from "../test-utils.ts"; + +import { afterAll } from "https://deno.land/std@0.177.0/testing/bdd.ts"; +import { + assert, + assertEquals, +} from "https://deno.land/std@0.177.0/testing/asserts.ts"; + +import { GeoAddCommand, GeoMember } from "./geo_add.ts"; + +const client = newHttpClient(); + +const { newKey, cleanup } = keygen(); +afterAll(cleanup); + +interface Coordinate { + latitude: number; + longitude: number; +} + +function generateRandomPoint(radius = 100): Coordinate { + const center = { lat: 14.23, lng: 23.12 }; + + const x0 = center.lng; + const y0 = center.lat; + // Convert Radius from meters to degrees. + const rd = radius / 111300; + + const u = Math.random(); + const v = Math.random(); + + const w = rd * Math.sqrt(u); + const t = 2 * Math.PI * v; + const x = w * Math.cos(t); + const y = w * Math.sin(t); + + const xp = x / Math.cos(y0); + + // Resulting point. + return { latitude: y + y0, longitude: xp + x0 }; +} + +function getTestMember(): GeoMember> { + const member = randomID(); + + return { + ...generateRandomPoint(), + member: { [member]: Math.random() * 1000 }, + }; +} + +Deno.test("without options", async (t) => { + await t.step("adds the geo member", async () => { + const key = newKey(); + const member = randomID(); + + const res = await new GeoAddCommand([ + key, + { ...generateRandomPoint(), member }, + ]).exec(client); + assertEquals(res, 1); + }); + + await t.step("adds multiple members", async () => { + const key = newKey(); + + const res = await new GeoAddCommand([ + key, + { ...generateRandomPoint(), member: randomID() }, + { ...generateRandomPoint(), member: randomID() }, + { ...generateRandomPoint(), member: randomID() }, + { ...generateRandomPoint(), member: randomID() }, + ]).exec(client); + + assertEquals(res, 4); + }); + + await t.step("adds the geo member with member as object", async () => { + const key = newKey(); + + const res = await new GeoAddCommand>([ + key, + getTestMember(), + getTestMember(), + getTestMember(), + getTestMember(), + getTestMember(), + getTestMember(), + getTestMember(), + ]).exec(client); + + assertEquals(res, 7); + }); +}); + +Deno.test("xx", async (t) => { + await t.step("when the member exists", async (t) => { + await t.step("updates the member", async () => { + const key = newKey(); + const member = getTestMember(); + + // Create member. + await new GeoAddCommand>([key, member]).exec( + client + ); + + const updatedMember = { ...generateRandomPoint(), member: member.member }; + + const response = await new GeoAddCommand>([ + key, + { xx: true }, + updatedMember, + ]).exec(client); + + assertEquals(response, 0); + }); + }); + await t.step("when the member does not exist", async (t) => { + await t.step("does nothing", async () => { + const key = newKey(); + const member = getTestMember(); + + // Create member. + await new GeoAddCommand>([ + key, + { xx: true }, + member, + ]).exec(client); + + const { result } = await client.request({ + body: ["geopos", key, JSON.stringify(member.member)], + }); + + assertEquals(result, [null]); + }); + }); +}); + +Deno.test("nx", async (t) => { + await t.step("when the member exists", async (t) => { + await t.step("does not update the member", async () => { + const key = newKey(); + const member = getTestMember(); + + // Create member. + await new GeoAddCommand>([key, member]).exec( + client + ); + + // Get member position + const { result } = await client.request({ + body: ["geopos", key, JSON.stringify(member.member)], + }); + + const updatedMember = { ...generateRandomPoint(), member: member.member }; + + // Update member with nx command. + const response = await new GeoAddCommand>([ + key, + { nx: true }, + updatedMember, + ]).exec(client); + + assertEquals(response, 0); + + // Get member position again. And assert it didn't change + const { result: updatedResult } = await client.request({ + body: ["geopos", key, JSON.stringify(member.member)], + }); + + assertEquals(result, updatedResult); + }); + }); + + await t.step("when the member does not exist", async (t) => { + await t.step("adds new member", async () => { + const key = newKey(); + const member = getTestMember(); + + // Create member. + const response = await new GeoAddCommand>([ + key, + { nx: true }, + member, + ]).exec(client); + + assertEquals(response, 1); + }); + }); +}); + +Deno.test("ch", async (t) => { + await t.step("returns the number of changed elements", async (t) => { + const key = newKey(); + const member = getTestMember(); + const member2 = getTestMember(); + const member3 = getTestMember(); + + // Create member. + await new GeoAddCommand>([ + key, + member, + member2, + member3, + ]).exec(client); + + const updatedMember2 = { ...member2, ...generateRandomPoint() }; + const updatedMember3 = { ...member3, ...generateRandomPoint() }; + + // Create members again, but this time change members 2 and 3 + const response = await new GeoAddCommand>([ + key, + { ch: true }, + member, + updatedMember2, + updatedMember3, + ]).exec(client); + + assertEquals(response, 2); + }); +}); diff --git a/pkg/commands/geo_add.ts b/pkg/commands/geo_add.ts new file mode 100644 index 00000000..2317b474 --- /dev/null +++ b/pkg/commands/geo_add.ts @@ -0,0 +1,67 @@ +import { Command, CommandOptions } from "./command.ts"; + +export type GeoAddCommandOptions = + | { + nx?: boolean; + xx?: never; + } + | ({ + nx?: never; + xx?: boolean; + } & { ch?: boolean }); + +export interface GeoMember { + latitude: number; + longitude: number; + member: TMemberType; +} + +/** + * This type is defined up here because otherwise it would be automatically formatted into + * multiple lines by Deno. As a result of that, Deno will add a comma to the end and then + * complain about the comma being there... + */ +type Arg2 = GeoMember | GeoAddCommandOptions; + +/** + * @see https://redis.io/commands/geoadd + */ +export class GeoAddCommand extends Command< + number | null, + number | null +> { + constructor( + [key, arg1, ...arg2]: [ + string, + Arg2, + ...GeoMember[] + ], + opts?: CommandOptions + ) { + const command: unknown[] = ["geoadd", key]; + + if ("nx" in arg1 && arg1.nx) { + command.push("nx"); + } else if ("xx" in arg1 && arg1.xx) { + command.push("xx"); + } + + if ("ch" in arg1 && arg1.ch) { + command.push("ch"); + } + + if ("latitude" in arg1 && arg1.latitude) { + command.push(arg1.longitude, arg1.latitude, arg1.member); + } + + command.push( + ...arg2.flatMap(({ latitude, longitude, member }) => [ + longitude, + latitude, + member, + ]) + ); + + super(command, opts); + } +} diff --git a/pkg/commands/mod.ts b/pkg/commands/mod.ts index 30d8b62c..6aa7b30e 100644 --- a/pkg/commands/mod.ts +++ b/pkg/commands/mod.ts @@ -15,6 +15,7 @@ export * from "./expire.ts"; export * from "./expireat.ts"; export * from "./flushall.ts"; export * from "./flushdb.ts"; +export * from "./geo_add.ts"; export * from "./get.ts"; export * from "./getbit.ts"; export * from "./getdel.ts"; diff --git a/pkg/redis.ts b/pkg/redis.ts index d717b498..d409bd45 100644 --- a/pkg/redis.ts +++ b/pkg/redis.ts @@ -16,6 +16,7 @@ import { ExpireCommand, FlushAllCommand, FlushDBCommand, + GeoAddCommand, GetBitCommand, GetCommand, GetDelCommand, @@ -238,6 +239,12 @@ export class Redis { forget: (...args: CommandArgs) => new JsonForgetCommand(args, this.opts).exec(this.client), + /** + * @see https://redis.io/commands/geoadd + */ + geoadd: (...args: CommandArgs) => + new GeoAddCommand(args, this.opts).exec(this.client), + /** * @see https://redis.io/commands/json.get */ @@ -318,9 +325,9 @@ export class Redis { middleware: ( r: UpstashRequest, next: ( - req: UpstashRequest, - ) => Promise>, - ) => Promise>, + req: UpstashRequest + ) => Promise> + ) => Promise> ) => { const makeRequest = this.client.request.bind(this.client); this.client.request = (req: UpstashRequest) => @@ -405,7 +412,7 @@ export class Redis { ) => new BitOpCommand( [op as any, destinationKey, sourceKey, ...sourceKeys], - this.opts, + this.opts ).exec(this.client); /** @@ -587,15 +594,17 @@ export class Redis { >( key: string, count: number, - withValues: boolean, + withValues: boolean ): Promise>; } = >( key: string, count?: number, - withValues?: boolean, + withValues?: boolean ) => - new HRandFieldCommand([key, count, withValues] as any, this.opts) - .exec(this.client); + new HRandFieldCommand( + [key, count, withValues] as any, + this.opts + ).exec(this.client); /** * @see https://redis.io/commands/hscan @@ -664,10 +673,10 @@ export class Redis { key: string, direction: "before" | "after", pivot: TData, - value: TData, + value: TData ) => new LInsertCommand([key, direction, pivot, value], this.opts).exec( - this.client, + this.client ); /** @@ -925,7 +934,7 @@ export class Redis { */ smismember = (key: string, members: TMembers) => new SMIsMemberCommand([key, members], this.opts).exec( - this.client, + this.client ); /** @@ -940,7 +949,7 @@ export class Redis { */ smove = (source: string, destination: string, member: TData) => new SMoveCommand([source, destination, member], this.opts).exec( - this.client, + this.client ); /** @@ -1020,26 +1029,26 @@ export class Redis { zadd = ( ...args: | [ - key: string, - scoreMember: ScoreMember, - ...scoreMemberPairs: ScoreMember[], - ] + key: string, + scoreMember: ScoreMember, + ...scoreMemberPairs: ScoreMember[] + ] | [ - key: string, - opts: ZAddCommandOptions | ZAddCommandOptionsWithIncr, - ...scoreMemberPairs: [ScoreMember, ...ScoreMember[]], - ] + key: string, + opts: ZAddCommandOptions | ZAddCommandOptionsWithIncr, + ...scoreMemberPairs: [ScoreMember, ...ScoreMember[]] + ] ) => { if ("score" in args[1]) { return new ZAddCommand( [args[0], args[1] as ScoreMember, ...(args.slice(2) as any)], - this.opts, + this.opts ).exec(this.client); } return new ZAddCommand( [args[0], args[1] as any, ...(args.slice(2) as any)], - this.opts, + this.opts ).exec(this.client); }; /** @@ -1065,7 +1074,7 @@ export class Redis { */ zincrby = (key: string, increment: number, member: TData) => new ZIncrByCommand([key, increment, member], this.opts).exec( - this.client, + this.client ); /** @@ -1105,17 +1114,17 @@ export class Redis { ...args: | [key: string, min: number, max: number, opts?: ZRangeCommandOptions] | [ - key: string, - min: `(${string}` | `[${string}` | "-" | "+", - max: `(${string}` | `[${string}` | "-" | "+", - opts: { byLex: true } & ZRangeCommandOptions, - ] + key: string, + min: `(${string}` | `[${string}` | "-" | "+", + max: `(${string}` | `[${string}` | "-" | "+", + opts: { byLex: true } & ZRangeCommandOptions + ] | [ - key: string, - min: number | `(${number}` | "-inf" | "+inf", - max: number | `(${number}` | "-inf" | "+inf", - opts: { byScore: true } & ZRangeCommandOptions, - ] + key: string, + min: number | `(${number}` | "-inf" | "+inf", + max: number | `(${number}` | "-inf" | "+inf", + opts: { byScore: true } & ZRangeCommandOptions + ] ) => new ZRangeCommand(args as any, this.opts).exec(this.client); /** From f6199c3c93a6b2c9e151570e3dc4233bbaade95f Mon Sep 17 00:00:00 2001 From: chronark Date: Tue, 17 Oct 2023 15:18:40 +0200 Subject: [PATCH 2/2] chore: clean up interface --- pkg/commands/geo_add.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/pkg/commands/geo_add.ts b/pkg/commands/geo_add.ts index 386fd4bc..70375b59 100644 --- a/pkg/commands/geo_add.ts +++ b/pkg/commands/geo_add.ts @@ -16,13 +16,6 @@ export interface GeoMember { member: TMemberType; } -/** - * This type is defined up here because otherwise it would be automatically formatted into - * multiple lines by Deno. As a result of that, Deno will add a comma to the end and then - * complain about the comma being there... - */ -type Arg2 = GeoMember | GeoAddCommandOptions; - /** * @see https://redis.io/commands/geoadd */ @@ -33,7 +26,7 @@ export class GeoAddCommand extends Command< constructor( [key, arg1, ...arg2]: [ string, - Arg2, + GeoMember | GeoAddCommandOptions, ...GeoMember[], ], opts?: CommandOptions,