From bc6a9b45646610f362b4287bc4110440991aa4d6 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 12 Apr 2019 18:37:22 -0700 Subject: [PATCH] ?_where= parameter on table views, closes #429 From pull request #430 --- datasette/static/app.css | 5 +++++ datasette/templates/table.html | 11 +++++++++++ datasette/views/table.py | 15 +++++++++++++++ docs/json_api.rst | 15 +++++++++++++++ tests/test_api.py | 34 ++++++++++++++++++++++++++++++++-- tests/test_html.py | 14 ++++++++++++++ 6 files changed, 92 insertions(+), 2 deletions(-) diff --git a/datasette/static/app.css b/datasette/static/app.css index f21d3ea4..468c15f6 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -105,6 +105,11 @@ h2 em { font-style: normal; font-weight: lighter; } +.extra-wheres ul, .extra-wheres li { + list-style-type: none; + padding: 0; + margin: 0; +} form.sql textarea { border: 1px solid #ccc; width: 70%; diff --git a/datasette/templates/table.html b/datasette/templates/table.html index 12887f63..1c65aa10 100644 --- a/datasette/templates/table.html +++ b/datasette/templates/table.html @@ -91,6 +91,17 @@ +{% if extra_wheres_for_ui %} +
+

{{ extra_wheres_for_ui|length }} extra where clause{% if extra_wheres_for_ui|length != 1 %}s{% endif %}

+ +
+{% endif %} + {% if query.sql and config.allow_sql %}

View and edit SQL

{% endif %} diff --git a/datasette/views/table.py b/datasette/views/table.py index b7c9a4b0..efd6c0e2 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -295,6 +295,20 @@ class TableView(RowTableShared): filters = Filters(sorted(other_args.items()), units, ureg) where_clauses, params = filters.build_where_clauses(table) + extra_wheres_for_ui = [] + # Add _where= from querystring + if "_where" in request.args: + if not self.ds.config("allow_sql"): + raise DatasetteError("_where= is not allowed", status=400) + else: + where_clauses.extend(request.args["_where"]) + extra_wheres_for_ui = [{ + "text": text, + "remove_url": path_with_removed_args( + request, {"_where": text} + ) + } for text in request.args["_where"]] + # _search support: fts_table = special_args.get("_fts_table") fts_table = fts_table or table_metadata.get("fts_table") @@ -751,6 +765,7 @@ class TableView(RowTableShared): key=lambda f: (len(f["results"]), f["name"]), reverse=True ), + "extra_wheres_for_ui": extra_wheres_for_ui, "form_hidden_args": form_hidden_args, "facet_hideable": lambda facet: facet not in metadata_facets, "is_sortable": any(c["sortable"] for c in display_columns), diff --git a/docs/json_api.rst b/docs/json_api.rst index cd666b54..871da888 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -206,6 +206,21 @@ The Datasette table view takes a number of special querystring arguments: Like ``_search=`` but allows you to specify the column to be searched, as opposed to searching all columns that have been indexed by FTS. +``?_where=SQL-fragment`` + If the :ref:`config_allow_sql` config option is enabled, this parameter + can be used to pass one or more additional SQL fragments to be used in the + `WHERE` clause of the SQL used to query the table. + + This is particularly useful if you are building a JavaScript application + that needs to do something creative but still wants the other conveniences + provided by the table view (such as faceting) and hence would like not to + have to construct a completely custom SQL query. + + Some examples: + + * `facetable?_where=state="MI"&_where=city_id=3 `__ + * `facetable?_where=city_id in (select id from facet_cities where name != "Detroit") `__ + ``?_group_count=COLUMN`` Executes a SQL query that returns a count of the number of rows matching each unique value in that column, with the most common ordered first. diff --git a/tests/test_api.py b/tests/test_api.py index b822d23f..d6f612c8 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -443,9 +443,11 @@ def test_allow_sql_off(): for client in make_app_client(config={ 'allow_sql': False, }): - assert 400 == client.get( + response = client.get( "/fixtures.json?sql=select+sleep(0.01)" - ).status + ) + assert 400 == response.status + assert 'sql= is not allowed' == response.json['error'] def test_table_json(app_client): @@ -913,6 +915,34 @@ def test_table_filter_json_arraycontains(app_client): ] == response.json['rows'] +def test_table_filter_extra_where(app_client): + response = app_client.get( + "/fixtures/facetable.json?_where=neighborhood='Dogpatch'" + ) + assert [ + [2, 1, 1, 'CA', 1, 'Dogpatch', '["tag1", "tag3"]'] + ] == response.json['rows'] + + +def test_table_filter_extra_where_invalid(app_client): + response = app_client.get( + "/fixtures/facetable.json?_where=neighborhood=Dogpatch'" + ) + assert 400 == response.status + assert 'Invalid SQL' == response.json['title'] + + +def test_table_filter_extra_where_disabled_if_no_sql_allowed(): + for client in make_app_client(config={ + 'allow_sql': False, + }): + response = client.get( + "/fixtures/facetable.json?_where=neighborhood='Dogpatch'" + ) + assert 400 == response.status + assert '_where= is not allowed' == response.json['error'] + + def test_max_returned_rows(app_client): response = app_client.get( '/fixtures.json?sql=select+content+from+no_primary_key' diff --git a/tests/test_html.py b/tests/test_html.py index a52b8d79..a32734f3 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -869,3 +869,17 @@ def test_show_hide_sql_query(app_client): ] == [ (hidden['name'], hidden['value']) for hidden in hiddens ] + + +def test_extra_where_clauses(app_client): + response = app_client.get( + "/fixtures/facetable?_where=neighborhood='Dogpatch'&_where=city_id=1" + ) + soup = Soup(response.body, "html.parser") + div = soup.select(".extra-wheres")[0] + assert "2 extra where clauses" == div.find("h3").text + hrefs = [a["href"] for a in div.findAll("a")] + assert [ + "/fixtures/facetable?_where=city_id%3D1", + "/fixtures/facetable?_where=neighborhood%3D%27Dogpatch%27" + ] == hrefs