diff --git a/datasette/app.py b/datasette/app.py index c5e6c274..ef0e7d57 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -230,7 +230,7 @@ class BaseView(RenderMixin): return response_or_template_contexts else: data, extra_template_data, templates = response_or_template_contexts - except (sqlite3.OperationalError, InvalidSql, DatasetteError) as e: + except (sqlite3.OperationalError, InvalidSql) as e: raise DatasetteError(str(e), title='Invalid SQL', status=400) except (sqlite3.OperationalError) as e: raise DatasetteError(str(e)) @@ -740,16 +740,44 @@ class TableView(RowTableShared): # _search support: fts_table = info[name]['tables'].get(table, {}).get('fts_table') - search = special_args.get('_search') - search_description = None - if search and fts_table: - where_clauses.append( - 'rowid in (select rowid from [{fts_table}] where [{fts_table}] match :search)'.format( - fts_table=fts_table + search_args = dict( + pair for pair in special_args.items() + if pair[0].startswith('_search') + ) + search_descriptions = [] + 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_table=fts_table + ) ) - ) - search_description = 'search matches "{}"'.format(search) - params['search'] = search + search_descriptions.append('search matches "{}"'.format(search)) + params['search'] = search + else: + # More complex: search against specific columns + valid_columns = set(info[name]['tables'][fts_table]['columns']) + for i, (key, search_text) in enumerate(search_args.items()): + search_col = key.split('_search_', 1)[1] + if search_col not in valid_columns: + raise DatasetteError( + 'Cannot search by that column', + status=400 + ) + where_clauses.append( + 'rowid in (select rowid from [{fts_table}] where [{search_col}] match :search_{i})'.format( + fts_table=fts_table, + search_col=search_col, + i=i, + ) + ) + search_descriptions.append( + 'search column "{}" matches "{}"'.format(search_col, search_text) + ) + params['search_{}'.format(i)] = search_text table_rows_count = None sortable_columns = set() @@ -955,7 +983,7 @@ class TableView(RowTableShared): pass # human_description_en combines filters AND search, if provided - human_description_en = filters.human_description_en(extra=search_description) + human_description_en = filters.human_description_en(extra=search_descriptions) if sort or sort_desc: sorted_by = 'sorted by {}{}'.format( diff --git a/datasette/utils.py b/datasette/utils.py index d905a97a..727c3b55 100644 --- a/datasette/utils.py +++ b/datasette/utils.py @@ -522,7 +522,7 @@ class Filters: def human_description_en(self, extra=None): bits = [] if extra: - bits.append(extra) + bits.extend(extra) for column, lookup, value in self.selections(): filter = self._filters_by_key.get(lookup, None) if filter: diff --git a/docs/json_api.rst b/docs/json_api.rst index bff270f9..20e9f530 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -146,6 +146,10 @@ The Datasette table view takes a number of special querystring arguments: `full-text search `_ executes a search with the provided keywords. +``?_search_COLUMN=keywords`` + Like ``_search=`` but allows you to specify the column to be searched, as + opposed to searching all columns that have been indexed by FTS. + ``?_group_count=COLUMN`` Executes a SQL query that returns a count of the number of rows matching each unique value in that column, with the most common ordered first. diff --git a/tests/fixtures.py b/tests/fixtures.py index 402690b5..219d4239 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -248,16 +248,17 @@ INSERT INTO units VALUES (3, 100000, 75000); CREATE TABLE searchable ( pk integer primary key, text1 text, - text2 text + text2 text, + [name with . and spaces] text ); -INSERT INTO searchable VALUES (1, 'barry cat', 'john dog'); -INSERT INTO searchable VALUES (2, 'terry cat', 'john weasel'); +INSERT INTO searchable VALUES (1, 'barry cat', 'terry dog', 'panther'); +INSERT INTO searchable VALUES (2, 'terry dog', 'sara weasel', 'puma'); CREATE VIRTUAL TABLE "searchable_fts" - USING FTS3 (text1, text2, content="searchable"); -INSERT INTO "searchable_fts" (rowid, text1, text2) - SELECT rowid, text1, text2 FROM searchable; + USING FTS3 (text1, text2, [name with . and spaces], content="searchable"); +INSERT INTO "searchable_fts" (rowid, text1, text2, [name with . and spaces]) + SELECT rowid, text1, text2, [name with . and spaces] FROM searchable; CREATE TABLE [select] ( [group] text, diff --git a/tests/test_api.py b/tests/test_api.py index ed7ce274..e864d89a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -157,7 +157,7 @@ def test_database_page(app_client): 'fts_table': None, 'primary_keys': ['id'] }, { - 'columns': ['pk', 'text1', 'text2'], + 'columns': ['pk', 'text1', 'text2', 'name with . and spaces'], 'name': 'searchable', 'count': 2, 'foreign_keys': {'incoming': [], 'outgoing': []}, @@ -242,7 +242,7 @@ def test_database_page(app_client): 'fts_table': None, 'primary_keys': [], }, { - 'columns': ['text1', 'text2', 'content'], + 'columns': ['text1', 'text2', 'name with . and spaces', 'content'], 'count': 2, 'foreign_keys': {'incoming': [], 'outgoing': []}, 'fts_table': 'searchable_fts', @@ -251,7 +251,7 @@ def test_database_page(app_client): 'name': 'searchable_fts', 'primary_keys': [] }, { - 'columns': ['docid', 'c0text1', 'c1text2', 'c2content'], + 'columns': ['docid', 'c0text1', 'c1text2', 'c2name with . and spaces', 'c3content'], 'count': 2, 'foreign_keys': {'incoming': [], 'outgoing': []}, 'fts_table': None, @@ -681,12 +681,18 @@ def test_sortable_columns_metadata(app_client): @pytest.mark.parametrize('path,expected_rows', [ - ('/test_tables/searchable.json?_search=cat', [ - [1, 'barry cat', 'john dog'], - [2, 'terry cat', 'john weasel'], + ('/test_tables/searchable.json?_search=dog', [ + [1, 'barry cat', 'terry dog', 'panther'], + [2, 'terry dog', 'sara weasel', 'puma'], ]), ('/test_tables/searchable.json?_search=weasel', [ - [2, 'terry cat', 'john weasel'], + [2, 'terry dog', 'sara weasel', 'puma'], + ]), + ('/test_tables/searchable.json?_search_text2=dog', [ + [1, 'barry cat', 'terry dog', 'panther'], + ]), + ('/test_tables/searchable.json?_search_name%20with%20.%20and%20spaces=panther', [ + [1, 'barry cat', 'terry dog', 'panther'], ]), ]) def test_searchable(app_client, path, expected_rows): @@ -694,6 +700,20 @@ def test_searchable(app_client, path, expected_rows): assert expected_rows == response.json['rows'] +def test_searchable_invalid_column(app_client): + response = app_client.get( + '/test_tables/searchable.json?_search_invalid=x', + gather_request=False + ) + assert 400 == response.status + assert { + 'ok': False, + 'error': 'Cannot search by that column', + 'status': 400, + 'title': None + } == response.json + + @pytest.mark.parametrize('path,expected_rows', [ ('/test_tables/simple_primary_key.json?content=hello', [ ['1', 'hello'],