Skip to content

Commit

Permalink
Python: adds JSON.RESP command (valkey-io#2451)
Browse files Browse the repository at this point in the history
---------

Signed-off-by: Shoham Elias <[email protected]>
  • Loading branch information
shohamazon authored Oct 29, 2024
1 parent 1e0476c commit 2284c75
Show file tree
Hide file tree
Showing 5 changed files with 225 additions and 14 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
* Python: Add `JSON.ARRINSERT` command ([#2464](https://github.com/valkey-io/valkey-glide/pull/2464))
* Python: Add `JSON.ARRTRIM` command ([#2457](https://github.com/valkey-io/valkey-glide/pull/2457))
* Python: Add `JSON.ARRAPPEND` command ([#2382](https://github.com/valkey-io/valkey-glide/pull/2382))
* Python: Add `JSON.RESP` command ([#2451](https://github.com/valkey-io/valkey-glide/pull/2451))

#### Breaking Changes

Expand Down
4 changes: 4 additions & 0 deletions python/python/glide/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@
TFunctionListResponse,
TFunctionStatsFullResponse,
TFunctionStatsSingleNodeResponse,
TJsonResponse,
TJsonUniversalResponse,
TResult,
TSingleNodeRoute,
TXInfoStreamFullResponse,
Expand Down Expand Up @@ -177,6 +179,8 @@
"TFunctionListResponse",
"TFunctionStatsFullResponse",
"TFunctionStatsSingleNodeResponse",
"TJsonResponse",
"TJsonUniversalResponse",
"TOK",
"TResult",
"TXInfoStreamFullResponse",
Expand Down
89 changes: 75 additions & 14 deletions python/python/glide/async_commands/server_modules/json.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from typing import List, Optional, Union, cast

from glide.async_commands.core import ConditionalChange
from glide.constants import TOK, TEncodable, TJsonResponse
from glide.constants import TOK, TEncodable, TJsonResponse, TJsonUniversalResponse
from glide.glide_client import TGlideClient
from glide.protobuf.command_request_pb2 import RequestType

Expand Down Expand Up @@ -309,7 +309,7 @@ async def arrtrim(
end: int,
) -> TJsonResponse[int]:
"""
Trims an array at the specified `path` within the JSON document stored at `key` so that it becomes a subarray [start, end], both inclusive.
Trims an array at the specified `path` within the JSON document stored at `key` so that it becomes a subarray [start, end], both inclusive.
If `start` < 0, it is treated as 0.
If `end` >= size (size of the array), it is treated as size-1.
If `start` >= size or `start` > `end`, the array is emptied and 0 is returned.
Expand Down Expand Up @@ -412,7 +412,7 @@ async def debug_fields(
client: TGlideClient,
key: TEncodable,
path: Optional[TEncodable] = None,
) -> Optional[Union[int, List[int]]]:
) -> Optional[TJsonUniversalResponse[int]]:
"""
Returns the number of fields of the JSON value at the specified `path` within the JSON document stored at `key`.
- **Primitive Values**: Each non-container JSON value (e.g., strings, numbers, booleans, and null) counts as one field.
Expand All @@ -429,7 +429,7 @@ async def debug_fields(
path (Optional[TEncodable]): The path within the JSON document. Defaults to root if not provided.
Returns:
Optional[Union[int, List[int]]]:
Optional[TJsonUniversalResponse[int]]:
For JSONPath (`path` starts with `$`):
Returns an array of integers, each indicating the number of fields for each matched `path`.
If `path` doesn't exist, an empty array will be returned.
Expand Down Expand Up @@ -460,14 +460,16 @@ async def debug_fields(
if path:
args.append(path)

return cast(Optional[Union[int, List[int]]], await client.custom_command(args))
return cast(
Optional[TJsonUniversalResponse[int]], await client.custom_command(args)
)


async def debug_memory(
client: TGlideClient,
key: TEncodable,
path: Optional[TEncodable] = None,
) -> Optional[Union[int, List[int]]]:
) -> Optional[TJsonUniversalResponse[int]]:
"""
Reports memory usage in bytes of a JSON value at the specified `path` within the JSON document stored at `key`.
Expand All @@ -477,7 +479,7 @@ async def debug_memory(
path (Optional[TEncodable]): The path within the JSON document. Defaults to None.
Returns:
Optional[Union[int, List[int]]]:
Optional[TJsonUniversalResponse[int]]:
For JSONPath (`path` starts with `$`):
Returns an array of integers, indicating the memory usage in bytes of a JSON value for each matched `path`.
If `path` doesn't exist, an empty array will be returned.
Expand Down Expand Up @@ -506,7 +508,9 @@ async def debug_memory(
if path:
args.append(path)

return cast(Optional[Union[int, List[int]]], await client.custom_command(args))
return cast(
Optional[TJsonUniversalResponse[int]], await client.custom_command(args)
)


async def delete(
Expand Down Expand Up @@ -612,7 +616,7 @@ async def numincrby(
>>> from glide import json
>>> await json.set(client, "doc", "$", '{"a": [], "b": [1], "c": [1, 2], "d": [1, 2, 3]}')
'OK'
>>> await json.numincrby(client, "doc", "$.d[*]", 10)
>>> await json.numincrby(client, "doc", "$.d[*]", 10)
b'[11,12,13]' # Increment each element in `d` array by 10.
>>> await json.numincrby(client, "doc", ".c[1]", 10)
b'12' # Increment the second element in the `c` array by 10.
Expand Down Expand Up @@ -720,7 +724,7 @@ async def objkeys(
client: TGlideClient,
key: TEncodable,
path: Optional[TEncodable] = None,
) -> Optional[Union[List[bytes], List[List[bytes]]]]:
) -> Optional[TJsonUniversalResponse[List[bytes]]]:
"""
Retrieves key names in the object values at the specified `path` within the JSON document stored at `key`.
Expand All @@ -731,7 +735,7 @@ async def objkeys(
Defaults to None.
Returns:
Optional[Union[List[bytes], List[List[bytes]]]]:
Optional[TJsonUniversalResponse[List[bytes]]]:
For JSONPath (`path` starts with `$`):
Returns a list of arrays containing key names for each matching object.
If a value matching the path is not an object, an empty array is returned.
Expand Down Expand Up @@ -765,6 +769,61 @@ async def objkeys(
)


async def resp(
client: TGlideClient, key: TEncodable, path: Optional[TEncodable] = None
) -> TJsonUniversalResponse[
Optional[Union[bytes, int, List[Optional[Union[bytes, int]]]]]
]:
"""
Retrieve the JSON value at the specified `path` within the JSON document stored at `key`.
The returning result is in the Valkey or Redis OSS Serialization Protocol (RESP).\n
JSON null is mapped to the RESP Null Bulk String.\n
JSON Booleans are mapped to RESP Simple string.\n
JSON integers are mapped to RESP Integers.\n
JSON doubles are mapped to RESP Bulk Strings.\n
JSON strings are mapped to RESP Bulk Strings.\n
JSON arrays are represented as RESP arrays, where the first element is the simple string [, followed by the array's elements.\n
JSON objects are represented as RESP object, where the first element is the simple string {, followed by key-value pairs, each of which is a RESP bulk string.\n
Args:
client (TGlideClient): The client to execute the command.
key (TEncodable): The key of the JSON document.
path (Optional[TEncodable]): The path within the JSON document. Default to None.
Returns:
TJsonUniversalResponse[Optional[Union[bytes, int, List[Optional[Union[bytes, int]]]]]]
For JSONPath ('path' starts with '$'):
Returns a list of replies for every possible path, indicating the RESP form of the JSON value.
If `path` doesn't exist, returns an empty list.
For legacy path (`path` doesn't starts with `$`):
Returns a single reply for the JSON value at the specified path, in its RESP form.
This can be a bytes object, an integer, None, or a list representing complex structures.
If multiple paths match, the value of the first JSON value match is returned.
If `path` doesn't exist, an error is raised.
If `key` doesn't exist, an None is returned.
Examples:
>>> from glide import json
>>> await json.set(client, "doc", "$", '{"a": [1, 2, 3], "b": {"a": [1, 2], "c": {"a": 42}}}')
'OK'
>>> await json.resp(client, "doc", "$..a")
[[b"[", 1, 2, 3],[b"[", 1, 2],42]
>>> await json.resp(client, "doc", "..a")
[b"[", 1, 2, 3]
"""
args = ["JSON.RESP", key]
if path:
args.append(path)

return cast(
TJsonUniversalResponse[
Optional[Union[bytes, int, List[Optional[Union[bytes, int]]]]]
],
await client.custom_command(args),
)


async def strappend(
client: TGlideClient,
key: TEncodable,
Expand Down Expand Up @@ -910,7 +969,7 @@ async def type(
client: TGlideClient,
key: TEncodable,
path: Optional[TEncodable] = None,
) -> Optional[Union[bytes, List[bytes]]]:
) -> Optional[TJsonUniversalResponse[bytes]]:
"""
Retrieves the type of the JSON value at the specified `path` within the JSON document stored at `key`.
Expand All @@ -920,7 +979,7 @@ async def type(
path (Optional[TEncodable]): The path within the JSON document. Default to None.
Returns:
Optional[Union[bytes, List[bytes]]]:
Optional[TJsonUniversalResponse[bytes]]:
For JSONPath ('path' starts with '$'):
Returns a list of byte string replies for every possible path, indicating the type of the JSON value.
If `path` doesn't exist, an empty array will be returned.
Expand All @@ -945,4 +1004,6 @@ async def type(
if path:
args.append(path)

return cast(Optional[Union[bytes, List[bytes]]], await client.custom_command(args))
return cast(
Optional[TJsonUniversalResponse[bytes]], await client.custom_command(args)
)
19 changes: 19 additions & 0 deletions python/python/glide/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,27 @@
TSingleNodeRoute = Union[RandomNode, SlotKeyRoute, SlotIdRoute, ByAddressRoute]
# When specifying legacy path (path doesn't start with `$`), response will be T
# Otherwise, (when specifying JSONPath), response will be List[Optional[T]].
#
# TJsonResponse is designed to handle scenarios where some paths may not contain valid values, especially with JSONPath targeting multiple paths.
# In such cases, the response may include None values, represented as `Optional[T]` in the list.
# This type provides flexibility for commands where a subset of the paths may return None.
#
# For more information, see: https://redis.io/docs/data-types/json/path/ .
TJsonResponse = Union[T, List[Optional[T]]]

# When specifying legacy path (path doesn't start with `$`), response will be T
# Otherwise, (when specifying JSONPath), response will be List[T].
# This type represents the response format for commands that apply to every path and every type in a JSON document.
# It covers both singular and multiple paths, ensuring that the command returns valid results for each matched path without None values.
#
# TJsonUniversalResponse is considered "universal" because it applies to every matched path and
# guarantees valid, non-null results across all paths, covering both singular and multiple paths.
# This type is used for commands that return results from all matched paths, ensuring that each
# path contains meaningful values without None entries (unless it's part of the commands response).
# It is typically used in scenarios where each target is expected to yield a valid response. For commands that are valid for all target types.
#
# For more information, see: https://redis.io/docs/data-types/json/path/ .
TJsonUniversalResponse = Union[T, List[T]]
TEncodable = Union[str, bytes]
TFunctionListResponse = List[
Mapping[
Expand Down
126 changes: 126 additions & 0 deletions python/python/tests/tests_server_modules/test_json.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0

import copy
import json as OuterJson
import random
import typing

import pytest
Expand All @@ -14,6 +16,19 @@
from tests.test_async_client import get_random_string, parse_info_response


def get_random_value(value_type="str"):
if value_type == "int":
return random.randint(1, 100)
elif value_type == "float":
return round(random.uniform(1, 100), 2)
elif value_type == "str":
return "".join(random.choices("abcdefghijklmnopqrstuvwxyz", k=5))
elif value_type == "bool":
return random.choice([True, False])
elif value_type == "null":
return None


@pytest.mark.asyncio
class TestJson:
@pytest.mark.parametrize("cluster_mode", [True, False])
Expand Down Expand Up @@ -1385,3 +1400,114 @@ async def test_json_arrappend(self, glide_client: TGlideClient):
result = await json.get(glide_client, key, "$")
assert isinstance(result, bytes)
assert OuterJson.loads(result) == [[["c"], ["a", "c"], ["a", "b", "c"]]]

@pytest.mark.parametrize("cluster_mode", [True, False])
@pytest.mark.parametrize("protocol", [ProtocolVersion.RESP2, ProtocolVersion.RESP3])
async def test_json_resp(self, glide_client: TGlideClient):
key = get_random_string(5)

# Generate random JSON content with specified types
json_value = {
"obj": {"a": get_random_value("int"), "b": get_random_value("float")},
"arr": [get_random_value("int") for _ in range(3)],
"str": get_random_value("str"),
"bool": get_random_value("bool"),
"int": get_random_value("int"),
"float": get_random_value("float"),
"nullVal": get_random_value("null"),
}

json_value_expected = copy.deepcopy(json_value)
json_value_expected["obj"]["b"] = str(json_value["obj"]["b"]).encode()
json_value_expected["float"] = str(json_value["float"]).encode()
json_value_expected["str"] = str(json_value["str"]).encode()
json_value_expected["bool"] = str(json_value["bool"]).lower().encode()
assert (
await json.set(glide_client, key, "$", OuterJson.dumps(json_value)) == "OK"
)

assert await json.resp(glide_client, key, "$.*") == [
[
b"{",
[b"a", json_value_expected["obj"]["a"]],
[b"b", json_value_expected["obj"]["b"]],
],
[b"[", *json_value_expected["arr"]],
json_value_expected["str"],
json_value_expected["bool"],
json_value_expected["int"],
json_value_expected["float"],
json_value_expected["nullVal"],
]

# multiple path match, the first will be returned
assert await json.resp(glide_client, key, "*") == [
b"{",
[b"a", json_value_expected["obj"]["a"]],
[b"b", json_value_expected["obj"]["b"]],
]

assert await json.resp(glide_client, key, "$") == [
[
b"{",
[
b"obj",
[
b"{",
[b"a", json_value_expected["obj"]["a"]],
[b"b", json_value_expected["obj"]["b"]],
],
],
[b"arr", [b"[", *json_value_expected["arr"]]],
[
b"str",
json_value_expected["str"],
],
[
b"bool",
json_value_expected["bool"],
],
[b"int", json_value["int"]],
[
b"float",
json_value_expected["float"],
],
[b"nullVal", json_value["nullVal"]],
],
]

assert await json.resp(glide_client, key, "$.str") == [
json_value_expected["str"]
]
assert await json.resp(glide_client, key, ".str") == json_value_expected["str"]

# Further tests with a new random JSON structure
json_value = {
"a": [random.randint(1, 10) for _ in range(3)],
"b": {
"a": [random.randint(1, 10) for _ in range(2)],
"c": {"a": random.randint(1, 10)},
},
}
assert (
await json.set(glide_client, key, "$", OuterJson.dumps(json_value)) == "OK"
)

# Multiple path match
assert await json.resp(glide_client, key, "$..a") == [
[b"[", *json_value["a"]],
[b"[", *json_value["b"]["a"]],
json_value["b"]["c"]["a"],
]

assert await json.resp(glide_client, key, "..a") == [b"[", *json_value["a"]]

# Test for non-existent paths
assert await json.resp(glide_client, key, "$.nonexistent") == []
with pytest.raises(RequestError):
await json.resp(glide_client, key, "nonexistent")

# Test for non-existent key
assert await json.resp(glide_client, "nonexistent_key", "$") is None
assert await json.resp(glide_client, "nonexistent_key", ".") is None
assert await json.resp(glide_client, "nonexistent_key") is None

0 comments on commit 2284c75

Please sign in to comment.