Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 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
80 changes: 70 additions & 10 deletions docs/source/crud/piccolo_crud.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,16 @@ API.
Endpoints
---------

========== ======================= ==========================================================================================================
Path Methods Description
========== ======================= ==========================================================================================================
/ GET, POST, DELETE Get all rows, post a new row, or delete all matching rows.
/<id>/ 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.
/<id>/ 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.
========== ======================== ==========================================================================================================

-------------------------------------------------------------------------------

Expand Down Expand Up @@ -244,6 +244,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 https://demo1.piccolo-orm.com/api/tables/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 https://demo1.piccolo-orm.com/api/tables/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 https://demo1.piccolo-orm.com/api/tables/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 ``rows_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 https://demo1.piccolo-orm.com/api/tables/movie/?rows_ids=1,2,3

If you pass a wrong or non-existent value to the query parameters ``rows_ids``,
no record will be changed and api response will be empty list.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For bulk update, we're using a parameter called row_ids, and for bulk delete we're using a parameter called __ids. I think it would be better if they're the same (i.e. both are __ids).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we stick to rows_ids (for both bulk delete and update) as I'm having trouble testing the FastAPI patch bulk in here with the __ids query parameter and I getting error. With rows_ids the test passes without problems.

@dantownsend dantownsend Oct 3, 2022

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


.. warning:: To be able to provide a bulk update action, we must set
``allow_bulk_update`` to ``True``.

-------------------------------------------------------------------------------

Readable
--------

Expand Down
90 changes: 81 additions & 9 deletions piccolo_api/crud/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ class Params:
include_readable: bool = False
page: int = 1
page_size: t.Optional[int] = None
ids: str = field(default="")
visible_fields: str = field(default="")
range_header: bool = False
range_header_name: str = field(default="")
Expand Down Expand Up @@ -142,6 +143,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,
Expand All @@ -155,8 +157,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 ``rows_ids`` query params.
:param page_size:
The number of results shown on each page by default.
:param exclude_secrets:
Expand Down Expand Up @@ -207,6 +212,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
Expand All @@ -231,7 +237,9 @@ def __init__(
root_methods = ["GET"]
if not read_only:
root_methods += (
["POST", "DELETE"] if allow_bulk_delete else ["POST"]
["POST", "DELETE", "PATCH"]
if allow_bulk_delete or allow_bulk_update
else ["POST"]

@dantownsend dantownsend Oct 3, 2022

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs tweaking. I think it should be:

if not read_only:
    root_methods.append('POST')
    if allow_bulk_delete:
        root_methods.append('DELETE')
    if allow_bulk_update:
        root_methods.append('PATCH')

At the moment, if allow_bulk_update == True, then it also enables DELETE.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. I will change that, but first I need to resolve the conflicts because the email column test and bulk delete and update use the same Studio table but with different columns, since it's been a while since I wrote these tests and there have been changes since then.

)

routes: t.List[BaseRoute] = [
Expand Down Expand Up @@ -513,6 +521,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("rows_ids", None)
if request.method == "GET":
params = self._parse_params(request.query_params)
return await self.get_all(request, params=params)
Expand All @@ -521,7 +530,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)

Expand All @@ -548,6 +560,9 @@ def _split_params(params: t.Dict[str, t.Any]) -> Params:

And can specify which page: {'__page': 2}.

You can specify which records want to delete from rows:
{'__ids': '1,2,3'}.

You can specify which fields want to display in rows:
{'__visible_fields': 'id,name'}.

Expand Down Expand Up @@ -601,6 +616,10 @@ def _split_params(params: t.Dict[str, t.Any]) -> Params:
response.page_size = page_size
continue

if key == "__ids":
response.ids = value
continue

if key == "__visible_fields":
response.visible_fields = value
continue
Expand Down Expand Up @@ -820,19 +839,72 @@ async def post_single(
return Response("Unable to save the resource.", status_code=500)

@apply_validators
async def delete_all(
async def patch_bulk(
self,
request: Request,
data: t.Dict[str, t.Any],
rows_ids: str,
) -> Response:
"""
Bulk update of rows whose primary keys are in the ``rows_ids``
query param.
"""
cleaned_data = self._clean_data(data)

try:
model = self.pydantic_model_optional(**cleaned_data)
except Exception as exception:
return Response(str(exception), status_code=400)

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:
"""
Deletes all rows - query parameters are used for filtering.
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 = self._apply_filters(
self.table.delete(force=True), split_params
)
query: t.Union[
Select, Count, Objects, Delete
] = self.table.delete()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this just be query: Delete = self.table.delete()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately that raise mypy error (error: Incompatible types in assignment (expression has type "Union[Select, Count, Objects, Delete]", variable has type "Delete")
Because of that I added t.Union[Select, Count, Objects, Delete] type annotation.

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)

Expand Down
6 changes: 4 additions & 2 deletions piccolo_api/crud/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,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] = [],
Expand All @@ -49,7 +50,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
Expand Down
58 changes: 51 additions & 7 deletions piccolo_api/fastapi/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,19 +34,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

Expand Down Expand Up @@ -244,28 +246,52 @@ 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,
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(rows_ids: str, request: Request, model):
"""
Bulk update of rows whose primary keys are in the ``rows_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"),
)

#######################################################################
Expand Down Expand Up @@ -454,6 +480,24 @@ def modify_signature(
)
)

if http_method == HTTPMethod.delete:
parameters.extend(
[
Parameter(
name="__ids",
kind=Parameter.POSITIONAL_OR_KEYWORD,
annotation=str,
default=Query(
default=None,
description=(
"Specifies which rows you want to delete "
"in bulk (default ' ')."
),
),
),
]
)

if http_method == HTTPMethod.get:
if allow_ordering:
parameters.extend(
Expand Down
Loading