← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:stormify-nl-phrase-search into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:stormify-nl-phrase-search into launchpad:master.

Commit message:
Convert lp.services.database.nl_search to Storm

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/394413

Most of the work here is in converting all the callers.  The most complicated case is QuestionSearch, where the prejoins become uses of DecoratedResultSet.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:stormify-nl-phrase-search into launchpad:master.
diff --git a/lib/lp/answers/model/faq.py b/lib/lp/answers/model/faq.py
index bc69e39..f5ed5ae 100644
--- a/lib/lp/answers/model/faq.py
+++ b/lib/lp/answers/model/faq.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """FAQ document models."""
@@ -133,13 +133,13 @@ class FAQ(SQLBase):
         assert not (product and distribution), (
             'only one of product or distribution should be provided')
         if product:
-            target_constraint = 'product = %s' % sqlvalues(product)
+            target_constraint = (FAQ.product == product)
         elif distribution:
-            target_constraint = 'distribution = %s' % sqlvalues(distribution)
+            target_constraint = (FAQ.distribution == distribution)
         else:
             raise AssertionError('must provide product or distribution')
 
-        phrases = nl_phrase_search(summary, FAQ, target_constraint)
+        phrases = nl_phrase_search(summary, FAQ, [target_constraint])
         if not phrases:
             # No useful words to search on in that summary.
             return FAQ.select('1 = 2')
diff --git a/lib/lp/answers/model/question.py b/lib/lp/answers/model/question.py
index d44bd58..8b7c35c 100644
--- a/lib/lp/answers/model/question.py
+++ b/lib/lp/answers/model/question.py
@@ -36,11 +36,17 @@ from sqlobject import (
     )
 from storm.expr import LeftJoin
 from storm.locals import (
+    And,
+    Desc,
     Int,
+    Join,
+    Not,
+    Or,
     Reference,
+    Select,
+    Store,
     )
 from storm.references import ReferenceSet
-from storm.store import Store
 from zope.component import getUtility
 from zope.event import notify
 from zope.interface import (
@@ -98,22 +104,26 @@ from lp.registry.interfaces.product import (
     IProductSet,
     )
 from lp.registry.interfaces.sourcepackagename import ISourcePackageNameSet
+from lp.registry.model.sourcepackagename import SourcePackageName
 from lp.services.database import bulk
 from lp.services.database.constants import (
     DEFAULT,
     UTC_NOW,
     )
 from lp.services.database.datetimecol import UtcDateTimeCol
+from lp.services.database.decoratedresultset import DecoratedResultSet
 from lp.services.database.enumcol import EnumCol
 from lp.services.database.interfaces import IStore
 from lp.services.database.nl_search import nl_phrase_search
 from lp.services.database.sqlbase import (
     cursor,
-    quote,
     SQLBase,
     sqlvalues,
     )
-from lp.services.database.stormexpr import rank_by_fti
+from lp.services.database.stormexpr import (
+    fti_search,
+    rank_by_fti,
+    )
 from lp.services.mail.notificationrecipientset import NotificationRecipientSet
 from lp.services.messages.interfaces.message import IMessage
 from lp.services.messages.model.message import (
@@ -916,6 +926,9 @@ class QuestionSearch:
 
     def getTargetConstraints(self):
         """Return the constraints related to the IQuestionTarget context."""
+        # Circular import.
+        from lp.registry.model.product import Product
+
         if self.sourcepackagename:
             assert self.distribution is not None, (
                 "Distribution must be specified if sourcepackage is not None")
@@ -923,23 +936,22 @@ class QuestionSearch:
         constraints = []
 
         if self.product:
-            constraints.append(
-                'Question.product = %s' % sqlvalues(self.product))
+            constraints.append(Question.product == self.product)
         elif self.distribution:
-            constraints.append(
-                'Question.distribution = %s' % sqlvalues(self.distribution))
+            constraints.append(Question.distribution == self.distribution)
             if self.sourcepackagename:
                 constraints.append(
-                    'Question.sourcepackagename = %s' % sqlvalues(
-                        self.sourcepackagename))
+                    Question.sourcepackagename == self.sourcepackagename)
         elif self.projectgroup:
-            constraints.append("""
-                Question.product = Product.id AND Product.active AND
-                Product.project = %s""" % sqlvalues(self.projectgroup))
+            constraints.extend([
+                Question.product == Product.id,
+                Product.active,
+                Product.projectgroup == self.projectgroup,
+                ])
         else:
-            constraints.append("""
-                ((Question.product = Product.id AND Product.active) OR
-                 Question.product IS NULL)""")
+            constraints.append(Or(
+                And(Question.product == Product.id, Product.active),
+                Question.product == None))
 
         return constraints
 
@@ -958,9 +970,11 @@ class QuestionSearch:
         """Create the joins needed to select constraints on the messages by a
         particular person."""
         joins = [
-            ("""LEFT OUTER JOIN QuestionMessage
-                ON QuestionMessage.question = Question.id
-                AND QuestionMessage.owner = %s""" % sqlvalues(person))]
+            LeftJoin(
+                QuestionMessage,
+                QuestionMessage.question == Question.id,
+                QuestionMessage.owner == person),
+            ]
         if self.projectgroup:
             joins.extend(self.getProductJoins())
         elif not self.product and not self.distribution:
@@ -971,53 +985,50 @@ class QuestionSearch:
     def getProductJoins(self):
         """Create the joins needed to select constraints on projects by a
         particular project group."""
-        return [('JOIN Product '
-                 'ON Question.product = Product.id')]
+        # Circular import.
+        from lp.registry.model.product import Product
+
+        return [Join(Product, Question.product == Product.id)]
 
     def getActivePillarJoins(self):
         """Create the joins needed to select constraints on active pillars."""
-        return [('LEFT OUTER JOIN Product ON Question.product = Product.id')]
+        # Circular import.
+        from lp.registry.model.product import Product
+
+        return [LeftJoin(Product, Question.product == Product.id)]
 
     def getConstraints(self):
-        """Return a list of SQL constraints to use for this search."""
+        """Return a list of Storm constraints to use for this search."""
 
         constraints = self.getTargetConstraints()
 
         if self.search_text is not None:
-            if self.nl_phrase_used:
-                constraints.append(
-                    'Question.fti @@ %s' % quote(self.search_text))
-            else:
-                constraints.append(
-                    'Question.fti @@ ftq(%s)' % quote(self.search_text))
+            constraints.append(fti_search(
+                Question, self.search_text, ftq=not self.nl_phrase_used))
 
         if self.status:
-            constraints.append('Question.status IN %s' % sqlvalues(
-                list(self.status)))
+            constraints.append(Question.status.is_in(self.status))
 
         if self.needs_attention_from:
-            constraints.append('''(
-                (Question.owner = %(person)s
-                    AND Question.status IN %(owner_status)s)
-                OR (Question.owner != %(person)s AND
-                    Question.status = %(open_status)s AND
-                    QuestionMessage.owner = %(person)s)
-                )''' % sqlvalues(
-                    person=self.needs_attention_from,
-                    owner_status=[
-                        QuestionStatus.NEEDSINFO, QuestionStatus.ANSWERED],
-                    open_status=QuestionStatus.OPEN))
+            constraints.append(Or(
+                And(
+                    Question.owner == self.needs_attention_from,
+                    Question.status.is_in([
+                        QuestionStatus.NEEDSINFO, QuestionStatus.ANSWERED])),
+                And(
+                    Question.owner != self.needs_attention_from,
+                    Question.status == QuestionStatus.OPEN,
+                    QuestionMessage.owner == self.needs_attention_from)))
 
         if self.language:
-            constraints.append(
-                'Question.language IN (%s)'
-                    % ', '.join(sqlvalues(*self.language)))
+            constraints.append(Question.languageID.is_in(
+                [language.id for language in self.language]))
 
         return constraints
 
     def getPrejoins(self):
         """Return a list of tables that should be prejoined on this search."""
-        # The idea is to prejoin all dependant tables, except if the
+        # The idea is to prejoin all dependent tables, except if the
         # object will be the same in all rows because it is used as a
         # search criteria.
         if self.product or self.sourcepackagename or self.projectgroup:
@@ -1030,12 +1041,6 @@ class QuestionSearch:
             # QuestionTarget will vary.
             return ['owner', 'product', 'distribution', 'sourcepackagename']
 
-    def getPrejoinClauseTables(self):
-        """Return a list of tables that are in the contraints"""
-        if self.getConstraints().count('Question.product = Product.id'):
-            return ['product']
-        return []
-
     def getOrderByClause(self):
         """Return the ORDER BY clause to use for this search's results."""
         sort = self.sort
@@ -1045,42 +1050,64 @@ class QuestionSearch:
             else:
                 sort = QuestionSort.NEWEST_FIRST
         if sort is QuestionSort.NEWEST_FIRST:
-            return "-Question.datecreated"
+            return [Desc(Question.datecreated)]
         elif sort is QuestionSort.OLDEST_FIRST:
-            return "Question.datecreated"
+            return [Question.datecreated]
         elif sort is QuestionSort.STATUS:
-            return ["Question.status", "-Question.datecreated"]
+            return [Question.status, Desc(Question.datecreated)]
         elif sort is QuestionSort.RELEVANCY:
             if self.search_text:
                 ftq = not self.nl_phrase_used
                 return [
                     rank_by_fti(Question, self.search_text, ftq=ftq),
-                    "-Question.datecreated"]
+                    Desc(Question.datecreated)]
             else:
-                return "-Question.datecreated"
+                return [Desc(Question.datecreated)]
         elif sort is QuestionSort.RECENT_OWNER_ACTIVITY:
-            return ['-Question.datelastquery']
+            return [Desc(Question.datelastquery)]
         else:
             raise AssertionError("Unknown QuestionSort value: %s" % sort)
 
     def getResults(self):
         """Return the questions that match this query."""
-        query = ''
+        # Circular imports.
+        from lp.registry.model.distribution import Distribution
+        from lp.registry.model.person import Person
+        from lp.registry.model.product import Product
+
+        prejoin_by_name = {
+            'owner': LeftJoin(Person, Question.owner == Person.id),
+            'product': LeftJoin(Product, Question.product == Product.id),
+            'distribution': LeftJoin(
+                Distribution, Question.distribution == Distribution.id),
+            'sourcepackagename': LeftJoin(
+                SourcePackageName,
+                Question.sourcepackagename == SourcePackageName.id),
+            }
+        prejoin_table_by_name = {
+            'owner': Person,
+            'product': Product,
+            'distribution': Distribution,
+            'sourcepackagename': SourcePackageName,
+            }
+
         constraints = self.getConstraints()
         if constraints:
             joins = self.getTableJoins()
-            if len(joins) > 0:
+            if joins:
                 # Make a slower query to accommodate the joins.
-                query += (
-                    'Question.id IN ('
-                        'SELECT Question.id FROM Question %s WHERE %s)' % (
-                            '\n'.join(joins), ' AND '.join(constraints)))
-            else:
-                query += ' AND '.join(constraints)
-        return Question.select(
-            query, prejoins=self.getPrejoins(),
-            prejoinClauseTables=self.getPrejoinClauseTables(),
-            orderBy=self.getOrderByClause())
+                constraints = [
+                    Question.id.is_in(Select(
+                        Question.id, where=And(*constraints),
+                        tables=[Question] + joins)),
+                    ]
+        prejoins = [prejoin_by_name[prejoin] for prejoin in self.getPrejoins()]
+        prejoin_tables = [
+            prejoin_table_by_name[prejoin] for prejoin in self.getPrejoins()]
+        rows = IStore(Question).using(Question, *prejoins).find(
+            (Question,) + tuple(prejoin_tables),
+            *constraints).order_by(*self.getOrderByClause())
+        return DecoratedResultSet(rows, operator.itemgetter(0))
 
 
 class QuestionTargetSearch(QuestionSearch):
@@ -1119,13 +1146,13 @@ class QuestionTargetSearch(QuestionSearch):
         """
         constraints = QuestionSearch.getConstraints(self)
         if self.owner:
-            constraints.append('Question.owner = %s' % self.owner.id)
+            constraints.append(Question.owner == self.owner)
         if self.unsupported_target is not None:
-            langs = [str(lang.id)
-                     for lang in (
-                        self.unsupported_target.getSupportedLanguages())]
-            constraints.append('Question.language NOT IN (%s)' %
-                               ', '.join(langs))
+            supported_languages = (
+                self.unsupported_target.getSupportedLanguages())
+            constraints.append(
+                Not(Question.languageID.is_in(
+                    [language.id for language in supported_languages])))
 
         return constraints
 
@@ -1156,7 +1183,7 @@ class SimilarQuestionsSearch(QuestionSearch):
         # Change the search text to use based on the native language
         # similarity search algorithm.
         self.search_text = nl_phrase_search(
-            title, Question, " AND ".join(self.getTargetConstraints()))
+            title, Question, self.getTargetConstraints())
         self.nl_phrase_used = True
 
 
@@ -1197,10 +1224,10 @@ class QuestionPersonSearch(QuestionSearch):
 
         if QuestionParticipation.SUBSCRIBER in self.participation:
             joins.append(
-                'LEFT OUTER JOIN QuestionSubscription '
-                'ON QuestionSubscription.question = Question.id'
-                ' AND QuestionSubscription.person = %s' % sqlvalues(
-                    self.person))
+                LeftJoin(
+                    QuestionSubscription,
+                    QuestionSubscription.question == Question.id,
+                    QuestionSubscription.person == self.person))
 
         if QuestionParticipation.COMMENTER in self.participation:
             message_joins = self.getMessageJoins(self.person)
@@ -1208,12 +1235,13 @@ class QuestionPersonSearch(QuestionSearch):
 
         return joins
 
-    queryByParticipationType = {
-        QuestionParticipation.ANSWERER: "Question.answerer = %s",
-        QuestionParticipation.SUBSCRIBER: "QuestionSubscription.person = %s",
-        QuestionParticipation.OWNER: "Question.owner = %s",
-        QuestionParticipation.COMMENTER: "QuestionMessage.owner = %s",
-        QuestionParticipation.ASSIGNEE: "Question.assignee = %s"}
+    columnByParticipationType = {
+        QuestionParticipation.ANSWERER: Question.answerer,
+        QuestionParticipation.SUBSCRIBER: QuestionSubscription.person,
+        QuestionParticipation.OWNER: Question.owner,
+        QuestionParticipation.COMMENTER: QuestionMessage.owner,
+        QuestionParticipation.ASSIGNEE: Question.assignee,
+        }
 
     def getConstraints(self):
         """See `QuestionSearch`.
@@ -1225,12 +1253,11 @@ class QuestionPersonSearch(QuestionSearch):
 
         participations_filter = []
         for participation_type in self.participation:
-            participations_filter.append(
-                self.queryByParticipationType[participation_type] % sqlvalues(
-                    self.person))
+            column = self.columnByParticipationType[participation_type]
+            participations_filter.append(column == self.person)
 
         if participations_filter:
-            constraints.append('(' + ' OR '.join(participations_filter) + ')')
+            constraints.append(Or(participations_filter))
 
         return constraints
 
diff --git a/lib/lp/bugs/model/bugtask.py b/lib/lp/bugs/model/bugtask.py
index 32440f1..d30232a 100644
--- a/lib/lp/bugs/model/bugtask.py
+++ b/lib/lp/bugs/model/bugtask.py
@@ -1461,25 +1461,22 @@ class BugTaskSet:
         # Avoid circular imports.
         from lp.bugs.model.bug import Bug
         search_params = BugTaskSearchParams(user)
-        constraint_clauses = ['BugTask.bug = Bug.id']
+        constraint_clauses = [BugTask.bug == Bug.id]
         if product:
             search_params.setProduct(product)
-            constraint_clauses.append(
-                'BugTask.product = %s' % sqlvalues(product))
+            constraint_clauses.append(BugTask.product == product)
         elif distribution:
             search_params.setDistribution(distribution)
-            constraint_clauses.append(
-                'BugTask.distribution = %s' % sqlvalues(distribution))
+            constraint_clauses.append(BugTask.distribution == distribution)
             if sourcepackagename:
                 search_params.sourcepackagename = sourcepackagename
                 constraint_clauses.append(
-                    'BugTask.sourcepackagename = %s' % sqlvalues(
-                        sourcepackagename))
+                    BugTask.sourcepackagename == sourcepackagename)
         else:
             raise AssertionError('Need either a product or distribution.')
 
         search_params.fast_searchtext = nl_phrase_search(
-            summary, Bug, ' AND '.join(constraint_clauses), ['BugTask'])
+            summary, Bug, constraint_clauses)
         return self.search(search_params, _noprejoins=True)
 
     def search(self, params, *args, **kwargs):
diff --git a/lib/lp/services/database/doc/textsearching.txt b/lib/lp/services/database/doc/textsearching.txt
index 2834aa5..e5feb20 100644
--- a/lib/lp/services/database/doc/textsearching.txt
+++ b/lib/lp/services/database/doc/textsearching.txt
@@ -722,15 +722,14 @@ Short queries are expanded more simply:
 Using other constraints
 .......................
 
-You can pass a third parameter to the function that will be use as
-an additional constraint to determine the total number of rows that
+You can pass a third parameter to the function that will be used as
+additional constraints to determine the total number of rows that
 could be matched. For example, when searching questions on the firefox
 product more than 50% have the word 'get' in (which surprisingly isn't
-considered a stop word by tsearch2). If the constraint uses any
-additional tables, you can pass in list of names of these tables as a
-forth parameter:
+considered a stop word by tsearch2).
 
     >>> from lp.registry.interfaces.product import IProductSet
+    >>> from lp.registry.model.product import Product
     >>> firefox_product = getUtility(IProductSet).getByName('firefox')
 
     >>> firefox_count = Question.select(
@@ -742,9 +741,8 @@ forth parameter:
 
     >>> nl_phrase_search(
     ...     'firefox gets very slow on flickr', Question,
-    ...     "Question.product = %s AND Product.active = 't'"
-    ...     % firefox_product.id,
-    ...     ['Product'], fast_enabled=False)
+    ...     [Question.product == firefox_product, Product.active],
+    ...     fast_enabled=False)
     u'slow|flickr'
 
 When the query only has stop words in it, the returned query will be the empty
@@ -759,7 +757,7 @@ is done.
     >>> Question.select('product = -1').count()
     0
     >>> nl_phrase_search('firefox is very slow on flickr', Question,
-    ...                  'product = -1')
+    ...                  [Question.product == -1])
     u'(firefox&flickr&slow)|(flickr&slow)|(firefox&slow)|(firefox&flickr)'
 
 
@@ -796,6 +794,6 @@ questions:
 
     >>> nl_phrase_search(
     ...     'firefox is slow', Question,
-    ...     'distribution = %s AND sourcepackagename = %s' % sqlvalues(
-    ...     ubuntu, firefox_package.sourcepackagename))
+    ...     [Question.distribution == ubuntu,
+    ...      Question.sourcepackagename == firefox_package.sourcepackagename])
     u'firefox|slow'
diff --git a/lib/lp/services/database/nl_search.py b/lib/lp/services/database/nl_search.py
index 1dc2e58..d0226bb 100644
--- a/lib/lp/services/database/nl_search.py
+++ b/lib/lp/services/database/nl_search.py
@@ -1,4 +1,4 @@
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Helpers for doing natural language phrase search using the
@@ -11,11 +11,22 @@ __all__ = ['nl_phrase_search']
 
 import re
 
-from lp.services.database.sqlbase import (
-    cursor,
-    quote,
-    sqlvalues,
+import six
+from storm.databases.postgres import Case
+from storm.locals import (
+    Count,
+    Select,
+    SQL,
     )
+from zope.component import getUtility
+
+from lp.services.database.interfaces import (
+    DEFAULT_FLAVOR,
+    IStore,
+    IStoreSelector,
+    MAIN_STORE,
+    )
+from lp.services.database.stormexpr import fti_search
 
 # Regular expression to extract terms from the printout of a ts_query
 TS_QUERY_TERM_RE = re.compile(r"'([^']+)'")
@@ -28,9 +39,9 @@ def nl_term_candidates(phrase):
 
     :phrase: a search phrase
     """
-    cur = cursor()
-    cur.execute("SELECT ftq(%(phrase)s)::text" % sqlvalues(phrase=phrase))
-    rs = cur.fetchall()
+    phrase = six.ensure_text(phrase)
+    store = getUtility(IStoreSelector).get(MAIN_STORE, DEFAULT_FLAVOR)
+    rs = store.execute(Select(SQL("ftq(?)::text", params=(phrase,)))).get_all()
     assert len(rs) == 1, "ftq() returned more than one row"
     terms = rs[0][0]
     if not terms:
@@ -39,8 +50,7 @@ def nl_term_candidates(phrase):
     return TS_QUERY_TERM_RE.findall(terms)
 
 
-def nl_phrase_search(phrase, table, constraints='',
-                     extra_constraints_tables=None,
+def nl_phrase_search(phrase, table, constraint_clauses=None,
                      fast_enabled=True):
     """Return the tsearch2 query that should be used to do a phrase search.
 
@@ -54,11 +64,9 @@ def nl_phrase_search(phrase, table, constraints='',
     for full text searching.
 
     :param phrase: A search phrase.
-    :param table: This should be the SQLBase class representing the base type.
-    :param constraints: Additional SQL clause that limits the rows to a subset
-        of the table.
-    :param extra_constraints_tables: A list of additional table names that are
-        needed by the constraints clause.
+    :param table: This should be the Storm class representing the base type.
+    :param constraint_clauses: Additional Storm clauses that limit the rows
+        to a subset of the table.
     :param fast_enabled: If true use a fast, but less precise, code path. When
         feature flags are available this will be converted to a feature flag.
     :return: A tsearch2 query string.
@@ -67,14 +75,12 @@ def nl_phrase_search(phrase, table, constraints='',
     if len(terms) == 0:
         return ''
     if fast_enabled:
-        return _nl_phrase_search(terms, table, constraints,
-            extra_constraints_tables)
+        return _nl_phrase_search(terms, table, constraint_clauses)
     else:
-        return _slow_nl_phrase_search(terms, table, constraints,
-            extra_constraints_tables)
+        return _slow_nl_phrase_search(terms, table, constraint_clauses)
 
 
-def _nl_phrase_search(terms, table, constraints, extra_constraints_tables):
+def _nl_phrase_search(terms, table, constraint_clauses):
     """Perform a very simple pruning of the phrase, letting fti do ranking.
 
     This function groups the terms with & clause, and creates an additional
@@ -101,8 +107,7 @@ def _nl_phrase_search(terms, table, constraints, extra_constraints_tables):
     return '|'.join(and_clauses)
 
 
-def _slow_nl_phrase_search(terms, table, constraints,
-    extra_constraints_tables):
+def _slow_nl_phrase_search(terms, table, constraint_clauses):
     """Return the tsearch2 query that should be use to do a phrase search.
 
     This function implement an algorithm similar to the one used by MySQL
@@ -110,8 +115,7 @@ def _slow_nl_phrase_search(terms, table, constraints,
     http://dev.mysql.com/doc/refman/5.0/en/fulltext-search.html).
 
     It eliminates stop words from the phrase and normalize each terms
-    according to the full text indexation rules (lowercasing and stemming).
-
+    according to the full text indexation rules (lowercasing and stemming)
     Each term that is present in more than 50% of the candidate rows is also
     eliminated from the query. That term eliminatation is only done when there
     are 5 candidate rows or more.
@@ -124,19 +128,19 @@ def _slow_nl_phrase_search(terms, table, constraints,
 
     :terms: Some candidate search terms.
 
-    :table: This should be the SQLBase class representing the base type.
+    :table: This should be the Storm class representing the base type.
 
-    :constraints: Additional SQL clause that limits the rows to a
-    subset of the table.
-
-    :extra_constraints_tables: A list of additional table names that are
-    needed by the constraints clause.
+    :constraints: Additional Storm clause that limits the rows to a subset
+        of the table.
 
     Caveat: The model class must define a 'fti' column which is then used
     for full text searching.
     """
-    total = table.select(
-        constraints, clauseTables=extra_constraints_tables).count()
+    if constraint_clauses is None:
+        constraint_clauses = []
+
+    store = IStore(table)
+    total = store.find(table, *constraint_clauses).count()
     term_candidates = terms
     if total < 5:
         return '|'.join(term_candidates)
@@ -144,23 +148,11 @@ def _slow_nl_phrase_search(terms, table, constraints,
     # Build the query to get all the counts. We get all the counts in
     # one query, using COUNT(CASE ...), since issuing separate queries
     # with COUNT(*) is a lot slower.
-    count_template = (
-        'COUNT(CASE WHEN %(table)s.fti @@ ftq(%(term)s)'
-        ' THEN TRUE ELSE null END)')
-    select_counts = [
-        count_template % {'table': table.__storm_table__, 'term': quote(term)}
-        for term in term_candidates
-        ]
-    select_tables = [table.__storm_table__]
-    if extra_constraints_tables is not None:
-        select_tables.extend(extra_constraints_tables)
-    count_query = "SELECT %s FROM %s" % (
-        ', '.join(select_counts), ', '.join(select_tables))
-    if constraints != '':
-        count_query += " WHERE %s" % constraints
-    cur = cursor()
-    cur.execute(count_query)
-    counts = cur.fetchone()
+    counts = store.find(
+        tuple(
+            Count(Case([(fti_search(table, term), True)], default=None))
+            for term in term_candidates),
+        *constraint_clauses).one()
 
     # Remove words that are too common.
     terms = [