Skip to content

Commit

Permalink
feat: update on setattr (#287)
Browse files Browse the repository at this point in the history
#279
BREAKING CHANGE: This is a breaking change to how updates for model
attributes are saved to Redis. It removes the update classmethod and
replaces with with a save method on each model.

Additionally, if _auto_sync is set to true on a model (the default), it
will save to redis on any attribute change
  • Loading branch information
andrewthetechie authored Sep 17, 2022
1 parent be83bfd commit f1ce5c2
Show file tree
Hide file tree
Showing 10 changed files with 133 additions and 55 deletions.
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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"])
Expand Down
2 changes: 1 addition & 1 deletion docs/_autosummary/pydantic_aioredis.model.Model.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
19 changes: 15 additions & 4 deletions examples/asyncio/asyncio_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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())
20 changes: 16 additions & 4 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 1 addition & 7 deletions pydantic_aioredis/abstract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"""
Expand Down
31 changes: 25 additions & 6 deletions pydantic_aioredis/ext/FastAPI/crudrouter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -17,6 +18,8 @@
CALLABLE = Callable[..., SCHEMA]
CALLABLE_LIST = Callable[..., List[SCHEMA]]

INVALID_UPDATE = HTTPException(400, "Invalid Update")


class PydanticAioredisCRUDRouter(CRUDGenerator[SCHEMA]):
def __init__(
Expand All @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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

Expand Down
52 changes: 25 additions & 27 deletions pydantic_aioredis/model.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -28,6 +30,8 @@ class Model(_AbstractModel):
thismodel:key
"""

_auto_sync = True

@classmethod
@lru_cache(1)
def _get_prefix(cls) -> str:
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down Expand Up @@ -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/*"
Expand Down
36 changes: 35 additions & 1 deletion test/ext/FastAPI/test_ext_fastapi_crudrouter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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"""
Expand Down
2 changes: 1 addition & 1 deletion test/test_pydantic_aioredis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down

0 comments on commit f1ce5c2

Please sign in to comment.