mirror of
https://github.com/simonw/datasette.git
synced 2026-06-02 23:26:59 +02:00
JumpSQL(database=) parameter
Refs https://github.com/simonw/datasette/pull/2732#issuecomment-4527304912
This commit is contained in:
parent
cef6aa85b6
commit
c980234c41
5 changed files with 226 additions and 20 deletions
|
|
@ -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, {}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue