From 2e40a506ad45b44fd7642474f630a31ef18b5911 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 26 Jul 2023 12:53:00 -0700 Subject: [PATCH 01/23] New database_view() works for HTML and JSON But it breaks when it dispatches ?sql= to the new query_view function. Refs #2111, closes #2110 --- datasette/app.py | 5 +- datasette/views/database.py | 253 +++++++++++++++++++----------------- 2 files changed, 135 insertions(+), 123 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index b8b84168b0..69074e8fdf 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -34,7 +34,7 @@ from jinja2.exceptions import TemplateNotFound from .views.base import ureg -from .views.database import database_download, DatabaseView, TableCreateView +from .views.database import database_download, database_view, TableCreateView from .views.index import IndexView from .views.special import ( JsonDataView, @@ -1368,7 +1368,8 @@ def add_route(view, regex): r"/(?P[^\/\.]+)\.db$", ) add_route( - DatabaseView.as_view(self), r"/(?P[^\/\.]+)(\.(?P\w+))?$" + wrap_view(database_view, self), + r"/(?P[^\/\.]+)(\.(?P\w+))?$", ) add_route(TableCreateView.as_view(self), r"/(?P[^\/\.]+)/-/create$") add_route( diff --git a/datasette/views/database.py b/datasette/views/database.py index ffa79e9643..eb888ecaab 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -31,142 +31,149 @@ from .base import BaseView, DatasetteError, DataView, _error -class DatabaseView(DataView): - name = "database" +async def database_view(request, datasette): + format_ = request.url_vars.get("format") or "html" + if format_ not in ("html", "json"): + raise NotFound("Invalid format: {}".format(format_)) - async def data(self, request, default_labels=False, _size=None): - db = await self.ds.resolve_database(request) - database = db.name + await datasette.refresh_schemas() + + db = await datasette.resolve_database(request) + database = db.name + + visible, private = await datasette.check_visibility( + request.actor, + permissions=[ + ("view-database", database), + "view-instance", + ], + ) + if not visible: + raise Forbidden("You do not have permission to view this database") + + sql = (request.args.get("sql") or "").strip() + if sql: + validate_sql_select(sql) + return await query_view(request, datasette, sql) - visible, private = await self.ds.check_visibility( + metadata = (datasette.metadata("databases") or {}).get(database, {}) + datasette.update_with_inherited_metadata(metadata) + + table_counts = await db.table_counts(5) + hidden_table_names = set(await db.hidden_table_names()) + all_foreign_keys = await db.get_all_foreign_keys() + + sql_views = [] + for view_name in await db.view_names(): + view_visible, view_private = await datasette.check_visibility( request.actor, permissions=[ + ("view-table", (database, view_name)), ("view-database", database), "view-instance", ], ) - if not visible: - raise Forbidden("You do not have permission to view this database") - - metadata = (self.ds.metadata("databases") or {}).get(database, {}) - self.ds.update_with_inherited_metadata(metadata) - - if request.args.get("sql"): - sql = request.args.get("sql") - validate_sql_select(sql) - return await QueryView(self.ds).data( - request, sql, _size=_size, metadata=metadata - ) - - table_counts = await db.table_counts(5) - hidden_table_names = set(await db.hidden_table_names()) - all_foreign_keys = await db.get_all_foreign_keys() - - views = [] - for view_name in await db.view_names(): - view_visible, view_private = await self.ds.check_visibility( - request.actor, - permissions=[ - ("view-table", (database, view_name)), - ("view-database", database), - "view-instance", - ], - ) - if view_visible: - views.append( - { - "name": view_name, - "private": view_private, - } - ) - - tables = [] - for table in table_counts: - table_visible, table_private = await self.ds.check_visibility( - request.actor, - permissions=[ - ("view-table", (database, table)), - ("view-database", database), - "view-instance", - ], - ) - if not table_visible: - continue - table_columns = await db.table_columns(table) - tables.append( + if view_visible: + sql_views.append( { - "name": table, - "columns": table_columns, - "primary_keys": await db.primary_keys(table), - "count": table_counts[table], - "hidden": table in hidden_table_names, - "fts_table": await db.fts_table(table), - "foreign_keys": all_foreign_keys[table], - "private": table_private, + "name": view_name, + "private": view_private, } ) - tables.sort(key=lambda t: (t["hidden"], t["name"])) - canned_queries = [] - for query in ( - await self.ds.get_canned_queries(database, request.actor) - ).values(): - query_visible, query_private = await self.ds.check_visibility( - request.actor, - permissions=[ - ("view-query", (database, query["name"])), - ("view-database", database), - "view-instance", - ], - ) - if query_visible: - canned_queries.append(dict(query, private=query_private)) - - async def database_actions(): - links = [] - for hook in pm.hook.database_actions( - datasette=self.ds, - database=database, - actor=request.actor, - request=request, - ): - extra_links = await await_me_maybe(hook) - if extra_links: - links.extend(extra_links) - return links - - attached_databases = [d.name for d in await db.attached_databases()] - - allow_execute_sql = await self.ds.permission_allowed( - request.actor, "execute-sql", database + tables = [] + for table in table_counts: + table_visible, table_private = await datasette.check_visibility( + request.actor, + permissions=[ + ("view-table", (database, table)), + ("view-database", database), + "view-instance", + ], ) - return ( - { - "database": database, - "private": private, - "path": self.ds.urls.database(database), - "size": db.size, - "tables": tables, - "hidden_count": len([t for t in tables if t["hidden"]]), - "views": views, - "queries": canned_queries, - "allow_execute_sql": allow_execute_sql, - "table_columns": await _table_columns(self.ds, database) - if allow_execute_sql - else {}, - }, + if not table_visible: + continue + table_columns = await db.table_columns(table) + tables.append( { - "database_actions": database_actions, - "show_hidden": request.args.get("_show_hidden"), - "editable": True, - "metadata": metadata, - "allow_download": self.ds.setting("allow_download") - and not db.is_mutable - and not db.is_memory, - "attached_databases": attached_databases, - }, - (f"database-{to_css_class(database)}.html", "database.html"), + "name": table, + "columns": table_columns, + "primary_keys": await db.primary_keys(table), + "count": table_counts[table], + "hidden": table in hidden_table_names, + "fts_table": await db.fts_table(table), + "foreign_keys": all_foreign_keys[table], + "private": table_private, + } + ) + + tables.sort(key=lambda t: (t["hidden"], t["name"])) + canned_queries = [] + for query in (await datasette.get_canned_queries(database, request.actor)).values(): + query_visible, query_private = await datasette.check_visibility( + request.actor, + permissions=[ + ("view-query", (database, query["name"])), + ("view-database", database), + "view-instance", + ], ) + if query_visible: + canned_queries.append(dict(query, private=query_private)) + + async def database_actions(): + links = [] + for hook in pm.hook.database_actions( + datasette=datasette, + database=database, + actor=request.actor, + request=request, + ): + extra_links = await await_me_maybe(hook) + if extra_links: + links.extend(extra_links) + return links + + attached_databases = [d.name for d in await db.attached_databases()] + + allow_execute_sql = await datasette.permission_allowed( + request.actor, "execute-sql", database + ) + json_data = { + "database": database, + "private": private, + "path": datasette.urls.database(database), + "size": db.size, + "tables": tables, + "hidden_count": len([t for t in tables if t["hidden"]]), + "views": sql_views, + "queries": canned_queries, + "allow_execute_sql": allow_execute_sql, + "table_columns": await _table_columns(datasette, database) + if allow_execute_sql + else {}, + } + + if format_ == "json": + return Response.json(json_data) + + assert format_ == "html" + context = { + **json_data, + "database_actions": database_actions, + "show_hidden": request.args.get("_show_hidden"), + "editable": True, + "metadata": metadata, + "allow_download": datasette.setting("allow_download") + and not db.is_mutable + and not db.is_memory, + "attached_databases": attached_databases, + "database_color": lambda _: "#ff0000", + } + templates = (f"database-{to_css_class(database)}.html", "database.html") + return Response.html( + await datasette.render_template(templates, context, request=request) + ) async def database_download(request, datasette): @@ -210,6 +217,10 @@ async def database_download(request, datasette): ) +async def query_view(request, datasette, sql): + return Response.html("Not yet implemented") + + class QueryView(DataView): async def data( self, From c3e3ecf590ca5fa61b00aba4c78599e33d370b60 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 26 Jul 2023 16:24:58 -0700 Subject: [PATCH 02/23] New query_view returning JSON --- datasette/views/database.py | 107 +++++++++++++++++++++++++++++++++--- 1 file changed, 100 insertions(+), 7 deletions(-) diff --git a/datasette/views/database.py b/datasette/views/database.py index eb888ecaab..97f2cd9879 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -1,14 +1,13 @@ +from asyncinject import Registry import os import hashlib import itertools import json -from markupsafe import Markup, escape +import markupsafe from urllib.parse import parse_qsl, urlencode import re import sqlite_utils -import markupsafe - from datasette.utils import ( add_cors_headers, await_me_maybe, @@ -54,7 +53,7 @@ async def database_view(request, datasette): sql = (request.args.get("sql") or "").strip() if sql: validate_sql_select(sql) - return await query_view(request, datasette, sql) + return await query_view(request, datasette) metadata = (datasette.metadata("databases") or {}).get(database, {}) datasette.update_with_inherited_metadata(metadata) @@ -217,8 +216,102 @@ async def database_download(request, datasette): ) -async def query_view(request, datasette, sql): - return Response.html("Not yet implemented") +async def query_view( + request, + datasette, + # canned_query=None, + # _size=None, + # named_parameters=None, + # write=False, +): + db = await datasette.resolve_database(request) + database = db.name + # TODO: Why do I do this? Is it to eliminate multi-args? + # It's going to break ?_extra=...&_extra=... + params = {key: request.args.get(key) for key in request.args} + sql = "" + if "sql" in params: + sql = params.pop("sql") + + # TODO: Behave differently for canned query here: + await datasette.ensure_permissions(request.actor, [("execute-sql", database)]) + + _shape = None + if "_shape" in params: + _shape = params.pop("_shape") + + async def _results(_sql, _params): + # Returns (results, error (can be None)) + try: + results = await db.execute(_sql, _params, truncate=True) + return results, None + except Exception as e: + return None, e + + async def shape_arrays(_results): + results, error = _results + if error: + return {"ok": False, "error": str(error)} + return { + "ok": True, + "rows": [list(r) for r in results.rows], + "truncated": results.truncated, + } + + async def shape_objects(_results): + results, error = _results + if error: + return {"ok": False, "error": str(error)} + return { + "ok": True, + "rows": [dict(r) for r in results.rows], + "truncated": results.truncated, + } + + async def shape_array(_results): + results, error = _results + if error: + return {"ok": False, "error": str(error)} + return [dict(r) for r in results.rows] + + async def shape_arrayfirst(_results): + results, error = _results + if error: + return {"ok": False, "error": str(error)} + return [r[0] for r in results.rows] + + shape_fn = { + "arrays": shape_arrays, + "objects": shape_objects, + "array": shape_array, + "arrayfirst": shape_arrayfirst, + }[_shape or "objects"] + + registry = Registry.from_dict( + { + "_results": _results, + "_shape": shape_fn, + }, + parallel=False, + ) + + results = await registry.resolve_multi( + ["_shape"], + results={ + "_sql": sql, + "_params": params, + }, + ) + + # If "shape" does not include "rows" we return that as the response + # because it's likely [{...}] or similar, with no room to attach extras + if "rows" not in results["_shape"]: + return Response.json(results["_shape"]) + + output = results["_shape"] + # Include the extras: + output.update(dict((k, v) for k, v in results.items() if not k.startswith("_"))) + return Response.json(output) class QueryView(DataView): @@ -415,7 +508,7 @@ async def extra_template(): display_value = plugin_display_value else: if value in ("", None): - display_value = Markup(" ") + display_value = markupsafe.Markup(" ") elif is_url(str(display_value).strip()): display_value = markupsafe.Markup( '{truncated_url}'.format( From ff728ccda8b54e40f6eb995c6647cef3d4d9b9c6 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 26 Jul 2023 16:46:01 -0700 Subject: [PATCH 03/23] WIP --- datasette/views/database.py | 195 ++++++++++++++++++++++-------------- 1 file changed, 119 insertions(+), 76 deletions(-) diff --git a/datasette/views/database.py b/datasette/views/database.py index 97f2cd9879..2b7edc7aef 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -27,7 +27,7 @@ from datasette.utils.asgi import AsgiFileDownload, NotFound, Response, Forbidden from datasette.plugins import pm -from .base import BaseView, DatasetteError, DataView, _error +from .base import BaseView, DatasetteError, DataView, _error, stream_csv async def database_view(request, datasette): @@ -226,92 +226,135 @@ async def query_view( ): db = await datasette.resolve_database(request) database = db.name - # TODO: Why do I do this? Is it to eliminate multi-args? - # It's going to break ?_extra=...&_extra=... + # Flattened because of ?sql=&name1=value1&name2=value2 feature params = {key: request.args.get(key) for key in request.args} - sql = "" + sql = None if "sql" in params: sql = params.pop("sql") + if "_shape" in params: + params.pop("_shape") + + # extras come from original request.args to avoid being flattened + extras = request.args.getlist("_extra") # TODO: Behave differently for canned query here: await datasette.ensure_permissions(request.actor, [("execute-sql", database)]) - _shape = None - if "_shape" in params: - _shape = params.pop("_shape") + format_ = request.url_vars.get("format") or "html" - async def _results(_sql, _params): - # Returns (results, error (can be None)) - try: - results = await db.execute(_sql, _params, truncate=True) - return results, None - except Exception as e: - return None, e + # Handle formats from plugins + if format_ == "csv": - async def shape_arrays(_results): - results, error = _results - if error: - return {"ok": False, "error": str(error)} - return { - "ok": True, - "rows": [list(r) for r in results.rows], - "truncated": results.truncated, - } + async def fetch_data_for_csv(request, _next=None): + results = await db.execute(sql, params, truncate=True) + data = { + "rows": results.rows, + "columns": results.columns() + } + return data, None, None + + return await stream_csv(datasette, fetch_data_for_csv, request, db.name) + elif format_ in datasette.renderers.keys(): + # Dispatch request to the correct output format renderer + # (CSV is not handled here due to streaming) + result = call_with_supported_arguments( + datasette.renderers[format_][0], + datasette=datasette, + columns=columns, + rows=rows, + sql=sql, + query_name=None, + database=resolved.db.name, + table=resolved.table, + request=request, + view_name="table", + # These will be deprecated in Datasette 1.0: + args=request.args, + data=data, + ) + if asyncio.iscoroutine(result): + result = await result + if result is None: + raise NotFound("No data") + if isinstance(result, dict): + r = Response( + body=result.get("body"), + status=result.get("status_code") or 200, + content_type=result.get("content_type", "text/plain"), + headers=result.get("headers"), + ) + elif isinstance(result, Response): + r = result + # if status_code is not None: + # # Over-ride the status code + # r.status = status_code + else: + assert False, f"{result} should be dict or Response" + elif format_ == "html": + headers = {} + templates = [f"query-{to_css_class(database)}.html", "query.html"] + template = datasette.jinja_env.select_template(templates) + alternate_url_json = datasette.absolute_url( + request, + datasette.urls.path(path_with_format(request=request, format="json")), + ) + data = { - async def shape_objects(_results): - results, error = _results - if error: - return {"ok": False, "error": str(error)} - return { - "ok": True, - "rows": [dict(r) for r in results.rows], - "truncated": results.truncated, } - - async def shape_array(_results): - results, error = _results - if error: - return {"ok": False, "error": str(error)} - return [dict(r) for r in results.rows] - - async def shape_arrayfirst(_results): - results, error = _results - if error: - return {"ok": False, "error": str(error)} - return [r[0] for r in results.rows] - - shape_fn = { - "arrays": shape_arrays, - "objects": shape_objects, - "array": shape_array, - "arrayfirst": shape_arrayfirst, - }[_shape or "objects"] - - registry = Registry.from_dict( - { - "_results": _results, - "_shape": shape_fn, - }, - parallel=False, - ) - - results = await registry.resolve_multi( - ["_shape"], - results={ - "_sql": sql, - "_params": params, - }, - ) - - # If "shape" does not include "rows" we return that as the response - # because it's likely [{...}] or similar, with no room to attach extras - if "rows" not in results["_shape"]: - return Response.json(results["_shape"]) - - output = results["_shape"] - # Include the extras: - output.update(dict((k, v) for k, v in results.items() if not k.startswith("_"))) - return Response.json(output) + headers.update( + { + "Link": '{}; rel="alternate"; type="application/json+datasette"'.format( + alternate_url_json + ) + } + ) + metadata = (datasette.metadata("databases") or {}).get(database, {}) + datasette.update_with_inherited_metadata(metadata) + r = Response.html( + await datasette.render_template( + template, + { + "todo": True, + "database": database, + "database_color": lambda _: "#ff0000", + "metadata": metadata, + "columns": columns, + "display_rows": display_rows, + }, + request=request, + ), + headers=headers, + ) + + # dict( + # data, + # append_querystring=append_querystring, + # path_with_replaced_args=path_with_replaced_args, + # fix_path=datasette.urls.path, + # settings=datasette.settings_dict(), + # # TODO: review up all of these hacks: + # alternate_url_json=alternate_url_json, + # datasette_allow_facet=( + # "true" if datasette.setting("allow_facet") else "false" + # ), + # is_sortable=any(c["sortable"] for c in data["display_columns"]), + # allow_execute_sql=await datasette.permission_allowed( + # request.actor, "execute-sql", resolved.db.name + # ), + # query_ms=1.2, + # select_templates=[ + # f"{'*' if template_name == template.name else ''}{template_name}" + # for template_name in templates + # ], + # ), + # request=request, + # view_name="table", + # ), + # headers=headers, + # ) + else: + assert False, "Invalid format: {}".format(format_) + return r class QueryView(DataView): From 002289b77441ed59d6332cf5cf25fcd7566fde85 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 6 Aug 2023 17:01:15 -0700 Subject: [PATCH 04/23] New Context dataclass/subclass mechanism, refs #2127 --- datasette/__init__.py | 3 ++- datasette/app.py | 12 ++++++++++-- datasette/views/__init__.py | 3 +++ tests/test_internals_datasette.py | 20 +++++++++++++++++++- 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/datasette/__init__.py b/datasette/__init__.py index 64fb4ff7d0..271e09ada0 100644 --- a/datasette/__init__.py +++ b/datasette/__init__.py @@ -1,6 +1,7 @@ -from datasette.permissions import Permission +from datasette.permissions import Permission # noqa from datasette.version import __version_info__, __version__ # noqa from datasette.utils.asgi import Forbidden, NotFound, Request, Response # noqa from datasette.utils import actor_matches_allow # noqa +from datasette.views import Context # noqa from .hookspecs import hookimpl # noqa from .hookspecs import hookspec # noqa diff --git a/datasette/app.py b/datasette/app.py index 69074e8fdf..39c2bb6de9 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -1,7 +1,8 @@ import asyncio -from typing import Sequence, Union, Tuple, Optional, Dict, Iterable +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union import asgi_csrf import collections +import dataclasses import datetime import functools import glob @@ -33,6 +34,7 @@ from jinja2.environment import Template from jinja2.exceptions import TemplateNotFound +from .views import Context from .views.base import ureg from .views.database import database_download, database_view, TableCreateView from .views.index import IndexView @@ -1115,7 +1117,11 @@ def _register_renderers(self): ) async def render_template( - self, templates, context=None, request=None, view_name=None + self, + templates: Union[List[str], str, Template], + context: Optional[Union[Dict[str, Any], Context]] = None, + request: Optional[Request] = None, + view_name: Optional[str] = None, ): if not self._startup_invoked: raise Exception("render_template() called before await ds.invoke_startup()") @@ -1126,6 +1132,8 @@ async def render_template( if isinstance(templates, str): templates = [templates] template = self.jinja_env.select_template(templates) + if dataclasses.is_dataclass(context): + context = dataclasses.asdict(context) body_scripts = [] # pylint: disable=no-member for extra_script in pm.hook.extra_body_script( diff --git a/datasette/views/__init__.py b/datasette/views/__init__.py index e69de29bb2..e3b1b7f44b 100644 --- a/datasette/views/__init__.py +++ b/datasette/views/__init__.py @@ -0,0 +1,3 @@ +class Context: + "Base class for all documented contexts" + pass diff --git a/tests/test_internals_datasette.py b/tests/test_internals_datasette.py index 3d5bb2da58..d59ff72976 100644 --- a/tests/test_internals_datasette.py +++ b/tests/test_internals_datasette.py @@ -1,10 +1,12 @@ """ Tests for the datasette.app.Datasette class """ -from datasette import Forbidden +import dataclasses +from datasette import Forbidden, Context from datasette.app import Datasette, Database from itsdangerous import BadSignature import pytest +from typing import Optional @pytest.fixture @@ -136,6 +138,22 @@ async def test_datasette_render_template_no_request(): assert "Error " in rendered +@pytest.mark.asyncio +async def test_datasette_render_template_with_dataclass(): + @dataclasses.dataclass + class ExampleContext(Context): + title: str + status: int + error: str + + context = ExampleContext(title="Hello", status=200, error="Error message") + ds = Datasette(memory=True) + await ds.invoke_startup() + rendered = await ds.render_template("error.html", context) + assert "

