From 7f0fb15f19395d3cd227dcba3382901dea72f3db Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Tue, 31 Aug 2021 12:03:53 -0700 Subject: [PATCH] Global prefix --- README.md | 200 +++++++++++++++++----------- redis_developer/orm/model.py | 31 +++-- redis_developer/tests/test_model.py | 192 ++++++++++++++++++++++++++ test.py | 2 +- 4 files changed, 336 insertions(+), 89 deletions(-) create mode 100644 redis_developer/tests/test_model.py diff --git a/README.md b/README.md index 9d9fafc..04a2189 100644 --- a/README.md +++ b/README.md @@ -14,119 +14,167 @@ redis-developer-python includes an ORM/ODM. ```pyhon import decimal import datetime -from typing import Optional +from typing import Optional, List -from redis import Redis +import redis +from pydantic import ValidationError from redis_developer.orm import ( - RedisModel, - Field, - Relationship + RedisModel, + Field, + Relationship, ) -db = Redis() - # Declarative model classes -class BaseModel(RedisModel): - config: - database = db + +class BaseRedisModel(RedisModel): + class Meta: + database = redis.Redis(password="my-password", decode_responses=True) + model_key_prefix = "redis-developer:" -class Address(BaseModel): - address_line_1: str - address_line_2: str - city: str - country: str - postal_code: str +class Address(BaseRedisModel): + address_line_1: str + address_line_2: Optional[str] + city: str + country: str + postal_code: str -class Order(BaseModel): - total: decimal.Decimal - currency: str - created_on: datetime.datetime +class Order(BaseRedisModel): + total: decimal.Decimal + currency: str + created_on: datetime.datetime -class Member(BaseModel): - # An auto-incrementing primary key is added by default if no primary key - # is specified. - id: Optional[int] = Field(default=None, primary_key=True) - first_name: str - last_name: str - email: str = Field(unique=True, index=True) - zipcode: Optional[int] - join_date: datetime.date +class Member(BaseRedisModel): + first_name: str + last_name: str + email: str = Field(unique=True, index=True) + join_date: datetime.date - # Creates an embedded document: stored as hash fields or JSON document. - address: Address + # Creates an embedded document: stored as hash fields or JSON document. + address: Address - # Creates a relationship to data in separate Hash or JSON documents. - orders: Relationship(Order, backref='recommended', - field_name='recommended_by') + # Creates a relationship to data in separate Hash or JSON documents. + orders: Optional[List[Order]] = Relationship(back_populates='member') - # Creates a self-relationship. - recommended_by: Relationship('Member', backref='recommended', - field_name='recommended_by') + # Creates a self-relationship. + recommended_by: Optional['Member'] = Relationship(back_populates='recommended') - class Meta: - key_pattern = "member:{id}" + class Meta(BaseRedisModel.Meta): + model_key_prefix = "member" + primary_key_pattern = "" # Validation -# Raises ValidationError: last_name is required -Member( - first_name="Andrew", - zipcode="97086", - join_date=datetime.date.today() +address = Address( + address_line_1="1 Main St.", + city="Happy Town", + state="WY", + postal_code=11111, + country="USA" ) +# Raises ValidationError: last_name, address are required +try: + Member( + first_name="Andrew", + zipcode="97086", + join_date=datetime.date.today() + ) +except ValidationError as e: + pass + + +# Raises ValidationError: join_date is not a date +try: + Member( + first_name="Andrew", + last_name="Brookins", + join_date="yesterday" + ) +except ValidationError as e: + pass + + # Passes validation -Member( - first_name="Andrew", - last_name="Brookins", - zipcode="97086", - join_date=datetime.date.today() -) - -# Raises ValidationError: zipcode is not a number -Member( - first_name="Andrew", - last_name="Brookins", - zipcode="not a number", - join_date=datetime.date.today() -) - -# Persist a model instance to Redis member = Member( - first_name="Andrew", - last_name="Brookins", - zipcode="97086", - join_date=datetime.date.today() + first_name="Andrew", + last_name="Brookins", + email="a@example.com", + address=address, + join_date=datetime.date.today() ) -# Assign the return value to get any auto-fields filled in, -# like the primary key (if an auto-incrementing integer). -member = member.save() -# Hydrate a model instance from Redis using the primary key. -member = Member.get(d=1) -# Hydrate a model instance from Redis using a secondary index on a unique field. -member = Member.get(email="a.m.brookins@gmail.com") +# Save a model instance to Redis -# What if the field wasn't unique and there were two "a.m.brookins@gmail.com" -# entries? -# This would raise a MultipleObjectsReturned error: +address.save() + +address2 = Address.get(address.pk) +assert address2 == address + + +# Save a model with relationships (TODO!) + +member.save() + + +# Save many model instances to Redis +today = datetime.date.today() +members = [ + Member( + first_name="Andrew", + last_name="Brookins", + email="a@example.com", + address=address, + join_date=today + ), + Member( + first_name="Kim", + last_name="Brookins", + email="k@example.com", + address=address, + join_date=today + ) +] +Member.add(members) + +# Get a model instance from Redis using the primary key. +member = Member.get(1) + + +# 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") + + +# Exact-value queries on indexed fields + +# What if the field wasn't unique and there were two "a@example.com" +# entries? This would raise a MultipleObjectsReturned error: member = Member.get(Member.email == "a.m.brookins@gmail.com") # What if you know there might be multiple results? Use filter(): members = Member.filter(Member.last_name == "Brookins") # What if you want to only return values that don't match a query? -members = Member.exclude(last_name="Brookins") +members = Member.exclude(Member.last_name == "Brookins") # You can combine filer() and exclude(): -members = Member.filter(last_name="Brookins").exclude(first_name="Andrew") +members = Member.filter(Member.last_name == "Brookins").exclude( + Member.first_name == "Andrew") + ``` diff --git a/redis_developer/orm/model.py b/redis_developer/orm/model.py index 8a72309..40b68ac 100644 --- a/redis_developer/orm/model.py +++ b/redis_developer/orm/model.py @@ -277,6 +277,7 @@ class RedisModel(BaseModel, metaclass=RedisModelMetaclass): if not hasattr(cls.Meta, 'primary_key_pattern'): cls.Meta.primary_key_pattern = f"{cls.Meta.primary_key.name}:{{pk}}" + def __init__(__pydantic_self__, **data: Any) -> None: # Uses something other than `self` the first arg to allow "self" as a # settable attribute @@ -323,27 +324,29 @@ class RedisModel(BaseModel, metaclass=RedisModelMetaclass): raise RedisModelError("You must define only one primary key for a model") @classmethod - def key(cls, part: str): + def make_key(cls, part: str): global_prefix = getattr(cls.Meta, 'global_key_prefix', '') model_prefix = getattr(cls.Meta, 'model_key_prefix', '') return f"{global_prefix}{model_prefix}{part}" + @classmethod + def make_primary_key(self, pk: Any): + """Return the Redis key for this model.""" + return self.make_key(self.Meta.primary_key_pattern.format(pk=pk)) + + def key(self): + """Return the Redis key for this model.""" + pk = self.__fields__[self.Meta.primary_key.field.name] + return self.make_primary_key(pk) + @classmethod def get(cls, pk: Any): # TODO: Getting related objects - pk_pattern = cls.Meta.primary_key_pattern.format(pk=str(pk)) - print("GET ", cls.key(pk_pattern)) - document = cls.db().hgetall(cls.key(pk_pattern)) + document = cls.db().hgetall(cls.make_primary_key(pk)) if not document: raise NotFoundError return cls.parse_obj(document) - def delete(self): - # TODO: deleting relationships - pk = self.__fields__[self.Meta.primary_key.field.name] - pk_pattern = self.Meta.primary_key_pattern.format(pk=pk) - return self.db().delete(self.key(pk_pattern)) - @classmethod def db(cls): return cls.Meta.database @@ -369,7 +372,12 @@ class RedisModel(BaseModel, metaclass=RedisModelMetaclass): """Return raw values from Redis instead of model instances.""" return cls + def delete(self): + # TODO: deleting relationships + return self.db().delete(self.key()) + def save(self) -> 'RedisModel': + # TODO: Saving related models pk_field = self.Meta.primary_key.field document = jsonable_encoder(self.dict()) pk = document[pk_field.name] @@ -379,8 +387,7 @@ class RedisModel(BaseModel, metaclass=RedisModelMetaclass): setattr(self, pk_field.name, pk) document[pk_field.name] = pk - pk_pattern = self.Meta.primary_key_pattern.format(pk=pk) - success = self.db().hset(self.key(pk_pattern), mapping=document) + success = self.db().hset(self.key(), mapping=document) return success Meta = DefaultMeta diff --git a/redis_developer/tests/test_model.py b/redis_developer/tests/test_model.py new file mode 100644 index 0000000..ceb6fc5 --- /dev/null +++ b/redis_developer/tests/test_model.py @@ -0,0 +1,192 @@ +import decimal +import datetime +from typing import Optional, List + +import pytest +import redis +from pydantic import ValidationError + +from redis_developer.orm import ( + RedisModel, + Field, + Relationship, +) + +r = redis.Redis() + + +class BaseRedisModel(RedisModel): + class Meta: + database = redis.Redis(password="my-password", decode_responses=True) + model_key_prefix = "redis-developer:" + + +class Address(BaseRedisModel): + address_line_1: str + address_line_2: Optional[str] + city: str + country: str + postal_code: str + + +class Order(BaseRedisModel): + total: decimal.Decimal + currency: str + created_on: datetime.datetime + + +class Member(BaseRedisModel): + first_name: str + last_name: str + email: str = Field(unique=True, index=True) + join_date: datetime.date + + # Creates an embedded document: stored as hash fields or JSON document. + address: Address + + # Creates a relationship to data in separate Hash or JSON documents. + orders: Optional[List[Order]] = Relationship(back_populates='member') + + # Creates a self-relationship. + recommended_by: Optional['Member'] = Relationship(back_populates='recommended') + + class Meta(BaseRedisModel.Meta): + model_key_prefix = "member" + primary_key_pattern = "" + + +def test_validates_required_fields(): + address = Address( + address_line_1="1 Main St.", + city="Happy Town", + state="WY", + postal_code=11111, + country="USA" + ) + + # Raises ValidationError: last_name, address are required + with pytest.raises(ValidationError): + Member( + first_name="Andrew", + zipcode="97086", + join_date=datetime.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", + address=address, + join_date=datetime.date.today() + ) + assert member.first_name == "Andrew" + + +def test_saves_model(): + address = Address( + address_line_1="1 Main St.", + city="Happy Town", + state="WY", + postal_code=11111, + country="USA" + ) + # Save a model instance to Redis + address.save() + + address2 = Address.get(address.pk) + assert address2 == address + + +# Saves a model with relationships (TODO!) +@pytest.skip("Not implemented yet") +def test_saves_with_relationships(): + 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", + address=address, + join_date=datetime.date.today() + ) + member.save() + + +# Save many model instances to Redis +@pytest.skip("Not implemented yet") +def test_saves_many(): + today = datetime.date.today() + members = [ + Member( + first_name="Andrew", + last_name="Brookins", + email="a@example.com", + address=address, + join_date=today + ), + Member( + first_name="Kim", + last_name="Brookins", + email="k@example.com", + address=address, + join_date=today + ) + ] + Member.add(members) + + +@pytest.skip("No implemented yet") +def test_updates_a_model(): + # 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") + + +@pytest.skip("Not implemented yet") +def test_exact_match_queries(): + # What if the field wasn't unique and there were two "a@example.com" + # entries? This would raise a MultipleObjectsReturned error: + member = Member.get(Member.email == "a.m.brookins@gmail.com") + + # What if you know there might be multiple results? Use filter(): + members = Member.filter(Member.last_name == "Brookins") + + # What if you want to only return values that don't match a query? + members = Member.exclude(Member.last_name == "Brookins") + + # You can combine filer() and exclude(): + members = Member.filter(Member.last_name == "Brookins").exclude( + Member.first_name == "Andrew") diff --git a/test.py b/test.py index 57b1a50..32be09a 100644 --- a/test.py +++ b/test.py @@ -17,7 +17,7 @@ from redis_developer.orm import ( class BaseRedisModel(RedisModel): class Meta: database = redis.Redis(password="my-password", decode_responses=True) - model_key_prefix = "redis-developer:" + global_key_prefix = "redis-developer:" class Address(BaseRedisModel):