diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6d21ab8b..54d38a41 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,6 +19,9 @@ repos: - id: python-no-eval - id: python-no-log-warn - id: python-use-type-annotations + - id: rst-backticks + - id: rst-directive-colons + - id: rst-inline-touching-normal - repo: https://github.com/asottile/reorder_python_imports rev: v2.4.0 hooks: diff --git a/README.md b/README.md index 57fd644b..5d941153 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Install the package pip install pydantic-aioredis -### Usage +### Quick Usage Import the `Store`, the `RedisConfig` and the `Model` classes and use accordingly diff --git a/docs/development.rst b/docs/development.rst new file mode 100644 index 00000000..89371c6c --- /dev/null +++ b/docs/development.rst @@ -0,0 +1,80 @@ +Development +=========== + +The `Makefile <./makefile>`_ has useful targets to help setup your +development encironment. We suggest using pyenv to have access to +multiple python versions easily. + +Environment Setup +^^^^^^^^^^^^^^^^^ + + +* + Clone the repo and enter its root folder + +.. code-block:: + + git clone https://github.com/sopherapps/pydantic-redis.git && cd pydantic-redis + +* + Create a python 3.9 virtual environment and activate it. We suggest + using `pyenv `_ to easily setup + multiple python environments on multiple versions. + +.. code-block:: + + # We use the extra python version (3.6, 3.7, 3.8) for tox testing + pyenv install 3.9.6 3.6.9 3.7.11 3.8.11 + pyenv virtualenv 3.9.6 python-aioredis + pyenv local python-aioredis 3.6.9 3.7.11 3.8.11 + +* + Install the dependencies + + .. code-block:: + + make setup + +How to Run Tests +^^^^^^^^^^^^^^^^ + + +* + Run the test command to run tests on only python 3.9 + +.. code-block:: + + pytest + +* + Run the tox command to run all python version tests + +.. code-block:: + + tox + +Test Requirements +^^^^^^^^^^^^^^^^^ + +Prs should always have tests to cover the change being made. Code +coverage goals for this project are 100% coverage. + +Code Linting +^^^^^^^^^^^^ + +All code should pass Flake8 and be blackened. If you install and setup +pre-commit (done automatically by environment setup), pre-commit will +lint your code for you. + +You can run the linting manually with make + +.. code-block:: + + make lint + +CI +-- + +CI is run via Github Actions on all PRs and pushes to the main branch. + +Releases are automatically released by Github Actions to Pypi. diff --git a/docs/index.rst b/docs/index.rst index 8cf8df3a..81fb1bec 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,193 +1,27 @@ pydantic-aioredis ============================================= -A simple declarative ORM for Redis, using aioredis. Use your Pydantic -models like an ORM, storing data in Redis! +A declarative ORM for Redis, using aioredis. Use your Pydantic +models like an ORM, storing data in Redis. Inspired by `pydantic-redis `_ by `Martin Ahindura `_ -ain Dependencies +Dependencies ----------------- - * `Python +3.6 `_ * `aioredis 2.0 `_ * `pydantic `_ -Getting Started ---------------- - -Examples -^^^^^^^^ - -Examples are in the `examples/ <./examples>`_ directory of this repo. - -Installation -^^^^^^^^^^^^ - -Install the package - -.. code-block:: - - pip install pydantic-aioredis - - -Usage -^^^^^ - -Import the ``Store``\ , the ``RedisConfig`` and the ``Model`` classes and use accordingly - -.. code-block:: - - from pydantic_aioredis import RedisConfig, Model, Store - - # Create models as you would create pydantic models i.e. using typings - class Book(Model): - _primary_key_field: str = 'title' - title: str - author: str - published_on: date - in_stock: bool = True - - # Do note that there is no concept of relationships here - class Library(Model): - # the _primary_key_field is mandatory - _primary_key_field: str = 'name' - name: str - address: str - - # Create the store and register your models - store = Store(name='some_name', redis_config=RedisConfig(db=5, host='localhost', port=6379),life_span_in_seconds=3600) - store.register_model(Book) - store.register_model(Library) - - # Sample books. You can create as many as you wish anywhere in the code - books = [ - Book(title="Oliver Twist", author='Charles Dickens', published_on=date(year=1215, month=4, day=4), - in_stock=False), - Book(title="Great Expectations", author='Charles Dickens', published_on=date(year=1220, month=4, day=4)), - Book(title="Jane Eyre", author='Charles Dickens', published_on=date(year=1225, month=6, day=4), in_stock=False), - Book(title="Wuthering Heights", author='Jane Austen', published_on=date(year=1600, month=4, day=4)), - ] - # Some library objects - libraries = [ - Library(name="The Grand Library", address="Kinogozi, Hoima, Uganda"), - Library(name="Christian Library", address="Buhimba, Hoima, Uganda") - ] - - async def work_with_orm(): - # Insert them into redis - await Book.insert(books) - await Library.insert(libraries) - - # Select all books to view them. A list of Model instances will be returned - all_books = await Book.select() - print(all_books) # Will print [Book(title="Oliver Twist", author="Charles Dickens", published_on=date(year=1215, month=4, day=4), in_stock=False), Book(...] - - # Or select some of the books - some_books = await Book.select(ids=["Oliver Twist", "Jane Eyre"]) - print(some_books) # Will return only those two books - - # Or select some of the columns. THIS RETURNS DICTIONARIES not MODEL Instances - # The Dictionaries have values in string form so you might need to do some extra work - books_with_few_fields = await Book.select(columns=["author", "in_stock"]) - print(books_with_few_fields) # Will print [{"author": "'Charles Dickens", "in_stock": "True"},...] - - # Update any book or library - await Book.update(_id="Oliver Twist", data={"author": "John Doe"}) - - # Delete any number of items - await Library.delete(ids=["The Grand Library"]) - - - -Development ------------ - -The `Makefile <./makefile>`_ has useful targets to help setup your -development encironment. We suggest using pyenv to have access to -multiple python versions easily. - -Environment Setup -^^^^^^^^^^^^^^^^^ - - -* - Clone the repo and enter its root folder - -.. code-block:: - - git clone https://github.com/sopherapps/pydantic-redis.git && cd pydantic-redis - -* - Create a python 3.9 virtual environment and activate it. We suggest - using `pyenv `_ to easily setup - multiple python environments on multiple versions. - -.. code-block:: - - # We use the extra python version (3.6, 3.7, 3.8) for tox testing - pyenv install 3.9.6 3.6.9 3.7.11 3.8.11 - pyenv virtualenv 3.9.6 python-aioredis - pyenv local python-aioredis 3.6.9 3.7.11 3.8.11 - -* - Install the dependencies - - .. code-block:: - - make setup - -How to Run Tests -^^^^^^^^^^^^^^^^ - - -* - Run the test command to run tests on only python 3.9 - -.. code-block:: - - pytest - -* - Run the tox command to run all python version tests - -.. code-block:: - - tox - -Test Requirements -^^^^^^^^^^^^^^^^^ - -Prs should always have tests to cover the change being made. Code -coverage goals for this project are 100% coverage. - -Code Linting -^^^^^^^^^^^^ - -All code should pass Flake8 and be blackened. If you install and setup -pre-commit (done automatically by environment setup), pre-commit will -lint your code for you. - -You can run the linting manually with make - -.. code-block:: - - make lint - -CI --- - -CI is run via Github Actions on all PRs and pushes to the main branch. - -Releases are automatically released by Github Actions to Pypi. - -License -------- -Licensed under the `MIT License <./LICENSE>`_ +.. toctree:: + :maxdepth: 2 + quickstart + serialization + development + module Indices and tables ================== diff --git a/docs/quickstart.rst b/docs/quickstart.rst new file mode 100644 index 00000000..27125f61 --- /dev/null +++ b/docs/quickstart.rst @@ -0,0 +1,86 @@ +Quick Start +=========== + +Examples +^^^^^^^^ + +Examples are in the `examples/ `_ directory of this repo. + +Installation +^^^^^^^^^^^^ + +Install the package + +.. code-block:: + + pip install pydantic-aioredis + + +Quick Usage +^^^^^^^^^^^ + +Import the ``Store``\ , the ``RedisConfig`` and the ``Model`` classes. + +Store and RedisConfig let you configure and customize the connection to your redis instance. Model is the base class for your ORM models. + +.. code-block:: + + from pydantic_aioredis import RedisConfig, Model, Store + + # Create models as you would create pydantic models i.e. using typings + class Book(Model): + _primary_key_field: str = 'title' + title: str + author: str + published_on: date + in_stock: bool = True + + # Do note that there is no concept of relationships here + class Library(Model): + # the _primary_key_field is mandatory + _primary_key_field: str = 'name' + name: str + address: str + + # Create the store and register your models + store = Store(name='some_name', redis_config=RedisConfig(db=5, host='localhost', port=6379),life_span_in_seconds=3600) + store.register_model(Book) + store.register_model(Library) + + # Sample books. You can create as many as you wish anywhere in the code + books = [ + Book(title="Oliver Twist", author='Charles Dickens', published_on=date(year=1215, month=4, day=4), + in_stock=False), + Book(title="Great Expectations", author='Charles Dickens', published_on=date(year=1220, month=4, day=4)), + Book(title="Jane Eyre", author='Charles Dickens', published_on=date(year=1225, month=6, day=4), in_stock=False), + Book(title="Wuthering Heights", author='Jane Austen', published_on=date(year=1600, month=4, day=4)), + ] + # Some library objects + libraries = [ + Library(name="The Grand Library", address="Kinogozi, Hoima, Uganda"), + Library(name="Christian Library", address="Buhimba, Hoima, Uganda") + ] + + async def work_with_orm(): + # Insert them into redis + await Book.insert(books) + await Library.insert(libraries) + + # Select all books to view them. A list of Model instances will be returned + all_books = await Book.select() + print(all_books) # Will print [Book(title="Oliver Twist", author="Charles Dickens", published_on=date(year=1215, month=4, day=4), in_stock=False), Book(...] + + # Or select some of the books + some_books = await Book.select(ids=["Oliver Twist", "Jane Eyre"]) + print(some_books) # Will return only those two books + + # Or select some of the columns. THIS RETURNS DICTIONARIES not MODEL Instances + # The Dictionaries have values in string form so you might need to do some extra work + books_with_few_fields = await Book.select(columns=["author", "in_stock"]) + print(books_with_few_fields) # Will print [{"author": "'Charles Dickens", "in_stock": "True"},...] + + # Update any book or library + await Book.update(_id="Oliver Twist", data={"author": "John Doe"}) + + # Delete any number of items + await Library.delete(ids=["The Grand Library"]) diff --git a/docs/serialization.rst b/docs/serialization.rst new file mode 100644 index 00000000..145a5ff9 --- /dev/null +++ b/docs/serialization.rst @@ -0,0 +1,21 @@ +Serialization +============= + +Data in Redis +------------- +pydantic-aioredis uses Redis Hashes to store data. The ```_primary_key_field``` of each Model is used as the key of the hash. + +Because Redis only supports string values as the fields of a hash, data types have to be serialized. + +Simple data types +----------------- +Simple python datatypes that can be represented as a string and natively converted by pydantic are converted to strings and stored. Examples +are ints, floats, strs, bools, and Nonetypes. + +Complex data types +------------------ +Complex data types are dumped to json with json.dumps(). + +Custom serialization is possible by overriding the serialize_partially and deserialize_partially methods in `AbstractModel `_. + +It is also possilbe to override json_default in AbstractModel. json_default is a callable used to convert any objects of a type json.dump cannot natively dump to string. diff --git a/examples/benchmarks/benchmark.py b/examples/benchmarks/benchmark.py index c81a863a..1e1282ea 100644 --- a/examples/benchmarks/benchmark.py +++ b/examples/benchmarks/benchmark.py @@ -3,13 +3,12 @@ import json import time from datetime import date -from datetime import datetime from random import randint from random import random from random import sample from typing import Any -from typing import Dict from typing import List +from typing import Optional from typing import Tuple import aioredis @@ -23,32 +22,6 @@ from pydantic_aioredis import Store -def json_serial(obj): - """JSON serializer for objects not serializable by default json code""" - - if isinstance(obj, (datetime, date)): - return obj.isoformat() - raise TypeError("Type %s not serializable" % type(obj)) - - -class JsonModel(BaseModel): - @classmethod - def serialize_partially(cls, data: Dict[str, Any]): - """Converts non primitive data types into str by json dumping""" - for key in cls._json_fields: - data[key] = json.dumps(data[key], default=json_serial) - - return data - - @classmethod - def deserialize_partially(cls, data: Dict[bytes, Any]): - """Deserializes non primitive data types from json""" - return { - key: value if key not in cls._json_fields else json.loads(value) - for key, value in data.items() - } - - # Create models as you would create pydantic models i.e. using typings class BookBase(BaseModel): title: str @@ -56,13 +29,14 @@ class BookBase(BaseModel): published_on: date in_stock: bool = True isbn: str + extra_info: Optional[str] = None class Book(BookBase, Model): _primary_key_field: str = "title" -class Library(JsonModel, Model): +class Library(Model): # the _primary_key_field is mandatory _primary_key_field: str = "name" _json_fields: List[str] = ["books_in_library"] @@ -71,7 +45,7 @@ class Library(JsonModel, Model): books_in_library: List[BookBase] -class Librarian(JsonModel, Model): +class Librarian(Model): _primary_key_field = "name" _json_fields: List[str] = ["qualifications"] name: str @@ -181,14 +155,20 @@ async def benchmark(books: List[Book], libraries: List[Library], librarians): return to_return -async def run_benchmark(number_of_iterations: int = 1000): +async def run_benchmark( + number_of_iterations: int = 1000, number_of_objects: int = 1000 +): tqdm.write( f"Starting benchmark run of {number_of_iterations} iterations. Will output data at the end of the run" ) tqdm.write( "Total run time will differ from benchmark reports due to setup and cleanup tasks" ) - books, libraries, librarians = await setup() + books, libraries, librarians = await setup( + count_of_books=number_of_objects, + count_of_libraries=number_of_objects, + count_of_librarians=number_of_objects, + ) results = {} test_start = time.perf_counter() tqdm.write("Setup complete, starting benchmarks") @@ -221,7 +201,16 @@ async def run_benchmark(number_of_iterations: int = 1000): default=10, help="Number of iterations to run", ) + parser.add_argument( + "-n", + "--number-of-objects", + action="store", + default=1000, + help="Number of objects to generate", + ) args = parser.parse_args() loop = asyncio.get_event_loop() - loop.run_until_complete(run_benchmark()) + loop.run_until_complete( + run_benchmark(int(args.iterations), int(args.number_of_objects)) + ) diff --git a/pydantic_aioredis/abstract.py b/pydantic_aioredis/abstract.py index 94598f53..1a69186d 100644 --- a/pydantic_aioredis/abstract.py +++ b/pydantic_aioredis/abstract.py @@ -1,4 +1,7 @@ """Module containing the main base classes""" +import json +from datetime import date +from datetime import datetime from typing import Any from typing import Dict from typing import List @@ -7,9 +10,29 @@ import aioredis from pydantic import BaseModel +from pydantic.fields import SHAPE_DEFAULTDICT +from pydantic.fields import SHAPE_DICT +from pydantic.fields import SHAPE_FROZENSET +from pydantic.fields import SHAPE_LIST +from pydantic.fields import SHAPE_MAPPING +from pydantic.fields import SHAPE_SEQUENCE +from pydantic.fields import SHAPE_SET +from pydantic.fields import SHAPE_TUPLE +from pydantic.fields import SHAPE_TUPLE_ELLIPSIS from pydantic_aioredis.config import RedisConfig -from pydantic_aioredis.utils import bytes_to_string + +JSON_DUMP_SHAPES = [ + SHAPE_LIST, + SHAPE_SET, + SHAPE_MAPPING, + SHAPE_TUPLE, + SHAPE_TUPLE_ELLIPSIS, + SHAPE_SEQUENCE, + SHAPE_FROZENSET, + SHAPE_DICT, + SHAPE_DEFAULTDICT, +] class _AbstractStore(BaseModel): @@ -38,34 +61,68 @@ class _AbstractModel(BaseModel): _primary_key_field: str @staticmethod - def serialize_partially(data: Dict[str, Any]): - """Converts non primitive data types into str""" - return { - key: ( - value - if isinstance(value, (str, float, int)) - and not isinstance(value, (bool,)) - else str(value) - ) - for key, value in data.items() - } + def json_default(obj: Any) -> str: + """JSON serializer for objects not serializable by default json library + Currently handles: + * datetimes -> obj.isoformat + """ - @staticmethod - def deserialize_partially(data: Dict[bytes, Any]): - """Converts non primitive data types into str""" - return { - bytes_to_string(key): ( - bytes_to_string(value) if isinstance(value, (bytes,)) else value - ) - for key, value in data.items() - } + if isinstance(obj, (datetime, date)): + return obj.isoformat() + raise TypeError("Type %s not serializable" % type(obj)) + + @classmethod + def serialize_partially(cls, data: Dict[str, Any]): + """Converts data types that are not compatible with Redis into json strings + by looping through the models fields and inspecting its types. + + str, float, int - will be stored in redis as a string field + None - will be converted to the string "None" + More complex data types will be json dumped. + + The json dumper uses class.json_serial as its default serializer. + Users can override json_serial with a custom json serializer if they chose to. + Users can override serialze paritally and deserialze partially + """ + columns = data.keys() + for field in cls.__fields__: + # if we're updating a few columns, we might not have all fields, skip ones we dont have + if field not in columns: + continue + if cls.__fields__[field].type_ not in [str, float, int]: + data[field] = json.dumps(data[field], default=cls.json_default) + if getattr(cls.__fields__[field], "shape", None) in JSON_DUMP_SHAPES: + data[field] = json.dumps(data[field], default=cls.json_default) + if getattr(cls.__fields__[field], "allow_none", False): + if data[field] is None: + data[field] = "None" + return data + + @classmethod + def deserialize_partially(cls, data: Dict[bytes, Any]): + """Converts model fields back from json strings into python data types. + + Users can override serialze paritally and deserialze partially + """ + columns = data.keys() + for field in cls.__fields__: + # if we're selecting a few columns, we might not have all fields, skip ones we dont have + if field not in columns: + continue + if cls.__fields__[field].type_ not in [str, float, int]: + data[field] = json.loads(data[field]) + if getattr(cls.__fields__[field], "shape", None) in JSON_DUMP_SHAPES: + data[field] = json.loads(data[field]) + if getattr(cls.__fields__[field], "allow_none", False): + if data[field] == "None": + data[field] = None + return data @classmethod def get_primary_key_field(cls): """Gets the protected _primary_key_field""" return cls._primary_key_field - @classmethod @classmethod async def insert( cls, @@ -79,19 +136,19 @@ async def insert( async def update( cls, _id: Any, data: Dict[str, Any], life_span_seconds: Optional[int] = None ): # pragma: no cover - """Update an existing key""" + """Update an existing key in the redis store""" raise NotImplementedError("update should be implemented") @classmethod async def delete(cls, ids: Union[Any, List[Any]]): # pragma: no cover - """Delete a key""" + """Delete a key from the redis store""" raise NotImplementedError("delete should be implemented") @classmethod async def select( cls, columns: Optional[List[str]] = None, ids: Optional[List[Any]] = None ): # pragma: no cover - """Should later allow AND, OR""" + """Select one or more object from the redis store""" raise NotImplementedError("select should be implemented") class Config: diff --git a/pydantic_aioredis/model.py b/pydantic_aioredis/model.py index d69711fb..0b33dbf8 100644 --- a/pydantic_aioredis/model.py +++ b/pydantic_aioredis/model.py @@ -1,5 +1,4 @@ """Module containing the model classes""" -import uuid from collections.abc import Generator from typing import Any from typing import Dict @@ -53,9 +52,7 @@ async def insert( data_list = [data] for record in data_list: - primary_key_value = getattr( - record, cls._primary_key_field, str(uuid.uuid4()) - ) + primary_key_value = getattr(record, cls._primary_key_field) name = cls.__get_primary_key(primary_key_value=primary_key_value) mapping = cls.serialize_partially(record.dict()) pipeline.hset(name=name, mapping=mapping) @@ -121,7 +118,7 @@ async def delete(cls, ids: Union[Any, List[Any]]): @classmethod async def select( cls, columns: Optional[List[str]] = None, ids: Optional[List[Any]] = None - ): + ) -> Optional[List[Any]]: """ Selects given rows or sets of rows in the table """ @@ -158,9 +155,11 @@ async def select( return [cls(**cls.deserialize_partially(record)) for record in response] return [ - { - field: bytes_to_string(record[index]) - for index, field in enumerate(columns) - } + cls.deserialize_partially( + { + field: bytes_to_string(record[index]) + for index, field in enumerate(columns) + } + ) for record in response ] diff --git a/requirements-test.txt b/requirements-test.txt index 0392baf9..d7585525 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -2,6 +2,7 @@ pytest pytest-asyncio pytest-cov pytest-env +pytest-lazy-fixture pytest-mock pytest-mockservers pytest-xdist diff --git a/test/test_pydantic_aioredis.py b/test/test_pydantic_aioredis.py index b34c1289..bd58d948 100644 --- a/test/test_pydantic_aioredis.py +++ b/test/test_pydantic_aioredis.py @@ -1,5 +1,9 @@ """Tests for the redis orm""" from datetime import date +from random import randint +from random import sample +from typing import List +from typing import Optional import pytest @@ -41,6 +45,30 @@ class Book(Model): ), ] +editions = ["first", "second", "third", "hardbound", "paperback", "ebook"] + + +class ExtendedBook(Book): + editions: List[Optional[str]] + + +class TestModelWithNone(Model): + _primary_key_field = "name" + name: str + optional_field: Optional[str] + + +extended_books = [ + ExtendedBook(**book.dict(), editions=sample(editions, randint(0, len(editions)))) + for book in books +] +extended_books[0].editions = list() + +test_models = [ + TestModelWithNone(name="test", optional_field="test"), + TestModelWithNone(name="test2"), +] + @pytest.fixture() async def redis_store(redis_server): @@ -51,15 +79,13 @@ async def redis_store(redis_server): life_span_in_seconds=3600, ) store.register_model(Book) + store.register_model(ExtendedBook) + store.register_model(TestModelWithNone) yield store keys = [f"book_%&_{book.title}" for book in books] await store.redis_store.delete(*keys) -class ModelWithoutPrimaryKey(Model): - title: str - - def test_redis_config_redis_url(): password = "password" config_with_no_pass = RedisConfig() @@ -75,6 +101,10 @@ def test_redis_config_redis_url(): def test_register_model_without_primary_key(redis_store): """Throws error when a model without the _primary_key_field class variable set is registered""" + + class ModelWithoutPrimaryKey(Model): + title: str + with pytest.raises(AttributeError, match=r"_primary_key_field"): redis_store.register_model(ModelWithoutPrimaryKey) @@ -92,59 +122,74 @@ def test_store_model(redis_store): redis_store.model("Notabook") +parameters = [ + (pytest.lazy_fixture("redis_store"), books, Book), + (pytest.lazy_fixture("redis_store"), extended_books, ExtendedBook), + (pytest.lazy_fixture("redis_store"), test_models, TestModelWithNone), +] + + @pytest.mark.asyncio -async def test_bulk_insert(redis_store): +@pytest.mark.parametrize("store, models, model_class", parameters) +async def test_bulk_insert(store, models, model_class): """Providing a list of Model instances to the insert method inserts the records in redis""" - keys = [f"book_%&_{book.title}" for book in books] - await redis_store.redis_store.delete(*keys) + keys = [ + f"{type(model).__name__.lower()}_%&_{getattr(model, type(model)._primary_key_field)}" + for model in models + ] + # keys = [f"book_%&_{book.title}" for book in models] + await store.redis_store.delete(*keys) for key in keys: - book_in_redis = await redis_store.redis_store.hgetall(name=key) + book_in_redis = await store.redis_store.hgetall(name=key) assert book_in_redis == {} - await Book.insert(books) + await model_class.insert(models) - async with redis_store.redis_store.pipeline() as pipeline: + async with store.redis_store.pipeline() as pipeline: for key in keys: pipeline.hgetall(name=key) - books_in_redis = await pipeline.execute() - books_in_redis_as_models = [ - Book(**Book.deserialize_partially(book)) for book in books_in_redis + models_in_redis = await pipeline.execute() + models_deserialized = [ + model_class(**model_class.deserialize_partially(model)) + for model in models_in_redis ] - assert books == books_in_redis_as_models + assert models == models_deserialized @pytest.mark.asyncio -async def test_insert_single(redis_store): +@pytest.mark.parametrize("store, models, model_class", parameters) +async def test_insert_single(store, models, model_class): """ Providing a single Model instance """ - key = f"book_%&_{books[0].title}" - book = await redis_store.redis_store.hgetall(name=key) - assert book == {} + key = f"{type(models[0]).__name__.lower()}_%&_{getattr(models[0], type(models[0])._primary_key_field)}" + model = await store.redis_store.hgetall(name=key) + assert model == {} - await Book.insert(books[0]) + await model_class.insert(models[0]) - book = await redis_store.redis_store.hgetall(name=key) - book_as_model = Book(**Book.deserialize_partially(book)) - assert books[0] == book_as_model + model = await store.redis_store.hgetall(name=key) + model_deser = model_class(**model_class.deserialize_partially(model)) + assert models[0] == model_deser @pytest.mark.asyncio -async def test_select_default(redis_store): +@pytest.mark.parametrize("store, models, model_class", parameters) +async def test_select_default(store, models, model_class): """Selecting without arguments returns all the book models""" - await Book.insert(books) - response = await Book.select() - sorted_books = sorted(books, key=lambda x: x.title) - sorted_response = sorted(response, key=lambda x: x.title) - assert sorted_books == sorted_response + await model_class.insert(models) + response = await model_class.select() + for model in response: + assert model in models @pytest.mark.asyncio -async def test_select_no_contents(redis_store): +@pytest.mark.parametrize("store, models, model_class", parameters) +async def test_select_no_contents(store, models, model_class): """Test that we get None when there are no models""" - await redis_store.redis_store.flushall() - response = await Book.select() + await store.redis_store.flushall() + response = await model_class.select() assert response is None @@ -163,7 +208,7 @@ async def test_select_single_content(redis_store): assert response[0]["title"] == books[1].title assert response[0]["author"] == books[1].author - assert response[0]["in_stock"] == str(books[1].in_stock) + assert response[0]["in_stock"] == books[1].in_stock with pytest.raises(KeyError): response[0]["published_on"] @@ -271,3 +316,19 @@ async def test_delete_multiple(redis_store): Book(**Book.deserialize_partially(book)) for book in books_in_redis ] assert books_left_in_db == books_in_redis_as_models + + +@pytest.mark.asyncio +async def test_unserializable_object(redis_store): + class MyClass(object): + ... + + class TestModel(Model): + _primary_key_field = "name" + name: str + object: MyClass + + redis_store.register_model(TestModel) + this_model = TestModel(name="test", object=MyClass()) + with pytest.raises(TypeError): + await TestModel.insert(this_model)