From eccd607796bc4b1578152109132ed36e8cdf0548 Mon Sep 17 00:00:00 2001 From: "J. Lewis" <6710419+lewxdev@users.noreply.github.com> Date: Fri, 28 Jun 2024 14:47:31 -0400 Subject: [PATCH 1/6] feat: add bitfield resolves #1150 --- pkg/commands/bitfield.test.ts | 50 ++++++++++++++++++++++++++++++++++ pkg/commands/bitfield.ts | 51 +++++++++++++++++++++++++++++++++++ pkg/commands/mod.ts | 3 ++- pkg/error.ts | 4 ++- pkg/redis.ts | 19 +++++++++++++ 5 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 pkg/commands/bitfield.test.ts create mode 100644 pkg/commands/bitfield.ts diff --git a/pkg/commands/bitfield.test.ts b/pkg/commands/bitfield.test.ts new file mode 100644 index 00000000..13334b44 --- /dev/null +++ b/pkg/commands/bitfield.test.ts @@ -0,0 +1,50 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { keygen, newHttpClient } from "../test-utils"; +import { BitFieldCommand } from "./bitfield"; + +const client = newHttpClient(); + +const { newKey, cleanup } = keygen(); +afterAll(cleanup); + +describe("when key is not set", () => { + test("returns 0", async () => { + const key = newKey(); + const res = await new BitFieldCommand([key]).get("u4", "#0").exec(client); + expect(res).toEqual([0]); + }); +}); + +describe("when key is set", () => { + test("sets / gets value", async () => { + const key = newKey(); + const value = 42; + const res = await new BitFieldCommand([key]) + .set("u8", "#0", value) + .get("u8", "#0") + .exec(client); + expect(res).toEqual([0, value]); + }); + + test("increments value", async () => { + const key = newKey(); + const value = 42; + const increment = 10; + const res = await new BitFieldCommand([key]) + .set("u8", "#0", value) + .incrby("u8", "#0", increment) + .exec(client); + expect(res).toEqual([0, value + increment]); + }); + + test("overflows", async () => { + const key = newKey(); + const value = 255; + const res = await new BitFieldCommand([key]) + .set("u8", "#0", value) + .incrby("u8", "#0", 10) + .overflow("WRAP") + .exec(client); + expect(res).toEqual([0, value]); + }); +}); diff --git a/pkg/commands/bitfield.ts b/pkg/commands/bitfield.ts new file mode 100644 index 00000000..a569dee8 --- /dev/null +++ b/pkg/commands/bitfield.ts @@ -0,0 +1,51 @@ +import { type Requester } from "../http"; +import { Command, type CommandOptions } from "./command"; + +type SubcommandArgs = [ + encoding: `u${number}` | `i${number}`, // u1 - u63 | i1 - i64 + offset: number | `#${number}`, // any int + ...Rest, +]; + +/** + * Returns an instance that can be used to execute `BITFIELD` commands on one key. + * + * @see https://redis.io/commands/bitfield + */ +export class BitFieldCommand extends Command { + constructor( + cmd: [key: string], + opts?: CommandOptions, + private client?: Requester, + ) { + super(cmd, opts); + } + + private pushSerializedArgs(...args: unknown[]) { + return this.command.push(...args.map((arg) => this.serialize(arg))); + } + + get(...args: SubcommandArgs): this { + this.pushSerializedArgs("get", ...args); + return this; + } + + set(...args: SubcommandArgs<[value: number]>): this { + this.pushSerializedArgs("set", ...args); + return this; + } + + incrby(...args: SubcommandArgs<[increment: number]>): this { + this.pushSerializedArgs("incrby", ...args); + return this; + } + + overflow(overflow: "WRAP" | "SAT" | "FAIL"): this { + this.pushSerializedArgs("overflow", overflow); + return this; + } + + override exec(client = this.client): Promise { + return this.exec(client); + } +} diff --git a/pkg/commands/mod.ts b/pkg/commands/mod.ts index 79abd9e2..0ca3cd7f 100644 --- a/pkg/commands/mod.ts +++ b/pkg/commands/mod.ts @@ -1,5 +1,6 @@ export * from "./append"; export * from "./bitcount"; +export * from "./bitfield"; export * from "./bitop"; export * from "./bitpos"; export * from "./command"; @@ -72,8 +73,8 @@ export * from "./lindex"; export * from "./linsert"; export * from "./llen"; export * from "./lmove"; -export * from "./lpop"; export * from "./lmpop"; +export * from "./lpop"; export * from "./lpos"; export * from "./lpush"; export * from "./lpushx"; diff --git a/pkg/error.ts b/pkg/error.ts index f57702f1..a766e302 100644 --- a/pkg/error.ts +++ b/pkg/error.ts @@ -10,7 +10,9 @@ export class UpstashError extends Error { export class UrlError extends Error { constructor(url: string) { - super(`Upstash Redis client was passed an invalid URL. You should pass the URL together with https. Received: "${url}". `); + super( + `Upstash Redis client was passed an invalid URL. You should pass the URL together with https. Received: "${url}". `, + ); this.name = "UrlError"; } } diff --git a/pkg/redis.ts b/pkg/redis.ts index dbf3e1e6..d5c8a350 100644 --- a/pkg/redis.ts +++ b/pkg/redis.ts @@ -2,6 +2,7 @@ import { createAutoPipelineProxy } from "../pkg/auto-pipeline"; import { AppendCommand, BitCountCommand, + BitFieldCommand, BitOpCommand, BitPosCommand, CommandOptions, @@ -402,6 +403,24 @@ export class Redis { multiExec: true, }); + /** + * Returns an instance that can be used to execute `BITFIELD` commands on one key. + * + * @example + * ```typescript + * redis.set("mykey", 0); + * const result = await redis.bitfield("mykey") + * .set("u4", 0, 16) + * .incr("u4", 4, 1) + * .exec(); + * console.log(result); // [0, 1] + * ``` + * + * @see https://redis.io/commands/bitfield + */ + bitfield = (...args: CommandArgs) => + new BitFieldCommand(args, this.opts, this.client); + /** * @see https://redis.io/commands/append */ From 2d1d251726c90772638af036ec441820a44f8ce4 Mon Sep 17 00:00:00 2001 From: "J. Lewis" <6710419+lewxdev@users.noreply.github.com> Date: Fri, 28 Jun 2024 19:04:03 -0400 Subject: [PATCH 2/6] fix: bitfield tests not running --- pkg/commands/bitfield.test.ts | 21 +++++++++++---------- pkg/commands/bitfield.ts | 35 +++++++++++++++-------------------- pkg/redis.ts | 4 ++-- 3 files changed, 28 insertions(+), 32 deletions(-) diff --git a/pkg/commands/bitfield.test.ts b/pkg/commands/bitfield.test.ts index 13334b44..7ea916ab 100644 --- a/pkg/commands/bitfield.test.ts +++ b/pkg/commands/bitfield.test.ts @@ -10,7 +10,7 @@ afterAll(cleanup); describe("when key is not set", () => { test("returns 0", async () => { const key = newKey(); - const res = await new BitFieldCommand([key]).get("u4", "#0").exec(client); + const res = await new BitFieldCommand([key], client).get("u4", "#0").exec(); expect(res).toEqual([0]); }); }); @@ -19,10 +19,10 @@ describe("when key is set", () => { test("sets / gets value", async () => { const key = newKey(); const value = 42; - const res = await new BitFieldCommand([key]) + const res = await new BitFieldCommand([key], client) .set("u8", "#0", value) .get("u8", "#0") - .exec(client); + .exec(); expect(res).toEqual([0, value]); }); @@ -30,21 +30,22 @@ describe("when key is set", () => { const key = newKey(); const value = 42; const increment = 10; - const res = await new BitFieldCommand([key]) + const res = await new BitFieldCommand([key], client) .set("u8", "#0", value) .incrby("u8", "#0", increment) - .exec(client); + .exec(); expect(res).toEqual([0, value + increment]); }); test("overflows", async () => { const key = newKey(); const value = 255; - const res = await new BitFieldCommand([key]) - .set("u8", "#0", value) - .incrby("u8", "#0", 10) + const bitWidth = 8; + const res = await new BitFieldCommand([key], client) + .set(`u${bitWidth}`, "#0", value) + .incrby(`u${bitWidth}`, "#0", 10) .overflow("WRAP") - .exec(client); - expect(res).toEqual([0, value]); + .exec(); + expect(res).toEqual([0, (value + 10) % 2 ** bitWidth]); }); }); diff --git a/pkg/commands/bitfield.ts b/pkg/commands/bitfield.ts index a569dee8..a9cf7189 100644 --- a/pkg/commands/bitfield.ts +++ b/pkg/commands/bitfield.ts @@ -8,44 +8,39 @@ type SubcommandArgs = [ ]; /** - * Returns an instance that can be used to execute `BITFIELD` commands on one key. - * * @see https://redis.io/commands/bitfield */ export class BitFieldCommand extends Command { constructor( cmd: [key: string], + private client: Requester, opts?: CommandOptions, - private client?: Requester, ) { - super(cmd, opts); + super(["bitfield", ...cmd], opts); } - private pushSerializedArgs(...args: unknown[]) { - return this.command.push(...args.map((arg) => this.serialize(arg))); + private chain(...args: typeof this.command): this { + this.command.push(...args); + return this; } - get(...args: SubcommandArgs): this { - this.pushSerializedArgs("get", ...args); - return this; + get(...args: SubcommandArgs) { + return this.chain("get", ...args); } - set(...args: SubcommandArgs<[value: number]>): this { - this.pushSerializedArgs("set", ...args); - return this; + set(...args: SubcommandArgs<[value: number]>) { + return this.chain("set", ...args); } - incrby(...args: SubcommandArgs<[increment: number]>): this { - this.pushSerializedArgs("incrby", ...args); - return this; + incrby(...args: SubcommandArgs<[increment: number]>) { + return this.chain("incrby", ...args); } - overflow(overflow: "WRAP" | "SAT" | "FAIL"): this { - this.pushSerializedArgs("overflow", overflow); - return this; + overflow(overflow: "WRAP" | "SAT" | "FAIL") { + return this.chain("overflow", overflow); } - override exec(client = this.client): Promise { - return this.exec(client); + override exec(): Promise { + return super.exec(this.client); } } diff --git a/pkg/redis.ts b/pkg/redis.ts index d5c8a350..a22abec3 100644 --- a/pkg/redis.ts +++ b/pkg/redis.ts @@ -411,7 +411,7 @@ export class Redis { * redis.set("mykey", 0); * const result = await redis.bitfield("mykey") * .set("u4", 0, 16) - * .incr("u4", 4, 1) + * .incr("u4", "#1", 1) * .exec(); * console.log(result); // [0, 1] * ``` @@ -419,7 +419,7 @@ export class Redis { * @see https://redis.io/commands/bitfield */ bitfield = (...args: CommandArgs) => - new BitFieldCommand(args, this.opts, this.client); + new BitFieldCommand(args, this.client, this.opts); /** * @see https://redis.io/commands/append From dace481e86a589718d0df5174be471d322310990 Mon Sep 17 00:00:00 2001 From: "J. Lewis" <6710419+lewxdev@users.noreply.github.com> Date: Fri, 28 Jun 2024 19:51:41 -0400 Subject: [PATCH 3/6] fix: type constraints and protected props --- pkg/commands/bitfield.ts | 6 +++--- pkg/commands/command.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pkg/commands/bitfield.ts b/pkg/commands/bitfield.ts index a9cf7189..92fa1e78 100644 --- a/pkg/commands/bitfield.ts +++ b/pkg/commands/bitfield.ts @@ -2,15 +2,15 @@ import { type Requester } from "../http"; import { Command, type CommandOptions } from "./command"; type SubcommandArgs = [ - encoding: `u${number}` | `i${number}`, // u1 - u63 | i1 - i64 - offset: number | `#${number}`, // any int + encoding: string, // u1 - u63 | i1 - i64 + offset: number | string, // # | ...Rest, ]; /** * @see https://redis.io/commands/bitfield */ -export class BitFieldCommand extends Command { +export class BitFieldCommand extends Command { constructor( cmd: [key: string], private client: Requester, diff --git a/pkg/commands/command.ts b/pkg/commands/command.ts index e25c4b2a..055c7aa3 100644 --- a/pkg/commands/command.ts +++ b/pkg/commands/command.ts @@ -37,9 +37,9 @@ export type CommandOptions = { * TResult is the raw data returned from upstash, which may need to be transformed or parsed. */ export class Command { - public readonly command: (string | number | boolean)[]; - public readonly serialize: Serialize; - public readonly deserialize: Deserialize; + protected readonly command: (string | number | boolean)[]; + protected readonly serialize: Serialize; + protected readonly deserialize: Deserialize; /** * Create a new command instance. * From db020ac458c2aec8213921e15883d40e7fc9a5e8 Mon Sep 17 00:00:00 2001 From: "J. Lewis" <6710419+lewxdev@users.noreply.github.com> Date: Fri, 28 Jun 2024 19:56:00 -0400 Subject: [PATCH 4/6] fix: type constraints --- pkg/commands/bitfield.ts | 2 +- pkg/commands/command.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/commands/bitfield.ts b/pkg/commands/bitfield.ts index 92fa1e78..1b5202b9 100644 --- a/pkg/commands/bitfield.ts +++ b/pkg/commands/bitfield.ts @@ -10,7 +10,7 @@ type SubcommandArgs = [ /** * @see https://redis.io/commands/bitfield */ -export class BitFieldCommand extends Command { +export class BitFieldCommand extends Command { constructor( cmd: [key: string], private client: Requester, diff --git a/pkg/commands/command.ts b/pkg/commands/command.ts index 055c7aa3..e25c4b2a 100644 --- a/pkg/commands/command.ts +++ b/pkg/commands/command.ts @@ -37,9 +37,9 @@ export type CommandOptions = { * TResult is the raw data returned from upstash, which may need to be transformed or parsed. */ export class Command { - protected readonly command: (string | number | boolean)[]; - protected readonly serialize: Serialize; - protected readonly deserialize: Deserialize; + public readonly command: (string | number | boolean)[]; + public readonly serialize: Serialize; + public readonly deserialize: Deserialize; /** * Create a new command instance. * From 9b2d5fa0f0e4952fabd362643ac999f38570d478 Mon Sep 17 00:00:00 2001 From: "J. Lewis" <6710419+lewxdev@users.noreply.github.com> Date: Mon, 1 Jul 2024 11:08:37 -0700 Subject: [PATCH 5/6] feat: add bitfield to pipeline (also addresses pr comments) --- pkg/commands/bitfield.ts | 57 +++++++++++++++++++++++++++++----------- pkg/pipeline.test.ts | 8 +++++- pkg/pipeline.ts | 19 ++++++++++++++ 3 files changed, 68 insertions(+), 16 deletions(-) diff --git a/pkg/commands/bitfield.ts b/pkg/commands/bitfield.ts index 1b5202b9..106a12f6 100644 --- a/pkg/commands/bitfield.ts +++ b/pkg/commands/bitfield.ts @@ -1,22 +1,21 @@ import { type Requester } from "../http"; +import { Pipeline } from "../pipeline"; import { Command, type CommandOptions } from "./command"; -type SubcommandArgs = [ +type SubCommandArgs = [ encoding: string, // u1 - u63 | i1 - i64 - offset: number | string, // # | - ...Rest, + offset: number | string, // | # + ...TRest, ]; /** * @see https://redis.io/commands/bitfield */ -export class BitFieldCommand extends Command { - constructor( - cmd: [key: string], - private client: Requester, - opts?: CommandOptions, - ) { - super(["bitfield", ...cmd], opts); +class BitFieldCommandFactory { + protected command: (string | number)[]; + + constructor(cmd: [key: string]) { + this.command = ["bitfield", ...cmd]; } private chain(...args: typeof this.command): this { @@ -24,23 +23,51 @@ export class BitFieldCommand extends Command { return this; } - get(...args: SubcommandArgs) { + get(...args: SubCommandArgs) { return this.chain("get", ...args); } - set(...args: SubcommandArgs<[value: number]>) { + set(...args: SubCommandArgs<[value: number]>) { return this.chain("set", ...args); } - incrby(...args: SubcommandArgs<[increment: number]>) { + incrby(...args: SubCommandArgs<[increment: number]>) { return this.chain("incrby", ...args); } overflow(overflow: "WRAP" | "SAT" | "FAIL") { return this.chain("overflow", overflow); } +} + +export class BitFieldCommand extends BitFieldCommandFactory { + constructor( + cmd: [key: string], + private client: Requester, + private opts?: CommandOptions, + ) { + super(cmd); + } + + exec(): Promise { + return new Command(this.command, this.opts).exec(this.client); + } +} + +export class BitFieldPipeline< + TCommands extends Command[], +> extends BitFieldCommandFactory { + constructor( + cmd: [key: string], + private pipeline: Pipeline, + private opts?: CommandOptions, + ) { + super(cmd); + } - override exec(): Promise { - return super.exec(this.client); + exec() { + const command = new Command(this.command, this.opts); + // biome-ignore lint/complexity/useLiteralKeys: chain is a private method we don't want to expose to the user + return this.pipeline["chain"](command); } } diff --git a/pkg/pipeline.test.ts b/pkg/pipeline.test.ts index 2c571a7c..c3cee85a 100644 --- a/pkg/pipeline.test.ts +++ b/pkg/pipeline.test.ts @@ -123,6 +123,12 @@ describe("use all the things", () => { p.append(newKey(), "hello") .bitcount(newKey(), 0, 1) + .bitfield(newKey()) + .set("u4", "#0", 15) + .get("u4", "#0") + .overflow("WRAP") + .incrby("u4", "#0", 10) + .exec() .bitop("and", newKey(), newKey()) .bitpos(newKey(), 1, 0) .dbsize() @@ -243,6 +249,6 @@ describe("use all the things", () => { .json.set(newKey(), "$", { hello: "world" }); const res = await p.exec(); - expect(res.length).toEqual(120); + expect(res.length).toEqual(121); }); }); diff --git a/pkg/pipeline.ts b/pkg/pipeline.ts index 18194eea..5076ebfa 100644 --- a/pkg/pipeline.ts +++ b/pkg/pipeline.ts @@ -3,6 +3,7 @@ import { HRandFieldCommand } from "./commands/hrandfield"; import { AppendCommand, BitCountCommand, + BitFieldPipeline, BitOpCommand, BitPosCommand, CopyCommand, @@ -322,6 +323,24 @@ export class Pipeline[] = []> { bitcount = (...args: CommandArgs) => this.chain(new BitCountCommand(args, this.commandOptions)); + /** + * Returns an instance that can be used to execute `BITFIELD` commands on one key. + * + * @example + * ```typescript + * redis.set("mykey", 0); + * const result = await redis.pipeline() + * .bitfield("mykey") + * .set("u4", 0, 16) + * .incr("u4", "#1", 1) + * .exec(); + * console.log(result); // [[0, 1]] + * ``` + * + * @see https://redis.io/commands/bitfield + */ + bitfield = (...args: CommandArgs) => new BitFieldPipeline(args, this); + /** * @see https://redis.io/commands/bitop */ From 69ffc5b7b97f854fe2b18d2d5d3f571c963deda0 Mon Sep 17 00:00:00 2001 From: "J. Lewis" <6710419+lewxdev@users.noreply.github.com> Date: Mon, 1 Jul 2024 22:33:01 -0700 Subject: [PATCH 6/6] fix: code complexity opt to pass private properties / methods from pipeline directly into the constructor for the bitfield command. no need to create two separate classes for command and pipeline --- pkg/commands/bitfield.ts | 46 +++++++++++----------------------------- pkg/pipeline.ts | 5 +++-- 2 files changed, 15 insertions(+), 36 deletions(-) diff --git a/pkg/commands/bitfield.ts b/pkg/commands/bitfield.ts index 106a12f6..3b5da3e7 100644 --- a/pkg/commands/bitfield.ts +++ b/pkg/commands/bitfield.ts @@ -1,5 +1,4 @@ import { type Requester } from "../http"; -import { Pipeline } from "../pipeline"; import { Command, type CommandOptions } from "./command"; type SubCommandArgs = [ @@ -11,14 +10,20 @@ type SubCommandArgs = [ /** * @see https://redis.io/commands/bitfield */ -class BitFieldCommandFactory { - protected command: (string | number)[]; +export class BitFieldCommand> { + private command: (string | number)[]; - constructor(cmd: [key: string]) { - this.command = ["bitfield", ...cmd]; + constructor( + args: [key: string], + private client: Requester, + private opts?: CommandOptions, + private execOperation = (command: Command) => + command.exec(this.client) as T, + ) { + this.command = ["bitfield", ...args]; } - private chain(...args: typeof this.command): this { + private chain(...args: typeof this.command) { this.command.push(...args); return this; } @@ -38,36 +43,9 @@ class BitFieldCommandFactory { overflow(overflow: "WRAP" | "SAT" | "FAIL") { return this.chain("overflow", overflow); } -} - -export class BitFieldCommand extends BitFieldCommandFactory { - constructor( - cmd: [key: string], - private client: Requester, - private opts?: CommandOptions, - ) { - super(cmd); - } - - exec(): Promise { - return new Command(this.command, this.opts).exec(this.client); - } -} - -export class BitFieldPipeline< - TCommands extends Command[], -> extends BitFieldCommandFactory { - constructor( - cmd: [key: string], - private pipeline: Pipeline, - private opts?: CommandOptions, - ) { - super(cmd); - } exec() { const command = new Command(this.command, this.opts); - // biome-ignore lint/complexity/useLiteralKeys: chain is a private method we don't want to expose to the user - return this.pipeline["chain"](command); + return this.execOperation(command); } } diff --git a/pkg/pipeline.ts b/pkg/pipeline.ts index 5076ebfa..003bd61d 100644 --- a/pkg/pipeline.ts +++ b/pkg/pipeline.ts @@ -3,7 +3,7 @@ import { HRandFieldCommand } from "./commands/hrandfield"; import { AppendCommand, BitCountCommand, - BitFieldPipeline, + BitFieldCommand, BitOpCommand, BitPosCommand, CopyCommand, @@ -339,7 +339,8 @@ export class Pipeline[] = []> { * * @see https://redis.io/commands/bitfield */ - bitfield = (...args: CommandArgs) => new BitFieldPipeline(args, this); + bitfield = (...args: CommandArgs) => + new BitFieldCommand(args, this.client, this.commandOptions, this.chain.bind(this)); /** * @see https://redis.io/commands/bitop