From ffd7b9a2ecb5610b3e78e6dd46fcac72d4138aeb Mon Sep 17 00:00:00 2001 From: Olivier Cervello Date: Thu, 2 Feb 2023 11:24:14 +0000 Subject: [PATCH 1/3] add post_save, post_patch, post_delete hooks --- piccolo_api/crud/endpoints.py | 23 +++++++++++++++++++++++ piccolo_api/crud/hooks.py | 3 +++ 2 files changed, 26 insertions(+) diff --git a/piccolo_api/crud/endpoints.py b/piccolo_api/crud/endpoints.py index 233c3caf..94e6cf2d 100644 --- a/piccolo_api/crud/endpoints.py +++ b/piccolo_api/crud/endpoints.py @@ -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) @@ -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() ) @@ -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. diff --git a/piccolo_api/crud/hooks.py b/piccolo_api/crud/hooks.py index e071f66a..6f01c2e9 100644 --- a/piccolo_api/crud/hooks.py +++ b/piccolo_api/crud/hooks.py @@ -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: From 30d64759e1fa6728e4d43d900b3a44ecf3437317 Mon Sep 17 00:00:00 2001 From: Olivier Cervello Date: Thu, 2 Feb 2023 11:36:35 +0000 Subject: [PATCH 2/3] add docs --- docs/source/crud/hooks.rst | 84 +++++++++++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 2 deletions(-) diff --git a/docs/source/crud/hooks.rst b/docs/source/crud/hooks.rst index 423fb2b7..13c1111b 100644 --- a/docs/source/crud/hooks.rst +++ b/docs/source/crud/hooks.rst @@ -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 ~~~~~~~~~ @@ -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 ~~~~~~~~~~ @@ -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 ~~~~~~~~~~~~~~~~~~~~ From 6a2633bf16004e933fbae1237e0ce80c8ac0d9d1 Mon Sep 17 00:00:00 2001 From: Olivier Cervello Date: Thu, 2 Feb 2023 12:05:40 +0000 Subject: [PATCH 3/3] add unit tests --- tests/crud/test_hooks.py | 79 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 2 deletions(-) diff --git a/tests/crud/test_hooks.py b/tests/crud/test_hooks.py index 15bc7454..5b45ec33 100644 --- a/tests/crud/test_hooks.py +++ b/tests/crud/test_hooks.py @@ -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 @@ -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( @@ -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}/") \ No newline at end of file