Skip to content

Commit 2b1994b

Browse files
author
Andrew Brookins
committed
Disable features without required Redis modules
Some features, like querying and embedded models, require either the RediSearch or RedisJSON modules running in Redis. Without these modules, using these features would result in inscrutable errors. We now disable some tests if the Redis module required for the test is not found in the Redis instance the tests are using, and raise errors or log messages if the same is true during execution of HashModel and JsonModel.
1 parent ca48b22 commit 2b1994b

File tree

8 files changed

+269
-10
lines changed

8 files changed

+269
-10
lines changed

Makefile

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,14 @@ format: $(INSTALL_STAMP)
5656
test: $(INSTALL_STAMP)
5757
$(POETRY) run pytest -n auto -s -vv ./tests/ --cov-report term-missing --cov $(NAME)
5858

59+
.PHONY: test_oss
60+
test_oss: $(INSTALL_STAMP)
61+
# Specifically tests against a local OSS Redis instance via
62+
# docker-compose.yml. Do not use this for CI testing, where we should
63+
# instead have a matrix of Docker images.
64+
REDIS_OM_URL="redis://localhost:6381" $(POETRY) run pytest -n auto -s -vv ./tests/ --cov-report term-missing --cov $(NAME)
65+
66+
5967
.PHONY: shell
6068
shell: $(INSTALL_STAMP)
6169
$(POETRY) shell

docker-compose.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,11 @@ services:
99
- "6380:6379"
1010
volumes:
1111
- ./data:/data
12+
13+
oss_redis:
14+
image: "redis:latest"
15+
restart: always
16+
ports:
17+
- "6381:6379"
18+
volumes:
19+
- ./oss_data:/oss_data

docs/getting_started.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -112,13 +112,13 @@ We're almost ready to create a Redis OM model! But first, we need to make sure t
112112

113113
By default, Redis OM tries to connect to Redis on your localhost at port 6379. Most local install methods will result in Redis running at this location, in which case you don't need to do anything special.
114114

115-
However, if you configured Redis to run on a different port, or if you're using a remote Redis server, you'll need to set the `REDIS_URL` environment variable.
115+
However, if you configured Redis to run on a different port, or if you're using a remote Redis server, you'll need to set the `REDIS_OM_URL` environment variable.
116116

117-
The `REDIS_URL` environment variable follows the redis-py URL format:
117+
The `REDIS_OM_URL` environment variable follows the redis-py URL format:
118118

119119
redis://[[username]:[password]]@localhost:6379/[database number]
120120

121-
The default connection is equivalent to the following `REDIS_URL` environment variable:
121+
The default connection is equivalent to the following `REDIS_OM_URL` environment variable:
122122

123123
redis://@localhost:6379
124124

