diff --git a/docs/source/crud/piccolo_crud.rst b/docs/source/crud/piccolo_crud.rst index 57e02993..25bebe72 100644 --- a/docs/source/crud/piccolo_crud.rst +++ b/docs/source/crud/piccolo_crud.rst @@ -10,16 +10,16 @@ methods on your Piccolo table, as well as some extras, via a `REST API / GET, PUT, DELETE, PATCH Get, update or delete a single row. -/schema/ GET Returns a JSON schema for the table. This allows clients to auto generate forms. -/ids/ GET Returns a mapping of all row ids to a description of the row. -/count/ GET Returns the number of matching rows. -/new/ GET Returns all of the default values for a new row - can be used to dynamically generate forms by the client. -========== ======================= ========================================================================================================== +========== ======================== ========================================================================================================== +Path Methods Description +========== ======================== ========================================================================================================== +/ GET, POST, DELETE, PATCH Get all rows, post a new row, delete all matching rows or update all matching rows. +// GET, PUT, DELETE, PATCH Get, update or delete a single row. +/schema/ GET Returns a JSON schema for the table. This allows clients to auto generate forms. +/ids/ GET Returns a mapping of all row ids to a description of the row. +/count/ GET Returns the number of matching rows. +/new/ GET Returns all of the default values for a new row - can be used to dynamically generate forms by the client. +========== ======================== ========================================================================================================== ------------------------------------------------------------------------------- @@ -265,6 +265,66 @@ For example, to return results 11 to 20: ------------------------------------------------------------------------------- +Bulk delete +----------- + +To specify which records you want to delete in bulk, pass a query parameter +like this ``__ids=1,2,3``, and you be able to delete all results whose ``id`` +is in the query params. + +.. hint:: You can also use this method with ``UUID`` primary keys and + the usage is the same. + +A query which delete movies with ``id`` pass in query parameter: + +.. code-block:: + + DELETE /movie/?__ids=1,2,3 + +You can delete rows in bulk with any filter params. A query which +delete movies with ``name`` pass in query parameter: + +.. code-block:: + + DELETE /movie/?name=Star + +Or you can combine multiple query params for additional security. +A query to delete records with name ``Star``, but with +specific ``id`` you can pass query like this: + +.. code-block:: + + DELETE /movie/?name=Star&__ids=1,2 + +.. warning:: To be able to provide a bulk delete action, we must set + ``allow_bulk_delete`` to ``True``. + +------------------------------------------------------------------------------- + +Bulk update +----------- + +To specify which records you want to update in bulk, pass a query parameter +like this ``__ids=1,2,3``, and you be able to update all results whose ``id`` +is in the query params. + +.. hint:: You can also use this method with ``UUID`` primary keys and + the usage is the same. + +A query which update movies with ``id`` pass in query parameter: + +.. code-block:: + + PATCH /movie/?__ids=1,2,3 + +If you pass a wrong or non-existent value to the query parameters ``__ids``, +no record will be changed and api response will be empty list. + +.. warning:: To be able to provide a bulk update action, we must set + ``allow_bulk_update`` to ``True``. + +------------------------------------------------------------------------------- + Readable -------- diff --git a/piccolo_api/crud/endpoints.py b/piccolo_api/crud/endpoints.py index bb0832b0..cbdb69ad 100644 --- a/piccolo_api/crud/endpoints.py +++ b/piccolo_api/crud/endpoints.py @@ -119,6 +119,7 @@ class Params: include_readable: bool = False page: int = 1 page_size: t.Optional[int] = None + ids: str = field(default="") visible_fields: t.Optional[t.List[Column]] = None range_header: bool = False range_header_name: str = field(default="") @@ -179,6 +180,7 @@ def __init__( table: t.Type[Table], read_only: bool = True, allow_bulk_delete: bool = False, + allow_bulk_update: bool = False, page_size: int = 15, exclude_secrets: bool = True, validators: t.Optional[Validators] = None, @@ -192,8 +194,11 @@ def __init__( :param read_only: If ``True``, only the GET method is allowed. :param allow_bulk_delete: - If ``True``, allows a delete request to the root to delete all - matching records. It is dangerous, so is disabled by default. + If ``True``, allows a delete request to the root and delete all + matching records with values in ``__ids`` query params. + :param allow_bulk_update: + If ``True``, allows a update request to the root and update all + matching records with values in ``__ids`` query params. :param page_size: The number of results shown on each page by default. :param exclude_secrets: @@ -244,6 +249,7 @@ def __init__( self.page_size = page_size self.read_only = read_only self.allow_bulk_delete = allow_bulk_delete + self.allow_bulk_update = allow_bulk_update self.exclude_secrets = exclude_secrets self.validators = validators self.max_joins = max_joins @@ -267,9 +273,11 @@ def __init__( root_methods = ["GET"] if not read_only: - root_methods += ( - ["POST", "DELETE"] if allow_bulk_delete else ["POST"] - ) + root_methods.append("POST") + if allow_bulk_delete: + root_methods.append("DELETE") + if allow_bulk_update: + root_methods.append("PATCH") routes: t.List[BaseRoute] = [ Route(path="/", endpoint=self.root, methods=root_methods), @@ -508,11 +516,11 @@ def _parse_params(self, params: QueryParams) -> t.Dict[str, t.Any]: The GET params may contain multiple values for each parameter name. For example: - /tables/movie?tag=horror&tag=scifi + /movie?tag=horror&tag=scifi Some clients, such as Axios, will use this convention: - /tables/movie?tag[]=horror&tag[]=scifi + /movie?tag[]=horror&tag[]=scifi This method normalises the parameter name, removing square brackets if present (tag[] -> tag), and will return a list of values if @@ -545,6 +553,7 @@ def _parse_params(self, params: QueryParams) -> t.Dict[str, t.Any]: return output async def root(self, request: Request) -> Response: + rows_ids = request.query_params.get("__ids", None) if request.method == "GET": params = self._parse_params(request.query_params) return await self.get_all(request, params=params) @@ -553,7 +562,10 @@ async def root(self, request: Request) -> Response: return await self.post_single(request, data) elif request.method == "DELETE": params = dict(request.query_params) - return await self.delete_all(request, params=params) + return await self.delete_bulk(request, params=params) + elif request.method == "PATCH": + data = await request.json() + return await self.patch_bulk(request, data, rows_ids=rows_ids) else: return Response(status_code=405) @@ -579,6 +591,9 @@ def _split_params(self, params: t.Dict[str, t.Any]) -> Params: And can specify which page: {'__page': 2}. + You can specify which records want to delete or update from rows: + {'__ids': '1,2,3'}. + You can specify which fields want to display in rows: {'__visible_fields': 'id,name'}. @@ -664,6 +679,10 @@ def _split_params(self, params: t.Dict[str, t.Any]) -> Params: response.page_size = page_size continue + if key == "__ids": + response.ids = value + continue + if key == "__visible_fields": column_names: t.List[str] @@ -714,6 +733,10 @@ def _apply_filters( Works on any queries which support `where` clauses - Select, Count, Objects etc. + + :raises MalformedQuery: + If the filters reference columns which don't exist. + """ fields = params.fields if fields: @@ -928,23 +951,70 @@ async def post_single( ) @apply_validators - async def delete_all( - self, request: Request, params: t.Optional[t.Dict[str, t.Any]] = None + async def patch_bulk( + self, + request: Request, + data: t.Dict[str, t.Any], + rows_ids: str, ) -> Response: """ - Deletes all rows - query parameters are used for filtering. + Bulk update of rows whose primary keys are in the ``__ids`` + query param. """ - params = self._clean_data(params) if params else {} + cleaned_data = self._clean_data(data) try: - split_params = self._split_params(params) - except ParamException as exception: + model = self.pydantic_model_optional(**cleaned_data) + except Exception as exception: return Response(str(exception), status_code=400) - try: - query = self._apply_filters( - self.table.delete(force=True), split_params + values = { + getattr(self.table, key): getattr(model, key) + for key in data.keys() + } + + # Serial or UUID primary keys enabled in query params + value_type = self.table._meta.primary_key.value_type + split_rows_ids = rows_ids.split(",") + ids = [value_type(item) for item in split_rows_ids] + + await self.table.update(values).where( + self.table._meta.primary_key.is_in(ids) + ).run() + updated_rows = ( + await self.table.select( + exclude_secrets=self.exclude_secrets, ) + .where(self.table._meta.primary_key.is_in(ids)) + .run() + ) + json = self.pydantic_model_plural()(rows=updated_rows).json() + + return CustomJSONResponse(json) + + @apply_validators + async def delete_bulk( + self, request: Request, params: t.Optional[t.Dict[str, t.Any]] = None + ) -> Response: + """ + Bulk deletes rows - query parameters are used for filtering. + """ + params = self._clean_data(params) if params else {} + split_params = self._split_params(params) + split_params_ids = split_params.ids.split(",") + + try: + query: t.Any = self.table.delete() + try: + # Serial or UUID primary keys enabled in query params + value_type = self.table._meta.primary_key.value_type + ids = [value_type(item) for item in split_params_ids] + query_ids = query.where( + self.table._meta.primary_key.is_in(ids) + ) + query = self._apply_filters(query_ids, split_params) + except ValueError: + query = self._apply_filters(query, split_params) except MalformedQuery as exception: return Response(str(exception), status_code=400) diff --git a/piccolo_api/crud/validators.py b/piccolo_api/crud/validators.py index a86bbaa3..9281bbc1 100644 --- a/piccolo_api/crud/validators.py +++ b/piccolo_api/crud/validators.py @@ -55,7 +55,8 @@ def __init__( delete_single: t.List[ValidatorFunction] = [], post_single: t.List[ValidatorFunction] = [], get_all: t.List[ValidatorFunction] = [], - delete_all: t.List[ValidatorFunction] = [], + delete_bulk: t.List[ValidatorFunction] = [], + patch_bulk: t.List[ValidatorFunction] = [], get_references: t.List[ValidatorFunction] = [], get_ids: t.List[ValidatorFunction] = [], get_new: t.List[ValidatorFunction] = [], @@ -70,7 +71,8 @@ def __init__( self.delete_single = delete_single self.post_single = post_single self.get_all = get_all - self.delete_all = delete_all + self.delete_bulk = delete_bulk + self.patch_bulk = patch_bulk self.get_references = get_references self.get_ids = get_ids self.get_new = get_new diff --git a/piccolo_api/fastapi/endpoints.py b/piccolo_api/fastapi/endpoints.py index 78d01ad9..0c755169 100644 --- a/piccolo_api/fastapi/endpoints.py +++ b/piccolo_api/fastapi/endpoints.py @@ -24,6 +24,7 @@ class HTTPMethod(str, Enum): get = "GET" delete = "DELETE" + patch = "PATCH" class FastAPIKwargs: @@ -35,19 +36,21 @@ def __init__( self, all_routes: t.Dict[str, t.Any] = {}, get: t.Dict[str, t.Any] = {}, - delete: t.Dict[str, t.Any] = {}, + delete_bulk: t.Dict[str, t.Any] = {}, post: t.Dict[str, t.Any] = {}, put: t.Dict[str, t.Any] = {}, patch: t.Dict[str, t.Any] = {}, + patch_bulk: t.Dict[str, t.Any] = {}, get_single: t.Dict[str, t.Any] = {}, delete_single: t.Dict[str, t.Any] = {}, ): self.all_routes = all_routes self.get = get - self.delete = delete + self.delete_bulk = delete_bulk self.post = post self.put = put self.patch = patch + self.patch_bulk = patch_bulk self.get_single = get_single self.delete_single = delete_single @@ -245,29 +248,53 @@ async def references(request: Request): ) ####################################################################### - # Root - DELETE + # Root - DELETE BULK if not piccolo_crud.read_only and piccolo_crud.allow_bulk_delete: - async def delete(request: Request, **kwargs): + async def delete_bulk(request: Request, **kwargs): """ Deletes all rows matching the given query. """ return await piccolo_crud.root(request=request) self.modify_signature( - endpoint=delete, + endpoint=delete_bulk, model=self.ModelOut, http_method=HTTPMethod.delete, ) fastapi_app.add_api_route( path=root_url, - endpoint=delete, + endpoint=delete_bulk, response_model=None, status_code=status.HTTP_204_NO_CONTENT, methods=["DELETE"], - **fastapi_kwargs.get_kwargs("delete"), + **fastapi_kwargs.get_kwargs("delete_bulk"), + ) + + ####################################################################### + # Root - PATCH BULK + + if not piccolo_crud.read_only and piccolo_crud.allow_bulk_update: + + async def patch_bulk(request: Request, model): + """ + Bulk update of rows whose primary keys are in the ``__ids`` + query param. + """ + return await piccolo_crud.root(request=request) + + patch_bulk.__annotations__[ + "model" + ] = f"ANNOTATIONS['{self.alias}']['ModelOptional']" + + fastapi_app.add_api_route( + path=root_url, + endpoint=patch_bulk, + response_model=self.ModelOut, + methods=["PATCH"], + **fastapi_kwargs.get_kwargs("patch_bulk"), ) ####################################################################### @@ -484,6 +511,24 @@ def modify_signature( ) ) + if http_method == HTTPMethod.delete or http_method == HTTPMethod.patch: + parameters.extend( + [ + Parameter( + name="__ids", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=str, + default=Query( + default=None, + description=( + "Specifies which rows you want to delete " + "or update in bulk (default ' ')." + ), + ), + ), + ] + ) + if http_method == HTTPMethod.get: if allow_ordering: parameters.extend( diff --git a/tests/crud/test_crud_endpoints.py b/tests/crud/test_crud_endpoints.py index bcc5d0b5..8b9d00e7 100644 --- a/tests/crud/test_crud_endpoints.py +++ b/tests/crud/test_crud_endpoints.py @@ -2,7 +2,16 @@ from unittest import TestCase from piccolo.apps.user.tables import BaseUser -from piccolo.columns import Email, ForeignKey, Integer, Secret, Text, Varchar +from piccolo.columns import ( + UUID, + Boolean, + Email, + ForeignKey, + Integer, + Secret, + Text, + Varchar, +) from piccolo.columns.readable import Readable from piccolo.table import Table from starlette.datastructures import QueryParams @@ -37,6 +46,12 @@ class TopSecret(Table): class Studio(Table): + pk = UUID(primary_key=True) + name = Varchar() + opened = Boolean() + + +class Company(Table): name = Varchar() contact_email = Email() booking_email = Email(default="booking@studio.com") @@ -1516,9 +1531,11 @@ def test_get_404(self): class TestBulkDelete(TestCase): def setUp(self): Movie.create_table(if_not_exists=True).run_sync() + Studio.create_table(if_not_exists=True).run_sync() def tearDown(self): Movie.alter().drop_table().run_sync() + Studio.alter().drop_table().run_sync() def test_no_bulk_delete(self): """ @@ -1538,25 +1555,27 @@ def test_no_bulk_delete(self): movie_count = Movie.count().run_sync() self.assertEqual(movie_count, 1) - def test_bulk_delete(self): + def test_bulk_delete_pk_serial(self): """ - Make sure that bulk deletes are only allowed is allow_bulk_delete is - True. + Make sure that bulk deletes are only allowed if ``allow_bulk_delete`` + is True. """ client = TestClient( PiccoloCRUD(table=Movie, read_only=False, allow_bulk_delete=True) ) - movie = Movie(name="Star Wars", rating=93) - movie.save().run_sync() + Movie.insert( + Movie(name="Star Wars", rating=93), + Movie(name="Lord of the Rings", rating=90), + ).run_sync() - response = client.delete("/") + response = client.delete("/", params={"__ids": "1,2"}) self.assertEqual(response.status_code, 204) movie_count = Movie.count().run_sync() self.assertEqual(movie_count, 0) - def test_bulk_delete_filtering(self): + def test_bulk_delete_pk_serial_filtering(self): """ Make sure filtering works with bulk deletes. """ @@ -1569,13 +1588,138 @@ def test_bulk_delete_filtering(self): Movie(name="Lord of the Rings", rating=90), ).run_sync() - response = client.delete("/?name=Star%20Wars") + response = client.delete( + "/", params={"__ids": "1", "name": "Star Wars"} + ) self.assertEqual(response.status_code, 204) movies = Movie.select().run_sync() self.assertEqual(len(movies), 1) self.assertEqual(movies[0]["name"], "Lord of the Rings") + def test_bulk_delete_pk_serial_filtering_without_ids(self): + """ + Make sure filtering works with bulk deletes and + without ``__ids`` query params. + """ + client = TestClient( + PiccoloCRUD(table=Movie, read_only=False, allow_bulk_delete=True) + ) + + Movie.insert( + Movie(name="Star Wars", rating=93), + Movie(name="Lord of the Rings", rating=90), + ).run_sync() + + response = client.delete("/", params={"name": "Star Wars"}) + self.assertEqual(response.status_code, 204) + + movies = Movie.select().run_sync() + self.assertEqual(len(movies), 1) + self.assertEqual(movies[0]["name"], "Lord of the Rings") + + def test_bulk_delete_pk_uuid(self): + """ + Make sure that bulk deletes are only allowed if ``allow_bulk_delete`` + is True. + """ + client = TestClient( + PiccoloCRUD(table=Studio, read_only=False, allow_bulk_delete=True) + ) + + Studio.insert( + Studio( + pk="af5dc416-2784-4d63-87a1-c987f7ad57fc", + name="Blasting Room", + opened=True, + ), + Studio( + pk="708a7531-b1cf-4ff8-a25d-3287fad1bac4", + name="JHOC Studio", + opened=True, + ), + ).run_sync() + + studios = Studio.select().run_sync() + response = client.delete( + "/", + params={"__ids": f"{studios[0]['pk']},{studios[1]['pk']}"}, + ) + self.assertEqual(response.status_code, 204) + + studio_count = Studio.count().run_sync() + self.assertEqual(studio_count, 0) + + def test_bulk_delete_pk_uuid_filtering(self): + """ + Make sure filtering works with bulk deletes. + """ + client = TestClient( + PiccoloCRUD(table=Studio, read_only=False, allow_bulk_delete=True) + ) + + Studio.insert( + Studio( + pk="af5dc416-2784-4d63-87a1-c987f7ad57fc", + name="Blasting Room", + opened=True, + ), + Studio( + pk="708a7531-b1cf-4ff8-a25d-3287fad1bac4", + name="JHOC Studio", + opened=True, + ), + ).run_sync() + + studios = Studio.select().run_sync() + response = client.delete( + "/", + params={ + "__ids": f"{studios[0]['pk']}", + "name": f"{studios[0]['name']}", + }, + ) + + studios = Studio.select().run_sync() + self.assertEqual(response.status_code, 204) + self.assertEqual(len(studios), 1) + self.assertEqual(studios[0]["name"], "JHOC Studio") + + def test_bulk_delete_pk_uuid_filtering_without_ids(self): + """ + Make sure filtering works with bulk deletes and + without ``__ids`` query params. + """ + client = TestClient( + PiccoloCRUD(table=Studio, read_only=False, allow_bulk_delete=True) + ) + + Studio.insert( + Studio( + pk="af5dc416-2784-4d63-87a1-c987f7ad57fc", + name="Blasting Room", + opened=True, + ), + Studio( + pk="708a7531-b1cf-4ff8-a25d-3287fad1bac4", + name="JHOC Studio", + opened=True, + ), + ).run_sync() + + studios = Studio.select().run_sync() + response = client.delete( + "/", + params={ + "name": f"{studios[0]['name']}", + }, + ) + + studios = Studio.select().run_sync() + self.assertEqual(response.status_code, 204) + self.assertEqual(len(studios), 1) + self.assertEqual(studios[0]["name"], "JHOC Studio") + def test_read_only(self): """ In read_only mode, no HTTP verbs should be allowed which modify data. @@ -1594,6 +1738,211 @@ def test_read_only(self): self.assertEqual(movie_count, 1) +class TestBulkUpdate(TestCase): + def setUp(self): + Movie.create_table(if_not_exists=True).run_sync() + Studio.create_table(if_not_exists=True).run_sync() + + def tearDown(self): + Movie.alter().drop_table().run_sync() + Studio.alter().drop_table().run_sync() + + def test_no_bulk_update(self): + """ + Make sure that updates aren't allowed when ``allow_bulk_update`` is + False. + """ + client = TestClient( + PiccoloCRUD(table=Movie, read_only=False, allow_bulk_update=False) + ) + + movie = Movie(name="Star Wars", rating=93) + movie.save().run_sync() + + params = {"__ids": "1"} + + response = client.patch("/", params=params, json={"rating": 98}) + self.assertEqual(response.status_code, 405) + + movie_count = Movie.count().run_sync() + self.assertEqual(movie_count, 1) + + def test_bulk_update_pk_serial_multiple_columns(self): + """ + Make sure that we can update multiple columns in bulk. + """ + client = TestClient( + PiccoloCRUD(table=Movie, read_only=False, allow_bulk_update=True) + ) + + Movie.insert( + Movie(name="Star Wars", rating=93), + Movie(name="Lord of the Rings", rating=90), + ).run_sync() + + params = {"__ids": "1,2"} + json = {"name": "Alien", "rating": 98} + + response = client.patch("/", params=params, json=json) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + { + "rows": [ + {"id": 1, "name": "Alien", "rating": 98}, + {"id": 2, "name": "Alien", "rating": 98}, + ] + }, + ) + + def test_bulk_update_pk_serial_single_column(self): + """ + Make sure to update only one column in bulk without changes + to other columns. + """ + client = TestClient( + PiccoloCRUD(table=Movie, read_only=False, allow_bulk_update=True) + ) + + Movie.insert( + Movie(name="Star Wars", rating=93), + Movie(name="Lord of the Rings", rating=90), + ).run_sync() + + params = {"__ids": "1,2"} + json = {"rating": 95} + + response = client.patch("/", params=params, json=json) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + { + "rows": [ + {"id": 1, "name": "Star Wars", "rating": 95}, + {"id": 2, "name": "Lord of the Rings", "rating": 95}, + ] + }, + ) + + def test_bulk_update_pk_serial_non_existing_column(self): + """ + Make sure if we pass non-existent values ​​in the `` __ids`` + query params, the rows do not change. + """ + client = TestClient( + PiccoloCRUD(table=Movie, read_only=False, allow_bulk_update=True) + ) + + Movie.insert( + Movie(name="Star Wars", rating=93), + Movie(name="Lord of the Rings", rating=90), + ).run_sync() + # non existing values + params = {"__ids": "3,4"} + json = {"rating": 95} + + response = client.patch("/", params=params, json=json) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"rows": []}) + + def test_bulk_update_pk_uuid_multiple_columns(self): + """ + Make sure that we can update multiple columns in bulk + with uuid primary key. + """ + client = TestClient( + PiccoloCRUD(table=Studio, read_only=False, allow_bulk_update=True) + ) + + Studio.insert( + Studio( + pk="af5dc416-2784-4d63-87a1-c987f7ad57fc", + name="Blasting Room", + opened=True, + ), + Studio( + pk="708a7531-b1cf-4ff8-a25d-3287fad1bac4", + name="JHOC Studio", + opened=True, + ), + ).run_sync() + + params = { + "__ids": ( + "af5dc416-2784-4d63-87a1-c987f7ad57fc," + "708a7531-b1cf-4ff8-a25d-3287fad1bac4" + ) + } + json = {"name": "New Studio", "opened": False} + + response = client.patch("/", params=params, json=json) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["rows"][0]["name"], "New Studio") + self.assertEqual(response.json()["rows"][0]["opened"], False) + + def test_bulk_update_pk_uuid_single_column(self): + """ + Make sure to update only one column in bulk without changes + to other columns. + """ + client = TestClient( + PiccoloCRUD(table=Studio, read_only=False, allow_bulk_update=True) + ) + + Studio.insert( + Studio( + pk="af5dc416-2784-4d63-87a1-c987f7ad57fc", + name="Blasting Room", + opened=True, + ), + Studio( + pk="708a7531-b1cf-4ff8-a25d-3287fad1bac4", + name="JHOC Studio", + opened=True, + ), + ).run_sync() + + params = { + "__ids": ( + "af5dc416-2784-4d63-87a1-c987f7ad57fc," + "708a7531-b1cf-4ff8-a25d-3287fad1bac4" + ) + } + json = {"opened": False} + + response = client.patch("/", params=params, json=json) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["rows"][0]["opened"], False) + + def test_bulk_update_uuid_serial_non_existing_column(self): + """ + Make sure if we pass non-existent values ​​in the `` __ids`` + query params, the rows do not change. + """ + client = TestClient( + PiccoloCRUD(table=Studio, read_only=False, allow_bulk_update=True) + ) + + Studio.insert( + Studio( + pk="af5dc416-2784-4d63-87a1-c987f7ad57fc", + name="Blasting Room", + opened=True, + ), + Studio( + pk="708a7531-b1cf-4ff8-a25d-3287fad1bac4", + name="JHOC Studio", + opened=True, + ), + ).run_sync() + + params = {"__ids": "708a7531-b1cf-4ff8-87a1-c987f7ad57fc"} + json = {"opened": False} + response = client.patch("/", params=params, json=json) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"rows": []}) + + class TestNew(TestCase): def setUp(self): Movie.create_table(if_not_exists=True).run_sync() @@ -1621,7 +1970,7 @@ def test_email(self): https://github.com/piccolo-orm/piccolo_api/issues/184 """ - client = TestClient(PiccoloCRUD(table=Studio)) + client = TestClient(PiccoloCRUD(table=Company)) response = client.get("/new/") self.assertEqual(response.status_code, 200) @@ -1662,26 +2011,10 @@ def test_malformed_query(self): response = client.get("/count/", params={"foobar": "1"}) self.assertEqual(response.status_code, 400) - response = client.delete("/", params={"foobar": "1"}) + response = client.delete("/", params={"__ids": "1", "foobar": "1"}) self.assertEqual(response.status_code, 400) -class TestIncorrectVerbs(TestCase): - def setUp(self): - Movie.create_table(if_not_exists=True).run_sync() - - def tearDown(self): - Movie.alter().drop_table().run_sync() - - def test_incorrect_verbs(self): - client = TestClient( - PiccoloCRUD(table=Movie, read_only=False, allow_bulk_delete=True) - ) - - response = client.patch("/", params={}) - self.assertEqual(response.status_code, 405) - - class TestParseParams(TestCase): def test_parsing(self): app = PiccoloCRUD(table=Movie) diff --git a/tests/fastapi/test_fastapi_endpoints.py b/tests/fastapi/test_fastapi_endpoints.py index 1284a163..88b85517 100644 --- a/tests/fastapi/test_fastapi_endpoints.py +++ b/tests/fastapi/test_fastapi_endpoints.py @@ -31,7 +31,10 @@ class Role(Table): root_url="/movies/", fastapi_app=app, piccolo_crud=PiccoloCRUD( - table=Movie, read_only=False, allow_bulk_delete=True + table=Movie, + read_only=False, + allow_bulk_delete=True, + allow_bulk_update=True, ), ) @@ -62,6 +65,7 @@ class TestResponses(TestCase): def setUp(self): Movie.create_table(if_not_exists=True).run_sync() Movie(name="Star Wars", rating=93).save().run_sync() + Movie(name="Alien", rating=95).save().run_sync() def tearDown(self): Movie.alter().drop_table().run_sync() @@ -72,7 +76,12 @@ def test_get(self): self.assertEqual(response.status_code, 200) self.assertEqual( response.json(), - {"rows": [{"id": 1, "name": "Star Wars", "rating": 93}]}, + { + "rows": [ + {"id": 2, "name": "Alien", "rating": 95}, + {"id": 1, "name": "Star Wars", "rating": 93}, + ] + }, ) def test_get_single(self): @@ -90,7 +99,7 @@ def test_count(self): self.assertEqual(response.status_code, 200) self.assertEqual( response.json(), - {"count": 1, "page_size": 15}, + {"count": 2, "page_size": 15}, ) def test_schema(self): @@ -175,7 +184,13 @@ def test_get_ids(self): client = TestClient(app) response = client.get("/movies/ids/") self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), {"1": "Star Wars"}) + self.assertEqual( + response.json(), + { + "1": "Star Wars", + "2": "Alien", + }, + ) def test_new(self): client = TestClient(app) @@ -197,17 +212,17 @@ def test_references(self): def test_delete(self): client = TestClient(app) - response = client.delete("/movies/?id=1") + response = client.delete("/movies/1/") self.assertEqual(response.status_code, 204) self.assertEqual(response.content, b"") def test_post(self): client = TestClient(app) response = client.post( - "/movies/", json={"name": "Star Wars", "rating": 93} + "/movies/", json={"name": "Alien 2", "rating": 94} ) self.assertEqual(response.status_code, 201) - self.assertEqual(response.json(), [{"id": 2}]) + self.assertEqual(response.json(), [{"id": 3}]) def test_put(self): client = TestClient(app) @@ -228,3 +243,26 @@ def test_patch(self): self.assertEqual( response.json(), {"id": 1, "name": "Star Wars", "rating": 90} ) + + def test_patch_bulk(self): + client = TestClient(app) + params = {"__ids": "1,2"} + json = {"rating": 99} + response = client.patch("/movies/", params=params, json=json) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + { + "rows": [ + {"id": 1, "name": "Star Wars", "rating": 99}, + {"id": 2, "name": "Alien", "rating": 99}, + ] + }, + ) + + def test_delete_bulk(self): + client = TestClient(app) + params = {"__ids": "1,2"} + response = client.delete("/movies/", params=params) + self.assertEqual(response.status_code, 204) + self.assertEqual(response.content, b"")