launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #25729
[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 = [