From dfd4ad558b74defbe23b01196260b087f9a56813 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 25 Feb 2024 12:54:16 -0800 Subject: [PATCH 001/334] New design for table and database action menus Closes #2281 --- datasette/static/app.css | 45 +++++++++++++++++++++-------- datasette/templates/database.html | 47 +++++++++++++++++------------- datasette/templates/patterns.html | 48 +++++++++++++++++++------------ datasette/templates/table.html | 46 ++++++++++++++++------------- 4 files changed, 116 insertions(+), 70 deletions(-) diff --git a/datasette/static/app.css b/datasette/static/app.css index 80dfc677..5453a3d4 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -163,28 +163,22 @@ h6, } .page-header { - display: flex; - align-items: center; padding-left: 10px; border-left: 10px solid #666; margin-bottom: 0.75rem; margin-top: 1rem; } .page-header h1 { - display: inline; margin: 0; font-size: 2rem; padding-right: 0.2em; } -.page-header details { - display: inline-flex; -} -.page-header details > summary { + +.page-action-menu details > summary { list-style: none; - display: inline-flex; cursor: pointer; } -.page-header details > summary::-webkit-details-marker { +.page-action-menu details > summary::-webkit-details-marker { display: none; } @@ -364,13 +358,40 @@ details .nav-menu-inner { } /* Table/database actions menu */ -.page-header { +.page-action-menu { position: relative; } +.actions-menu-links { + display: inline; +} .actions-menu-links .dropdown-menu { position: absolute; top: calc(100% + 10px); - left: -10px; + left: 0; +} +.page-action-menu .icon-text { + display: inline-flex; + align-items: center; + border-radius: .25rem; + padding: 5px 12px 3px 7px; + color: #fff; + font-weight: 400; + font-size: 0.8em; + background: linear-gradient(180deg, #007bff 0%, #4E79C7 100%); + border-color: #007bff; +} +.page-action-menu .icon-text span { + /* Nudge text up a bit */ + position: relative; + top: -2px; +} +.page-action-menu .icon-text:hover { + cursor: pointer; +} +.page-action-menu .icon { + width: 18px; + height: 18px; + margin-right: 4px; } /* Components ============================================================== */ @@ -536,7 +557,7 @@ form input[type=submit], form button[type=button] { form input[type=submit] { color: #fff; - background-color: #007bff; + background: linear-gradient(180deg, #007bff 0%, #4E79C7 100%); border-color: #007bff; -webkit-appearance: button; } diff --git a/datasette/templates/database.html b/datasette/templates/database.html index ee4dd705..6c0cebcd 100644 --- a/datasette/templates/database.html +++ b/datasette/templates/database.html @@ -12,27 +12,34 @@ {% block content %} +{% endif %} + {{ top_database() }} diff --git a/datasette/templates/patterns.html b/datasette/templates/patterns.html index 9905df2c..33db3d1a 100644 --- a/datasette/templates/patterns.html +++ b/datasette/templates/patterns.html @@ -96,18 +96,24 @@
+
@@ -158,18 +164,24 @@
+
diff --git a/datasette/templates/table.html b/datasette/templates/table.html index 5aee6319..0c2be672 100644 --- a/datasette/templates/table.html +++ b/datasette/templates/table.html @@ -23,27 +23,33 @@ {% block content %} +{% set links = table_actions() %}{% if links %} +
+ +
+{% endif %} {{ top_table() }} From c863443ea11ea9b9f0e264ab38f103691509fab9 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 27 Feb 2024 13:24:47 -0800 Subject: [PATCH 002/334] Documentation for derive_named_parameters() Closes #2284 Refs https://github.com/simonw/datasette-write/issues/7#issuecomment-1967593883 --- datasette/utils/__init__.py | 12 ++++++++++-- docs/internals.rst | 9 +++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index e3637f7a..4adfcc8d 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -18,12 +18,14 @@ import time import types import secrets import shutil -from typing import Iterable, Tuple +from typing import Iterable, List, Tuple import urllib import yaml from .shutil_backport import copytree from .sqlite import sqlite3, supports_table_xinfo +if typing.TYPE_CHECKING: + from datasette.database import Database # From https://www.sqlite.org/lang_keywords.html reserved_words = set( @@ -1130,7 +1132,13 @@ class StartupError(Exception): _re_named_parameter = re.compile(":([a-zA-Z0-9_]+)") -async def derive_named_parameters(db, sql): +@documented +async def derive_named_parameters(db: "Database", sql: str) -> List[str]: + """ + Given a SQL statement, return a list of named parameters that are used in the statement + + e.g. for ``select * from foo where id=:id`` this would return ``["id"]`` + """ explain = "explain {}".format(sql.strip().rstrip(";")) possible_params = _re_named_parameter.findall(sql) try: diff --git a/docs/internals.rst b/docs/internals.rst index 6ca62423..e9f8b391 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -1256,6 +1256,15 @@ Utility function for calling ``await`` on a return value if it is awaitable, oth .. autofunction:: datasette.utils.await_me_maybe +.. _internals_utils_derive_named_parameters: + +derive_named_parameters(db, sql) +-------------------------------- + +Derive the list of named parameters referenced in a SQL query, using an ``explain`` query executed against the provided database. + +.. autofunction:: datasette.utils.derive_named_parameters + .. _internals_tilde_encoding: Tilde encoding From f99c2f5f8cd1550442f69def0b27f8ec799d6bd8 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 27 Feb 2024 16:07:37 -0800 Subject: [PATCH 003/334] ?column_notcontains= table filter, closes #2287 --- datasette/filters.py | 7 +++++++ docs/json_api.rst | 3 +++ tests/test_filters.py | 5 +++++ 3 files changed, 15 insertions(+) diff --git a/datasette/filters.py b/datasette/filters.py index 4d9580d8..585d4865 100644 --- a/datasette/filters.py +++ b/datasette/filters.py @@ -281,6 +281,13 @@ class Filters: '{c} contains "{v}"', format="%{}%", ), + TemplatedFilter( + "notcontains", + "does not contain", + '"{c}" not like :{p}', + '{c} does not contain "{v}"', + format="%{}%", + ), TemplatedFilter( "endswith", "ends with", diff --git a/docs/json_api.rst b/docs/json_api.rst index 366f74b2..4b39a048 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -237,6 +237,9 @@ You can filter the data returned by the table based on column values using a que ``?column__contains=value`` Rows where the string column contains the specified value (``column like "%value%"`` in SQL). +``?column__notcontains=value`` + Rows where the string column does not contain the specified value (``column not like "%value%"`` in SQL). + ``?column__endswith=value`` Rows where the string column ends with the specified value (``column like "%value"`` in SQL). diff --git a/tests/test_filters.py b/tests/test_filters.py index 5b2e9636..a3fada98 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -7,6 +7,11 @@ import pytest "args,expected_where,expected_params", [ ((("name_english__contains", "foo"),), ['"name_english" like :p0'], ["%foo%"]), + ( + (("name_english__notcontains", "foo"),), + ['"name_english" not like :p0'], + ["%foo%"], + ), ( (("foo", "bar"), ("bar__contains", "baz")), ['"bar" like :p0', '"foo" = :p1'], From 6ec0081f5d827a71c96972e634a2e887edaf5392 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 27 Feb 2024 21:55:16 -0800 Subject: [PATCH 004/334] `query_actions` plugin hook * New query_actions plugin hook, closes #2283 --- datasette/hookspecs.py | 5 ++++ datasette/templates/query.html | 27 ++++++++++++++++++ datasette/views/database.py | 23 +++++++++++++++ docs/plugin_hooks.rst | 52 ++++++++++++++++++++++++++++++++++ tests/fixtures.py | 1 + tests/plugins/my_plugin.py | 18 ++++++++++++ tests/test_plugins.py | 25 ++++++++++++++++ 7 files changed, 151 insertions(+) diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index b473f398..1141ca75 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -145,6 +145,11 @@ def table_actions(datasette, actor, database, table, request): """Links for the table actions menu""" +@hookspec +def query_actions(datasette, actor, database, query_name, request, sql, params): + """Links for the query and canned query actions menu""" + + @hookspec def database_actions(datasette, actor, database, request): """Links for the database actions menu""" diff --git a/datasette/templates/query.html b/datasette/templates/query.html index 1815e592..b5991772 100644 --- a/datasette/templates/query.html +++ b/datasette/templates/query.html @@ -29,6 +29,33 @@ {% endif %}

