diff --git a/datasette/app.py b/datasette/app.py
index 0b4a7e7e..c71968fc 100644
--- a/datasette/app.py
+++ b/datasette/app.py
@@ -2,6 +2,7 @@ from sanic import Sanic
from sanic import response
from sanic.exceptions import NotFound
from sanic.views import HTTPMethodView
+from sanic.request import RequestParameters
from sanic_jinja2 import SanicJinja2
from jinja2 import FileSystemLoader
import re
@@ -412,13 +413,19 @@ class TableView(BaseView):
if is_view:
order_by = ''
+ # We roll our own query_string decoder because by default Sanic
+ # drops anything with an empty value e.g. ?name__exact=
+ args = RequestParameters(
+ urllib.parse.parse_qs(request.query_string, keep_blank_values=True)
+ )
+
# Special args start with _ and do not contain a __
# That's so if there is a column that starts with _
# it can still be queried using ?_col__exact=blah
special_args = {}
special_args_lists = {}
other_args = {}
- for key, value in request.args.items():
+ for key, value in args.items():
if key.startswith('_') and '__' not in key:
special_args[key] = value[0]
special_args_lists[key] = value
@@ -540,6 +547,7 @@ class TableView(BaseView):
'supports_search': bool(fts_table),
'search': search or '',
'use_rowid': use_rowid,
+ 'filters': filters,
'display_columns': display_columns,
'display_rows': await self.make_display_rows(name, hash, table, rows, display_columns, pks, is_view, use_rowid),
}
diff --git a/datasette/templates/table.html b/datasette/templates/table.html
index ef9b7296..41cb0c27 100644
--- a/datasette/templates/table.html
+++ b/datasette/templates/table.html
@@ -29,13 +29,49 @@
{% endif %}
-{% if query.params %}
+
+
{{ query.sql }}
-params = {{ query.params|tojson(4) }}
-{% endif %}
+{% if query.params %}params = {{ query.params|tojson(4) }}
{% endif %}
This data as .json, .jsono
+Returned {% if truncated %}more than {% endif %}{{ "{:,}".format(display_rows|length) }} row{% if display_rows|length == 1 %}{% else %}s{% endif %}
+
diff --git a/datasette/utils.py b/datasette/utils.py
index 52d145b2..a27c2e53 100644
--- a/datasette/utils.py
+++ b/datasette/utils.py
@@ -290,8 +290,9 @@ def detect_fts_sql(table):
class Filter:
- def __init__(self, key, sql_template, human_template, format='{}', numeric=False, no_argument=False):
+ def __init__(self, key, display, sql_template, human_template, format='{}', numeric=False, no_argument=False):
self.key = key
+ self.display = display
self.sql_template = sql_template
self.human_template = human_template
self.format = format
@@ -327,36 +328,37 @@ class Filter:
class Filters:
_filters = [
- Filter('exact', '"{c}" = :{p}', lambda c, v: '{c} = {v}' if v.isdigit() else '{c} = "{v}"'),
- Filter('contains', '"{c}" like :{p}', '{c} contains "{v}"', format='%{}%'),
- Filter('endswith', '"{c}" like :{p}', '{c} ends with "{v}"', format='%{}'),
- Filter('startswith', '"{c}" like :{p}', '{c} starts with "{v}"', format='{}%'),
- Filter('gt', '"{c}" > :{p}', '{c} > {v}', numeric=True),
- Filter('gte', '"{c}" >= :{p}', '{c} \u2265 {v}', numeric=True),
- Filter('lt', '"{c}" < :{p}', '{c} < {v}', numeric=True),
- Filter('lte', '"{c}" <= :{p}', '{c} \u2264 {v}', numeric=True),
- Filter('glob', '"{c}" glob :{p}', '{c} glob "{v}"'),
- Filter('like', '"{c}" like :{p}', '{c} like "{v}"'),
- Filter('isnull', '"{c}" is null', '{c} is null', no_argument=True),
- Filter('notnull', '"{c}" is not null', '{c} is not null', no_argument=True),
- Filter('isblank', '("{c}" is null or "{c}" = "")', '{c} is blank', no_argument=True),
- Filter('notblank', '("{c}" is not null and "{c}" != "")', '{c} is not blank', no_argument=True),
+ Filter('exact', '=', '"{c}" = :{p}', lambda c, v: '{c} = {v}' if v.isdigit() else '{c} = "{v}"'),
+ Filter('contains', 'contains', '"{c}" like :{p}', '{c} contains "{v}"', format='%{}%'),
+ Filter('endswith', 'ends with', '"{c}" like :{p}', '{c} ends with "{v}"', format='%{}'),
+ Filter('startswith', 'starts with', '"{c}" like :{p}', '{c} starts with "{v}"', format='{}%'),
+ Filter('gt', '>', '"{c}" > :{p}', '{c} > {v}', numeric=True),
+ Filter('gte', '\u2265', '"{c}" >= :{p}', '{c} \u2265 {v}', numeric=True),
+ Filter('lt', '<', '"{c}" < :{p}', '{c} < {v}', numeric=True),
+ Filter('lte', '\u2264', '"{c}" <= :{p}', '{c} \u2264 {v}', numeric=True),
+ Filter('glob', 'glob', '"{c}" glob :{p}', '{c} glob "{v}"'),
+ Filter('like', 'like', '"{c}" like :{p}', '{c} like "{v}"'),
+ Filter('isnull', 'is null', '"{c}" is null', '{c} is null', no_argument=True),
+ Filter('notnull', 'is not null', '"{c}" is not null', '{c} is not null', no_argument=True),
+ Filter('isblank', 'is blank', '("{c}" is null or "{c}" = "")', '{c} is blank', no_argument=True),
+ Filter('notblank', 'is not blank', '("{c}" is not null and "{c}" != "")', '{c} is not blank', no_argument=True),
]
_filters_by_key = {
f.key: f for f in _filters
}
def __init__(self, pairs):
+ print('pairs = ', pairs)
self.pairs = pairs
+ def lookups(self):
+ "Yields (lookup, display, no_argument) pairs"
+ for filter in self._filters:
+ yield filter.key, filter.display, filter.no_argument
+
def human_description(self):
bits = []
- for key, value in self.pairs:
- if '__' in key:
- column, lookup = key.rsplit('__', 1)
- else:
- column = key
- lookup = 'exact'
+ for column, lookup, value in self.selections():
filter = self._filters_by_key.get(lookup, None)
if filter:
bits.append(filter.human_clause(column, value))
@@ -369,15 +371,23 @@ class Filters:
and_bits.append(tail[0])
return ' and '.join(and_bits)
- def build_where_clauses(self):
- sql_bits = []
- params = {}
- for i, (key, value) in enumerate(self.pairs):
+ def selections(self):
+ "Yields (column, lookup, value) tuples"
+ for key, value in self.pairs:
if '__' in key:
column, lookup = key.rsplit('__', 1)
else:
column = key
lookup = 'exact'
+ yield column, lookup, value
+
+ def has_selections(self):
+ return bool(self.pairs)
+
+ def build_where_clauses(self):
+ sql_bits = []
+ params = {}
+ for i, (column, lookup, value) in enumerate(self.selections()):
filter = self._filters_by_key.get(lookup, None)
if filter:
sql_bit, param = filter.where_clause(column, value, i)
@@ -386,7 +396,6 @@ class Filters:
param_id = 'p{}'.format(i)
params[param_id] = param
return sql_bits, params
- return ' and '.join(sql_bits), params
filter_column_re = re.compile(r'^_filter_column_\d+$')
diff --git a/tests/test_app.py b/tests/test_app.py
index d9ebe5dd..70556252 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -80,7 +80,7 @@ def test_database_page(app_client):
}, {
'columns': ['pk', 'content'],
'name': 'simple_primary_key',
- 'count': 2,
+ 'count': 3,
'hidden': False,
'foreign_keys': {'incoming': [], 'outgoing': []},
'label_column': None,
@@ -106,7 +106,8 @@ def test_custom_sql(app_client):
} == data['query']
assert [
{'content': 'hello'},
- {'content': 'world'}
+ {'content': 'world'},
+ {'content': ''}
] == data['rows']
assert ['content'] == data['columns']
assert 'test_tables' == data['database']
@@ -166,6 +167,9 @@ def test_table_page(app_client):
}, {
'pk': '2',
'content': 'world',
+ }, {
+ 'pk': '3',
+ 'content': '',
}]
@@ -202,6 +206,23 @@ def test_paginate_tables_and_views(app_client, path, expected_rows, expected_pag
assert expected_pages == count
+@pytest.mark.parametrize('path,expected_rows', [
+ ('/test_tables/simple_primary_key.json?content=hello', [
+ ['1', 'hello'],
+ ]),
+ ('/test_tables/simple_primary_key.json?content__contains=o', [
+ ['1', 'hello'],
+ ['2', 'world'],
+ ]),
+ ('/test_tables/simple_primary_key.json?content__exact=', [
+ ['3', ''],
+ ]),
+])
+def test_table_filter_queries(app_client, path, expected_rows):
+ response = app_client.get(path, gather_request=False)
+ assert expected_rows == response.json['rows']
+
+
def test_max_returned_rows(app_client):
response = app_client.get(
'/test_tables.jsono?sql=select+content+from+no_primary_key',
@@ -228,6 +249,9 @@ def test_view(app_client):
}, {
'upper_content': 'WORLD',
'content': 'world',
+ }, {
+ 'upper_content': '',
+ 'content': '',
}]
@@ -343,6 +367,7 @@ CREATE TABLE "table/with/slashes.csv" (
INSERT INTO simple_primary_key VALUES (1, 'hello');
INSERT INTO simple_primary_key VALUES (2, 'world');
+INSERT INTO simple_primary_key VALUES (3, '');
INSERT INTO [table/with/slashes.csv] VALUES (3, 'hey');