Skip to content

Commit 2ed0cab

Browse files
feat: add eval_ro and evalsha_ro commands
1 parent 8154476 commit 2ed0cab

13 files changed

+274
-5
lines changed

Diff for: pkg/auto-pipeline.test.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ describe("Auto pipeline", () => {
3434
redis.decrby(newKey(), 1),
3535
redis.del(newKey()),
3636
redis.echo("hello"),
37+
redis.eval_ro("return ARGV[1]", [], ["Hello"]),
3738
redis.eval("return ARGV[1]", [], ["Hello"]),
39+
redis.evalsha_ro(scriptHash, [], ["Hello"]),
3840
redis.evalsha(scriptHash, [], ["Hello"]),
3941
redis.exists(newKey()),
4042
redis.expire(newKey(), 5),
@@ -149,7 +151,7 @@ describe("Auto pipeline", () => {
149151
redis.json.arrappend(persistentKey3, "$.log", '"three"'),
150152
]);
151153
expect(result).toBeTruthy();
152-
expect(result.length).toBe(122); // returns
154+
expect(result.length).toBe(124); // returns
153155
// @ts-expect-error pipelineCounter is not in type but accessible120 results
154156
expect(redis.pipelineCounter).toBe(1);
155157
});

Diff for: pkg/commands/eval_ro.test.ts

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { EvalROCommand } from "./eval_ro";
2+
3+
import { keygen, newHttpClient, randomID } from "../test-utils";
4+
5+
import { afterAll, describe, expect, test } from "bun:test";
6+
import { SetCommand } from "./set";
7+
const client = newHttpClient();
8+
9+
const { newKey, cleanup } = keygen();
10+
afterAll(cleanup);
11+
12+
describe("without keys", () => {
13+
test("returns something", async () => {
14+
const value = randomID();
15+
const res = await new EvalROCommand(["return ARGV[1]", [], [value]]).exec(client);
16+
expect(res).toEqual(value);
17+
});
18+
});
19+
20+
describe("with keys", () => {
21+
test("returns something", async () => {
22+
const value = randomID();
23+
const key = newKey();
24+
await new SetCommand([key, value]).exec(client);
25+
const res = await new EvalROCommand([`return redis.call("GET", KEYS[1])`, [key], []]).exec(
26+
client
27+
);
28+
expect(res).toEqual(value);
29+
});
30+
});
31+
32+
describe("with keys and write commands", () => {
33+
test("throws", async () => {
34+
const value = randomID();
35+
const key = newKey();
36+
await new SetCommand([key, value]).exec(client);
37+
expect(async () => {
38+
await new EvalROCommand([`return redis.call("DEL", KEYS[1])`, [key], []]).exec(client);
39+
}).toThrow();
40+
});
41+
});

Diff for: pkg/commands/eval_ro.ts

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { CommandOptions } from "./command";
2+
import { Command } from "./command";
3+
4+
/**
5+
* @see https://redis.io/commands/eval_ro
6+
*/
7+
export class EvalROCommand<TArgs extends unknown[], TData> extends Command<unknown, TData> {
8+
constructor(
9+
[script, keys, args]: [script: string, keys: string[], args: TArgs],
10+
opts?: CommandOptions<unknown, TData>
11+
) {
12+
super(["eval_ro", script, keys.length, ...keys, ...(args ?? [])], opts);
13+
}
14+
}

