mirror of
https://github.com/simonw/datasette.git
synced 2026-06-11 11:36:58 +02:00
parent
221be2632e
commit
4b5fac9cf7
7 changed files with 904 additions and 8 deletions
|
|
@ -43,7 +43,18 @@ from jinja2.exceptions import TemplateNotFound
|
||||||
from .events import Event
|
from .events import Event
|
||||||
from .column_types import SQLiteType
|
from .column_types import SQLiteType
|
||||||
from .views import Context
|
from .views import Context
|
||||||
from .views.database import database_download, DatabaseView, TableCreateView, QueryView
|
from .views.database import (
|
||||||
|
database_download,
|
||||||
|
DatabaseView,
|
||||||
|
TableCreateView,
|
||||||
|
QueryView,
|
||||||
|
QueryCreateView,
|
||||||
|
QueryDeleteView,
|
||||||
|
QueryDefinitionView,
|
||||||
|
QueryInsertView,
|
||||||
|
QueryListView,
|
||||||
|
QueryUpdateView,
|
||||||
|
)
|
||||||
from .views.index import IndexView
|
from .views.index import IndexView
|
||||||
from .views.special import (
|
from .views.special import (
|
||||||
JsonDataView,
|
JsonDataView,
|
||||||
|
|
@ -2524,6 +2535,18 @@ class Datasette:
|
||||||
r"/(?P<database>[^\/\.]+)(\.(?P<format>\w+))?$",
|
r"/(?P<database>[^\/\.]+)(\.(?P<format>\w+))?$",
|
||||||
)
|
)
|
||||||
add_route(TableCreateView.as_view(self), r"/(?P<database>[^\/\.]+)/-/create$")
|
add_route(TableCreateView.as_view(self), r"/(?P<database>[^\/\.]+)/-/create$")
|
||||||
|
add_route(
|
||||||
|
QueryListView.as_view(self),
|
||||||
|
r"/(?P<database>[^\/\.]+)/-/queries$",
|
||||||
|
)
|
||||||
|
add_route(
|
||||||
|
QueryCreateView.as_view(self),
|
||||||
|
r"/(?P<database>[^\/\.]+)/-/queries/-/create$",
|
||||||
|
)
|
||||||
|
add_route(
|
||||||
|
QueryInsertView.as_view(self),
|
||||||
|
r"/(?P<database>[^\/\.]+)/-/queries/-/insert$",
|
||||||
|
)
|
||||||
add_route(
|
add_route(
|
||||||
DatabaseSchemaView.as_view(self),
|
DatabaseSchemaView.as_view(self),
|
||||||
r"/(?P<database>[^\/\.]+)/-/schema(\.(?P<format>json|md))?$",
|
r"/(?P<database>[^\/\.]+)/-/schema(\.(?P<format>json|md))?$",
|
||||||
|
|
@ -2532,6 +2555,18 @@ class Datasette:
|
||||||
wrap_view(QueryView, self),
|
wrap_view(QueryView, self),
|
||||||
r"/(?P<database>[^\/\.]+)/-/query(\.(?P<format>\w+))?$",
|
r"/(?P<database>[^\/\.]+)/-/query(\.(?P<format>\w+))?$",
|
||||||
)
|
)
|
||||||
|
add_route(
|
||||||
|
QueryDefinitionView.as_view(self),
|
||||||
|
r"/(?P<database>[^\/\.]+)/(?P<query>[^\/\.]+)/-/definition$",
|
||||||
|
)
|
||||||
|
add_route(
|
||||||
|
QueryUpdateView.as_view(self),
|
||||||
|
r"/(?P<database>[^\/\.]+)/(?P<query>[^\/\.]+)/-/update$",
|
||||||
|
)
|
||||||
|
add_route(
|
||||||
|
QueryDeleteView.as_view(self),
|
||||||
|
r"/(?P<database>[^\/\.]+)/(?P<query>[^\/\.]+)/-/delete$",
|
||||||
|
)
|
||||||
add_route(
|
add_route(
|
||||||
wrap_view(table_view, self),
|
wrap_view(table_view, self),
|
||||||
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)(\.(?P<format>\w+))?$",
|
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)(\.(?P<format>\w+))?$",
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@
|
||||||
{% if not hide_sql %}<button id="sql-format" type="button" hidden>Format SQL</button>{% endif %}
|
{% 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 %}>
|
<input type="submit" value="Run SQL"{% if canned_query_write and db_is_immutable %} disabled{% endif %}>
|
||||||
{{ show_hide_hidden }}
|
{{ show_hide_hidden }}
|
||||||
|
{% if save_query_url %}<a href="{{ save_query_url }}" class="save-query">Save query</a>{% endif %}
|
||||||
{% if canned_query and edit_sql_url %}<a href="{{ edit_sql_url }}" class="canned-query-edit-sql">Edit SQL</a>{% endif %}
|
{% if canned_query and edit_sql_url %}<a href="{{ edit_sql_url }}" class="canned-query-edit-sql">Edit SQL</a>{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
71
datasette/templates/query_create.html
Normal file
71
datasette/templates/query_create.html
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Create query{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
{{- super() -}}
|
||||||
|
{% include "_codemirror.html" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body_class %}query-create db-{{ database|to_css_class }}{% endblock %}
|
||||||
|
|
||||||
|
{% block crumbs %}
|
||||||
|
{{ crumbs.nav(request=request, database=database) }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">Create query</h1>
|
||||||
|
|
||||||
|
<form class="sql core" action="{{ urls.database(database) }}/-/queries/-/insert" method="post">
|
||||||
|
<p><label for="query-name">Name</label> <input id="query-name" name="name" type="text"></p>
|
||||||
|
<p><label for="query-title">Title</label> <input id="query-title" name="title" type="text"></p>
|
||||||
|
<p><label for="query-description">Description</label><br><textarea id="query-description" name="description" rows="3"></textarea></p>
|
||||||
|
<p>
|
||||||
|
<label><input type="radio" name="mode" value="read-only" checked> Read-only</label>
|
||||||
|
<label><input type="radio" name="mode" value="writable"> Writable</label>
|
||||||
|
</p>
|
||||||
|
<p><textarea id="sql-editor" name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></p>
|
||||||
|
<p><label for="query-parameters">Parameters</label> <input id="query-parameters" name="parameters" type="text" value="{{ parameter_names|join(', ') }}"></p>
|
||||||
|
{% if can_publish %}
|
||||||
|
<p><label><input type="checkbox" name="published" value="1"> Published</label></p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h2>Analysis</h2>
|
||||||
|
{% if analysis_error %}
|
||||||
|
<p class="message-error">{{ analysis_error }}</p>
|
||||||
|
{% elif analysis_rows %}
|
||||||
|
<div class="table-wrapper"><table>
|
||||||
|
<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>
|
||||||
|
<th scope="col">Source</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in analysis_rows %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ row.operation }}</td>
|
||||||
|
<td>{{ row.database }}</td>
|
||||||
|
<td>{{ row.table }}</td>
|
||||||
|
<td>{{ row.required_permission }}</td>
|
||||||
|
<td>{% if row.allowed is none %}{% elif row.allowed %}yes{% else %}no{% endif %}</td>
|
||||||
|
<td>{{ row.source or "" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table></div>
|
||||||
|
{% else %}
|
||||||
|
<p>Analysis will show each affected table and required permission.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p><input type="submit" value="Save query"{% if save_disabled %} disabled{% endif %}></p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% include "_codemirror_foot.html" %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -241,6 +241,14 @@ async def _build_single_action_sql(
|
||||||
"),",
|
"),",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
query_parts.extend(
|
||||||
|
[
|
||||||
|
"anon_rules AS (",
|
||||||
|
" SELECT NULL AS parent, NULL AS child, 0 AS allow, NULL AS reason WHERE 0",
|
||||||
|
"),",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
# Continue with the cascading logic
|
# Continue with the cascading logic
|
||||||
query_parts.extend(
|
query_parts.extend(
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import textwrap
|
||||||
|
|
||||||
from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent
|
from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent
|
||||||
from datasette.database import QueryInterrupted
|
from datasette.database import QueryInterrupted
|
||||||
from datasette.resources import DatabaseResource, QueryResource
|
from datasette.resources import DatabaseResource, QueryResource, TableResource
|
||||||
from datasette.utils import (
|
from datasette.utils import (
|
||||||
add_cors_headers,
|
add_cors_headers,
|
||||||
await_me_maybe,
|
await_me_maybe,
|
||||||
|
|
@ -302,6 +302,9 @@ class QueryContext(Context):
|
||||||
allow_execute_sql: bool = field(
|
allow_execute_sql: bool = field(
|
||||||
metadata={"help": "Boolean indicating if custom SQL can be executed"}
|
metadata={"help": "Boolean indicating if custom SQL can be executed"}
|
||||||
)
|
)
|
||||||
|
save_query_url: str = field(
|
||||||
|
metadata={"help": "URL to save the current arbitrary SQL as a query"}
|
||||||
|
)
|
||||||
tables: list = field(metadata={"help": "List of table objects in the database"})
|
tables: list = field(metadata={"help": "List of table objects in the database"})
|
||||||
named_parameter_values: dict = field(
|
named_parameter_values: dict = field(
|
||||||
metadata={"help": "Dictionary of parameter names/values"}
|
metadata={"help": "Dictionary of parameter names/values"}
|
||||||
|
|
@ -417,6 +420,510 @@ async def database_download(request, datasette):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_query_name_re = re.compile(r"^[^/\.\n]+$")
|
||||||
|
|
||||||
|
_query_fields = {
|
||||||
|
"sql",
|
||||||
|
"title",
|
||||||
|
"description",
|
||||||
|
"description_html",
|
||||||
|
"hide_sql",
|
||||||
|
"fragment",
|
||||||
|
"parameters",
|
||||||
|
"params",
|
||||||
|
"published",
|
||||||
|
"on_success_message",
|
||||||
|
"on_success_message_sql",
|
||||||
|
"on_success_redirect",
|
||||||
|
"on_error_message",
|
||||||
|
"on_error_redirect",
|
||||||
|
}
|
||||||
|
|
||||||
|
_query_create_fields = _query_fields | {"name", "mode", "csrftoken"}
|
||||||
|
_query_update_fields = _query_fields
|
||||||
|
_query_write_fields = {
|
||||||
|
"on_success_message",
|
||||||
|
"on_success_message_sql",
|
||||||
|
"on_success_redirect",
|
||||||
|
"on_error_message",
|
||||||
|
"on_error_redirect",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class QueryValidationError(Exception):
|
||||||
|
def __init__(self, message, status=400):
|
||||||
|
self.message = message
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
|
||||||
|
def _actor_id(actor):
|
||||||
|
if isinstance(actor, dict):
|
||||||
|
return actor.get("id")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _as_bool(value):
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if value is None:
|
||||||
|
return False
|
||||||
|
if isinstance(value, int):
|
||||||
|
return bool(value)
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.lower() in {"1", "true", "t", "yes", "on"}
|
||||||
|
return bool(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _derived_query_parameters(sql):
|
||||||
|
parameters = []
|
||||||
|
seen = set()
|
||||||
|
for parameter in derive_named_parameters(sql):
|
||||||
|
if parameter.startswith("_"):
|
||||||
|
raise QueryValidationError("Magic parameters are not allowed")
|
||||||
|
if parameter not in seen:
|
||||||
|
parameters.append(parameter)
|
||||||
|
seen.add(parameter)
|
||||||
|
return parameters
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_query_parameters(value, derived):
|
||||||
|
if value is None:
|
||||||
|
return derived
|
||||||
|
if isinstance(value, str):
|
||||||
|
parameters = [
|
||||||
|
parameter.strip()
|
||||||
|
for parameter in re.split(r"[\s,]+", value)
|
||||||
|
if parameter.strip()
|
||||||
|
]
|
||||||
|
elif isinstance(value, list):
|
||||||
|
parameters = value
|
||||||
|
else:
|
||||||
|
raise QueryValidationError("parameters must be a list of strings")
|
||||||
|
if not all(isinstance(parameter, str) for parameter in parameters):
|
||||||
|
raise QueryValidationError("parameters must be a list of strings")
|
||||||
|
if any(parameter.startswith("_") for parameter in parameters):
|
||||||
|
raise QueryValidationError("Magic parameters are not allowed")
|
||||||
|
if set(parameters) != set(derived):
|
||||||
|
raise QueryValidationError("parameters must match SQL named parameters")
|
||||||
|
return parameters
|
||||||
|
|
||||||
|
|
||||||
|
async def _json_or_form_payload(request):
|
||||||
|
content_type = request.headers.get("content-type", "")
|
||||||
|
if content_type.startswith("application/json"):
|
||||||
|
body = await request.post_body()
|
||||||
|
try:
|
||||||
|
return json.loads(body or b"{}"), True
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise QueryValidationError("Invalid JSON: {}".format(e))
|
||||||
|
return await request.post_vars(), False
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_query_name(db, name, *, existing=False):
|
||||||
|
if not name or not isinstance(name, str):
|
||||||
|
raise QueryValidationError("Query name is required")
|
||||||
|
if not _query_name_re.match(name):
|
||||||
|
raise QueryValidationError("Invalid query name")
|
||||||
|
if not existing and (await db.table_exists(name) or await db.view_exists(name)):
|
||||||
|
raise QueryValidationError("Query name conflicts with a table or view")
|
||||||
|
|
||||||
|
|
||||||
|
async def _analyze_user_query(datasette, db, sql, *, actor, published):
|
||||||
|
if not sql or not isinstance(sql, str):
|
||||||
|
raise QueryValidationError("SQL is required")
|
||||||
|
derived = _derived_query_parameters(sql)
|
||||||
|
params = {parameter: "" for parameter in derived}
|
||||||
|
try:
|
||||||
|
analysis = await db.analyze_sql(sql, params)
|
||||||
|
except sqlite3.DatabaseError as ex:
|
||||||
|
raise QueryValidationError("Could not analyze query: {}".format(ex)) from ex
|
||||||
|
|
||||||
|
is_write = any(
|
||||||
|
access.operation in {"insert", "update", "delete"}
|
||||||
|
for access in analysis.table_accesses
|
||||||
|
)
|
||||||
|
if is_write:
|
||||||
|
if published:
|
||||||
|
raise QueryValidationError("Writable queries cannot be published")
|
||||||
|
try:
|
||||||
|
await datasette.ensure_query_write_permissions(db.name, sql, actor=actor)
|
||||||
|
except Forbidden as ex:
|
||||||
|
raise QueryValidationError(str(ex), status=403) from ex
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
validate_sql_select(sql)
|
||||||
|
except InvalidSql as ex:
|
||||||
|
raise QueryValidationError(str(ex)) from ex
|
||||||
|
return is_write, derived, analysis
|
||||||
|
|
||||||
|
|
||||||
|
def _analysis_rows(analysis):
|
||||||
|
write_actions = {
|
||||||
|
"insert": "insert-row",
|
||||||
|
"update": "update-row",
|
||||||
|
"delete": "delete-row",
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"operation": access.operation,
|
||||||
|
"database": access.database,
|
||||||
|
"table": access.table,
|
||||||
|
"required_permission": write_actions.get(access.operation, ""),
|
||||||
|
"source": access.source,
|
||||||
|
}
|
||||||
|
for access in analysis.table_accesses
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_query_data_types(data):
|
||||||
|
typed = dict(data)
|
||||||
|
for key in ("hide_sql", "published"):
|
||||||
|
if key in typed:
|
||||||
|
typed[key] = _as_bool(typed[key])
|
||||||
|
return typed
|
||||||
|
|
||||||
|
|
||||||
|
async def _prepare_query_create(datasette, request, db, data):
|
||||||
|
invalid_keys = set(data) - _query_create_fields
|
||||||
|
if invalid_keys:
|
||||||
|
raise QueryValidationError("Invalid keys: {}".format(", ".join(invalid_keys)))
|
||||||
|
|
||||||
|
data = _apply_query_data_types(data)
|
||||||
|
name = data.get("name")
|
||||||
|
await _check_query_name(db, name)
|
||||||
|
if await datasette.get_query(db.name, name) is not None:
|
||||||
|
raise QueryValidationError("Query already exists")
|
||||||
|
|
||||||
|
published = _as_bool(data.get("published"))
|
||||||
|
is_write, derived, analysis = await _analyze_user_query(
|
||||||
|
datasette,
|
||||||
|
db,
|
||||||
|
data.get("sql"),
|
||||||
|
actor=request.actor,
|
||||||
|
published=published,
|
||||||
|
)
|
||||||
|
if published and not await datasette.allowed(
|
||||||
|
action="publish-query",
|
||||||
|
resource=DatabaseResource(db.name),
|
||||||
|
actor=request.actor,
|
||||||
|
):
|
||||||
|
raise QueryValidationError("Permission denied: need publish-query", status=403)
|
||||||
|
if not is_write and any(data.get(field) for field in _query_write_fields):
|
||||||
|
raise QueryValidationError("Writable query fields require writable SQL")
|
||||||
|
|
||||||
|
parameters = _coerce_query_parameters(
|
||||||
|
data.get("parameters", data.get("params")),
|
||||||
|
derived,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"sql": data["sql"],
|
||||||
|
"title": data.get("title"),
|
||||||
|
"description": data.get("description"),
|
||||||
|
"description_html": data.get("description_html"),
|
||||||
|
"hide_sql": _as_bool(data.get("hide_sql")),
|
||||||
|
"fragment": data.get("fragment"),
|
||||||
|
"parameters": parameters,
|
||||||
|
"is_write": is_write,
|
||||||
|
"published": published,
|
||||||
|
"source": "user",
|
||||||
|
"owner_id": _actor_id(request.actor),
|
||||||
|
"on_success_message": data.get("on_success_message"),
|
||||||
|
"on_success_message_sql": data.get("on_success_message_sql"),
|
||||||
|
"on_success_redirect": data.get("on_success_redirect"),
|
||||||
|
"on_error_message": data.get("on_error_message"),
|
||||||
|
"on_error_redirect": data.get("on_error_redirect"),
|
||||||
|
"analysis": analysis,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _prepare_query_update(datasette, request, db, existing, update):
|
||||||
|
invalid_keys = set(update) - _query_update_fields
|
||||||
|
if invalid_keys:
|
||||||
|
raise QueryValidationError("Invalid keys: {}".format(", ".join(invalid_keys)))
|
||||||
|
|
||||||
|
update = _apply_query_data_types(update)
|
||||||
|
sql = update.get("sql", existing["sql"])
|
||||||
|
published = update.get("published", existing["published"])
|
||||||
|
query_is_write = existing["is_write"]
|
||||||
|
derived = _derived_query_parameters(sql)
|
||||||
|
parameters = None
|
||||||
|
|
||||||
|
if "sql" in update:
|
||||||
|
query_is_write, derived, _ = await _analyze_user_query(
|
||||||
|
datasette,
|
||||||
|
db,
|
||||||
|
sql,
|
||||||
|
actor=request.actor,
|
||||||
|
published=published,
|
||||||
|
)
|
||||||
|
elif published and query_is_write:
|
||||||
|
raise QueryValidationError("Writable queries cannot be published")
|
||||||
|
if published and not existing["published"]:
|
||||||
|
if not await datasette.allowed(
|
||||||
|
action="publish-query",
|
||||||
|
resource=DatabaseResource(db.name),
|
||||||
|
actor=request.actor,
|
||||||
|
):
|
||||||
|
raise QueryValidationError(
|
||||||
|
"Permission denied: need publish-query", status=403
|
||||||
|
)
|
||||||
|
|
||||||
|
if "parameters" in update or "params" in update:
|
||||||
|
parameters = _coerce_query_parameters(
|
||||||
|
update.get("parameters", update.get("params")),
|
||||||
|
derived,
|
||||||
|
)
|
||||||
|
elif "sql" in update:
|
||||||
|
parameters = derived
|
||||||
|
|
||||||
|
if not query_is_write and any(update.get(field) for field in _query_write_fields):
|
||||||
|
raise QueryValidationError("Writable query fields require writable SQL")
|
||||||
|
|
||||||
|
field_values = {
|
||||||
|
"sql": sql,
|
||||||
|
"title": update.get("title"),
|
||||||
|
"description": update.get("description"),
|
||||||
|
"description_html": update.get("description_html"),
|
||||||
|
"hide_sql": update.get("hide_sql"),
|
||||||
|
"fragment": update.get("fragment"),
|
||||||
|
"parameters": parameters,
|
||||||
|
"is_write": query_is_write,
|
||||||
|
"published": published,
|
||||||
|
"on_success_message": update.get("on_success_message"),
|
||||||
|
"on_success_message_sql": update.get("on_success_message_sql"),
|
||||||
|
"on_success_redirect": update.get("on_success_redirect"),
|
||||||
|
"on_error_message": update.get("on_error_message"),
|
||||||
|
"on_error_redirect": update.get("on_error_redirect"),
|
||||||
|
}
|
||||||
|
update_kwargs = {}
|
||||||
|
for field, value in field_values.items():
|
||||||
|
if field in update:
|
||||||
|
update_kwargs[field] = value
|
||||||
|
if parameters is not None:
|
||||||
|
update_kwargs["parameters"] = parameters
|
||||||
|
if "sql" in update:
|
||||||
|
update_kwargs["is_write"] = query_is_write
|
||||||
|
return update_kwargs
|
||||||
|
|
||||||
|
|
||||||
|
class QueryListView(BaseView):
|
||||||
|
name = "query-list"
|
||||||
|
|
||||||
|
async def get(self, request):
|
||||||
|
db = await self.ds.resolve_database(request)
|
||||||
|
page = await self.ds.allowed_resources(
|
||||||
|
"view-query",
|
||||||
|
request.actor,
|
||||||
|
parent=db.name,
|
||||||
|
limit=1000,
|
||||||
|
)
|
||||||
|
all_queries = await self.ds.get_queries(db.name)
|
||||||
|
queries = [
|
||||||
|
all_queries[resource.child]
|
||||||
|
for resource in page.resources
|
||||||
|
if resource.child in all_queries
|
||||||
|
]
|
||||||
|
return Response.json({"ok": True, "database": db.name, "queries": queries})
|
||||||
|
|
||||||
|
|
||||||
|
class QueryCreateView(BaseView):
|
||||||
|
name = "query-create"
|
||||||
|
has_json_alternate = False
|
||||||
|
|
||||||
|
async def get(self, request):
|
||||||
|
db = await self.ds.resolve_database(request)
|
||||||
|
await self.ds.ensure_permission(
|
||||||
|
action="execute-sql",
|
||||||
|
resource=DatabaseResource(db.name),
|
||||||
|
actor=request.actor,
|
||||||
|
)
|
||||||
|
await self.ds.ensure_permission(
|
||||||
|
action="insert-query",
|
||||||
|
resource=DatabaseResource(db.name),
|
||||||
|
actor=request.actor,
|
||||||
|
)
|
||||||
|
|
||||||
|
sql = request.args.get("sql") or ""
|
||||||
|
analysis_error = None
|
||||||
|
analysis_rows = []
|
||||||
|
parameter_names = []
|
||||||
|
if sql:
|
||||||
|
try:
|
||||||
|
parameter_names = _derived_query_parameters(sql)
|
||||||
|
params = {parameter: "" for parameter in parameter_names}
|
||||||
|
analysis = await db.analyze_sql(sql, params)
|
||||||
|
rows = _analysis_rows(analysis)
|
||||||
|
for row in rows:
|
||||||
|
permission = row["required_permission"]
|
||||||
|
if permission:
|
||||||
|
row["allowed"] = await self.ds.allowed(
|
||||||
|
action=permission,
|
||||||
|
resource=TableResource(row["database"], row["table"]),
|
||||||
|
actor=request.actor,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
row["allowed"] = None
|
||||||
|
analysis_rows = rows
|
||||||
|
except (QueryValidationError, sqlite3.DatabaseError) as ex:
|
||||||
|
analysis_error = getattr(ex, "message", str(ex))
|
||||||
|
|
||||||
|
return await self.render(
|
||||||
|
["query_create.html"],
|
||||||
|
request,
|
||||||
|
{
|
||||||
|
"database": db.name,
|
||||||
|
"database_color": db.color,
|
||||||
|
"sql": sql,
|
||||||
|
"parameter_names": parameter_names,
|
||||||
|
"can_publish": await self.ds.allowed(
|
||||||
|
action="publish-query",
|
||||||
|
resource=DatabaseResource(db.name),
|
||||||
|
actor=request.actor,
|
||||||
|
),
|
||||||
|
"analysis_error": analysis_error,
|
||||||
|
"analysis_rows": analysis_rows,
|
||||||
|
"save_disabled": bool(
|
||||||
|
analysis_error
|
||||||
|
or any(row["allowed"] is False for row in analysis_rows)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class QueryInsertView(BaseView):
|
||||||
|
name = "query-insert"
|
||||||
|
|
||||||
|
async def post(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 _error(["Permission denied: need execute-sql"], 403)
|
||||||
|
if not await self.ds.allowed(
|
||||||
|
action="insert-query",
|
||||||
|
resource=DatabaseResource(db.name),
|
||||||
|
actor=request.actor,
|
||||||
|
):
|
||||||
|
return _error(["Permission denied: need insert-query"], 403)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data, is_json = await _json_or_form_payload(request)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise QueryValidationError("JSON must be a dictionary")
|
||||||
|
query_data = data.get("query") if is_json else data
|
||||||
|
if not isinstance(query_data, dict):
|
||||||
|
raise QueryValidationError("JSON must contain a query dictionary")
|
||||||
|
prepared = await _prepare_query_create(self.ds, request, db, query_data)
|
||||||
|
except QueryValidationError as ex:
|
||||||
|
return _error([ex.message], ex.status)
|
||||||
|
|
||||||
|
prepared.pop("analysis")
|
||||||
|
name = prepared.pop("name")
|
||||||
|
try:
|
||||||
|
await self.ds.add_query(db.name, name, replace=False, **prepared)
|
||||||
|
except sqlite3.IntegrityError as ex:
|
||||||
|
return _error([str(ex)], 400)
|
||||||
|
|
||||||
|
query = await self.ds.get_query(db.name, name)
|
||||||
|
if is_json:
|
||||||
|
return Response.json({"ok": True, "query": query}, status=201)
|
||||||
|
self.ds.add_message(request, "Query saved", self.ds.INFO)
|
||||||
|
return Response.redirect(self.ds.urls.path(self.ds.urls.table(db.name, name)))
|
||||||
|
|
||||||
|
|
||||||
|
class QueryDefinitionView(BaseView):
|
||||||
|
name = "query-definition"
|
||||||
|
|
||||||
|
async def get(self, request):
|
||||||
|
db = await self.ds.resolve_database(request)
|
||||||
|
query_name = tilde_decode(request.url_vars["query"])
|
||||||
|
query = await self.ds.get_query(db.name, query_name)
|
||||||
|
if query is None:
|
||||||
|
return _error(["Query not found: {}".format(query_name)], 404)
|
||||||
|
if not await self.ds.allowed(
|
||||||
|
action="view-query",
|
||||||
|
resource=QueryResource(db.name, query_name),
|
||||||
|
actor=request.actor,
|
||||||
|
):
|
||||||
|
return _error(["Permission denied"], 403)
|
||||||
|
return Response.json({"ok": True, "query": query})
|
||||||
|
|
||||||
|
|
||||||
|
class QueryUpdateView(BaseView):
|
||||||
|
name = "query-update"
|
||||||
|
|
||||||
|
async def post(self, request):
|
||||||
|
db = await self.ds.resolve_database(request)
|
||||||
|
query_name = tilde_decode(request.url_vars["query"])
|
||||||
|
existing = await self.ds.get_query(db.name, query_name)
|
||||||
|
if existing is None:
|
||||||
|
return _error(["Query not found: {}".format(query_name)], 404)
|
||||||
|
if not await self.ds.allowed(
|
||||||
|
action="update-query",
|
||||||
|
resource=QueryResource(db.name, query_name),
|
||||||
|
actor=request.actor,
|
||||||
|
):
|
||||||
|
return _error(["Permission denied: need update-query"], 403)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data, _ = await _json_or_form_payload(request)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise QueryValidationError("JSON must be a dictionary")
|
||||||
|
invalid_keys = set(data) - {"update", "return"}
|
||||||
|
if invalid_keys:
|
||||||
|
raise QueryValidationError(
|
||||||
|
"Invalid keys: {}".format(", ".join(invalid_keys))
|
||||||
|
)
|
||||||
|
update = data.get("update")
|
||||||
|
if not isinstance(update, dict):
|
||||||
|
raise QueryValidationError("JSON must contain an update dictionary")
|
||||||
|
if "sql" in update and not await self.ds.allowed(
|
||||||
|
action="execute-sql",
|
||||||
|
resource=DatabaseResource(db.name),
|
||||||
|
actor=request.actor,
|
||||||
|
):
|
||||||
|
raise QueryValidationError(
|
||||||
|
"Permission denied: need execute-sql", status=403
|
||||||
|
)
|
||||||
|
update_kwargs = await _prepare_query_update(
|
||||||
|
self.ds, request, db, existing, update
|
||||||
|
)
|
||||||
|
except QueryValidationError as ex:
|
||||||
|
return _error([ex.message], ex.status)
|
||||||
|
|
||||||
|
await self.ds.update_query(db.name, query_name, **update_kwargs)
|
||||||
|
if data.get("return"):
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"query": await self.ds.get_query(db.name, query_name),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return Response.json({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
class QueryDeleteView(BaseView):
|
||||||
|
name = "query-delete"
|
||||||
|
|
||||||
|
async def post(self, request):
|
||||||
|
db = await self.ds.resolve_database(request)
|
||||||
|
query_name = tilde_decode(request.url_vars["query"])
|
||||||
|
existing = await self.ds.get_query(db.name, query_name)
|
||||||
|
if existing is None:
|
||||||
|
return _error(["Query not found: {}".format(query_name)], 404)
|
||||||
|
if not await self.ds.allowed(
|
||||||
|
action="delete-query",
|
||||||
|
resource=QueryResource(db.name, query_name),
|
||||||
|
actor=request.actor,
|
||||||
|
):
|
||||||
|
return _error(["Permission denied: need delete-query"], 403)
|
||||||
|
await self.ds.remove_query(db.name, query_name)
|
||||||
|
return Response.json({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
class QueryView(View):
|
class QueryView(View):
|
||||||
async def post(self, request, datasette):
|
async def post(self, request, datasette):
|
||||||
from datasette.app import TableNotFound
|
from datasette.app import TableNotFound
|
||||||
|
|
@ -741,6 +1248,11 @@ class QueryView(View):
|
||||||
resource=DatabaseResource(database=database),
|
resource=DatabaseResource(database=database),
|
||||||
actor=request.actor,
|
actor=request.actor,
|
||||||
)
|
)
|
||||||
|
allow_insert_query = await datasette.allowed(
|
||||||
|
action="insert-query",
|
||||||
|
resource=DatabaseResource(database=database),
|
||||||
|
actor=request.actor,
|
||||||
|
)
|
||||||
|
|
||||||
show_hide_hidden = ""
|
show_hide_hidden = ""
|
||||||
if canned_query and canned_query.get("hide_sql"):
|
if canned_query and canned_query.get("hide_sql"):
|
||||||
|
|
@ -790,6 +1302,19 @@ class QueryView(View):
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
save_query_url = None
|
||||||
|
if (
|
||||||
|
not canned_query
|
||||||
|
and allow_execute_sql
|
||||||
|
and allow_insert_query
|
||||||
|
and is_validated_sql
|
||||||
|
and ":_" not in sql
|
||||||
|
):
|
||||||
|
save_query_url = (
|
||||||
|
datasette.urls.database(database)
|
||||||
|
+ "/-/queries/-/create?"
|
||||||
|
+ urlencode({"sql": sql})
|
||||||
|
)
|
||||||
|
|
||||||
async def query_actions():
|
async def query_actions():
|
||||||
query_actions = []
|
query_actions = []
|
||||||
|
|
@ -827,6 +1352,7 @@ class QueryView(View):
|
||||||
show_hide_text=show_hide_text,
|
show_hide_text=show_hide_text,
|
||||||
editable=not canned_query,
|
editable=not canned_query,
|
||||||
allow_execute_sql=allow_execute_sql,
|
allow_execute_sql=allow_execute_sql,
|
||||||
|
save_query_url=save_query_url,
|
||||||
tables=await get_tables(datasette, request, db, allowed_dict),
|
tables=await get_tables(datasette, request, db, allowed_dict),
|
||||||
named_parameter_values=named_parameter_values,
|
named_parameter_values=named_parameter_values,
|
||||||
edit_sql_url=edit_sql_url,
|
edit_sql_url=edit_sql_url,
|
||||||
|
|
|
||||||
|
|
@ -1285,12 +1285,56 @@ Actor is allowed to view a table (or view) page, e.g. https://latest.datasette.i
|
||||||
view-query
|
view-query
|
||||||
----------
|
----------
|
||||||
|
|
||||||
Actor is allowed to view (and execute) a :ref:`canned query <canned_queries>` page, e.g. https://latest.datasette.io/fixtures/pragma_cache_size - this includes executing :ref:`canned_queries_writable`.
|
Actor is allowed to view (and execute) a saved query page, e.g. https://latest.datasette.io/fixtures/pragma_cache_size - this includes executing :ref:`canned_queries_writable`.
|
||||||
|
|
||||||
``resource`` - ``datasette.resources.QueryResource(database, query)``
|
``resource`` - ``datasette.resources.QueryResource(database, query)``
|
||||||
``database`` is the name of the database (string)
|
``database`` is the name of the database (string)
|
||||||
|
|
||||||
``query`` is the name of the canned query (string)
|
``query`` is the name of the query (string)
|
||||||
|
|
||||||
|
.. _actions_insert_query:
|
||||||
|
|
||||||
|
insert-query
|
||||||
|
------------
|
||||||
|
|
||||||
|
Actor is allowed to create saved queries in a database.
|
||||||
|
|
||||||
|
``resource`` - ``datasette.resources.DatabaseResource(database)``
|
||||||
|
``database`` is the name of the database (string)
|
||||||
|
|
||||||
|
.. _actions_publish_query:
|
||||||
|
|
||||||
|
publish-query
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Actor is allowed to publish a saved read-only query so actors without ``execute-sql`` can run it.
|
||||||
|
|
||||||
|
``resource`` - ``datasette.resources.DatabaseResource(database)``
|
||||||
|
``database`` is the name of the database (string)
|
||||||
|
|
||||||
|
.. _actions_update_query:
|
||||||
|
|
||||||
|
update-query
|
||||||
|
------------
|
||||||
|
|
||||||
|
Actor is allowed to update a saved query.
|
||||||
|
|
||||||
|
``resource`` - ``datasette.resources.QueryResource(database, query)``
|
||||||
|
``database`` is the name of the database (string)
|
||||||
|
|
||||||
|
``query`` is the name of the query (string)
|
||||||
|
|
||||||
|
.. _actions_delete_query:
|
||||||
|
|
||||||
|
delete-query
|
||||||
|
------------
|
||||||
|
|
||||||
|
Actor is allowed to delete a saved query.
|
||||||
|
|
||||||
|
``resource`` - ``datasette.resources.QueryResource(database, query)``
|
||||||
|
``database`` is the name of the database (string)
|
||||||
|
|
||||||
|
``query`` is the name of the query (string)
|
||||||
|
|
||||||
.. _actions_insert_row:
|
.. _actions_insert_row:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -179,9 +179,7 @@ async def test_query_resources_come_from_internal_table():
|
||||||
|
|
||||||
page = await ds.allowed_resources("view-query", actor=None)
|
page = await ds.allowed_resources("view-query", actor=None)
|
||||||
|
|
||||||
assert [(r.parent, r.child) for r in page.resources] == [
|
assert [(r.parent, r.child) for r in page.resources] == [("data", "internal_query")]
|
||||||
("data", "internal_query")
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -279,3 +277,216 @@ async def test_analyze_write_query_rejects_writes_to_attached_databases():
|
||||||
"insert into extra.cats (id) values (1)",
|
"insert into extra.cats (id) values (1)",
|
||||||
actor={"id": "writer"},
|
actor={"id": "writer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_query_insert_api_creates_read_only_query():
|
||||||
|
ds = Datasette(memory=True, default_deny=True)
|
||||||
|
ds.root_enabled = True
|
||||||
|
db = ds.add_memory_database("query_insert_api", 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/-/queries/-/insert",
|
||||||
|
actor={"id": "root"},
|
||||||
|
json={
|
||||||
|
"query": {
|
||||||
|
"name": "by_name",
|
||||||
|
"sql": "select * from dogs where name = :name",
|
||||||
|
"title": "By name",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["ok"] is True
|
||||||
|
assert data["query"]["name"] == "by_name"
|
||||||
|
assert data["query"]["parameters"] == ["name"]
|
||||||
|
assert data["query"]["is_write"] is False
|
||||||
|
assert data["query"]["source"] == "user"
|
||||||
|
assert data["query"]["owner_id"] == "root"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_query_list_and_definition_api():
|
||||||
|
ds = Datasette(memory=True)
|
||||||
|
ds.root_enabled = True
|
||||||
|
ds.add_memory_database("query_list_api", name="data")
|
||||||
|
await ds.invoke_startup()
|
||||||
|
await ds.add_query("data", "listed", "select 1", title="Listed", published=True)
|
||||||
|
|
||||||
|
list_response = await ds.client.get(
|
||||||
|
"/data/-/queries",
|
||||||
|
actor={"id": "root"},
|
||||||
|
)
|
||||||
|
definition_response = await ds.client.get(
|
||||||
|
"/data/listed/-/definition",
|
||||||
|
actor={"id": "root"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert list_response.status_code == 200
|
||||||
|
assert list_response.json()["queries"][0]["name"] == "listed"
|
||||||
|
assert definition_response.status_code == 200
|
||||||
|
assert definition_response.json()["query"]["title"] == "Listed"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_query_insert_api_publish_requires_publish_query():
|
||||||
|
ds = Datasette(
|
||||||
|
memory=True,
|
||||||
|
default_deny=True,
|
||||||
|
config={
|
||||||
|
"databases": {
|
||||||
|
"data": {
|
||||||
|
"permissions": {
|
||||||
|
"view-database": {"id": "writer"},
|
||||||
|
"execute-sql": {"id": "writer"},
|
||||||
|
"insert-query": {"id": "writer"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ds.add_memory_database("query_publish_api", name="data")
|
||||||
|
await ds.invoke_startup()
|
||||||
|
|
||||||
|
response = await ds.client.post(
|
||||||
|
"/data/-/queries/-/insert",
|
||||||
|
actor={"id": "writer"},
|
||||||
|
json={"query": {"name": "public", "sql": "select 1", "published": True}},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json()["errors"] == ["Permission denied: need publish-query"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_query_insert_api_creates_writable_query():
|
||||||
|
ds = Datasette(memory=True, default_deny=True)
|
||||||
|
ds.root_enabled = True
|
||||||
|
db = ds.add_memory_database("query_write_api", 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/-/queries/-/insert",
|
||||||
|
actor={"id": "root"},
|
||||||
|
json={
|
||||||
|
"query": {
|
||||||
|
"name": "insert_dog",
|
||||||
|
"sql": "insert into dogs (name) values (:name)",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
query = response.json()["query"]
|
||||||
|
assert query["is_write"] is True
|
||||||
|
assert query["published"] is False
|
||||||
|
assert query["parameters"] == ["name"]
|
||||||
|
|
||||||
|
bad_response = await ds.client.post(
|
||||||
|
"/data/-/queries/-/insert",
|
||||||
|
actor={"id": "root"},
|
||||||
|
json={
|
||||||
|
"query": {
|
||||||
|
"name": "published_insert",
|
||||||
|
"sql": "insert into dogs (name) values (:name)",
|
||||||
|
"published": True,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert bad_response.status_code == 400
|
||||||
|
assert bad_response.json()["errors"] == ["Writable queries cannot be published"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_query_update_and_delete_api():
|
||||||
|
ds = Datasette(memory=True, default_deny=True)
|
||||||
|
ds.root_enabled = True
|
||||||
|
ds.add_memory_database("query_update_api", name="data")
|
||||||
|
await ds.invoke_startup()
|
||||||
|
await ds.add_query(
|
||||||
|
"data",
|
||||||
|
"editable",
|
||||||
|
"select 1",
|
||||||
|
title="Original",
|
||||||
|
source="user",
|
||||||
|
owner_id="root",
|
||||||
|
)
|
||||||
|
|
||||||
|
update_response = await ds.client.post(
|
||||||
|
"/data/editable/-/update",
|
||||||
|
actor={"id": "root"},
|
||||||
|
json={
|
||||||
|
"update": {
|
||||||
|
"title": "Updated",
|
||||||
|
"description": "Fresh",
|
||||||
|
"on_success_redirect": None,
|
||||||
|
},
|
||||||
|
"return": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert update_response.status_code == 200
|
||||||
|
updated = update_response.json()["query"]
|
||||||
|
assert updated["title"] == "Updated"
|
||||||
|
assert updated["description"] == "Fresh"
|
||||||
|
assert updated["on_success_redirect"] is None
|
||||||
|
|
||||||
|
delete_response = await ds.client.post(
|
||||||
|
"/data/editable/-/delete",
|
||||||
|
actor={"id": "root"},
|
||||||
|
json={},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert delete_response.status_code == 200
|
||||||
|
assert delete_response.json() == {"ok": True}
|
||||||
|
assert await ds.get_query("data", "editable") is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_query_insert_api_rejects_magic_parameters():
|
||||||
|
ds = Datasette(memory=True, default_deny=True)
|
||||||
|
ds.root_enabled = True
|
||||||
|
ds.add_memory_database("query_magic_api", name="data")
|
||||||
|
await ds.invoke_startup()
|
||||||
|
|
||||||
|
response = await ds.client.post(
|
||||||
|
"/data/-/queries/-/insert",
|
||||||
|
actor={"id": "root"},
|
||||||
|
json={"query": {"name": "magic", "sql": "select :_actor_id"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json()["errors"] == ["Magic parameters are not allowed"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_query_ui_and_arbitrary_sql_save_link():
|
||||||
|
ds = Datasette(memory=True, default_deny=True)
|
||||||
|
ds.root_enabled = True
|
||||||
|
db = ds.add_memory_database("query_create_ui", name="data")
|
||||||
|
await db.execute_write("create table dogs (id integer primary key, name text)")
|
||||||
|
await ds.invoke_startup()
|
||||||
|
|
||||||
|
create_response = await ds.client.get(
|
||||||
|
"/data/-/queries/-/create?sql=select+*+from+dogs",
|
||||||
|
actor={"id": "root"},
|
||||||
|
)
|
||||||
|
query_response = await ds.client.get(
|
||||||
|
"/data/-/query?sql=select+*+from+dogs",
|
||||||
|
actor={"id": "root"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert create_response.status_code == 200
|
||||||
|
assert "Create query" in create_response.text
|
||||||
|
assert "Read-only" in create_response.text
|
||||||
|
assert "Writable" in create_response.text
|
||||||
|
assert "required permission" in create_response.text
|
||||||
|
assert query_response.status_code == 200
|
||||||
|
assert "Save query" in query_response.text
|
||||||
|
assert "/data/-/queries/-/create?sql=select+%2A+from+dogs" in query_response.text
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue