diff --git a/aredis_om/model/encoders.py b/aredis_om/model/encoders.py index aa42643..4007640 100644 --- a/aredis_om/model/encoders.py +++ b/aredis_om/model/encoders.py @@ -69,14 +69,6 @@ def jsonable_encoder( if exclude is not None and not isinstance(exclude, (set, dict)): exclude = set(exclude) - if custom_encoder: - if type(obj) in custom_encoder: - return custom_encoder[type(obj)](obj) - else: - for encoder_type, encoder in custom_encoder.items(): - if isinstance(obj, encoder_type): - return encoder(obj) - if isinstance(obj, BaseModel): encoder = getattr(obj.__config__, "json_encoders", {}) if custom_encoder: @@ -154,9 +146,13 @@ def jsonable_encoder( ) return encoded_list - # This function originally called custom encoders here, - # which meant we couldn't override the encoder for many - # types hard-coded into this function (lists, etc.). + if custom_encoder: + if type(obj) in custom_encoder: + return custom_encoder[type(obj)](obj) + else: + for encoder_type, encoder in custom_encoder.items(): + if isinstance(obj, encoder_type): + return encoder(obj) if type(obj) in ENCODERS_BY_TYPE: return ENCODERS_BY_TYPE[type(obj)](obj) diff --git a/aredis_om/model/model.py b/aredis_om/model/model.py index ac620ea..7d75030 100644 --- a/aredis_om/model/model.py +++ b/aredis_om/model/model.py @@ -63,6 +63,8 @@ SINGLE_VALUE_TAG_FIELD_SEPARATOR = "|" # multi-value field lookup, like a IN or NOT_IN. DEFAULT_REDISEARCH_FIELD_SEPARATOR = "," +ERRORS_URL = "https://github.com/redis/redis-om-python/blob/main/docs/errors.md" + class RedisModelError(Exception): """Raised when a problem exists in the definition of a RedisModel.""" @@ -288,6 +290,11 @@ class ExpressionProxy: left=self.field, op=Operators.IN, right=other, parents=self.parents ) + def __rshift__(self, other: Any) -> Expression: + return Expression( + left=self.field, op=Operators.NOT_IN, right=other, parents=self.parents + ) + def __getattr__(self, item): if is_supported_container_type(self.field.outer_type_): embedded_cls = get_args(self.field.outer_type_) @@ -295,7 +302,7 @@ class ExpressionProxy: raise QuerySyntaxError( "In order to query on a list field, you must define " "the contents of the list with a type annotation, like: " - "orders: List[Order]. Docs: TODO" + f"orders: List[Order]. Docs: {ERRORS_URL}#E1" ) embedded_cls = embedded_cls[0] attr = getattr(embedded_cls, item) @@ -419,7 +426,7 @@ class FindQuery: if not getattr(field_proxy.field.field_info, "sortable", False): raise QueryNotSupportedError( f"You tried sort by {field_name}, but {self.model} does " - "not define that field as sortable. See docs: XXX" + f"not define that field as sortable. Docs: {ERRORS_URL}#E2" ) return sort_fields @@ -433,7 +440,7 @@ class FindQuery: raise QuerySyntaxError( f"You tried to do a full-text search on the field '{field.name}', " f"but the field is not indexed for full-text search. Use the " - f"full_text_search=True option. Docs: TODO" + f"full_text_search=True option. Docs: {ERRORS_URL}#E3" ) return RediSearchFieldTypes.TEXT @@ -460,7 +467,7 @@ class FindQuery: elif container_type is not None: raise QuerySyntaxError( "Only lists and tuples are supported for multi-value fields. " - "See docs: TODO" + f"Docs: {ERRORS_URL}#E4" ) elif any(issubclass(field_type, t) for t in NUMERIC_TYPES): # Index numeric Python types as NUMERIC fields, so we can support @@ -519,8 +526,8 @@ class FindQuery: else: raise QueryNotSupportedError( "Only equals (=), not-equals (!=), and like() " - "comparisons are supported for TEXT fields. See " - "docs: TODO." + "comparisons are supported for TEXT fields. " + f"Docs: {ERRORS_URL}#E5" ) elif field_type is RediSearchFieldTypes.NUMERIC: if op is Operators.EQ: @@ -571,7 +578,6 @@ class FindQuery: value = escaper.escape(value) result += f"-(@{field_name}:{{{value}}})" elif op is Operators.IN: - # TODO: Implement IN, test this... expanded_value = cls.expand_tag_value(value) result += f"(@{field_name}:{{{expanded_value}}})" elif op is Operators.NOT_IN: @@ -651,13 +657,12 @@ class FindQuery: if not field_info or not getattr(field_info, "index", None): raise QueryNotSupportedError( f"You tried to query by a field ({field_name}) " - f"that isn't indexed. See docs: TODO" + f"that isn't indexed. Docs: {ERRORS_URL}#E6" ) else: raise QueryNotSupportedError( "A query expression should start with either a field " - "or an expression enclosed in parenthesis. See docs: " - "TODO" + f"or an expression enclosed in parentheses. Docs: {ERRORS_URL}#E7" ) right = expression.right @@ -670,7 +675,7 @@ class FindQuery: else: raise QueryNotSupportedError( "You can only combine two query expressions with" - "AND (&) or OR (|). See docs: TODO" + f"AND (&) or OR (|). Docs: {ERRORS_URL}#E8" ) if isinstance(right, NegatedExpression): diff --git a/docs/errors.md b/docs/errors.md new file mode 100644 index 0000000..9fde50f --- /dev/null +++ b/docs/errors.md @@ -0,0 +1,228 @@ +# Errors + +This page lists errors that Redis OM might generate while you're using it, with more context about the error. + +## E1 + +> In order to query on a list field, you must define the contents of the list with a type annotation, like: orders: List[Order]. + +You will see this error if you try to use an "IN" query, e.g., `await TarotWitch.find(TarotWitch.tarot_cards << "death").all()`, on a field that is not a list. + +In this example, `TarotWitch.tarot_cards` is a list, so the query works: + +```python +from typing import List + +from redis_om import JsonModel, Field + +class TarotWitch(JsonModel): + tarot_cards: List[str] = Field(index=True) +``` + +But if `tarot_cards` was _not_ a list, trying to query with `<<` would have resulted in this error. + +## E2 + +> You tried sort by {field_name}, but {self.model} does not define that field as sortable. + +You tried to sort query results by a field that is not sortable. Here is how you mark a field as sortable: + +```python +from typing import List + +from redis_om import JsonModel, Field + +class Member(JsonModel): + age: int = Field(index=True, sortable=True) +``` + +**NOTE:** Only an indexed field can be sortable. + +## E3 + +>You tried to do a full-text search on the field '{field.name}', but the field is not indexed for full-text search. Use the full_text_search=True option. + +You can make a full-text search with the module (`%`) operator. Such a query looks like this: + +```python +from redis_om import JsonModel, Field + +class Member(JsonModel): + bio: str = Field(index=True, full_text_search=True, default="") + +Member.find(Member.bio % "beaches").all() +``` + +If you see this error, it means that the field you are querying (`bio` in the example) is not indexed for full-text search. Make sure you're marking the field both `index=True` and `full_text_search=True`, as in the example. + +## E4 + +> Only lists and tuples are supported for multi-value fields. + +This means that you marked a field as `index=True`, but the field is not a type that Redis OM can actually index. + +Specifically, you probably used a _subscripted_ annotation, like `Dict[str, str]`. The only subscripted types that OM can index are `List` and `Tuple`. + +## E5 + +> Only equals (=), not-equals (!=), and like() comparisons are supported for TEXT fields. + +You are querying a field you marked as indexed for full-text search. You can only query such fields with the operators for equality (==), non-equality (!=), and like `(%)`. + +```python +from redis_om import JsonModel, Field + +class Member(JsonModel): + bio: str = Field(index=True, full_text_search=True, default="") + +# Equality +Member.find(Member.bio == "Programmer").all() + +# Non-equality +Member.find(Member.bio != "Programmer").all() + +# Like (full-text search). This stems "programming" +# to find any matching terms with the same stem, +# "program". +Member.find(Member.bio % "programming").all() +``` + +## E6 + +> You tried to query by a field ({field_name}) that isn't indexed. + +You wrote a query using a model field that you did not make indexed. You can only query indexed fields. Here is example code that would generate this error: + +```python +from redis_om import JsonModel, Field + +class Member(JsonModel): + first_name: str + bio: str = Field(index=True, full_text_search=True, default="") + +# Raises a QueryNotSupportedError because we did not make +# `first_name` indexed! +Member.find(Member.first_name == "Andrew").all() +``` + +Fix this by making the field indexed: + +```python +from redis_om import JsonModel, Field + +class Member(JsonModel): + first_name: str = Field(index=True) + bio: str = Field(index=True, full_text_search=True, default="") + +# Raises a QueryNotSupportedError because we did not make +# `first_name` indexed! +Member.find(Member.first_name == "Andrew").all() +``` + +## E7 + +> A query expression should start with either a field or an expression enclosed in parentheses. + +We got confused trying to parse your query expression. It's not you, it's us! Some code examples might help... + +```python +from redis_om import JsonModel, Field + +class Member(JsonModel): + first_name: str = Field(index=True) + last_name: str = Field(index=True) + + +# Queries with a single operator are usually simple: +Member.find(Member.first_name == "Andrew").all() + +# If you want to add multiple conditions, you can AND +# them together by including the conditions one after +# another as arguments. +Member.find(Member.first_name=="Andrew", + Member.last_name=="Brookins").all() + +# Alternatively, you can separate the conditions with +# parenthesis and use an explicit AND. +Member.find( + (Member.first_name == "Andrew") & ~(Member.last_name == "Brookins") +).all() + +# You can't use `!` to say NOT. Instead, use `~`. +Member.find( + (Member.first_name == "Andrew") & + ~(Member.last_name == "Brookins") # <- Notice, this one is NOT now! +).all() + +# Parenthesis are key to building more complex queries, +# like this one. +Member.find( + ~(Member.first_name == "Andrew") + & ((Member.last_name == "Brookins") | (Member.last_name == "Smith")) +).all() + +# If you're confused about how Redis OM interprets a query, +# use the `tree()` method to visualize the expression tree +# for a `FindQuery`. +query = Member.find( + ~(Member.first_name == "Andrew") + & ((Member.last_name == "Brookins") | (Member.last_name == "Smith")) +) +print(query.expression.tree) +""" + ┌first_name + ┌NOT EQ┤ + | └Andrew + AND┤ + | ┌last_name + | ┌EQ┤ + | | └Brookins + └OR┤ + | ┌last_name + └EQ┤ + └Smith +""" +``` + +## E8 + +> You can only combine two query expressions with AND (&) or OR (|). + +The only two operators you can use to combine expressions in a query +are `&` and `|`. You may have accidentally used another operator, +or Redis OM might be confused. Make sure you are using parentheses +to organize your query expressions. + +If you are trying to use "NOT," you can do that by prefixing a query +with the `~` operator, like this: + +```python +from redis_om import JsonModel, Field + +class Member(JsonModel): + first_name: str = Field(index=True) + last_name: str = Field(index=True) + + +# Find people who are not named Andrew. +Member.find(~(Member.first_name == "Andrew")).all() +``` + +Note that this form requires parenthesis around the expression +that you are "negating." Of course, this example makes more sense +with `!=`: + +```python +from redis_om import JsonModel, Field + +class Member(JsonModel): + first_name: str = Field(index=True) + last_name: str = Field(index=True) + + +# Find people who are not named Andrew. +Member.find(Member.first_name != "Andrew").all() +``` + +Still, `~` is useful to negate groups of expressions +surrounded by parentheses. diff --git a/tests/test_hash_model.py b/tests/test_hash_model.py index d1fd5b6..331e3ff 100644 --- a/tests/test_hash_model.py +++ b/tests/test_hash_model.py @@ -1,9 +1,11 @@ +# type: ignore + import abc import dataclasses import datetime import decimal from collections import namedtuple -from typing import Optional, Dict, Set, List +from typing import Dict, List, Optional, Set from unittest import mock import pytest @@ -372,24 +374,28 @@ def test_raises_error_with_dataclasses(m): address_line_1: str with pytest.raises(RedisModelError): + class InvalidMember(m.BaseHashModel): address: Address def test_raises_error_with_dicts(m): with pytest.raises(RedisModelError): + class InvalidMember(m.BaseHashModel): address: Dict[str, str] def test_raises_error_with_sets(m): with pytest.raises(RedisModelError): + class InvalidMember(m.BaseHashModel): friend_ids: Set[str] def test_raises_error_with_lists(m): with pytest.raises(RedisModelError): + class InvalidMember(m.BaseHashModel): friend_ids: List[str] diff --git a/tests/test_json_model.py b/tests/test_json_model.py index 15b7470..8b360f7 100644 --- a/tests/test_json_model.py +++ b/tests/test_json_model.py @@ -1,9 +1,11 @@ +# type: ignore + import abc import dataclasses import datetime import decimal from collections import namedtuple -from typing import List, Optional, Set, Dict +from typing import Dict, List, Optional, Set from unittest import mock import pytest @@ -687,7 +689,7 @@ async def test_allows_and_serializes_dicts(m): member2 = await ValidMember.get(member.pk) assert member2 == member - assert member2.address['address_line_1'] == "hey" + assert member2.address["address_line_1"] == "hey" @pytest.mark.asyncio