diff --git a/datasette/app.py b/datasette/app.py index 2678356a..9040f059 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -62,13 +62,19 @@ from .utils import ( parse_metadata, resolve_env_secrets, resolve_routes, + tilde_decode, to_css_class, + urlsafe_components, + row_sql_params_pks, ) from .utils.asgi import ( AsgiLifespan, Base400, Forbidden, NotFound, + DatabaseNotFound, + TableNotFound, + RowNotFound, Request, Response, asgi_static, @@ -198,6 +204,12 @@ async def favicon(request, send): ) +ResolvedTable = collections.namedtuple("ResolvedTable", ("db", "table", "is_view")) +ResolvedRow = collections.namedtuple( + "ResolvedRow", ("db", "table", "sql", "params", "pks", "pk_values", "row") +) + + class Datasette: # Message constants: INFO = 1 @@ -1292,6 +1304,41 @@ class Datasette: for pattern, view in routes ] + async def resolve_database(self, request): + database_route = tilde_decode(request.url_vars["database"]) + try: + return self.get_database(route=database_route) + except KeyError: + raise DatabaseNotFound( + "Database not found: {}".format(database_route), database_route + ) + + async def resolve_table(self, request): + db = await self.resolve_database(request) + table_name = tilde_decode(request.url_vars["table"]) + # Table must exist + is_view = False + table_exists = await db.table_exists(table_name) + if not table_exists: + is_view = await db.view_exists(table_name) + if not (table_exists or is_view): + raise TableNotFound( + "Table not found: {}".format(table_name), db.name, table_name + ) + return ResolvedTable(db, table_name, is_view) + + async def resolve_row(self, request): + db, table_name, _ = await self.resolve_table(request) + pk_values = urlsafe_components(request.url_vars["pks"]) + sql, params, pks = await row_sql_params_pks(db, table_name, pk_values) + results = await db.execute(sql, params, truncate=True) + row = results.first() + if row is None: + raise RowNotFound( + "Row not found: {}".format(pk_values), db.name, table_name, pk_values + ) + return ResolvedRow(db, table_name, sql, params, pks, pk_values, results.first()) + def app(self): """Returns an ASGI app function that serves the whole of Datasette""" routes = self._routes() diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index 5acfb8b4..a2254ba6 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -1193,3 +1193,18 @@ def truncate_url(url, length): rest, ext = bits return rest[: length - 1 - len(ext)] + "…." + ext return url[: length - 1] + "…" + + +async def row_sql_params_pks(db, table, pk_values): + pks = await db.primary_keys(table) + use_rowid = not pks + select = "*" + if use_rowid: + select = "rowid, *" + pks = ["rowid"] + wheres = [f'"{pk}"=:p{i}' for i, pk in enumerate(pks)] + sql = f"select {select} from {escape_sqlite(table)} where {' AND '.join(wheres)}" + params = {} + for i, pk_value in enumerate(pk_values): + params[f"p{i}"] = pk_value + return sql, params, pks diff --git a/datasette/utils/asgi.py b/datasette/utils/asgi.py index 8a2fa060..f080df91 100644 --- a/datasette/utils/asgi.py +++ b/datasette/utils/asgi.py @@ -21,6 +21,27 @@ class NotFound(Base400): status = 404 +class DatabaseNotFound(NotFound): + def __init__(self, message, database_name): + super().__init__(message) + self.database_name = database_name + + +class TableNotFound(NotFound): + def __init__(self, message, database_name, table): + super().__init__(message) + self.database_name = database_name + self.table = table + + +class RowNotFound(NotFound): + def __init__(self, message, database_name, table, pk_values): + super().__init__(message) + self.database_name = database_name + self.table_name = table + self.pk_values = pk_values + + class Forbidden(Base400): status = 403 diff --git a/datasette/views/base.py b/datasette/views/base.py index b30f3eb5..74ef5f6d 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -20,7 +20,6 @@ from datasette.utils import ( InvalidSql, LimitedWriter, call_with_supported_arguments, - tilde_decode, path_from_row_pks, path_with_added_args, path_with_removed_args, @@ -346,13 +345,9 @@ class DataView(BaseView): return AsgiStream(stream_fn, headers=headers, content_type=content_type) async def get(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)) + db = await self.ds.resolve_database(request) database = db.name + database_route = db.route _format = request.url_vars["format"] data_kwargs = {} diff --git a/datasette/views/database.py b/datasette/views/database.py index 63da6992..a420bb5c 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -35,11 +35,7 @@ class DatabaseView(DataView): name = "database" async def data(self, request, default_labels=False, _size=None): - 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)) + db = await self.ds.resolve_database(request) database = db.name visible, private = await self.ds.check_visibility( @@ -228,11 +224,7 @@ class QueryView(DataView): named_parameters=None, write=False, ): - 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)) + db = await self.ds.resolve_database(request) database = db.name params = {key: request.args.get(key) for key in request.args} if "sql" in params: @@ -582,11 +574,7 @@ class TableCreateView(BaseView): 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: - return _error(["Database not found: {}".format(database_route)], 404) + db = await self.ds.resolve_database(request) database_name = db.name # Must have create-table permission @@ -727,11 +715,7 @@ class TableCreateView(BaseView): 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: - return _error(["Database not found: {}".format(database_route)], 404) + db = await self.ds.resolve_database(request) database_name = db.name # Must have create-table permission diff --git a/datasette/views/row.py b/datasette/views/row.py index 9406d58c..dc93c3be 100644 --- a/datasette/views/row.py +++ b/datasette/views/row.py @@ -6,22 +6,21 @@ from datasette.utils import ( urlsafe_components, to_css_class, escape_sqlite, + row_sql_params_pks, ) +import json import sqlite_utils -from .table import _sql_params_pks, display_columns_and_rows +from .table import display_columns_and_rows class RowView(DataView): name = "row" async def data(self, request, default_labels=False): - 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: - raise NotFound("Database not found: {}".format(database_route)) - database = db.name + resolved = await self.ds.resolve_row(request) + database = resolved.db.name + table = resolved.table + pk_values = resolved.pk_values # Ensure user has permission to view this row visible, private = await self.ds.check_visibility( @@ -35,14 +34,9 @@ class RowView(DataView): if not visible: raise Forbidden("You do not have permission to view this table") - pk_values = urlsafe_components(request.url_vars["pks"]) - try: - db = self.ds.get_database(route=database_route) - except KeyError: - raise NotFound("Database not found: {}".format(database_route)) - database = db.name - sql, params, pks = await _sql_params_pks(db, table, pk_values) - results = await db.execute(sql, params, truncate=True) + results = await resolved.db.execute( + resolved.sql, resolved.params, truncate=True + ) columns = [r[0] for r in results.description] rows = list(results.rows) if not rows: @@ -83,7 +77,7 @@ class RowView(DataView): "table": table, "rows": rows, "columns": columns, - "primary_keys": pks, + "primary_keys": resolved.pks, "primary_key_values": pk_values, "units": self.ds.table_metadata(database, table).get("units", {}), } @@ -149,6 +143,11 @@ class RowView(DataView): return foreign_key_tables +class RowError(Exception): + def __init__(self, error): + self.error = error + + class RowDeleteView(BaseView): name = "row-delete" @@ -156,24 +155,20 @@ class RowDeleteView(BaseView): self.ds = datasette async def post(self, request): - database_route = tilde_decode(request.url_vars["database"]) - table = tilde_decode(request.url_vars["table"]) + from datasette.app import DatabaseNotFound, TableNotFound, RowNotFound + try: - db = self.ds.get_database(route=database_route) - except KeyError: - return _error(["Database not found: {}".format(database_route)], 404) - + resolved = await self.ds.resolve_row(request) + except DatabaseNotFound as e: + return _error(["Database not found: {}".format(e.database_name)], 404) + except TableNotFound as e: + return _error(["Table not found: {}".format(e.table)], 404) + except RowNotFound as e: + return _error(["Record not found: {}".format(e.pk_values)], 404) + db = resolved.db 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) + table = resolved.table + pk_values = resolved.pk_values # Ensure user has permission to delete this row if not await self.ds.permission_allowed( diff --git a/datasette/views/table.py b/datasette/views/table.py index 8b987221..7ba78c11 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -93,36 +93,33 @@ class TableView(DataView): return expandables 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"]) - # Handle POST to a canned query - canned_query = await self.ds.get_canned_query( - database_name, table_name, request.actor - ) - if canned_query: - return await QueryView(self.ds).data( - request, - canned_query["sql"], - metadata=canned_query, - editable=False, - canned_query=table_name, - named_parameters=canned_query.get("params"), - write=bool(canned_query.get("write")), - ) - else: - # Handle POST to a table - return await self.table_post(request, database_name, table_name) + from datasette.app import TableNotFound - async def table_post(self, request, database_name, table_name): - # 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)) + try: + resolved = await self.ds.resolve_table(request) + except TableNotFound as e: + # Was this actually a canned query? + canned_query = await self.ds.get_canned_query( + e.database_name, e.table, request.actor + ) + if canned_query: + # Handle POST to a canned query + return await QueryView(self.ds).data( + request, + canned_query["sql"], + metadata=canned_query, + editable=False, + canned_query=e.table, + named_parameters=canned_query.get("params"), + write=bool(canned_query.get("write")), + ) + + # Handle POST to a table + return await self.table_post( + request, resolved.db, resolved.db.name, resolved.table + ) + + async def table_post(self, request, db, database_name, table_name): # Must have insert-row permission if not await self.ds.permission_allowed( request.actor, "insert-row", resource=(database_name, table_name) @@ -221,12 +218,31 @@ class TableView(DataView): _next=None, _size=None, ): - database_route = tilde_decode(request.url_vars["database"]) - table_name = tilde_decode(request.url_vars["table"]) + from datasette.app import TableNotFound + try: - db = self.ds.get_database(route=database_route) - except KeyError: - raise NotFound("Database not found: {}".format(database_route)) + resolved = await self.ds.resolve_table(request) + except TableNotFound as e: + # Was this actually a canned query? + canned_query = await self.ds.get_canned_query( + e.database_name, e.table, request.actor + ) + # If this is a canned query, not a table, then dispatch to QueryView instead + if canned_query: + return await QueryView(self.ds).data( + request, + canned_query["sql"], + metadata=canned_query, + editable=False, + canned_query=e.table, + named_parameters=canned_query.get("params"), + write=bool(canned_query.get("write")), + ) + else: + raise + + table_name = resolved.table + db = resolved.db database_name = db.name # For performance profiling purposes, ?_noparallel=1 turns off asyncio.gather @@ -243,21 +259,6 @@ class TableView(DataView): _gather_sequential if request.args.get("_noparallel") else _gather_parallel ) - # If this is a canned query, not a table, then dispatch to QueryView instead - canned_query = await self.ds.get_canned_query( - database_name, table_name, request.actor - ) - if canned_query: - return await QueryView(self.ds).data( - request, - canned_query["sql"], - metadata=canned_query, - editable=False, - canned_query=table_name, - named_parameters=canned_query.get("params"), - write=bool(canned_query.get("write")), - ) - is_view, table_exists = map( bool, await gather( @@ -874,21 +875,6 @@ class TableView(DataView): ) -async def _sql_params_pks(db, table, pk_values): - pks = await db.primary_keys(table) - use_rowid = not pks - select = "*" - if use_rowid: - select = "rowid, *" - pks = ["rowid"] - wheres = [f'"{pk}"=:p{i}' for i, pk in enumerate(pks)] - sql = f"select {select} from {escape_sqlite(table)} where {' AND '.join(wheres)}" - params = {} - for i, pk_value in enumerate(pk_values): - params[f"p{i}"] = pk_value - return sql, params, pks - - async def display_columns_and_rows( datasette, database_name, @@ -1161,13 +1147,13 @@ class TableInsertView(BaseView): return rows, errors, extras async def post(self, request): - database_route = tilde_decode(request.url_vars["database"]) try: - db = self.ds.get_database(route=database_route) - except KeyError: - return _error(["Database not found: {}".format(database_route)], 404) + resolved = await self.ds.resolve_table(request) + except NotFound as e: + return _error([e.args[0]], 404) + db = resolved.db database_name = db.name - table_name = tilde_decode(request.url_vars["table"]) + table_name = resolved.table # Table must exist (may handle table creation in the future) db = self.ds.get_database(database_name) @@ -1221,13 +1207,13 @@ class TableDropView(BaseView): 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: - return _error(["Database not found: {}".format(database_route)], 404) + resolved = await self.ds.resolve_table(request) + except NotFound as e: + return _error([e.args[0]], 404) + db = resolved.db database_name = db.name - table_name = tilde_decode(request.url_vars["table"]) + table_name = resolved.table # Table must exist db = self.ds.get_database(database_name) if not await db.table_exists(table_name): diff --git a/docs/internals.rst b/docs/internals.rst index 00e82cbf..8b5a2b6e 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -579,6 +579,84 @@ For example: downloads_are_allowed = datasette.setting("allow_download") +.. _datasette_resolve_database: + +.resolve_database(request) +-------------------------- + +``request`` - :ref:`internals_request` + A request object + +If you are implementing your own custom views, you may need to resolve the database that the user is requesting based on a URL path. If the regular expression for your route declares a ``database`` named group, you can use this method to resolve the database object. + +This returns a :ref:`Database ` instance. + +If the database cannot be found, it raises a ``datasette.utils.asgi.DatabaseNotFound`` exception - which is a subclass of ``datasette.utils.asgi.NotFound`` with a ``.database_name`` attribute set to the name of the database that was requested. + +.. _datasette_resolve_table: + +.resolve_table(request) +----------------------- + +``request`` - :ref:`internals_request` + A request object + +This assumes that the regular expression for your route declares both a ``database`` and a ``table`` named group. + +It returns a ``ResolvedTable`` named tuple instance with the following fields: + +``db`` - :ref:`Database ` + The database object + +``table`` - string + The name of the table (or view) + +``is_view`` - boolean + ``True`` if this is a view, ``False`` if it is a table + +If the database or table cannot be found it raises a ``datasette.utils.asgi.DatabaseNotFound`` exception. + +If the table does not exist it raises a ``datasette.utils.asgi.TableNotFound`` exception - a subclass of ``datasette.utils.asgi.NotFound`` with ``.database_name`` and ``.table`` attributes. + +.. _datasette_resolve_row: + +.resolve_row(request) +--------------------- + +``request`` - :ref:`internals_request` + A request object + +This method assumes your route declares named groups for ``database``, ``table`` and ``pks``. + +It returns a ``ResolvedRow`` named tuple instance with the following fields: + +``db`` - :ref:`Database ` + The database object + +``table`` - string + The name of the table + +``sql`` - string + SQL snippet that can be used in a ``WHERE`` clause to select the row + +``params`` - dict + Parameters that should be passed to the SQL query + +``pks`` - list + List of primary key column names + +``pk_values`` - list + List of primary key values decoded from the URL + +``row`` - ``sqlite3.Row`` + The row itself + +If the database or table cannot be found it raises a ``datasette.utils.asgi.DatabaseNotFound`` exception. + +If the table does not exist it raises a ``datasette.utils.asgi.TableNotFound`` exception. + +If the row cannot be found it raises a ``datasette.utils.asgi.RowNotFound`` exception. This has ``.database_name``, ``.table`` and ``.pk_values`` attributes, extracted from the request path. + .. _internals_datasette_client: datasette.client @@ -770,7 +848,7 @@ The ``Results`` object also has the following properties and methods: ``.columns`` - list of strings A list of column names returned by the query. -``.rows`` - list of sqlite3.Row +``.rows`` - list of ``sqlite3.Row`` This property provides direct access to the list of rows returned by the database. You can access specific rows by index using ``results.rows[0]``. ``.first()`` - row or None