Add create table execute-write template (#2794)

Add a Create table starter template to the execute-write page, alongside the existing table-specific insert/update/delete templates.

Keep template button clicks in-page by updating CodeMirror and the URL without collapsing the template controls.

Refresh schema metadata after successful schema-changing execute-write statements so newly created tables appear immediately.
This commit is contained in:
Simon Willison 2026-06-19 17:14:33 -07:00 • committed by GitHub
commit f673e7416f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 220 additions and 18 deletions

View file

@ -625,9 +625,12 @@ class Datasette:
return action
return None
async def refresh_schemas(self):
async def refresh_schemas(self, *, force=False):
# Throttle schema refreshes to at most once per second
if time.monotonic() - getattr(self, "_last_schema_refresh", 0) < 1.0:
if (
not force
and time.monotonic() - getattr(self, "_last_schema_refresh", 0) < 1.0
):
return
self._last_schema_refresh = time.monotonic()
if self._refresh_schemas_lock.locked():

View file

@ -93,20 +93,25 @@ form.sql.core input[data-execute-write-submit]:disabled {
{% endif %}
<form class="sql core" action="{{ urls.database(database) }}/-/execute-write" method="post" data-analyze-url="{{ urls.database(database) }}/-/execute-write/analyze">
{% if write_template_tables %}
{% if write_create_table_template_sql or write_template_tables %}
<div class="execute-write-template-menu">
<details>
<summary>Start with a template</summary>
<p class="execute-write-template-controls">
<label for="execute-write-template-table">Table</label>
<select id="execute-write-template-table">
{% for table_name, table in write_template_tables|dictsort %}
<option value="{{ table_name }}"{% for operation, template_sql in table.templates|dictsort %} data-template-{{ operation }}-sql="{{ template_sql }}"{% endfor %}>{{ table_name }}</option>
{% if write_create_table_template_sql %}
<button type="button" data-sql-template="create" data-template-sql="{{ write_create_table_template_sql }}">Create table</button>
{% endif %}
{% if write_template_tables %}
<label for="execute-write-template-table">{% if write_create_table_template_sql %}or table:{% else %}Table{% endif %}</label>
<select id="execute-write-template-table">
{% for table_name, table in write_template_tables|dictsort %}
<option value="{{ table_name }}"{% for operation, template_sql in table.templates|dictsort %} data-template-{{ operation }}-sql="{{ template_sql }}"{% endfor %}>{{ table_name }}</option>
{% endfor %}
</select>
{% for operation in write_template_operations %}
<button type="button" data-sql-template="{{ operation.name }}">{{ operation.label }}</button>
{% endfor %}
</select>
{% for operation in write_template_operations %}
<button type="button" data-sql-template="{{ operation.name }}">{{ operation.label }}</button>
{% endfor %}
{% endif %}
</p>
</details>
</div>
@ -252,11 +257,12 @@ window.addEventListener("DOMContentLoaded", () => {
});
</script>
{% if write_template_tables %}
{% if write_create_table_template_sql or write_template_tables %}
<script>
window.addEventListener("DOMContentLoaded", () => {
const tableSelect = document.querySelector("#execute-write-template-table");
const templateButtons = document.querySelectorAll("[data-sql-template]");
const sqlInput = document.querySelector("textarea#sql-editor");
function dataKey(operation) {
return `template${operation.charAt(0).toUpperCase()}${operation.slice(1)}Sql`;
@ -266,26 +272,59 @@ window.addEventListener("DOMContentLoaded", () => {
return tableSelect ? tableSelect.options[tableSelect.selectedIndex] : null;
}
function templateSql(operation) {
function templateSql(button) {
if (button.dataset.templateSql) {
return button.dataset.templateSql;
}
const operation = button.dataset.sqlTemplate;
const option = selectedOption();
return option ? option.dataset[dataKey(operation)] || "" : "";
}
function updateTemplateButtons() {
templateButtons.forEach((button) => {
button.hidden = !templateSql(button.dataset.sqlTemplate);
button.hidden = !templateSql(button);
});
}
function updateSqlUrl(sql) {
if (!window.history || !window.history.replaceState) {
return;
}
const url = new URL(window.location.href);
url.searchParams.set("sql", sql);
window.history.replaceState(null, "", url.toString());
}
function setEditorSql(sql) {
if (window.editor) {
window.editor.dispatch({
changes: {
from: 0,
to: window.editor.state.doc.length,
insert: sql,
},
selection: { anchor: sql.length },
});
window.editor.focus();
if (sqlInput) {
sqlInput.value = sql;
}
} else if (sqlInput) {
sqlInput.value = sql;
sqlInput.dispatchEvent(new Event("input", { bubbles: true }));
sqlInput.focus();
}
updateSqlUrl(sql);
}
templateButtons.forEach((button) => {
button.addEventListener("click", () => {
const sql = templateSql(button.dataset.sqlTemplate);
const sql = templateSql(button);
if (!sql) {
return;
}
const url = new URL(window.location.href);
url.searchParams.set("sql", sql);
window.location.href = url.toString();
setEditorSql(sql);
});
});
if (tableSelect) {

View file

@ -31,6 +31,15 @@ WRITE_TEMPLATE_LABELS = {
"delete": "Delete rows",
}
WRITE_TEMPLATE_OPERATIONS = tuple(WRITE_TEMPLATE_LABELS)
CREATE_TABLE_TEMPLATE_SQL = "\n".join(
(
"create table new_table (",
" id integer primary key,",
" name text",
" -- created text default (datetime('now'))",
")",
)
)
def _parameter_names(columns):
@ -207,6 +216,23 @@ def _write_template_operations(write_template_tables):
return operations
async def _create_table_template_sql(datasette, db, actor):
if await datasette.allowed(
action="create-table",
resource=DatabaseResource(db.name),
actor=actor,
):
return CREATE_TABLE_TEMPLATE_SQL
return None
def _analysis_changes_schema(analysis):
return any(
operation.operation in {"create", "alter", "drop"}
for operation in analysis.operations
)
class ExecuteWriteView(BaseView):
name = "execute-write"
has_json_alternate = False
@ -241,6 +267,9 @@ class ExecuteWriteView(BaseView):
self.ds, db, table_columns, hidden_table_names, request.actor
)
write_template_operations = _write_template_operations(write_template_tables)
write_create_table_template_sql = await _create_table_template_sql(
self.ds, db, request.actor
)
if sql and analysis_error is None:
try:
parameter_names = _derived_query_parameters(sql)
@ -302,6 +331,7 @@ class ExecuteWriteView(BaseView):
"table_columns": table_columns,
"write_template_tables": write_template_tables,
"write_template_operations": write_template_operations,
"write_create_table_template_sql": write_create_table_template_sql,
"save_query_url": save_query_url,
"save_query_base_url": save_query_base_url,
},
@ -387,6 +417,9 @@ class ExecuteWriteView(BaseView):
status=400,
)
if _analysis_changes_schema(analysis):
await self.ds.refresh_schemas(force=True)
if cursor.rowcount == -1:
message = "Query executed"
else:

View file

@ -14,6 +14,15 @@ from datasette.utils.sqlite import sqlite3, supports_returning
requires_sqlite_returning = pytest.mark.skipif(
not supports_returning(), reason="SQLite does not support RETURNING"
)
EXPECTED_CREATE_TABLE_TEMPLATE_SQL = "\n".join(
(
"create table new_table (",
" id integer primary key,",
" name text",
" -- created text default (datetime('now'))",
")",
)
)
def _template_option_attributes(html, table):
@ -29,6 +38,16 @@ def _template_sql(html, table, operation):
return unescape(match.group(1))
def _template_button_sql(html, operation):
soup = Soup(html, "html.parser")
button = soup.select_one('button[data-sql-template="{}"]'.format(operation))
assert button, "Could not find {} template button".format(operation)
assert button.get(
"data-template-sql"
), "Could not find SQL for {} template button".format(operation)
return button["data-template-sql"]
async def add_numbered_queries(ds, database, count):
for i in range(1, count + 1):
await ds.add_query(
@ -1645,6 +1664,14 @@ async def test_execute_write_get_prepopulates_without_executing():
)
assert "<h2>Query operations</h2>" in response.text
assert "<summary>Start with a template</summary>" in response.text
assert 'data-sql-template="create"' in response.text
assert _template_button_sql(response.text, "create") == (
EXPECTED_CREATE_TABLE_TEMPLATE_SQL
)
assert ">Create table</button>" in response.text
assert '<label for="execute-write-template-table">or table:</label>' in (
response.text
)
assert '<option value="dogs"' in response.text
assert "data-template-insert-sql=" in response.text
assert 'data-sql-template="insert"' in response.text
@ -1660,6 +1687,9 @@ async def test_execute_write_get_prepopulates_without_executing():
assert 'addEventListener("paste"' in response.text
assert "setupSqlParameterRefresh" in response.text
assert "datasetteSqlAnalysis.renderAnalysis" in response.text
assert "window.editor.dispatch" in response.text
assert "window.history.replaceState" in response.text
assert "window.location.href = url.toString();" not in response.text
assert "input[data-execute-write-submit]:disabled" in response.text
assert (
'data-execute-write-disabled-reason aria-live="polite" hidden' in response.text
@ -1813,6 +1843,7 @@ async def test_execute_write_templates_are_filtered_by_permission_and_server_gen
assert '<option value="dogs"' in writer_response.text
assert '<option value="manual"' in writer_response.text
assert '<option value="cats"' not in writer_response.text
assert 'data-sql-template="create"' not in writer_response.text
assert "function insertSql(" not in writer_response.text
assert "function updateSql(" not in writer_response.text
assert "function deleteSql(" not in writer_response.text
@ -1842,6 +1873,7 @@ async def test_execute_write_templates_are_filtered_by_permission_and_server_gen
assert 'data-sql-template="delete"' in deleter_response.text
assert 'data-sql-template="insert"' not in deleter_response.text
assert 'data-sql-template="update"' not in deleter_response.text
assert 'data-sql-template="create"' not in deleter_response.text
assert viewer_response.status_code == 200
assert "<summary>Start with a template</summary>" not in viewer_response.text
@ -1851,6 +1883,101 @@ async def test_execute_write_templates_are_filtered_by_permission_and_server_gen
assert "data-template-delete-sql" not in viewer_response.text
@pytest.mark.asyncio
async def test_execute_write_create_table_template_is_filtered_by_permission():
ds = Datasette(
memory=True,
default_deny=True,
config={
"databases": {
"data": {
"permissions": {
"view-database": {"id": ["creator", "editor", "both"]},
"execute-write-sql": {"id": ["creator", "editor", "both"]},
"create-table": {"id": ["creator", "both"]},
},
"tables": {
"dogs": {
"permissions": {
"view-table": {"id": ["editor", "both"]},
"insert-row": {"id": ["editor", "both"]},
"update-row": {"id": ["editor", "both"]},
"delete-row": {"id": ["editor", "both"]},
}
},
},
}
}
},
)
db = ds.add_memory_database("execute_write_create_template", name="data")
await db.execute_write("create table dogs (id integer primary key, name text)")
await ds.invoke_startup()
creator_response = await ds.client.get(
"/data/-/execute-write", actor={"id": "creator"}
)
editor_response = await ds.client.get(
"/data/-/execute-write", actor={"id": "editor"}
)
both_response = await ds.client.get("/data/-/execute-write", actor={"id": "both"})
assert creator_response.status_code == 200
assert "<summary>Start with a template</summary>" in creator_response.text
assert _template_button_sql(creator_response.text, "create") == (
EXPECTED_CREATE_TABLE_TEMPLATE_SQL
)
assert "There are no tables that you can currently edit." not in (
creator_response.text
)
assert 'id="execute-write-template-table"' not in creator_response.text
assert 'data-sql-template="insert"' not in creator_response.text
assert 'data-sql-template="update"' not in creator_response.text
assert 'data-sql-template="delete"' not in creator_response.text
assert editor_response.status_code == 200
assert 'data-sql-template="create"' not in editor_response.text
assert '<label for="execute-write-template-table">Table</label>' in (
editor_response.text
)
assert 'data-sql-template="insert"' in editor_response.text
assert 'data-sql-template="update"' in editor_response.text
assert 'data-sql-template="delete"' in editor_response.text
assert both_response.status_code == 200
assert _template_button_sql(both_response.text, "create") == (
EXPECTED_CREATE_TABLE_TEMPLATE_SQL
)
assert '<label for="execute-write-template-table">or table:</label>' in (
both_response.text
)
assert 'data-sql-template="insert"' in both_response.text
assert 'data-sql-template="update"' in both_response.text
assert 'data-sql-template="delete"' in both_response.text
@pytest.mark.asyncio
async def test_execute_write_create_table_refreshes_template_tables():
ds = Datasette(memory=True, default_deny=True)
ds.root_enabled = True
db = ds.add_memory_database("execute_write_create_template_refresh", name="data")
await ds.invoke_startup()
response = await ds.client.post(
"/data/-/execute-write",
actor={"id": "root"},
data={"sql": "create table selectable (id integer primary key, name text)"},
)
assert response.status_code == 200
assert "Query executed" in response.text
assert '<option value="selectable"' in response.text
assert _template_sql(response.text, "selectable", "insert") == (
'insert into "selectable" (\n' ' "name"\n' ")\n" "values (\n" " :name\n" ")"
)
assert await db.table_exists("selectable")
@pytest.mark.asyncio
async def test_execute_write_analyze_endpoint_uses_sql_only():
ds = Datasette(memory=True, default_deny=True)