Initial attempt at /db/table/row/-/delete, refs #1864

This commit is contained in:
Simon Willison 2022-10-30 16:16:00 -07:00
commit 00632ded30
6 changed files with 144 additions and 4 deletions

View file

@ -41,7 +41,7 @@ from .views.special import (
MessagesDebugView, MessagesDebugView,
) )
from .views.table import TableView, TableInsertView, TableDropView from .views.table import TableView, TableInsertView, TableDropView
from .views.row import RowView from .views.row import RowView, RowDeleteView
from .renderer import json_renderer from .renderer import json_renderer
from .url_builder import Urls from .url_builder import Urls
from .database import Database, QueryInterrupted from .database import Database, QueryInterrupted
@ -1280,6 +1280,10 @@ class Datasette:
TableDropView.as_view(self), TableDropView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/drop$", r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/drop$",
) )
add_route(
RowDeleteView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^/]+?)/(?P<pks>[^/]+?)/-/delete$",
)
return [ return [
# Compile any strings to regular expressions # Compile any strings to regular expressions
((re.compile(pattern) if isinstance(pattern, str) else pattern), view) ((re.compile(pattern) if isinstance(pattern, str) else pattern), view)

View file

@ -9,7 +9,13 @@ import time
@hookimpl(tryfirst=True) @hookimpl(tryfirst=True)
def permission_allowed(datasette, actor, action, resource): def permission_allowed(datasette, actor, action, resource):
async def inner(): async def inner():
if action in ("permissions-debug", "debug-menu", "insert-row", "drop-table"): if action in (
"permissions-debug",
"debug-menu",
"insert-row",
"drop-table",
"delete-row",
):
if actor and actor.get("id") == "root": if actor and actor.get("id") == "root":
return True return True
elif action == "view-instance": elif action == "view-instance":

View file

@ -1,15 +1,20 @@
from datasette.utils.asgi import NotFound, Forbidden from datasette.utils.asgi import NotFound, Forbidden, Response
from datasette.database import QueryInterrupted from datasette.database import QueryInterrupted
from .base import DataView from .base import DataView, BaseView
from datasette.utils import ( from datasette.utils import (
tilde_decode, tilde_decode,
urlsafe_components, urlsafe_components,
to_css_class, to_css_class,
escape_sqlite, escape_sqlite,
) )
import sqlite_utils
from .table import _sql_params_pks, display_columns_and_rows from .table import _sql_params_pks, display_columns_and_rows
def _error(messages, status=400):
return Response.json({"ok": False, "errors": messages}, status=status)
class RowView(DataView): class RowView(DataView):
name = "row" name = "row"
@ -146,3 +151,43 @@ class RowView(DataView):
) )
foreign_key_tables.append({**fk, **{"count": count, "link": link}}) foreign_key_tables.append({**fk, **{"count": count, "link": link}})
return foreign_key_tables return foreign_key_tables
class RowDeleteView(BaseView):
name = "row-delete"
def __init__(self, datasette):
self.ds = datasette
async def post(self, request):
database_route = tilde_decode(request.url_vars["database"])
table = tilde_decode(request.url_vars["table"])
try:
db = self.ds.get_database(route=database_route)
except KeyError:
return _error(["Database not found: {}".format(database_route)], 404)
database_name = db.name
if not await db.table_exists(table):
return _error(["Table not found: {}".format(table)], 404)
pk_values = urlsafe_components(request.url_vars["pks"])
sql, params, pks = await _sql_params_pks(db, table, pk_values)
results = await db.execute(sql, params, truncate=True)
rows = list(results.rows)
if not rows:
return _error([f"Record not found: {pk_values}"], 404)
# Ensure user has permission to delete this row
if not await self.ds.permission_allowed(
request.actor, "delete-row", resource=(database_name, table)
):
return _error(["Permission denied"], 403)
# Delete table
def delete_row(conn):
sqlite_utils.Database(conn)[table].delete(pk_values)
await db.execute_write_fn(delete_row)
return Response.json({"ok": True}, status=200)

View file

@ -559,6 +559,18 @@ Actor is allowed to insert rows into a table.
Default *deny*. Default *deny*.
.. _permissions_delete_row:
delete-row
----------
Actor is allowed to delete rows from a table.
``resource`` - tuple: (string, string)
The name of the database, then the name of the table
Default *deny*.
.. _permissions_drop_table: .. _permissions_drop_table:
drop-table drop-table

View file

@ -540,6 +540,25 @@ To return the newly inserted rows, add the ``"return_rows": true`` key to the re
This will return the same ``"inserted"`` key as the single row example above. There is a small performance penalty for using this option. This will return the same ``"inserted"`` key as the single row example above. There is a small performance penalty for using this option.
.. _RowDeleteView:
Deleting rows
~~~~~~~~~~~~~
To delete a row, make a ``POST`` to ``/<database>/<table>/<row-pks>/-/delete``. This requires the :ref:`permissions_delete_row` permission.
::
POST /<database>/<table>/<row-pks>/-/delete
Content-Type: application/json
Authorization: Bearer dstok_<rest-of-token>
``<row-pks>`` here is the :ref:`tilde-encoded <internals_tilde_encoding>` primary key value of the row to delete - or a comma-separated list of primary key values if the table has a composite primary key.
If successful, this will return a ``200`` status code and a ``{"ok": true}`` response body.
Any errors will return ``{"errors": ["... descriptive message ..."], "ok": false}``, and a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error.
.. _TableDropView: .. _TableDropView:
Dropping tables Dropping tables

View file

@ -196,6 +196,60 @@ async def test_write_row_errors(
assert response.json()["errors"] == expected_errors assert response.json()["errors"] == expected_errors
@pytest.mark.asyncio
@pytest.mark.parametrize("scenario", ("no_token", "no_perm", "bad_table", "has_perm"))
async def test_delete_row(ds_write, scenario):
if scenario == "no_token":
token = "bad_token"
elif scenario == "no_perm":
token = write_token(ds_write, actor_id="not-root")
else:
token = write_token(ds_write)
should_work = scenario == "has_perm"
# Insert a row
insert_response = await ds_write.client.post(
"/data/docs/-/insert",
json={"row": {"title": "Row one", "score": 1.0}, "return_rows": True},
headers={
"Authorization": "Bearer {}".format(write_token(ds_write)),
"Content-Type": "application/json",
},
)
assert insert_response.status_code == 201
pk = insert_response.json()["inserted"][0]["id"]
path = "/data/{}/{}/-/delete".format(
"docs" if scenario != "bad_table" else "bad_table", pk
)
response = await ds_write.client.post(
path,
headers={
"Authorization": "Bearer {}".format(token),
"Content-Type": "application/json",
},
)
if should_work:
assert response.status_code == 200
assert response.json() == {"ok": True}
assert (await ds_write.client.get("/data/docs.json?_shape=array")).json() == []
else:
assert (
response.status_code == 403
if scenario in ("no_token", "bad_token")
else 404
)
assert response.json()["ok"] is False
assert (
response.json()["errors"] == ["Permission denied"]
if scenario == "no_token"
else ["Table not found: bad_table"]
)
assert (
len((await ds_write.client.get("/data/docs.json?_shape=array")).json()) == 1
)
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize("scenario", ("no_token", "no_perm", "bad_table", "has_perm")) @pytest.mark.parametrize("scenario", ("no_token", "no_perm", "bad_table", "has_perm"))
async def test_drop_table(ds_write, scenario): async def test_drop_table(ds_write, scenario):