Skip to content

Commit

Permalink
New plugin hook: canned_queries(), refs #852
Browse files Browse the repository at this point in the history
  • Loading branch information
simonw committed Jun 18, 2020
1 parent d2f3875 commit 6c26345
Show file tree
Hide file tree
Showing 12 changed files with 203 additions and 50 deletions.
28 changes: 19 additions & 9 deletions datasette/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -387,18 +387,28 @@ def app_css_hash(self):
).hexdigest()[:6]
return self._app_css_hash

def get_canned_queries(self, database_name):
queries = self.metadata("queries", database=database_name, fallback=False) or {}
names = queries.keys()
return [self.get_canned_query(database_name, name) for name in names]

def get_canned_query(self, database_name, query_name):
async def get_canned_queries(self, database_name, actor):
queries = self.metadata("queries", database=database_name, fallback=False) or {}
for more_queries in pm.hook.canned_queries(
datasette=self, database=database_name, actor=actor,
):
if callable(more_queries):
more_queries = more_queries()
if asyncio.iscoroutine(more_queries):
more_queries = await more_queries
queries.update(more_queries or {})
# Fix any {"name": "select ..."} queries to be {"name": {"sql": "select ..."}}
for key in queries:
if not isinstance(queries[key], dict):
queries[key] = {"sql": queries[key]}
# Also make sure "name" is available:
queries[key]["name"] = key
return queries

async def get_canned_query(self, database_name, query_name, actor):
queries = await self.get_canned_queries(database_name, actor)
query = queries.get(query_name)
if query:
if not isinstance(query, dict):
query = {"sql": query}
query["name"] = query_name
return query

def update_with_inherited_metadata(self, metadata):
Expand Down
75 changes: 38 additions & 37 deletions datasette/default_permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,41 +4,42 @@

@hookimpl(tryfirst=True)
def permission_allowed(datasette, actor, action, resource):
if action == "permissions-debug":
if actor and actor.get("id") == "root":
return True
elif action == "view-instance":
allow = datasette.metadata("allow")
if allow is not None:
async def inner():
if action == "permissions-debug":
if actor and actor.get("id") == "root":
return True
elif action == "view-instance":
allow = datasette.metadata("allow")
if allow is not None:
return actor_matches_allow(actor, allow)
elif action == "view-database":
database_allow = datasette.metadata("allow", database=resource)
if database_allow is None:
return True
return actor_matches_allow(actor, database_allow)
elif action == "view-table":
database, table = resource
tables = datasette.metadata("tables", database=database) or {}
table_allow = (tables.get(table) or {}).get("allow")
if table_allow is None:
return True
return actor_matches_allow(actor, table_allow)
elif action == "view-query":
# Check if this query has a "allow" block in metadata
database, query_name = resource
query = await datasette.get_canned_query(database, query_name, actor)
assert query is not None
allow = query.get("allow")
if allow is None:
return True
return actor_matches_allow(actor, allow)
elif action == "view-database":
database_allow = datasette.metadata("allow", database=resource)
if database_allow is None:
return True
return actor_matches_allow(actor, database_allow)
elif action == "view-table":
database, table = resource
tables = datasette.metadata("tables", database=database) or {}
table_allow = (tables.get(table) or {}).get("allow")
if table_allow is None:
return True
return actor_matches_allow(actor, table_allow)
elif action == "view-query":
# Check if this query has a "allow" block in metadata
database, query_name = resource
queries_metadata = datasette.metadata("queries", database=database)
assert query_name in queries_metadata
if isinstance(queries_metadata[query_name], str):
return True
allow = queries_metadata[query_name].get("allow")
if allow is None:
return True
return actor_matches_allow(actor, allow)
elif action == "execute-sql":
# Use allow_sql block from database block, or from top-level
database_allow_sql = datasette.metadata("allow_sql", database=resource)
if database_allow_sql is None:
database_allow_sql = datasette.metadata("allow_sql")
if database_allow_sql is None:
return True
return actor_matches_allow(actor, database_allow_sql)
elif action == "execute-sql":
# Use allow_sql block from database block, or from top-level
database_allow_sql = datasette.metadata("allow_sql", database=resource)
if database_allow_sql is None:
database_allow_sql = datasette.metadata("allow_sql")
if database_allow_sql is None:
return True
return actor_matches_allow(actor, database_allow_sql)