@@ -133,11 +133,11 @@ For more details about how to connect to Redis with Redis OM, see the [connectio
133133

134134
### Redis Cluster Support
135135

136-
Redis OM supports connecting to Redis Cluster, but this preview release does not support doing so with the `REDIS_URL` environment variable. However, you can connect by manually creating a connection object.
136+
Redis OM supports connecting to Redis Cluster, but this preview release does not support doing so with the `REDIS_OM_URL` environment variable. However, you can connect by manually creating a connection object.
137137

138138
See the [connections documentation](connections.md) for examples of connecting to Redis Cluster.
139139

140-
Support for connecting to Redis Cluster via `REDIS_URL` will be added in a future release.
140+
Support for connecting to Redis Cluster via `REDIS_OM_URL` will be added in a future release.
141141

142142
## Defining a Model
143143

redis_om/checks.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from functools import lru_cache
2+
from typing import List
3+
4+
from redis_om.connections import get_redis_connection
5+
6+
7+
@lru_cache(maxsize=None)
8+
def get_modules(conn) -> List[str]:
9+
modules = conn.execute_command("module", "list")
10+
return [m[1] for m in modules]
11+
12+
13+
@lru_cache(maxsize=None)
14+
def has_redis_json(conn=None):
15+
if conn is None:
16+
conn = get_redis_connection()
17+
names = get_modules(conn)
18+
return b"ReJSON" in names or "ReJSON" in names
19+
20+
21+
@lru_cache(maxsize=None)
22+
def has_redisearch(conn=None):
23+
if conn is None:
24+
conn = get_redis_connection()
25+
if has_redis_json(conn):
26+
return True
27+
names = get_modules(conn)
28+
return b"search" in names or "search" in names

redis_om/model/model.py

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from redis.client import Pipeline
3838
from ulid import ULID
3939

40+
from ..checks import has_redis_json, has_redisearch
4041
from ..connections import get_redis_connection
4142
from .encoders import jsonable_encoder
4243
from .render_tree import render_tree
@@ -121,6 +122,20 @@ def validate_model_fields(model: Type["RedisModel"], field_values: Dict[str, Any
121122
)
122123

123124

125+
def decode_redis_value(
126+
obj: Union[List[bytes], Dict[bytes, bytes], bytes], encoding: str
127+
) -> Union[List[str], Dict[str, str], str]:
128+
"""Decode a binary-encoded Redis hash into the specified encoding."""
129+
if isinstance(obj, list):
130+
return [v.decode(encoding) for v in obj]
131+
if isinstance(obj, dict):
132+
return {
133+
key.decode(encoding): value.decode(encoding) for key, value in obj.items()
134+
}
135+
elif isinstance(obj, bytes):
136+
return obj.decode(encoding)
137+
138+
124139
class ExpressionProtocol(Protocol):
125140
op: Operators
126141
left: ExpressionOrModelField
@@ -317,6 +332,11 @@ def __init__(
317332
page_size: int = DEFAULT_PAGE_SIZE,
318333
sort_fields: Optional[List[str]] = None,
319334
):
335+
if not has_redisearch(model.db()):
336+
raise RedisModelError("Your Redis instance does not have either the RediSearch module "
337+
"or RedisJSON module installed. Querying requires that your Redis "
338+
"instance has one of these modules installed.")
339+
320340
self.expressions = expressions
321341
self.model = model
322342
self.offset = offset
@@ -330,8 +350,8 @@ def __init__(
330350

331351
self._expression = None
332352
self._query: Optional[str] = None
333-
self._pagination: list[str] = []
334-
self._model_cache: list[RedisModel] = []
353+
self._pagination: List[str] = []
354+
self._model_cache: List[RedisModel] = []
335355

336356
def dict(self) -> Dict[str, Any]:
337357
return dict(
@@ -919,6 +939,7 @@ class MetaProtocol(Protocol):
919939
index_name: str
920940
abstract: bool
921941
embedded: bool
942+
encoding: str
922943

923944

924945
@dataclasses.dataclass
@@ -938,6 +959,7 @@ class DefaultMeta:
938959
index_name: Optional[str] = None
939960
abstract: Optional[bool] = False
940961
embedded: Optional[bool] = False
962+
encoding: Optional[str] = "utf-8"
941963

942964

943965
class ModelMeta(ModelMetaclass):
@@ -1007,6 +1029,8 @@ def __new__(cls, name, bases, attrs, **kwargs): # noqa C901
10071029
new_class._meta.database = getattr(
10081030
base_meta, "database", get_redis_connection()
10091031
)
1032+
if not getattr(new_class._meta, "encoding", None):
1033+
new_class._meta.encoding = getattr(base_meta, "encoding")
10101034
if not getattr(new_class._meta, "primary_key_creator_cls", None):
10111035
new_class._meta.primary_key_creator_cls = getattr(
10121036
base_meta, "primary_key_creator_cls", UlidPrimaryKey
@@ -1059,7 +1083,7 @@ def update(self, **field_values):
10591083
def save(self, pipeline: Optional[Pipeline] = None) -> "RedisModel":
10601084
raise NotImplementedError
10611085

1062-
@validator("pk", always=True)
1086+
@validator("pk", always=True, allow_reuse=True)
10631087
def validate_pk(cls, v):
10641088
if not v:
10651089
v = cls._meta.primary_key_creator_cls().create_pk()
@@ -1205,7 +1229,18 @@ def get(cls, pk: Any) -> "HashModel":
12051229
document = cls.db().hgetall(cls.make_primary_key(pk))
12061230
if not document:
12071231
raise NotFoundError
1208-
return cls.parse_obj(document)
1232+
try:
1233+
result = cls.parse_obj(document)
1234+
except TypeError as e:
1235+
log.warning(
1236+
f'Could not parse Redis response. Error was: "{e}". Probably, the '
1237+
"connection is not set to decode responses from bytes. "
1238+
"Attempting to decode response using the encoding set on "
1239+
f"model class ({cls.__class__}. Encoding: {cls.Meta.encoding}."
1240+
)
1241+
document = decode_redis_value(document, cls.Meta.encoding)
1242+
result = cls.parse_obj(document)
1243+
return result
12091244

12101245
@classmethod
12111246
@no_type_check
@@ -1316,6 +1351,9 @@ def schema_for_type(cls, name, typ: Any, field_info: PydanticFieldInfo):
13161351

13171352
class JsonModel(RedisModel, abc.ABC):
13181353
def __init_subclass__(cls, **kwargs):
1354+
if not has_redis_json(cls.db()):
1355+
log.error("Your Redis instance does not have the RedisJson module "
1356+
"loaded. JsonModel depends on RedisJson.")
13191357
# Generate the RediSearch schema once to validate fields.
13201358
cls.redisearch_schema()
13211359

tests/test_hash_model.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,15 @@
88
import pytest
99
from pydantic import ValidationError
1010

11+
from redis_om.checks import has_redisearch
1112
from redis_om.model import Field, HashModel
1213
from redis_om.model.migrations.migrator import Migrator
1314
from redis_om.model.model import NotFoundError, QueryNotSupportedError, RedisModelError
1415

1516

17+
if not has_redisearch():
18+
pytestmark = pytest.mark.skip
19+
1620
today = datetime.date.today()
1721

1822

tests/test_json_model.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,15 @@
88
import pytest
99
from pydantic import ValidationError
1010

11+
from redis_om.checks import has_redis_json
1112
from redis_om.model import EmbeddedJsonModel, Field, JsonModel
1213
from redis_om.model.migrations.migrator import Migrator
1314
from redis_om.model.model import NotFoundError, QueryNotSupportedError, RedisModelError
1415

1516

17+
if not has_redis_json():
18+
pytestmark = pytest.mark.skip
19+
1620
today = datetime.date.today()
1721

1822

@@ -477,7 +481,7 @@ def test_numeric_queries(members, m):
477481
actual = m.Member.find(m.Member.age == 34).all()
478482
assert actual == [member2]
479483

480-
actual = m.Member.find(m.Member.age > 34).all()
484+
actual = m.Member.find(m.Member.age > 34).sort_by("age").all()
481485
assert actual == [member1, member3]
482486

483487
actual = m.Member.find(m.Member.age < 35).all()

0 commit comments

Comments
 (0)