Pass defaultTable to the SQL editor from table-scoped pages

The table page's 'View and edit SQL' link now carries ?_table=<name>;
QueryView validates it against the actor-visible tables/views for the
database before exposing it as default_table, so the editor completes
that table's columns unprefixed. Stored/canned queries are unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Alex Garcia 2026-07-10 10:59:23 -07:00
commit cc1a24fb4f
5 changed files with 69 additions and 1 deletions

View file

@ -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) => {

View file

@ -126,7 +126,7 @@
{% endif %}
{% if query.sql and allow_execute_sql %}
<p><a class="not-underlined" title="{{ query.sql }}" href="{{ urls.database(database) }}?{{ {'sql': query.sql}|urlencode|safe }}{% if query.params %}&amp;{{ query.params|urlencode|safe }}{% endif %}">&#x270e; <span class="underlined">View and edit SQL</span></a></p>
<p><a class="not-underlined" title="{{ query.sql }}" href="{{ urls.database(database) }}?{{ {'sql': query.sql, '_table': table}|urlencode|safe }}{% if query.params %}&amp;{{ query.params|urlencode|safe }}{% endif %}">&#x270e; <span class="underlined">View and edit SQL</span></a></p>
{% endif %}
<p class="export-links">This data as {% for name, url in renderers.items() %}<a href="{{ url }}">{{ name }}</a>{{ ", " if not loop.last }}{% endfor %}{% if display_rows %}, <a href="{{ url_csv }}">CSV</a> (<a href="#export">advanced</a>){% endif %}</p>

View file

@ -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(

View file

@ -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``.

View file

@ -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