mirror of
https://github.com/simonw/datasette.git
synced 2026-06-01 14:47:00 +02:00
Update parameters/query operations as user edits the write query
Refs #2742
This commit is contained in:
parent
abb17ba773
commit
e1261442c0
5 changed files with 476 additions and 73 deletions
|
|
@ -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$",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue