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 %}
+
+{% 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