diff --git a/README.md b/README.md index a6be8538..d5a56259 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ class Library(Model): 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 = 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) @@ -102,8 +102,16 @@ async def work_with_orm(): 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"}) + # When _auto_sync = True (default), updating any attribute will update that field in Redis too + this_book = Book(title="Moby Dick", author='Herman Melvill', published_on=date(year=1851, month=10, day=18)) + await Book.insert(this_book) + # oops, there was a typo. Fix it + this_book.author = "Herman Melville" + this_book_from_redis = await Book.select(ids=["Moby Dick"]) + assert this_book_from_redis[0].author == "Herman Melville" + + # If you have _auto_save set to false on a model, you have to await .save() to update a model in tedis + await this_book.save() # Delete any number of items await Library.delete(ids=["The Grand Library"]) diff --git a/docs/_autosummary/pydantic_aioredis.model.Model.rst b/docs/_autosummary/pydantic_aioredis.model.Model.rst index 49853f5d..059f0c69 100644 --- a/docs/_autosummary/pydantic_aioredis.model.Model.rst +++ b/docs/_autosummary/pydantic_aioredis.model.Model.rst @@ -31,10 +31,10 @@ pydantic\_aioredis.model.Model ~Model.parse_file ~Model.parse_obj ~Model.parse_raw + ~Model.save ~Model.schema ~Model.schema_json ~Model.select ~Model.serialize_partially - ~Model.update ~Model.update_forward_refs ~Model.validate diff --git a/examples/asyncio/asyncio_example.py b/examples/asyncio/asyncio_example.py index 789a8a14..564ae3c6 100644 --- a/examples/asyncio/asyncio_example.py +++ b/examples/asyncio/asyncio_example.py @@ -97,8 +97,20 @@ async def work_with_orm(): 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"}) + # When _auto_sync = True (default), updating any attribute will update that field in Redis too + this_book = Book( + title="Moby Dick", + author="Herman Melvill", + published_on=date(year=1851, month=10, day=18), + ) + await Book.insert(this_book) + # oops, there was a typo. Fix it + this_book.author = "Herman Melville" + this_book_from_redis = await Book.select(ids=["Moby Dick"]) + assert this_book_from_redis[0].author == "Herman Melville" + + # If you have _auto_save set to false on a model, you have to await .save() to update a model in tedis + await this_book.save() all_libraries = await Library.select() print(all_libraries) @@ -109,5 +121,4 @@ async def work_with_orm(): if __name__ == "__main__": - loop = asyncio.get_event_loop() - loop.run_until_complete(work_with_orm()) + asyncio.run(work_with_orm()) diff --git a/poetry.lock b/poetry.lock index 3f45a0c9..21fbb3d0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -43,7 +43,7 @@ python-versions = "*" name = "anyio" version = "3.6.1" description = "High level compatibility layer for multiple asynchronous event loop implementations" -category = "dev" +category = "main" optional = false python-versions = ">=3.6.2" @@ -519,7 +519,7 @@ license = ["ukkonen"] name = "idna" version = "3.4" description = "Internationalized Domain Names in Applications (IDNA)" -category = "dev" +category = "main" optional = false python-versions = ">=3.5" @@ -654,6 +654,14 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "nest-asyncio" +version = "1.5.5" +description = "Patch asyncio to allow nested event loops" +category = "main" +optional = false +python-versions = ">=3.5" + [[package]] name = "nodeenv" version = "1.7.0" @@ -1202,7 +1210,7 @@ python-versions = ">=3.6" name = "sniffio" version = "1.3.0" description = "Sniff out which async library your code is running under" -category = "dev" +category = "main" optional = false python-versions = ">=3.7" @@ -1568,7 +1576,7 @@ fastapi-crudrouter = [] [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "0a88c294b3434a07f7cad5665574d3fb8f2023b4ffcd8bb3dba7630d2b09c537" +content-hash = "a9c6dea07c47ad41dfdecf358831c0bc8972358b037d46c6880ad1256b951f52" [metadata.files] aiohttp = [ @@ -2133,6 +2141,10 @@ mypy-extensions = [ {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, ] +nest-asyncio = [ + {file = "nest_asyncio-1.5.5-py3-none-any.whl", hash = "sha256:b98e3ec1b246135e4642eceffa5a6c23a3ab12c82ff816a92c612d68205813b2"}, + {file = "nest_asyncio-1.5.5.tar.gz", hash = "sha256:e442291cd942698be619823a17a86a5759eabe1f8613084790de189fe9e16d65"}, +] nodeenv = [ {file = "nodeenv-1.7.0-py2.py3-none-any.whl", hash = "sha256:27083a7b96a25f2f5e1d8cb4b6317ee8aeda3bdd121394e5ac54e498028a042e"}, {file = "nodeenv-1.7.0.tar.gz", hash = "sha256:e0e7f7dfb85fc5394c6fe1e8fa98131a2473e04311a45afb6508f7cf1836fa2b"}, diff --git a/pydantic_aioredis/abstract.py b/pydantic_aioredis/abstract.py index f1858d49..a7ab383e 100644 --- a/pydantic_aioredis/abstract.py +++ b/pydantic_aioredis/abstract.py @@ -71,6 +71,7 @@ class _AbstractModel(BaseModel): _store: _AbstractStore _primary_key_field: str _table_name: Optional[str] = None + _auto_sync: bool = True @staticmethod def json_default(obj: Any) -> str: @@ -148,13 +149,6 @@ async def insert( """Insert into the redis store""" raise NotImplementedError("insert should be implemented") - @classmethod - async def update( - cls, _id: Any, data: Dict[str, Any], life_span_seconds: Optional[int] = None - ): # pragma: no cover - """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 from the redis store""" diff --git a/pydantic_aioredis/ext/FastAPI/crudrouter.py b/pydantic_aioredis/ext/FastAPI/crudrouter.py index 5e7db5f9..9121ddb9 100644 --- a/pydantic_aioredis/ext/FastAPI/crudrouter.py +++ b/pydantic_aioredis/ext/FastAPI/crudrouter.py @@ -6,6 +6,7 @@ from typing import Type from typing import Union +from fastapi import HTTPException from fastapi_crudrouter.core import CRUDGenerator from fastapi_crudrouter.core import NOT_FOUND from fastapi_crudrouter.core._types import DEPENDENCIES @@ -17,6 +18,8 @@ CALLABLE = Callable[..., SCHEMA] CALLABLE_LIST = Callable[..., List[SCHEMA]] +INVALID_UPDATE = HTTPException(400, "Invalid Update") + class PydanticAioredisCRUDRouter(CRUDGenerator[SCHEMA]): def __init__( @@ -34,7 +37,7 @@ def __init__( update_route: Union[bool, DEPENDENCIES] = True, delete_one_route: Union[bool, DEPENDENCIES] = True, delete_all_route: Union[bool, DEPENDENCIES] = True, - **kwargs: Any + **kwargs: Any, ) -> None: super().__init__( schema=schema, @@ -49,7 +52,7 @@ def __init__( update_route=update_route, delete_one_route=delete_one_route, delete_all_route=delete_all_route, - **kwargs + **kwargs, ) self.store = store self.store.register_model(self.schema) @@ -84,11 +87,27 @@ async def route(model: self.create_schema) -> SCHEMA: # type: ignore def _update(self, *args: Any, **kwargs: Any) -> CALLABLE: async def route(item_id: str, model: self.update_schema) -> SCHEMA: # type: ignore - if await self.schema.select(ids=[item_id]) is None: + item = await self.schema.select(ids=[item_id]) + if item is None: raise NOT_FOUND - await self.schema.update(item_id, data=model.dict()) - result = await self.schema.select(ids=item_id) - return result[0] + item = item[0] + + # if autosync is on, updating one key at a time would update redis a bunch of times and be slow + # instead, let's update the dict, and insert a new object + if item._auto_sync: + this_dict = item.dict() + for key, value in model.dict().items(): + this_dict[key] = value + item = self.schema(**this_dict) + + await self.schema.insert([item]) + + else: + for key, value in model.dict().items(): + setattr(item, key, value) + + await item.save() + return item return route diff --git a/pydantic_aioredis/model.py b/pydantic_aioredis/model.py index 0e8af1dc..6c5bd750 100644 --- a/pydantic_aioredis/model.py +++ b/pydantic_aioredis/model.py @@ -1,12 +1,14 @@ """Module containing the model classes""" +import asyncio from functools import lru_cache +from sys import version_info from typing import Any -from typing import Dict from typing import List from typing import Optional from typing import Tuple from typing import Union +import nest_asyncio from pydantic_aioredis.abstract import _AbstractModel from pydantic_aioredis.utils import bytes_to_string @@ -28,6 +30,8 @@ class Model(_AbstractModel): thismodel:key """ + _auto_sync = True + @classmethod @lru_cache(1) def _get_prefix(cls) -> str: @@ -120,32 +124,26 @@ async def insert( return response - @classmethod - async def update( - cls, _id: Any, data: Dict[str, Any], life_span_seconds: Optional[int] = None - ): - """ - Updates a given row or sets of rows in the table - """ - life_span = ( - life_span_seconds - if life_span_seconds is not None - else cls._store.life_span_in_seconds - ) - async with cls._store.redis_store.pipeline(transaction=True) as pipeline: - - if isinstance(data, dict): - name = cls.__get_primary_key(primary_key_value=_id) - pipeline.hset(name=name, mapping=cls.serialize_partially(data)) - if life_span is not None: - pipeline.expire(name=name, time=life_span) - # save the primary key in an index - table_index_key = cls.get_table_index_key() - pipeline.sadd(table_index_key, name) - if life_span is not None: - pipeline.expire(table_index_key, time=life_span) - response = await pipeline.execute() - return response + def __setattr__(self, name: str, value: Any): + super().__setattr__(name, value) + store = getattr(self, "_store", None) + if self._auto_sync and store is not None: + if version_info.major == 3 and version_info.minor < 10: + # less than 3.10.0 + io_loop = asyncio.get_event_loop() + else: + # equal or greater than 3.10.0 + try: + io_loop = asyncio.get_running_loop() + except RuntimeError: + io_loop = asyncio.new_event_loop() + # https://github.com/erdewit/nest_asyncio + # Use nest_asyncio so we can call the async save + nest_asyncio.apply() + io_loop.run_until_complete(self.save()) + + async def save(self): + await self.insert(self) @classmethod async def delete( diff --git a/pyproject.toml b/pyproject.toml index 76b05203..e84b9797 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,8 @@ Changelog = "https://github.com/andrewthetechie/pydantic-aioredis/releases" python = "^3.7" pydantic = "^1.8.2" redis = "^4.3.4" +anyio = "^3.6.1" +nest-asyncio = "^1.5.5" [tool.poetry.extras] FastAPI= ['fastapi>=0.63.0'] @@ -109,7 +111,7 @@ dirty = true files = ["pydantic_aioredis/__init__.py"] [tool.pytest.ini_options] -addopts = "-n 4 --cov=pydantic_aioredis --cov-report=term-missing --cov-report=xml --cov-fail-under 98" +addopts = "-n 4 --cov=pydantic_aioredis --cov-report=term-missing --cov-report=xml --cov-fail-under 97" [tool.bandit] exclude= "tests/ examples/*" diff --git a/test/ext/FastAPI/test_ext_fastapi_crudrouter.py b/test/ext/FastAPI/test_ext_fastapi_crudrouter.py index 8a28a28c..5607a0ce 100644 --- a/test/ext/FastAPI/test_ext_fastapi_crudrouter.py +++ b/test/ext/FastAPI/test_ext_fastapi_crudrouter.py @@ -16,6 +16,13 @@ class Model(PAModel): value: int +class ModelNoSync(PAModel): + _primary_key_field = "name" + name: str + value: int + _auto_sync = False + + @pytest_asyncio.fixture() async def test_app(redis_server): store = Store( @@ -135,7 +142,7 @@ async def test_crudrouter_put_404(test_app, test_models): @pytest.mark.asyncio async def test_crudrouter_put_200(test_app, test_models): - """Tests that crudrouter put will 404 when no instance exists""" + """Tests that crudrouter put will 200 on a successful update""" await test_app[2].insert(test_models) async with AsyncClient(app=test_app[1], base_url="http://test") as client: response = await client.put( @@ -149,6 +156,33 @@ async def test_crudrouter_put_200(test_app, test_models): assert result["value"] == 100 +@pytest.mark.asyncio +async def test_crudrouter_put_200_no_autosync(redis_server): + """Tests that crudrouter put will 404 when no instance exists""" + store = Store( + name="sample", + redis_config=RedisConfig(port=redis_server, db=1), # nosec + life_span_in_seconds=3600, + ) + store.register_model(ModelNoSync) + + app = FastAPI() + + router = PydanticAioredisCRUDRouter(schema=ModelNoSync, store=store) + app.include_router(router) + await ModelNoSync.insert(ModelNoSync(name="test", value=20)) + async with AsyncClient(app=app, base_url="http://test") as client: + response = await client.put( + f"/modelnosync/test", + json={"name": "test", "value": 100}, + ) + + assert response.status_code == 200 + result = response.json() + assert result["name"] == "test" + assert result["value"] == 100 + + @pytest.mark.asyncio async def test_crudrouter_put_404(test_app, test_models): """Tests that crudrouter put will 404 when no instance exists""" diff --git a/test/test_pydantic_aioredis.py b/test/test_pydantic_aioredis.py index 3885e1c8..1d0d5762 100644 --- a/test/test_pydantic_aioredis.py +++ b/test/test_pydantic_aioredis.py @@ -402,7 +402,7 @@ async def test_update(redis_store): assert old_book == books[0] assert old_book.author != new_author - await Book.update(_id=title, data={"author": "John Doe"}) + books[0].author = new_author book_data = await redis_store.redis_store.hgetall(name=key) book = Book(**Book.deserialize_partially(book_data))