Diff for: pkg/commands/evalsha_ro.test.ts

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { keygen, newHttpClient, randomID } from "../test-utils";
2+
3+
import { afterAll, describe, expect, test } from "bun:test";
4+
import { EvalshaROCommand } from "./evalsha_ro";
5+
import { ScriptLoadCommand } from "./script_load";
6+
import { SetCommand } from "./set";
7+
8+
const client = newHttpClient();
9+
10+
const { newKey, cleanup } = keygen();
11+
afterAll(cleanup);
12+
13+
describe("without keys", () => {
14+
test("returns something", async () => {
15+
const value = randomID();
16+
const sha1 = await new ScriptLoadCommand([`return {ARGV[1], "${value}"}`]).exec(client);
17+
const res = await new EvalshaROCommand([sha1, [], [value]]).exec(client);
18+
expect(res).toEqual([value, value]);
19+
});
20+
});
21+
22+
describe("with keys", () => {
23+
test("returns something", async () => {
24+
const value = randomID();
25+
const key = newKey();
26+
await new SetCommand([key, value]).exec(client);
27+
const sha1 = await new ScriptLoadCommand([`return redis.call("GET", KEYS[1])`]).exec(client);
28+
const res = await new EvalshaROCommand([sha1, [key], []]).exec(client);
29+
expect(res).toEqual(value);
30+
});
31+
});
32+
33+
describe("with keys and write commands", () => {
34+
test("throws", async () => {
35+
const value = randomID();
36+
const key = newKey();
37+
await new SetCommand([key, value]).exec(client);
38+
const sha1 = await new ScriptLoadCommand([`return redis.call("DEL", KEYS[1])`]).exec(client);
39+
expect(async () => {
40+
await new EvalshaROCommand([sha1, [key], []]).exec(client);
41+
}).toThrow();
42+
});
43+
});

Diff for: pkg/commands/evalsha_ro.ts

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { CommandOptions } from "./command";
2+
import { Command } from "./command";
3+
4+
/**
5+
* @see https://redis.io/commands/evalsha_ro
6+
*/
7+
export class EvalshaROCommand<TArgs extends unknown[], TData> extends Command<unknown, TData> {
8+
constructor(
9+
[sha, keys, args]: [sha: string, keys: string[], args?: TArgs],
10+
opts?: CommandOptions<unknown, TData>
11+
) {
12+
super(["evalsha_ro", sha, keys.length, ...keys, ...(args ?? [])], opts);
13+
}
14+
}

Diff for: pkg/commands/mod.ts

+2
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ export * from "./decr";
1010
export * from "./decrby";
1111
export * from "./del";
1212
export * from "./echo";
13+
export * from "./eval_ro";
1314
export * from "./eval";
15+
export * from "./evalsha_ro";
1416
export * from "./evalsha";
1517
export * from "./exec";
1618
export * from "./exists";

Diff for: pkg/commands/types.ts

+2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ export { type DecrCommand } from "./decr";
88
export { type DecrByCommand } from "./decrby";
99
export { type DelCommand } from "./del";
1010
export { type EchoCommand } from "./echo";
11+
export { type EvalROCommand } from "./eval_ro";
1112
export { type EvalCommand } from "./eval";
13+
export { type EvalshaROCommand } from "./evalsha_ro";
1214
export { type EvalshaCommand } from "./evalsha";
1315
export { type ExistsCommand } from "./exists";
1416
export { type ExpireCommand } from "./expire";

