Skip to content
Open
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
84 changes: 82 additions & 2 deletions docs/source/crud/hooks.rst
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,38 @@ It takes a single parameter, ``row``, and should return the row:
return row


app = PiccoloCRUD(table=Movie, read_only=False, hooks=[
Hook(hook_type=HookType.pre_save, callable=set_movie_rating_10)
app = PiccoloCRUD(
table=Movie,
read_only=False,
hooks=[
Hook(hook_type=HookType.pre_save, callable=set_movie_rating_10)
]
)


post_save
~~~~~~~~

This hook runs during POST requests, after inserting data into the database.
It takes a single parameter, ``row``.

``post_save`` hooks should not return data.

.. code-block:: python

async def print_movie(row: Movie):
print(f'Movie {row.id} added to db.')


app = PiccoloCRUD(
table=Movie,
read_only=False,
hooks=[
Hook(hook_type=HookType.pre_save, callable=print_movie)
]
)


pre_patch
~~~~~~~~~

Expand Down Expand Up @@ -107,6 +134,33 @@ Each function must return a dictionary which represent the data to be modified.
)


post_patch
~~~~~~~~~

This hook runs during PATCH requests, after changing the specified row in
the database.

It takes two parameters, ``row_id`` which is the id of the row to be changed,
and ``values`` which is a dictionary of incoming values.

``post_patch`` hooks should not return data.

.. code-block:: python

async def print_movie_changes(row_id: int, values: dict):
current_db_row = await Movie.objects().get(Movie.id==row_id)
print(f'Movie {row_id} updated with values {values}')


app = PiccoloCRUD(
table=Movie,
read_only=False,
hooks=[
Hook(hook_type=HookType.post_patch, callable=print_movie_changes)
]
)


pre_delete
~~~~~~~~~~

Expand All @@ -131,6 +185,32 @@ It takes one parameter, ``row_id`` which is the id of the row to be deleted.
]
)


post_delete
~~~~~~~~~~

This hook runs during DELETE requests, after deleting the specified row in
the database.

It takes one parameter, ``row_id`` which is the id of the row to be deleted.

``post_delete`` hooks should not return data.

.. code-block:: python

async def post_delete(row_id: int):
pass


app = PiccoloCRUD(
table=Movie,
read_only=False,
hooks=[
Hook(hook_type=HookType.pre_delete, callable=post_delete)
]
)


Dependency injection
~~~~~~~~~~~~~~~~~~~~

Expand Down
23 changes: 23 additions & 0 deletions piccolo_api/crud/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -817,6 +817,13 @@ async def post_single(
request=request,
)
response = await row.save().run()
if self._hook_map:
await execute_post_hooks(
hooks=self._hook_map,
hook_type=HookType.post_save,
row=row,
request=request,
)
json = dump_json(response)
# Returns the id of the inserted row.
return CustomJSONResponse(json, status_code=201)
Expand Down Expand Up @@ -1100,6 +1107,14 @@ async def patch_single(
.first()
.run()
)
if self._hook_map:
await execute_patch_hooks(
hooks=self._hook_map,
hook_type=HookType.post_patch,
row_id=row_id,
values=values,
request=request,
)
return CustomJSONResponse(
self.pydantic_model(**new_row).json()
)
Expand Down Expand Up @@ -1128,10 +1143,18 @@ async def delete_single(
await self.table.delete().where(
self.table._meta.primary_key == row_id
).run()
if self._hook_map:
await execute_delete_hooks(
hooks=self._hook_map,
hook_type=HookType.post_delete,
row_id=row_id,
request=request,
)
return Response(status_code=204)
except ValueError:
return Response("Unable to delete the resource.", status_code=500)


def __eq__(self, other: t.Any) -> bool:
"""
To keep LGTM happy.
Expand Down
3 changes: 3 additions & 0 deletions piccolo_api/crud/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ class HookType(Enum):
pre_save = "pre_save"
pre_patch = "pre_patch"
pre_delete = "pre_delete"
post_save = "post_save"
post_patch = "post_patch"
post_delete = "post_delete"


class Hook:
Expand Down
79 changes: 77 additions & 2 deletions tests/crud/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,29 @@ def test_multi_pre_post_hooks(self):
movie = Movie.objects().first().run_sync()
self.assertEqual(movie.rating, 20)

def test_post_save_hook_failed(self):
"""
Make sure failing post_save hook bubbles up
(this implicitly also tests that post_save hooks execute)
"""
client = TestClient(
PiccoloCRUD(
table=Movie,
read_only=False,
hooks=[
Hook(
hook_type=HookType.post_save,
callable=failing_hook,
)
],
)
)
json_req = {"name": "Star Wars", "rating": 93}
with self.assertRaises(Exception, msg="Test Passed"):
_ = client.post("/", json=json_req)
movie = Movie.objects().first().run_sync()
self.assertEqual(movie.rating, 20)

def test_request_context_passed_to_patch_hook(self):
"""
Make sure request context can be passed to patch hook
Expand Down Expand Up @@ -246,9 +269,39 @@ def test_pre_patch_hook_db_lookup(self):
movies = Movie.select().run_sync()
self.assertEqual(movies[0]["name"], original_name)

def test_post_patch_hook_failed(self):
"""
Make sure failing post_patch hook bubbles up
(this implicitly also tests that post_patch hooks execute)
"""
client = TestClient(
PiccoloCRUD(
table=Movie,
read_only=False,
hooks=[
Hook(
hook_type=HookType.post_patch,
callable=failing_hook,
)
],
)
)

original_name = "Star Wars"
movie = Movie(name="Star Wars", rating=93)
movie.save().run_sync()

new_name = "Star Wars: A New Hope"

with self.assertRaises(Exception, msg="Test Passed"):
_ = client.patch(f"/{movie.id}/", json={"name": new_name})

movies = Movie.select().run_sync()
self.assertEqual(movies[0]["name"], original_name)

def test_request_context_passed_to_delete_hook(self):
"""
Make sure request context can be passed to patch hook
Make sure request context can be passed to delete hook
callable
"""
client = TestClient(
Expand Down Expand Up @@ -290,5 +343,27 @@ def test_delete_hook_fails(self):
movie = Movie(name="Star Wars", rating=10)
movie.save().run_sync()

with self.assertRaises(Exception):
with self.assertRaises(Exception, msg="Test Passed"):
_ = client.delete(f"/{movie.id}/")


def test_post_delete_hook_fails(self):
"""
Make sure failing post_delete hook bubbles up
(this implicitly also tests that pre_delete hooks execute)
"""
client = TestClient(
PiccoloCRUD(
table=Movie,
read_only=False,
hooks=[
Hook(hook_type=HookType.post_delete, callable=failing_hook)
],
)
)

movie = Movie(name="Star Wars", rating=10)
movie.save().run_sync()

with self.assertRaises(Exception, msg="Test Passed"):
_ = client.delete(f"/{movie.id}/")