Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: update on setattr #287

Merged
merged 1 commit into from
Sep 17, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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