kopia lustrzana https://github.com/simonw/datasette
Merge "Support filtering with units" from #205
commit
c857608738
|
@ -85,6 +85,12 @@ class BaseView(RenderMixin):
|
||||||
self.page_size = datasette.page_size
|
self.page_size = datasette.page_size
|
||||||
self.max_returned_rows = datasette.max_returned_rows
|
self.max_returned_rows = datasette.max_returned_rows
|
||||||
|
|
||||||
|
def table_metadata(self, database, table):
|
||||||
|
"Fetch table-specific metadata."
|
||||||
|
return self.ds.metadata.get(
|
||||||
|
'databases', {}
|
||||||
|
).get(database, {}).get('tables', {}).get(table, {})
|
||||||
|
|
||||||
def options(self, request, *args, **kwargs):
|
def options(self, request, *args, **kwargs):
|
||||||
r = response.text('ok')
|
r = response.text('ok')
|
||||||
if self.ds.cors:
|
if self.ds.cors:
|
||||||
|
@ -450,11 +456,6 @@ class DatabaseDownload(BaseView):
|
||||||
|
|
||||||
|
|
||||||
class RowTableShared(BaseView):
|
class RowTableShared(BaseView):
|
||||||
def table_metadata(self, database, table):
|
|
||||||
return self.ds.metadata.get(
|
|
||||||
'databases', {}
|
|
||||||
).get(database, {}).get('tables', {}).get(table, {})
|
|
||||||
|
|
||||||
def sortable_columns_for_table(self, name, table, use_rowid):
|
def sortable_columns_for_table(self, name, table, use_rowid):
|
||||||
table_metadata = self.table_metadata(name, table)
|
table_metadata = self.table_metadata(name, table)
|
||||||
if 'sortable_columns' in table_metadata:
|
if 'sortable_columns' in table_metadata:
|
||||||
|
@ -657,7 +658,9 @@ class TableView(RowTableShared):
|
||||||
forward_querystring=False
|
forward_querystring=False
|
||||||
)
|
)
|
||||||
|
|
||||||
filters = Filters(sorted(other_args.items()))
|
units = self.table_metadata(name, table).get('units', {})
|
||||||
|
|
||||||
|
filters = Filters(sorted(other_args.items()), units, ureg)
|
||||||
where_clauses, params = filters.build_where_clauses()
|
where_clauses, params = filters.build_where_clauses()
|
||||||
|
|
||||||
# _search support:
|
# _search support:
|
||||||
|
@ -901,6 +904,7 @@ class TableView(RowTableShared):
|
||||||
'filtered_table_rows_count': filtered_table_rows_count,
|
'filtered_table_rows_count': filtered_table_rows_count,
|
||||||
'columns': columns,
|
'columns': columns,
|
||||||
'primary_keys': pks,
|
'primary_keys': pks,
|
||||||
|
'units': units,
|
||||||
'query': {
|
'query': {
|
||||||
'sql': sql,
|
'sql': sql,
|
||||||
'params': params,
|
'params': params,
|
||||||
|
@ -970,6 +974,7 @@ class RowView(RowTableShared):
|
||||||
'columns': columns,
|
'columns': columns,
|
||||||
'primary_keys': pks,
|
'primary_keys': pks,
|
||||||
'primary_key_values': pk_values,
|
'primary_key_values': pk_values,
|
||||||
|
'units': self.table_metadata(name, table).get('units', {})
|
||||||
}
|
}
|
||||||
|
|
||||||
if 'foreign_key_tables' in (request.raw_args.get('_extras') or '').split(','):
|
if 'foreign_key_tables' in (request.raw_args.get('_extras') or '').split(','):
|
||||||
|
@ -1195,6 +1200,11 @@ class Datasette:
|
||||||
}
|
}
|
||||||
return self._inspect
|
return self._inspect
|
||||||
|
|
||||||
|
def register_custom_units(self):
|
||||||
|
"Register any custom units defined in the metadata.json with Pint"
|
||||||
|
for unit in self.metadata.get('custom_units', []):
|
||||||
|
ureg.define(unit)
|
||||||
|
|
||||||
def app(self):
|
def app(self):
|
||||||
app = Sanic(__name__)
|
app = Sanic(__name__)
|
||||||
default_templates = str(app_root / 'datasette' / 'templates')
|
default_templates = str(app_root / 'datasette' / 'templates')
|
||||||
|
@ -1239,6 +1249,8 @@ class Datasette:
|
||||||
'/<db_name:[^/]+>/<table:[^/]+?>/<pk_path:[^/]+?><as_json:(\.jsono?)?$>'
|
'/<db_name:[^/]+>/<table:[^/]+?>/<pk_path:[^/]+?><as_json:(\.jsono?)?$>'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.register_custom_units()
|
||||||
|
|
||||||
@app.exception(Exception)
|
@app.exception(Exception)
|
||||||
def on_exception(request, exception):
|
def on_exception(request, exception):
|
||||||
title = None
|
title = None
|
||||||
|
|
|
@ -160,6 +160,7 @@ def skeleton(files, metadata, sqlite_extensions):
|
||||||
'license_url': None,
|
'license_url': None,
|
||||||
'source': None,
|
'source': None,
|
||||||
'source_url': None,
|
'source_url': None,
|
||||||
|
'units': {}
|
||||||
} for table_name in (info.get('tables') or {})
|
} for table_name in (info.get('tables') or {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ import tempfile
|
||||||
import time
|
import time
|
||||||
import shutil
|
import shutil
|
||||||
import urllib
|
import urllib
|
||||||
|
import numbers
|
||||||
|
|
||||||
|
|
||||||
# From https://www.sqlite.org/lang_keywords.html
|
# From https://www.sqlite.org/lang_keywords.html
|
||||||
|
@ -459,8 +460,10 @@ class Filters:
|
||||||
f.key: f for f in _filters
|
f.key: f for f in _filters
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, pairs):
|
def __init__(self, pairs, units={}, ureg=None):
|
||||||
self.pairs = pairs
|
self.pairs = pairs
|
||||||
|
self.units = units
|
||||||
|
self.ureg = ureg
|
||||||
|
|
||||||
def lookups(self):
|
def lookups(self):
|
||||||
"Yields (lookup, display, no_argument) pairs"
|
"Yields (lookup, display, no_argument) pairs"
|
||||||
|
@ -500,13 +503,27 @@ class Filters:
|
||||||
def has_selections(self):
|
def has_selections(self):
|
||||||
return bool(self.pairs)
|
return bool(self.pairs)
|
||||||
|
|
||||||
|
def convert_unit(self, column, value):
|
||||||
|
"If the user has provided a unit in the quey, convert it into the column unit, if present."
|
||||||
|
if column not in self.units:
|
||||||
|
return value
|
||||||
|
|
||||||
|
# Try to interpret the value as a unit
|
||||||
|
value = self.ureg(value)
|
||||||
|
if isinstance(value, numbers.Number):
|
||||||
|
# It's just a bare number, assume it's the column unit
|
||||||
|
return value
|
||||||
|
|
||||||
|
column_unit = self.ureg(self.units[column])
|
||||||
|
return value.to(column_unit).magnitude
|
||||||
|
|
||||||
def build_where_clauses(self):
|
def build_where_clauses(self):
|
||||||
sql_bits = []
|
sql_bits = []
|
||||||
params = {}
|
params = {}
|
||||||
for i, (column, lookup, value) in enumerate(self.selections()):
|
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, self.convert_unit(column, value), i)
|
||||||
sql_bits.append(sql_bit)
|
sql_bits.append(sql_bit)
|
||||||
if param is not None:
|
if param is not None:
|
||||||
param_id = 'p{}'.format(i)
|
param_id = 'p{}'.format(i)
|
||||||
|
|
|
@ -55,6 +55,43 @@ You can also provide metadata at the per-database or per-table level, like this:
|
||||||
|
|
||||||
Each of the top-level metadata fields can be used at the database and table level.
|
Each of the top-level metadata fields can be used at the database and table level.
|
||||||
|
|
||||||
|
Specifying units for a column
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Datasette supports attaching units to a column, which will be used when displaying
|
||||||
|
values from that column. SI prefixes will be used where appropriate.
|
||||||
|
|
||||||
|
Column units are configured in the metadata like so::
|
||||||
|
|
||||||
|
{
|
||||||
|
"databases": {
|
||||||
|
"database1": {
|
||||||
|
"tables": {
|
||||||
|
"example_table": {
|
||||||
|
"units": {
|
||||||
|
"column1": "metres",
|
||||||
|
"column2": "Hz"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Units are interpreted using Pint_, and you can see the full list of available units in
|
||||||
|
Pint's `unit registry`_. You can also add `custom units`_ to the metadata, which will be
|
||||||
|
registered with Pint::
|
||||||
|
|
||||||
|
{
|
||||||
|
"custom_units": [
|
||||||
|
"decibel = [] = dB"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
.. _Pint: https://pint.readthedocs.io/
|
||||||
|
.. _unit registry: https://github.com/hgrecco/pint/blob/master/pint/default_en.txt
|
||||||
|
.. _custom units: http://pint.readthedocs.io/en/latest/defining.html
|
||||||
|
|
||||||
Setting which columns can be used for sorting
|
Setting which columns can be used for sorting
|
||||||
---------------------------------------------
|
---------------------------------------------
|
||||||
|
|
||||||
|
@ -119,7 +156,8 @@ This will create a ``metadata.json`` file looking something like this::
|
||||||
"license": null,
|
"license": null,
|
||||||
"license_url": null,
|
"license_url": null,
|
||||||
"source": null,
|
"source": null,
|
||||||
"source_url": null
|
"source_url": null,
|
||||||
|
"units": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -79,6 +79,12 @@ METADATA = {
|
||||||
'no_primary_key': {
|
'no_primary_key': {
|
||||||
'sortable_columns': [],
|
'sortable_columns': [],
|
||||||
},
|
},
|
||||||
|
'units': {
|
||||||
|
'units': {
|
||||||
|
'distance': 'm',
|
||||||
|
'frequency': 'Hz'
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -169,6 +175,16 @@ CREATE TABLE "complex_foreign_keys" (
|
||||||
FOREIGN KEY ("f3") REFERENCES [simple_primary_key](id)
|
FOREIGN KEY ("f3") REFERENCES [simple_primary_key](id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE units (
|
||||||
|
pk integer primary key,
|
||||||
|
distance int,
|
||||||
|
frequency int
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO units VALUES (1, 1, 100);
|
||||||
|
INSERT INTO units VALUES (2, 5000, 2500);
|
||||||
|
INSERT INTO units VALUES (3, 100000, 75000);
|
||||||
|
|
||||||
CREATE TABLE [select] (
|
CREATE TABLE [select] (
|
||||||
[group] text,
|
[group] text,
|
||||||
[having] text,
|
[having] text,
|
||||||
|
@ -207,7 +223,6 @@ CREATE VIEW simple_view AS
|
||||||
).replace('None', 'null') for row in generate_sortable_rows(201)
|
).replace('None', 'null') for row in generate_sortable_rows(201)
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
filename = sys.argv[-1]
|
filename = sys.argv[-1]
|
||||||
if filename.endswith('.db'):
|
if filename.endswith('.db'):
|
||||||
|
|
|
@ -14,7 +14,7 @@ def test_homepage(app_client):
|
||||||
assert response.json.keys() == {'test_tables': 0}.keys()
|
assert response.json.keys() == {'test_tables': 0}.keys()
|
||||||
d = response.json['test_tables']
|
d = response.json['test_tables']
|
||||||
assert d['name'] == 'test_tables'
|
assert d['name'] == 'test_tables'
|
||||||
assert d['tables_count'] == 12
|
assert d['tables_count'] == 13
|
||||||
|
|
||||||
|
|
||||||
def test_database_page(app_client):
|
def test_database_page(app_client):
|
||||||
|
@ -172,6 +172,14 @@ def test_database_page(app_client):
|
||||||
'foreign_keys': {'incoming': [], 'outgoing': []},
|
'foreign_keys': {'incoming': [], 'outgoing': []},
|
||||||
'label_column': None,
|
'label_column': None,
|
||||||
'primary_keys': ['pk'],
|
'primary_keys': ['pk'],
|
||||||
|
}, {
|
||||||
|
'columns': ['pk', 'distance', 'frequency'],
|
||||||
|
'name': 'units',
|
||||||
|
'count': 3,
|
||||||
|
'hidden': False,
|
||||||
|
'foreign_keys': {'incoming': [], 'outgoing': []},
|
||||||
|
'label_column': None,
|
||||||
|
'primary_keys': ['pk'],
|
||||||
}] == data['tables']
|
}] == data['tables']
|
||||||
|
|
||||||
|
|
||||||
|
@ -577,3 +585,16 @@ def test_row_foreign_key_tables(app_client):
|
||||||
'other_column': 'f1',
|
'other_column': 'f1',
|
||||||
'other_table': 'complex_foreign_keys'
|
'other_table': 'complex_foreign_keys'
|
||||||
}] == response.json['foreign_key_tables']
|
}] == response.json['foreign_key_tables']
|
||||||
|
|
||||||
|
|
||||||
|
def test_unit_filters(app_client):
|
||||||
|
response = app_client.get('/test_tables/units.json?distance__lt=75km&frequency__gt=1kHz',
|
||||||
|
gather_request=False)
|
||||||
|
assert response.status == 200
|
||||||
|
data = response.json
|
||||||
|
|
||||||
|
assert data['units']['distance'] == 'm'
|
||||||
|
assert data['units']['frequency'] == 'Hz'
|
||||||
|
|
||||||
|
assert len(data['rows']) == 1
|
||||||
|
assert data['rows'][0][0] == 2
|
||||||
|
|
Ładowanie…
Reference in New Issue