Merge lp:~adeuring/launchpad/bug-594247 into lp:launchpad

Proposed by Abel Deuring
Status: Merged
Approved by: Gavin Panella
Approved revision: no longer in the source branch.
Merged at revision: 11933
Proposed branch: lp:~adeuring/launchpad/bug-594247
Merge into: lp:launchpad
Diff against target: 495 lines (+233/-121)
2 files modified
lib/lp/bugs/model/bugtask.py (+166/-117)
lib/lp/bugs/tests/test_bugtask_search.py (+67/-4)
To merge this branch: bzr merge lp:~adeuring/launchpad/bug-594247
Reviewer Review Type Date Requested Status
Gavin Panella (community) code Approve
Graham Binns (community) code Approve
Review via email: mp+40713@code.launchpad.net

Commit message

[r=allenap,gmb][ui=none][bug=594247] use JOIN and LEFT JOIN to link tables needed to search for bugtask releated to a structural subscriber; refactoring of BugTaskSet.search() and BugTaskSet.serachBugIds(); more tests for BugTaskSet.search()

Description of the change

This branch fixes bug 594247: searchTasks with structural_subscriber
times out regularly.

The fix uses stub's suggestion from a comment in the bug report
to (LEFT) JOIN the tables Product and StructuralSubscription and
to define the filter clause "StructuralSubscriber=somebody"
directly in the main query.

This required some larger modification of the method
BugTaskSet.buildQuery(): Up to now, it returned four variables,
query, clauseTables, orderby_arg, decorator. query contains a string
with the WHERE clause, clauseTables contains a list of tables
used in the WHERE clause.

Simply adding Storm Join instances for

  LEFT OUTER JOIN Product ON BugTask.product=Product.id

and

  JOIN StructuralSubscription ON (some_longer_expression)

to clauseTables has at least one problem: The method BugTaskSet.search()
optionally joins some tables, including Product, in order to pre-load
some objects that will be needed later to process a request.

So it could happen that the table Product is JOINed twice: The first time
in clauseTables because it is needed in the WHERE expression for
structural subscription, the second time to pre-load Storm Product
instances.

Hence I did not add the Storm Join instances to clauseTables; instead,
BugTaskSet.buildQuery() now returns another parameter join_tables,
which contains a sequence of tuples (storm_table, join_expression).

A new method BugTaskSet.buildOrigin() now processes join_expression and
clauseTables together with a sequence of Join instances needed to
pre-load Product, Bug, SourcePackageName instances, so that each
table appears exactly once in the WHERE clause of the SQL query.

The new way to find bugtasks related to a structural subscription
can return bug tasks twice, for example, if somebody has
a structural subscription to a distribution and to a distro source
package. So BugTaskSet.buildQuery() now returns one more parameter,
has_duplicate_results.

BugTaskSet.buildQuery() was called by BugTaskSet.search() and
by BugTaskSet.searchBugIds(). Processing the data returned by
buildQuery() became a bit more complicated, and BugTaskSet.search()
could already create two variants of the SQL query: one to retrieve
only BugTask instances and another to retrieve related Product,
Bug, SourcePackageName instances. We can consider searchBugIds()
to be variant of search() which just defines another type of data to
retrieve, so the SQL query is now built in a new method _search()
which is called by search() and by searchBugIds().

This required another tweak: BugTaskSet.buildQuery() returns a
decorator for the result set whichs avoids later SQL queries
ensuring that the current user is allowed to view a bug. (see
lp.bugs.model.bugtask.get_bug_privacy_filter_with_decorator())

This decorator assumes that the Storm query returns BugTask
instances, which is not the case for BugTaskSet.searchBugIds().
So BugTaskSet._search() has an optional parameter "undecorated".
If it is True, _search() returns a plain Storm result set,
otherwise, it returns a DecoratedResultSet.

Treatment of queries which may have duplicate result rows
("if has_duplicate_result_rows:" in _search()): We cannot simply
call resultset.config(distinct=True) because we may get two rows
for one bugtask if somebody is subscribed to a distribution and
to a distro source package and if we preload SourcePackageName.

Using a DISTINCT ON BugTask.id query would require sorting by
BugTask.id, so I simply used a sub-query.

The part of BugTaskSet.search() (now in _search()) which handles
queries with more that one BugTaskSearchParams instance now
preloads/prejoins Bug, Product etc only if _noprejoins is False.

Finally, I added some more tests to test_bugtask_search.py

no lint.

test: ./bin/test -vvt test_bugtask_search

It might make sense to change BugTaskSet.getAssignedMilestonesFromSearch()
in a follow-up brnach so that it calls _search() directly,
instead of calling search(), extracting milestone IDs
from the result set and running another SQL query to retrieve
these milestones.

To post a comment you must log in.
Revision history for this message
Graham Binns (gmb) wrote :

Hi Abel,

As we discussed on IRC, I think you should get a review from someone more SQL / Storm savvy than I, since my brain seems to just skip off those parts of the code.

I'm happy with the rest of the diff, with just two comments:

> 94 + # Circular.

Please expand this to something less terse ;). ("Prevent circular
import problems." or something similar.)

> 319 + # Circular.

Here too.

review: Approve (code)
Revision history for this message
Gavin Panella (allenap) wrote :
Download full text (5.8 KiB)

Cool :) Lots of comments, but none of them are very important.

[1]

+ Or(BugTask.sourcepackagenameID ==
+ StructuralSubscription.sourcepackagenameID,
+ StructuralSubscription.sourcepackagename == None)))

Indentation is a little confusing there.

[2]

+ join_clause = (
+ BugTask.productID == StructuralSubscription.productID)
+ join_clause = Or(
+ join_clause,
+ (BugTask.productseriesID ==
+ StructuralSubscription.productseriesID))
+ # Circular.
+ from lp.registry.model.product import Product
+ join_clause = Or(
+ join_clause,
+ And(Product.projectID == StructuralSubscription.projectID,
+ BugTask.product == Product.id))
+ join_clause = Or(
+ join_clause,
+ And((BugTask.distributionID ==
+ StructuralSubscription.distributionID),
+ Or(BugTask.sourcepackagenameID ==
+ StructuralSubscription.sourcepackagenameID,
+ StructuralSubscription.sourcepackagename == None)))
+ join_clause = Or(
+ join_clause,
+ (BugTask.distroseriesID ==
+ StructuralSubscription.distroseriesID))
+ join_clause = Or(
+ join_clause,
+ BugTask.milestoneID == StructuralSubscription.milestoneID)

I had trouble reading this, so I refactored it to use more descriptive
names. If you like what I've done then I think it would be a good
change to make:

    ssub_match_product = (
        BugTask.productID ==
        StructuralSubscription.productID)
    ssub_match_productseries = (
        BugTask.productseriesID ==
        StructuralSubscription.productseriesID)
    ssub_match_project = And(
        Product.projectID ==
        StructuralSubscription.projectID,
        BugTask.product == Product.id)
    ssub_match_distribution = (
        BugTask.distributionID ==
        StructuralSubscription.distributionID)
    ssub_match_sourcepackagename = (
        BugTask.sourcepackagenameID ==
        StructuralSubscription.sourcepackagenameID)
    ssub_match_null_sourcepackagename = (
        StructuralSubscription.sourcepackagename == None)
    ssub_match_distribution_with_optional_package = And(
        ssub_match_distribution, Or(
            ssub_match_sourcepackagename,
            ssub_match_null_sourcepackagename))
    ssub_match_distribution_series = (
        BugTask.distroseriesID ==
        StructuralSubscription.distroseriesID)
    ssub_match_milestone = (
        BugTask.milestoneID ==
        StructuralSubscription.milestoneID)

    join_clause = Or(
        ssub_match_product,
        ssub_match_productseries,
        ssub_match_project,
        ssub_match_distribution_with_optional_package,
        ssub_match_distribution_series,
        ssub_match_milestone)

[3]

+ query, clauseTables, orderby_arg, decorator, join_tables,

Just because everything else seems to not be camel-case any more,
consider doing s/clauseTables/clause_...

Read more...

review: Approve (code)
Revision history for this message
Abel Deuring (adeuring) wrote :
Download full text (7.7 KiB)

Hi Gavin,

thanks for your thorough review!

On 12.11.2010 17:29, Gavin Panella wrote:
> Review: Approve code
> Cool :) Lots of comments, but none of them are very important.
>
>
> [1]
>
> + Or(BugTask.sourcepackagenameID ==
> + StructuralSubscription.sourcepackagenameID,
> + StructuralSubscription.sourcepackagename == None)))
>
> Indentation is a little confusing there.

yeah... But your proposal below to make the huge join clause more
readable fixes it already :)

>
>
> [2]
>
> + join_clause = (
> + BugTask.productID == StructuralSubscription.productID)
> + join_clause = Or(
> + join_clause,
> + (BugTask.productseriesID ==
> + StructuralSubscription.productseriesID))
> + # Circular.
> + from lp.registry.model.product import Product
> + join_clause = Or(
> + join_clause,
> + And(Product.projectID == StructuralSubscription.projectID,
> + BugTask.product == Product.id))
> + join_clause = Or(
> + join_clause,
> + And((BugTask.distributionID ==
> + StructuralSubscription.distributionID),
> + Or(BugTask.sourcepackagenameID ==
> + StructuralSubscription.sourcepackagenameID,
> + StructuralSubscription.sourcepackagename == None)))
> + join_clause = Or(
> + join_clause,
> + (BugTask.distroseriesID ==
> + StructuralSubscription.distroseriesID))
> + join_clause = Or(
> + join_clause,
> + BugTask.milestoneID == StructuralSubscription.milestoneID)
>
> I had trouble reading this, so I refactored it to use more descriptive
> names. If you like what I've done then I think it would be a good
> change to make:
>
> ssub_match_product = (
> BugTask.productID ==
> StructuralSubscription.productID)
> ssub_match_productseries = (
> BugTask.productseriesID ==
> StructuralSubscription.productseriesID)
> ssub_match_project = And(
> Product.projectID ==
> StructuralSubscription.projectID,
> BugTask.product == Product.id)
> ssub_match_distribution = (
> BugTask.distributionID ==
> StructuralSubscription.distributionID)
> ssub_match_sourcepackagename = (
> BugTask.sourcepackagenameID ==
> StructuralSubscription.sourcepackagenameID)
> ssub_match_null_sourcepackagename = (
> StructuralSubscription.sourcepackagename == None)
> ssub_match_distribution_with_optional_package = And(
> ssub_match_distribution, Or(
> ssub_match_sourcepackagename,
> ssub_match_null_sourcepackagename))
> ssub_match_distribution_series = (
> BugTask.distroseriesID ==
> StructuralSubscription.distroseriesID)
> ssub_match_milestone = (
> BugTask.milestoneID ==
> StructuralSubscription.milestoneID)
>
> join_clause = Or(
> ssub_match_product,
> ...

Read more...

Revision history for this message
Robert Collins (lifeless) wrote :

"
This decorator assumes that the Storm query returns BugTask
instances, which is not the case for BugTaskSet.searchBugIds().
So BugTaskSet._search() has an optional parameter "undecorated".
If it is True, _search() returns a plain Storm result set,
otherwise, it returns a DecoratedResultSet."

I have a thought here: the decorator is meant to be opaque. So why not
just return an appropriate one for the query being constructed?

I guess I'm saying that 'undecorated' is the what, not the why, and
some other approach might be clearer.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/bugs/model/bugtask.py'
2--- lib/lp/bugs/model/bugtask.py 2010-11-09 08:43:34 +0000
3+++ lib/lp/bugs/model/bugtask.py 2010-11-17 13:53:59 +0000
4@@ -35,11 +35,12 @@
5 from storm.expr import (
6 Alias,
7 And,
8- AutoTables,
9 Desc,
10+ In,
11 Join,
12 LeftJoin,
13 Or,
14+ Select,
15 SQL,
16 )
17 from storm.store import (
18@@ -160,6 +161,7 @@
19 )
20 from lp.registry.model.pillar import pillar_sort_key
21 from lp.registry.model.sourcepackagename import SourcePackageName
22+from lp.registry.model.structuralsubscription import StructuralSubscription
23 from lp.services.propertycache import get_property_cache
24 from lp.soyuz.enums import PackagePublishingStatus
25 from lp.soyuz.model.publishing import SourcePackagePublishingHistory
26@@ -1606,7 +1608,9 @@
27 from lp.bugs.model.bug import Bug
28 extra_clauses = ['Bug.id = BugTask.bug']
29 clauseTables = [BugTask, Bug]
30+ join_tables = []
31 decorators = []
32+ has_duplicate_results = False
33
34 # These arguments can be processed in a loop without any other
35 # special handling.
36@@ -1662,7 +1666,7 @@
37 extra_clauses.append("BugTask.milestone %s" % where_cond)
38
39 if params.project:
40- # Circular.
41+ # Prevent circular import problems.
42 from lp.registry.model.product import Product
43 clauseTables.append(Product)
44 extra_clauses.append("BugTask.product = Product.id")
45@@ -1713,47 +1717,54 @@
46 sqlvalues(personid=params.subscriber.id))
47
48 if params.structural_subscriber is not None:
49- structural_subscriber_clause = ("""BugTask.id IN (
50- SELECT BugTask.id FROM BugTask, StructuralSubscription
51- WHERE BugTask.product = StructuralSubscription.product
52- AND StructuralSubscription.subscriber = %(personid)s
53- UNION ALL
54- SELECT BugTask.id FROM BugTask, StructuralSubscription
55- WHERE
56- BugTask.distribution = StructuralSubscription.distribution
57- AND BugTask.sourcepackagename =
58- StructuralSubscription.sourcepackagename
59- AND StructuralSubscription.subscriber = %(personid)s
60- UNION ALL
61- SELECT BugTask.id FROM BugTask, StructuralSubscription
62- WHERE
63- BugTask.distroseries = StructuralSubscription.distroseries
64- AND StructuralSubscription.subscriber = %(personid)s
65- UNION ALL
66- SELECT BugTask.id FROM BugTask, StructuralSubscription
67- WHERE
68- BugTask.milestone = StructuralSubscription.milestone
69- AND StructuralSubscription.subscriber = %(personid)s
70- UNION ALL
71- SELECT BugTask.id FROM BugTask, StructuralSubscription
72- WHERE
73- BugTask.productseries = StructuralSubscription.productseries
74- AND StructuralSubscription.subscriber = %(personid)s
75- UNION ALL
76- SELECT BugTask.id
77- FROM BugTask, StructuralSubscription, Product
78- WHERE
79- BugTask.product = Product.id
80- AND Product.project = StructuralSubscription.project
81- AND StructuralSubscription.subscriber = %(personid)s
82- UNION ALL
83- SELECT BugTask.id FROM BugTask, StructuralSubscription
84- WHERE
85- BugTask.distribution = StructuralSubscription.distribution
86- AND StructuralSubscription.sourcepackagename is NULL
87- AND StructuralSubscription.subscriber = %(personid)s)""" %
88- sqlvalues(personid=params.structural_subscriber))
89- extra_clauses.append(structural_subscriber_clause)
90+ ssub_match_product = (
91+ BugTask.productID ==
92+ StructuralSubscription.productID)
93+ ssub_match_productseries = (
94+ BugTask.productseriesID ==
95+ StructuralSubscription.productseriesID)
96+ # Prevent circular import problems.
97+ from lp.registry.model.product import Product
98+ ssub_match_project = And(
99+ Product.projectID ==
100+ StructuralSubscription.projectID,
101+ BugTask.product == Product.id)
102+ ssub_match_distribution = (
103+ BugTask.distributionID ==
104+ StructuralSubscription.distributionID)
105+ ssub_match_sourcepackagename = (
106+ BugTask.sourcepackagenameID ==
107+ StructuralSubscription.sourcepackagenameID)
108+ ssub_match_null_sourcepackagename = (
109+ StructuralSubscription.sourcepackagename == None)
110+ ssub_match_distribution_with_optional_package = And(
111+ ssub_match_distribution, Or(
112+ ssub_match_sourcepackagename,
113+ ssub_match_null_sourcepackagename))
114+ ssub_match_distribution_series = (
115+ BugTask.distroseriesID ==
116+ StructuralSubscription.distroseriesID)
117+ ssub_match_milestone = (
118+ BugTask.milestoneID ==
119+ StructuralSubscription.milestoneID)
120+
121+ join_clause = Or(
122+ ssub_match_product,
123+ ssub_match_productseries,
124+ ssub_match_project,
125+ ssub_match_distribution_with_optional_package,
126+ ssub_match_distribution_series,
127+ ssub_match_milestone)
128+
129+ join_tables.append(
130+ (Product, LeftJoin(Product, BugTask.productID == Product.id)))
131+ join_tables.append(
132+ (StructuralSubscription,
133+ Join(StructuralSubscription, join_clause)))
134+ extra_clauses.append(
135+ 'StructuralSubscription.subscriber = %s'
136+ % sqlvalues(params.structural_subscriber))
137+ has_duplicate_results = True
138
139 if params.component:
140 clauseTables += [SourcePackagePublishingHistory,
141@@ -1928,7 +1939,9 @@
142 for decor in decorators:
143 obj = decor(obj)
144 return obj
145- return query, clauseTables, orderby_arg, decorator
146+ return (
147+ query, clauseTables, orderby_arg, decorator, join_tables,
148+ has_duplicate_results)
149
150 def _buildUpstreamClause(self, params):
151 """Return an clause for returning upstream data if the data exists.
152@@ -2156,101 +2169,137 @@
153 ', '.join(tables), ' AND '.join(clauses))
154 return clause
155
156- def search(self, params, *args, **kwargs):
157- """See `IBugTaskSet`.
158-
159- :param _noprejoins: Private internal parameter to BugTaskSet which
160- disables all use of prejoins : consolidated from code paths that
161- claim they were inefficient and unwanted.
162- """
163- # Circular.
164- from lp.registry.model.product import Product
165- from lp.bugs.model.bug import Bug
166- _noprejoins = kwargs.get('_noprejoins', False)
167+ def buildOrigin(self, join_tables, prejoin_tables, clauseTables):
168+ """Build the parameter list for Store.using().
169+
170+ :param join_tables: A sequence of tables that should be joined
171+ as returned by buildQuery(). Each element has the form
172+ (table, join), where table is the table to join and join
173+ is a Storm Join or LeftJoin instance.
174+ :param prejoin_tables: A sequence of tables that should additionally
175+ be joined. Each element has the form (table, join),
176+ where table is the table to join and join is a Storm Join
177+ or LeftJoin instance.
178+ :param clauseTables: A sequence of tables that should appear in
179+ the FROM clause of a query. The join condition is defined in
180+ the WHERE clause.
181+
182+ Tables may appear simultaneously in join_tables, prejoin_tables
183+ and in clauseTables. This method ensures that each table
184+ appears exactly once in the returned sequence.
185+ """
186+ origin = [BugTask]
187+ already_joined = set(origin)
188+ for table, join in join_tables:
189+ origin.append(join)
190+ already_joined.add(table)
191+ for table, join in prejoin_tables:
192+ if table not in already_joined:
193+ origin.append(join)
194+ already_joined.add(table)
195+ for table in clauseTables:
196+ if table not in already_joined:
197+ origin.append(table)
198+ return origin
199+
200+ def _search(self, resultrow, prejoins, params, *args, **kw):
201+ """Return a Storm result set for the given search parameters.
202+
203+ :param resultrow: The type of data returned by the query.
204+ :param prejoins: A sequence of Storm SQL row instances which are
205+ pre-joined.
206+ :param params: A BugTaskSearchParams instance.
207+ :param args: optional additional BugTaskSearchParams instances,
208+ """
209 store = IStore(BugTask)
210- query, clauseTables, orderby, bugtask_decorator = self.buildQuery(
211- params)
212+ [query, clauseTables, orderby, bugtask_decorator, join_tables,
213+ has_duplicate_results] = self.buildQuery(params)
214 if len(args) == 0:
215- if _noprejoins:
216- resultset = store.find(BugTask,
217- AutoTables(SQL("1=1"), clauseTables),
218- query)
219+ if has_duplicate_results:
220+ origin = self.buildOrigin(join_tables, [], clauseTables)
221+ outer_origin = self.buildOrigin([], prejoins, [])
222+ subquery = Select(BugTask.id, where=SQL(query), tables=origin)
223+ resultset = store.using(*outer_origin).find(
224+ resultrow, In(BugTask.id, subquery))
225+ else:
226+ origin = self.buildOrigin(join_tables, prejoins, clauseTables)
227+ resultset = store.using(*origin).find(resultrow, query)
228+ if prejoins:
229+ decorator = lambda row: bugtask_decorator(row[0])
230+ else:
231 decorator = bugtask_decorator
232- else:
233- tables = clauseTables + [Product, SourcePackageName]
234- origin = [
235- BugTask,
236- LeftJoin(Bug, BugTask.bug == Bug.id),
237- LeftJoin(Product, BugTask.product == Product.id),
238- LeftJoin(
239- SourcePackageName,
240- BugTask.sourcepackagename == SourcePackageName.id),
241- ]
242- # NB: these may work with AutoTables, but its hard to tell,
243- # this way is known to work.
244- if BugNomination in tables:
245- # The relation is already in query.
246- origin.append(BugNomination)
247- if BugSubscription in tables:
248- # The relation is already in query.
249- origin.append(BugSubscription)
250- if SourcePackageRelease in tables:
251- origin.append(SourcePackageRelease)
252- if SourcePackagePublishingHistory in tables:
253- origin.append(SourcePackagePublishingHistory)
254- resultset = store.using(*origin).find(
255- (BugTask, Product, SourcePackageName, Bug),
256- AutoTables(SQL("1=1"), tables),
257- query)
258- decorator=lambda row: bugtask_decorator(row[0])
259+
260 resultset.order_by(orderby)
261 return DecoratedResultSet(resultset, result_decorator=decorator)
262
263 bugtask_fti = SQL('BugTask.fti')
264- result = store.find((BugTask, bugtask_fti), query,
265- AutoTables(SQL("1=1"), clauseTables))
266+ inner_resultrow = (BugTask, bugtask_fti)
267+ origin = self.buildOrigin(join_tables, [], clauseTables)
268+ resultset = store.using(*origin).find(inner_resultrow, query)
269+
270 decorators = [bugtask_decorator]
271 for arg in args:
272- query, clauseTables, dummy, decorator = self.buildQuery(arg)
273- result = result.union(
274- store.find((BugTask, bugtask_fti), query,
275- AutoTables(SQL("1=1"), clauseTables)))
276+ [query, clauseTables, ignore, decorator, join_tables,
277+ has_duplicate_results] = self.buildQuery(arg)
278+ origin = self.buildOrigin(join_tables, [], clauseTables)
279+ next_result = store.using(*origin).find(inner_resultrow, query)
280+ resultset = resultset.union(next_result)
281 # NB: assumes the decorators are all compatible.
282 # This may need revisiting if e.g. searches on behalf of different
283 # users are combined.
284 decorators.append(decorator)
285
286- def decorator(row):
287+ def prejoin_decorator(row):
288 bugtask = row[0]
289 for decorator in decorators:
290 bugtask = decorator(bugtask)
291 return bugtask
292
293- # Build up the joins.
294- # TODO: implement _noprejoins for this code path: as of 20100818 it
295- # has been silently disabled because clients of the API were setting
296- # prejoins=[] which had no effect; this TODO simply notes the reality
297- # already existing when it was added.
298- joins = Alias(result._get_select(), "BugTask")
299- joins = Join(joins, Bug, BugTask.bug == Bug.id)
300- joins = LeftJoin(joins, Product, BugTask.product == Product.id)
301- joins = LeftJoin(joins, SourcePackageName,
302- BugTask.sourcepackagename == SourcePackageName.id)
303-
304- result = store.using(joins).find(
305- (BugTask, Bug, Product, SourcePackageName))
306+ def simple_decorator(bugtask):
307+ for decorator in decorators:
308+ bugtask = decorator(bugtask)
309+ return bugtask
310+
311+ origin = [Alias(resultset._get_select(), "BugTask")]
312+ if prejoins:
313+ origin += [join for table, join in prejoins]
314+ decorator = prejoin_decorator
315+ else:
316+ decorator = simple_decorator
317+
318+ result = store.using(*origin).find(resultrow)
319 result.order_by(orderby)
320 return DecoratedResultSet(result, result_decorator=decorator)
321
322+ def search(self, params, *args, **kwargs):
323+ """See `IBugTaskSet`.
324+
325+ :param _noprejoins: Private internal parameter to BugTaskSet which
326+ disables all use of prejoins : consolidated from code paths that
327+ claim they were inefficient and unwanted.
328+ """
329+ # Prevent circular import problems.
330+ from lp.registry.model.product import Product
331+ from lp.bugs.model.bug import Bug
332+ _noprejoins = kwargs.get('_noprejoins', False)
333+ if _noprejoins:
334+ prejoins = []
335+ resultrow = BugTask
336+ else:
337+ prejoins = [
338+ (Bug, LeftJoin(Bug, BugTask.bug == Bug.id)),
339+ (Product, LeftJoin(Product, BugTask.product == Product.id)),
340+ (SourcePackageName,
341+ LeftJoin(
342+ SourcePackageName,
343+ BugTask.sourcepackagename == SourcePackageName.id)),
344+ ]
345+ resultrow = (BugTask, Bug, Product, SourcePackageName, )
346+ return self._search(resultrow, prejoins, params, *args)
347+
348 def searchBugIds(self, params):
349 """See `IBugTaskSet`."""
350- query, clauseTables, orderby, decorator = self.buildQuery(
351- params)
352- store = IStore(BugTask)
353- resultset = store.find(BugTask.bugID,
354- AutoTables(SQL("1=1"), clauseTables), query)
355- resultset.order_by(orderby)
356- return resultset
357+ return self._search(BugTask.bugID, [], params).result_set
358
359 def getAssignedMilestonesFromSearch(self, search_results):
360 """See `IBugTaskSet`."""
361@@ -2854,8 +2903,8 @@
362
363 if recipients is not None:
364 # We need to process subscriptions, so pull all the
365- # subscribes into the cache, then update recipients with
366- # the subscriptions.
367+ # subscribers into the cache, then update recipients
368+ # with the subscriptions.
369 subscribers = list(subscribers)
370 for subscription in subscriptions:
371 recipients.addStructuralSubscriber(
372
373=== modified file 'lib/lp/bugs/tests/test_bugtask_search.py'
374--- lib/lp/bugs/tests/test_bugtask_search.py 2010-11-04 15:53:15 +0000
375+++ lib/lp/bugs/tests/test_bugtask_search.py 2010-11-17 13:53:59 +0000
376@@ -507,6 +507,23 @@
377 self.assertSearchFinds(params, self.bugtasks[:1])
378
379
380+class ProjectGroupAndDistributionTests:
381+ """Tests which are useful for project groups and distributions."""
382+
383+ def setUpStructuralSubscriptions(self):
384+ # Subscribe a user to the search target of this test and to
385+ # another target.
386+ raise NotImplementedError
387+
388+ def test_unique_results_for_multiple_structural_subscriptions(self):
389+ # Searching for a subscriber who is more than once subscribed to a
390+ # bug task returns this bug task only once.
391+ subscriber = self.setUpStructuralSubscriptions()
392+ params = self.getBugTaskSearchParams(
393+ user=None, structural_subscriber=subscriber)
394+ self.assertSearchFinds(params, self.bugtasks)
395+
396+
397 class BugTargetTestBase:
398 """A base class for the bug target mixin classes."""
399
400@@ -628,7 +645,8 @@
401 bugtask, self.searchtarget.product)
402
403
404-class ProjectGroupTarget(BugTargetTestBase, BugTargetWithBugSuperVisor):
405+class ProjectGroupTarget(BugTargetTestBase, BugTargetWithBugSuperVisor,
406+ ProjectGroupAndDistributionTests):
407 """Use a project group as the bug target."""
408
409 def setUp(self):
410@@ -698,6 +716,15 @@
411 'No bug task found for a product that is not the target of '
412 'the main test bugtask.')
413
414+ def setUpStructuralSubscriptions(self):
415+ # See `ProjectGroupAndDistributionTests`.
416+ subscriber = self.factory.makePerson()
417+ self.subscribeToTarget(subscriber)
418+ with person_logged_in(subscriber):
419+ self.bugtasks[0].target.addSubscription(
420+ subscriber, subscribed_by=subscriber)
421+ return subscriber
422+
423
424 class MilestoneTarget(BugTargetTestBase):
425 """Use a milestone as the bug target."""
426@@ -731,7 +758,8 @@
427
428
429 class DistributionTarget(BugTargetTestBase, ProductAndDistributionTests,
430- BugTargetWithBugSuperVisor):
431+ BugTargetWithBugSuperVisor,
432+ ProjectGroupAndDistributionTests):
433 """Use a distribution as the bug target."""
434
435 def setUp(self):
436@@ -753,6 +781,18 @@
437 """See `ProductAndDistributionTests`."""
438 return self.factory.makeDistroSeries(distribution=self.searchtarget)
439
440+ def setUpStructuralSubscriptions(self):
441+ # See `ProjectGroupAndDistributionTests`.
442+ subscriber = self.factory.makePerson()
443+ sourcepackage = self.factory.makeDistributionSourcePackage(
444+ distribution=self.searchtarget)
445+ self.bugtasks.append(self.factory.makeBugTask(target=sourcepackage))
446+ self.subscribeToTarget(subscriber)
447+ with person_logged_in(subscriber):
448+ sourcepackage.addSubscription(
449+ subscriber, subscribed_by=subscriber)
450+ return subscriber
451+
452
453 class DistroseriesTarget(BugTargetTestBase):
454 """Use a distro series as the bug target."""
455@@ -838,7 +878,30 @@
456 )
457
458
459-class PreloadBugtaskTargets:
460+class MultipleParams:
461+ """A mixin class for tests with more than one search parameter object.
462+
463+ BugTaskSet.search() can be called with more than one
464+ BugTaskSearchParams instances, while BugTaskSet.searchBugIds()
465+ accepts exactly one instance.
466+ """
467+
468+ def test_two_param_objects(self):
469+ # We can pass more than one BugTaskSearchParams instance to
470+ # BugTaskSet.search().
471+ params1 = self.getBugTaskSearchParams(
472+ user=None, status=BugTaskStatus.FIXCOMMITTED)
473+ subscriber = self.factory.makePerson()
474+ self.subscribeToTarget(subscriber)
475+ params2 = self.getBugTaskSearchParams(
476+ user=None, status=BugTaskStatus.NEW,
477+ structural_subscriber=subscriber)
478+ search_result = self.runSearch(params1, params2)
479+ expected = self.resultValuesForBugtasks(self.bugtasks[1:])
480+ self.assertEqual(expected, search_result)
481+
482+
483+class PreloadBugtaskTargets(MultipleParams):
484 """Preload bug targets during a BugTaskSet.search() query."""
485
486 def setUp(self):
487@@ -852,7 +915,7 @@
488 return expected_bugtasks
489
490
491-class NoPreloadBugtaskTargets:
492+class NoPreloadBugtaskTargets(MultipleParams):
493 """Do not preload bug targets during a BugTaskSet.search() query."""
494
495 def setUp(self):