New URL design /db/table/-/insert, refs #1851

This commit is contained in:
Simon Willison 2022-10-27 13:17:18 -07:00
commit 6e788b49ed
4 changed files with 86 additions and 13 deletions

View file

@ -39,7 +39,7 @@ from .views.special import (
PermissionsDebugView, PermissionsDebugView,
MessagesDebugView, MessagesDebugView,
) )
from .views.table import TableView from .views.table import TableView, TableInsertView
from .views.row import RowView from .views.row import RowView
from .renderer import json_renderer from .renderer import json_renderer
from .url_builder import Urls from .url_builder import Urls
@ -1262,6 +1262,10 @@ class Datasette:
RowView.as_view(self), RowView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^/]+?)/(?P<pks>[^/]+?)(\.(?P<format>\w+))?$", r"/(?P<database>[^\/\.]+)/(?P<table>[^/]+?)/(?P<pks>[^/]+?)(\.(?P<format>\w+))?$",
) )
add_route(
TableInsertView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/insert$",
)
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

@ -30,7 +30,7 @@ from datasette.utils import (
) )
from datasette.utils.asgi import BadRequest, Forbidden, NotFound, Response from datasette.utils.asgi import BadRequest, Forbidden, NotFound, Response
from datasette.filters import Filters from datasette.filters import Filters
from .base import DataView, DatasetteError, ureg from .base import BaseView, DataView, DatasetteError, ureg
from .database import QueryView from .database import QueryView
LINK_WITH_LABEL = ( LINK_WITH_LABEL = (
@ -1077,3 +1077,70 @@ async def display_columns_and_rows(
} }
columns = [first_column] + columns columns = [first_column] + columns
return columns, cell_rows return columns, cell_rows
class TableInsertView(BaseView):
name = "table-insert"
def __init__(self, datasette):
self.ds = datasette
async def post(self, request):
database_route = tilde_decode(request.url_vars["database"])
try:
db = self.ds.get_database(route=database_route)
except KeyError:
raise NotFound("Database not found: {}".format(database_route))
database_name = db.name
table_name = tilde_decode(request.url_vars["table"])
# Table must exist (may handle table creation in the future)
db = self.ds.get_database(database_name)
if not await db.table_exists(table_name):
raise NotFound("Table not found: {}".format(table_name))
# Must have insert-row permission
if not await self.ds.permission_allowed(
request.actor, "insert-row", resource=(database_name, table_name)
):
raise Forbidden("Permission denied")
if request.headers.get("content-type") != "application/json":
# TODO: handle form-encoded data
raise BadRequest("Must send JSON data")
data = json.loads(await request.post_body())
if "row" not in data:
raise BadRequest('Must send a "row" key containing a dictionary')
row = data["row"]
if not isinstance(row, dict):
raise BadRequest("row must be a dictionary")
# Verify all columns exist
columns = await db.table_columns(table_name)
pks = await db.primary_keys(table_name)
for key in row:
if key not in columns:
raise BadRequest("Column not found: {}".format(key))
if key in pks:
raise BadRequest(
"Cannot insert into primary key column: {}".format(key)
)
# Perform the insert
sql = "INSERT INTO [{table}] ({columns}) VALUES ({values})".format(
table=escape_sqlite(table_name),
columns=", ".join(escape_sqlite(c) for c in row),
values=", ".join("?" for c in row),
)
cursor = await db.execute_write(sql, list(row.values()))
# Return the new row
rowid = cursor.lastrowid
new_row = (
await db.execute(
"SELECT * FROM [{table}] WHERE rowid = ?".format(
table=escape_sqlite(table_name)
),
[rowid],
)
).first()
return Response.json(
{
"inserted": [dict(new_row)],
},
status=201,
)

View file

@ -463,7 +463,7 @@ The JSON write API
Datasette provides a write API for JSON data. This is a POST-only API that requires an authenticated API token, see :ref:`CreateTokenView`. Datasette provides a write API for JSON data. This is a POST-only API that requires an authenticated API token, see :ref:`CreateTokenView`.
.. _json_api_write_insert_row: .. _TableInsertView:
Inserting a single row Inserting a single row
~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~
@ -472,11 +472,11 @@ This requires the :ref:`permissions_insert_row` permission.
:: ::
POST /<database>/<table> POST /<database>/<table>/-/insert
Content-Type: application/json Content-Type: application/json
Authorization: Bearer dstok_<rest-of-token> Authorization: Bearer dstok_<rest-of-token>
{ {
"insert": { "row": {
"column1": "value1", "column1": "value1",
"column2": "value2" "column2": "value2"
} }
@ -487,9 +487,11 @@ If successful, this will return a ``201`` status code and the newly inserted row
.. code-block:: json .. code-block:: json
{ {
"inserted_row": { "inserted": [
"id": 1, {
"column1": "value1", "id": 1,
"column2": "value2" "column1": "value1",
} "column2": "value2"
}
]
} }

View file

@ -24,8 +24,8 @@ async def test_write_row(ds_write):
) )
) )
response = await ds_write.client.post( response = await ds_write.client.post(
"/data/docs", "/data/docs/-/insert",
json={"insert": {"title": "Test", "score": 1.0}}, json={"row": {"title": "Test", "score": 1.0}},
headers={ headers={
"Authorization": "Bearer {}".format(token), "Authorization": "Bearer {}".format(token),
"Content-Type": "application/json", "Content-Type": "application/json",
@ -33,6 +33,6 @@ async def test_write_row(ds_write):
) )
expected_row = {"id": 1, "title": "Test", "score": 1.0} expected_row = {"id": 1, "title": "Test", "score": 1.0}
assert response.status_code == 201 assert response.status_code == 201
assert response.json()["inserted_row"] == expected_row assert response.json()["inserted"] == [expected_row]
rows = (await ds_write.get_database("data").execute("select * from docs")).rows rows = (await ds_write.get_database("data").execute("select * from docs")).rows
assert dict(rows[0]) == expected_row assert dict(rows[0]) == expected_row