diff --git a/datasette/templates/_codemirror_foot.html b/datasette/templates/_codemirror_foot.html index a624c8a4..2912292c 100644 --- a/datasette/templates/_codemirror_foot.html +++ b/datasette/templates/_codemirror_foot.html @@ -15,6 +15,9 @@ if (sqlInput) { var editor = (window.editor = cm.editorFromTextArea(sqlInput, { schema, + {% if default_table is defined and default_table %} + defaultTable: {{ default_table|tojson }}, + {% endif %} })); if (sqlFormat) { sqlFormat.addEventListener("click", (ev) => { diff --git a/datasette/templates/table.html b/datasette/templates/table.html index c2131360..cd7c9329 100644 --- a/datasette/templates/table.html +++ b/datasette/templates/table.html @@ -126,7 +126,7 @@ {% endif %} {% if query.sql and allow_execute_sql %} -

View and edit SQL

+

View and edit SQL

{% endif %} diff --git a/datasette/views/database.py b/datasette/views/database.py index d9ca8012..0133289a 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -457,6 +457,11 @@ class QueryContext(Context): "help": "Dictionary mapping table names to lists of column names, used to power SQL autocomplete." } ) + default_table: str = field( + metadata={ + "help": "Name of the focal table for this query, if any - set when the query page was reached from a table-scoped context (such as the table page's 'View and edit SQL' link) so the SQL editor can complete that table's columns unprefixed. ``None`` otherwise, including for stored/canned queries." + } + ) alternate_url_json: str = field( metadata={"help": "URL for alternate JSON version of this page"} ) @@ -716,6 +721,15 @@ class QueryView(View): # Create lookup dict for quick access allowed_dict = {r.child: r for r in allowed_tables_page.resources} + # If the request carries a ?_table= pointing at a real (visible) table + # or view in this database, treat this as a table-scoped query - e.g. + # arriving here via the "View and edit SQL" link on a table page - so + # the SQL editor can offer that table's columns unprefixed. Anything + # else (including stored/canned queries, which may reference more + # than one table) leaves this as None. + requested_table = request.args.get("_table") + default_table = requested_table if requested_table in allowed_dict else None + # Are we a stored query? stored_query = None stored_query_write = False @@ -1101,6 +1115,7 @@ class QueryView(View): if allow_execute_sql else {} ), + default_table=default_table, columns=columns, renderers=renderers, url_csv=datasette.urls.path( diff --git a/docs/template_context.rst b/docs/template_context.rst index e445b335..890846bb 100644 --- a/docs/template_context.rst +++ b/docs/template_context.rst @@ -168,6 +168,9 @@ The page for arbitrary SQL queries (/database/-/query?sql=...) and stored querie ``db_is_immutable`` - ``bool`` Boolean indicating if this database is immutable +``default_table`` - ``str`` + Name of the focal table for this query, if any - set when the query page was reached from a table-scoped context (such as the table page's 'View and edit SQL' link) so the SQL editor can complete that table's columns unprefixed. ``None`` otherwise, including for stored/canned queries. + ``display_rows`` - ``list`` List of result rows formatted for HTML display. Each row is a list of rendered cell values in the same order as ``columns``. diff --git a/tests/test_html.py b/tests/test_html.py index b4c47d80..f53ed09c 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -284,6 +284,53 @@ async def test_query_page_with_no_sql(ds_client): assert 'class="rows-and-columns"' not in response.text +@pytest.mark.asyncio +async def test_table_page_view_and_edit_sql_link_carries_table(ds_client): + # The table page's "View and edit SQL" link should point at the query + # page with a &_table= param identifying the focal table, so the SQL + # editor can offer that table's columns unprefixed. + response = await ds_client.get("/fixtures/facetable") + assert response.status_code == 200 + soup = Soup(response.content, "html.parser") + link = soup.find("span", string="View and edit SQL").find_parent("a") + assert link is not None + assert "_table=facetable" in link["href"] + + +@pytest.mark.asyncio +async def test_query_page_default_table_from_table_scoped_link(ds_client): + # Following the table page's edit-SQL link should result in a query page + # whose SQL editor is initialized with defaultTable set to that table. + table_response = await ds_client.get("/fixtures/facetable") + soup = Soup(table_response.content, "html.parser") + href = soup.find("span", string="View and edit SQL").find_parent("a")["href"] + response = await ds_client.get(href, follow_redirects=True) + assert response.status_code == 200 + assert 'defaultTable: "facetable"' in response.text + + +@pytest.mark.asyncio +async def test_query_page_no_default_table_without_table_scope(ds_client): + # The plain database query page (no focal table) should not set + # defaultTable at all. + response = await ds_client.get("/fixtures/-/query?sql=select+1") + assert response.status_code == 200 + assert "defaultTable" not in response.text + + +@pytest.mark.asyncio +async def test_query_page_ignores_invalid_table_param(ds_client): + # A ?_table= value that isn't a real table/view in this database should + # not be reflected back into the page - and should not break execution + # of the query itself (leading-underscore params are not treated as SQL + # bind parameters unless they appear as :name in the SQL). + response = await ds_client.get( + "/fixtures/-/query?sql=select+1&_table=not_a_real_table" + ) + assert response.status_code == 200 + assert "defaultTable" not in response.text + + @pytest.mark.asyncio async def test_query_csv_with_no_sql_is_400(ds_client): # https://github.com/simonw/datasette/issues/2743