Repository: plone/Products.CMFPlone

Issue:
searching for phrases within quotes doesn't work
the `munge_search_term` method doesn't work when searching for quoted phrases.

https://github.com/plone/Products.CMFPlone/blob/master/Products/CMFPlone/browser/search.py

**Reproduce:**

- go to https://6-classic.demo.plone.org/en
- search for `"Welcome to Plone"`
- you get an empty search result

**Background**

the `munge_search_term` returns this for the phrase above:

`'Welcome AND to AND Plone*'`

**Solution**

keep phrases within quotes untouched.


Discussion:
"Welcome" -> 1 result
"Welcome " -> 0 results (a space after 'e')

Resolving patch:
diff --git a/Products/CMFPlone/browser/search.py b/Products/CMFPlone/browser/search.py
--- a/Products/CMFPlone/browser/search.py
+++ b/Products/CMFPlone/browser/search.py
@@ -16,6 +16,7 @@
 from ZTUtils import make_query
 
 import json
+import re
 
 _ = MessageFactory('plone')
 
@@ -45,18 +46,33 @@ def quote(term):
     return term
 
 
+def munge_search_term(query):
+    for char in BAD_CHARS:
+        query = query.replace(char, ' ')
+
+    # extract quoted phrases first
+    quoted_phrases = re.findall(r'"([^"]*)"', query)
+    r = []
+    for qp in quoted_phrases:
+        # remove from original query
+        query = query.replace(f'"{qp}"', "")
+        # replace with cleaned leading/trailing whitespaces
+        # and skip empty phrases
+        clean_qp = qp.strip()
+        if not clean_qp:
+            continue
+        r.append(f'"{clean_qp}"')
+
+    r += map(quote, query.strip().split())
+    r = " AND ".join(r)
+    r = quote_chars(r) + ('*' if r and not r.endswith('"') else '')
+    return r
+
+
 class Search(BrowserView):
 
     valid_keys = ('sort_on', 'sort_order', 'sort_limit', 'fq', 'fl', 'facet')
 
-    def munge_search_term(self, q):
-        for char in BAD_CHARS:
-            q = q.replace(char, ' ')
-        r = map(quote, q.split())
-        r = " AND ".join(r)
-        r = quote_chars(r) + '*'
-        return r
-
     def results(self, query=None, batch=True, b_size=10, b_start=0,
                 use_content_listing=True):
         """ Get properly wrapped search results from the catalog.
@@ -106,7 +122,7 @@ def _filter_query(self, query):
             if v and ((k in valid_keys) or k.startswith('facet.')):
                 query[k] = v
         if text:
-            query['SearchableText'] = self.munge_search_term(text)
+            query['SearchableText'] = munge_search_term(text)
 
         # don't filter on created at all if we want all results
         created = query.get('created')


Test patch:
diff --git a/Products/CMFPlone/tests/testSearch.py b/Products/CMFPlone/tests/testSearch.py
--- a/Products/CMFPlone/tests/testSearch.py
+++ b/Products/CMFPlone/tests/testSearch.py
@@ -27,7 +27,6 @@
 from plone.app.testing import applyProfile
 from plone.app.testing import PLONE_FIXTURE
 from plone.app.testing import IntegrationTesting
-from plone.testing import z2
 
 
 def test_request():
@@ -257,6 +256,110 @@ def test_filter_with_plone3_query(self):
         res = view.results(batch=False)
         self.assertEqual([], [r for r in res])
 
+    def test_quoted_phrase(self):
+        portal = self.layer['portal']
+        # searching for ""
+        view = portal.restrictedTraverse('@@search')
+        # unqoted
+        self.assertEqual(view.results(
+            query=dict(SearchableText='spam ham')).sequence_length, 12)
+        # quoted -> same result
+        self.assertEqual(view.results(
+            query=dict(SearchableText='"spam ham"')).sequence_length, 12)
+        # unquoted reverse order -> all results
+        self.assertEqual(view.results(
+            query=dict(SearchableText='ham spam')).sequence_length, 12)
+        # quoted reverse order -> no results!
+        self.assertEqual(view.results(
+            query=dict(SearchableText='"ham spam"')).sequence_length, 0)
+
+        # arbitrary words within index
+        self.assertEqual(view.results(
+            query=dict(SearchableText='spam eggs')).sequence_length, 12)
+        # arbitrary words within index quoted -> no results
+        self.assertEqual(view.results(
+            query=dict(SearchableText='"spam eggs"')).sequence_length, 0)
+
+        # unquoted subtring search
+        self.assertEqual(view.results(
+            query=dict(SearchableText='egg')).sequence_length, 12)
+        # quoted substring search -> exact match
+        self.assertEqual(view.results(
+            query=dict(SearchableText='"egg"')).sequence_length, 0)
+
+        # unquoted multi substring search
+        # XXX: this is munged to "egg AND spa*" and doesn't find any results
+        self.assertEqual(view.results(
+            query=dict(SearchableText='egg spa')).sequence_length, 0)
+
+        # weird input
+        self.assertEqual(view.results(
+            query=dict(SearchableText='"eggs" ham spam')).sequence_length, 12)
+        self.assertEqual(view.results(
+            query=dict(SearchableText='"eggs ham spam')).sequence_length, 12)
+        self.assertEqual(view.results(
+            query=dict(SearchableText='eggs ham spam"')).sequence_length, 12)
+
+    def test_munge_search_term(self):
+        from Products.CMFPlone.browser.search import BAD_CHARS
+        from Products.CMFPlone.browser.search import munge_search_term
+
+        search_term_tests = [
+            (
+                # search term
+                'spam ham',
+                'spam AND ham*',
+            ),
+            (
+                # quoted term
+                '"spam ham"',
+                '"spam ham"',
+            ),
+            (
+                # cleanup quoted terms
+                '" spam ham   "',
+                '"spam ham"',
+            ),
+            (
+                # mixed cases
+                'Spam hAm',
+                'Spam AND hAm*',
+            ),
+            (
+                # mix quoting and unquoted
+                'let\'s eat some "ham and eggs " without spam ',
+                '"ham and eggs" AND let\'s AND eat AND some '
+                'AND without AND spam*',
+            ),
+            (
+                'test "Welcome" to "Plone" retest',
+                '"Welcome" AND "Plone" AND test AND to AND retest*',
+            ),
+            (
+                # parentheses
+                'spam (ham)',
+                'spam AND "("ham")"*',
+            ),
+            (
+                # special keywords
+                'spam or not ham and eggs',
+                'spam AND "or" AND "not" AND ham AND "and" AND eggs*',
+            ),
+            (
+                # bad characters
+                " ".join(BAD_CHARS),
+                "",
+            ),
+            (
+                # weird input
+                'test ""Welcome" to "Plone"" retest',
+                '"to" AND test AND WelcomePlone AND retest*',
+            )
+        ]
+
+        for _in, _out in search_term_tests:
+            self.assertEqual(munge_search_term(_in), _out)
+
 
 def test_suite():
     """This sets up a test suite that actually runs the tests in the class