Diff for: pkg/pipeline.test.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,9 @@ describe("use all the things", () => {
136136
.decrby(newKey(), 1)
137137
.del(newKey())
138138
.echo("hello")
139+
.eval_ro("return ARGV[1]", [], ["Hello"])
139140
.eval("return ARGV[1]", [], ["Hello"])
141+
.evalsha_ro(scriptHash, [], ["Hello"])
140142
.evalsha(scriptHash, [], ["Hello"])
141143
.exists(newKey())
142144
.expire(newKey(), 5)
@@ -250,7 +252,7 @@ describe("use all the things", () => {
250252
.json.set(newKey(), "$", { hello: "world" });
251253

252254
const res = await p.exec();
253-
expect(res.length).toEqual(122);
255+
expect(res.length).toEqual(124);
254256
});
255257
});
256258
describe("keep errors", () => {

Diff for: pkg/pipeline.ts

+16
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ import {
1818
DecrCommand,
1919
DelCommand,
2020
EchoCommand,
21+
EvalROCommand,
2122
EvalCommand,
23+
EvalshaROCommand,
2224
EvalshaCommand,
2325
ExistsCommand,
2426
ExpireAtCommand,
@@ -449,13 +451,27 @@ export class Pipeline<TCommands extends Command<any, any>[] = []> {
449451
echo = (...args: CommandArgs<typeof EchoCommand>) =>
450452
this.chain(new EchoCommand(args, this.commandOptions));
451453

454+
/**
455+
* @see https://redis.io/commands/eval_ro
456+
*/
457+
eval_ro = <TArgs extends unknown[], TData = unknown>(
458+
...args: [script: string, keys: string[], args: TArgs]
459+
) => this.chain(new EvalROCommand<TArgs, TData>(args, this.commandOptions));
460+
452461
/**
453462
* @see https://redis.io/commands/eval
454463
*/
455464
eval = <TArgs extends unknown[], TData = unknown>(
456465
...args: [script: string, keys: string[], args: TArgs]
457466
) => this.chain(new EvalCommand<TArgs, TData>(args, this.commandOptions));
458467

468+
/**
469+
* @see https://redis.io/commands/evalsha_ro
470+
*/
471+
evalsha_ro = <TArgs extends unknown[], TData = unknown>(
472+
...args: [sha1: string, keys: string[], args: TArgs]
473+
) => this.chain(new EvalshaROCommand<TArgs, TData>(args, this.commandOptions));
474+
459475
/**
460476
* @see https://redis.io/commands/evalsha
461477
*/

Diff for: pkg/redis.ts

+24-3
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ import {
1818
DecrCommand,
1919
DelCommand,
2020
EchoCommand,
21+
EvalROCommand,
2122
EvalCommand,
23+
EvalshaROCommand,
2224
EvalshaCommand,
2325
ExecCommand,
2426
ExistsCommand,
@@ -183,7 +185,8 @@ import { ZMScoreCommand } from "./commands/zmscore";
183185
import type { Requester, UpstashRequest, UpstashResponse } from "./http";
184186
import { Pipeline } from "./pipeline";
185187
import { Script } from "./script";
186-
import type { CommandArgs, RedisOptions, Telemetry } from "./types";
188+
import { ScriptRO } from "./script_ro";
189+
import type { CommandArgs, RedisOptions, Telemetry, ScriptOptions } from "./types";
187190

188191
// See https://github.com/upstash/upstash-redis/issues/342
189192
// why we need this export
@@ -393,9 +396,13 @@ export class Redis {
393396
}
394397
};
395398

396-
createScript(script: string): Script {
397-
return new Script(this, script);
399+
createScript(script: string): Script;
400+
createScript(script: string, opts: ScriptOptions & { readOnly: true }): ScriptRO;
401+
createScript(script: string, opts: ScriptOptions & { readOnly?: false }): Script;
402+
createScript(script: string, opts?: ScriptOptions): Script | ScriptRO {
403+
return opts?.readOnly ? new ScriptRO(this, script) : new Script(this, script);
398404
}
405+
399406
/**
400407
* Create a new pipeline that allows you to send requests in bulk.
401408
*
@@ -520,13 +527,27 @@ export class Redis {
520527
echo = (...args: CommandArgs<typeof EchoCommand>) =>
521528
new EchoCommand(args, this.opts).exec(this.client);
522529

530+
/**
531+
* @see https://redis.io/commands/eval_ro
532+
*/
533+
eval_ro = <TArgs extends unknown[], TData = unknown>(
534+
...args: [script: string, keys: string[], args: TArgs]
535+
) => new EvalROCommand<TArgs, TData>(args, this.opts).exec(this.client);
536+
523537
/**
524538
* @see https://redis.io/commands/eval
525539
*/
526540
eval = <TArgs extends unknown[], TData = unknown>(
527541
...args: [script: string, keys: string[], args: TArgs]
528542
) => new EvalCommand<TArgs, TData>(args, this.opts).exec(this.client);
529543

544+
/**
545+
* @see https://redis.io/commands/evalsha_ro
546+
*/
547+
evalsha_ro = <TArgs extends unknown[], TData = unknown>(
548+
...args: [sha1: string, keys: string[], args: TArgs]
549+
) => new EvalshaROCommand<TArgs, TData>(args, this.opts).exec(this.client);
550+
530551
/**
531552
* @see https://redis.io/commands/evalsha
532553
*/

Diff for: pkg/script_ro.test.ts

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { afterEach, describe, expect, test } from "bun:test";
2+
import { Redis } from "./redis";
3+
import { keygen, newHttpClient, randomID } from "./test-utils";
4+
const client = newHttpClient();
5+
6+
const { newKey, cleanup } = keygen();
7+
afterEach(cleanup);
8+
9+
describe("create a new readonly script", () => {
10+
test(
11+
"creates a new readonly script",
12+
async () => {
13+
const redis = new Redis(client);
14+
const value = randomID();
15+
const key = newKey();
16+
await redis.set(key, value);
17+
const script = redis.createScript("return redis.call('GET', KEYS[1]);", { readOnly: true });
18+
19+
const res = await script.eval_ro([key], []);
20+
expect(res).toEqual(value);
21+
},
22+
{ timeout: 15_000 }
23+
);
24+
25+
test(
26+
"throws when write commands are used",
27+
async () => {
28+
const redis = new Redis(client);
29+
const value = randomID();
30+
const key = newKey();
31+
await redis.set(key, value);
32+
const script = redis.createScript("return redis.call('DEL', KEYS[1]);", { readOnly: true });
33+
34+
expect(async () => {
35+
await script.eval_ro([key], []);
36+
}).toThrow();
37+
},
38+
{ timeout: 15_000 }
39+
);
40+
});

Diff for: pkg/script_ro.ts

+68
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import Hex from "crypto-js/enc-hex.js";
2+
import sha1 from "crypto-js/sha1.js";
3+
import type { Redis } from "./redis";
4+
5+
/**
6+
* Creates a new script.
7+
*
8+
* Scripts offer the ability to optimistically try to execute a script without having to send the
9+
* entire script to the server. If the script is loaded on the server, it tries again by sending
10+
* the entire script. Afterwards, the script is cached on the server.
11+
*
12+
* @example
13+
* ```ts
14+
* const redis = new Redis({...})
15+
*
16+
* const script = redis.createScript<string>("return ARGV[1];", { readOnly: true })
17+
* const arg1 = await script.eval_ro([], ["Hello World"])
18+
* expect(arg1, "Hello World")
19+
* ```
20+
*/
21+
export class ScriptRO<TResult = unknown> {
22+
public readonly script: string;
23+
public readonly sha1: string;
24+
private readonly redis: Redis;
25+
26+
constructor(redis: Redis, script: string) {
27+
this.redis = redis;
28+
this.sha1 = this.digest(script);
29+
this.script = script;
30+
}
31+
32+
/**
33+
* Send an `EVAL_RO` command to redis.
34+
*/
35+
public async eval_ro(keys: string[], args: string[]): Promise<TResult> {
36+
return await this.redis.eval_ro(this.script, keys, args);
37+
}
38+
39+
/**
40+
* Calculates the sha1 hash of the script and then calls `EVALSHA_RO`.
41+
*/
42+
public async evalsha_ro(keys: string[], args: string[]): Promise<TResult> {
43+
return await this.redis.evalsha_ro(this.sha1, keys, args);
44+
}
45+
46+
/**
47+
* Optimistically try to run `EVALSHA_RO` first.
48+
* If the script is not loaded in redis, it will fall back and try again with `EVAL_RO`.
49+
*
50+
* Following calls will be able to use the cached script
51+
*/
52+
public async exec(keys: string[], args: string[]): Promise<TResult> {
53+
const res = await this.redis.evalsha_ro(this.sha1, keys, args).catch(async (error) => {
54+
if (error instanceof Error && error.message.toLowerCase().includes("noscript")) {
55+
return await this.redis.eval_ro(this.script, keys, args);
56+
}
57+
throw error;
58+
});
59+
return res as TResult;
60+
}
61+
62+
/**
63+
* Compute the sha1 hash of the script and return its hex representation.
64+
*/
65+
private digest(s: string): string {
66+
return Hex.stringify(sha1(s));
67+
}
68+
}

Diff for: pkg/types.ts

+4
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,7 @@ export type RedisOptions = {
3333
enableAutoPipelining?: boolean;
3434
readYourWrites?: boolean;
3535
};
36+
37+
export type ScriptOptions = {
38+
readOnly?: boolean;
39+
};

0 commit comments

Comments
 (0)