{{ metadata.title or database }}{% if canned_query and not metadata.title %}: {{ canned_query }}{% endif %}{% if private %} 🔒{% endif %}

+{% set links = query_actions() %}{% if links %} +
+ +
+{% endif %} + {% if canned_query %}{{ top_canned_query() }}{% else %}{{ top_query() }}{% endif %} diff --git a/datasette/views/database.py b/datasette/views/database.py index 56fc6f8c..851ae21f 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -9,6 +9,7 @@ import os import re import sqlite_utils import textwrap +from typing import List from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent from datasette.database import QueryInterrupted @@ -256,6 +257,11 @@ class QueryContext: top_canned_query: callable = field( metadata={"help": "Callable to render the top_canned_query slot"} ) + query_actions: callable = field( + metadata={ + "help": "Callable returning a list of links for the query action menu" + } + ) async def get_tables(datasette, request, db): @@ -694,6 +700,22 @@ class QueryView(View): ) ) + async def query_actions(): + query_actions = [] + for hook in pm.hook.query_actions( + datasette=datasette, + actor=request.actor, + database=database, + query_name=canned_query["name"] if canned_query else None, + request=request, + sql=sql, + params=params, + ): + extra_links = await await_me_maybe(hook) + if extra_links: + query_actions.extend(extra_links) + return query_actions + r = Response.html( await datasette.render_template( template, @@ -749,6 +771,7 @@ class QueryView(View): database=database, query_name=canned_query["name"] if canned_query else None, ), + query_actions=query_actions, ), request=request, view_name="database", diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 5372ea5e..3ada41e2 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1520,6 +1520,58 @@ This example adds a new table action if the signed in user is ``"root"``: Example: `datasette-graphql `_ +.. _plugin_hook_query_actions: + +query_actions(datasette, actor, database, query_name, request, sql, params) +--------------------------------------------------------------------------- + +``datasette`` - :ref:`internals_datasette` + You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries. + +``actor`` - dictionary or None + The currently authenticated :ref:`actor `. + +``database`` - string + The name of the database. + +``query_name`` - string or None + The name of the canned query, or ``None`` if this is an arbitrary SQL query. + +``request`` - :ref:`internals_request` + The current HTTP request. + +``sql`` - string + The SQL query being executed + +``params`` - dictionary + The parameters passed to the SQL query, if any. + +This hook is similar to :ref:`plugin_hook_table_actions` but populates an actions menu on the canned query and arbitrary SQL query pages. + +This example adds a new query action linking to a page for explaining a query: + +.. code-block:: python + + from datasette import hookimpl + import urllib + + + @hookimpl + def query_actions(datasette, database, sql): + return [ + { + "href": datasette.urls.database(database) + + "/-/explain?" + + urllib.parse.urlencode( + { + "sql": sql, + } + ), + "label": "Explain this query", + }, + ] + + .. _plugin_hook_database_actions: database_actions(datasette, actor, database, request) diff --git a/tests/fixtures.py b/tests/fixtures.py index bb979d79..c3c77fce 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -46,6 +46,7 @@ EXPECTED_PLUGINS = [ "permission_allowed", "prepare_connection", "prepare_jinja2_environment", + "query_actions", "register_facet_classes", "register_magic_parameters", "register_permissions", diff --git a/tests/plugins/my_plugin.py b/tests/plugins/my_plugin.py index 9d1f86bc..f96441cb 100644 --- a/tests/plugins/my_plugin.py +++ b/tests/plugins/my_plugin.py @@ -7,6 +7,7 @@ from datasette.utils.asgi import asgi_send_json, Response import base64 import pint import json +import urllib ureg = pint.UnitRegistry() @@ -390,6 +391,23 @@ def table_actions(datasette, database, table, actor): ] +@hookimpl +def query_actions(datasette, database, query_name, sql): + args = { + "sql": sql, + } + if query_name: + args["query_name"] = query_name + return [ + { + "href": datasette.urls.database(database) + + "/-/explain?" + + urllib.parse.urlencode(args), + "label": "Explain this query", + }, + ] + + @hookimpl def database_actions(datasette, database, actor, request): if actor: diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 40d01c71..86208371 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -945,6 +945,31 @@ async def test_hook_table_actions(ds_client, table_or_view): ] +@pytest.mark.asyncio +@pytest.mark.parametrize( + "path,expected_url", + ( + ("/fixtures?sql=select+1", "/fixtures/-/explain?sql=select+1"), + ( + "/fixtures/pragma_cache_size", + "/fixtures/-/explain?sql=PRAGMA+cache_size%3B&query_name=pragma_cache_size", + ), + ), +) +async def test_hook_query_actions(ds_client, path, expected_url): + def get_table_actions_links(html): + soup = Soup(html, "html.parser") + details = soup.find("details", {"class": "actions-menu-links"}) + if details is None: + return [] + return [{"label": a.text, "href": a["href"]} for a in details.select("a")] + + response = await ds_client.get(path) + assert response.status_code == 200 + links = get_table_actions_links(response.text) + assert links == [{"label": "Explain this query", "href": expected_url}] + + @pytest.mark.asyncio async def test_hook_database_actions(ds_client): def get_table_actions_links(html): From 57c1ce0e8b17deae33eb9033383c6ecfc041104a Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 29 Feb 2024 14:25:50 -0800 Subject: [PATCH 005/334] Reset column menu on every click, closes #2289 --- datasette/static/table.js | 1 + 1 file changed, 1 insertion(+) diff --git a/datasette/static/table.js b/datasette/static/table.js index 4f81b2e5..909eebf3 100644 --- a/datasette/static/table.js +++ b/datasette/static/table.js @@ -88,6 +88,7 @@ const initDatasetteTable = function (manager) { function onTableHeaderClick(ev) { ev.preventDefault(); ev.stopPropagation(); + menu.innerHTML = DROPDOWN_HTML; var th = ev.target; while (th.nodeName != "TH") { th = th.parentNode; From 86335dc722d31dbf44c6d4bbffd7c5d2d11b1290 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 29 Feb 2024 14:35:28 -0800 Subject: [PATCH 006/334] Release 1.0a12 Refs #2281, #2283, #2287, #2289 --- datasette/version.py | 2 +- docs/changelog.rst | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/datasette/version.py b/datasette/version.py index c27e1e06..b2cd10bf 100644 --- a/datasette/version.py +++ b/datasette/version.py @@ -1,2 +1,2 @@ -__version__ = "1.0a11" +__version__ = "1.0a12" __version_info__ = tuple(__version__.split(".")) diff --git a/docs/changelog.rst b/docs/changelog.rst index 492b2921..240df141 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,16 @@ Changelog ========= +.. _v1_0_a12: + +1.0a12 (2024-02-29) +------------------- + +- New :ref:`query_actions() ` plugin hook, similar to :ref:`table_actions() ` and :ref:`database_actions() `. Can be used to add a menu of actions to the canned query or arbitrary SQL query page. (:issue:`2283`) +- New design for the button that opens the query, table and database actions menu. (:issue:`2281`) +- "does not contain" table filter for finding rows that do not contain a string. (:issue:`2287`) +- Fixed a bug in the :ref:`javascript_plugins_makeColumnActions` JavaScript plugin mechanism where the column action menu was not fully reset in between each interaction. (:issue:`2289`) + .. _v1_0_a11: 1.0a11 (2024-02-19) From 5de6797d4a4333653568dd3db24c4df7b7502ba3 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 5 Mar 2024 18:06:38 -0800 Subject: [PATCH 007/334] Better demo plugin for query_actions, refs #2293 --- docs/plugin_hooks.rst | 4 ++-- tests/plugins/my_plugin.py | 8 ++++++-- tests/test_plugins.py | 4 ++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 3ada41e2..62062bfd 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1561,10 +1561,10 @@ This example adds a new query action linking to a page for explaining a query: return [ { "href": datasette.urls.database(database) - + "/-/explain?" + + "?" + urllib.parse.urlencode( { - "sql": sql, + "sql": "explain " + sql, } ), "label": "Explain this query", diff --git a/tests/plugins/my_plugin.py b/tests/plugins/my_plugin.py index f96441cb..650cc57d 100644 --- a/tests/plugins/my_plugin.py +++ b/tests/plugins/my_plugin.py @@ -401,8 +401,12 @@ def query_actions(datasette, database, query_name, sql): return [ { "href": datasette.urls.database(database) - + "/-/explain?" - + urllib.parse.urlencode(args), + + "?" + + urllib.parse.urlencode( + { + "sql": "explain " + sql, + } + ), "label": "Explain this query", }, ] diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 86208371..4620af51 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -949,10 +949,10 @@ async def test_hook_table_actions(ds_client, table_or_view): @pytest.mark.parametrize( "path,expected_url", ( - ("/fixtures?sql=select+1", "/fixtures/-/explain?sql=select+1"), + ("/fixtures?sql=select+1", "/fixtures?sql=explain+select+1"), ( "/fixtures/pragma_cache_size", - "/fixtures/-/explain?sql=PRAGMA+cache_size%3B&query_name=pragma_cache_size", + "/fixtures?sql=explain+PRAGMA+cache_size%3B", ), ), ) From 4d24bf6b34c0d9ed6e6831f68f625bee7f447b85 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 5 Mar 2024 18:14:55 -0800 Subject: [PATCH 008/334] Don't explain an explain even in the demo, refs #2293 --- docs/plugin_hooks.rst | 6 ++++-- tests/plugins/my_plugin.py | 8 +++----- tests/test_plugins.py | 7 ++++++- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 62062bfd..ecc8f058 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1557,7 +1557,10 @@ This example adds a new query action linking to a page for explaining a query: @hookimpl - def query_actions(datasette, database, sql): + def query_actions(datasette, database, query_name, sql): + # Don't explain an explain + if sql.lower().startswith("explain"): + return return [ { "href": datasette.urls.database(database) @@ -1571,7 +1574,6 @@ This example adds a new query action linking to a page for explaining a query: }, ] - .. _plugin_hook_database_actions: database_actions(datasette, actor, database, request) diff --git a/tests/plugins/my_plugin.py b/tests/plugins/my_plugin.py index 650cc57d..01324213 100644 --- a/tests/plugins/my_plugin.py +++ b/tests/plugins/my_plugin.py @@ -393,11 +393,9 @@ def table_actions(datasette, database, table, actor): @hookimpl def query_actions(datasette, database, query_name, sql): - args = { - "sql": sql, - } - if query_name: - args["query_name"] = query_name + # Don't explain an explain + if sql.lower().startswith("explain"): + return return [ { "href": datasette.urls.database(database) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 4620af51..d1da16fa 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -954,6 +954,8 @@ async def test_hook_table_actions(ds_client, table_or_view): "/fixtures/pragma_cache_size", "/fixtures?sql=explain+PRAGMA+cache_size%3B", ), + # Don't attempt to explain an explain + ("/fixtures?sql=explain+select+1", None), ), ) async def test_hook_query_actions(ds_client, path, expected_url): @@ -967,7 +969,10 @@ async def test_hook_query_actions(ds_client, path, expected_url): response = await ds_client.get(path) assert response.status_code == 200 links = get_table_actions_links(response.text) - assert links == [{"label": "Explain this query", "href": expected_url}] + if expected_url is None: + assert links == [] + else: + assert links == [{"label": "Explain this query", "href": expected_url}] @pytest.mark.asyncio From c6e8a4a76cedb7d178bf20a37fc331f8a5cfb6b9 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 5 Mar 2024 19:34:57 -0800 Subject: [PATCH 009/334] margin-bottom on .page-action-menu, refs #2286 --- datasette/static/app.css | 1 + 1 file changed, 1 insertion(+) diff --git a/datasette/static/app.css b/datasette/static/app.css index 5453a3d4..b3223abf 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -360,6 +360,7 @@ details .nav-menu-inner { /* Table/database actions menu */ .page-action-menu { position: relative; + margin-bottom: 0.5em; } .actions-menu-links { display: inline; From 090dff542bee7f716088835b3cc633e9e04b985d Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 6 Mar 2024 22:54:06 -0500 Subject: [PATCH 010/334] Action menu descriptions * Refactor tests to extract get_actions_links() helper * Table, database and query action menu items now support optional descriptions Closes #2294 --- datasette/static/app.css | 7 ++++ datasette/templates/database.html | 6 ++- datasette/templates/query.html | 6 ++- datasette/templates/table.html | 6 ++- docs/plugin_hooks.rst | 4 +- tests/plugins/my_plugin.py | 1 + tests/test_plugins.py | 64 ++++++++++++++++--------------- 7 files changed, 59 insertions(+), 35 deletions(-) diff --git a/datasette/static/app.css b/datasette/static/app.css index b3223abf..e4a0ee10 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -841,6 +841,13 @@ svg.dropdown-menu-icon { .dropdown-menu a:hover { background-color: #eee; } +.dropdown-menu .dropdown-description { + margin: 0; + color: #666; + font-size: 0.8em; + max-width: 80vw; + white-space: normal; +} .dropdown-menu .hook { display: block; position: absolute; diff --git a/datasette/templates/database.html b/datasette/templates/database.html index 6c0cebcd..02e6fb3d 100644 --- a/datasette/templates/database.html +++ b/datasette/templates/database.html @@ -31,7 +31,11 @@ {% if links %} {% endif %} diff --git a/datasette/templates/query.html b/datasette/templates/query.html index b5991772..09a29118 100644 --- a/datasette/templates/query.html +++ b/datasette/templates/query.html @@ -47,7 +47,11 @@ {% if links %} {% endif %} diff --git a/datasette/templates/table.html b/datasette/templates/table.html index 0c2be672..1d328366 100644 --- a/datasette/templates/table.html +++ b/datasette/templates/table.html @@ -42,7 +42,11 @@ {% if links %} {% endif %} diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index ecc8f058..4f77d75c 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1493,7 +1493,7 @@ table_actions(datasette, actor, database, table, request) ``request`` - :ref:`internals_request` or None The current HTTP request. This can be ``None`` if the request object is not available. -This hook allows table actions to be displayed in a menu accessed via an action icon at the top of the table page. It should return a list of ``{"href": "...", "label": "..."}`` menu items. +This hook allows table actions to be displayed in a menu accessed via an action icon at the top of the table page. It should return a list of ``{"href": "...", "label": "..."}`` menu items, with optional ``"description": "..."`` keys describing each action in more detail. It can alternatively return an ``async def`` awaitable function which returns a list of menu items. @@ -1515,6 +1515,7 @@ This example adds a new table action if the signed in user is ``"root"``: ) ), "label": "Edit schema for this table", + "description": "Add, remove, rename or alter columns for this table.", } ] @@ -1571,6 +1572,7 @@ This example adds a new query action linking to a page for explaining a query: } ), "label": "Explain this query", + "description": "Get a summary of how SQLite executes the query", }, ] diff --git a/tests/plugins/my_plugin.py b/tests/plugins/my_plugin.py index 01324213..5f000537 100644 --- a/tests/plugins/my_plugin.py +++ b/tests/plugins/my_plugin.py @@ -406,6 +406,7 @@ def query_actions(datasette, database, query_name, sql): } ), "label": "Explain this query", + "description": "Runs a SQLite explain", }, ] diff --git a/tests/test_plugins.py b/tests/test_plugins.py index d1da16fa..9f69e4fa 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -925,26 +925,36 @@ async def test_hook_menu_links(ds_client): @pytest.mark.asyncio @pytest.mark.parametrize("table_or_view", ["facetable", "simple_view"]) async def test_hook_table_actions(ds_client, table_or_view): - def get_table_actions_links(html): - soup = Soup(html, "html.parser") - details = soup.find("details", {"class": "actions-menu-links"}) - if details is None: - return [] - return [{"label": a.text, "href": a["href"]} for a in details.select("a")] - response = await ds_client.get(f"/fixtures/{table_or_view}") - assert get_table_actions_links(response.text) == [] + assert get_actions_links(response.text) == [] response_2 = await ds_client.get(f"/fixtures/{table_or_view}?_bot=1&_hello=BOB") assert sorted( - get_table_actions_links(response_2.text), key=lambda link: link["label"] + get_actions_links(response_2.text), key=lambda link: link["label"] ) == [ - {"label": "Database: fixtures", "href": "/"}, - {"label": "From async BOB", "href": "/"}, - {"label": f"Table: {table_or_view}", "href": "/"}, + {"label": "Database: fixtures", "href": "/", "description": None}, + {"label": "From async BOB", "href": "/", "description": None}, + {"label": f"Table: {table_or_view}", "href": "/", "description": None}, ] +def get_actions_links(html): + soup = Soup(html, "html.parser") + details = soup.find("details", {"class": "actions-menu-links"}) + if details is None: + return [] + links = [] + for a_el in details.select("a"): + description = None + if a_el.find("p") is not None: + description = a_el.find("p").text.strip() + a_el.find("p").extract() + label = a_el.text.strip() + href = a_el["href"] + links.append({"label": label, "href": href, "description": description}) + return links + + @pytest.mark.asyncio @pytest.mark.parametrize( "path,expected_url", @@ -959,37 +969,29 @@ async def test_hook_table_actions(ds_client, table_or_view): ), ) async def test_hook_query_actions(ds_client, path, expected_url): - def get_table_actions_links(html): - soup = Soup(html, "html.parser") - details = soup.find("details", {"class": "actions-menu-links"}) - if details is None: - return [] - return [{"label": a.text, "href": a["href"]} for a in details.select("a")] - response = await ds_client.get(path) assert response.status_code == 200 - links = get_table_actions_links(response.text) + links = get_actions_links(response.text) if expected_url is None: assert links == [] else: - assert links == [{"label": "Explain this query", "href": expected_url}] + assert links == [ + { + "label": "Explain this query", + "href": expected_url, + "description": "Runs a SQLite explain", + } + ] @pytest.mark.asyncio async def test_hook_database_actions(ds_client): - def get_table_actions_links(html): - soup = Soup(html, "html.parser") - details = soup.find("details", {"class": "actions-menu-links"}) - if details is None: - return [] - return [{"label": a.text, "href": a["href"]} for a in details.select("a")] - response = await ds_client.get("/fixtures") - assert get_table_actions_links(response.text) == [] + assert get_actions_links(response.text) == [] response_2 = await ds_client.get("/fixtures?_bot=1&_hello=BOB") - assert get_table_actions_links(response_2.text) == [ - {"label": "Database: fixtures - BOB", "href": "/"}, + assert get_actions_links(response_2.text) == [ + {"label": "Database: fixtures - BOB", "href": "/", "description": None}, ] From a395256c8c10f19bb006e0fce8f5eddd2b645757 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 7 Mar 2024 00:03:20 -0500 Subject: [PATCH 011/334] Allow-list select * from pragma_table_list() Refs https://github.com/simonw/datasette/issues/2104#issuecomment-1982352475 --- datasette/utils/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index 4adfcc8d..9c0bbfa3 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -246,6 +246,7 @@ allowed_pragmas = ( "schema_version", "table_info", "table_xinfo", + "table_list", ) disallawed_sql_res = [ ( From 7818e8b9d15a3d50c16f080dc7fe4b5e8eb3d241 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 7 Mar 2024 00:03:42 -0500 Subject: [PATCH 012/334] Hide tables starting with an _, refs #2104 --- datasette/database.py | 1 + docs/pages.rst | 15 +++++++++++++++ tests/test_api.py | 15 +++++++++++++++ 3 files changed, 31 insertions(+) diff --git a/datasette/database.py b/datasette/database.py index 4e590d3a..ffe94ea7 100644 --- a/datasette/database.py +++ b/datasette/database.py @@ -469,6 +469,7 @@ class Database: and ( sql like '%VIRTUAL TABLE%USING FTS%' ) or name in ('sqlite_stat1', 'sqlite_stat2', 'sqlite_stat3', 'sqlite_stat4') + or name like '\\_%' escape '\\' """ ) ).rows diff --git a/docs/pages.rst b/docs/pages.rst index 2ce05428..1c3e2c1e 100644 --- a/docs/pages.rst +++ b/docs/pages.rst @@ -40,6 +40,21 @@ The JSON version of this page provides programmatic access to the underlying dat * `fivethirtyeight.datasettes.com/fivethirtyeight.json `_ * `global-power-plants.datasettes.com/global-power-plants.json `_ +.. _DatabaseView_hidden: + +Hidden tables +------------- + +Some tables listed on the database page are treated as hidden. Hidden tables are not completely invisible - they can be accessed through the "hidden tables" link at the bottom of the page. They are hidden because they represent low-level implementation details which are generally not useful to end-users of Datasette. + +The following tables are hidden by default: + +- Any table with a name that starts with an underscore - this is a Datasette convention to help plugins easily hide their own internal tables. +- Tables that have been configured as ``"hidden": true`` using :ref:`metadata_hiding_tables`. +- ``*_fts`` tables that implement SQLite full-text search indexes. +- Tables relating to the inner workings of the SpatiaLite SQLite extension. +- ``sqlite_stat`` tables used to store statistics used by the query optimizer. + .. _TableView: Table diff --git a/tests/test_api.py b/tests/test_api.py index 7a25b55e..4ad55d72 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1018,6 +1018,21 @@ async def test_hidden_sqlite_stat1_table(): ) +@pytest.mark.asyncio +async def test_hide_tables_starting_with_underscore(): + ds = Datasette() + db = ds.add_memory_database("test_hide_tables_starting_with_underscore") + await db.execute_write("create table normal (id integer primary key, name text)") + await db.execute_write("create table _hidden (id integer primary key, name text)") + data = ( + await ds.client.get( + "/test_hide_tables_starting_with_underscore.json?_show_hidden=1" + ) + ).json() + tables = [(t["name"], t["hidden"]) for t in data["tables"]] + assert tables == [("normal", False), ("_hidden", True)] + + @pytest.mark.asyncio @pytest.mark.parametrize("db_name", ("foo", r"fo%o", "f~/c.d")) async def test_tilde_encoded_database_names(db_name): From 7b32d5f7d83e8373e52ceef6198a6737b2c10f71 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 7 Mar 2024 00:11:14 -0500 Subject: [PATCH 013/334] datasette-create-view as example of query_actions hook --- docs/plugin_hooks.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 4f77d75c..91db80f8 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1576,6 +1576,8 @@ This example adds a new query action linking to a page for explaining a query: }, ] +Example: `datasette-create-view `_ + .. _plugin_hook_database_actions: database_actions(datasette, actor, database, request) From daf5ca02ca9df56de1f920a6bd20e81dbad2686c Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 12 Mar 2024 13:44:07 -0700 Subject: [PATCH 014/334] homepage_actions() plugin hook, closes #2298 --- datasette/hookspecs.py | 5 +++++ datasette/templates/database.html | 1 - datasette/templates/index.html | 31 +++++++++++++++++++++++++++++ datasette/views/index.py | 18 ++++++++++++++++- docs/plugin_hooks.rst | 33 +++++++++++++++++++++++++++++++ tests/fixtures.py | 1 + tests/plugins/my_plugin.py | 12 +++++++++++ tests/test_plugins.py | 19 ++++++++++++++++++ 8 files changed, 118 insertions(+), 2 deletions(-) diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index 1141ca75..5a8439b4 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -155,6 +155,11 @@ def database_actions(datasette, actor, database, request): """Links for the database actions menu""" +@hookspec +def homepage_actions(datasette, actor, request): + """Links for the homepage actions menu""" + + @hookspec def skip_csrf(datasette, scope): """Mechanism for skipping CSRF checks for certain requests""" diff --git a/datasette/templates/database.html b/datasette/templates/database.html index 02e6fb3d..b5a0edc4 100644 --- a/datasette/templates/database.html +++ b/datasette/templates/database.html @@ -44,7 +44,6 @@
{% endif %} - {{ top_database() }} {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %} diff --git a/datasette/templates/index.html b/datasette/templates/index.html index 203abca8..d08cdb10 100644 --- a/datasette/templates/index.html +++ b/datasette/templates/index.html @@ -7,6 +7,37 @@ {% block content %}

