mirror of
https://github.com/simonw/datasette.git
synced 2026-09-29 05:14:21 +02:00
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:
parent
dfd5b95ec8
commit
f673e7416f
4 changed files with 220 additions and 18 deletions
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue