kopia lustrzana https://github.com/simonw/datasette
rodzic
53534b6e9d
commit
0071b5d6f5
|
@ -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),
|
||||
}
|
||||
|
|
|
@ -29,13 +29,49 @@
|
|||
</form>
|
||||
{% 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>params = {{ query.params|tojson(4) }}</pre>
|
||||
{% endif %}
|
||||
{% if query.params %}<pre>params = {{ query.params|tojson(4) }}</pre>{% endif %}
|
||||
|
||||
<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>
|
||||
<thead>
|
||||
<tr>
|
||||
|
|
|
@ -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+$')
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
Ładowanie…
Reference in New Issue