{{ metadata.title or "Datasette" }}{% if private %} 🔒{% endif %}

+{% set links = homepage_actions %}{% if links %} +
+ +
+{% endif %} + {{ top_homepage() }} {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %} diff --git a/datasette/views/index.py b/datasette/views/index.py index 2cb18b1c..6546b7ae 100644 --- a/datasette/views/index.py +++ b/datasette/views/index.py @@ -1,6 +1,12 @@ import json -from datasette.utils import add_cors_headers, make_slot_function, CustomJSONEncoder +from datasette.plugins import pm +from datasette.utils import ( + add_cors_headers, + await_me_maybe, + make_slot_function, + CustomJSONEncoder, +) from datasette.utils.asgi import Response from datasette.version import __version__ @@ -131,6 +137,15 @@ class IndexView(BaseView): headers=headers, ) else: + homepage_actions = [] + for hook in pm.hook.homepage_actions( + datasette=self.ds, + actor=request.actor, + request=request, + ): + extra_links = await await_me_maybe(hook) + if extra_links: + homepage_actions.extend(extra_links) return await self.render( ["index.html"], request=request, @@ -144,5 +159,6 @@ class IndexView(BaseView): "top_homepage": make_slot_function( "top_homepage", self.ds, request ), + "homepage_actions": homepage_actions, }, ) diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 91db80f8..4ac391ef 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1629,6 +1629,39 @@ This example adds a new database action for creating a table, if the user has th Example: `datasette-graphql `_, `datasette-edit-schema `_ +.. _plugin_hook_homepage_actions: + +homepage_actions(datasette, actor, request) +------------------------------------------- + +``datasette`` - :ref:`internals_datasette` + You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries. + +``actor`` - dictionary or None + The currently authenticated :ref:`actor `. + +``request`` - :ref:`internals_request` + The current HTTP request. + +This hook is similar to :ref:`plugin_hook_table_actions` but populates an actions menu on the index page of the Datasette instance. + +This example adds a link an imagined tool for editing the homepage, only for signed in users: + +.. code-block:: python + + from datasette import hookimpl + + + @hookimpl + def homepage_actions(datasette, actor): + if actor: + return [ + { + "href": datasette.urls.path("/-/customize-homepage"), + "label": "Customize homepage", + } + ] + .. _plugin_hook_skip_csrf: skip_csrf(datasette, scope) diff --git a/tests/fixtures.py b/tests/fixtures.py index c3c77fce..61d240da 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -42,6 +42,7 @@ EXPECTED_PLUGINS = [ "extra_js_urls", "extra_template_vars", "forbidden", + "homepage_actions", "menu_links", "permission_allowed", "prepare_connection", diff --git a/tests/plugins/my_plugin.py b/tests/plugins/my_plugin.py index 5f000537..94267f04 100644 --- a/tests/plugins/my_plugin.py +++ b/tests/plugins/my_plugin.py @@ -425,6 +425,18 @@ def database_actions(datasette, database, actor, request): ] +@hookimpl +def homepage_actions(datasette, actor, request): + if actor: + label = f"Custom homepage for: {actor['id']}" + return [ + { + "href": datasette.urls.path("/-/custom-homepage"), + "label": label, + } + ] + + @hookimpl def skip_csrf(scope): return scope["path"] == "/skip-csrf" diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 9f69e4fa..dcc5de20 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -995,6 +995,25 @@ async def test_hook_database_actions(ds_client): ] +@pytest.mark.asyncio +async def test_hook_homepage_actions(ds_client): + response = await ds_client.get("/") + # No button for anonymous users + assert "Homepage actions" not in response.text + # Signed in user gets an action + response2 = await ds_client.get( + "/", cookies={"ds_actor": ds_client.actor_cookie({"id": "troy"})} + ) + assert "Homepage actions" in response2.text + assert get_actions_links(response2.text) == [ + { + "label": "Custom homepage for: troy", + "href": "/-/custom-homepage", + "description": None, + }, + ] + + def test_hook_skip_csrf(app_client): cookie = app_client.actor_cookie({"id": "test"}) csrf_response = app_client.post( From 909c85cd2b64646edaaa8e3c19f5cd4e51a13fad Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 12 Mar 2024 14:25:07 -0700 Subject: [PATCH 015/334] view_actions plugin hook, closes #2297 --- datasette/hookspecs.py | 5 +++++ datasette/templates/table.html | 6 +++--- datasette/views/table.py | 30 ++++++++++++++++++------------ docs/plugin_hooks.rst | 26 +++++++++++++++++++++++++- tests/fixtures.py | 1 + tests/plugins/my_plugin.py | 12 ++++++++++++ tests/test_plugins.py | 26 +++++++++++++++++++++----- 7 files changed, 85 insertions(+), 21 deletions(-) diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index 5a8439b4..6ce1e85e 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -145,6 +145,11 @@ def table_actions(datasette, actor, database, table, request): """Links for the table actions menu""" +@hookspec +def view_actions(datasette, actor, database, view, request): + """Links for the view actions menu""" + + @hookspec def query_actions(datasette, actor, database, query_name, request, sql, params): """Links for the query and canned query actions menu""" diff --git a/datasette/templates/table.html b/datasette/templates/table.html index 1d328366..505335d6 100644 --- a/datasette/templates/table.html +++ b/datasette/templates/table.html @@ -24,17 +24,17 @@ -{% set links = table_actions() %}{% if links %} +{% set links = actions() %}{% if links %}