diff --git a/datasette/templates/table.html b/datasette/templates/table.html index c9ec4aa1..12887f63 100644 --- a/datasette/templates/table.html +++ b/datasette/templates/table.html @@ -84,6 +84,9 @@ {% for facet in sorted_facet_results %} {% endfor %} + {% for key, value in form_hidden_args %} + + {% endfor %} diff --git a/datasette/views/table.py b/datasette/views/table.py index 2727565b..b7c9a4b0 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -296,10 +296,12 @@ class TableView(RowTableShared): where_clauses, params = filters.build_where_clauses(table) # _search support: - fts_table = await self.ds.execute_against_connection_in_thread( + fts_table = special_args.get("_fts_table") + fts_table = fts_table or table_metadata.get("fts_table") + fts_table = fts_table or await self.ds.execute_against_connection_in_thread( database, lambda conn: detect_fts(conn, table) ) - fts_pk = table_metadata.get("fts_pk", "rowid") + fts_pk = special_args.get("_fts_pk", table_metadata.get("fts_pk", "rowid")) search_args = dict( pair for pair in special_args.items() if pair[0].startswith("_search") ) @@ -731,6 +733,10 @@ class TableView(RowTableShared): table, {} ) self.ds.update_with_inherited_metadata(metadata) + form_hidden_args = [] + for arg in ("_fts_table", "_fts_pk"): + if arg in special_args: + form_hidden_args.append((arg, special_args[arg])) return { "supports_search": bool(fts_table), "search": search or "", @@ -745,6 +751,7 @@ class TableView(RowTableShared): key=lambda f: (len(f["results"]), f["name"]), reverse=True ), + "form_hidden_args": form_hidden_args, "facet_hideable": lambda facet: facet not in metadata_facets, "is_sortable": any(c["sortable"] for c in display_columns), "path_with_replaced_args": path_with_replaced_args, diff --git a/docs/full_text_search.rst b/docs/full_text_search.rst index 987e2272..08e85c90 100644 --- a/docs/full_text_search.rst +++ b/docs/full_text_search.rst @@ -78,9 +78,13 @@ 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. +You can also manually configure which table should be used for full-text search using querystring parameters or :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. +Use ``?_fts_table=x`` to over-ride the FTS table for a specific page. If the primary key was something other than ``rowid`` you can use ``?_fts_pk=col`` to set that as well. This is particularly useful for views, for example: + +https://latest.datasette.io/fixtures/searchable_view?_fts_table=searchable_fts&_fts_pk=pk + +The ``fts_table`` metadata 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:: diff --git a/tests/fixtures.py b/tests/fixtures.py index b3b38c95..cb6f7a39 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -209,6 +209,10 @@ METADATA = { }, 'simple_view': { 'sortable_columns': ['content'], + }, + 'searchable_view_configured_by_metadata': { + 'fts_table': 'searchable_fts', + 'fts_pk': 'pk' } }, 'queries': { @@ -564,6 +568,12 @@ INSERT INTO [table/with/slashes.csv] VALUES (3, 'hey'); CREATE VIEW simple_view AS SELECT content, upper(content) AS upper_content FROM simple_primary_key; +CREATE VIEW searchable_view AS + SELECT * from searchable; + +CREATE VIEW searchable_view_configured_by_metadata AS + SELECT * from searchable; + ''' + '\n'.join([ 'INSERT INTO no_primary_key VALUES ({i}, "a{i}", "b{i}", "c{i}");'.format(i=i + 1) for i in range(201) diff --git a/tests/test_api.py b/tests/test_api.py index 188a60e8..b822d23f 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -847,6 +847,24 @@ def test_searchable(app_client, path, expected_rows): assert expected_rows == response.json['rows'] +@pytest.mark.parametrize('path,expected_rows', [ + ('/fixtures/searchable_view_configured_by_metadata.json?_search=weasel', [ + [2, 'terry dog', 'sara weasel', 'puma'], + ]), + # This should return all results because search is not configured: + ('/fixtures/searchable_view.json?_search=weasel', [ + [1, 'barry cat', 'terry dog', 'panther'], + [2, 'terry dog', 'sara weasel', 'puma'], + ]), + ('/fixtures/searchable_view.json?_search=weasel&_fts_table=searchable_fts&_fts_pk=pk', [ + [2, 'terry dog', 'sara weasel', 'puma'], + ]), +]) +def test_searchable_views(app_client, path, expected_rows): + response = app_client.get(path) + assert expected_rows == response.json['rows'] + + def test_searchable_invalid_column(app_client): response = app_client.get( '/fixtures/searchable.json?_search_invalid=x' diff --git a/tests/test_html.py b/tests/test_html.py index 1babaa60..3e2ea845 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -185,6 +185,20 @@ def test_empty_search_parameter_gets_removed(app_client): ) +def test_searchable_view_persists_fts_table(app_client): + # The search form should persist ?_fts_table as a hidden field + response = app_client.get( + "/fixtures/searchable_view?_fts_table=searchable_fts&_fts_pk=pk" + ) + inputs = Soup(response.body, "html.parser").find("form").findAll("input") + hiddens = [i for i in inputs if i["type"] == "hidden"] + assert [ + ('_fts_table', 'searchable_fts'), ('_fts_pk', 'pk') + ] == [ + (hidden['name'], hidden['value']) for hidden in hiddens + ] + + def test_sort_by_desc_redirects(app_client): path_base = '/fixtures/sortable' path = path_base + '?' + urllib.parse.urlencode({