From d1756d773685ca4f9c5b57fb40e1aa743bc95525 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 8 Apr 2018 21:58:25 -0700 Subject: [PATCH] New sortable_columns option in metadata.json to control sort options You can now explicitly set which columns in a table can be used for sorting using the _sort and _sort_desc arguments using metadata.json: { "databases": { "database1": { "tables": { "example_table": { "sortable_columns": [ "height", "weight" ] } } } } } Refs #189 --- datasette/app.py | 32 ++++++++++++++++++---- datasette/templates/_rows_and_columns.html | 12 ++++---- datasette/templates/table.html | 2 +- docs/metadata.rst | 27 ++++++++++++++++++ tests/fixtures.py | 14 ++++++++-- tests/test_api.py | 17 +++++++++++- tests/test_html.py | 12 +++----- 7 files changed, 93 insertions(+), 23 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 8713c61848..32dab8a506 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -456,10 +456,27 @@ async def view_get(self, request, name, hash, **kwargs): class RowTableShared(BaseView): + def sortable_columns_for_table(self, name, table, use_rowid): + table_metadata = self.ds.metadata.get( + 'databases', {} + ).get(name, {}).get('tables', {}).get(table, {}) + if 'sortable_columns' in table_metadata: + sortable_columns = set(table_metadata['sortable_columns']) + else: + table_info = self.ds.inspect()[name]['tables'].get(table) or {} + sortable_columns = set(table_info.get('columns', [])) + if use_rowid: + sortable_columns.add('rowid') + return sortable_columns + async def display_columns_and_rows(self, database, table, description, rows, link_column=False, expand_foreign_keys=True): "Returns columns, rows for specified table - including fancy foreign key treatment" info = self.ds.inspect()[database] - columns = [r[0] for r in description] + sortable_columns = self.sortable_columns_for_table(database, table, True) + columns = [{ + 'name': r[0], + 'sortable': r[0] in sortable_columns, + } for r in description] tables = info['tables'] table_info = tables.get(table) or {} pks = await self.pks_for_table(database, table) @@ -505,7 +522,8 @@ async def display_columns_and_rows(self, database, table, description, rows, lin ) ), }) - for value, column in zip(row, columns): + for value, column_dict in zip(row, columns): + column = column_dict['name'] if (column, value) in expanded: other_table, label = expanded[(column, value)] display_value = jinja2.Markup( @@ -533,7 +551,10 @@ async def display_columns_and_rows(self, database, table, description, rows, lin cell_rows.append(cells) if link_column: - columns = ['Link'] + columns + columns = [{ + 'name': 'Link', + 'sortable': False, + }] + columns return columns, cell_rows @@ -623,7 +644,7 @@ async def data(self, request, name, hash, table): if not is_view: table_info = info[name]['tables'][table] table_rows = table_info['count'] - sortable_columns = set(table_info['columns']) + sortable_columns = self.sortable_columns_for_table(name, table, use_rowid) # Allow for custom sort order sort = special_args.get('_sort') @@ -885,6 +906,8 @@ async def template_data(): display_columns, display_rows = await self.display_columns_and_rows( name, table, description, rows, link_column=False, expand_foreign_keys=True ) + for column in display_columns: + column['sortable'] = False return { 'database_hash': hash, 'foreign_key_tables': await self.foreign_key_tables(name, table, pk_values), @@ -895,7 +918,6 @@ async def template_data(): '_rows_and_columns-row-{}-{}.html'.format(to_css_class(name), to_css_class(table)), '_rows_and_columns.html', ], - 'disable_sort': True, 'enumerate': enumerate, 'metadata': self.ds.metadata.get( 'databases', {} diff --git a/datasette/templates/_rows_and_columns.html b/datasette/templates/_rows_and_columns.html index 4a2a727f40..7e11b2e8f6 100644 --- a/datasette/templates/_rows_and_columns.html +++ b/datasette/templates/_rows_and_columns.html @@ -1,15 +1,15 @@ - {% for i, column in enumerate(display_columns) %} + {% for column in display_columns %} diff --git a/datasette/templates/table.html b/datasette/templates/table.html index 91cf70f0e6..3238e83f31 100644 --- a/datasette/templates/table.html +++ b/datasette/templates/table.html @@ -8,7 +8,7 @@ diff --git a/docs/metadata.rst b/docs/metadata.rst index aa002ff753..3c842634db 100644 --- a/docs/metadata.rst +++ b/docs/metadata.rst @@ -55,6 +55,33 @@ 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. +Setting which columns can be used for sorting +--------------------------------------------- + +Datasette allows any column to be used for sorting by default. If you need to +control which columns are available for sorting you can do so using the optional +``sortable_columns`` key:: + + { + "databases": { + "database1": { + "tables": { + "example_table": { + "sortable_columns": [ + "height", + "weight" + ] + } + } + } + } + } + +This will restrict sorting of ``example_table`` to just the ``height`` and +``weight`` columns. + +You can also disable sorting entirely by setting ``"sortable_columns": []`` + Generating a metadata skeleton ------------------------------ diff --git a/tests/fixtures.py b/tests/fixtures.py index 4ede6a906b..b44b2e1d15 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -68,9 +68,19 @@ def generate_sortable_rows(num): 'simple_primary_key': { 'description_html': 'Simple primary key', 'title': 'This HTML is escaped', - } + }, + 'sortable': { + 'sortable_columns': [ + 'sortable', + 'sortable_with_nulls', + 'sortable_with_nulls_2', + ] + }, + 'no_primary_key': { + 'sortable_columns': [], + }, } - } + }, } } diff --git a/tests/test_api.py b/tests/test_api.py index ad9f97b4c1..da1672430e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -412,12 +412,27 @@ def test_sortable_argument_errors(app_client): ) assert 'Cannot sort table by badcolumn2' == response.json['error'] response = app_client.get( - '/test_tables/sortable.json?_sort=content&_sort_desc=pk2', + '/test_tables/sortable.json?_sort=sortable_with_nulls&_sort_desc=sortable', gather_request=False ) assert 'Cannot use _sort and _sort_desc at the same time' == response.json['error'] +def test_sortable_columns_metadata(app_client): + response = app_client.get( + '/test_tables/sortable.json?_sort=content', + gather_request=False + ) + assert 'Cannot sort table by content' == response.json['error'] + # no_primary_key has ALL sort options disabled + for column in ('content', 'a', 'b', 'c'): + response = app_client.get( + '/test_tables/sortable.json?_sort={}'.format(column), + gather_request=False + ) + assert 'Cannot sort table by {}'.format(column) == response.json['error'] + + @pytest.mark.parametrize('path,expected_rows', [ ('/test_tables/simple_primary_key.json?content=hello', [ ['1', 'hello'], diff --git a/tests/test_html.py b/tests/test_html.py index 691bf27e46..417fd2ce9e 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -200,14 +200,10 @@ def test_row_html_simple_primary_key(app_client): def test_table_html_no_primary_key(app_client): response = app_client.get('/test_tables/no_primary_key', gather_request=False) table = Soup(response.body, 'html.parser').find('table') - ths = table.findAll('th') - assert 'Link' == ths[0].string.strip() - for expected_col, th in zip(('rowid', 'content', 'a', 'b', 'c'), ths[1:]): - a = th.find('a') - assert expected_col == a.string - assert a['href'].endswith('/no_primary_key?_sort={}'.format( - expected_col - )) + # We have disabled sorting for this table using metadata.json + assert [ + 'content', 'a', 'b', 'c' + ] == [th.string.strip() for th in table.select('thead th')[2:]] expected = [ [ ''.format(i, i),
- {% if i == 0 or disable_sort %} - {{ column }} + {% if not column.sortable %} + {{ column.name }} {% else %} - {% if column == sort %} - {{ column }} ▼ + {% if column.name == sort %} + {{ column.name }} ▼ {% else %} - {{ column }}{% if column == sort_desc %} ▲{% endif %} + {{ column.name }}{% if column.name == sort_desc %} ▲{% endif %} {% endif %} {% endif %} {}