Update parameters/query operations as user edits the write query

Refs #2742
This commit is contained in:
Simon Willison 2026-05-25 12:08:22 -07:00
commit e1261442c0
5 changed files with 476 additions and 73 deletions

View file

@ -46,6 +46,7 @@ from .views import Context
from .views.database import (
database_download,
DatabaseView,
ExecuteWriteAnalyzeView,
ExecuteWriteView,
TableCreateView,
QueryView,
@ -2745,6 +2746,10 @@ class Datasette:
QueryInsertView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/queries/-/insert$",
)
add_route(
ExecuteWriteAnalyzeView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/execute-write/-/analyze$",
)
add_route(
ExecuteWriteView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/execute-write$",

View file

@ -148,7 +148,7 @@ form.sql .execute-write-editor textarea#sql-editor {
<p class="{% if execution_ok %}message-info{% else %}message-error{% endif %}">{{ execution_message }}{% for link in execution_links %} <a href="{{ link.href }}">{{ link.label }}</a>{% endfor %}</p>
{% endif %}
<form class="sql core" action="{{ urls.database(database) }}/-/execute-write" method="post">
<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 %}
<div class="execute-write-template-menu">
<details>
@ -170,44 +170,48 @@ form.sql .execute-write-editor textarea#sql-editor {
<p class="execute-write-editor"><textarea id="sql-editor" name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></p>
{% if parameter_names %}
<h2>Parameters</h2>
{% for parameter in parameter_names %}
<p class="execute-write-parameter-row"><label for="qp{{ loop.index }}">{{ parameter }}</label> <input type="text" id="qp{{ loop.index }}" name="{{ parameter }}" value="{{ parameter_values.get(parameter, "") }}" data-parameter-control> <button type="button" class="execute-write-parameter-toggle" data-parameter-toggle aria-controls="qp{{ loop.index }}" aria-expanded="false">Expand</button></p>
{% endfor %}
{% endif %}
<div id="execute-write-parameters-section">
{% if parameter_names %}
<h2>Parameters</h2>
{% for parameter in parameter_names %}
<p class="execute-write-parameter-row"><label for="qp{{ loop.index }}">{{ parameter }}</label> <input type="text" id="qp{{ loop.index }}" name="{{ parameter }}" value="{{ parameter_values.get(parameter, "") }}" data-parameter-control> <button type="button" class="execute-write-parameter-toggle" data-parameter-toggle aria-controls="qp{{ loop.index }}" aria-expanded="false">Expand</button></p>
{% endfor %}
{% endif %}
</div>
<h2>Query operations</h2>
{% if analysis_error %}
<p class="message-error">{{ analysis_error }}</p>
{% elif analysis_rows %}
<div class="table-wrapper"><table class="execute-write-analysis">
<thead>
<tr>
<th scope="col">Operation</th>
<th scope="col">Database</th>
<th scope="col">Table</th>
<th scope="col">Required permission</th>
<th scope="col">Allowed</th>
</tr>
</thead>
<tbody>
{% for row in analysis_rows %}
<div id="execute-write-analysis-section">
<h2>Query operations</h2>
{% if analysis_error %}
<p class="message-error">{{ analysis_error }}</p>
{% elif analysis_rows %}
<div class="table-wrapper"><table class="execute-write-analysis">
<thead>
<tr>
<td><code>{{ row.operation }}</code></td>
<td><code>{{ row.database }}</code></td>
<td><code>{{ row.table }}</code></td>
<td>{% if row.required_permission %}<code>{{ row.required_permission }}</code>{% endif %}</td>
<td>{% if row.allowed is none %}{% elif row.allowed %}<span class="execute-write-analysis-allowed">yes</span>{% else %}<span class="execute-write-analysis-denied">no</span>{% endif %}</td>
<th scope="col">Operation</th>
<th scope="col">Database</th>
<th scope="col">Table</th>
<th scope="col">Required permission</th>
<th scope="col">Allowed</th>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}
<p>Analysis will show each affected table and required permission.</p>
{% endif %}
</thead>
<tbody>
{% for row in analysis_rows %}
<tr>
<td><code>{{ row.operation }}</code></td>
<td><code>{{ row.database }}</code></td>
<td><code>{{ row.table }}</code></td>
<td>{% if row.required_permission %}<code>{{ row.required_permission }}</code>{% endif %}</td>
<td>{% if row.allowed is none %}{% elif row.allowed %}<span class="execute-write-analysis-allowed">yes</span>{% else %}<span class="execute-write-analysis-denied">no</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}
<p>Analysis will show each affected table and required permission.</p>
{% endif %}
</div>
<p><input type="submit" value="Execute"{% if execute_disabled %} disabled{% endif %}></p>
<p><input type="submit" value="Execute" data-execute-write-submit{% if execute_disabled %} disabled{% endif %}></p>
</form>
<script>
@ -221,6 +225,40 @@ if (executeWriteSqlInput && !executeWriteSqlInput.value) {
<script>
window.addEventListener("DOMContentLoaded", () => {
const form = document.querySelector("form.sql.core");
const parametersSection = document.querySelector(
"#execute-write-parameters-section"
);
const analysisSection = document.querySelector("#execute-write-analysis-section");
const submitButton = form
? form.querySelector("[data-execute-write-submit]")
: null;
const sqlInput = document.querySelector("textarea#sql-editor");
let parameterState = new Map();
let refreshTimer = null;
let refreshSequence = 0;
function currentSql() {
if (window.editor) {
return window.editor.state.doc.toString();
}
return sqlInput ? sqlInput.value : "";
}
function controlState(control) {
return {
value: control.value,
expanded: control.tagName.toLowerCase() === "textarea",
};
}
function syncParameterState() {
parameterState = new Map();
document.querySelectorAll("[data-parameter-control]").forEach((control) => {
parameterState.set(control.name, controlState(control));
});
}
function replaceParameterControl(control, button, expand, value, selectionStart) {
const replacement = document.createElement(expand ? "textarea" : "input");
replacement.id = control.id;
@ -241,54 +279,290 @@ window.addEventListener("DOMContentLoaded", () => {
if (selectionStart !== undefined && replacement.setSelectionRange) {
replacement.setSelectionRange(selectionStart, selectionStart);
}
parameterState.set(replacement.name, controlState(replacement));
}
document.querySelectorAll("[data-parameter-toggle]").forEach((button) => {
button.addEventListener("click", () => {
const control = document.getElementById(button.getAttribute("aria-controls"));
if (!control) {
function createControl(parameter, id, state) {
const control = document.createElement(state.expanded ? "textarea" : "input");
control.id = id;
control.name = parameter;
control.value = state.value;
control.setAttribute("data-parameter-control", "");
if (state.expanded) {
control.rows = 5;
} else {
control.type = "text";
}
return control;
}
function renderExecuteWriteParameters(parameters) {
if (!parametersSection) {
return;
}
syncParameterState();
const previousState = parameterState;
const nextState = new Map();
parametersSection.replaceChildren();
if (!parameters.length) {
parameterState = nextState;
return;
}
const heading = document.createElement("h2");
heading.textContent = "Parameters";
parametersSection.appendChild(heading);
parameters.forEach((parameter, index) => {
const id = `qp${index + 1}`;
const state = previousState.get(parameter) || {
value: "",
expanded: false,
};
nextState.set(parameter, state);
const row = document.createElement("p");
row.className = "execute-write-parameter-row";
const label = document.createElement("label");
label.htmlFor = id;
label.textContent = parameter;
const control = createControl(parameter, id, state);
const button = document.createElement("button");
button.type = "button";
button.className = "execute-write-parameter-toggle";
button.setAttribute("data-parameter-toggle", "");
button.setAttribute("aria-controls", id);
button.setAttribute("aria-expanded", state.expanded ? "true" : "false");
button.textContent = state.expanded ? "Collapse" : "Expand";
row.append(label, control, button);
parametersSection.appendChild(row);
});
parameterState = nextState;
}
function appendCodeCell(row, value) {
const cell = document.createElement("td");
if (value) {
const code = document.createElement("code");
code.textContent = value;
cell.appendChild(code);
}
row.appendChild(cell);
}
function renderExecuteWriteAnalysis(data) {
if (!analysisSection) {
return;
}
analysisSection.replaceChildren();
const heading = document.createElement("h2");
heading.textContent = "Query operations";
analysisSection.appendChild(heading);
if (data.analysis_error) {
const error = document.createElement("p");
error.className = "message-error";
error.textContent = data.analysis_error;
analysisSection.appendChild(error);
return;
}
const rows = data.analysis_rows || [];
if (!rows.length) {
const empty = document.createElement("p");
empty.textContent =
"Analysis will show each affected table and required permission.";
analysisSection.appendChild(empty);
return;
}
const wrapper = document.createElement("div");
wrapper.className = "table-wrapper";
const table = document.createElement("table");
table.className = "execute-write-analysis";
const thead = document.createElement("thead");
const headerRow = document.createElement("tr");
[
"Operation",
"Database",
"Table",
"Required permission",
"Allowed",
].forEach((label) => {
const th = document.createElement("th");
th.scope = "col";
th.textContent = label;
headerRow.appendChild(th);
});
thead.appendChild(headerRow);
table.appendChild(thead);
const tbody = document.createElement("tbody");
rows.forEach((analysisRow) => {
const row = document.createElement("tr");
appendCodeCell(row, analysisRow.operation);
appendCodeCell(row, analysisRow.database);
appendCodeCell(row, analysisRow.table);
appendCodeCell(row, analysisRow.required_permission);
const allowedCell = document.createElement("td");
if (analysisRow.allowed !== null && analysisRow.allowed !== undefined) {
const allowed = document.createElement("span");
allowed.className = analysisRow.allowed
? "execute-write-analysis-allowed"
: "execute-write-analysis-denied";
allowed.textContent = analysisRow.allowed ? "yes" : "no";
allowedCell.appendChild(allowed);
}
row.appendChild(allowedCell);
tbody.appendChild(row);
});
table.appendChild(tbody);
wrapper.appendChild(table);
analysisSection.appendChild(wrapper);
}
async function refreshExecuteWriteAnalysis() {
if (!form || !form.dataset.analyzeUrl) {
return;
}
const sequence = ++refreshSequence;
try {
const response = await fetch(form.dataset.analyzeUrl, {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/json",
},
body: JSON.stringify({ sql: currentSql() }),
});
const data = await response.json();
if (sequence !== refreshSequence) {
return;
}
const expanded = control.tagName.toLowerCase() === "textarea";
replaceParameterControl(control, button, !expanded);
});
if (!response.ok) {
throw new Error((data.errors || [response.statusText]).join("; "));
}
renderExecuteWriteParameters(data.parameters || []);
renderExecuteWriteAnalysis(data);
if (submitButton) {
submitButton.disabled = data.execute_disabled;
}
} catch (error) {
if (sequence !== refreshSequence) {
return;
}
renderExecuteWriteAnalysis({
analysis_error: error.message,
analysis_rows: [],
});
if (submitButton) {
submitButton.disabled = true;
}
}
}
function scheduleExecuteWriteAnalysisRefresh() {
clearTimeout(refreshTimer);
refreshTimer = setTimeout(refreshExecuteWriteAnalysis, 350);
}
function wrapEditorDispatch() {
if (!window.editor || window.editor.executeWriteRefreshWrapped) {
return false;
}
const editor = window.editor;
const originalDispatch = editor.dispatch.bind(editor);
editor.dispatch = (...transactions) => {
const before = editor.state.doc.toString();
originalDispatch(...transactions);
if (editor.state.doc.toString() !== before) {
scheduleExecuteWriteAnalysisRefresh();
}
};
editor.executeWriteRefreshWrapped = true;
return true;
}
if (!wrapEditorDispatch()) {
setTimeout(wrapEditorDispatch, 0);
}
const editorElement = document.querySelector(".execute-write-editor .cm-content");
if (editorElement) {
editorElement.addEventListener("input", scheduleExecuteWriteAnalysisRefresh);
} else if (sqlInput) {
sqlInput.addEventListener("input", scheduleExecuteWriteAnalysisRefresh);
}
if (!form) {
return;
}
form.addEventListener("click", (event) => {
const button = event.target.closest
? event.target.closest("[data-parameter-toggle]")
: null;
if (!button || !form.contains(button)) {
return;
}
const control = document.getElementById(button.getAttribute("aria-controls"));
if (!control) {
return;
}
const expanded = control.tagName.toLowerCase() === "textarea";
replaceParameterControl(control, button, !expanded);
});
document
.querySelector("form.sql.core")
.addEventListener("paste", (event) => {
const control = event.target;
if (
!(control instanceof HTMLInputElement) ||
!control.matches("[data-parameter-control]")
) {
return;
}
const pasted = event.clipboardData ? event.clipboardData.getData("text") : "";
if (!/[\r\n]/.test(pasted)) {
return;
}
const button = document.querySelector(
`[data-parameter-toggle][aria-controls="${control.id}"]`
);
if (!button) {
return;
}
event.preventDefault();
const selectionStart = control.selectionStart ?? control.value.length;
const selectionEnd = control.selectionEnd ?? selectionStart;
const value =
control.value.slice(0, selectionStart) +
pasted +
control.value.slice(selectionEnd);
replaceParameterControl(
control,
button,
true,
value,
selectionStart + pasted.length
);
});
form.addEventListener("input", (event) => {
const control = event.target;
if (!control.matches || !control.matches("[data-parameter-control]")) {
return;
}
parameterState.set(control.name, controlState(control));
});
form.addEventListener("paste", (event) => {
const control = event.target;
if (
!(control instanceof HTMLInputElement) ||
!control.matches("[data-parameter-control]")
) {
return;
}
const pasted = event.clipboardData ? event.clipboardData.getData("text") : "";
if (!/[\r\n]/.test(pasted)) {
return;
}
const button = document.querySelector(
`[data-parameter-toggle][aria-controls="${control.id}"]`
);
if (!button) {
return;
}
event.preventDefault();
const selectionStart = control.selectionStart ?? control.value.length;
const selectionEnd = control.selectionEnd ?? selectionStart;
const value =
control.value.slice(0, selectionStart) +
pasted +
control.value.slice(selectionEnd);
replaceParameterControl(
control,
button,
true,
value,
selectionStart + pasted.length
);
});
syncParameterState();
});
</script>

View file

@ -680,6 +680,39 @@ async def _prepare_execute_write(datasette, db, sql, params, actor):
return parameter_names, params, analysis
async def _execute_write_analysis_data(datasette, db, sql, actor):
parameter_names = []
analysis_rows = []
analysis_error = None
if sql:
try:
parameter_names = _derived_query_parameters(sql)
params = {parameter: "" for parameter in parameter_names}
analysis = await db.analyze_sql(sql, params)
if _analysis_is_write(analysis):
analysis_rows = await _analysis_rows_with_permissions(
datasette, analysis, actor
)
else:
analysis_error = (
"Use /-/query for read-only SQL; "
"this endpoint only executes writes"
)
except (QueryValidationError, sqlite3.DatabaseError) as ex:
analysis_error = getattr(ex, "message", str(ex))
return {
"ok": analysis_error is None,
"parameters": parameter_names,
"analysis_error": analysis_error,
"analysis_rows": [row for row in analysis_rows if row["operation"] != "read"],
"execute_disabled": bool(
(not sql)
or analysis_error
or any(row["allowed"] is False for row in analysis_rows)
),
}
async def _inserted_row_url(datasette, db, analysis, cursor):
if cursor.rowcount != 1:
return None
@ -1024,6 +1057,45 @@ class ExecuteWriteView(BaseView):
)
class ExecuteWriteAnalyzeView(BaseView):
name = "execute-write-analyze"
has_json_alternate = False
async def post(self, request):
db = await self.ds.resolve_database(request)
if not await self.ds.allowed(
action="execute-write-sql",
resource=DatabaseResource(db.name),
actor=request.actor,
):
return _block_framing(
_error(["Permission denied: need execute-write-sql"], 403)
)
try:
data, _ = await _json_or_form_payload(request)
except QueryValidationError as ex:
return _block_framing(_error([ex.message], ex.status))
if not isinstance(data, dict):
return _block_framing(_error(["JSON must be a dictionary"], 400))
invalid_keys = set(data) - {"sql"}
if invalid_keys:
return _block_framing(
_error(
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
400,
)
)
sql = data.get("sql") or ""
if not isinstance(sql, str):
return _block_framing(_error(["sql must be a string"], 400))
return _block_framing(
Response.json(
await _execute_write_analysis_data(self.ds, db, sql, request.actor)
)
)
class QueryListView(BaseView):
name = "query-list"

View file

@ -528,6 +528,7 @@ Creating saved queries
``POST /<database>/-/queries/-/insert`` creates a saved query. This requires ``execute-sql`` and ``insert-query`` for the database.
.. _ExecuteWriteView:
.. _ExecuteWriteAnalyzeView:
Executing write SQL
~~~~~~~~~~~~~~~~~~~
@ -536,6 +537,8 @@ Executing write SQL
``POST /<database>/-/execute-write`` executes writable SQL. This requires ``execute-write-sql`` for the database plus the relevant table-level write permissions.
``POST /<database>/-/execute-write/-/analyze`` accepts ``{"sql": "..."}`` and returns the derived parameters plus the write operations that SQL would need in order to execute.
.. _QueryDefinitionView:
Getting a saved query definition

View file

@ -719,7 +719,9 @@ async def test_execute_write_get_prepopulates_without_executing():
assert 'data-sql-template="insert"' in response.text
assert 'data-sql-template="update"' in response.text
assert 'data-sql-template="delete"' in response.text
assert 'data-analyze-url="/data/-/execute-write/-/analyze"' in response.text
assert 'addEventListener("paste"' in response.text
assert "refreshExecuteWriteAnalysis" in response.text
assert '<table class="execute-write-analysis">' in response.text
assert '<th scope="col">Required permission</th>' in response.text
assert "<td><code>insert</code></td>" in response.text
@ -737,6 +739,53 @@ async def test_execute_write_get_prepopulates_without_executing():
assert 'executeWriteSqlInput.value = "\\n\\n\\n";' in empty_response.text
@pytest.mark.asyncio
async def test_execute_write_analyze_endpoint_uses_sql_only():
ds = Datasette(memory=True, default_deny=True)
ds.root_enabled = True
db = ds.add_memory_database("execute_write_analyze", name="data")
await db.execute_write("create table dogs (id integer primary key, name text)")
await ds.invoke_startup()
response = await ds.client.post(
"/data/-/execute-write/-/analyze",
actor={"id": "root"},
json={"sql": "insert into dogs (name) values (:name)"},
)
read_only_response = await ds.client.post(
"/data/-/execute-write/-/analyze",
actor={"id": "root"},
json={"sql": "select * from dogs where name = :name"},
)
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
assert data["parameters"] == ["name"]
assert data["analysis_error"] is None
assert data["execute_disabled"] is False
assert data["analysis_rows"] == [
{
"operation": "insert",
"database": "data",
"table": "dogs",
"required_permission": "insert-row",
"source": None,
"allowed": True,
}
]
assert "params" not in data
assert read_only_response.status_code == 200
read_only_data = read_only_response.json()
assert read_only_data["ok"] is False
assert read_only_data["parameters"] == ["name"]
assert read_only_data["analysis_error"] == (
"Use /-/query for read-only SQL; this endpoint only executes writes"
)
assert read_only_data["execute_disabled"] is True
@pytest.mark.asyncio
async def test_database_action_menu_links_to_execute_write_for_permitted_actor():
ds = Datasette(