Hello

" in rendered + assert "Error message" in rendered + + def test_datasette_error_if_string_not_list(tmpdir): # https://github.com/simonw/datasette/issues/1985 db_path = str(tmpdir / "data.db") From 2f9038a831a3510d4c9ab39a12d96259b3a55bc7 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 6 Aug 2023 17:02:05 -0700 Subject: [PATCH 05/23] Define QueryContext and extract get_tables() method, refs #2127 --- datasette/views/database.py | 238 ++++++++++++++++++++++++++++++------ 1 file changed, 199 insertions(+), 39 deletions(-) diff --git a/datasette/views/database.py b/datasette/views/database.py index 2b7edc7aef..434cdb29ab 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -1,4 +1,5 @@ from asyncinject import Registry +import asyncio import os import hashlib import itertools @@ -11,6 +12,7 @@ from datasette.utils import ( add_cors_headers, await_me_maybe, + call_with_supported_arguments, derive_named_parameters, format_bytes, tilde_decode, @@ -80,33 +82,7 @@ async def database_view(request, datasette): } ) - tables = [] - for table in table_counts: - table_visible, table_private = await datasette.check_visibility( - request.actor, - permissions=[ - ("view-table", (database, table)), - ("view-database", database), - "view-instance", - ], - ) - if not table_visible: - continue - table_columns = await db.table_columns(table) - tables.append( - { - "name": table, - "columns": table_columns, - "primary_keys": await db.primary_keys(table), - "count": table_counts[table], - "hidden": table in hidden_table_names, - "fts_table": await db.fts_table(table), - "foreign_keys": all_foreign_keys[table], - "private": table_private, - } - ) - - tables.sort(key=lambda t: (t["hidden"], t["name"])) + tables = await get_tables(datasette, request, db) canned_queries = [] for query in (await datasette.get_canned_queries(database, request.actor)).values(): query_visible, query_private = await datasette.check_visibility( @@ -175,6 +151,96 @@ async def database_actions(): ) +from dataclasses import dataclass, field + + +@dataclass +class QueryContext: + database: str = field(metadata={"help": "The name of the database being queried"}) + query: dict = field( + metadata={"help": "The SQL query object containing the `sql` string"} + ) + canned_query: str = field( + metadata={"help": "The name of the canned query if this is a canned query"} + ) + private: bool = field( + metadata={"help": "Boolean indicating if this is a private database"} + ) + urls: dict = field( + metadata={"help": "Object containing URL helpers like `database()`"} + ) + canned_write: bool = field( + metadata={"help": "Boolean indicating if this canned query allows writes"} + ) + db_is_immutable: bool = field( + metadata={"help": "Boolean indicating if this database is immutable"} + ) + error: str = field(metadata={"help": "Any query error message"}) + hide_sql: bool = field( + metadata={"help": "Boolean indicating if the SQL should be hidden"} + ) + show_hide_link: str = field( + metadata={"help": "The URL to toggle showing/hiding the SQL"} + ) + show_hide_text: str = field( + metadata={"help": "The text for the show/hide SQL link"} + ) + editable: bool = field( + metadata={"help": "Boolean indicating if the SQL can be edited"} + ) + allow_execute_sql: bool = field( + metadata={"help": "Boolean indicating if custom SQL can be executed"} + ) + tables: list = field(metadata={"help": "List of table objects in the database"}) + named_parameter_values: dict = field( + metadata={"help": "Dictionary of parameter names/values"} + ) + csrftoken: callable = field(metadata={"help": "Function to generate a CSRF token"}) + edit_sql_url: str = field( + metadata={"help": "URL to edit the SQL for a canned query"} + ) + display_rows: list = field(metadata={"help": "List of result rows to display"}) + columns: list = field(metadata={"help": "List of column names"}) + renderers: dict = field(metadata={"help": "Dictionary of renderer name to URL"}) + url_csv: str = field(metadata={"help": "URL for CSV export"}) + metadata: dict = field(metadata={"help": "Metadata about the query/database"}) + + +async def get_tables(datasette, request, db): + tables = [] + database = db.name + table_counts = await db.table_counts(5) + hidden_table_names = set(await db.hidden_table_names()) + all_foreign_keys = await db.get_all_foreign_keys() + + for table in table_counts: + table_visible, table_private = await datasette.check_visibility( + request.actor, + permissions=[ + ("view-table", (database, table)), + ("view-database", database), + "view-instance", + ], + ) + if not table_visible: + continue + table_columns = await db.table_columns(table) + tables.append( + { + "name": table, + "columns": table_columns, + "primary_keys": await db.primary_keys(table), + "count": table_counts[table], + "hidden": table in hidden_table_names, + "fts_table": await db.fts_table(table), + "foreign_keys": all_foreign_keys[table], + "private": table_private, + } + ) + tables.sort(key=lambda t: (t["hidden"], t["name"])) + return tables + + async def database_download(request, datasette): database = tilde_decode(request.url_vars["database"]) await datasette.ensure_permissions( @@ -233,7 +299,7 @@ async def query_view( sql = params.pop("sql") if "_shape" in params: params.pop("_shape") - + # extras come from original request.args to avoid being flattened extras = request.args.getlist("_extra") @@ -247,10 +313,7 @@ async def query_view( async def fetch_data_for_csv(request, _next=None): results = await db.execute(sql, params, truncate=True) - data = { - "rows": results.rows, - "columns": results.columns() - } + data = {"rows": results.rows, "columns": results.columns} return data, None, None return await stream_csv(datasette, fetch_data_for_csv, request, db.name) @@ -264,8 +327,8 @@ async def fetch_data_for_csv(request, _next=None): rows=rows, sql=sql, query_name=None, - database=resolved.db.name, - table=resolved.table, + database=database, + table=None, request=request, view_name="table", # These will be deprecated in Datasette 1.0: @@ -298,9 +361,7 @@ async def fetch_data_for_csv(request, _next=None): request, datasette.urls.path(path_with_format(request=request, format="json")), ) - data = { - - } + data = {} headers.update( { "Link": '{}; rel="alternate"; type="application/json+datasette"'.format( @@ -310,6 +371,32 @@ async def fetch_data_for_csv(request, _next=None): ) metadata = (datasette.metadata("databases") or {}).get(database, {}) datasette.update_with_inherited_metadata(metadata) + + results = await db.execute(sql, params, truncate=True) + rows = results.rows + columns = results.columns + + renderers = {} + for key, (_, can_render) in datasette.renderers.items(): + it_can_render = call_with_supported_arguments( + can_render, + datasette=datasette, + columns=data.get("columns") or [], + rows=data.get("rows") or [], + sql=data.get("query", {}).get("sql", None), + query_name=data.get("query_name"), + database=database, + table=data.get("table"), + request=request, + # TODO: Fix this + view_name=None, + ) + it_can_render = await await_me_maybe(it_can_render) + if it_can_render: + renderers[key] = datasette.urls.path( + path_with_format(request=request, format=key) + ) + r = Response.html( await datasette.render_template( template, @@ -319,13 +406,20 @@ async def fetch_data_for_csv(request, _next=None): "database_color": lambda _: "#ff0000", "metadata": metadata, "columns": columns, - "display_rows": display_rows, + "display_rows": await display_rows( + datasette, database, request, rows, columns + ), + "renderers": renderers, + "editable": True, + # TODO: permission check + "allow_execute_sql": True, + "tables": await get_tables(datasette, request, db), }, request=request, ), headers=headers, ) - + # dict( # data, # append_querystring=append_querystring, @@ -902,3 +996,69 @@ async def _table_columns(datasette, database_name): for view_name in await db.view_names(): table_columns[view_name] = [] return table_columns + + +async def display_rows(datasette, database, request, rows, columns): + display_rows = [] + truncate_cells = datasette.setting("truncate_cells_html") + for row in rows: + display_row = [] + for column, value in zip(columns, row): + display_value = value + # Let the plugins have a go + # pylint: disable=no-member + plugin_display_value = None + for candidate in pm.hook.render_cell( + row=row, + value=value, + column=column, + table=None, + database=database, + datasette=datasette, + request=request, + ): + candidate = await await_me_maybe(candidate) + if candidate is not None: + plugin_display_value = candidate + break + if plugin_display_value is not None: + display_value = plugin_display_value + else: + if value in ("", None): + display_value = markupsafe.Markup(" ") + elif is_url(str(display_value).strip()): + display_value = markupsafe.Markup( + '{truncated_url}'.format( + url=markupsafe.escape(value.strip()), + truncated_url=markupsafe.escape( + truncate_url(value.strip(), truncate_cells) + ), + ) + ) + elif isinstance(display_value, bytes): + blob_url = path_with_format( + request=request, + format="blob", + extra_qs={ + "_blob_column": column, + "_blob_hash": hashlib.sha256(display_value).hexdigest(), + }, + ) + formatted = format_bytes(len(value)) + display_value = markupsafe.Markup( + '<Binary: {:,} byte{}>'.format( + blob_url, + ' title="{}"'.format(formatted) + if "bytes" not in formatted + else "", + len(value), + "" if len(value) == 1 else "s", + ) + ) + else: + display_value = str(value) + if truncate_cells and len(display_value) > truncate_cells: + display_value = display_value[:truncate_cells] + "\u2026" + display_row.append(display_value) + display_rows.append(display_row) + return display_rows From 743c13c84b4e28ade32d19e7e5f6fabfe8c495d3 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 6 Aug 2023 17:32:34 -0700 Subject: [PATCH 06/23] First version that uses the QueryContext context, refs #2127 --- datasette/views/database.py | 61 ++++++++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 17 deletions(-) diff --git a/datasette/views/database.py b/datasette/views/database.py index 434cdb29ab..a6cdc0598d 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -8,6 +8,7 @@ from urllib.parse import parse_qsl, urlencode import re import sqlite_utils +from typing import Callable from datasette.utils import ( add_cors_headers, @@ -166,9 +167,9 @@ class QueryContext: private: bool = field( metadata={"help": "Boolean indicating if this is a private database"} ) - urls: dict = field( - metadata={"help": "Object containing URL helpers like `database()`"} - ) + # urls: dict = field( + # metadata={"help": "Object containing URL helpers like `database()`"} + # ) canned_write: bool = field( metadata={"help": "Boolean indicating if this canned query allows writes"} ) @@ -195,7 +196,6 @@ class QueryContext: named_parameter_values: dict = field( metadata={"help": "Dictionary of parameter names/values"} ) - csrftoken: callable = field(metadata={"help": "Function to generate a CSRF token"}) edit_sql_url: str = field( metadata={"help": "URL to edit the SQL for a canned query"} ) @@ -204,6 +204,9 @@ class QueryContext: renderers: dict = field(metadata={"help": "Dictionary of renderer name to URL"}) url_csv: str = field(metadata={"help": "URL for CSV export"}) metadata: dict = field(metadata={"help": "Metadata about the query/database"}) + database_color: Callable = field( + metadata={"help": "Function that returns a color for a given database name"} + ) async def get_tables(datasette, request, db): @@ -306,6 +309,14 @@ async def query_view( # TODO: Behave differently for canned query here: await datasette.ensure_permissions(request.actor, [("execute-sql", database)]) + _, private = await datasette.check_visibility( + request.actor, + permissions=[ + ("view-database", database), + "view-instance", + ], + ) + format_ = request.url_vars.get("format") or "html" # Handle formats from plugins @@ -400,21 +411,37 @@ async def fetch_data_for_csv(request, _next=None): r = Response.html( await datasette.render_template( template, - { - "todo": True, - "database": database, - "database_color": lambda _: "#ff0000", - "metadata": metadata, - "columns": columns, - "display_rows": await display_rows( + QueryContext( + database=database, + query={ + "sql": sql, + # TODO: Params? + }, + canned_query=None, + private=private, + canned_write=False, + db_is_immutable=not db.is_mutable, + # TODO: error + error=None, + hide_sql=None, # TODO + show_hide_link="todo", + show_hide_text="todo", + editable=True, # TODO + allow_execute_sql=await datasette.permission_allowed( + request.actor, "execute-sql", database + ), + tables=await get_tables(datasette, request, db), + named_parameter_values={}, # TODO + edit_sql_url="todo", + display_rows=await display_rows( datasette, database, request, rows, columns ), - "renderers": renderers, - "editable": True, - # TODO: permission check - "allow_execute_sql": True, - "tables": await get_tables(datasette, request, db), - }, + columns=columns, + renderers=renderers, + url_csv="todo", + metadata=metadata, + database_color=lambda _: "#ff0000", + ), request=request, ), headers=headers, From dff29b06b77c2e6d373d55e10d35aa15a2b69ca7 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 7 Aug 2023 08:44:19 -0700 Subject: [PATCH 07/23] Handle DB errors --- datasette/views/database.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/datasette/views/database.py b/datasette/views/database.py index a6cdc0598d..c0fc2c8d8e 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -317,7 +317,25 @@ async def query_view( ], ) + extra_args = {} + if params.get("_timelimit"): + extra_args["custom_time_limit"] = int(params["_timelimit"]) + format_ = request.url_vars.get("format") or "html" + query_error = None + try: + results = await datasette.execute( + database, sql, params, truncate=True, **extra_args + ) + columns = [r[0] for r in results.description] + except (sqlite3.DatabaseError, InvalidSql) as e: + query_error = e + results = None + columns = [] + + results = await db.execute(sql, params, truncate=True) + rows = results.rows + columns = results.columns # Handle formats from plugins if format_ == "csv": @@ -383,10 +401,6 @@ async def fetch_data_for_csv(request, _next=None): metadata = (datasette.metadata("databases") or {}).get(database, {}) datasette.update_with_inherited_metadata(metadata) - results = await db.execute(sql, params, truncate=True) - rows = results.rows - columns = results.columns - renderers = {} for key, (_, can_render) in datasette.renderers.items(): it_can_render = call_with_supported_arguments( @@ -421,8 +435,7 @@ async def fetch_data_for_csv(request, _next=None): private=private, canned_write=False, db_is_immutable=not db.is_mutable, - # TODO: error - error=None, + error=query_error, hide_sql=None, # TODO show_hide_link="todo", show_hide_text="todo", From 2dd4e148925913ca3ef0621e7e51259011bf30bd Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 7 Aug 2023 09:18:40 -0700 Subject: [PATCH 08/23] Down to 31 failing tests --- datasette/renderer.py | 5 +++- datasette/views/base.py | 1 + datasette/views/database.py | 49 ++++++++++++++++++++++++++++--------- datasette/views/table.py | 1 + tests/test_api.py | 27 ++++++++++---------- tests/test_cli_serve_get.py | 1 - 6 files changed, 56 insertions(+), 28 deletions(-) diff --git a/datasette/renderer.py b/datasette/renderer.py index 5354f34852..7c0be956d9 100644 --- a/datasette/renderer.py +++ b/datasette/renderer.py @@ -27,7 +27,7 @@ def convert_specific_columns_to_json(rows, columns, json_cols): return new_rows -def json_renderer(args, data, view_name): +def json_renderer(args, data, view_name, truncated=None): """Render a response as JSON""" status_code = 200 @@ -50,6 +50,9 @@ def json_renderer(args, data, view_name): if data.get("error"): shape = "objects" + if truncated is not None: + data["truncated"] = truncated + if shape == "arrayfirst": if not data["rows"]: data = [] diff --git a/datasette/views/base.py b/datasette/views/base.py index 94645cd8dd..96ce2b4a1d 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -309,6 +309,7 @@ async def get(self, request): table=data.get("table"), request=request, view_name=self.name, + truncated=False, # TODO: support this # These will be deprecated in Datasette 1.0: args=request.args, data=data, diff --git a/datasette/views/database.py b/datasette/views/database.py index c0fc2c8d8e..4c6818fa50 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -8,8 +8,10 @@ from urllib.parse import parse_qsl, urlencode import re import sqlite_utils +import textwrap from typing import Callable +from datasette.database import QueryInterrupted from datasette.utils import ( add_cors_headers, await_me_maybe, @@ -55,7 +57,6 @@ async def database_view(request, datasette): sql = (request.args.get("sql") or "").strip() if sql: - validate_sql_select(sql) return await query_view(request, datasette) metadata = (datasette.metadata("databases") or {}).get(database, {}) @@ -131,7 +132,10 @@ async def database_actions(): } if format_ == "json": - return Response.json(json_data) + response = Response.json(json_data) + if datasette.cors: + add_cors_headers(response.headers) + return response assert format_ == "html" context = { @@ -324,18 +328,38 @@ async def query_view( format_ = request.url_vars.get("format") or "html" query_error = None try: + validate_sql_select(sql) results = await datasette.execute( database, sql, params, truncate=True, **extra_args ) - columns = [r[0] for r in results.description] - except (sqlite3.DatabaseError, InvalidSql) as e: - query_error = e - results = None - columns = [] - - results = await db.execute(sql, params, truncate=True) - rows = results.rows - columns = results.columns + columns = results.columns + rows = results.rows + except QueryInterrupted as ex: + raise DatasetteError( + textwrap.dedent( + """ +

