Basic hash and JSON models
This commit is contained in:
		
							parent
							
								
									0e72f06ba5
								
							
						
					
					
						commit
						59dced95a6
					
				
					 4 changed files with 260 additions and 135 deletions
				
			
		| 
						 | 
					@ -1,5 +1,6 @@
 | 
				
			||||||
from .model import (
 | 
					from .model import (
 | 
				
			||||||
    RedisModel,
 | 
					    RedisModel,
 | 
				
			||||||
    Relationship,
 | 
					    HashModel,
 | 
				
			||||||
 | 
					    JsonModel,
 | 
				
			||||||
    Field
 | 
					    Field
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -9,20 +9,20 @@ from typing import (
 | 
				
			||||||
    Optional,
 | 
					    Optional,
 | 
				
			||||||
    Set,
 | 
					    Set,
 | 
				
			||||||
    Tuple,
 | 
					    Tuple,
 | 
				
			||||||
    Type,
 | 
					 | 
				
			||||||
    TypeVar,
 | 
					    TypeVar,
 | 
				
			||||||
    Union,
 | 
					    Union,
 | 
				
			||||||
    Sequence, ClassVar, TYPE_CHECKING, no_type_check,
 | 
					    Sequence,
 | 
				
			||||||
    Protocol
 | 
					    no_type_check,
 | 
				
			||||||
 | 
					    Protocol,
 | 
				
			||||||
 | 
					    List, Type
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
import uuid
 | 
					import uuid
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import redis
 | 
					import redis
 | 
				
			||||||
from pydantic import BaseModel
 | 
					from pydantic import BaseModel, validator
 | 
				
			||||||
from pydantic.fields import FieldInfo as PydanticFieldInfo
 | 
					from pydantic.fields import FieldInfo as PydanticFieldInfo
 | 
				
			||||||
from pydantic.fields import ModelField, Undefined, UndefinedType
 | 
					from pydantic.fields import ModelField, Undefined, UndefinedType
 | 
				
			||||||
from pydantic.main import BaseConfig, ModelMetaclass, validate_model
 | 
					from pydantic.typing import NoArgAnyCallable
 | 
				
			||||||
from pydantic.typing import NoArgAnyCallable, resolve_annotations
 | 
					 | 
				
			||||||
from pydantic.utils import Representation
 | 
					from pydantic.utils import Representation
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from .encoders import jsonable_encoder
 | 
					from .encoders import jsonable_encoder
 | 
				
			||||||
| 
						 | 
					@ -52,12 +52,12 @@ class Expression:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class PrimaryKeyCreator(Protocol):
 | 
					class PrimaryKeyCreator(Protocol):
 | 
				
			||||||
    def create_pk(self, *args, **kwargs):
 | 
					    def create_pk(self, *args, **kwargs) -> str:
 | 
				
			||||||
        """Create a new primary key"""
 | 
					        """Create a new primary key"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Uuid4PrimaryKey:
 | 
					class Uuid4PrimaryKey:
 | 
				
			||||||
    def create_pk(self):
 | 
					    def create_pk(self) -> str:
 | 
				
			||||||
        return str(uuid.uuid4())
 | 
					        return str(uuid.uuid4())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -92,14 +92,12 @@ class FieldInfo(PydanticFieldInfo):
 | 
				
			||||||
        foreign_key = kwargs.pop("foreign_key", Undefined)
 | 
					        foreign_key = kwargs.pop("foreign_key", Undefined)
 | 
				
			||||||
        index = kwargs.pop("index", Undefined)
 | 
					        index = kwargs.pop("index", Undefined)
 | 
				
			||||||
        unique = kwargs.pop("unique", Undefined)
 | 
					        unique = kwargs.pop("unique", Undefined)
 | 
				
			||||||
        primary_key_creator_cls = kwargs.pop("primary_key_creator_cls", Undefined)
 | 
					 | 
				
			||||||
        super().__init__(default=default, **kwargs)
 | 
					        super().__init__(default=default, **kwargs)
 | 
				
			||||||
        self.primary_key = primary_key
 | 
					        self.primary_key = primary_key
 | 
				
			||||||
        self.nullable = nullable
 | 
					        self.nullable = nullable
 | 
				
			||||||
        self.foreign_key = foreign_key
 | 
					        self.foreign_key = foreign_key
 | 
				
			||||||
        self.index = index
 | 
					        self.index = index
 | 
				
			||||||
        self.unique = unique
 | 
					        self.unique = unique
 | 
				
			||||||
        self.primary_key_creator_cls = primary_key_creator_cls
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class RelationshipInfo(Representation):
 | 
					class RelationshipInfo(Representation):
 | 
				
			||||||
| 
						 | 
					@ -143,7 +141,6 @@ def Field(
 | 
				
			||||||
    foreign_key: Optional[Any] = None,
 | 
					    foreign_key: Optional[Any] = None,
 | 
				
			||||||
    nullable: Union[bool, UndefinedType] = Undefined,
 | 
					    nullable: Union[bool, UndefinedType] = Undefined,
 | 
				
			||||||
    index: Union[bool, UndefinedType] = Undefined,
 | 
					    index: Union[bool, UndefinedType] = Undefined,
 | 
				
			||||||
    primary_key_creator_cls: Optional[PrimaryKeyCreator] = Uuid4PrimaryKey,
 | 
					 | 
				
			||||||
    schema_extra: Optional[Dict[str, Any]] = None,
 | 
					    schema_extra: Optional[Dict[str, Any]] = None,
 | 
				
			||||||
) -> Any:
 | 
					) -> Any:
 | 
				
			||||||
    current_schema_extra = schema_extra or {}
 | 
					    current_schema_extra = schema_extra or {}
 | 
				
			||||||
| 
						 | 
					@ -172,80 +169,12 @@ def Field(
 | 
				
			||||||
        foreign_key=foreign_key,
 | 
					        foreign_key=foreign_key,
 | 
				
			||||||
        nullable=nullable,
 | 
					        nullable=nullable,
 | 
				
			||||||
        index=index,
 | 
					        index=index,
 | 
				
			||||||
        primary_key_creator_cls=primary_key_creator_cls,
 | 
					 | 
				
			||||||
        **current_schema_extra,
 | 
					        **current_schema_extra,
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    field_info._validate()
 | 
					    field_info._validate()
 | 
				
			||||||
    return field_info
 | 
					    return field_info
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def Relationship(
 | 
					 | 
				
			||||||
    *,
 | 
					 | 
				
			||||||
    back_populates: Optional[str] = None,
 | 
					 | 
				
			||||||
    link_model: Optional[Any] = None
 | 
					 | 
				
			||||||
) -> Any:
 | 
					 | 
				
			||||||
    relationship_info = RelationshipInfo(
 | 
					 | 
				
			||||||
        back_populates=back_populates,
 | 
					 | 
				
			||||||
        link_model=link_model,
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    return relationship_info
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@__dataclass_transform__(kw_only_default=True, field_descriptors=(Field, FieldInfo))
 | 
					 | 
				
			||||||
class RedisModelMetaclass(ModelMetaclass):
 | 
					 | 
				
			||||||
    __redismodel_relationships__: Dict[str, RelationshipInfo]
 | 
					 | 
				
			||||||
    __config__: Type[BaseConfig]
 | 
					 | 
				
			||||||
    __fields__: Dict[str, ModelField]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # From Pydantic
 | 
					 | 
				
			||||||
    def __new__(cls, name, bases, class_dict: dict, **kwargs) -> Any:
 | 
					 | 
				
			||||||
        relationships: Dict[str, RelationshipInfo] = {}
 | 
					 | 
				
			||||||
        dict_for_pydantic = {}
 | 
					 | 
				
			||||||
        original_annotations = resolve_annotations(
 | 
					 | 
				
			||||||
            class_dict.get("__annotations__", {}), class_dict.get("__module__", None)
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        pydantic_annotations = {}
 | 
					 | 
				
			||||||
        relationship_annotations = {}
 | 
					 | 
				
			||||||
        for k, v in class_dict.items():
 | 
					 | 
				
			||||||
            if isinstance(v, RelationshipInfo):
 | 
					 | 
				
			||||||
                relationships[k] = v
 | 
					 | 
				
			||||||
            else:
 | 
					 | 
				
			||||||
                dict_for_pydantic[k] = v
 | 
					 | 
				
			||||||
        for k, v in original_annotations.items():
 | 
					 | 
				
			||||||
            if k in relationships:
 | 
					 | 
				
			||||||
                relationship_annotations[k] = v
 | 
					 | 
				
			||||||
            else:
 | 
					 | 
				
			||||||
                pydantic_annotations[k] = v
 | 
					 | 
				
			||||||
        dict_used = {
 | 
					 | 
				
			||||||
            **dict_for_pydantic,
 | 
					 | 
				
			||||||
            "__weakref__": None,
 | 
					 | 
				
			||||||
            "__redismodel_relationships__": relationships,
 | 
					 | 
				
			||||||
            "__annotations__": pydantic_annotations,
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        # Duplicate logic from Pydantic to filter config kwargs because if they are
 | 
					 | 
				
			||||||
        # passed directly including the registry Pydantic will pass them over to the
 | 
					 | 
				
			||||||
        # superclass causing an error
 | 
					 | 
				
			||||||
        allowed_config_kwargs: Set[str] = {
 | 
					 | 
				
			||||||
            key
 | 
					 | 
				
			||||||
            for key in dir(BaseConfig)
 | 
					 | 
				
			||||||
            if not (
 | 
					 | 
				
			||||||
                key.startswith("__") and key.endswith("__")
 | 
					 | 
				
			||||||
            )  # skip dunder methods and attributes
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        pydantic_kwargs = kwargs.copy()
 | 
					 | 
				
			||||||
        config_kwargs = {
 | 
					 | 
				
			||||||
            key: pydantic_kwargs.pop(key)
 | 
					 | 
				
			||||||
            for key in pydantic_kwargs.keys() & allowed_config_kwargs
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        new_cls = super().__new__(cls, name, bases, dict_used, **config_kwargs)
 | 
					 | 
				
			||||||
        new_cls.__annotations__ = {
 | 
					 | 
				
			||||||
            **relationship_annotations,
 | 
					 | 
				
			||||||
            **pydantic_annotations,
 | 
					 | 
				
			||||||
            **new_cls.__annotations__,
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        return new_cls
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@dataclass
 | 
					@dataclass
 | 
				
			||||||
class PrimaryKey:
 | 
					class PrimaryKey:
 | 
				
			||||||
    name: str
 | 
					    name: str
 | 
				
			||||||
| 
						 | 
					@ -258,9 +187,10 @@ class DefaultMeta:
 | 
				
			||||||
    primary_key_pattern: Optional[str] = None
 | 
					    primary_key_pattern: Optional[str] = None
 | 
				
			||||||
    database: Optional[redis.Redis] = None
 | 
					    database: Optional[redis.Redis] = None
 | 
				
			||||||
    primary_key: Optional[PrimaryKey] = None
 | 
					    primary_key: Optional[PrimaryKey] = None
 | 
				
			||||||
 | 
					    primary_key_creator_cls: Type[PrimaryKeyCreator] = None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class RedisModel(BaseModel, metaclass=RedisModelMetaclass):
 | 
					class RedisModel(BaseModel):
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
    TODO: Convert expressions to Redis commands, execute
 | 
					    TODO: Convert expressions to Redis commands, execute
 | 
				
			||||||
    TODO: Key prefix vs. "key pattern" (that's actually the primary key pattern)
 | 
					    TODO: Key prefix vs. "key pattern" (that's actually the primary key pattern)
 | 
				
			||||||
| 
						 | 
					@ -288,25 +218,27 @@ class RedisModel(BaseModel, metaclass=RedisModelMetaclass):
 | 
				
			||||||
            if isinstance(field.field_info, FieldInfo):
 | 
					            if isinstance(field.field_info, FieldInfo):
 | 
				
			||||||
                if field.field_info.primary_key:
 | 
					                if field.field_info.primary_key:
 | 
				
			||||||
                    cls.Meta.primary_key = PrimaryKey(name=name, field=field)
 | 
					                    cls.Meta.primary_key = PrimaryKey(name=name, field=field)
 | 
				
			||||||
                if not hasattr(cls.Meta, 'primary_key_pattern'):
 | 
					                # TODO: Raise exception here, global key prefix required?
 | 
				
			||||||
                    cls.Meta.primary_key_pattern = f"{cls.Meta.primary_key.name}:{{pk}}"
 | 
					                if not getattr(cls.Meta, 'global_key_prefix'):
 | 
				
			||||||
 | 
					                    cls.Meta.global_key_prefix = ""
 | 
				
			||||||
 | 
					                if not getattr(cls.Meta, 'model_key_prefix'):
 | 
				
			||||||
 | 
					                    cls.Meta.model_key_prefix = f"{cls.__name__.lower()}"
 | 
				
			||||||
 | 
					                if not getattr(cls.Meta, 'primary_key_pattern'):
 | 
				
			||||||
 | 
					                    cls.Meta.primary_key_pattern = "{pk}"
 | 
				
			||||||
 | 
					                if not getattr(cls.Meta, 'database'):
 | 
				
			||||||
 | 
					                    cls.Meta.database = redis.Redis(decode_responses=True)
 | 
				
			||||||
 | 
					                if not getattr(cls.Meta, 'primary_key_creator_cls'):
 | 
				
			||||||
 | 
					                    cls.Meta.primary_key_creator_cls = Uuid4PrimaryKey
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __init__(__pydantic_self__, **data: Any) -> None:
 | 
					    def __init__(__pydantic_self__, **data: Any) -> None:
 | 
				
			||||||
        super().__init__(**data)
 | 
					        super().__init__(**data)
 | 
				
			||||||
        __pydantic_self__.validate_primary_key()
 | 
					        __pydantic_self__.validate_primary_key()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @classmethod
 | 
					    @validator("pk", always=True)
 | 
				
			||||||
    @no_type_check
 | 
					    def validate_pk(cls, v):
 | 
				
			||||||
    def _get_value(cls, *args, **kwargs) -> Any:
 | 
					        if not v:
 | 
				
			||||||
        """
 | 
					            v = cls.Meta.primary_key_creator_cls().create_pk()
 | 
				
			||||||
        Always send None as an empty string.
 | 
					        return v
 | 
				
			||||||
 | 
					 | 
				
			||||||
        TODO: How broken is this?
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        val = super()._get_value(*args, **kwargs)
 | 
					 | 
				
			||||||
        if val is None:
 | 
					 | 
				
			||||||
            return ""
 | 
					 | 
				
			||||||
        return val
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @classmethod
 | 
					    @classmethod
 | 
				
			||||||
    def validate_primary_key(cls):
 | 
					    def validate_primary_key(cls):
 | 
				
			||||||
| 
						 | 
					@ -322,9 +254,9 @@ class RedisModel(BaseModel, metaclass=RedisModelMetaclass):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @classmethod
 | 
					    @classmethod
 | 
				
			||||||
    def make_key(cls, part: str):
 | 
					    def make_key(cls, part: str):
 | 
				
			||||||
        global_prefix = getattr(cls.Meta, 'global_key_prefix', '')
 | 
					        global_prefix = getattr(cls.Meta, 'global_key_prefix', '').strip(":")
 | 
				
			||||||
        model_prefix = getattr(cls.Meta, 'model_key_prefix', '')
 | 
					        model_prefix = getattr(cls.Meta, 'model_key_prefix', '').strip(":")
 | 
				
			||||||
        return f"{global_prefix}{model_prefix}{part}"
 | 
					        return f"{global_prefix}:{model_prefix}:{part}"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @classmethod
 | 
					    @classmethod
 | 
				
			||||||
    def make_primary_key(cls, pk: Any):
 | 
					    def make_primary_key(cls, pk: Any):
 | 
				
			||||||
| 
						 | 
					@ -336,14 +268,6 @@ class RedisModel(BaseModel, metaclass=RedisModelMetaclass):
 | 
				
			||||||
        pk = getattr(self, self.Meta.primary_key.field.name)
 | 
					        pk = getattr(self, self.Meta.primary_key.field.name)
 | 
				
			||||||
        return self.make_primary_key(pk)
 | 
					        return self.make_primary_key(pk)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @classmethod
 | 
					 | 
				
			||||||
    def get(cls, pk: Any):
 | 
					 | 
				
			||||||
        # TODO: Getting related objects?
 | 
					 | 
				
			||||||
        document = cls.db().hgetall(cls.make_primary_key(pk))
 | 
					 | 
				
			||||||
        if not document:
 | 
					 | 
				
			||||||
            raise NotFoundError
 | 
					 | 
				
			||||||
        return cls.parse_obj(document)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @classmethod
 | 
					    @classmethod
 | 
				
			||||||
    def db(cls):
 | 
					    def db(cls):
 | 
				
			||||||
        return cls.Meta.database
 | 
					        return cls.Meta.database
 | 
				
			||||||
| 
						 | 
					@ -370,19 +294,67 @@ class RedisModel(BaseModel, metaclass=RedisModelMetaclass):
 | 
				
			||||||
        return cls
 | 
					        return cls
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def delete(self):
 | 
					    def delete(self):
 | 
				
			||||||
        # TODO: deleting relationships?
 | 
					 | 
				
			||||||
        return self.db().delete(self.key())
 | 
					        return self.db().delete(self.key())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def save(self) -> 'RedisModel':
 | 
					    # TODO: Protocol
 | 
				
			||||||
        # TODO: Saving related models
 | 
					    @classmethod
 | 
				
			||||||
        pk_field = self.Meta.primary_key.field
 | 
					    def get(cls, pk: Any):
 | 
				
			||||||
 | 
					        raise NotImplementedError
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def save(self, *args, **kwargs) -> 'RedisModel':
 | 
				
			||||||
 | 
					        raise NotImplementedError
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class HashModel(RedisModel):
 | 
				
			||||||
 | 
					    def __init_subclass__(cls, **kwargs):
 | 
				
			||||||
 | 
					        super().__init_subclass__(**kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        for name, field in cls.__fields__.items():
 | 
				
			||||||
 | 
					            if issubclass(field.outer_type_, RedisModel):
 | 
				
			||||||
 | 
					                raise RedisModelError(f"HashModels cannot have embedded model "
 | 
				
			||||||
 | 
					                                      f"fields. Field: {name}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            for typ in (Set, Mapping, List):
 | 
				
			||||||
 | 
					                if issubclass(field.outer_type_, typ):
 | 
				
			||||||
 | 
					                    raise RedisModelError(f"HashModels cannot have set, list,"
 | 
				
			||||||
 | 
					                                          f" or mapping fields. Field: {name}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def save(self, *args, **kwargs) -> 'HashModel':
 | 
				
			||||||
        document = jsonable_encoder(self.dict())
 | 
					        document = jsonable_encoder(self.dict())
 | 
				
			||||||
        pk = document[pk_field.name]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if not pk:
 | 
					 | 
				
			||||||
            pk = pk_field.field_info.primary_key_creator_cls().create_pk()
 | 
					 | 
				
			||||||
            setattr(self, pk_field.name, pk)
 | 
					 | 
				
			||||||
            document[pk_field.name] = pk
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        success = self.db().hset(self.key(), mapping=document)
 | 
					        success = self.db().hset(self.key(), mapping=document)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return success
 | 
					        return success
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @classmethod
 | 
				
			||||||
 | 
					    def get(cls, pk: Any) -> 'HashModel':
 | 
				
			||||||
 | 
					        document = cls.db().hgetall(cls.make_primary_key(pk))
 | 
				
			||||||
 | 
					        if not document:
 | 
				
			||||||
 | 
					            raise NotFoundError
 | 
				
			||||||
 | 
					        return cls.parse_obj(document)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @classmethod
 | 
				
			||||||
 | 
					    @no_type_check
 | 
				
			||||||
 | 
					    def _get_value(cls, *args, **kwargs) -> Any:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Always send None as an empty string.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        TODO: We do this because redis-py's hset() method requires non-null
 | 
				
			||||||
 | 
					        values. Is there a better way?
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        val = super()._get_value(*args, **kwargs)
 | 
				
			||||||
 | 
					        if val is None:
 | 
				
			||||||
 | 
					            return ""
 | 
				
			||||||
 | 
					        return val
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class JsonModel(RedisModel):
 | 
				
			||||||
 | 
					    def save(self, *args, **kwargs) -> 'JsonModel':
 | 
				
			||||||
 | 
					        success = self.db().execute_command('JSON.SET', self.key(), ".", self.json())
 | 
				
			||||||
 | 
					        return success
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @classmethod
 | 
				
			||||||
 | 
					    def get(cls, pk: Any) -> 'JsonModel':
 | 
				
			||||||
 | 
					        document = cls.db().execute_command("JSON.GET", cls.make_primary_key(pk))
 | 
				
			||||||
 | 
					        if not document:
 | 
				
			||||||
 | 
					            raise NotFoundError
 | 
				
			||||||
 | 
					        return cls.parse_raw(document)
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										144
									
								
								tests/test_hash_model.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										144
									
								
								tests/test_hash_model.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,144 @@
 | 
				
			||||||
 | 
					import decimal
 | 
				
			||||||
 | 
					import datetime
 | 
				
			||||||
 | 
					from typing import Optional, List
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import pytest
 | 
				
			||||||
 | 
					import redis
 | 
				
			||||||
 | 
					from pydantic import ValidationError
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from redis_developer.orm import (
 | 
				
			||||||
 | 
					    HashModel,
 | 
				
			||||||
 | 
					    Field,
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					from redis_developer.orm.model import RedisModelError
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					r = redis.Redis()
 | 
				
			||||||
 | 
					today = datetime.date.today()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class BaseHashModel(HashModel):
 | 
				
			||||||
 | 
					    class Meta(HashModel.Meta):
 | 
				
			||||||
 | 
					        global_key_prefix = "redis-developer"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Address(BaseHashModel):
 | 
				
			||||||
 | 
					    address_line_1: str
 | 
				
			||||||
 | 
					    address_line_2: Optional[str]
 | 
				
			||||||
 | 
					    city: str
 | 
				
			||||||
 | 
					    country: str
 | 
				
			||||||
 | 
					    postal_code: str
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Order(BaseHashModel):
 | 
				
			||||||
 | 
					    total: decimal.Decimal
 | 
				
			||||||
 | 
					    currency: str
 | 
				
			||||||
 | 
					    created_on: datetime.datetime
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Member(BaseHashModel):
 | 
				
			||||||
 | 
					    first_name: str
 | 
				
			||||||
 | 
					    last_name: str
 | 
				
			||||||
 | 
					    email: str = Field(unique=True, index=True)
 | 
				
			||||||
 | 
					    join_date: datetime.date
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    class Meta(BaseHashModel.Meta):
 | 
				
			||||||
 | 
					        model_key_prefix = "member"
 | 
				
			||||||
 | 
					        primary_key_pattern = ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def test_validates_required_fields():
 | 
				
			||||||
 | 
					    # Raises ValidationError: last_name, address are required
 | 
				
			||||||
 | 
					    with pytest.raises(ValidationError):
 | 
				
			||||||
 | 
					        Member(
 | 
				
			||||||
 | 
					            first_name="Andrew",
 | 
				
			||||||
 | 
					            zipcode="97086",
 | 
				
			||||||
 | 
					            join_date=today
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def test_validates_field():
 | 
				
			||||||
 | 
					    # Raises ValidationError: join_date is not a date
 | 
				
			||||||
 | 
					    with pytest.raises(ValidationError):
 | 
				
			||||||
 | 
					        Member(
 | 
				
			||||||
 | 
					            first_name="Andrew",
 | 
				
			||||||
 | 
					            last_name="Brookins",
 | 
				
			||||||
 | 
					            join_date="yesterday"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Passes validation
 | 
				
			||||||
 | 
					def test_validation_passes():
 | 
				
			||||||
 | 
					    address = Address(
 | 
				
			||||||
 | 
					        address_line_1="1 Main St.",
 | 
				
			||||||
 | 
					        city="Happy Town",
 | 
				
			||||||
 | 
					        state="WY",
 | 
				
			||||||
 | 
					        postal_code=11111,
 | 
				
			||||||
 | 
					        country="USA"
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    member = Member(
 | 
				
			||||||
 | 
					        first_name="Andrew",
 | 
				
			||||||
 | 
					        last_name="Brookins",
 | 
				
			||||||
 | 
					        email="a@example.com",
 | 
				
			||||||
 | 
					        join_date=today
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    assert member.first_name == "Andrew"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def test_saves_model_and_creates_pk():
 | 
				
			||||||
 | 
					    member = Member(
 | 
				
			||||||
 | 
					        first_name="Andrew",
 | 
				
			||||||
 | 
					        last_name="Brookins",
 | 
				
			||||||
 | 
					        email="a@example.com",
 | 
				
			||||||
 | 
					        join_date=today
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    # Save a model instance to Redis
 | 
				
			||||||
 | 
					    member.save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    member2 = Member.get(member.pk)
 | 
				
			||||||
 | 
					    assert member2 == member
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def test_raises_error_with_embedded_models():
 | 
				
			||||||
 | 
					    with pytest.raises(RedisModelError):
 | 
				
			||||||
 | 
					        class InvalidMember(BaseHashModel):
 | 
				
			||||||
 | 
					            address: Address
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.mark.skip("Not implemented yet")
 | 
				
			||||||
 | 
					def test_saves_many():
 | 
				
			||||||
 | 
					    members = [
 | 
				
			||||||
 | 
					        Member(
 | 
				
			||||||
 | 
					            first_name="Andrew",
 | 
				
			||||||
 | 
					            last_name="Brookins",
 | 
				
			||||||
 | 
					            email="a@example.com",
 | 
				
			||||||
 | 
					            join_date=today
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        Member(
 | 
				
			||||||
 | 
					            first_name="Kim",
 | 
				
			||||||
 | 
					            last_name="Brookins",
 | 
				
			||||||
 | 
					            email="k@example.com",
 | 
				
			||||||
 | 
					            join_date=today
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					    Member.add(members)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.mark.skip("No implemented yet")
 | 
				
			||||||
 | 
					def test_updates_a_model():
 | 
				
			||||||
 | 
					    member = Member(
 | 
				
			||||||
 | 
					        first_name="Andrew",
 | 
				
			||||||
 | 
					        last_name="Brookins",
 | 
				
			||||||
 | 
					        email="a@example.com",
 | 
				
			||||||
 | 
					        join_date=today
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Update a model instance in Redis
 | 
				
			||||||
 | 
					    member.first_name = "Brian"
 | 
				
			||||||
 | 
					    member.last_name = "Sam-Bodden"
 | 
				
			||||||
 | 
					    member.save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Or, with an implicit save:
 | 
				
			||||||
 | 
					    member.update(first_name="Brian", last_name="Sam-Bodden")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Or, affecting multiple model instances with an implicit save:
 | 
				
			||||||
 | 
					    Member.filter(Member.last_name == "Brookins").update(last_name="Sam-Bodden")
 | 
				
			||||||
| 
						 | 
					@ -7,21 +7,19 @@ import redis
 | 
				
			||||||
from pydantic import ValidationError
 | 
					from pydantic import ValidationError
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from redis_developer.orm import (
 | 
					from redis_developer.orm import (
 | 
				
			||||||
    RedisModel,
 | 
					    JsonModel,
 | 
				
			||||||
    Field,
 | 
					    Field,
 | 
				
			||||||
    Relationship,
 | 
					 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
r = redis.Redis()
 | 
					r = redis.Redis()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class BaseRedisModel(RedisModel):
 | 
					class BaseJsonModel(JsonModel):
 | 
				
			||||||
    class Meta:
 | 
					    class Meta(JsonModel.Meta):
 | 
				
			||||||
        database = redis.Redis(password="my-password", decode_responses=True)
 | 
					        global_key_prefix = "redis-developer"
 | 
				
			||||||
        model_key_prefix = "redis-developer:"
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Address(BaseRedisModel):
 | 
					class Address(BaseJsonModel):
 | 
				
			||||||
    address_line_1: str
 | 
					    address_line_1: str
 | 
				
			||||||
    address_line_2: Optional[str]
 | 
					    address_line_2: Optional[str]
 | 
				
			||||||
    city: str
 | 
					    city: str
 | 
				
			||||||
| 
						 | 
					@ -29,28 +27,25 @@ class Address(BaseRedisModel):
 | 
				
			||||||
    postal_code: str
 | 
					    postal_code: str
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Order(BaseRedisModel):
 | 
					class Order(BaseJsonModel):
 | 
				
			||||||
    total: decimal.Decimal
 | 
					    total: decimal.Decimal
 | 
				
			||||||
    currency: str
 | 
					    currency: str
 | 
				
			||||||
    created_on: datetime.datetime
 | 
					    created_on: datetime.datetime
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Member(BaseRedisModel):
 | 
					class Member(BaseJsonModel):
 | 
				
			||||||
    first_name: str
 | 
					    first_name: str
 | 
				
			||||||
    last_name: str
 | 
					    last_name: str
 | 
				
			||||||
    email: str = Field(unique=True, index=True)
 | 
					    email: str = Field(unique=True, index=True)
 | 
				
			||||||
    join_date: datetime.date
 | 
					    join_date: datetime.date
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # Creates an embedded document: stored as hash fields or JSON document.
 | 
					    # Creates an embedded model: stored as hash fields or JSON document.
 | 
				
			||||||
    address: Address
 | 
					    address: Address
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # Creates a relationship to data in separate Hash or JSON documents.
 | 
					    # Creates an embedded list of models.
 | 
				
			||||||
    orders: Optional[List[Order]] = Relationship(back_populates='member')
 | 
					    orders: Optional[List[Order]]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # Creates a self-relationship.
 | 
					    class Meta(BaseJsonModel.Meta):
 | 
				
			||||||
    recommended_by: Optional['Member'] = Relationship(back_populates='recommended')
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    class Meta(BaseRedisModel.Meta):
 | 
					 | 
				
			||||||
        model_key_prefix = "member"
 | 
					        model_key_prefix = "member"
 | 
				
			||||||
        primary_key_pattern = ""
 | 
					        primary_key_pattern = ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -94,6 +89,17 @@ def test_validation_passes():
 | 
				
			||||||
    assert member.first_name == "Andrew"
 | 
					    assert member.first_name == "Andrew"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def test_gets_pk():
 | 
				
			||||||
 | 
					    address = Address(
 | 
				
			||||||
 | 
					        address_line_1="1 Main St.",
 | 
				
			||||||
 | 
					        city="Happy Town",
 | 
				
			||||||
 | 
					        state="WY",
 | 
				
			||||||
 | 
					        postal_code=11111,
 | 
				
			||||||
 | 
					        country="USA"
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    assert address.pk is not None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def test_saves_model():
 | 
					def test_saves_model():
 | 
				
			||||||
    address = Address(
 | 
					    address = Address(
 | 
				
			||||||
        address_line_1="1 Main St.",
 | 
					        address_line_1="1 Main St.",
 | 
				
			||||||
| 
						 | 
					@ -109,8 +115,7 @@ def test_saves_model():
 | 
				
			||||||
    assert address2 == address
 | 
					    assert address2 == address
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Saves a model with relationships (TODO!)
 | 
					# Saves a model with embedded models
 | 
				
			||||||
@pytest.mark.skip("Not implemented yet")
 | 
					 | 
				
			||||||
def test_saves_with_relationships():
 | 
					def test_saves_with_relationships():
 | 
				
			||||||
    address = Address(
 | 
					    address = Address(
 | 
				
			||||||
        address_line_1="1 Main St.",
 | 
					        address_line_1="1 Main St.",
 | 
				
			||||||
| 
						 | 
					@ -128,6 +133,9 @@ def test_saves_with_relationships():
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    member.save()
 | 
					    member.save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    member2 = Member.get(member.pk)
 | 
				
			||||||
 | 
					    assert member2.address == address
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Save many model instances to Redis
 | 
					# Save many model instances to Redis
 | 
				
			||||||
@pytest.mark.skip("Not implemented yet")
 | 
					@pytest.mark.skip("Not implemented yet")
 | 
				
			||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue