diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index 233a9aa0..92343506 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -33,3 +33,8 @@ def publish_subcommand(publish): @hookspec(firstresult=True) def render_cell(value): "Customize rendering of HTML table cell values" + + +@hookspec +def table_filter(): + "Custom filtering of the current table based on the request" diff --git a/datasette/templates/table.html b/datasette/templates/table.html index 27cc1467..a768a9fc 100644 --- a/datasette/templates/table.html +++ b/datasette/templates/table.html @@ -88,7 +88,7 @@ -{% if query.sql %} +{% if query.sql and config.allow_sql %}

View and edit SQL

{% endif %} diff --git a/datasette/utils.py b/datasette/utils.py index 29360b35..1f2ed910 100644 --- a/datasette/utils.py +++ b/datasette/utils.py @@ -1,5 +1,5 @@ from contextlib import contextmanager -from collections import OrderedDict +from collections import OrderedDict, namedtuple import base64 import click import hashlib @@ -39,6 +39,10 @@ RUN apt-get update && \ ENV SQLITE_EXTENSIONS /usr/lib/x86_64-linux-gnu/mod_spatialite.so ''' +TableFilter = namedtuple("TableFilter", ( + "human_description_extras", "where_clauses", "params") +) + class InterruptedError(Exception): pass diff --git a/datasette/views/table.py b/datasette/views/table.py index ae71d33d..05e33f89 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -309,22 +309,26 @@ class TableView(RowTableShared): where_clauses, params = filters.build_where_clauses() # _search support: - fts_table = info[name]["tables"].get(table, {}).get("fts_table") + fts_table = table_metadata.get( + "fts_table", info[name]["tables"].get(table, {}).get("fts_table") + ) + fts_pk = table_metadata.get("fts_pk", "rowid") search_args = dict( pair for pair in special_args.items() if pair[0].startswith("_search") ) - search_descriptions = [] + human_description_extras = [] search = "" if fts_table and search_args: if "_search" in search_args: # Simple ?_search=xxx search = search_args["_search"] where_clauses.append( - "rowid in (select rowid from {fts_table} where {fts_table} match :search)".format( + "{fts_pk} in (select rowid from {fts_table} where {fts_table} match :search)".format( fts_table=escape_sqlite(fts_table), + fts_pk=escape_sqlite(fts_pk) ) ) - search_descriptions.append('search matches "{}"'.format(search)) + human_description_extras.append('search matches "{}"'.format(search)) params["search"] = search else: # More complex: search against specific columns @@ -341,18 +345,32 @@ class TableView(RowTableShared): i=i ) ) - search_descriptions.append( + human_description_extras.append( 'search column "{}" matches "{}"'.format( search_col, search_text ) ) params["search_{}".format(i)] = search_text + # filter_arguments plugin hook support + for awaitable_fn in pm.hook.table_filter(): + if awaitable_fn is None: + continue + extras = await awaitable_fn( + view=self, name=name, table=table, request=request + ) + if extras is None: + continue + human_description_extras.extend(extras.human_description_extras) + where_clauses.extend(extras.where_clauses) + params.update(extras.params) + table_rows_count = None sortable_columns = set() if not is_view: table_rows_count = table_info["count"] - sortable_columns = self.sortable_columns_for_table(name, table, use_rowid) + + sortable_columns = self.sortable_columns_for_table(name, table, use_rowid) # Allow for custom sort order sort = special_args.get("_sort") @@ -713,7 +731,7 @@ class TableView(RowTableShared): pass # human_description_en combines filters AND search, if provided - human_description_en = filters.human_description_en(extra=search_descriptions) + human_description_en = filters.human_description_en(extra=human_description_extras) if sort or sort_desc: sorted_by = "sorted by {}{}".format( diff --git a/docs/full_text_search.rst b/docs/full_text_search.rst index 72eabc7d..70900341 100644 --- a/docs/full_text_search.rst +++ b/docs/full_text_search.rst @@ -69,6 +69,30 @@ And then populate it like this: You can use this technique to populate the full-text search index from any combination of tables and joins that makes sense for your project. +Configuring full-text search for a table or view +------------------------------------------------ + +If a table has a corresponding FTS table set up using the ``content=`` argument to ``CREATE VIRTUAL TABLE`` shown above, Datasette will detect it automatically and add a search interface to the table page for that table. + +You can also manually configure which table should be used for full-text search using :ref:`metadata`. You can set the associated FTS table for a specific table and you can also set one for a view - if you do that, the page for that SQL view will offer a search option. + +The ``fts_table`` property can be used to specify an associated FTS table. If the primary key column in your table which was used to populate the FTS table is something other than ``rowid``, you can specify the column to use with the ``fts_pk`` property. + +Here is an example which enables full-text search for a ``display_ads`` view which is defined against the ``ads`` table and hence needs to run FTS against the ``ads_fts`` table, using the ``id`` as the primary key:: + + { + "databases": { + "russian-ads": { + "tables": { + "display_ads": { + "fts_table": "ads_fts", + "fts_pk": "id" + } + } + } + } + } + Setting up full-text search using csvs-to-sqlite ------------------------------------------------ diff --git a/docs/metadata.rst b/docs/metadata.rst index 7ae561af..a0b1e88f 100644 --- a/docs/metadata.rst +++ b/docs/metadata.rst @@ -121,6 +121,23 @@ This will restrict sorting of ``example_table`` to just the ``height`` and You can also disable sorting entirely by setting ``"sortable_columns": []`` +By default, database views in Datasette do not support sorting. You can use ``sortable_columns`` to enable specific sort orders for a view called ``name_of_view`` in the database ``my_database`` like so:: + +{ + "databases": { + "my_database": { + "tables": { + "name_of_view": { + "sortable_columns": [ + "clicks", + "impressions" + ] + } + } + } + } +} + .. _label_columns: Specifying the label column for a table diff --git a/docs/plugins.rst b/docs/plugins.rst index f69fed95..c7b3ee24 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -320,3 +320,28 @@ If the value matches that pattern, the plugin returns an HTML link element: href=jinja2.escape(data["href"]), label=jinja2.escape(data["label"] or "") or " " )) + +table_filter +~~~~~~~~~~~~ + +Apply additional SQL filters to the current table based on the request. + +This should return an awaitable function with the following signature: + +.. code-block:: python + + from datasette.utils import TableFilter + + @hookimpl + def table_filter(): + async def inner(view, name, table, request): + extra_human_descriptions = [] + where_clauses = [] + params = {} + # ... build those things here + return TableFilter( + human_description_extras=extra_human_descriptions, + where_clauses=where_clauses, + params=params, + ) + return inner diff --git a/tests/fixtures.py b/tests/fixtures.py index ffacfa51..8f4b50dd 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -158,6 +158,9 @@ METADATA = { 'primary_key_multiple_columns_explicit_label': { 'label_column': 'content2', }, + 'simple_view': { + 'sortable_columns': ['content'], + } }, 'queries': { 'pragma_cache_size': 'PRAGMA cache_size;', diff --git a/tests/test_html.py b/tests/test_html.py index 5f59f61a..4a792b62 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -40,11 +40,6 @@ def test_sql_time_limit(app_client_shorter_time_limit): assert expected_html_fragment in response.text -def test_view(app_client): - response = app_client.get('/fixtures/simple_view') - assert response.status == 200 - - def test_row(app_client): response = app_client.get( '/fixtures/simple_primary_key/1', @@ -618,25 +613,33 @@ def test_compound_primary_key_with_foreign_key_references(app_client): def test_view_html(app_client): - response = app_client.get('/fixtures/simple_view') + response = app_client.get("/fixtures/simple_view") assert response.status == 200 - table = Soup(response.body, 'html.parser').find('table') - assert [ - 'content', 'upper_content' - ] == [th.string.strip() for th in table.select('thead th')] + table = Soup(response.body, "html.parser").find("table") + ths = table.select("thead th") + assert 2 == len(ths) + assert ths[0].find("a") is not None + assert ths[0].find("a")["href"].endswith("/simple_view?_sort=content") + assert ths[0].find("a").string.strip() == "content" + assert ths[1].find("a") is None + assert ths[1].string.strip() == "upper_content" expected = [ [ 'hello', - 'HELLO' - ], [ + 'HELLO', + ], + [ 'world', - 'WORLD' - ], [ + 'WORLD', + ], + [ '\xa0', - '\xa0' - ] + '\xa0', + ], + ] + assert expected == [ + [str(td) for td in tr.select("td")] for tr in table.select("tbody tr") ] - assert expected == [[str(td) for td in tr.select('td')] for tr in table.select('tbody tr')] def test_index_metadata(app_client): @@ -710,6 +713,10 @@ def test_allow_sql_on(app_client): ) soup = Soup(response.body, 'html.parser') assert len(soup.findAll('textarea', {'name': 'sql'})) + response = app_client.get( + "/fixtures/sortable" + ) + assert b"View and edit SQL" in response.body def test_allow_sql_off(): @@ -721,6 +728,11 @@ def test_allow_sql_off(): ) soup = Soup(response.body, 'html.parser') assert not len(soup.findAll('textarea', {'name': 'sql'})) + # The table page should no longer show "View and edit SQL" + response = client.get( + "/fixtures/sortable" + ) + assert b"View and edit SQL" not in response.body def assert_querystring_equal(expected, actual):