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 %} +
+ {% for column, lookup, value in filters.selections() %} +

+ + + +

+ {% endfor %} +

+ + + + +

+
+
{{ 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');