Skip to content

Commit 16b2811

Browse files
committed
feat: implement type safety for JSON.GET and JSON.SET
1 parent e21c60c commit 16b2811

File tree

5 files changed

+177
-12
lines changed

5 files changed

+177
-12
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
"@cloudflare/workers-types": "^4.20241011.0",
5959
"@sinclair/typebox": "^0.34.21",
6060
"@types/node": "^22.7.5",
61+
"jsonpath-ts": "^0.1.1",
6162
"prettier": "^3.3.3",
6263
"prettier-plugin-organize-imports": "^4.1.0",
6364
"radashi": "13.0.0-beta.ffa4778",

pnpm-lock.yaml

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/commands/json.ts

Lines changed: 73 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,96 @@
11
import { StaticEncode, TSchema } from "@sinclair/typebox";
2+
import { Parse } from "jsonpath-ts";
23
import { RedisCommand } from "../command";
3-
import { RedisKey } from "../key";
4+
import { RedisJSONEntity } from "../key";
45
import { encodeModifiers, Modifiers } from "../modifier";
56
import { NX, XX } from "../modifiers";
7+
import { JSONPath } from "../utils/json-path";
68

79
/**
810
* Set the JSON value at path in key
911
* @see https://redis.io/commands/json.set/
1012
*/
11-
export function SET<T extends TSchema>(
12-
key: RedisKey<T>,
13-
path: string,
14-
value: StaticEncode<T>,
13+
export function SET<T extends TSchema, TPath extends JSONPath>(
14+
key: RedisJSONEntity<T>,
15+
path: TPath,
16+
value: Parse<TPath, StaticEncode<T>>,
1517
...modifiers: Modifiers<[NX | XX]>
1618
) {
1719
return new RedisCommand<"OK" | null>([
1820
"JSON.SET",
1921
key.name,
2022
path,
21-
key.encode(value),
23+
key.encode(value, path),
2224
...encodeModifiers(modifiers),
2325
]);
2426
}
2527

28+
type ParseMultiGet<TPaths extends JSONPath[], T> = (
29+
TPaths extends [
30+
infer TPath extends JSONPath,
31+
...infer TPaths extends JSONPath[],
32+
]
33+
? { [P in TPath]: Parse<TPath, T>[] } & ParseMultiGet<TPaths, T>
34+
: unknown
35+
) extends infer TResult
36+
? { [K in keyof TResult]: TResult[K] }
37+
: never;
38+
39+
/**
40+
* Retrieve the root (`$`) JSON value for the given key.
41+
*
42+
* @see https://redis.io/commands/json.get/
43+
*/
44+
export function GET<T extends TSchema>(
45+
key: RedisJSONEntity<T>,
46+
): RedisCommand<StaticEncode<T> | undefined>;
47+
2648
/**
27-
* Get the JSON value at path in key
49+
* Retrieve the JSON value at the given path in key.
50+
*
2851
* @see https://redis.io/commands/json.get/
2952
*/
30-
export function GET<T extends TSchema>(key: RedisKey<T>, paths: string[]) {
31-
return new RedisCommand<StaticEncode<T> | undefined>(
32-
["JSON.GET", key.name, ...paths],
33-
(result) => (result !== null ? key.decode(result) : undefined),
34-
);
53+
export function GET<T extends TSchema, TPath extends JSONPath>(
54+
key: RedisJSONEntity<T>,
55+
path: TPath,
56+
): RedisCommand<Parse<TPath, StaticEncode<T>> | undefined>;
57+
58+
/**
59+
* Retrieve the JSON values at the given paths in key.
60+
*
61+
* Returns an object with the paths as keys. Each key points to an array of
62+
* parsed JSON values.
63+
*
64+
* @see https://redis.io/commands/json.get/
65+
*/
66+
export function GET<
67+
T extends TSchema,
68+
TPaths extends [JSONPath, ...JSONPath[]],
69+
>(
70+
key: RedisJSONEntity<T>,
71+
paths: TPaths,
72+
): RedisCommand<ParseMultiGet<TPaths, StaticEncode<T>> | undefined>;
73+
74+
export function GET(key: RedisJSONEntity, path?: JSONPath | JSONPath[]) {
75+
if (!path) {
76+
return new RedisCommand(["JSON.GET", key.name], (result) =>
77+
result !== null ? JSON.parse(result) : undefined,
78+
);
79+
}
80+
if (!Array.isArray(path)) {
81+
return new RedisCommand(
82+
["JSON.GET", key.name],
83+
(result) => JSON.parse(result)[0],
84+
);
85+
}
86+
const paths: JSONPath[] = path;
87+
if (!paths.length) {
88+
throw new Error("Expected at least one path");
89+
}
90+
return new RedisCommand(["JSON.GET", key.name, ...paths], (results) => {
91+
// Normalize the results to an object with the paths as keys.
92+
return paths.length > 1
93+
? JSON.parse(results)
94+
: { [paths[0]]: JSON.parse(results) };
95+
});
3596
}

src/key.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { StaticEncode, TObject, TSchema } from "@sinclair/typebox/type";
33
import { Decode, Encode } from "@sinclair/typebox/value";
44
import { RedisValue } from "./command";
55
import { RedisTransform } from "./transform";
6+
import { JSONPath, resolveSchemaForJSONPath } from "./utils/json-path";
67

78
/**
89
* Represents a namespace of keys in a Redis database.
@@ -81,6 +82,40 @@ export class RedisEntity<
8182
};
8283
}
8384

85+
/**
86+
* Represents a key in a Redis database with a JSON value.
87+
*
88+
* You must use `JSON.*` commands to read or edit this key's value. Of
89+
* course, you may use key-focused commands (e.g. `DEL` may delete this
90+
* key).
91+
*
92+
* @see https://redis.io/docs/latest/develop/data-types/json/
93+
*/
94+
export class RedisJSONEntity<
95+
T extends TSchema = TSchema,
96+
K extends string | RedisKeyspace = string,
97+
> extends RedisKey<T, K> {
98+
declare $$typeof: "RedisKey" & { subtype: "RedisJSONEntity" };
99+
100+
encode(value: unknown, path?: JSONPath): RedisValue {
101+
// The schema is defined for JS, not Redis, so a "decoded" value
102+
// represents a Redis value.
103+
return Decode(
104+
path ? resolveSchemaForJSONPath(this.schema, path) : this.schema,
105+
value,
106+
);
107+
}
108+
109+
decode(value: unknown, path?: JSONPath): StaticEncode<T> {
110+
// The schema is defined for JS, not Redis, so an "encoded" value
111+
// represents a JS value.
112+
return Encode(
113+
path ? resolveSchemaForJSONPath(this.schema, path) : this.schema,
114+
value,
115+
);
116+
}
117+
}
118+
84119
export class RedisSet<
85120
T extends TSchema = TSchema,
86121
K extends string | RedisKeyspace = string,

src/utils/json-path.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { KindGuard, TSchema } from "@sinclair/typebox";
2+
import * as Type from "@sinclair/typebox/type";
3+
4+
export type JSONPath = `$${string}`;
5+
6+
/**
7+
* Given a TypeBox schema and a JSON path, return the TypeBox schema that
8+
* corresponds to the JSON path.
9+
*/
10+
export function resolveSchemaForJSONPath(
11+
schema: TSchema,
12+
path: JSONPath,
13+
state = parseJSONPath(path),
14+
) {
15+
const part = state.parts[state.index++];
16+
if (!part) {
17+
return schema;
18+
}
19+
if (part === "$") {
20+
return resolveSchemaForJSONPath(schema, path, state);
21+
}
22+
let key: string | undefined;
23+
if (part.charCodeAt(0) === 46 /* . */) {
24+
key = part.slice(1);
25+
} else if (part.charCodeAt(0) === 91 /* [ */) {
26+
if (part.charCodeAt(1) === 39 /* ' */) {
27+
key = part.slice(2, -2);
28+
}
29+
// If no single quote is present, then this is either [*] or a specific
30+
// array index, like [0] or [1].
31+
else if (KindGuard.IsArray(schema)) {
32+
return resolveSchemaForJSONPath(schema.items, path, state);
33+
}
34+
}
35+
if (key != null && KindGuard.IsObject(schema) && key in schema.properties) {
36+
return resolveSchemaForJSONPath(schema.properties[key], path, state);
37+
}
38+
return Type.Never();
39+
}
40+
41+
function parseJSONPath(path: JSONPath) {
42+
return {
43+
parts: path.match(/(^\$|\.[^.[\]*]+|\[(?:\*|\d+|'[^']+')\])/gi) ?? [],
44+
index: 0,
45+
};
46+
}

0 commit comments

Comments
 (0)