Basic hash and JSON models

This commit is contained in:
Andrew Brookins 2021-09-01 12:56:06 -07:00
parent 0e72f06ba5
commit 59dced95a6
4 changed files with 260 additions and 135 deletions

View file

@ -1,5 +1,6 @@
from .model import ( from .model import (
RedisModel, RedisModel,
Relationship, HashModel,
JsonModel,
Field Field
) )

View file

@ -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
View 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")

View file

@ -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")