JumpSQL(database=) parameter

Refs https://github.com/simonw/datasette/pull/2732#issuecomment-4527304912
This commit is contained in:
Simon Willison 2026-05-23 21:00:04 -07:00
commit c980234c41
5 changed files with 226 additions and 20 deletions

View file

@ -9,6 +9,7 @@ from typing import Any
class JumpSQL:
sql: str
params: dict[str, Any] | None = None
database: str | None = None
@classmethod
def menu_item(
@ -50,7 +51,7 @@ _PARAM_RE = re.compile(r"(?<!:):([A-Za-z_][A-Za-z0-9_]*)")
def namespace_sql_params(sql: str, params: dict[str, Any], prefix: str):
"""Rename named SQL parameters so UNION fragments cannot collide."""
"""Rename named SQL parameters so UNION query parameters cannot collide."""
if not params:
return sql, {}

View file

@ -972,15 +972,28 @@ class JumpView(BaseView):
f"Invalid arguments for datasette.urls.{method_name}(): {ex}"
) from ex
async def get(self, request):
q = request.args.get("q", "").strip()
terms = q.split()
pattern = "%" + "%".join(terms) + "%" if terms else "%"
fragments = await self._fragments(request)
def _sort_key(self, row, q):
display_label = row["display_name"] or row["label"]
display_label_lower = display_label.lower()
q_lower = q.lower()
if display_label_lower == q_lower:
relevance = 0
elif display_label_lower.startswith(q_lower):
relevance = 1
else:
relevance = 2
type_sort = {
"database": 10,
"table": 20,
"view": 25,
"query": 30,
}.get(row["type"], 50)
return (relevance, type_sort, len(display_label), row["label"])
async def _rows_for_database(self, database_name, indexed_fragments, q, pattern):
params = {"q": q, "pattern": pattern}
union_parts = []
all_params = {"q": q, "pattern": pattern}
for index, fragment in enumerate(fragments):
for index, fragment in indexed_fragments:
fragment_sql, fragment_params = namespace_sql_params(
fragment.sql,
fragment.params or {},
@ -998,13 +1011,18 @@ class JumpView(BaseView):
{fragment_sql}
)
""")
all_params.update(fragment_params)
params.update(fragment_params)
sql = f"""
WITH jump_items AS (
{" UNION ALL ".join(union_parts)}
)
SELECT *
SELECT
type,
label,
description,
url,
search_text,
display_name
FROM jump_items
WHERE :q = ''
OR search_text LIKE :pattern COLLATE NOCASE
@ -1025,10 +1043,40 @@ class JumpView(BaseView):
label
LIMIT 101
"""
result = await self.ds.get_internal_database().execute(sql, all_params)
rows = list(result.rows)
truncated = len(rows) > 100
if truncated:
db = (
self.ds.get_internal_database()
if database_name is None
else self.ds.get_database(database_name)
)
result = await db.execute(sql, params)
return list(result.rows)
async def get(self, request):
q = request.args.get("q", "").strip()
terms = q.split()
pattern = "%" + "%".join(terms) + "%" if terms else "%"
fragments = await self._fragments(request)
fragments_by_database = {}
for index, fragment in enumerate(fragments):
fragments_by_database.setdefault(fragment.database, []).append(
(index, fragment)
)
rows = []
truncated = False
for database_name, indexed_fragments in fragments_by_database.items():
database_rows = await self._rows_for_database(
database_name, indexed_fragments, q, pattern
)
if len(database_rows) > 100:
truncated = True
database_rows = database_rows[:100]
rows.extend(database_rows)
rows.sort(key=lambda row: self._sort_key(row, q))
if len(rows) > 100:
truncated = True
rows = rows[:100]
matches = []

View file

@ -14,7 +14,7 @@ Unreleased
- Fixed a Safari bug with the table search mechanism triggered by pressing ``/``. (:issue:`2724`)
- New "Jump to..." menu item, always visible, for triggering the previously undocumented ``/`` menu. (:issue:`2725`)
- The ``/`` jump-to search interface now covers databases, views, canned queries and plugin-provided items in addition to tables. The endpoint backing it has been renamed from ``/-/tables`` to ``/-/jump``.
- New :ref:`plugin_hook_jump_items_sql` plugin hook, allowing plugins to contribute additional items to the jump-to menu by returning SQL that queries the internal catalog. Each row returned by this hook includes a ``url`` value, which can be a string starting with ``/`` or a JSON object describing a call to one of the :ref:`internals_datasette_urls` methods.
- New :ref:`plugin_hook_jump_items_sql` plugin hook, allowing plugins to contribute additional items to the jump-to menu by returning SQL. ``JumpSQL`` queries run against Datasette's internal database by default, or can target another database using the optional ``database=`` argument. Datasette groups these queries by database and executes one ``UNION ALL`` query for each database. Each row returned by this hook includes a ``url`` value, which can be a string starting with ``/`` or a JSON object describing a call to one of the :ref:`internals_datasette_urls` methods.
- ``datasette.jump.JumpSQL.menu_item()`` is a shortcut for adding individual jump menu items that are not backed by resources in the internal catalog.
- New :ref:`javascript_plugins_makeJumpSections` JavaScript plugin hook, allowing plugins to add custom blank-state sections to the jump-to menu before the user has typed a query.
- Jump menu results now show their ``type`` as a category label, and can show optional longer ``description`` text for individual results.

View file

@ -1884,7 +1884,7 @@ Examples: `datasette-search-all <https://datasette.io/plugins/datasette-search-a
.. _plugin_hook_jump_items_sql:
jump_items_sql(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.
@ -1897,7 +1897,11 @@ jump_items_sql(datasette, actor, request)
This hook allows plugins to add extra results to Datasette's ``/`` jump menu, which is powered by the ``/-/jump`` JSON endpoint.
Return a ``datasette.jump.JumpSQL`` object, or a list of ``JumpSQL`` objects. Each ``JumpSQL`` object wraps a SQL query to be combined with Datasette's own databases, tables, views and canned query results.
Return a ``datasette.jump.JumpSQL`` object, or a list of ``JumpSQL`` objects. Each ``JumpSQL`` object wraps a SQL query to be searched alongside Datasette's own databases, tables, views and canned query results.
``JumpSQL`` queries run against Datasette's internal database by default. To run a query against another database, pass its name as the optional ``database=`` argument. For example, ``JumpSQL(database="content", sql="...")`` runs against the ``content`` database.
Datasette groups ``JumpSQL`` queries by database and executes one ``UNION ALL`` query for each database.
The SQL query must return these columns:
@ -1940,7 +1944,7 @@ This example adds a "Plugin dashboard" result for signed-in users:
display_name="Plugin dashboard",
)
Use ``params=`` to pass SQL parameters. Datasette will automatically namespace those parameters before combining SQL fragments from different plugins.
Use ``params=`` to pass SQL parameters. Datasette will automatically namespace those parameters before adding the SQL fragment to the per-database ``UNION ALL`` query.
``JumpSQL.menu_item(...)`` is a shortcut for adding a single jump menu item from Python code. It accepts the keyword arguments shown above.

View file

@ -143,6 +143,10 @@ async def test_jump_respects_resource_permissions(ds_for_jump):
@pytest.mark.asyncio
async def test_jump_sql_menu_item_helper(ds_for_jump):
assert JumpSQL("SELECT 1").database is None
assert JumpSQL("SELECT 1", database="content").database == "content"
assert JumpSQL("SELECT 1", None, "content").database == "content"
fragment = JumpSQL.menu_item(
label="Plugin dashboard",
url="/-/plugin-dashboard",
@ -253,6 +257,155 @@ async def test_jump_uses_plugin_sql_with_namespaced_parameters(ds_for_jump):
]
@pytest.mark.asyncio
async def test_jump_sql_unions_fragments_by_database(ds_for_jump, monkeypatch):
class JumpPlugin:
@hookimpl
def jump_items_sql(self, datasette, actor, request):
return [
JumpSQL(sql="""
SELECT
'plugin' AS type,
'first-unioned-item' AS label,
NULL AS description,
'/-/first-unioned-item' AS url,
'unioned item' AS search_text,
NULL AS display_name
"""),
JumpSQL(sql="""
SELECT
'plugin' AS type,
'second-unioned-item' AS label,
NULL AS description,
'/-/second-unioned-item' AS url,
'unioned item' AS search_text,
NULL AS display_name
"""),
JumpSQL(
"""
SELECT
'plugin' AS type,
'content-first-unioned-item' AS label,
NULL AS description,
'/-/content-first-unioned-item' AS url,
'unioned item' AS search_text,
NULL AS display_name
""",
None,
"content",
),
JumpSQL(
database="content",
sql="""
SELECT
'plugin' AS type,
'content-second-unioned-item' AS label,
NULL AS description,
'/-/content-second-unioned-item' AS url,
'unioned item' AS search_text,
NULL AS display_name
""",
),
]
internal_db = ds_for_jump.get_internal_database()
original_execute = internal_db.execute
internal_jump_query_sql = []
async def internal_execute_with_recording(sql, *args, **kwargs):
if "unioned-item" in sql:
internal_jump_query_sql.append(sql)
return await original_execute(sql, *args, **kwargs)
monkeypatch.setattr(internal_db, "execute", internal_execute_with_recording)
content_db = ds_for_jump.get_database("content")
original_content_execute = content_db.execute
content_jump_query_sql = []
async def content_execute_with_recording(sql, *args, **kwargs):
if "unioned-item" in sql:
content_jump_query_sql.append(sql)
return await original_content_execute(sql, *args, **kwargs)
monkeypatch.setattr(content_db, "execute", content_execute_with_recording)
plugin = JumpPlugin()
pm.register(plugin, name="test-jump-union-plugin")
try:
response = await ds_for_jump.client.get(
"/-/jump.json?q=unioned", actor={"id": "alice"}
)
finally:
pm.unregister(name="test-jump-union-plugin")
assert response.status_code == 200
assert len(internal_jump_query_sql) == 1
assert " UNION ALL " in internal_jump_query_sql[0]
assert len(content_jump_query_sql) == 1
assert " UNION ALL " in content_jump_query_sql[0]
assert {match["name"] for match in response.json()["matches"]} == {
"content-first-unioned-item",
"content-second-unioned-item",
"first-unioned-item",
"second-unioned-item",
}
@pytest.mark.asyncio
async def test_jump_sql_can_query_named_database(ds_for_jump):
content_db = ds_for_jump.get_database("content")
await content_db.execute_write(
"INSERT INTO comments (id, body) VALUES (1001, 'Named database jump target')"
)
class JumpPlugin:
@hookimpl
def jump_items_sql(self, datasette, actor, request):
return JumpSQL(
database="content",
sql="""
SELECT
'comment' AS type,
body AS label,
'Comment from content database' AS description,
json_object(
'method', 'table',
'database', 'content',
'table', 'comments'
) AS url,
body AS search_text,
body AS display_name
FROM comments
WHERE id = :comment_id
""",
params={"comment_id": 1001},
)
plugin = JumpPlugin()
pm.register(plugin, name="test-jump-content-db-plugin")
try:
response = await ds_for_jump.client.get(
"/-/jump.json?q=named+database", actor={"id": "alice"}
)
finally:
pm.unregister(name="test-jump-content-db-plugin")
assert response.status_code == 200
plugin_matches = [
match for match in response.json()["matches"] if match["type"] == "comment"
]
assert plugin_matches == [
{
"name": "Named database jump target",
"display_name": "Named database jump target",
"url": "/content/comments",
"type": "comment",
"description": "Comment from content database",
}
]
@pytest.mark.asyncio
async def test_jump_resolves_url_descriptors_from_sql(ds_for_jump):
class JumpPlugin: