diff --git a/qutebrowser/completion/models/histcategory.py b/qutebrowser/completion/models/histcategory.py index fe89dc79b..57a2aa936 100644 --- a/qutebrowser/completion/models/histcategory.py +++ b/qutebrowser/completion/models/histcategory.py @@ -19,8 +19,6 @@ """A completion category that queries the SQL History store.""" -import re - from PyQt5.QtSql import QSqlQueryModel from qutebrowser.misc import sql @@ -36,21 +34,7 @@ class HistoryCategory(QSqlQueryModel): """Create a new History completion category.""" super().__init__(parent=parent) self.name = "History" - - # replace ' in timestamp-format to avoid breaking the query - timestamp_format = config.val.completion.timestamp_format - timefmt = ("strftime('{}', last_atime, 'unixepoch', 'localtime')" - .format(timestamp_format.replace("'", "`"))) - - self._query = sql.Query(' '.join([ - "SELECT url, title, {}".format(timefmt), - "FROM CompletionHistory", - # the incoming pattern will have literal % and _ escaped with '\' - # we need to tell sql to treat '\' as an escape character - "WHERE ((url || title) LIKE :pat escape '\\')", - self._atime_expr(), - "ORDER BY last_atime DESC", - ]), forward_only=False) + self._query = None # advertise that this model filters by URL and title self.columns_to_filter = [0, 1] @@ -86,11 +70,36 @@ class HistoryCategory(QSqlQueryModel): # escape to treat a user input % or _ as a literal, not a wildcard pattern = pattern.replace('%', '\\%') pattern = pattern.replace('_', '\\_') - # treat spaces as wildcards to match any of the typed words - pattern = re.sub(r' +', '%', pattern) - pattern = '%{}%'.format(pattern) + words = ['%{}%'.format(w) for w in pattern.split(' ')] + + # build a where clause to match all of the words in any order + # given the search term "a b", the WHERE clause would be: + # ((url || title) LIKE '%a%') AND ((url || title) LIKE '%b%') + where_clause = ' AND '.join( + "(url || title) LIKE :{} escape '\\'".format(i) + for i in range(len(words))) + + # replace ' in timestamp-format to avoid breaking the query + timestamp_format = config.val.completion.timestamp_format + timefmt = ("strftime('{}', last_atime, 'unixepoch', 'localtime')" + .format(timestamp_format.replace("'", "`"))) + + if not self._query or len(words) != len(self._query.boundValues()): + # if the number of words changed, we need to generate a new query + # otherwise, we can reuse the prepared query for performance + self._query = sql.Query(' '.join([ + "SELECT url, title, {}".format(timefmt), + "FROM CompletionHistory", + # the incoming pattern will have literal % and _ escaped + # we need to tell sql to treat '\' as an escape character + 'WHERE ({})'.format(where_clause), + self._atime_expr(), + "ORDER BY last_atime DESC", + ]), forward_only=False) + with debug.log_time('sql', 'Running completion query'): - self._query.run(pat=pattern) + self._query.run(**{ + str(i): w for i, w in enumerate(words)}) self.setQuery(self._query) def removeRows(self, row, _count, _parent=None): diff --git a/tests/unit/completion/test_histcategory.py b/tests/unit/completion/test_histcategory.py index b87eb6ac2..e42f9b91f 100644 --- a/tests/unit/completion/test_histcategory.py +++ b/tests/unit/completion/test_histcategory.py @@ -61,7 +61,7 @@ def hist(init_sql, config_stub): ('foo bar', [('foo', ''), ('bar foo', ''), ('xfooyybarz', '')], - [('xfooyybarz', '')]), + [('bar foo', ''), ('xfooyybarz', '')]), ('foo%bar', [('foo%bar', ''), ('foo bar', ''), ('foobar', '')], @@ -93,6 +93,38 @@ def test_set_pattern(pattern, before, after, model_validator, hist): model_validator.validate(after) +def test_set_pattern_repeated(model_validator, hist): + """Validate multiple subsequent calls to set_pattern.""" + hist.insert({'url': 'example.com/foo', 'title': 'title1', 'last_atime': 1}) + hist.insert({'url': 'example.com/bar', 'title': 'title2', 'last_atime': 1}) + hist.insert({'url': 'example.com/baz', 'title': 'title3', 'last_atime': 1}) + cat = histcategory.HistoryCategory() + model_validator.set_model(cat) + + cat.set_pattern('b') + model_validator.validate([ + ('example.com/bar', 'title2'), + ('example.com/baz', 'title3'), + ]) + + cat.set_pattern('ba') + model_validator.validate([ + ('example.com/bar', 'title2'), + ('example.com/baz', 'title3'), + ]) + + cat.set_pattern('ba ') + model_validator.validate([ + ('example.com/bar', 'title2'), + ('example.com/baz', 'title3'), + ]) + + cat.set_pattern('ba z') + model_validator.validate([ + ('example.com/baz', 'title3'), + ]) + + @pytest.mark.parametrize('max_items, before, after', [ (-1, [ ('a', 'a', '2017-04-16'), diff --git a/tests/unit/completion/test_models.py b/tests/unit/completion/test_models.py index 8879f3201..c4d224dcc 100644 --- a/tests/unit/completion/test_models.py +++ b/tests/unit/completion/test_models.py @@ -414,12 +414,12 @@ def test_url_completion_no_bookmarks(qtmodeltester, web_history_populated, ('example.com', 'Site Title', 'am', 1), ('example.com', 'Site Title', 'com', 1), ('example.com', 'Site Title', 'ex com', 1), - ('example.com', 'Site Title', 'com ex', 0), + ('example.com', 'Site Title', 'com ex', 1), ('example.com', 'Site Title', 'ex foo', 0), ('example.com', 'Site Title', 'foo com', 0), ('example.com', 'Site Title', 'exm', 0), ('example.com', 'Site Title', 'Si Ti', 1), - ('example.com', 'Site Title', 'Ti Si', 0), + ('example.com', 'Site Title', 'Ti Si', 1), ('example.com', '', 'foo', 0), ('foo_bar', '', '_', 1), ('foobar', '', '_', 0),