Refactor to share JS/HTML between execute and execute-write

Refs #2742
This commit is contained in:
Simon Willison 2026-05-25 12:45:42 -07:00
commit 1f7c26ffea
12 changed files with 494 additions and 330 deletions

View file

@ -56,6 +56,7 @@ from .views.database import (
GlobalQueryListView,
QueryInsertView,
QueryListView,
QueryParametersView,
QueryUpdateView,
)
from .views.index import IndexView
@ -2758,6 +2759,10 @@ class Datasette:
DatabaseSchemaView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/schema(\.(?P<format>json|md))?$",
)
add_route(
QueryParametersView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/query/-/parameters$",
)
add_route(
wrap_view(QueryView, self),
r"/(?P<database>[^\/\.]+)/-/query(\.(?P<format>\w+))?$",

View file

@ -0,0 +1,286 @@
<script>
window.datasetteSqlParameters = (() => {
if (
window.datasetteSqlParameters &&
window.datasetteSqlParameters.setupSqlParameterRefresh
) {
return window.datasetteSqlParameters;
}
function currentSql(form) {
if (window.editor) {
return window.editor.state.doc.toString();
}
const sqlInput = form.querySelector("textarea#sql-editor, input[name=sql]");
return sqlInput ? sqlInput.value : "";
}
function controlState(control) {
return {
value: control.value,
expanded: control.tagName.toLowerCase() === "textarea",
};
}
function syncParameterState(manager) {
manager.parameterState = new Map();
manager.section
.querySelectorAll("[data-parameter-control]")
.forEach((control) => {
manager.parameterState.set(control.name, controlState(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 replaceParameterControl(
manager,
control,
button,
expand,
value,
selectionStart
) {
const replacement = createControl(control.name, control.id, {
value: value === undefined ? control.value : value,
expanded: expand,
});
button.textContent = expand ? "Collapse" : "Expand";
button.setAttribute("aria-expanded", expand ? "true" : "false");
control.replaceWith(replacement);
replacement.focus();
if (selectionStart !== undefined && replacement.setSelectionRange) {
replacement.setSelectionRange(selectionStart, selectionStart);
}
manager.parameterState.set(replacement.name, controlState(replacement));
}
function renderParameters(manager, parameters) {
syncParameterState(manager);
const previousState = manager.parameterState;
const nextState = new Map();
manager.section.replaceChildren();
if (!parameters.length) {
manager.parameterState = nextState;
return;
}
const heading = document.createElement("h2");
heading.textContent = "Parameters";
manager.section.appendChild(heading);
parameters.forEach((parameter, index) => {
const id = `qp${index + 1}`;
const state = previousState.get(parameter) || {
value: "",
expanded: false,
};
if (!manager.allowExpand) {
state.expanded = false;
}
nextState.set(parameter, state);
const row = document.createElement("p");
row.className = "sql-parameter-row";
const label = document.createElement("label");
label.htmlFor = id;
label.textContent = parameter;
const control = createControl(parameter, id, state);
row.append(label, control);
if (manager.allowExpand) {
const button = document.createElement("button");
button.type = "button";
button.className = "sql-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(" ", button);
}
manager.section.appendChild(row);
});
manager.parameterState = nextState;
}
function bindParameterControls(manager) {
manager.form.addEventListener("input", (event) => {
const control = event.target;
if (!control.matches || !control.matches("[data-parameter-control]")) {
return;
}
manager.parameterState.set(control.name, controlState(control));
});
if (!manager.allowExpand) {
return;
}
manager.form.addEventListener("click", (event) => {
const button = event.target.closest
? event.target.closest("[data-parameter-toggle]")
: null;
if (!button || !manager.form.contains(button)) {
return;
}
const control = document.getElementById(button.getAttribute("aria-controls"));
if (!control) {
return;
}
const expanded = control.tagName.toLowerCase() === "textarea";
replaceParameterControl(manager, control, button, !expanded);
});
manager.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(
manager,
control,
button,
true,
value,
selectionStart + pasted.length
);
});
}
function bindEditorChanges(form, callback) {
const editorElement = form.querySelector(".cm-content");
if (editorElement) {
editorElement.addEventListener("input", callback);
}
if (!window.editor) {
const sqlInput = form.querySelector("textarea#sql-editor");
if (sqlInput) {
sqlInput.addEventListener("input", callback);
}
return;
}
if (!window.editor.datasetteSqlParameterCallbacks) {
const editor = window.editor;
const originalDispatch = editor.dispatch.bind(editor);
editor.datasetteSqlParameterCallbacks = [];
editor.dispatch = (...transactions) => {
const before = editor.state.doc.toString();
originalDispatch(...transactions);
if (editor.state.doc.toString() !== before) {
editor.datasetteSqlParameterCallbacks.forEach((listener) => listener());
}
};
}
window.editor.datasetteSqlParameterCallbacks.push(callback);
}
function setupSqlParameterRefresh(options) {
const form =
options.form || document.querySelector("form.sql.core[data-parameters-url]");
if (!form) {
return null;
}
const section =
options.section || form.querySelector("[data-sql-parameters-section]");
if (!section) {
return null;
}
const manager = {
form,
section,
allowExpand:
options.allowExpand === undefined
? section.dataset.allowExpand === "1"
: options.allowExpand,
parameterState: new Map(),
};
bindParameterControls(manager);
syncParameterState(manager);
const url = options.url || form.dataset.parametersUrl;
let refreshTimer = null;
let refreshSequence = 0;
async function refreshParameters() {
if (!url) {
return;
}
const sequence = ++refreshSequence;
try {
const requestUrl = new URL(url, window.location.href);
requestUrl.searchParams.set("sql", currentSql(form));
const response = await fetch(requestUrl, {
headers: { accept: "application/json" },
});
const data = await response.json();
if (sequence !== refreshSequence) {
return;
}
if (!response.ok) {
throw new Error((data.errors || [response.statusText]).join("; "));
}
renderParameters(manager, data.parameters || []);
if (options.onData) {
options.onData(data, manager);
}
} catch (error) {
if (sequence !== refreshSequence) {
return;
}
if (options.onError) {
options.onError(error, manager);
}
}
}
function scheduleRefresh() {
clearTimeout(refreshTimer);
refreshTimer = setTimeout(refreshParameters, options.debounceMs || 350);
}
bindEditorChanges(form, scheduleRefresh);
return {
currentSql: () => currentSql(form),
refreshParameters,
renderParameters: (parameters) => renderParameters(manager, parameters),
};
}
return { setupSqlParameterRefresh };
})();
</script>

View file

@ -0,0 +1,58 @@
<style>
form.sql .sql-editor {
max-width: 52rem;
}
form.sql .sql-editor textarea#sql-editor {
width: 100%;
}
form.sql .sql-parameters-section {
max-width: 52rem;
}
form.sql .sql-parameter-row {
align-items: start;
column-gap: 0.6rem;
display: grid;
grid-template-columns: minmax(8rem, 11rem) minmax(16rem, 1fr) auto;
margin: 0 0 0.65rem;
max-width: 52rem;
}
form.sql .sql-parameter-row label {
overflow-wrap: anywhere;
padding-top: 0.55rem;
width: auto;
}
form.sql .sql-parameter-row input[data-parameter-control],
form.sql .sql-parameter-row textarea[data-parameter-control] {
box-sizing: border-box;
width: 100%;
}
form.sql .sql-parameter-row textarea[data-parameter-control] {
border: 1px solid #ccc;
border-radius: 3px;
display: block;
font-family: Helvetica, sans-serif;
font-size: 1em;
min-height: 7rem;
padding: 9px 4px;
}
form.sql.core button.sql-parameter-toggle[type=button] {
font-size: 0.72rem;
height: 1.8rem;
line-height: 1;
margin: 0.25rem 0 0;
padding: 0.25rem 0.45rem;
}
@media (max-width: 480px) {
form.sql .sql-parameter-row {
grid-template-columns: 1fr;
row-gap: 0.25rem;
}
form.sql .sql-parameter-row label {
padding-top: 0;
}
form.sql.core button.sql-parameter-toggle[type=button] {
justify-self: start;
margin-top: 0;
}
}
</style>

View file

@ -0,0 +1,9 @@
<div id="{{ sql_parameters_section_id|default("sql-parameters-section") }}" class="sql-parameters-section" data-sql-parameters-section{% if sql_parameters_allow_expand|default(false) %} data-allow-expand="1"{% endif %}>
{% if parameter_names %}
<h2>Parameters</h2>
{% for parameter in parameter_names %}
{% set parameter_id = (sql_parameter_id_prefix|default("qp")) ~ loop.index %}
<p class="sql-parameter-row"><label for="{{ parameter_id }}">{{ parameter }}</label> <input type="text" id="{{ parameter_id }}" name="{{ parameter }}" value="{{ parameter_values.get(parameter, "") }}" data-parameter-control>{% if sql_parameters_allow_expand|default(false) %} <button type="button" class="sql-parameter-toggle" data-parameter-toggle aria-controls="{{ parameter_id }}" aria-expanded="false">Expand</button>{% endif %}</p>
{% endfor %}
{% endif %}
</div>

View file

@ -5,6 +5,7 @@
{% block extra_head %}
{{- super() -}}
{% include "_codemirror.html" %}
{% include "_sql_parameter_styles.html" %}
{% endblock %}
{% block body_class %}db db-{{ database|to_css_class }}{% endblock %}
@ -25,9 +26,13 @@
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
{% if allow_execute_sql %}
<form class="sql core" action="{{ urls.database(database) }}/-/query" method="get">
<form class="sql core" action="{{ urls.database(database) }}/-/query" method="get" data-parameters-url="{{ urls.database(database) }}/-/query/-/parameters">
<h3>Custom SQL query</h3>
<p><textarea id="sql-editor" name="sql">{% if tables %}select * from {{ tables[0].name|escape_sqlite }}{% else %}select sqlite_version(){% endif %}</textarea></p>
<p class="sql-editor"><textarea id="sql-editor" name="sql">{% if tables %}select * from {{ tables[0].name|escape_sqlite }}{% else %}select sqlite_version(){% endif %}</textarea></p>
{% set parameter_names = [] %}
{% set parameter_values = {} %}
{% set sql_parameters_allow_expand = false %}
{% include "_sql_parameters.html" %}
<p>
<button id="sql-format" type="button" hidden>Format SQL</button>
<input type="submit" value="Run SQL">
@ -90,5 +95,11 @@
{% endif %}
{% include "_codemirror_foot.html" %}
{% include "_sql_parameter_scripts.html" %}
<script>
window.addEventListener("DOMContentLoaded", () => {
window.datasetteSqlParameters.setupSqlParameterRefresh({});
});
</script>
{% endblock %}

View file

@ -75,61 +75,8 @@
color: #b00020;
font-weight: 700;
}
form.sql .execute-write-parameter-row textarea[data-parameter-control] {
border: 1px solid #ccc;
border-radius: 3px;
box-sizing: border-box;
display: block;
font-family: Helvetica, sans-serif;
font-size: 1em;
min-height: 7rem;
padding: 9px 4px;
width: 100%;
}
form.sql .execute-write-parameter-row {
align-items: start;
column-gap: 0.6rem;
display: grid;
grid-template-columns: minmax(8rem, 11rem) minmax(16rem, 1fr) auto;
margin: 0 0 0.65rem;
max-width: 52rem;
}
form.sql .execute-write-parameter-row label {
overflow-wrap: anywhere;
padding-top: 0.55rem;
width: auto;
}
form.sql .execute-write-parameter-row input[data-parameter-control] {
box-sizing: border-box;
width: 100%;
}
form.sql.core button.execute-write-parameter-toggle[type=button] {
font-size: 0.72rem;
height: 1.8rem;
line-height: 1;
margin: 0.25rem 0 0;
padding: 0.25rem 0.45rem;
}
@media (max-width: 480px) {
form.sql .execute-write-parameter-row {
grid-template-columns: 1fr;
row-gap: 0.25rem;
}
form.sql .execute-write-parameter-row label {
padding-top: 0;
}
form.sql.core button.execute-write-parameter-toggle[type=button] {
justify-self: start;
margin-top: 0;
}
}
form.sql .execute-write-editor {
max-width: 52rem;
}
form.sql .execute-write-editor textarea#sql-editor {
width: 100%;
}
</style>
{% include "_sql_parameter_styles.html" %}
{% endblock %}
{% block body_class %}execute-write db-{{ database|to_css_class }}{% endblock %}
@ -168,16 +115,11 @@ form.sql .execute-write-editor textarea#sql-editor {
</div>
{% endif %}
<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>
<p class="sql-editor"><textarea id="sql-editor" name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></p>
<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>
{% set sql_parameters_section_id = "execute-write-parameters-section" %}
{% set sql_parameters_allow_expand = true %}
{% include "_sql_parameters.html" %}
<div id="execute-write-analysis-section">
<h2>Query operations</h2>
@ -222,128 +164,15 @@ if (executeWriteSqlInput && !executeWriteSqlInput.value) {
</script>
{% include "_codemirror_foot.html" %}
{% include "_sql_parameter_scripts.html" %}
<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;
replacement.name = control.name;
replacement.value = value === undefined ? control.value : value;
replacement.setAttribute("data-parameter-control", "");
if (expand) {
replacement.rows = 5;
button.textContent = "Collapse";
button.setAttribute("aria-expanded", "true");
} else {
replacement.type = "text";
button.textContent = "Expand";
button.setAttribute("aria-expanded", "false");
}
control.replaceWith(replacement);
replacement.focus();
if (selectionStart !== undefined && replacement.setSelectionRange) {
replacement.setSelectionRange(selectionStart, selectionStart);
}
parameterState.set(replacement.name, controlState(replacement));
}
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");
@ -428,36 +257,17 @@ window.addEventListener("DOMContentLoaded", () => {
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;
}
if (!response.ok) {
throw new Error((data.errors || [response.statusText]).join("; "));
}
renderExecuteWriteParameters(data.parameters || []);
window.datasetteSqlParameters.setupSqlParameterRefresh({
form,
url: form.dataset.analyzeUrl,
allowExpand: true,
onData(data) {
renderExecuteWriteAnalysis(data);
if (submitButton) {
submitButton.disabled = data.execute_disabled;
}
} catch (error) {
if (sequence !== refreshSequence) {
return;
}
},
onError(error) {
renderExecuteWriteAnalysis({
analysis_error: error.message,
analysis_rows: [],
@ -465,104 +275,8 @@ window.addEventListener("DOMContentLoaded", () => {
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);
},
});
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

@ -14,6 +14,7 @@
</style>
{% endif %}
{% include "_codemirror.html" %}
{% include "_sql_parameter_styles.html" %}
{% endblock %}
{% block body_class %}query db-{{ database|to_css_class }}{% if canned_query %} query-{{ canned_query|to_css_class }}{% endif %}{% endblock %}
@ -36,7 +37,7 @@
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
<form class="sql core" action="{{ urls.database(database) }}{% if canned_query %}/{{ canned_query }}{% endif %}" method="{% if canned_query_write %}post{% else %}get{% endif %}">
<form class="sql core" action="{{ urls.database(database) }}{% if canned_query %}/{{ canned_query }}{% endif %}" method="{% if canned_query_write %}post{% else %}get{% endif %}" data-parameters-url="{{ urls.database(database) }}/-/query/-/parameters">
<h3>Custom SQL query{% if display_rows %} returning {% if truncated %}more than {% endif %}{{ "{:,}".format(display_rows|length) }} row{% if display_rows|length == 1 %}{% else %}s{% endif %}{% endif %}{% if not query_error %}
<span class="show-hide-sql">(<a href="{{ show_hide_link }}">{{ show_hide_text }}</a>)</span>
{% endif %}</h3>
@ -45,7 +46,7 @@
{% endif %}
{% if not hide_sql %}
{% if editable and allow_execute_sql %}
<p><textarea id="sql-editor" name="sql"{% if query and query.sql %} style="height: {{ query.sql.split("\n")|length + 2 }}em"{% endif %}
<p class="sql-editor"><textarea id="sql-editor" name="sql"{% if query and query.sql %} style="height: {{ query.sql.split("\n")|length + 2 }}em"{% endif %}
>{% if query and query.sql %}{{ query.sql }}{% else %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}</textarea></p>
{% else %}
<pre id="sql-query">{% if query %}{{ query.sql }}{% endif %}</pre>
@ -57,12 +58,10 @@
>
{% endif %}
{% endif %}
{% if named_parameter_values %}
<h3>Query parameters</h3>
{% for name, value in named_parameter_values.items() %}
<p><label for="qp{{ loop.index }}">{{ name }}</label> <input type="text" id="qp{{ loop.index }}" name="{{ name }}" value="{{ value }}"></p>
{% endfor %}
{% endif %}
{% set parameter_names = named_parameter_values.keys()|list %}
{% set parameter_values = named_parameter_values %}
{% set sql_parameters_allow_expand = false %}
{% include "_sql_parameters.html" %}
<p>
{% if not hide_sql %}<button id="sql-format" type="button" hidden>Format SQL</button>{% endif %}
<input type="submit" value="Run SQL"{% if canned_query_write and db_is_immutable %} disabled{% endif %}>
@ -97,5 +96,11 @@
{% endif %}
{% include "_codemirror_foot.html" %}
{% include "_sql_parameter_scripts.html" %}
<script>
window.addEventListener("DOMContentLoaded", () => {
window.datasetteSqlParameters.setupSqlParameterRefresh({});
});
</script>
{% endblock %}

View file

@ -1061,7 +1061,7 @@ class ExecuteWriteAnalyzeView(BaseView):
name = "execute-write-analyze"
has_json_alternate = False
async def post(self, request):
async def get(self, request):
db = await self.ds.resolve_database(request)
if not await self.ds.allowed(
action="execute-write-sql",
@ -1072,13 +1072,7 @@ class ExecuteWriteAnalyzeView(BaseView):
_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"}
invalid_keys = set(request.args) - {"sql"}
if invalid_keys:
return _block_framing(
_error(
@ -1086,9 +1080,7 @@ class ExecuteWriteAnalyzeView(BaseView):
400,
)
)
sql = data.get("sql") or ""
if not isinstance(sql, str):
return _block_framing(_error(["sql must be a string"], 400))
sql = request.args.get("sql") or ""
return _block_framing(
Response.json(
await _execute_write_analysis_data(self.ds, db, sql, request.actor)
@ -1096,6 +1088,34 @@ class ExecuteWriteAnalyzeView(BaseView):
)
class QueryParametersView(BaseView):
name = "query-parameters"
has_json_alternate = False
async def get(self, request):
db = await self.ds.resolve_database(request)
if not await self.ds.allowed(
action="execute-sql",
resource=DatabaseResource(db.name),
actor=request.actor,
):
return _block_framing(_error(["Permission denied: need execute-sql"], 403))
invalid_keys = set(request.args) - {"sql"}
if invalid_keys:
return _block_framing(
_error(
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
400,
)
)
try:
parameters = _derived_query_parameters(request.args.get("sql") or "")
except QueryValidationError as ex:
return _block_framing(_error([ex.message], ex.status))
return _block_framing(Response.json({"ok": True, "parameters": parameters}))
class QueryListView(BaseView):
name = "query-list"

View file

@ -527,17 +527,20 @@ Creating saved queries
``POST /<database>/-/queries/-/insert`` creates a saved query. This requires ``execute-sql`` and ``insert-query`` for the database.
.. _QueryParametersView:
.. _ExecuteWriteView:
.. _ExecuteWriteAnalyzeView:
Executing write SQL
~~~~~~~~~~~~~~~~~~~
``GET /<database>/-/query/-/parameters?sql=...`` returns the named parameters used by a SQL query. This requires ``execute-sql`` for the database.
``GET /<database>/-/execute-write`` displays a form for executing writable SQL. A ``?sql=`` query string pre-populates the form without executing it.
``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.
``GET /<database>/-/execute-write/-/analyze?sql=...`` returns the derived parameters plus the write operations that SQL would need in order to execute.
.. _QueryDefinitionView:

View file

@ -200,7 +200,10 @@ def test_error_in_on_success_message_sql(canned_write_client):
def test_custom_params(canned_write_client):
response = canned_write_client.get("/data/update_name?extra=foo")
assert '<input type="text" id="qp3" name="extra" value="foo">' in response.text
assert (
'<input type="text" id="qp3" name="extra" value="foo" data-parameter-control>'
in response.text
)
def test_canned_query_pages_no_vary_header(canned_write_client):

View file

@ -326,17 +326,29 @@ async def test_query_parameter_form_fields(ds_client):
response = await ds_client.get("/fixtures/-/query?sql=select+:name")
assert response.status_code == 200
assert (
'<label for="qp1">name</label> <input type="text" id="qp1" name="name" value="">'
'<label for="qp1">name</label> <input type="text" id="qp1" name="name" value="" data-parameter-control>'
in response.text
)
assert 'data-parameters-url="/fixtures/-/query/-/parameters"' in response.text
assert 'id="sql-parameters-section"' in response.text
assert "setupSqlParameterRefresh" in response.text
response2 = await ds_client.get("/fixtures/-/query?sql=select+:name&name=hello")
assert response2.status_code == 200
assert (
'<label for="qp1">name</label> <input type="text" id="qp1" name="name" value="hello">'
'<label for="qp1">name</label> <input type="text" id="qp1" name="name" value="hello" data-parameter-control>'
in response2.text
)
@pytest.mark.asyncio
async def test_database_page_sql_parameter_refresh_markup(ds_client):
response = await ds_client.get("/fixtures")
assert response.status_code == 200
assert 'data-parameters-url="/fixtures/-/query/-/parameters"' in response.text
assert 'id="sql-parameters-section"' in response.text
assert "setupSqlParameterRefresh" in response.text
@pytest.mark.asyncio
async def test_row_html_simple_primary_key(ds_client):
response = await ds_client.get("/fixtures/simple_primary_key/1")

View file

@ -721,7 +721,7 @@ async def test_execute_write_get_prepopulates_without_executing():
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 "setupSqlParameterRefresh" 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
@ -747,15 +747,15 @@ async def test_execute_write_analyze_endpoint_uses_sql_only():
await db.execute_write("create table dogs (id integer primary key, name text)")
await ds.invoke_startup()
response = await ds.client.post(
response = await ds.client.get(
"/data/-/execute-write/-/analyze",
actor={"id": "root"},
json={"sql": "insert into dogs (name) values (:name)"},
params={"sql": "insert into dogs (name) values (:name)"},
)
read_only_response = await ds.client.post(
read_only_response = await ds.client.get(
"/data/-/execute-write/-/analyze",
actor={"id": "root"},
json={"sql": "select * from dogs where name = :name"},
params={"sql": "select * from dogs where name = :name"},
)
assert response.status_code == 200
@ -786,6 +786,44 @@ async def test_execute_write_analyze_endpoint_uses_sql_only():
assert read_only_data["execute_disabled"] is True
@pytest.mark.asyncio
async def test_query_parameters_endpoint_uses_get_sql_only():
ds = Datasette(memory=True, default_deny=True)
ds.root_enabled = True
db = ds.add_memory_database("query_parameters", name="data")
await db.execute_write("create table dogs (id integer primary key, name text)")
await ds.invoke_startup()
response = await ds.client.get(
"/data/-/query/-/parameters",
actor={"id": "root"},
params={
"sql": "select * from dogs where name = :name and id = :id",
},
)
permission_denied_response = await ds.client.get(
"/data/-/query/-/parameters",
actor={"id": "not-root"},
params={"sql": "select * from dogs where name = :name"},
)
magic_parameter_response = await ds.client.get(
"/data/-/query/-/parameters",
actor={"id": "root"},
params={"sql": "select :_actor_id"},
)
assert response.status_code == 200
assert response.json() == {"ok": True, "parameters": ["name", "id"]}
assert permission_denied_response.status_code == 403
assert permission_denied_response.json()["errors"] == [
"Permission denied: need execute-sql"
]
assert magic_parameter_response.status_code == 400
assert magic_parameter_response.json()["errors"] == [
"Magic parameters are not allowed"
]
@pytest.mark.asyncio
async def test_database_action_menu_links_to_execute_write_for_permitted_actor():
ds = Datasette(