mirror of
https://github.com/simonw/datasette.git
synced 2026-06-02 07:07:00 +02:00
Renamed canned queries to queries / stored queries in docs
And a few renames in code and YAML as well.
This commit is contained in:
parent
56b14f37d5
commit
02a1468f1b
17 changed files with 115 additions and 605 deletions
33
.github/workflows/deploy-latest.yml
vendored
33
.github/workflows/deploy-latest.yml
vendored
|
|
@ -57,7 +57,7 @@ jobs:
|
|||
db.route = "alternative-route"
|
||||
' > plugins/alternative_route.py
|
||||
cp fixtures.db fixtures2.db
|
||||
- name: And the counters writable canned query demo
|
||||
- name: And the counters writable stored query demo
|
||||
run: |
|
||||
cat > plugins/counters.py <<EOF
|
||||
from datasette import hookimpl
|
||||
|
|
@ -69,23 +69,22 @@ jobs:
|
|||
await db.execute_write("insert or ignore into counters (name, value) values ('counter_a', 0)")
|
||||
await db.execute_write("insert or ignore into counters (name, value) values ('counter_b', 0)")
|
||||
await db.execute_write("insert or ignore into counters (name, value) values ('counter_c', 0)")
|
||||
return inner
|
||||
@hookimpl
|
||||
def canned_queries(database):
|
||||
if database == "counters":
|
||||
queries = {}
|
||||
for name in ("counter_a", "counter_b", "counter_c"):
|
||||
queries["increment_{}".format(name)] = {
|
||||
"sql": "update counters set value = value + 1 where name = '{}'".format(name),
|
||||
"on_success_message_sql": "select 'Counter {name} incremented to ' || value from counters where name = '{name}'".format(name=name),
|
||||
"write": True,
|
||||
}
|
||||
queries["decrement_{}".format(name)] = {
|
||||
"sql": "update counters set value = value - 1 where name = '{}'".format(name),
|
||||
"on_success_message_sql": "select 'Counter {name} decremented to ' || value from counters where name = '{name}'".format(name=name),
|
||||
"write": True,
|
||||
}
|
||||
return queries
|
||||
await datasette.add_query(
|
||||
"counters",
|
||||
"increment_{}".format(name),
|
||||
"update counters set value = value + 1 where name = '{}'".format(name),
|
||||
on_success_message_sql="select 'Counter {name} incremented to ' || value from counters where name = '{name}'".format(name=name),
|
||||
is_write=True,
|
||||
)
|
||||
await datasette.add_query(
|
||||
"counters",
|
||||
"decrement_{}".format(name),
|
||||
"update counters set value = value - 1 where name = '{}'".format(name),
|
||||
on_success_message_sql="select 'Counter {name} decremented to ' || value from counters where name = '{name}'".format(name=name),
|
||||
is_write=True,
|
||||
)
|
||||
return inner
|
||||
EOF
|
||||
# - name: Make some modifications to metadata.json
|
||||
# run: |
|
||||
|
|
|
|||
|
|
@ -1784,13 +1784,6 @@ class Datasette:
|
|||
def app_css_hash(self):
|
||||
return self.static_hash("app.css")
|
||||
|
||||
async def get_canned_queries(self, database_name, actor):
|
||||
page = await self.list_queries(database_name, actor=actor, limit=1000)
|
||||
return {query["name"]: query for query in page["queries"]}
|
||||
|
||||
async def get_canned_query(self, database_name, query_name, actor):
|
||||
return await self.get_query(database_name, query_name)
|
||||
|
||||
def _prepare_connection(self, conn, database):
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.text_factory = lambda x: str(x, "utf-8", "replace")
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ class Facet:
|
|||
self.ds = ds
|
||||
self.request = request
|
||||
self.database = database
|
||||
# For foreign key expansion. Can be None for e.g. canned SQL queries:
|
||||
# For foreign key expansion. Can be None for e.g. stored SQL queries:
|
||||
self.table = table
|
||||
self.sql = sql or f"select * from [{table}]"
|
||||
self.params = params or []
|
||||
|
|
|
|||
|
|
@ -1409,7 +1409,7 @@ svg.dropdown-menu-icon {
|
|||
border-bottom: 5px solid #666;
|
||||
}
|
||||
|
||||
.canned-query-edit-sql {
|
||||
.stored-query-edit-sql {
|
||||
padding-left: 0.5em;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
{% 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 %}
|
||||
{% block body_class %}query db-{{ database|to_css_class }}{% if stored_query %} query-{{ stored_query|to_css_class }}{% endif %}{% endblock %}
|
||||
|
||||
{% block crumbs %}
|
||||
{{ crumbs.nav(request=request, database=database) }}
|
||||
|
|
@ -25,19 +25,19 @@
|
|||
|
||||
{% block content %}
|
||||
|
||||
{% if canned_query_write and db_is_immutable %}
|
||||
{% if stored_query_write and db_is_immutable %}
|
||||
<p class="message-error">This query cannot be executed because the database is immutable.</p>
|
||||
{% endif %}
|
||||
|
||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">{{ metadata.title or database }}{% if canned_query and not metadata.title %}: {{ canned_query }}{% endif %}{% if private %} 🔒{% endif %}</h1>
|
||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">{{ metadata.title or database }}{% if stored_query and not metadata.title %}: {{ stored_query }}{% endif %}{% if private %} 🔒{% endif %}</h1>
|
||||
{% set action_links, action_title = query_actions(), "Query actions" %}
|
||||
{% include "_action_menu.html" %}
|
||||
|
||||
{% if canned_query %}{{ top_stored_query() }}{% else %}{{ top_query() }}{% endif %}
|
||||
{% if stored_query %}{{ top_stored_query() }}{% else %}{{ top_query() }}{% endif %}
|
||||
|
||||
{% 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 %}" data-parameters-url="{{ urls.database(database) }}/-/query/parameters">
|
||||
<form class="sql core" action="{{ urls.database(database) }}{% if stored_query %}/{{ stored_query }}{% endif %}" method="{% if stored_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>
|
||||
|
|
@ -52,7 +52,7 @@
|
|||
<pre id="sql-query">{% if query %}{{ query.sql }}{% endif %}</pre>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if not canned_query %}
|
||||
{% if not stored_query %}
|
||||
<input type="hidden" name="sql"
|
||||
value="{% if query and query.sql %}{{ query.sql }}{% elif tables %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}"
|
||||
>
|
||||
|
|
@ -64,10 +64,10 @@
|
|||
{% 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 %}>
|
||||
<input type="submit" value="Run SQL"{% if stored_query_write and db_is_immutable %} disabled{% endif %}>
|
||||
{{ show_hide_hidden }}
|
||||
{% if save_query_url %}<a href="{{ save_query_url }}" class="save-query">Save this 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 stored_query and edit_sql_url %}<a href="{{ edit_sql_url }}" class="stored-query-edit-sql">Edit SQL</a>{% endif %}
|
||||
</p>
|
||||
</form>
|
||||
|
||||
|
|
@ -90,7 +90,7 @@
|
|||
</tbody>
|
||||
</table></div>
|
||||
{% else %}
|
||||
{% if not canned_query_write and not error %}
|
||||
{% if not stored_query_write and not error %}
|
||||
<p class="zero-results">0 results</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -100,12 +100,12 @@ class DatabaseView(View):
|
|||
limit=5,
|
||||
include_private=True,
|
||||
)
|
||||
canned_queries = queries_page["queries"]
|
||||
stored_queries = queries_page["queries"]
|
||||
queries_more = queries_page["has_more"]
|
||||
queries_count = (
|
||||
await datasette.count_queries(database, actor=request.actor)
|
||||
if queries_more
|
||||
else len(canned_queries)
|
||||
else len(stored_queries)
|
||||
)
|
||||
|
||||
async def database_actions():
|
||||
|
|
@ -137,7 +137,7 @@ class DatabaseView(View):
|
|||
"tables": tables,
|
||||
"hidden_count": len([t for t in tables if t["hidden"]]),
|
||||
"views": sql_views,
|
||||
"queries": canned_queries,
|
||||
"queries": stored_queries,
|
||||
"queries_more": queries_more,
|
||||
"queries_count": queries_count,
|
||||
"allow_execute_sql": allow_execute_sql,
|
||||
|
|
@ -172,7 +172,7 @@ class DatabaseView(View):
|
|||
tables=tables,
|
||||
hidden_count=len([t for t in tables if t["hidden"]]),
|
||||
views=sql_views,
|
||||
queries=canned_queries,
|
||||
queries=stored_queries,
|
||||
queries_more=queries_more,
|
||||
queries_count=queries_count,
|
||||
allow_execute_sql=allow_execute_sql,
|
||||
|
|
@ -271,7 +271,7 @@ class QueryContext(Context):
|
|||
query: dict = field(
|
||||
metadata={"help": "The SQL query object containing the `sql` string"}
|
||||
)
|
||||
canned_query: str = field(
|
||||
stored_query: str = field(
|
||||
metadata={"help": "The name of the stored query if this is a stored query"}
|
||||
)
|
||||
private: bool = field(
|
||||
|
|
@ -280,7 +280,7 @@ class QueryContext(Context):
|
|||
# urls: dict = field(
|
||||
# metadata={"help": "Object containing URL helpers like `database()`"}
|
||||
# )
|
||||
canned_query_write: bool = field(
|
||||
stored_query_write: bool = field(
|
||||
metadata={
|
||||
"help": "Boolean indicating if this is a stored query that allows writes"
|
||||
}
|
||||
|
|
@ -1629,10 +1629,10 @@ class QueryView(View):
|
|||
await datasette.resolve_table(request)
|
||||
table_found = True
|
||||
except TableNotFound as table_not_found:
|
||||
canned_query = await datasette.get_canned_query(
|
||||
table_not_found.database_name, table_not_found.table, request.actor
|
||||
stored_query = await datasette.get_query(
|
||||
table_not_found.database_name, table_not_found.table
|
||||
)
|
||||
if canned_query is None:
|
||||
if stored_query is None:
|
||||
raise
|
||||
if table_found:
|
||||
# That should not have happened
|
||||
|
|
@ -1640,13 +1640,13 @@ class QueryView(View):
|
|||
|
||||
if not await datasette.allowed(
|
||||
action="view-query",
|
||||
resource=QueryResource(database=db.name, query=canned_query["name"]),
|
||||
resource=QueryResource(database=db.name, query=stored_query["name"]),
|
||||
actor=request.actor,
|
||||
):
|
||||
raise Forbidden("You do not have permission to view this query")
|
||||
|
||||
await _ensure_stored_query_execution_permissions(
|
||||
datasette, db, canned_query, request.actor
|
||||
datasette, db, stored_query, request.actor
|
||||
)
|
||||
|
||||
# If database is immutable, return an error
|
||||
|
|
@ -1674,19 +1674,19 @@ class QueryView(View):
|
|||
or params.get("_json")
|
||||
)
|
||||
params_for_query = MagicParameters(
|
||||
canned_query["sql"], params, request, datasette
|
||||
stored_query["sql"], params, request, datasette
|
||||
)
|
||||
await params_for_query.execute_params()
|
||||
ok = None
|
||||
redirect_url = None
|
||||
try:
|
||||
cursor = await db.execute_write(
|
||||
canned_query["sql"], params_for_query, request=request
|
||||
stored_query["sql"], params_for_query, request=request
|
||||
)
|
||||
# success message can come from on_success_message or on_success_message_sql
|
||||
message = None
|
||||
message_type = datasette.INFO
|
||||
on_success_message_sql = canned_query.get("on_success_message_sql")
|
||||
on_success_message_sql = stored_query.get("on_success_message_sql")
|
||||
if on_success_message_sql:
|
||||
try:
|
||||
message_result = (
|
||||
|
|
@ -1698,18 +1698,18 @@ class QueryView(View):
|
|||
message = "Error running on_success_message_sql: {}".format(ex)
|
||||
message_type = datasette.ERROR
|
||||
if not message:
|
||||
message = canned_query.get(
|
||||
message = stored_query.get(
|
||||
"on_success_message"
|
||||
) or "Query executed, {} row{} affected".format(
|
||||
cursor.rowcount, "" if cursor.rowcount == 1 else "s"
|
||||
)
|
||||
|
||||
redirect_url = canned_query.get("on_success_redirect")
|
||||
redirect_url = stored_query.get("on_success_redirect")
|
||||
ok = True
|
||||
except Exception as ex:
|
||||
message = canned_query.get("on_error_message") or str(ex)
|
||||
message = stored_query.get("on_error_message") or str(ex)
|
||||
message_type = datasette.ERROR
|
||||
redirect_url = canned_query.get("on_error_redirect")
|
||||
redirect_url = stored_query.get("on_error_redirect")
|
||||
ok = False
|
||||
if should_return_json:
|
||||
return Response.json(
|
||||
|
|
@ -1743,33 +1743,33 @@ class QueryView(View):
|
|||
allowed_dict = {r.child: r for r in allowed_tables_page.resources}
|
||||
|
||||
# Are we a stored query?
|
||||
canned_query = None
|
||||
canned_query_write = False
|
||||
stored_query = None
|
||||
stored_query_write = False
|
||||
if "table" in request.url_vars:
|
||||
try:
|
||||
await datasette.resolve_table(request)
|
||||
except TableNotFound as table_not_found:
|
||||
# Was this actually a stored query?
|
||||
canned_query = await datasette.get_canned_query(
|
||||
table_not_found.database_name, table_not_found.table, request.actor
|
||||
stored_query = await datasette.get_query(
|
||||
table_not_found.database_name, table_not_found.table
|
||||
)
|
||||
if canned_query is None:
|
||||
if stored_query is None:
|
||||
raise
|
||||
canned_query_write = bool(canned_query.get("write"))
|
||||
stored_query_write = bool(stored_query.get("write"))
|
||||
|
||||
private = False
|
||||
if canned_query:
|
||||
if stored_query:
|
||||
# Respect stored query permissions
|
||||
visible, private = await datasette.check_visibility(
|
||||
request.actor,
|
||||
action="view-query",
|
||||
resource=QueryResource(database=database, query=canned_query["name"]),
|
||||
resource=QueryResource(database=database, query=stored_query["name"]),
|
||||
)
|
||||
if not visible:
|
||||
raise Forbidden("You do not have permission to view this query")
|
||||
if not canned_query_write:
|
||||
if not stored_query_write:
|
||||
await _ensure_stored_query_execution_permissions(
|
||||
datasette, db, canned_query, request.actor
|
||||
datasette, db, stored_query, request.actor
|
||||
)
|
||||
|
||||
else:
|
||||
|
|
@ -1783,15 +1783,15 @@ class QueryView(View):
|
|||
params = {key: request.args.get(key) for key in request.args}
|
||||
sql = None
|
||||
|
||||
if canned_query:
|
||||
sql = canned_query["sql"]
|
||||
if stored_query:
|
||||
sql = stored_query["sql"]
|
||||
elif "sql" in params:
|
||||
sql = params.pop("sql")
|
||||
|
||||
# Extract any :named parameters
|
||||
named_parameters = []
|
||||
if canned_query and canned_query.get("params"):
|
||||
named_parameters = canned_query["params"]
|
||||
if stored_query and stored_query.get("params"):
|
||||
named_parameters = stored_query["params"]
|
||||
if not named_parameters and sql:
|
||||
named_parameters = derive_named_parameters(sql)
|
||||
named_parameter_values = {
|
||||
|
|
@ -1817,9 +1817,9 @@ class QueryView(View):
|
|||
|
||||
params_for_query = params
|
||||
|
||||
if sql and not canned_query_write:
|
||||
if sql and not stored_query_write:
|
||||
try:
|
||||
if not canned_query:
|
||||
if not stored_query:
|
||||
# For regular queries we only allow SELECT, plus other rules
|
||||
validate_sql_select(sql)
|
||||
else:
|
||||
|
|
@ -1879,7 +1879,7 @@ class QueryView(View):
|
|||
columns=columns,
|
||||
rows=rows,
|
||||
sql=sql,
|
||||
query_name=canned_query["name"] if canned_query else None,
|
||||
query_name=stored_query["name"] if stored_query else None,
|
||||
database=database,
|
||||
table=None,
|
||||
request=request,
|
||||
|
|
@ -1911,10 +1911,10 @@ class QueryView(View):
|
|||
elif format_ == "html":
|
||||
headers = {}
|
||||
templates = [f"query-{to_css_class(database)}.html", "query.html"]
|
||||
if canned_query:
|
||||
if stored_query:
|
||||
templates.insert(
|
||||
0,
|
||||
f"query-{to_css_class(database)}-{to_css_class(canned_query['name'])}.html",
|
||||
f"query-{to_css_class(database)}-{to_css_class(stored_query['name'])}.html",
|
||||
)
|
||||
|
||||
environment = datasette.get_jinja_environment(request)
|
||||
|
|
@ -1932,8 +1932,8 @@ class QueryView(View):
|
|||
}
|
||||
)
|
||||
metadata = await datasette.get_database_metadata(database)
|
||||
if canned_query:
|
||||
metadata = dict(canned_query)
|
||||
if stored_query:
|
||||
metadata = dict(stored_query)
|
||||
metadata.pop("source", None)
|
||||
|
||||
renderers = {}
|
||||
|
|
@ -1968,7 +1968,7 @@ class QueryView(View):
|
|||
)
|
||||
|
||||
show_hide_hidden = ""
|
||||
if canned_query and canned_query.get("hide_sql"):
|
||||
if stored_query and stored_query.get("hide_sql"):
|
||||
if bool(params.get("_show_sql")):
|
||||
show_hide_link = path_with_removed_args(request, {"_show_sql"})
|
||||
show_hide_text = "hide"
|
||||
|
|
@ -2018,7 +2018,7 @@ class QueryView(View):
|
|||
)
|
||||
save_query_url = None
|
||||
if (
|
||||
not canned_query
|
||||
not stored_query
|
||||
and allow_execute_sql
|
||||
and allow_store_query
|
||||
and is_validated_sql
|
||||
|
|
@ -2036,7 +2036,7 @@ class QueryView(View):
|
|||
datasette=datasette,
|
||||
actor=request.actor,
|
||||
database=database,
|
||||
query_name=canned_query["name"] if canned_query else None,
|
||||
query_name=stored_query["name"] if stored_query else None,
|
||||
request=request,
|
||||
sql=sql,
|
||||
params=params,
|
||||
|
|
@ -2056,15 +2056,15 @@ class QueryView(View):
|
|||
"sql": sql,
|
||||
"params": params,
|
||||
},
|
||||
canned_query=canned_query["name"] if canned_query else None,
|
||||
stored_query=stored_query["name"] if stored_query else None,
|
||||
private=private,
|
||||
canned_query_write=canned_query_write,
|
||||
stored_query_write=stored_query_write,
|
||||
db_is_immutable=not db.is_mutable,
|
||||
error=query_error,
|
||||
hide_sql=hide_sql,
|
||||
show_hide_link=datasette.urls.path(show_hide_link),
|
||||
show_hide_text=show_hide_text,
|
||||
editable=not canned_query,
|
||||
editable=not stored_query,
|
||||
allow_execute_sql=allow_execute_sql,
|
||||
save_query_url=save_query_url,
|
||||
tables=await get_tables(datasette, request, db, allowed_dict),
|
||||
|
|
@ -2100,7 +2100,7 @@ class QueryView(View):
|
|||
datasette,
|
||||
request,
|
||||
database=database,
|
||||
query_name=canned_query["name"] if canned_query else None,
|
||||
query_name=stored_query["name"] if stored_query else None,
|
||||
),
|
||||
query_actions=query_actions,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -964,11 +964,11 @@ async def table_view_traced(datasette, request):
|
|||
resolved = await datasette.resolve_table(request)
|
||||
except TableNotFound as not_found:
|
||||
# Was this actually a stored query?
|
||||
canned_query = await datasette.get_canned_query(
|
||||
not_found.database_name, not_found.table, request.actor
|
||||
stored_query = await datasette.get_query(
|
||||
not_found.database_name, not_found.table
|
||||
)
|
||||
# If this is a stored query, not a table, then dispatch to QueryView instead
|
||||
if canned_query:
|
||||
if stored_query:
|
||||
return await QueryView()(request, datasette)
|
||||
else:
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ This configuration will deny access to everyone except the user with ``id`` of `
|
|||
How permissions are resolved
|
||||
----------------------------
|
||||
|
||||
Datasette performs permission checks using the internal :ref:`datasette_allowed`, method which accepts keyword arguments for ``action``, ``resource`` and an optional ``actor``.
|
||||
Datasette performs permission checks using the internal :ref:`datasette_allowed`, method which accepts keyword arguments for ``action``, ``resource`` and an optional ``actor``.
|
||||
|
||||
``resource`` should be an instance of the appropriate ``Resource`` subclass from :mod:`datasette.resources`—for example ``InstanceResource()``, ``DatabaseResource(database="...``)`` or ``TableResource(database="...", table="...")``. This defaults to ``InstanceResource()`` if not specified.
|
||||
|
||||
|
|
@ -468,7 +468,7 @@ You can control the following:
|
|||
* Access to the entire Datasette instance
|
||||
* Access to specific databases
|
||||
* Access to specific tables and views
|
||||
* Access to specific :ref:`queries <canned_queries>`
|
||||
* Access to specific :ref:`queries <queries>`
|
||||
|
||||
If a user has permission to view a table they will be able to view that table, independent of if they have permission to view the database or instance that the table exists within.
|
||||
|
||||
|
|
@ -496,7 +496,7 @@ Here's how to restrict access to your entire Datasette instance to just the ``"i
|
|||
title: My private Datasette instance
|
||||
allow:
|
||||
id: root
|
||||
|
||||
|
||||
|
||||
.. tab:: datasette.json
|
||||
|
||||
|
|
@ -644,7 +644,7 @@ This works for SQL views as well - you can list their names in the ``"tables"``
|
|||
Access to specific queries
|
||||
--------------------------
|
||||
|
||||
:ref:`Queries <canned_queries>` allow you to configure named SQL queries in your ``datasette.yaml`` that can be executed by users. These queries can be set up to both read and write to the database, so controlling who can execute them can be important.
|
||||
:ref:`Queries <queries>` allow you to configure named SQL queries in your ``datasette.yaml`` that can be executed by users. These queries can be set up to both read and write to the database, so controlling who can execute them can be important.
|
||||
|
||||
To limit access to the ``add_name`` query in your ``dogs.db`` database to just the :ref:`root user<authentication_root>`:
|
||||
|
||||
|
|
@ -1020,7 +1020,7 @@ You can also restrict permissions such that they can only be used within specifi
|
|||
|
||||
The resulting token will only be able to insert rows, and only to tables in the ``mydatabase`` database.
|
||||
|
||||
Finally, you can restrict permissions to individual resources - tables, SQL views and :ref:`named queries <canned_queries>` - within a specific database::
|
||||
Finally, you can restrict permissions to individual resources - tables, SQL views and :ref:`named queries <queries>` - within a specific database::
|
||||
|
||||
datasette create-token root --resource mydatabase mytable insert-row
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ Unreleased
|
|||
|
||||
- Fixed a bug where visiting ``/<database>/-/query`` without a ``?sql=`` parameter returned a 500 error. (:issue:`2743`)
|
||||
- The ``top_canned_query()`` plugin hook has been renamed to :ref:`top_stored_query() <plugin_hook_top_stored_query>`. (:issue:`2747`)
|
||||
- The ``canned_queries()`` plugin hook has been removed. Plugins can use the new ``datasette.add_query()``, ``datasette.update_query()`` and ``datasette.remove_query()`` methods to managed stored queries instead.
|
||||
- The ``canned_queries()`` plugin hook has been removed. Plugins can use the new ``datasette.add_query()``, ``datasette.update_query()`` and ``datasette.remove_query()`` methods to manage stored queries instead.
|
||||
- The ``datasette.get_canned_query()`` and ``datasette.get_canned_queries()`` methods have been removed. Plugins can use ``datasette.get_query()`` and ``datasette.list_queries()`` instead.
|
||||
|
||||
.. _v1_0_a30:
|
||||
|
||||
|
|
@ -658,7 +659,7 @@ For more information and workarounds, read `the security advisory <https://githu
|
|||
Also in this alpha:
|
||||
|
||||
- The new ``datasette plugins --requirements`` option outputs a list of currently installed plugins in Python ``requirements.txt`` format, useful for duplicating that installation elsewhere. (:issue:`2133`)
|
||||
- :ref:`canned_queries_writable` can now define a ``on_success_message_sql`` field in their configuration, containing a SQL query that should be executed upon successful completion of the write operation in order to generate a message to be shown to the user. (:issue:`2138`)
|
||||
- :ref:`queries_writable` can now define a ``on_success_message_sql`` field in their configuration, containing a SQL query that should be executed upon successful completion of the write operation in order to generate a message to be shown to the user. (:issue:`2138`)
|
||||
- The automatically generated border color for a database is now shown in more places around the application. (:issue:`2119`)
|
||||
- Every instance of example shell script code in the documentation should now include a working copy button, free from additional syntax. (:issue:`2140`)
|
||||
|
||||
|
|
@ -1052,7 +1053,7 @@ Other small fixes
|
|||
- The ``base.html`` template now wraps everything other than the ``<footer>`` in a ``<div class="not-footer">`` element, to help with advanced CSS customization. (:issue:`1446`)
|
||||
- The :ref:`render_cell() <plugin_hook_render_cell>` plugin hook can now return an awaitable function. This means the hook can execute SQL queries. (:issue:`1425`)
|
||||
- :ref:`plugin_register_routes` plugin hook now accepts an optional ``datasette`` argument. (:issue:`1404`)
|
||||
- New ``hide_sql`` canned query option for defaulting to hiding the SQL query used by a canned query, see :ref:`canned_queries_options`. (:issue:`1422`)
|
||||
- New ``hide_sql`` canned query option for defaulting to hiding the SQL query used by a canned query, see :ref:`queries_options`. (:issue:`1422`)
|
||||
- New ``--cpu`` option for :ref:`datasette publish cloudrun <publish_cloud_run>`. (:issue:`1420`)
|
||||
- If `Rich <https://github.com/willmcgugan/rich>`__ is installed in the same virtual environment as Datasette, it will be used to provide enhanced display of error tracebacks on the console. (:issue:`1416`)
|
||||
- ``datasette.utils`` :ref:`internals_utils_parse_metadata` function, used by the new `datasette-remote-metadata plugin <https://datasette.io/plugins/datasette-remote-metadata>`__, is now a documented API. (:issue:`1405`)
|
||||
|
|
@ -1426,7 +1427,7 @@ See also `Datasette 0.50: The annotated release notes <https://simonwillison.net
|
|||
|
||||
See also `Datasette 0.49: The annotated release notes <https://simonwillison.net/2020/Sep/15/datasette-0-49/>`__.
|
||||
|
||||
- Writable canned queries now expose a JSON API, see :ref:`canned_queries_json_api`. (:issue:`880`)
|
||||
- Writable canned queries now expose a JSON API, see :ref:`queries_json_api`. (:issue:`880`)
|
||||
- New mechanism for defining page templates with custom path parameters - a template file called ``pages/about/{slug}.html`` will be used to render any requests to ``/about/something``. See :ref:`custom_pages_parameters`. (:issue:`944`)
|
||||
- ``register_output_renderer()`` render functions can now return a ``Response``. (:issue:`953`)
|
||||
- New ``--upgrade`` option for ``datasette install``. (:issue:`945`)
|
||||
|
|
@ -1518,7 +1519,7 @@ Magic parameters for canned queries, a log out feature, improved plugin document
|
|||
Magic parameters for canned queries
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Canned queries now support :ref:`canned_queries_magic_parameters`, which can be used to insert or select automatically generated values. For example::
|
||||
Canned queries now support :ref:`queries_magic_parameters`, which can be used to insert or select automatically generated values. For example::
|
||||
|
||||
insert into logs
|
||||
(user_id, timestamp)
|
||||
|
|
@ -1549,7 +1550,7 @@ New plugin hooks
|
|||
|
||||
- :ref:`plugin_hook_register_magic_parameters` can be used to define new types of magic canned query parameters.
|
||||
- :ref:`plugin_hook_startup` can run custom code when Datasette first starts up. `datasette-init <https://github.com/simonw/datasette-init>`__ is a new plugin that uses this hook to create database tables and views on startup if they have not yet been created. (:issue:`834`)
|
||||
- :ref:`plugin_hook_canned_queries` lets plugins provide additional canned queries beyond those defined in Datasette's metadata. See `datasette-saved-queries <https://github.com/simonw/datasette-saved-queries>`__ for an example of this hook in action. (:issue:`852`)
|
||||
- ``canned_queries()`` lets plugins provide additional canned queries beyond those defined in Datasette's metadata. See `datasette-saved-queries <https://github.com/simonw/datasette-saved-queries>`__ for an example of this hook in action. (:issue:`852`)
|
||||
- :ref:`plugin_hook_forbidden` is a hook for customizing how Datasette responds to 403 forbidden errors. (:issue:`812`)
|
||||
|
||||
Smaller changes
|
||||
|
|
@ -1624,7 +1625,7 @@ A new debug page at ``/-/permissions`` shows recent permission checks, to help a
|
|||
Writable canned queries
|
||||
~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Datasette's :ref:`canned_queries` feature lets you define SQL queries in ``metadata.json`` which can then be executed by users visiting a specific URL. https://latest.datasette.io/fixtures/neighborhood_search for example.
|
||||
Datasette's :ref:`queries` feature lets you define SQL queries in ``metadata.json`` which can then be executed by users visiting a specific URL. https://latest.datasette.io/fixtures/neighborhood_search for example.
|
||||
|
||||
Canned queries were previously restricted to ``SELECT``, but Datasette 0.44 introduces the ability for canned queries to execute ``INSERT`` or ``UPDATE`` queries as well, using the new ``"write": true`` property (:issue:`800`):
|
||||
|
||||
|
|
@ -1643,7 +1644,7 @@ Canned queries were previously restricted to ``SELECT``, but Datasette 0.44 intr
|
|||
}
|
||||
}
|
||||
|
||||
See :ref:`canned_queries_writable` for more details.
|
||||
See :ref:`queries_writable` for more details.
|
||||
|
||||
Flash messages
|
||||
~~~~~~~~~~~~~~
|
||||
|
|
@ -1698,7 +1699,7 @@ Smaller changes
|
|||
- New ``request.cookies`` property.
|
||||
- ``/-/plugins`` endpoint now shows a list of hooks implemented by each plugin, e.g. https://latest.datasette.io/-/plugins?all=1
|
||||
- ``request.post_vars()`` method no longer discards empty values.
|
||||
- New "params" canned query key for explicitly setting named parameters, see :ref:`canned_queries_named_parameters`. (:issue:`797`)
|
||||
- New "params" canned query key for explicitly setting named parameters, see :ref:`queries_named_parameters`. (:issue:`797`)
|
||||
- ``request.args`` is now a :ref:`MultiParams <internals_multiparams>` object.
|
||||
- Fixed a bug with the ``datasette plugins`` command. (:issue:`802`)
|
||||
- Nicer pattern for using ``make_app_client()`` in tests. (:issue:`395`)
|
||||
|
|
@ -1732,7 +1733,7 @@ The main focus of this release is a major upgrade to the :ref:`plugin_register_o
|
|||
* Visually distinguish float and integer columns - useful for figuring out why order-by-column might be returning unexpected results. (:issue:`729`)
|
||||
* The :ref:`internals_request`, which is passed to several plugin hooks, is now documented. (:issue:`706`)
|
||||
* New ``metadata.json`` option for setting a custom default page size for specific tables and views, see :ref:`table_configuration_size`. (:issue:`751`)
|
||||
* Canned queries can now be configured with a default URL fragment hash, useful when working with plugins such as `datasette-vega <https://github.com/simonw/datasette-vega>`__, see :ref:`canned_queries_options`. (:issue:`706`)
|
||||
* Canned queries can now be configured with a default URL fragment hash, useful when working with plugins such as `datasette-vega <https://github.com/simonw/datasette-vega>`__, see :ref:`queries_options`. (:issue:`706`)
|
||||
* Fixed a bug in ``datasette publish`` when running on operating systems where the ``/tmp`` directory lives in a different volume, using a backport of the Python 3.8 ``shutil.copytree()`` function. (:issue:`744`)
|
||||
* Every plugin hook is now covered by the unit tests, and a new unit test checks that each plugin hook has at least one corresponding test. (:issue:`771`, :issue:`773`)
|
||||
|
||||
|
|
@ -2249,7 +2250,7 @@ A number of small new features:
|
|||
- Documentation for :ref:`datasette publish and datasette package <publishing>`, closes `#337 <https://github.com/simonw/datasette/issues/337>`_
|
||||
- Fixed compatibility with Python 3.7
|
||||
- ``datasette publish heroku`` now supports app names via the ``-n`` option, which can also be used to overwrite an existing application [Russ Garrett]
|
||||
- Title and description metadata can now be set for :ref:`canned SQL queries <canned_queries>`, closes `#342 <https://github.com/simonw/datasette/issues/342>`_
|
||||
- Title and description metadata can now be set for :ref:`canned SQL queries <queries>`, closes `#342 <https://github.com/simonw/datasette/issues/342>`_
|
||||
- New ``force_https_on`` config option, fixes ``https://`` API URLs when deploying to Zeit Now - closes `#333 <https://github.com/simonw/datasette/issues/333>`_
|
||||
- ``?_json_infinity=1`` query string argument for handling Infinity/-Infinity values in JSON, closes `#332 <https://github.com/simonw/datasette/issues/332>`_
|
||||
- URLs displayed in the results of custom SQL queries are now URLified, closes `#298 <https://github.com/simonw/datasette/issues/298>`_
|
||||
|
|
|
|||
|
|
@ -434,12 +434,12 @@ Here is a simple example:
|
|||
|
||||
:ref:`authentication_permissions_config` has the full details.
|
||||
|
||||
.. _configuration_reference_canned_queries:
|
||||
.. _configuration_reference_queries:
|
||||
|
||||
Queries configuration
|
||||
~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
:ref:`Queries <canned_queries>` are named SQL queries that appear in the Datasette interface. They can be configured in ``datasette.yaml`` using the ``queries`` key at the database level:
|
||||
:ref:`Queries <queries>` are named SQL queries that appear in the Datasette interface. They can be configured in ``datasette.yaml`` using the ``queries`` key at the database level:
|
||||
|
||||
.. [[[cog
|
||||
from metadata_doc import config_example, config_example
|
||||
|
|
@ -484,7 +484,7 @@ Queries configuration
|
|||
}
|
||||
.. [[[end]]]
|
||||
|
||||
See the :ref:`queries documentation <canned_queries>` for more, including how to configure :ref:`writable queries <canned_queries_writable>`.
|
||||
See the :ref:`queries documentation <queries>` for more, including how to configure :ref:`writable queries <queries_writable>`.
|
||||
|
||||
.. _configuration_reference_css_js:
|
||||
|
||||
|
|
|
|||
|
|
@ -1207,16 +1207,6 @@ Potential use-cases:
|
|||
|
||||
Examples: `datasette-saved-queries <https://datasette.io/plugins/datasette-saved-queries>`__, `datasette-init <https://datasette.io/plugins/datasette-init>`__
|
||||
|
||||
.. _plugin_hook_canned_queries:
|
||||
|
||||
canned_queries(datasette, database, actor)
|
||||
------------------------------------------
|
||||
|
||||
This hook has been removed. Plugins that need to add stored queries should use
|
||||
the :ref:`plugin_hook_startup` hook and call ``await datasette.add_query(...)``.
|
||||
|
||||
Example: `datasette-saved-queries <https://datasette.io/plugins/datasette-saved-queries>`__
|
||||
|
||||
.. _plugin_hook_actor_from_request:
|
||||
|
||||
actor_from_request(datasette, request)
|
||||
|
|
@ -1635,7 +1625,7 @@ register_magic_parameters(datasette)
|
|||
``datasette`` - :ref:`internals_datasette`
|
||||
You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``.
|
||||
|
||||
:ref:`canned_queries_magic_parameters` can be used to add automatic parameters to :ref:`configured queries <canned_queries>`. This plugin hook allows additional magic parameters to be defined by plugins.
|
||||
:ref:`queries_magic_parameters` can be used to add automatic parameters to :ref:`configured queries <queries>`. This plugin hook allows additional magic parameters to be defined by plugins.
|
||||
|
||||
Magic parameters all take this format: ``_prefix_rest_of_parameter``. The prefix indicates which magic parameter function should be called - the rest of the parameter is passed as an argument to that function.
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ Warning
|
|||
The following steps are recommended:
|
||||
|
||||
- Disable arbitrary SQL queries by untrusted users. See :ref:`authentication_permissions_execute_sql` for ways to do this. The easiest is to start Datasette with the ``datasette --setting default_allow_sql off`` option.
|
||||
- Define :ref:`queries <canned_queries>` with the SQL queries that use SpatiaLite functions that you want people to be able to execute.
|
||||
- Define :ref:`queries <queries>` with the SQL queries that use SpatiaLite functions that you want people to be able to execute.
|
||||
|
||||
The `Datasette SpatiaLite tutorial <https://datasette.io/tutorials/spatialite>`__ includes detailed instructions for running SpatiaLite safely using these techniques
|
||||
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ You can also use the `sqlite-utils <https://sqlite-utils.datasette.io/>`__ tool
|
|||
|
||||
sqlite-utils create-view sf-trees.db demo_view "select qSpecies from Street_Tree_List"
|
||||
|
||||
.. _canned_queries:
|
||||
.. _queries:
|
||||
|
||||
Queries
|
||||
-------
|
||||
|
|
@ -173,7 +173,7 @@ You can opt out of this behavior for a configured query using ``is_trusted: fals
|
|||
sql: select * from report
|
||||
is_trusted: false
|
||||
|
||||
.. _canned_queries_named_parameters:
|
||||
.. _queries_named_parameters:
|
||||
|
||||
Query parameters
|
||||
~~~~~~~~~~~~~~~~
|
||||
|
|
@ -313,7 +313,7 @@ You can alternatively provide an explicit list of named parameters using the ``"
|
|||
}
|
||||
.. [[[end]]]
|
||||
|
||||
.. _canned_queries_options:
|
||||
.. _queries_options:
|
||||
|
||||
Additional query options
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
|
@ -389,7 +389,7 @@ This example demonstrates both ``fragment`` and ``hide_sql``:
|
|||
|
||||
`See here <https://latest.datasette.io/fixtures#queries>`__ for a demo of this in action.
|
||||
|
||||
.. _canned_queries_writable:
|
||||
.. _queries_writable:
|
||||
|
||||
Writable queries
|
||||
~~~~~~~~~~~~~~~~
|
||||
|
|
@ -524,7 +524,7 @@ You can pre-populate form fields when the page first loads using a query string,
|
|||
|
||||
If you specify a query in ``"on_success_message_sql"``, that query will be executed after the main query. The first column of the first row return by that query will be displayed as a success message. Named parameters from the main query will be made available to the success message query as well.
|
||||
|
||||
.. _canned_queries_magic_parameters:
|
||||
.. _queries_magic_parameters:
|
||||
|
||||
Magic parameters
|
||||
~~~~~~~~~~~~~~~~
|
||||
|
|
@ -621,7 +621,7 @@ The form presented at ``/mydatabase/add_message`` will have just a field for ``m
|
|||
|
||||
Additional custom magic parameters can be added by plugins using the :ref:`plugin_hook_register_magic_parameters` hook.
|
||||
|
||||
.. _canned_queries_json_api:
|
||||
.. _queries_json_api:
|
||||
|
||||
JSON API for writable queries
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
|
|
|||
|
|
@ -244,7 +244,7 @@ except (KeyError, TypeError):
|
|||
New code:
|
||||
```python
|
||||
try:
|
||||
query_info = await datasette.get_canned_query(database, query_name, request.actor)
|
||||
query_info = await datasette.get_query(database, query_name)
|
||||
if query_info and "title" in query_info:
|
||||
title = query_info["title"]
|
||||
except (KeyError, TypeError):
|
||||
|
|
@ -253,7 +253,7 @@ except (KeyError, TypeError):
|
|||
|
||||
### Update render functions to async
|
||||
|
||||
If your plugin's render function needs to call `datasette.get_canned_query()` or other async Datasette methods, it must be declared as async:
|
||||
If your plugin's render function needs to call `datasette.get_query()` or other async Datasette methods, it must be declared as async:
|
||||
|
||||
Old code:
|
||||
```python
|
||||
|
|
@ -268,7 +268,7 @@ New code:
|
|||
async def render_atom(datasette, request, sql, columns, rows, database, table, query_name, view_name, data):
|
||||
# ...
|
||||
if query_name:
|
||||
query_info = await datasette.get_canned_query(database, query_name, request.actor)
|
||||
query_info = await datasette.get_query(database, query_name)
|
||||
if query_info and "title" in query_info:
|
||||
title = query_info["title"]
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,473 +0,0 @@
|
|||
from bs4 import BeautifulSoup as Soup
|
||||
from asgiref.sync import async_to_sync
|
||||
import json
|
||||
import pytest
|
||||
import re
|
||||
from .fixtures import make_app_client
|
||||
|
||||
|
||||
def update_query(client, name, **kwargs):
|
||||
async_to_sync(client.ds.invoke_startup)()
|
||||
async_to_sync(client.ds.update_query)("data", name, **kwargs)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def canned_write_client(tmpdir):
|
||||
template_dir = tmpdir / "canned_write_templates"
|
||||
template_dir.mkdir()
|
||||
(template_dir / "query-data-update_name.html").write_text(
|
||||
"""
|
||||
{% extends "query.html" %}
|
||||
{% block content %}!!!CUSTOM_UPDATE_NAME_TEMPLATE!!!{{ super() }}{% endblock %}
|
||||
""",
|
||||
"utf-8",
|
||||
)
|
||||
with make_app_client(
|
||||
extra_databases={"data.db": "create table names (name text)"},
|
||||
template_dir=str(template_dir),
|
||||
config={
|
||||
"databases": {
|
||||
"data": {
|
||||
"queries": {
|
||||
"canned_read": {"sql": "select * from names"},
|
||||
"add_name": {
|
||||
"sql": "insert into names (name) values (:name)",
|
||||
"write": True,
|
||||
"on_success_redirect": "/data/add_name?success",
|
||||
},
|
||||
"add_name_specify_id": {
|
||||
"sql": "insert into names (rowid, name) values (:rowid, :name)",
|
||||
"on_success_message_sql": "select 'Name added: ' || :name || ' with rowid ' || :rowid",
|
||||
"write": True,
|
||||
"on_error_redirect": "/data/add_name_specify_id?error",
|
||||
},
|
||||
"add_name_specify_id_with_error_in_on_success_message_sql": {
|
||||
"sql": "insert into names (rowid, name) values (:rowid, :name)",
|
||||
"on_success_message_sql": "select this is bad SQL",
|
||||
"write": True,
|
||||
},
|
||||
"delete_name": {
|
||||
"sql": "delete from names where rowid = :rowid",
|
||||
"write": True,
|
||||
"on_success_message": "Name deleted",
|
||||
"allow": {"id": "root"},
|
||||
},
|
||||
"update_name": {
|
||||
"sql": "update names set name = :name where rowid = :rowid",
|
||||
"params": ["rowid", "name", "extra"],
|
||||
"write": True,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) as client:
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def canned_write_immutable_client():
|
||||
with make_app_client(
|
||||
is_immutable=True,
|
||||
config={
|
||||
"databases": {
|
||||
"fixtures": {
|
||||
"queries": {
|
||||
"add": {
|
||||
"sql": "insert into sortable (text) values (:text)",
|
||||
"write": True,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) as client:
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_canned_query_with_named_parameter(ds_client):
|
||||
response = await ds_client.get(
|
||||
"/fixtures/neighborhood_search.json?text=town&_shape=arrays"
|
||||
)
|
||||
assert response.json()["rows"] == [
|
||||
["Corktown", "Detroit", "MI"],
|
||||
["Downtown", "Los Angeles", "CA"],
|
||||
["Downtown", "Detroit", "MI"],
|
||||
["Greektown", "Detroit", "MI"],
|
||||
["Koreatown", "Los Angeles", "CA"],
|
||||
["Mexicantown", "Detroit", "MI"],
|
||||
]
|
||||
|
||||
|
||||
def test_insert(canned_write_client):
|
||||
response = canned_write_client.post(
|
||||
"/data/add_name",
|
||||
{"name": "Hello"},
|
||||
csrftoken_from=True,
|
||||
cookies={"foo": "bar"},
|
||||
)
|
||||
messages = canned_write_client.ds.unsign(
|
||||
response.cookies["ds_messages"], "messages"
|
||||
)
|
||||
assert messages == [["Query executed, 1 row affected", 1]]
|
||||
assert response.status == 302
|
||||
assert response.headers["Location"] == "/data/add_name?success"
|
||||
|
||||
|
||||
def test_insert_blocked_cross_site(canned_write_client):
|
||||
# A cross-site POST (browser-originated) must be blocked
|
||||
response = canned_write_client.post(
|
||||
"/data/add_name",
|
||||
{"name": "Hello"},
|
||||
headers={"sec-fetch-site": "cross-site"},
|
||||
)
|
||||
assert 403 == response.status
|
||||
|
||||
|
||||
def test_insert_no_cookies_no_csrf(canned_write_client):
|
||||
response = canned_write_client.post("/data/add_name", {"name": "Hello"})
|
||||
assert 302 == response.status
|
||||
assert "/data/add_name?success" == response.headers["Location"]
|
||||
|
||||
|
||||
def test_custom_success_message(canned_write_client):
|
||||
response = canned_write_client.post(
|
||||
"/data/delete_name",
|
||||
{"rowid": 1},
|
||||
cookies={"ds_actor": canned_write_client.actor_cookie({"id": "root"})},
|
||||
csrftoken_from=True,
|
||||
)
|
||||
assert 302 == response.status
|
||||
messages = canned_write_client.ds.unsign(
|
||||
response.cookies["ds_messages"], "messages"
|
||||
)
|
||||
assert [["Name deleted", 1]] == messages
|
||||
|
||||
|
||||
def test_insert_error(canned_write_client):
|
||||
canned_write_client.post("/data/add_name", {"name": "Hello"}, csrftoken_from=True)
|
||||
response = canned_write_client.post(
|
||||
"/data/add_name_specify_id",
|
||||
{"rowid": 1, "name": "Should fail"},
|
||||
csrftoken_from=True,
|
||||
)
|
||||
assert 302 == response.status
|
||||
assert "/data/add_name_specify_id?error" == response.headers["Location"]
|
||||
messages = canned_write_client.ds.unsign(
|
||||
response.cookies["ds_messages"], "messages"
|
||||
)
|
||||
assert [["UNIQUE constraint failed: names.rowid", 3]] == messages
|
||||
# How about with a custom error message?
|
||||
update_query(canned_write_client, "add_name_specify_id", on_error_message="ERROR")
|
||||
response = canned_write_client.post(
|
||||
"/data/add_name_specify_id",
|
||||
{"rowid": 1, "name": "Should fail"},
|
||||
csrftoken_from=True,
|
||||
)
|
||||
assert [["ERROR", 3]] == canned_write_client.ds.unsign(
|
||||
response.cookies["ds_messages"], "messages"
|
||||
)
|
||||
|
||||
|
||||
def test_on_success_message_sql(canned_write_client):
|
||||
response = canned_write_client.post(
|
||||
"/data/add_name_specify_id",
|
||||
{"rowid": 5, "name": "Should be OK"},
|
||||
csrftoken_from=True,
|
||||
)
|
||||
assert response.status == 302
|
||||
assert response.headers["Location"] == "/data/add_name_specify_id"
|
||||
messages = canned_write_client.ds.unsign(
|
||||
response.cookies["ds_messages"], "messages"
|
||||
)
|
||||
assert messages == [["Name added: Should be OK with rowid 5", 1]]
|
||||
|
||||
|
||||
def test_error_in_on_success_message_sql(canned_write_client):
|
||||
response = canned_write_client.post(
|
||||
"/data/add_name_specify_id_with_error_in_on_success_message_sql",
|
||||
{"rowid": 1, "name": "Should fail"},
|
||||
csrftoken_from=True,
|
||||
)
|
||||
messages = canned_write_client.ds.unsign(
|
||||
response.cookies["ds_messages"], "messages"
|
||||
)
|
||||
assert messages == [
|
||||
["Error running on_success_message_sql: no such column: bad", 3]
|
||||
]
|
||||
|
||||
|
||||
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" data-parameter-control>'
|
||||
in response.text
|
||||
)
|
||||
|
||||
|
||||
def test_canned_query_pages_no_vary_header(canned_write_client):
|
||||
# These pages no longer embed per-cookie CSRF tokens, so they must not
|
||||
# set Vary: Cookie - they should be cacheable across users.
|
||||
assert "vary" not in canned_write_client.get("/data").headers
|
||||
assert "vary" not in canned_write_client.get("/data/update_name").headers
|
||||
|
||||
|
||||
def test_json_post_body(canned_write_client):
|
||||
response = canned_write_client.post(
|
||||
"/data/add_name",
|
||||
body=json.dumps({"name": ["Hello", "there"]}),
|
||||
)
|
||||
assert 302 == response.status
|
||||
assert "/data/add_name?success" == response.headers["Location"]
|
||||
rows = canned_write_client.get("/data/names.json?_shape=array").json
|
||||
assert rows == [{"rowid": 1, "name": "['Hello', 'there']"}]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"headers,body,querystring",
|
||||
(
|
||||
(None, "name=NameGoesHere", "?_json=1"),
|
||||
({"Accept": "application/json"}, "name=NameGoesHere", None),
|
||||
(None, "name=NameGoesHere&_json=1", None),
|
||||
(None, '{"name": "NameGoesHere", "_json": 1}', None),
|
||||
),
|
||||
)
|
||||
def test_json_response(canned_write_client, headers, body, querystring):
|
||||
response = canned_write_client.post(
|
||||
"/data/add_name" + (querystring or ""),
|
||||
body=body,
|
||||
headers=headers,
|
||||
)
|
||||
assert 200 == response.status
|
||||
assert response.headers["content-type"] == "application/json; charset=utf-8"
|
||||
assert response.json == {
|
||||
"ok": True,
|
||||
"message": "Query executed, 1 row affected",
|
||||
"redirect": "/data/add_name?success",
|
||||
}
|
||||
rows = canned_write_client.get("/data/names.json?_shape=array").json
|
||||
assert rows == [{"rowid": 1, "name": "NameGoesHere"}]
|
||||
|
||||
|
||||
def test_canned_query_permissions_on_database_page(canned_write_client):
|
||||
# Without auth shows the five public queries
|
||||
anon_response = canned_write_client.get("/data.json")
|
||||
query_names = {q["name"] for q in anon_response.json["queries"]}
|
||||
assert query_names == {
|
||||
"add_name_specify_id_with_error_in_on_success_message_sql",
|
||||
"update_name",
|
||||
"add_name_specify_id",
|
||||
"canned_read",
|
||||
"add_name",
|
||||
}
|
||||
assert anon_response.json["queries_more"] is False
|
||||
|
||||
# With auth the database page preview shows the first five queries
|
||||
response = canned_write_client.get(
|
||||
"/data.json",
|
||||
cookies={"ds_actor": canned_write_client.actor_cookie({"id": "root"})},
|
||||
)
|
||||
assert response.status == 200
|
||||
query_names_and_private = sorted(
|
||||
[
|
||||
{"name": q["name"], "private": q["private"]}
|
||||
for q in response.json["queries"]
|
||||
],
|
||||
key=lambda q: q["name"],
|
||||
)
|
||||
assert query_names_and_private == [
|
||||
{"name": "add_name", "private": False},
|
||||
{"name": "add_name_specify_id", "private": False},
|
||||
{
|
||||
"name": "add_name_specify_id_with_error_in_on_success_message_sql",
|
||||
"private": False,
|
||||
},
|
||||
{"name": "canned_read", "private": False},
|
||||
{"name": "delete_name", "private": True},
|
||||
]
|
||||
assert response.json["queries_more"] is True
|
||||
|
||||
# The full query list endpoint includes the remaining query
|
||||
response = canned_write_client.get(
|
||||
"/data/-/queries.json?_size=10",
|
||||
cookies={"ds_actor": canned_write_client.actor_cookie({"id": "root"})},
|
||||
)
|
||||
assert response.status == 200
|
||||
query_names_and_private = sorted(
|
||||
[
|
||||
{"name": q["name"], "private": q["private"]}
|
||||
for q in response.json["queries"]
|
||||
],
|
||||
key=lambda q: q["name"],
|
||||
)
|
||||
assert query_names_and_private == [
|
||||
{"name": "add_name", "private": False},
|
||||
{"name": "add_name_specify_id", "private": False},
|
||||
{
|
||||
"name": "add_name_specify_id_with_error_in_on_success_message_sql",
|
||||
"private": False,
|
||||
},
|
||||
{"name": "canned_read", "private": False},
|
||||
{"name": "delete_name", "private": True},
|
||||
{"name": "update_name", "private": False},
|
||||
]
|
||||
|
||||
|
||||
def test_canned_query_permissions(canned_write_client):
|
||||
assert 403 == canned_write_client.get("/data/delete_name").status
|
||||
assert 200 == canned_write_client.get("/data/update_name").status
|
||||
cookies = {"ds_actor": canned_write_client.actor_cookie({"id": "root"})}
|
||||
assert 200 == canned_write_client.get("/data/delete_name", cookies=cookies).status
|
||||
assert 200 == canned_write_client.get("/data/update_name", cookies=cookies).status
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def magic_parameters_client():
|
||||
with make_app_client(
|
||||
extra_databases={"data.db": "create table logs (line text)"},
|
||||
config={
|
||||
"databases": {
|
||||
"data": {
|
||||
"queries": {
|
||||
"runme_post": {"sql": "", "write": True},
|
||||
"runme_get": {"sql": ""},
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) as client:
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"magic_parameter,expected_re",
|
||||
[
|
||||
("_actor_id", "root"),
|
||||
("_header_host", "localhost"),
|
||||
("_header_not_a_thing", ""),
|
||||
("_cookie_foo", "bar"),
|
||||
("_now_epoch", r"^\d+$"),
|
||||
("_now_date_utc", r"^\d{4}-\d{2}-\d{2}$"),
|
||||
("_now_datetime_utc", r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$"),
|
||||
("_random_chars_1", r"^\w$"),
|
||||
("_random_chars_10", r"^\w{10}$"),
|
||||
],
|
||||
)
|
||||
def test_magic_parameters(magic_parameters_client, magic_parameter, expected_re):
|
||||
update_query(
|
||||
magic_parameters_client,
|
||||
"runme_post",
|
||||
sql=f"insert into logs (line) values (:{magic_parameter})",
|
||||
)
|
||||
update_query(
|
||||
magic_parameters_client,
|
||||
"runme_get",
|
||||
sql=f"select :{magic_parameter} as result",
|
||||
)
|
||||
cookies = {
|
||||
"ds_actor": magic_parameters_client.actor_cookie({"id": "root"}),
|
||||
"foo": "bar",
|
||||
}
|
||||
# Test the GET version
|
||||
get_response = magic_parameters_client.get(
|
||||
"/data/runme_get.json?_shape=array", cookies=cookies
|
||||
)
|
||||
get_actual = get_response.json[0]["result"]
|
||||
assert re.match(expected_re, str(get_actual))
|
||||
# Test the form
|
||||
form_response = magic_parameters_client.get("/data/runme_post")
|
||||
soup = Soup(form_response.body, "html.parser")
|
||||
# The magic parameter should not be represented as a form field
|
||||
assert None is soup.find("input", {"name": magic_parameter})
|
||||
# Submit the form to create a log line
|
||||
response = magic_parameters_client.post(
|
||||
"/data/runme_post?_json=1", {}, csrftoken_from=True, cookies=cookies
|
||||
)
|
||||
assert response.json == {
|
||||
"ok": True,
|
||||
"message": "Query executed, 1 row affected",
|
||||
"redirect": None,
|
||||
}
|
||||
post_actual = magic_parameters_client.get(
|
||||
"/data/logs.json?_sort_desc=rowid&_shape=array"
|
||||
).json[0]["line"]
|
||||
assert re.match(expected_re, post_actual)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_csrf", [True, False])
|
||||
@pytest.mark.parametrize("return_json", [True, False])
|
||||
def test_magic_parameters_csrf_json(magic_parameters_client, use_csrf, return_json):
|
||||
update_query(
|
||||
magic_parameters_client,
|
||||
"runme_post",
|
||||
sql="insert into logs (line) values (:_header_host)",
|
||||
)
|
||||
qs = ""
|
||||
if return_json:
|
||||
qs = "?_json=1"
|
||||
response = magic_parameters_client.post(
|
||||
f"/data/runme_post{qs}",
|
||||
{},
|
||||
csrftoken_from=use_csrf or None,
|
||||
)
|
||||
if return_json:
|
||||
assert response.status == 200
|
||||
assert response.json["ok"], response.json
|
||||
else:
|
||||
assert response.status == 302
|
||||
messages = magic_parameters_client.ds.unsign(
|
||||
response.cookies["ds_messages"], "messages"
|
||||
)
|
||||
assert [["Query executed, 1 row affected", 1]] == messages
|
||||
post_actual = magic_parameters_client.get(
|
||||
"/data/logs.json?_sort_desc=rowid&_shape=array"
|
||||
).json[0]["line"]
|
||||
assert post_actual == "localhost"
|
||||
|
||||
|
||||
def test_magic_parameters_cannot_be_used_in_arbitrary_queries(magic_parameters_client):
|
||||
response = magic_parameters_client.get(
|
||||
"/data/-/query.json?sql=select+:_header_host&_shape=array"
|
||||
)
|
||||
assert 400 == response.status
|
||||
assert response.json["error"].startswith("You did not supply a value for binding")
|
||||
|
||||
|
||||
def test_canned_write_custom_template(canned_write_client):
|
||||
response = canned_write_client.get("/data/update_name")
|
||||
assert response.status == 200
|
||||
assert "!!!CUSTOM_UPDATE_NAME_TEMPLATE!!!" in response.text
|
||||
assert (
|
||||
"<!-- Templates considered: *query-data-update_name.html, query-data.html, query.html -->"
|
||||
in response.text
|
||||
)
|
||||
# And test for link rel=alternate while we're here:
|
||||
assert (
|
||||
'<link rel="alternate" type="application/json+datasette" href="http://localhost/data/update_name.json">'
|
||||
in response.text
|
||||
)
|
||||
assert (
|
||||
response.headers["link"]
|
||||
== '<http://localhost/data/update_name.json>; rel="alternate"; type="application/json+datasette"'
|
||||
)
|
||||
|
||||
|
||||
def test_canned_write_query_disabled_for_immutable_database(
|
||||
canned_write_immutable_client,
|
||||
):
|
||||
response = canned_write_immutable_client.get("/fixtures/add")
|
||||
assert response.status == 200
|
||||
assert (
|
||||
"This query cannot be executed because the database is immutable."
|
||||
in response.text
|
||||
)
|
||||
assert '<input type="submit" value="Run SQL" disabled>' in response.text
|
||||
# Submitting form should get a forbidden error
|
||||
response = canned_write_immutable_client.post(
|
||||
"/fixtures/add",
|
||||
{"text": "text"},
|
||||
csrftoken_from=True,
|
||||
)
|
||||
assert response.status == 403
|
||||
assert "Database is immutable" in response.text
|
||||
|
|
@ -633,7 +633,7 @@ async def test_404_content_type(ds_client):
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_canned_query_default_title(ds_client):
|
||||
async def test_stored_query_default_title(ds_client):
|
||||
response = await ds_client.get("/fixtures/magic_parameters")
|
||||
assert response.status_code == 200
|
||||
soup = Soup(response.content, "html.parser")
|
||||
|
|
@ -641,7 +641,7 @@ async def test_canned_query_default_title(ds_client):
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_canned_query_with_custom_metadata(ds_client):
|
||||
async def test_stored_query_with_custom_metadata(ds_client):
|
||||
response = await ds_client.get("/fixtures/neighborhood_search?text=town")
|
||||
assert response.status_code == 200
|
||||
soup = Soup(response.content, "html.parser")
|
||||
|
|
@ -700,7 +700,7 @@ async def test_show_hide_sql_query(ds_client):
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_canned_query_with_hide_has_no_hidden_sql(ds_client):
|
||||
async def test_stored_query_with_hide_has_no_hidden_sql(ds_client):
|
||||
# For a stored query the show/hide should NOT have a hidden SQL field
|
||||
# https://github.com/simonw/datasette/issues/1411
|
||||
response = await ds_client.get("/fixtures/pragma_cache_size?_hide_sql=1")
|
||||
|
|
@ -720,7 +720,7 @@ async def test_canned_query_with_hide_has_no_hidden_sql(ds_client):
|
|||
(True, "?_show_sql=1", "_show_sql", "/_memory/one", "hide"),
|
||||
),
|
||||
)
|
||||
def test_canned_query_show_hide_metadata_option(
|
||||
def test_stored_query_show_hide_metadata_option(
|
||||
hide_sql,
|
||||
querystring,
|
||||
expected_hidden,
|
||||
|
|
@ -981,10 +981,10 @@ def test_base_url_affects_metadata_extra_css_urls(app_client_base_url_prefix):
|
|||
("/fixtures/magic_parameters", None),
|
||||
],
|
||||
)
|
||||
async def test_edit_sql_link_on_canned_queries(ds_client, path, expected):
|
||||
async def test_edit_sql_link_on_stored_queries(ds_client, path, expected):
|
||||
response = await ds_client.get(path)
|
||||
assert response.status_code == 200
|
||||
expected_link = f'<a href="{expected}" class="canned-query-edit-sql">Edit SQL</a>'
|
||||
expected_link = f'<a href="{expected}" class="stored-query-edit-sql">Edit SQL</a>'
|
||||
if expected:
|
||||
assert expected_link in response.text
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ async def ds_for_jump():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jump_searches_tables_databases_views_and_canned_queries(ds_for_jump):
|
||||
async def test_jump_searches_tables_databases_views_and_stored_queries(ds_for_jump):
|
||||
response = await ds_for_jump.client.get(
|
||||
"/-/jump.json?q=content", actor={"id": "user"}
|
||||
)
|
||||
|
|
@ -98,7 +98,7 @@ async def test_jump_searches_tables_databases_views_and_canned_queries(ds_for_ju
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jump_uses_canned_query_names_not_titles(ds_for_jump):
|
||||
async def test_jump_uses_stored_query_names_not_titles(ds_for_jump):
|
||||
response = await ds_for_jump.client.get(
|
||||
"/-/jump.json?q=datasette", actor={"id": "user"}
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue