Tests & errors for preview list limitations
This commit is contained in:
parent
389a6ea878
commit
c51a071982
3 changed files with 283 additions and 116 deletions
|
@ -33,6 +33,7 @@ from pydantic.fields import ModelField, Undefined, UndefinedType
|
|||
from pydantic.main import ModelMetaclass
|
||||
from pydantic.typing import NoArgAnyCallable
|
||||
from pydantic.utils import Representation
|
||||
from redis.client import Pipeline
|
||||
from ulid import ULID
|
||||
|
||||
from .encoders import jsonable_encoder
|
||||
|
@ -102,6 +103,19 @@ def embedded(cls):
|
|||
setattr(cls.Meta, 'embedded', True)
|
||||
|
||||
|
||||
def is_supported_container_type(typ: type) -> bool:
|
||||
if typ == list or typ == tuple:
|
||||
return True
|
||||
unwrapped = get_origin(typ)
|
||||
return unwrapped == list or unwrapped == tuple
|
||||
|
||||
|
||||
def validate_model_fields(model: Type['RedisModel'], field_values: Dict[str, Any]):
|
||||
for field_name in field_values.keys():
|
||||
if field_name not in model.__fields__:
|
||||
raise QuerySyntaxError(f"The field {field_name} does not exist on the model {self.model}")
|
||||
|
||||
|
||||
class ExpressionProtocol(Protocol):
|
||||
op: Operators
|
||||
left: ExpressionOrModelField
|
||||
|
@ -227,7 +241,7 @@ class ExpressionProxy:
|
|||
return Expression(left=self.field, op=Operators.IN, right=other, parents=self.parents)
|
||||
|
||||
def __getattr__(self, item):
|
||||
if get_origin(self.field.outer_type_) == list:
|
||||
if is_supported_container_type(self.field.outer_type_):
|
||||
embedded_cls = get_args(self.field.outer_type_)
|
||||
if not embedded_cls:
|
||||
raise QuerySyntaxError("In order to query on a list field, you must define "
|
||||
|
@ -332,7 +346,7 @@ class FindQuery:
|
|||
self._query = self.resolve_redisearch_query(self.expression)
|
||||
return self._query
|
||||
|
||||
def validate_sort_fields(self, sort_fields):
|
||||
def validate_sort_fields(self, sort_fields: List[str]):
|
||||
for sort_field in sort_fields:
|
||||
field_name = sort_field.lstrip("-")
|
||||
if field_name not in self.model.__fields__:
|
||||
|
@ -358,26 +372,56 @@ class FindQuery:
|
|||
|
||||
field_type = field.outer_type_
|
||||
|
||||
# TODO: GEO
|
||||
if any(issubclass(field_type, t) for t in NUMERIC_TYPES):
|
||||
# TODO: GEO fields
|
||||
container_type = get_origin(field_type)
|
||||
|
||||
if is_supported_container_type(container_type):
|
||||
# NOTE: A list of integers, like:
|
||||
#
|
||||
# luck_numbers: List[int] = field(index=True)
|
||||
#
|
||||
# becomes a TAG field, which means that users cannot perform range
|
||||
# queries on the values within the multi-value field, only equality
|
||||
# and membership queries.
|
||||
#
|
||||
# Meanwhile, a list of RedisModels, like:
|
||||
#
|
||||
# friends: List[Friend] = field(index=True)
|
||||
#
|
||||
# is not itself directly indexed, but instead, we index any fields
|
||||
# within the model marked as `index=True`.
|
||||
return RediSearchFieldTypes.TAG
|
||||
elif container_type is not None:
|
||||
raise QuerySyntaxError("Only lists and tuples are supported for multi-value fields. "
|
||||
"See docs: TODO")
|
||||
elif any(issubclass(field_type, t) for t in NUMERIC_TYPES):
|
||||
# Index numeric Python types as NUMERIC fields, so we can support
|
||||
# range queries.
|
||||
return RediSearchFieldTypes.NUMERIC
|
||||
else:
|
||||
# TAG fields are the default field type.
|
||||
# TODO: A ListField or ArrayField that supports multiple values
|
||||
# and contains logic should allow IN and NOT_IN queries.
|
||||
# TAG fields are the default field type and support equality and membership queries,
|
||||
# though membership (and the multi-value nature of the field) are hidden from
|
||||
# users unless they explicitly index multiple values, with either a list or tuple,
|
||||
# e.g.,
|
||||
# favorite_foods: List[str] = field(index=True)
|
||||
return RediSearchFieldTypes.TAG
|
||||
|
||||
@staticmethod
|
||||
def expand_tag_value(value):
|
||||
if isinstance(value, str):
|
||||
return escaper.escape(value)
|
||||
if isinstance(value, bytes):
|
||||
# TODO: We don't decode and then escape bytes objects passed as input.
|
||||
# Should we?
|
||||
# TODO: TAG indexes fail on JSON arrays of numbers -- only strings
|
||||
# are allowed -- what happens if we save an array of bytes?
|
||||
return value
|
||||
try:
|
||||
expanded_value = "|".join([escaper.escape(v) for v in value])
|
||||
return "|".join([escaper.escape(str(v)) for v in value])
|
||||
except TypeError:
|
||||
raise QuerySyntaxError("Values passed to an IN query must be iterables,"
|
||||
"like a list of strings. For more information, see:"
|
||||
"TODO: doc.")
|
||||
return expanded_value
|
||||
log.debug("Escaping single non-iterable value used for an IN or "
|
||||
"NOT_IN query: %s", value)
|
||||
return escaper.escape(str(value))
|
||||
|
||||
@classmethod
|
||||
def resolve_value(cls, field_name: str, field_type: RediSearchFieldTypes,
|
||||
|
@ -614,19 +658,30 @@ class FindQuery:
|
|||
return self
|
||||
return self.copy(sort_fields=list(fields))
|
||||
|
||||
def update(self, **kwargs):
|
||||
"""Update all matching records in this query."""
|
||||
# TODO
|
||||
def update(self, use_transaction=True, **field_values) -> Optional[List[str]]:
|
||||
"""
|
||||
Update models that match this query to the given field-value pairs.
|
||||
|
||||
def delete(cls, **field_values):
|
||||
Keys and values given as keyword arguments are interpreted as fields
|
||||
on the target model and the values as the values to which to set the
|
||||
given fields.
|
||||
"""
|
||||
validate_model_fields(self.model, field_values)
|
||||
pipeline = self.model.db().pipeline() if use_transaction else None
|
||||
|
||||
for model in self.all():
|
||||
for field, value in field_values.items():
|
||||
setattr(model, field, value)
|
||||
model.save(pipeline=pipeline)
|
||||
|
||||
if pipeline:
|
||||
# TODO: Better response type, error detection
|
||||
return pipeline.execute()
|
||||
|
||||
def delete(self):
|
||||
"""Delete all matching records in this query."""
|
||||
for field_name, value in field_values:
|
||||
valid_attr = hasattr(cls.model, field_name)
|
||||
if not valid_attr:
|
||||
raise RedisModelError(f"Can't update field {field_name} because "
|
||||
f"the field does not exist on the model {cls}")
|
||||
|
||||
return cls
|
||||
# TODO: Better response type, error detection
|
||||
return self.model.db().delete(*[m.key() for m in self.all()])
|
||||
|
||||
def __iter__(self):
|
||||
if self._model_cache:
|
||||
|
@ -822,15 +877,14 @@ class ModelMeta(ModelMetaclass):
|
|||
new_class.Meta = meta
|
||||
new_class._meta = meta
|
||||
elif base_meta:
|
||||
new_class._meta = deepcopy(base_meta)
|
||||
new_class._meta = type(f'{new_class.__name__}Meta', (base_meta,), dict(base_meta.__dict__))
|
||||
new_class.Meta = new_class._meta
|
||||
# Unset inherited values we don't want to reuse (typically based on
|
||||
# the model name).
|
||||
new_class._meta.embedded = False
|
||||
new_class._meta.model_key_prefix = None
|
||||
new_class._meta.index_name = None
|
||||
else:
|
||||
new_class._meta = deepcopy(DefaultMeta)
|
||||
new_class._meta = type(f'{new_class.__name__}Meta', (DefaultMeta,), dict(DefaultMeta.__dict__))
|
||||
new_class.Meta = new_class._meta
|
||||
|
||||
# Create proxies for each model field so that we can use the field
|
||||
|
@ -887,6 +941,21 @@ class RedisModel(BaseModel, abc.ABC, metaclass=ModelMeta):
|
|||
"""Default sort: compare primary key of models."""
|
||||
return self.pk < other.pk
|
||||
|
||||
def key(self):
|
||||
"""Return the Redis key for this model."""
|
||||
pk = getattr(self, self._meta.primary_key.field.name)
|
||||
return self.make_primary_key(pk)
|
||||
|
||||
def delete(self):
|
||||
return self.db().delete(self.key())
|
||||
|
||||
def update(self, **field_values):
|
||||
"""Update this model instance with the specified key-value pairs."""
|
||||
raise NotImplementedError
|
||||
|
||||
def save(self, *args, **kwargs) -> 'RedisModel':
|
||||
raise NotImplementedError
|
||||
|
||||
@validator("pk", always=True)
|
||||
def validate_pk(cls, v):
|
||||
if not v:
|
||||
|
@ -916,11 +985,6 @@ class RedisModel(BaseModel, abc.ABC, metaclass=ModelMeta):
|
|||
"""Return the Redis key for this model."""
|
||||
return cls.make_key(cls._meta.primary_key_pattern.format(pk=pk))
|
||||
|
||||
def key(self):
|
||||
"""Return the Redis key for this model."""
|
||||
pk = getattr(self, self._meta.primary_key.field.name)
|
||||
return self.make_primary_key(pk)
|
||||
|
||||
@classmethod
|
||||
def db(cls):
|
||||
return cls._meta.database
|
||||
|
@ -931,7 +995,7 @@ class RedisModel(BaseModel, abc.ABC, metaclass=ModelMeta):
|
|||
|
||||
@classmethod
|
||||
def from_redis(cls, res: Any):
|
||||
# TODO: Parsing logic borrowed from redisearch-py. Evaluate.
|
||||
# TODO: Parsing logic copied from redisearch-py. Evaluate.
|
||||
import six
|
||||
from six.moves import xrange, zip as izip
|
||||
|
||||
|
@ -974,25 +1038,14 @@ class RedisModel(BaseModel, abc.ABC, metaclass=ModelMeta):
|
|||
docs.append(doc)
|
||||
return docs
|
||||
|
||||
|
||||
@classmethod
|
||||
def add(cls, models: Sequence['RedisModel']) -> Sequence['RedisModel']:
|
||||
# TODO: Add transaction support
|
||||
return [model.save() for model in models]
|
||||
|
||||
@classmethod
|
||||
def update(cls, **field_values):
|
||||
"""Update this model instance."""
|
||||
return cls
|
||||
|
||||
@classmethod
|
||||
def values(cls):
|
||||
"""Return raw values from Redis instead of model instances."""
|
||||
return cls
|
||||
|
||||
def delete(self):
|
||||
return self.db().delete(self.key())
|
||||
|
||||
def save(self, *args, **kwargs) -> 'RedisModel':
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
|
@ -1014,11 +1067,14 @@ class HashModel(RedisModel, abc.ABC):
|
|||
raise RedisModelError(f"HashModels cannot have set, list,"
|
||||
f" or mapping fields. Field: {name}")
|
||||
|
||||
def save(self, *args, **kwargs) -> 'HashModel':
|
||||
def save(self, pipeline: Optional[Pipeline] = None) -> 'HashModel':
|
||||
if pipeline is None:
|
||||
db = self.db()
|
||||
else:
|
||||
db = pipeline
|
||||
document = jsonable_encoder(self.dict())
|
||||
success = self.db().hset(self.key(), mapping=document)
|
||||
|
||||
return success
|
||||
db.hset(self.key(), mapping=document)
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
def get(cls, pk: Any) -> 'HashModel':
|
||||
|
@ -1063,12 +1119,7 @@ class HashModel(RedisModel, abc.ABC):
|
|||
schema_parts.append(redisearch_field)
|
||||
elif getattr(field.field_info, 'index', None) is True:
|
||||
schema_parts.append(cls.schema_for_type(name, _type, field.field_info))
|
||||
# TODO: Raise error if user embeds a model field or list and makes it
|
||||
# sortable. Instead, the embedded model should mark individual fields
|
||||
# as sortable.
|
||||
if getattr(field.field_info, 'sortable', False) is True:
|
||||
schema_parts.append("SORTABLE")
|
||||
elif get_origin(_type) == list:
|
||||
elif is_supported_container_type(_type):
|
||||
embedded_cls = get_args(_type)
|
||||
if not embedded_cls:
|
||||
# TODO: Test if this can really happen.
|
||||
|
@ -1083,36 +1134,62 @@ class HashModel(RedisModel, abc.ABC):
|
|||
|
||||
@classmethod
|
||||
def schema_for_type(cls, name, typ: Any, field_info: PydanticFieldInfo):
|
||||
if get_origin(typ) == list:
|
||||
# TODO: Import parent logic from JsonModel to deal with lists, so that
|
||||
# a List[int] gets indexed as TAG instead of NUMERICAL.
|
||||
# TODO: Raise error if user embeds a model field or list and makes it
|
||||
# sortable. Instead, the embedded model should mark individual fields
|
||||
# as sortable.
|
||||
# TODO: Abstract string-building logic for each type (TAG, etc.) into
|
||||
# classes that take a field name.
|
||||
sortable = getattr(field_info, 'sortable', False)
|
||||
|
||||
if is_supported_container_type(typ):
|
||||
embedded_cls = get_args(typ)
|
||||
if not embedded_cls:
|
||||
# TODO: Test if this can really happen.
|
||||
log.warning("Model %s defined an empty list field: %s", cls, name)
|
||||
log.warning("Model %s defined an empty list or tuple field: %s", cls, name)
|
||||
return ""
|
||||
embedded_cls = embedded_cls[0]
|
||||
return cls.schema_for_type(name, embedded_cls, field_info)
|
||||
schema = cls.schema_for_type(name, embedded_cls, field_info)
|
||||
elif any(issubclass(typ, t) for t in NUMERIC_TYPES):
|
||||
return f"{name} NUMERIC"
|
||||
schema = f"{name} NUMERIC"
|
||||
elif issubclass(typ, str):
|
||||
if getattr(field_info, 'full_text_search', False) is True:
|
||||
return f"{name} TAG SEPARATOR {SINGLE_VALUE_TAG_FIELD_SEPARATOR} " \
|
||||
schema = f"{name} TAG SEPARATOR {SINGLE_VALUE_TAG_FIELD_SEPARATOR} " \
|
||||
f"{name}_fts TEXT"
|
||||
else:
|
||||
return f"{name} TAG SEPARATOR {SINGLE_VALUE_TAG_FIELD_SEPARATOR}"
|
||||
schema = f"{name} TAG SEPARATOR {SINGLE_VALUE_TAG_FIELD_SEPARATOR}"
|
||||
elif issubclass(typ, RedisModel):
|
||||
sub_fields = []
|
||||
for embedded_name, field in typ.__fields__.items():
|
||||
sub_fields.append(cls.schema_for_type(f"{name}_{embedded_name}", field.outer_type_,
|
||||
field.field_info))
|
||||
return " ".join(sub_fields)
|
||||
schema = " ".join(sub_fields)
|
||||
else:
|
||||
return f"{name} TAG SEPARATOR {SINGLE_VALUE_TAG_FIELD_SEPARATOR}"
|
||||
schema = f"{name} TAG SEPARATOR {SINGLE_VALUE_TAG_FIELD_SEPARATOR}"
|
||||
if schema and sortable is True:
|
||||
schema += " SORTABLE"
|
||||
return schema
|
||||
|
||||
|
||||
class JsonModel(RedisModel, abc.ABC):
|
||||
def save(self, *args, **kwargs) -> 'JsonModel':
|
||||
success = self.db().execute_command('JSON.SET', self.key(), ".", self.json())
|
||||
return success
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
# Generate the RediSearch schema once to validate fields.
|
||||
cls.redisearch_schema()
|
||||
|
||||
def save(self, pipeline: Optional[Pipeline] = None) -> 'JsonModel':
|
||||
if pipeline is None:
|
||||
db = self.db()
|
||||
else:
|
||||
db = pipeline
|
||||
db.execute_command('JSON.SET', self.key(), ".", self.json())
|
||||
return self
|
||||
|
||||
def update(self, **field_values):
|
||||
validate_model_fields(self.__class__, field_values)
|
||||
for field, value in field_values.items():
|
||||
setattr(self, field, value)
|
||||
self.save()
|
||||
|
||||
@classmethod
|
||||
def get(cls, pk: Any) -> 'JsonModel':
|
||||
|
@ -1144,7 +1221,25 @@ class JsonModel(RedisModel, abc.ABC):
|
|||
field_info: PydanticFieldInfo,
|
||||
parent_type: Optional[Any] = None) -> str:
|
||||
should_index = getattr(field_info, 'index', False)
|
||||
field_type = get_origin(typ)
|
||||
is_container_type = is_supported_container_type(typ)
|
||||
parent_is_container_type = is_supported_container_type(parent_type)
|
||||
try:
|
||||
parent_is_model = issubclass(parent_type, RedisModel)
|
||||
except TypeError:
|
||||
parent_is_model = False
|
||||
|
||||
# TODO: We need a better way to know that we're indexing a value
|
||||
# discovered in a model within an array.
|
||||
#
|
||||
# E.g., say we have a field like `orders: List[Order]`, and we're
|
||||
# indexing the "name" field from the Order model (because it's marked
|
||||
# index=True in the Order model). The JSONPath for this field is
|
||||
# $.orders[*].name, but the "parent" type at this point is Order, not
|
||||
# List. For now, we'll discover that Orders are stored in a list by
|
||||
# checking if the JSONPath contains the expression for all items in
|
||||
# an array.
|
||||
parent_is_model_in_container = parent_is_model and json_path.endswith("[*]")
|
||||
|
||||
try:
|
||||
field_is_model = issubclass(typ, RedisModel)
|
||||
except TypeError:
|
||||
|
@ -1154,10 +1249,11 @@ class JsonModel(RedisModel, abc.ABC):
|
|||
# When we encounter a list or model field, we need to descend
|
||||
# into the values of the list or the fields of the model to
|
||||
# find any values marked as indexed.
|
||||
if field_type == list:
|
||||
if is_container_type:
|
||||
field_type = get_origin(typ)
|
||||
embedded_cls = get_args(typ)
|
||||
if not embedded_cls:
|
||||
log.warning("Model %s defined an empty list field: %s", cls, name)
|
||||
log.warning("Model %s defined an empty list or tuple field: %s", cls, name)
|
||||
return ""
|
||||
embedded_cls = embedded_cls[0]
|
||||
return cls.schema_for_type(f"{json_path}.{name}[*]", name, name_prefix,
|
||||
|
@ -1166,10 +1262,11 @@ class JsonModel(RedisModel, abc.ABC):
|
|||
name_prefix = f"{name_prefix}_{name}" if name_prefix else name
|
||||
sub_fields = []
|
||||
for embedded_name, field in typ.__fields__.items():
|
||||
if parent_type == list or isinstance(parent_type, RedisModel):
|
||||
# This is a list, so the correct JSONPath expression is to
|
||||
# refer directly to attribute names after the list notation,
|
||||
# e.g. orders[*].created_date.
|
||||
if parent_is_container_type:
|
||||
# We'll store this value either as a JavaScript array, so
|
||||
# the correct JSONPath expression is to refer directly to
|
||||
# attribute names after the container notation, e.g.
|
||||
# orders[*].created_date.
|
||||
path = json_path
|
||||
else:
|
||||
# All other fields should use dot notation with both the
|
||||
|
@ -1181,23 +1278,56 @@ class JsonModel(RedisModel, abc.ABC):
|
|||
name_prefix,
|
||||
field.outer_type_,
|
||||
field.field_info,
|
||||
parent_type=field_type))
|
||||
parent_type=typ))
|
||||
return " ".join(filter(None, sub_fields))
|
||||
# NOTE: This is the termination point for recursion. We've descended
|
||||
# into models and lists until we found an actual value to index.
|
||||
elif should_index:
|
||||
index_field_name = f"{name_prefix}_{name}" if name_prefix else name
|
||||
path = f"{json_path}.{name}"
|
||||
if any(issubclass(typ, t) for t in NUMERIC_TYPES):
|
||||
schema_part = f"{path} AS {index_field_name} NUMERIC"
|
||||
elif issubclass(typ, str):
|
||||
if getattr(field_info, 'full_text_search', False) is True:
|
||||
schema_part = f"{path} AS {index_field_name} TAG SEPARATOR {SINGLE_VALUE_TAG_FIELD_SEPARATOR} " \
|
||||
f"{path} AS {index_field_name}_fts TEXT"
|
||||
else:
|
||||
schema_part = f"{path} AS {index_field_name} TAG SEPARATOR {SINGLE_VALUE_TAG_FIELD_SEPARATOR}"
|
||||
if parent_is_container_type:
|
||||
# If we're indexing the this field as a JavaScript array, then
|
||||
# the currently built-up JSONPath expression will be
|
||||
# "field_name[*]", which is what we want to use.
|
||||
path = json_path
|
||||
else:
|
||||
schema_part = f"{path} AS {index_field_name} TAG"
|
||||
# TODO: GEO field
|
||||
schema_part += " SORTABLE"
|
||||
return schema_part
|
||||
path = f"{json_path}.{name}"
|
||||
sortable = getattr(field_info, 'sortable', False)
|
||||
full_text_search = getattr(field_info, 'full_text_search', False)
|
||||
sortable_tag_error = RedisModelError("In this Preview release, TAG fields cannot "
|
||||
f"be marked as sortable. Problem field: {name}. "
|
||||
"See docs: TODO")
|
||||
|
||||
# TODO: GEO field
|
||||
if parent_is_container_type or parent_is_model_in_container:
|
||||
if typ is not str:
|
||||
raise RedisModelError("In this Preview release, list and tuple fields can only "
|
||||
f"contain strings. Problem field: {name}. See docs: TODO")
|
||||
if full_text_search is True:
|
||||
raise RedisModelError("List and tuple fields cannot be indexed for full-text "
|
||||
f"search. Problem field: {name}. See docs: TODO")
|
||||
schema = f"{path} AS {index_field_name} TAG SEPARATOR {SINGLE_VALUE_TAG_FIELD_SEPARATOR}"
|
||||
if sortable is True:
|
||||
raise sortable_tag_error
|
||||
elif any(issubclass(typ, t) for t in NUMERIC_TYPES):
|
||||
schema = f"{path} AS {index_field_name} NUMERIC"
|
||||
elif issubclass(typ, str):
|
||||
if full_text_search is True:
|
||||
schema = f"{path} AS {index_field_name} TAG SEPARATOR {SINGLE_VALUE_TAG_FIELD_SEPARATOR} " \
|
||||
f"{path} AS {index_field_name}_fts TEXT"
|
||||
if sortable is True:
|
||||
# NOTE: With the current preview release, making a field
|
||||
# full-text searchable and sortable only makes the TEXT
|
||||
# field sortable. This means that results for full-text
|
||||
# search queries can be sorted, but not exact match
|
||||
# queries.
|
||||
schema += " SORTABLE"
|
||||
else:
|
||||
schema = f"{path} AS {index_field_name} TAG SEPARATOR {SINGLE_VALUE_TAG_FIELD_SEPARATOR}"
|
||||
if sortable is True:
|
||||
raise sortable_tag_error
|
||||
else:
|
||||
schema = f"{path} AS {index_field_name} TAG SEPARATOR {SINGLE_VALUE_TAG_FIELD_SEPARATOR}"
|
||||
if sortable is True:
|
||||
raise sortable_tag_error
|
||||
return schema
|
||||
return ""
|
||||
|
|
|
@ -16,9 +16,9 @@ class TokenEscaper:
|
|||
else:
|
||||
self.escaped_chars_re = re.compile(self.DEFAULT_ESCAPED_CHARS)
|
||||
|
||||
def escape(self, string: str) -> str:
|
||||
def escape(self, value: str) -> str:
|
||||
def escape_symbol(match):
|
||||
value = match.group(0)
|
||||
return f"\\{value}"
|
||||
|
||||
return self.escaped_chars_re.sub(escape_symbol, string)
|
||||
return self.escaped_chars_re.sub(escape_symbol, value)
|
||||
|
|
|
@ -12,7 +12,8 @@ from redis_developer.orm import (
|
|||
JsonModel,
|
||||
Field,
|
||||
)
|
||||
from redis_developer.orm.model import QueryNotSupportedError, NotFoundError
|
||||
from redis_developer.orm.migrations.migrator import Migrator
|
||||
from redis_developer.orm.model import QueryNotSupportedError, NotFoundError, RedisModelError
|
||||
|
||||
r = redis.Redis()
|
||||
today = datetime.date.today()
|
||||
|
@ -29,7 +30,10 @@ class EmbeddedJsonModel(BaseJsonModel, abc.ABC):
|
|||
|
||||
|
||||
class Note(EmbeddedJsonModel):
|
||||
description: str = Field(index=True, full_text_search=True)
|
||||
# TODO: This was going to be a full-text search example, but
|
||||
# we can't index embedded documents for full-text search in
|
||||
# the preview release.
|
||||
description: str = Field(index=True)
|
||||
created_on: datetime.datetime
|
||||
|
||||
|
||||
|
@ -45,12 +49,11 @@ class Address(EmbeddedJsonModel):
|
|||
|
||||
class Item(EmbeddedJsonModel):
|
||||
price: decimal.Decimal
|
||||
name: str = Field(index=True, full_text_search=True)
|
||||
name: str = Field(index=True)
|
||||
|
||||
|
||||
class Order(EmbeddedJsonModel):
|
||||
items: List[Item]
|
||||
total: decimal.Decimal
|
||||
created_on: datetime.datetime
|
||||
|
||||
|
||||
|
@ -60,6 +63,7 @@ class Member(BaseJsonModel):
|
|||
email: str = Field(index=True)
|
||||
join_date: datetime.date
|
||||
age: int = Field(index=True)
|
||||
bio: Optional[str] = Field(index=True, full_text_search=True, default="")
|
||||
|
||||
# Creates an embedded model.
|
||||
address: Address
|
||||
|
@ -317,32 +321,15 @@ def test_recursive_query_field_resolution(members):
|
|||
|
||||
def test_full_text_search(members):
|
||||
member1, member2, _ = members
|
||||
member1.address.note = Note(description="white house",
|
||||
created_on=datetime.datetime.now())
|
||||
member2.address.note = Note(description="blue house",
|
||||
created_on=datetime.datetime.now())
|
||||
member1.save()
|
||||
member2.save()
|
||||
member1.update(bio="Hates sunsets, likes beaches")
|
||||
member2.update(bio="Hates beaches, likes forests")
|
||||
|
||||
actual = Member.find(Member.address.note.description % "white").all()
|
||||
assert actual == [member1]
|
||||
|
||||
member1.orders = [
|
||||
Order(items=[Item(price=10.99, name="balls")],
|
||||
total=10.99,
|
||||
created_on=datetime.datetime.now())
|
||||
]
|
||||
member2.orders = [
|
||||
Order(items=[Item(price=10.99, name="white ball")],
|
||||
total=10.99,
|
||||
created_on=datetime.datetime.now())
|
||||
]
|
||||
|
||||
member1.save()
|
||||
member2.save()
|
||||
actual = Member.find(Member.orders.items.name % "ball").all()
|
||||
actual = Member.find(Member.bio % "beaches").all()
|
||||
assert actual == [member1, member2]
|
||||
|
||||
actual = Member.find(Member.bio % "forests").all()
|
||||
assert actual == [member2]
|
||||
|
||||
|
||||
def test_tag_queries_boolean_logic(members):
|
||||
member1, member2, member3 = members
|
||||
|
@ -507,5 +494,55 @@ def test_not_found():
|
|||
Member.get(1000)
|
||||
|
||||
|
||||
def test_list_field_limitations():
|
||||
with pytest.raises(RedisModelError):
|
||||
class SortableTarotWitch(BaseJsonModel):
|
||||
# We support indexing lists of strings for quality and membership
|
||||
# queries. Sorting is not supported, but is planned.
|
||||
tarot_cards: List[str] = Field(index=True, sortable=True)
|
||||
|
||||
with pytest.raises(RedisModelError):
|
||||
class SortableFullTextSearchAlchemicalWitch(BaseJsonModel):
|
||||
# We don't support indexing a list of strings for full-text search
|
||||
# queries. Support for this feature is not planned.
|
||||
potions: List[str] = Field(index=True, full_text_search=True)
|
||||
|
||||
with pytest.raises(RedisModelError):
|
||||
class NumerologyWitch(BaseJsonModel):
|
||||
# We don't support indexing a list of numbers. Support for this
|
||||
# feature is To Be Determined.
|
||||
lucky_numbers: List[int] = Field(index=True)
|
||||
|
||||
with pytest.raises(RedisModelError):
|
||||
class ReadingWithPrice(EmbeddedJsonModel):
|
||||
gold_coins_charged: int = Field(index=True)
|
||||
|
||||
class TarotWitchWhoCharges(BaseJsonModel):
|
||||
tarot_cards: List[str] = Field(index=True)
|
||||
|
||||
# The preview release does not support indexing numeric fields on models
|
||||
# found within a list or tuple. This is the same limitation that stops
|
||||
# us from indexing plain lists (or tuples) containing numeric values.
|
||||
# The fate of this feature is To Be Determined.
|
||||
readings: List[ReadingWithPrice]
|
||||
|
||||
class TarotWitch(BaseJsonModel):
|
||||
# We support indexing lists of strings for quality and membership
|
||||
# queries. Sorting is not supported, but is planned.
|
||||
tarot_cards: List[str] = Field(index=True)
|
||||
|
||||
# We need to import and run this manually because we defined
|
||||
# our model classes within a function that runs after the test
|
||||
# suite's migrator has already looked for migrations to run.
|
||||
Migrator().run()
|
||||
|
||||
witch = TarotWitch(
|
||||
tarot_cards=['death']
|
||||
)
|
||||
witch.save()
|
||||
actual = TarotWitch.find(TarotWitch.tarot_cards << 'death').all()
|
||||
assert actual == [witch]
|
||||
|
||||
|
||||
def test_schema():
|
||||
assert Member.redisearch_schema() == "ON JSON PREFIX 1 redis-developer:tests.test_json_model.Member: SCHEMA $.pk AS pk TAG SEPARATOR | SORTABLE $.first_name AS first_name TAG SEPARATOR | SORTABLE $.last_name AS last_name TAG SEPARATOR | SORTABLE $.email AS email TAG SEPARATOR | SORTABLE $.age AS age NUMERIC SORTABLE $.address.pk AS address_pk TAG SEPARATOR | SORTABLE $.address.city AS address_city TAG SEPARATOR | SORTABLE $.address.postal_code AS address_postal_code TAG SEPARATOR | SORTABLE $.address.note.pk AS address_note_pk TAG SEPARATOR | SORTABLE $.address.note.description AS address_note_description TAG SEPARATOR | $.address.note.description AS address_note_description_fts TEXT SORTABLE $.orders[*].pk AS orders_pk TAG SEPARATOR | SORTABLE $.orders[*].items[*].pk AS orders_items_pk TAG SEPARATOR | SORTABLE $.orders[*].items[*].name AS orders_items_name TAG SEPARATOR | $.orders[*].items[*].name AS orders_items_name_fts TEXT SORTABLE"
|
||||
assert Member.redisearch_schema() == "ON JSON PREFIX 1 redis-developer:tests.test_json_model.Member: SCHEMA $.pk AS pk TAG SEPARATOR | $.first_name AS first_name TAG SEPARATOR | $.last_name AS last_name TAG SEPARATOR | $.email AS email TAG SEPARATOR | $.age AS age NUMERIC $.bio AS bio TAG SEPARATOR | $.bio AS bio_fts TEXT $.address.pk AS address_pk TAG SEPARATOR | $.address.city AS address_city TAG SEPARATOR | $.address.postal_code AS address_postal_code TAG SEPARATOR | $.address.note.pk AS address_note_pk TAG SEPARATOR | $.address.note.description AS address_note_description TAG SEPARATOR | $.orders[*].pk AS orders_pk TAG SEPARATOR | $.orders[*].items[*].pk AS orders_items_pk TAG SEPARATOR | $.orders[*].items[*].name AS orders_items_name TAG SEPARATOR |"
|
Loading…
Reference in a new issue