SQL query took too long. The time limit is controlled by the + sql_time_limit_ms + configuration option.

+ + + """.format( + markupsafe.escape(ex.sql) + ) + ).strip(), + title="SQL Interrupted", + status=400, + message_is_html=True, + ) + except (sqlite3.OperationalError, InvalidSql) as ex: + raise DatasetteError(str(ex), title="Invalid SQL", status=400) + except sqlite3.OperationalError as ex: + raise DatasetteError(str(ex)) + except DatasetteError: + raise # Handle formats from plugins if format_ == "csv": @@ -360,9 +384,10 @@ async def fetch_data_for_csv(request, _next=None): table=None, request=request, view_name="table", + truncated=results.truncated if results else False, # These will be deprecated in Datasette 1.0: args=request.args, - data=data, + data={"rows": rows, "columns": columns}, ) if asyncio.iscoroutine(result): result = await result diff --git a/datasette/views/table.py b/datasette/views/table.py index c102c10319..ce45c2e018 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -833,6 +833,7 @@ async def fetch_data(request, _next=None): table=resolved.table, request=request, view_name="table", + truncated=False, # These will be deprecated in Datasette 1.0: args=request.args, data=data, diff --git a/tests/test_api.py b/tests/test_api.py index 40a3e2b825..c136e433c3 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -638,22 +638,20 @@ def test_database_page_for_database_with_dot_in_name(app_client_with_dot): @pytest.mark.asyncio async def test_custom_sql(ds_client): response = await ds_client.get( - "/fixtures.json?sql=select+content+from+simple_primary_key&_shape=objects" + "/fixtures.json?sql=select+content+from+simple_primary_key" ) data = response.json() - assert {"sql": "select content from simple_primary_key", "params": {}} == data[ - "query" - ] - assert [ - {"content": "hello"}, - {"content": "world"}, - {"content": ""}, - {"content": "RENDER_CELL_DEMO"}, - {"content": "RENDER_CELL_ASYNC"}, - ] == data["rows"] - assert ["content"] == data["columns"] - assert "fixtures" == data["database"] - assert not data["truncated"] + assert data == { + "rows": [ + {"content": "hello"}, + {"content": "world"}, + {"content": ""}, + {"content": "RENDER_CELL_DEMO"}, + {"content": "RENDER_CELL_ASYNC"}, + ], + "columns": ["content"], + "truncated": False, + } def test_sql_time_limit(app_client_shorter_time_limit): @@ -990,6 +988,7 @@ def test_inspect_file_used_for_count(app_client_immutable_and_inspect_file): @pytest.mark.asyncio +@pytest.mark.xfail # TODO: Fix this feature async def test_http_options_request(ds_client): response = await ds_client.options("/fixtures") assert response.status_code == 200 diff --git a/tests/test_cli_serve_get.py b/tests/test_cli_serve_get.py index ac44e1e285..2e0390bb8c 100644 --- a/tests/test_cli_serve_get.py +++ b/tests/test_cli_serve_get.py @@ -36,7 +36,6 @@ def startup(datasette): ) assert 0 == result.exit_code, result.output assert { - "database": "_memory", "truncated": False, "columns": ["sqlite_version()"], }.items() <= json.loads(result.output).items() From c138df61e120096e7da1f546cfcb5ff676f13fd0 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 7 Aug 2023 11:20:43 -0700 Subject: [PATCH 09/23] Show error messages with a form to edit the SQL --- datasette/views/database.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/datasette/views/database.py b/datasette/views/database.py index 4c6818fa50..77f729d26d 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -354,6 +354,11 @@ async def query_view( status=400, message_is_html=True, ) + except sqlite3.DatabaseError as ex: + query_error = ex + results = None + rows = [] + columns = [] except (sqlite3.OperationalError, InvalidSql) as ex: raise DatasetteError(str(ex), title="Invalid SQL", status=400) except sqlite3.OperationalError as ex: From e2b60f57ae2fd20baa2308464843cf11039efc92 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 7 Aug 2023 13:34:28 -0700 Subject: [PATCH 10/23] Fix OPTIONS bug by porting DatbaseView to be a View subclass --- datasette/app.py | 4 +- datasette/views/database.py | 213 ++++++++++++++++++------------------ tests/test_api.py | 1 - 3 files changed, 110 insertions(+), 108 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 39c2bb6de9..595ce78092 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -36,7 +36,7 @@ from .views import Context from .views.base import ureg -from .views.database import database_download, database_view, TableCreateView +from .views.database import database_download, DatabaseView, TableCreateView from .views.index import IndexView from .views.special import ( JsonDataView, @@ -1376,7 +1376,7 @@ def add_route(view, regex): r"/(?P[^\/\.]+)\.db$", ) add_route( - wrap_view(database_view, self), + wrap_view(DatabaseView, self), r"/(?P[^\/\.]+)(\.(?P\w+))?$", ) add_route(TableCreateView.as_view(self), r"/(?P[^\/\.]+)/-/create$") diff --git a/datasette/views/database.py b/datasette/views/database.py index 77f729d26d..ea3cafb3c3 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -32,128 +32,131 @@ from datasette.utils.asgi import AsgiFileDownload, NotFound, Response, Forbidden from datasette.plugins import pm -from .base import BaseView, DatasetteError, DataView, _error, stream_csv +from .base import BaseView, DatasetteError, DataView, View, _error, stream_csv -async def database_view(request, datasette): - format_ = request.url_vars.get("format") or "html" - if format_ not in ("html", "json"): - raise NotFound("Invalid format: {}".format(format_)) - - await datasette.refresh_schemas() - - db = await datasette.resolve_database(request) - database = db.name - - visible, private = await datasette.check_visibility( - request.actor, - permissions=[ - ("view-database", database), - "view-instance", - ], - ) - if not visible: - raise Forbidden("You do not have permission to view this database") +class DatabaseView(View): + async def get(self, request, datasette): + format_ = request.url_vars.get("format") or "html" + if format_ not in ("html", "json"): + raise NotFound("Invalid format: {}".format(format_)) - sql = (request.args.get("sql") or "").strip() - if sql: - return await query_view(request, datasette) + await datasette.refresh_schemas() - metadata = (datasette.metadata("databases") or {}).get(database, {}) - datasette.update_with_inherited_metadata(metadata) - - table_counts = await db.table_counts(5) - hidden_table_names = set(await db.hidden_table_names()) - all_foreign_keys = await db.get_all_foreign_keys() + db = await datasette.resolve_database(request) + database = db.name - sql_views = [] - for view_name in await db.view_names(): - view_visible, view_private = await datasette.check_visibility( + visible, private = await datasette.check_visibility( request.actor, permissions=[ - ("view-table", (database, view_name)), ("view-database", database), "view-instance", ], ) - if view_visible: - sql_views.append( - { - "name": view_name, - "private": view_private, - } + if not visible: + raise Forbidden("You do not have permission to view this database") + + sql = (request.args.get("sql") or "").strip() + if sql: + return await query_view(request, datasette) + + metadata = (datasette.metadata("databases") or {}).get(database, {}) + datasette.update_with_inherited_metadata(metadata) + + table_counts = await db.table_counts(5) + hidden_table_names = set(await db.hidden_table_names()) + all_foreign_keys = await db.get_all_foreign_keys() + + sql_views = [] + for view_name in await db.view_names(): + view_visible, view_private = await datasette.check_visibility( + request.actor, + permissions=[ + ("view-table", (database, view_name)), + ("view-database", database), + "view-instance", + ], ) + if view_visible: + sql_views.append( + { + "name": view_name, + "private": view_private, + } + ) - tables = await get_tables(datasette, request, db) - canned_queries = [] - for query in (await datasette.get_canned_queries(database, request.actor)).values(): - query_visible, query_private = await datasette.check_visibility( - request.actor, - permissions=[ - ("view-query", (database, query["name"])), - ("view-database", database), - "view-instance", - ], - ) - if query_visible: - canned_queries.append(dict(query, private=query_private)) + tables = await get_tables(datasette, request, db) + canned_queries = [] + for query in ( + await datasette.get_canned_queries(database, request.actor) + ).values(): + query_visible, query_private = await datasette.check_visibility( + request.actor, + permissions=[ + ("view-query", (database, query["name"])), + ("view-database", database), + "view-instance", + ], + ) + if query_visible: + canned_queries.append(dict(query, private=query_private)) - async def database_actions(): - links = [] - for hook in pm.hook.database_actions( - datasette=datasette, - database=database, - actor=request.actor, - request=request, - ): - extra_links = await await_me_maybe(hook) - if extra_links: - links.extend(extra_links) - return links + async def database_actions(): + links = [] + for hook in pm.hook.database_actions( + datasette=datasette, + database=database, + actor=request.actor, + request=request, + ): + extra_links = await await_me_maybe(hook) + if extra_links: + links.extend(extra_links) + return links - attached_databases = [d.name for d in await db.attached_databases()] + attached_databases = [d.name for d in await db.attached_databases()] - allow_execute_sql = await datasette.permission_allowed( - request.actor, "execute-sql", database - ) - json_data = { - "database": database, - "private": private, - "path": datasette.urls.database(database), - "size": db.size, - "tables": tables, - "hidden_count": len([t for t in tables if t["hidden"]]), - "views": sql_views, - "queries": canned_queries, - "allow_execute_sql": allow_execute_sql, - "table_columns": await _table_columns(datasette, database) - if allow_execute_sql - else {}, - } + allow_execute_sql = await datasette.permission_allowed( + request.actor, "execute-sql", database + ) + json_data = { + "database": database, + "private": private, + "path": datasette.urls.database(database), + "size": db.size, + "tables": tables, + "hidden_count": len([t for t in tables if t["hidden"]]), + "views": sql_views, + "queries": canned_queries, + "allow_execute_sql": allow_execute_sql, + "table_columns": await _table_columns(datasette, database) + if allow_execute_sql + else {}, + } - if format_ == "json": - response = Response.json(json_data) - if datasette.cors: - add_cors_headers(response.headers) - return response - - assert format_ == "html" - context = { - **json_data, - "database_actions": database_actions, - "show_hidden": request.args.get("_show_hidden"), - "editable": True, - "metadata": metadata, - "allow_download": datasette.setting("allow_download") - and not db.is_mutable - and not db.is_memory, - "attached_databases": attached_databases, - "database_color": lambda _: "#ff0000", - } - templates = (f"database-{to_css_class(database)}.html", "database.html") - return Response.html( - await datasette.render_template(templates, context, request=request) - ) + if format_ == "json": + response = Response.json(json_data) + if datasette.cors: + add_cors_headers(response.headers) + return response + + assert format_ == "html" + context = { + **json_data, + "database_actions": database_actions, + "show_hidden": request.args.get("_show_hidden"), + "editable": True, + "metadata": metadata, + "allow_download": datasette.setting("allow_download") + and not db.is_mutable + and not db.is_memory, + "attached_databases": attached_databases, + "database_color": lambda _: "#ff0000", + } + templates = (f"database-{to_css_class(database)}.html", "database.html") + return Response.html( + await datasette.render_template(templates, context, request=request) + ) from dataclasses import dataclass, field diff --git a/tests/test_api.py b/tests/test_api.py index c136e433c3..9d5966ba6c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -988,7 +988,6 @@ def test_inspect_file_used_for_count(app_client_immutable_and_inspect_file): @pytest.mark.asyncio -@pytest.mark.xfail # TODO: Fix this feature async def test_http_options_request(ds_client): response = await ds_client.options("/fixtures") assert response.status_code == 200 From f2a110f7e9f8a10417cb9f7d48867e623261d5e1 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 7 Aug 2023 13:47:53 -0700 Subject: [PATCH 11/23] Expose async_view_for_class.view_class for test_routes test --- datasette/app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/datasette/app.py b/datasette/app.py index 595ce78092..b2644ace17 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -1716,6 +1716,7 @@ async def async_view_for_class(request, send): datasette=datasette, ) + async_view_for_class.view_class = view_class return async_view_for_class From 80953da70727de9ef7f0fdea0eb6f29ccad7fca7 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 7 Aug 2023 13:55:30 -0700 Subject: [PATCH 12/23] Fix for messages tests --- datasette/views/database.py | 5 +++-- tests/test_messages.py | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/datasette/views/database.py b/datasette/views/database.py index ea3cafb3c3..951a216530 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -38,8 +38,6 @@ class DatabaseView(View): async def get(self, request, datasette): format_ = request.url_vars.get("format") or "html" - if format_ not in ("html", "json"): - raise NotFound("Invalid format: {}".format(format_)) await datasette.refresh_schemas() @@ -60,6 +58,9 @@ async def get(self, request, datasette): if sql: return await query_view(request, datasette) + if format_ not in ("html", "json"): + raise NotFound("Invalid format: {}".format(format_)) + metadata = (datasette.metadata("databases") or {}).get(database, {}) datasette.update_with_inherited_metadata(metadata) diff --git a/tests/test_messages.py b/tests/test_messages.py index 8417b9ae2c..a7e4d04698 100644 --- a/tests/test_messages.py +++ b/tests/test_messages.py @@ -12,7 +12,7 @@ ], ) async def test_add_message_sets_cookie(ds_client, qs, expected): - response = await ds_client.get(f"/fixtures.message?{qs}") + response = await ds_client.get(f"/fixtures.message?sql=select+1&{qs}") signed = response.cookies["ds_messages"] decoded = ds_client.ds.unsign(signed, "messages") assert expected == decoded @@ -21,7 +21,9 @@ async def test_add_message_sets_cookie(ds_client, qs, expected): @pytest.mark.asyncio async def test_messages_are_displayed_and_cleared(ds_client): # First set the message cookie - set_msg_response = await ds_client.get("/fixtures.message?add_msg=xmessagex") + set_msg_response = await ds_client.get( + "/fixtures.message?sql=select+1&add_msg=xmessagex" + ) # Now access a page that displays messages response = await ds_client.get("/", cookies=set_msg_response.cookies) # Messages should be in that HTML From fcdb96c2f1d4c173eb09c8eb734928e585d30cd0 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 7 Aug 2023 14:03:17 -0700 Subject: [PATCH 13/23] Added table_columns schema back to query/database pages --- datasette/views/database.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/datasette/views/database.py b/datasette/views/database.py index 951a216530..38e4cdd697 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -215,6 +215,9 @@ class QueryContext: database_color: Callable = field( metadata={"help": "Function that returns a color for a given database name"} ) + table_columns: dict = field( + metadata={"help": "Dictionary of table name to list of column names"} + ) async def get_tables(datasette, request, db): @@ -456,6 +459,9 @@ async def fetch_data_for_csv(request, _next=None): path_with_format(request=request, format=key) ) + allow_execute_sql = await datasette.permission_allowed( + request.actor, "execute-sql", database + ) r = Response.html( await datasette.render_template( template, @@ -474,15 +480,16 @@ async def fetch_data_for_csv(request, _next=None): show_hide_link="todo", show_hide_text="todo", editable=True, # TODO - allow_execute_sql=await datasette.permission_allowed( - request.actor, "execute-sql", database - ), + allow_execute_sql=allow_execute_sql, tables=await get_tables(datasette, request, db), named_parameter_values={}, # TODO edit_sql_url="todo", display_rows=await display_rows( datasette, database, request, rows, columns ), + table_columns=await _table_columns(datasette, database) + if allow_execute_sql + else {}, columns=columns, renderers=renderers, url_csv="todo", From 8ec6bc8273a2d5e539800dc8d6b14e738dfa0e98 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 7 Aug 2023 14:08:23 -0700 Subject: [PATCH 14/23] Clean up imports a bit --- datasette/views/database.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/datasette/views/database.py b/datasette/views/database.py index 38e4cdd697..8e8ee5dea2 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -1,15 +1,16 @@ from asyncinject import Registry +from dataclasses import dataclass, field +from typing import Callable +from urllib.parse import parse_qsl, urlencode import asyncio -import os import hashlib import itertools import json import markupsafe -from urllib.parse import parse_qsl, urlencode +import os import re import sqlite_utils import textwrap -from typing import Callable from datasette.database import QueryInterrupted from datasette.utils import ( @@ -160,9 +161,6 @@ async def database_actions(): ) -from dataclasses import dataclass, field - - @dataclass class QueryContext: database: str = field(metadata={"help": "The name of the database being queried"}) From cd1dd0169397b4e9805ac629d457012e39c9c6a7 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 7 Aug 2023 16:36:58 -0700 Subject: [PATCH 15/23] error/truncated aruments for renderers, refs #2130 --- datasette/renderer.py | 18 +++++++++++------- datasette/views/base.py | 1 + datasette/views/database.py | 3 ++- datasette/views/table.py | 1 + docs/plugin_hooks.rst | 6 ++++++ 5 files changed, 21 insertions(+), 8 deletions(-) diff --git a/datasette/renderer.py b/datasette/renderer.py index 7c0be956d9..0bd74e81ea 100644 --- a/datasette/renderer.py +++ b/datasette/renderer.py @@ -27,7 +27,7 @@ def convert_specific_columns_to_json(rows, columns, json_cols): return new_rows -def json_renderer(args, data, view_name, truncated=None): +def json_renderer(args, data, error, truncated=None): """Render a response as JSON""" status_code = 200 @@ -47,8 +47,12 @@ def json_renderer(args, data, view_name, truncated=None): # Deal with the _shape option shape = args.get("_shape", "objects") # if there's an error, ignore the shape entirely - if data.get("error"): + data["ok"] = True + if error: shape = "objects" + status_code = 400 + data["error"] = error + data["ok"] = False if truncated is not None: data["truncated"] = truncated @@ -67,13 +71,13 @@ def json_renderer(args, data, view_name, truncated=None): if rows and columns: data["rows"] = [dict(zip(columns, row)) for row in rows] if shape == "object": - error = None + shape_error = None if "primary_keys" not in data: - error = "_shape=object is only available on tables" + shape_error = "_shape=object is only available on tables" else: pks = data["primary_keys"] if not pks: - error = ( + shape_error = ( "_shape=object not available for tables with no primary keys" ) else: @@ -82,8 +86,8 @@ def json_renderer(args, data, view_name, truncated=None): pk_string = path_from_row_pks(row, pks, not pks) object_rows[pk_string] = row data = object_rows - if error: - data = {"ok": False, "error": error} + if shape_error: + data = {"ok": False, "error": shape_error} elif shape == "array": data = data["rows"] diff --git a/datasette/views/base.py b/datasette/views/base.py index 96ce2b4a1d..da5c55ad5c 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -310,6 +310,7 @@ async def get(self, request): request=request, view_name=self.name, truncated=False, # TODO: support this + error=data.get("error"), # These will be deprecated in Datasette 1.0: args=request.args, data=data, diff --git a/datasette/views/database.py b/datasette/views/database.py index 8e8ee5dea2..163b3c820d 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -360,7 +360,7 @@ async def query_view( message_is_html=True, ) except sqlite3.DatabaseError as ex: - query_error = ex + query_error = str(ex) results = None rows = [] columns = [] @@ -395,6 +395,7 @@ async def fetch_data_for_csv(request, _next=None): request=request, view_name="table", truncated=results.truncated if results else False, + error=query_error, # These will be deprecated in Datasette 1.0: args=request.args, data={"rows": rows, "columns": columns}, diff --git a/datasette/views/table.py b/datasette/views/table.py index ce45c2e018..77acfd9504 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -834,6 +834,7 @@ async def fetch_data(request, _next=None): request=request, view_name="table", truncated=False, + error=None, # These will be deprecated in Datasette 1.0: args=request.args, data=data, diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 973065292b..9bbe6fc6fa 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -516,6 +516,12 @@ When a request is received, the ``"render"`` callback function is called with ze ``request`` - :ref:`internals_request` The current HTTP request. +``error`` - string or None + If an error occurred this string will contain the error message. + +``truncated`` - bool or None + If the query response was truncated - for example a SQL query returning more than 1,000 results where pagination is not available - this will be ``True``. + ``view_name`` - string The name of the current view being called. ``index``, ``database``, ``table``, and ``row`` are the most important ones. From a791115e31e64e9bbd1d36f4d46887b3e42e752a Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 7 Aug 2023 16:49:16 -0700 Subject: [PATCH 16/23] Implemented show/hide links --- datasette/views/database.py | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/datasette/views/database.py b/datasette/views/database.py index 163b3c820d..15cbcc269a 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -461,6 +461,26 @@ async def fetch_data_for_csv(request, _next=None): allow_execute_sql = await datasette.permission_allowed( request.actor, "execute-sql", database ) + + show_hide_hidden = "" + if metadata.get("hide_sql"): + if bool(params.get("_show_sql")): + show_hide_link = path_with_removed_args(request, {"_show_sql"}) + show_hide_text = "hide" + show_hide_hidden = '' + else: + show_hide_link = path_with_added_args(request, {"_show_sql": 1}) + show_hide_text = "show" + else: + if bool(params.get("_hide_sql")): + show_hide_link = path_with_removed_args(request, {"_hide_sql"}) + show_hide_text = "show" + show_hide_hidden = '' + else: + show_hide_link = path_with_added_args(request, {"_hide_sql": 1}) + show_hide_text = "hide" + hide_sql = show_hide_text == "show" + r = Response.html( await datasette.render_template( template, @@ -475,9 +495,9 @@ async def fetch_data_for_csv(request, _next=None): canned_write=False, db_is_immutable=not db.is_mutable, error=query_error, - hide_sql=None, # TODO - show_hide_link="todo", - show_hide_text="todo", + hide_sql=hide_sql, + show_hide_link=datasette.urls.path(show_hide_link), + show_hide_text=show_hide_text, editable=True, # TODO allow_execute_sql=allow_execute_sql, tables=await get_tables(datasette, request, db), @@ -491,7 +511,11 @@ async def fetch_data_for_csv(request, _next=None): else {}, columns=columns, renderers=renderers, - url_csv="todo", + url_csv=datasette.urls.path( + path_with_format( + request=request, format="csv" + ) # , extra_qs=url_csv_args) + ), metadata=metadata, database_color=lambda _: "#ff0000", ), From ea24c9c46ffc9eac55011e88671fd7bb329da4bf Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 7 Aug 2023 16:58:08 -0700 Subject: [PATCH 17/23] Fixed a test and implemented show_hide_hidden --- datasette/views/database.py | 4 ++++ tests/test_api.py | 1 + 2 files changed, 5 insertions(+) diff --git a/datasette/views/database.py b/datasette/views/database.py index 15cbcc269a..27ce9ad728 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -209,6 +209,9 @@ class QueryContext: columns: list = field(metadata={"help": "List of column names"}) renderers: dict = field(metadata={"help": "Dictionary of renderer name to URL"}) url_csv: str = field(metadata={"help": "URL for CSV export"}) + show_hide_hidden: str = field( + metadata={"help": "Hidden input field for the _show_sql parameter"} + ) metadata: dict = field(metadata={"help": "Metadata about the query/database"}) database_color: Callable = field( metadata={"help": "Function that returns a color for a given database name"} @@ -516,6 +519,7 @@ async def fetch_data_for_csv(request, _next=None): request=request, format="csv" ) # , extra_qs=url_csv_args) ), + show_hide_hidden=markupsafe.Markup(show_hide_hidden), metadata=metadata, database_color=lambda _: "#ff0000", ), diff --git a/tests/test_api.py b/tests/test_api.py index 9d5966ba6c..28415a0bcd 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -650,6 +650,7 @@ async def test_custom_sql(ds_client): {"content": "RENDER_CELL_ASYNC"}, ], "columns": ["content"], + "ok": True, "truncated": False, } From 8fbcd7f85158c02d040cfa9fc47ea25a7fe34da1 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 7 Aug 2023 18:18:55 -0700 Subject: [PATCH 18/23] Implemented alternate_url_json --- datasette/views/database.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/datasette/views/database.py b/datasette/views/database.py index 27ce9ad728..cc5e8ba12a 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -143,6 +143,10 @@ async def database_actions(): return response assert format_ == "html" + alternate_url_json = datasette.absolute_url( + request, + datasette.urls.path(path_with_format(request=request, format="json")), + ) context = { **json_data, "database_actions": database_actions, @@ -154,10 +158,16 @@ async def database_actions(): and not db.is_memory, "attached_databases": attached_databases, "database_color": lambda _: "#ff0000", + "alternate_url_json": alternate_url_json, } templates = (f"database-{to_css_class(database)}.html", "database.html") return Response.html( - await datasette.render_template(templates, context, request=request) + await datasette.render_template(templates, context, request=request), + headers={ + "Link": '{}; rel="alternate"; type="application/json+datasette"'.format( + alternate_url_json + ) + }, ) @@ -219,6 +229,9 @@ class QueryContext: table_columns: dict = field( metadata={"help": "Dictionary of table name to list of column names"} ) + alternate_url_json: str = field( + metadata={"help": "URL for alternate JSON version of this page"} + ) async def get_tables(datasette, request, db): @@ -522,6 +535,7 @@ async def fetch_data_for_csv(request, _next=None): show_hide_hidden=markupsafe.Markup(show_hide_hidden), metadata=metadata, database_color=lambda _: "#ff0000", + alternate_url_json=alternate_url_json, ), request=request, ), From 7532feb424b1dce614351e21b2265c04f9669fe2 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 7 Aug 2023 18:27:30 -0700 Subject: [PATCH 19/23] select_templates for templates considered comment --- datasette/views/database.py | 7 ++++++- tests/test_html.py | 8 +++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/datasette/views/database.py b/datasette/views/database.py index cc5e8ba12a..c5b59dd274 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -147,6 +147,8 @@ async def database_actions(): request, datasette.urls.path(path_with_format(request=request, format="json")), ) + templates = (f"database-{to_css_class(database)}.html", "database.html") + template = datasette.jinja_env.select_template(templates) context = { **json_data, "database_actions": database_actions, @@ -159,8 +161,11 @@ async def database_actions(): "attached_databases": attached_databases, "database_color": lambda _: "#ff0000", "alternate_url_json": alternate_url_json, + "select_templates": [ + f"{'*' if template_name == template.name else ''}{template_name}" + for template_name in templates + ], } - templates = (f"database-{to_css_class(database)}.html", "database.html") return Response.html( await datasette.render_template(templates, context, request=request), headers={ diff --git a/tests/test_html.py b/tests/test_html.py index eadbd720a5..6c3860d73a 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -248,6 +248,9 @@ async def test_css_classes_on_body(ds_client, path, expected_classes): assert classes == expected_classes +templates_considered_re = re.compile(r"") + + @pytest.mark.asyncio @pytest.mark.parametrize( "path,expected_considered", @@ -271,7 +274,10 @@ async def test_css_classes_on_body(ds_client, path, expected_classes): async def test_templates_considered(ds_client, path, expected_considered): response = await ds_client.get(path) assert response.status_code == 200 - assert f"" in response.text + match = templates_considered_re.search(response.text) + assert match, "No templates considered comment found" + actual_considered = match.group(1) + assert actual_considered == expected_considered @pytest.mark.asyncio From b45965b338b4e7e6f453f21cc5224532ff84d1df Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 7 Aug 2023 18:33:32 -0700 Subject: [PATCH 20/23] Set view_name correctly in a few more places --- datasette/views/database.py | 11 ++++++++--- tests/test_plugins.py | 5 ++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/datasette/views/database.py b/datasette/views/database.py index c5b59dd274..6d6821bddc 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -167,7 +167,12 @@ async def database_actions(): ], } return Response.html( - await datasette.render_template(templates, context, request=request), + await datasette.render_template( + templates, + context, + request=request, + view_name="database", + ), headers={ "Link": '{}; rel="alternate"; type="application/json+datasette"'.format( alternate_url_json @@ -470,8 +475,7 @@ async def fetch_data_for_csv(request, _next=None): database=database, table=data.get("table"), request=request, - # TODO: Fix this - view_name=None, + view_name="database", ) it_can_render = await await_me_maybe(it_can_render) if it_can_render: @@ -543,6 +547,7 @@ async def fetch_data_for_csv(request, _next=None): alternate_url_json=alternate_url_json, ), request=request, + view_name="database", ), headers=headers, ) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 6971bbf739..28fe720fa5 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -121,9 +121,8 @@ async def test_hook_extra_css_urls(ds_client, path, expected_decoded_object): ][0]["href"] # This link has a base64-encoded JSON blob in it encoded = special_href.split("/")[3] - assert expected_decoded_object == json.loads( - base64.b64decode(encoded).decode("utf8") - ) + actual_decoded_object = json.loads(base64.b64decode(encoded).decode("utf8")) + assert expected_decoded_object == actual_decoded_object @pytest.mark.asyncio From 05050321cccd0ced3303880c847c394ccbe66e19 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 7 Aug 2023 18:36:15 -0700 Subject: [PATCH 21/23] More cors headers, plus deleted some dead code --- datasette/views/database.py | 29 ++--------------------------- 1 file changed, 2 insertions(+), 27 deletions(-) diff --git a/datasette/views/database.py b/datasette/views/database.py index 6d6821bddc..3e203f988c 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -551,35 +551,10 @@ async def fetch_data_for_csv(request, _next=None): ), headers=headers, ) - - # dict( - # data, - # append_querystring=append_querystring, - # path_with_replaced_args=path_with_replaced_args, - # fix_path=datasette.urls.path, - # settings=datasette.settings_dict(), - # # TODO: review up all of these hacks: - # alternate_url_json=alternate_url_json, - # datasette_allow_facet=( - # "true" if datasette.setting("allow_facet") else "false" - # ), - # is_sortable=any(c["sortable"] for c in data["display_columns"]), - # allow_execute_sql=await datasette.permission_allowed( - # request.actor, "execute-sql", resolved.db.name - # ), - # query_ms=1.2, - # select_templates=[ - # f"{'*' if template_name == template.name else ''}{template_name}" - # for template_name in templates - # ], - # ), - # request=request, - # view_name="table", - # ), - # headers=headers, - # ) else: assert False, "Invalid format: {}".format(format_) + if datasette.cors: + add_cors_headers(r.headers) return r From 5382ec64462de590244b4bd488b9b2f9b6b0378b Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 7 Aug 2023 18:37:22 -0700 Subject: [PATCH 22/23] Fixed broken test --- tests/test_table_api.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_table_api.py b/tests/test_table_api.py index cd664ffbff..46d1c9b8cc 100644 --- a/tests/test_table_api.py +++ b/tests/test_table_api.py @@ -700,7 +700,6 @@ async def test_max_returned_rows(ds_client): "/fixtures.json?sql=select+content+from+no_primary_key" ) data = response.json() - assert {"sql": "select content from no_primary_key", "params": {}} == data["query"] assert data["truncated"] assert 100 == len(data["rows"]) From 8b1dea3c0994d5875e372965eb5ca388ea08fe6a Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 7 Aug 2023 18:41:06 -0700 Subject: [PATCH 23/23] CSV links have _size=max on them --- datasette/views/database.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/datasette/views/database.py b/datasette/views/database.py index 3e203f988c..77f3f5b04c 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -538,8 +538,8 @@ async def fetch_data_for_csv(request, _next=None): renderers=renderers, url_csv=datasette.urls.path( path_with_format( - request=request, format="csv" - ) # , extra_qs=url_csv_args) + request=request, format="csv", extra_qs={"_size": "max"} + ) ), show_hide_hidden=markupsafe.Markup(show_hide_hidden), metadata=metadata,