return inner
5 changes: 5 additions & 0 deletions datasette/hookspecs.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,8 @@ def actor_from_request(datasette, request):
@hookspec
def permission_allowed(datasette, actor, action, resource):
"Check if actor is allowed to perfom this action - return True, False or None"


@hookspec
def canned_queries(datasette, database, actor):
"Return a dictonary of canned query definitions or an awaitable function that returns them"
4 changes: 3 additions & 1 deletion datasette/views/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,9 @@ async def data(self, request, database, hash, default_labels=False, _size=None):

tables.sort(key=lambda t: (t["hidden"], t["name"]))
canned_queries = []
for query in self.ds.get_canned_queries(database):
for query in (
await self.ds.get_canned_queries(database, request.actor)
).values():
visible, private = await check_visibility(
self.ds, request.actor, "view-query", (database, query["name"]),
)
Expand Down
6 changes: 4 additions & 2 deletions datasette/views/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,9 @@ class TableView(RowTableShared):

async def post(self, request, db_name, table_and_format):
# Handle POST to a canned query
canned_query = self.ds.get_canned_query(db_name, table_and_format)
canned_query = await self.ds.get_canned_query(
db_name, table_and_format, request.actor
)
assert canned_query, "You may only POST to a canned query"
return await QueryView(self.ds).data(
request,
Expand All @@ -247,7 +249,7 @@ async def data(
_next=None,
_size=None,
):
canned_query = self.ds.get_canned_query(database, table)
canned_query = await self.ds.get_canned_query(database, table, request.actor)
if canned_query:
return await QueryView(self.ds).data(
request,
Expand Down
67 changes: 67 additions & 0 deletions docs/plugins.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1031,6 +1031,73 @@ Potential use-cases:
* Create database tables that a plugin needs
* Validate the metadata configuration for a plugin on startup, and raise an error if it is invalid

.. _plugin_hook_canned_queries:

canned_queries(datasette, database, actor)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

``datasette`` - :ref:`internals_datasette`
You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.

``database`` - string
The name of the database.

``actor`` - dictionary or None
The currently authenticated :ref:`authentication_actor`.

Ues this hook to return a dictionary of additional :ref:`canned query <canned_queries>` definitions for the specified database. The return value should be the same shape as the JSON described in the :ref:`canned query <canned_queries>` documentation.

.. code-block:: python
from datasette import hookimpl
@hookimpl
def canned_queries(datasette, database):
if database == "mydb":
return {
"my_query": {
"sql": "select * from my_table where id > :min_id"
}
}
The hook can alternatively return an awaitable function that returns a list. Here's an example that returns queries that have been stored in the ``saved_queries`` database table, if one exists:

.. code-block:: python
from datasette import hookimpl
@hookimpl
def canned_queries(datasette, database):
async def inner():
db = datasette.get_database(database)
if await db.table_exists("saved_queries"):
results = await db.execute("select name, sql from saved_queries")
return {result["name"]: {
"sql": result["sql"]
} for result in results}
return inner
The actor parameter can be used to include the currently authenticated actor in your decision. Here's an example that returns saved queries that were saved by that actor:

.. code-block:: python
from datasette import hookimpl
@hookimpl
def canned_queries(datasette, database, actor):
async def inner():
db = datasette.get_database(database)
if actor is not None and await db.table_exists("saved_queries"):
results = await db.execute(
"select name, sql from saved_queries where actor_id = :id", {
"id": actor["id"]
}
)
return {result["name"]: {
"sql": result["sql"]
} for result in results}
return inner
.. _plugin_hook_actor_from_request:

actor_from_request(datasette, request)
Expand Down
2 changes: 2 additions & 0 deletions tests/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"hooks": [
"actor_from_request",
"asgi_wrapper",
"canned_queries",
"extra_body_script",
"extra_css_urls",
"extra_js_urls",
Expand All @@ -61,6 +62,7 @@
"hooks": [
"actor_from_request",
"asgi_wrapper",
"canned_queries",
"extra_js_urls",
"extra_template_vars",
"permission_allowed",
Expand Down
9 changes: 9 additions & 0 deletions tests/plugins/my_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,12 @@ async def post(request):
@hookimpl
def startup(datasette):
datasette._startup_hook_fired = True


@hookimpl
def canned_queries(datasette, database, actor):
return {
"from_hook": "select 1, '{}' as actor_id".format(
actor["id"] if actor else "null"
)
}
14 changes: 14 additions & 0 deletions tests/plugins/my_plugin_2.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,17 @@ async def inner():
datasette._startup_hook_calculation = result.first()[0]

return inner


@hookimpl
def canned_queries(datasette, database):
async def inner():
return {
"from_async_hook": "select {}".format(
(
await datasette.get_database(database).execute("select 1 + 1")
).first()[0]
)
}

return inner
10 changes: 9 additions & 1 deletion tests/test_canned_write.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,13 @@ def test_canned_query_permissions_on_database_page(canned_write_client):
query_names = [
q["name"] for q in canned_write_client.get("/data.json").json["queries"]
]
assert ["add_name", "add_name_specify_id", "update_name"] == query_names
assert [
"add_name",
"add_name_specify_id",
"update_name",
"from_async_hook",
"from_hook",
] == query_names

# With auth shows four
response = canned_write_client.get(
Expand All @@ -124,6 +130,8 @@ def test_canned_query_permissions_on_database_page(canned_write_client):
{"name": "add_name_specify_id", "private": False},
{"name": "delete_name", "private": True},
{"name": "update_name", "private": False},
{"name": "from_async_hook", "private": False},
{"name": "from_hook", "private": False},
] == [
{"name": q["name"], "private": q["private"]} for q in response.json["queries"]
]
Expand Down
2 changes: 2 additions & 0 deletions tests/test_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ def test_database_page(app_client):
),
("/fixtures/pragma_cache_size", "pragma_cache_size"),
("/fixtures/neighborhood_search#fragment-goes-here", "Search neighborhoods"),
("/fixtures/from_async_hook", "from_async_hook"),
("/fixtures/from_hook", "from_hook"),
] == [(a["href"], a.text) for a in queries_ul.find_all("a")]


Expand Down
31 changes: 31 additions & 0 deletions tests/test_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -591,3 +591,34 @@ async def test_startup(app_client):
await app_client.ds.invoke_startup()
assert app_client.ds._startup_hook_fired
assert 2 == app_client.ds._startup_hook_calculation


def test_canned_queries(app_client):
queries = app_client.get("/fixtures.json").json["queries"]
queries_by_name = {q["name"]: q for q in queries}
assert {
"sql": "select 2",
"name": "from_async_hook",
"private": False,
} == queries_by_name["from_async_hook"]
assert {
"sql": "select 1, 'null' as actor_id",
"name": "from_hook",
"private": False,
} == queries_by_name["from_hook"]


def test_canned_queries_non_async(app_client):
response = app_client.get("/fixtures/from_hook.json?_shape=array")
assert [{"1": 1, "actor_id": "null"}] == response.json


def test_canned_queries_async(app_client):
response = app_client.get("/fixtures/from_async_hook.json?_shape=array")
assert [{"2": 2}] == response.json


def test_canned_queries_actor(app_client):
assert [{"1": 1, "actor_id": "bot"}] == app_client.get(
"/fixtures/from_hook.json?_bot=1&_shape=array"
).json

0 comments on commit 6c26345

Please sign in to comment.