mirror of
https://github.com/simonw/datasette.git
synced 2026-06-02 15:16:59 +02:00
parent
e1261442c0
commit
1f7c26ffea
12 changed files with 494 additions and 330 deletions
|
|
@ -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+))?$",
|
||||
|
|
|
|||
286
datasette/templates/_sql_parameter_scripts.html
Normal file
286
datasette/templates/_sql_parameter_scripts.html
Normal 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>
|
||||
58
datasette/templates/_sql_parameter_styles.html
Normal file
58
datasette/templates/_sql_parameter_styles.html
Normal 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>
|
||||
9
datasette/templates/_sql_parameters.html
Normal file
9
datasette/templates/_sql_parameters.html
Normal 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>
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue