Repository: qutebrowser/qutebrowser

Issue:
Completion: match terms in any order
[This regex](https://github.com/The-Compiler/qutebrowser/blob/master/qutebrowser/completion/models/sortfilter.py#L75), which is used to filter items for completion, constrains the match to the same order the terms are supplied by the user. I.e., "browser qute" does not match "qutebrowser". According to [this post](http://stackoverflow.com/a/18944925/4180822), this issue can be solved by constructing better regular expression using positive lookaheads. I think it's worthwhile to examine the performance impact of the solution, or find a different one.



Discussion:
I've heard this before and I'm pretty sure I had opened an issue, but apparently not?

The SQL history completion uses the [LIKE operator](https://github.com/qutebrowser/qutebrowser/blob/6973a703c54783b774079bab40f4d75e64262e17/qutebrowser/completion/models/histcategory.py#L89-L91) which forces the typed-in order. I had it fixed before SQL in https://github.com/qutebrowser/qutebrowser/pull/1654/commits/dcec568d1fe2d874c3b450544902ec9455f92251, which can be easily [applied to SQL](https://stackoverflow.com/a/14291100/4180822) and thanks to indexing, the performance problems from #1654 might be gone.

Resolving patch:
diff --git a/qutebrowser/completion/models/histcategory.py b/qutebrowser/completion/models/histcategory.py
--- 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 @@ def __init__(self, *, delete_func=None, parent=None):
         """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 @@ def set_pattern(self, pattern):
         # 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):


Test patch:
diff --git a/tests/unit/completion/test_histcategory.py b/tests/unit/completion/test_histcategory.py
--- 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
--- 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),