Added UI for editing table filters

Refs #86
sanic-07
Simon Willison 2017-11-22 20:33:55 -08:00
rodzic 53534b6e9d
commit 0071b5d6f5
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: FBB38AFE227189DB
4 zmienionych plików z 110 dodań i 32 usunięć

Wyświetl plik

@ -2,6 +2,7 @@ from sanic import Sanic
from sanic import response from sanic import response
from sanic.exceptions import NotFound from sanic.exceptions import NotFound
from sanic.views import HTTPMethodView from sanic.views import HTTPMethodView
from sanic.request import RequestParameters
from sanic_jinja2 import SanicJinja2 from sanic_jinja2 import SanicJinja2
from jinja2 import FileSystemLoader from jinja2 import FileSystemLoader
import re import re
@ -412,13 +413,19 @@ class TableView(BaseView):
if is_view: if is_view:
order_by = '' 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 __ # Special args start with _ and do not contain a __
# That's so if there is a column that starts with _ # That's so if there is a column that starts with _
# it can still be queried using ?_col__exact=blah # it can still be queried using ?_col__exact=blah
special_args = {} special_args = {}
special_args_lists = {} special_args_lists = {}
other_args = {} other_args = {}
for key, value in request.args.items(): for key, value in args.items():
if key.startswith('_') and '__' not in key: if key.startswith('_') and '__' not in key:
special_args[key] = value[0] special_args[key] = value[0]
special_args_lists[key] = value special_args_lists[key] = value
@ -540,6 +547,7 @@ class TableView(BaseView):
'supports_search': bool(fts_table), 'supports_search': bool(fts_table),
'search': search or '', 'search': search or '',
'use_rowid': use_rowid, 'use_rowid': use_rowid,
'filters': filters,
'display_columns': display_columns, 'display_columns': display_columns,
'display_rows': await self.make_display_rows(name, hash, table, rows, display_columns, pks, is_view, use_rowid), 'display_rows': await self.make_display_rows(name, hash, table, rows, display_columns, pks, is_view, use_rowid),
} }

Wyświetl plik

@ -29,13 +29,49 @@
</form> </form>
{% endif %} {% endif %}
{% if query.params %} <form class="filters" action="/{{ database }}-{{ database_hash }}/{{ table|quote_plus }}" method="get">
{% for column, lookup, value in filters.selections() %}
<p>
<select name="_filter_column_{{ loop.index }}" style="font-size: 20px">
{% for c in display_columns %}
{% if c != 'rowid' %}
<option{% if c == column %} selected{% endif %}>{{ c }}</option>
{% endif %}
{% endfor %}
</select>
<select name="_filter_op_{{ loop.index }}" style="font-size: 20px">
{% for key, display, no_argument in filters.lookups() %}
<option value="{{ key }}{% if no_argument %}__1{% endif %}"{% if key == lookup %} selected{% endif %}>{{ display }}</option>
{% endfor %}
</select>
<input type="text" name="_filter_value_{{ loop.index }}" style="width: 200px" value="{{ value }}">
</p>
{% endfor %}
<p>
<select name="_filter_column" style="font-size: 20px">
{% for column in display_columns %}
{% if column != 'rowid' %}
<option>{{ column }}</option>
{% endif %}
{% endfor %}
</select>
<select name="_filter_op" style="font-size: 20px">
{% for key, display, no_argument in filters.lookups() %}
<option value="{{ key }}{% if no_argument %}__1{% endif %}"{% if key == lookup %} selected{% endif %}>{{ display }}</option>
{% endfor %}
</select>
<input type="text" name="_filter_value" style="width: 200px">
<input type="submit" value="{% if filters.has_selections() %}Apply filters{% else %}Add filter{% endif %}">
</p>
</form>
<pre>{{ query.sql }}</pre> <pre>{{ query.sql }}</pre>
<pre>params = {{ query.params|tojson(4) }}</pre> {% if query.params %}<pre>params = {{ query.params|tojson(4) }}</pre>{% endif %}
{% endif %}
<p>This data as <a href="{{ url_json }}">.json</a>, <a href="{{ url_jsono }}">.jsono</a></p> <p>This data as <a href="{{ url_json }}">.json</a>, <a href="{{ url_jsono }}">.jsono</a></p>
<p>Returned {% if truncated %}more than {% endif %}{{ "{:,}".format(display_rows|length) }} row{% if display_rows|length == 1 %}{% else %}s{% endif %}</p>
<table> <table>
<thead> <thead>
<tr> <tr>

Wyświetl plik

@ -290,8 +290,9 @@ def detect_fts_sql(table):
class Filter: 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.key = key
self.display = display
self.sql_template = sql_template self.sql_template = sql_template
self.human_template = human_template self.human_template = human_template
self.format = format self.format = format
@ -327,36 +328,37 @@ class Filter:
class Filters: class Filters:
_filters = [ _filters = [
Filter('exact', '"{c}" = :{p}', lambda c, v: '{c} = {v}' if v.isdigit() else '{c} = "{v}"'), 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('contains', 'contains', '"{c}" like :{p}', '{c} contains "{v}"', format='%{}%'),
Filter('endswith', '"{c}" like :{p}', '{c} ends with "{v}"', format='%{}'), Filter('endswith', 'ends with', '"{c}" like :{p}', '{c} ends with "{v}"', format='%{}'),
Filter('startswith', '"{c}" like :{p}', '{c} starts with "{v}"', format='{}%'), Filter('startswith', 'starts with', '"{c}" like :{p}', '{c} starts with "{v}"', format='{}%'),
Filter('gt', '"{c}" > :{p}', '{c} > {v}', numeric=True), Filter('gt', '>', '"{c}" > :{p}', '{c} > {v}', numeric=True),
Filter('gte', '"{c}" >= :{p}', '{c} \u2265 {v}', numeric=True), Filter('gte', '\u2265', '"{c}" >= :{p}', '{c} \u2265 {v}', numeric=True),
Filter('lt', '"{c}" < :{p}', '{c} < {v}', numeric=True), Filter('lt', '<', '"{c}" < :{p}', '{c} < {v}', numeric=True),
Filter('lte', '"{c}" <= :{p}', '{c} \u2264 {v}', numeric=True), Filter('lte', '\u2264', '"{c}" <= :{p}', '{c} \u2264 {v}', numeric=True),
Filter('glob', '"{c}" glob :{p}', '{c} glob "{v}"'), Filter('glob', 'glob', '"{c}" glob :{p}', '{c} glob "{v}"'),
Filter('like', '"{c}" like :{p}', '{c} like "{v}"'), Filter('like', 'like', '"{c}" like :{p}', '{c} like "{v}"'),
Filter('isnull', '"{c}" is null', '{c} is null', no_argument=True), Filter('isnull', 'is null', '"{c}" is null', '{c} is null', no_argument=True),
Filter('notnull', '"{c}" is not null', '{c} is not null', no_argument=True), Filter('notnull', 'is not null', '"{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('isblank', 'is blank', '("{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('notblank', 'is not blank', '("{c}" is not null and "{c}" != "")', '{c} is not blank', no_argument=True),
] ]
_filters_by_key = { _filters_by_key = {
f.key: f for f in _filters f.key: f for f in _filters
} }
def __init__(self, pairs): def __init__(self, pairs):
print('pairs = ', pairs)
self.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): def human_description(self):
bits = [] bits = []
for key, value in self.pairs: for column, lookup, value in self.selections():
if '__' in key:
column, lookup = key.rsplit('__', 1)
else:
column = key
lookup = 'exact'
filter = self._filters_by_key.get(lookup, None) filter = self._filters_by_key.get(lookup, None)
if filter: if filter:
bits.append(filter.human_clause(column, value)) bits.append(filter.human_clause(column, value))
@ -369,15 +371,23 @@ class Filters:
and_bits.append(tail[0]) and_bits.append(tail[0])
return ' and '.join(and_bits) return ' and '.join(and_bits)
def build_where_clauses(self): def selections(self):
sql_bits = [] "Yields (column, lookup, value) tuples"
params = {} for key, value in self.pairs:
for i, (key, value) in enumerate(self.pairs):
if '__' in key: if '__' in key:
column, lookup = key.rsplit('__', 1) column, lookup = key.rsplit('__', 1)
else: else:
column = key column = key
lookup = 'exact' 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) filter = self._filters_by_key.get(lookup, None)
if filter: if filter:
sql_bit, param = filter.where_clause(column, value, i) sql_bit, param = filter.where_clause(column, value, i)
@ -386,7 +396,6 @@ class Filters:
param_id = 'p{}'.format(i) param_id = 'p{}'.format(i)
params[param_id] = param params[param_id] = param
return sql_bits, params return sql_bits, params
return ' and '.join(sql_bits), params
filter_column_re = re.compile(r'^_filter_column_\d+$') filter_column_re = re.compile(r'^_filter_column_\d+$')

Wyświetl plik

@ -80,7 +80,7 @@ def test_database_page(app_client):
}, { }, {
'columns': ['pk', 'content'], 'columns': ['pk', 'content'],
'name': 'simple_primary_key', 'name': 'simple_primary_key',
'count': 2, 'count': 3,
'hidden': False, 'hidden': False,
'foreign_keys': {'incoming': [], 'outgoing': []}, 'foreign_keys': {'incoming': [], 'outgoing': []},
'label_column': None, 'label_column': None,
@ -106,7 +106,8 @@ def test_custom_sql(app_client):
} == data['query'] } == data['query']
assert [ assert [
{'content': 'hello'}, {'content': 'hello'},
{'content': 'world'} {'content': 'world'},
{'content': ''}
] == data['rows'] ] == data['rows']
assert ['content'] == data['columns'] assert ['content'] == data['columns']
assert 'test_tables' == data['database'] assert 'test_tables' == data['database']
@ -166,6 +167,9 @@ def test_table_page(app_client):
}, { }, {
'pk': '2', 'pk': '2',
'content': 'world', '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 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): def test_max_returned_rows(app_client):
response = app_client.get( response = app_client.get(
'/test_tables.jsono?sql=select+content+from+no_primary_key', '/test_tables.jsono?sql=select+content+from+no_primary_key',
@ -228,6 +249,9 @@ def test_view(app_client):
}, { }, {
'upper_content': 'WORLD', 'upper_content': 'WORLD',
'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 (1, 'hello');
INSERT INTO simple_primary_key VALUES (2, 'world'); INSERT INTO simple_primary_key VALUES (2, 'world');
INSERT INTO simple_primary_key VALUES (3, '');
INSERT INTO [table/with/slashes.csv] VALUES (3, 'hey'); INSERT INTO [table/with/slashes.csv] VALUES (3, 'hey');