Merge lp:~wgrant/launchpad/bugtaskflat-search-3 into lp:launchpad

Proposed by William Grant on 2012-04-19
Status: Merged
Merged at revision: 15121
Proposed branch: lp:~wgrant/launchpad/bugtaskflat-search-3
Merge into: lp:launchpad
Diff against target: 558 lines (+241/-83)
5 files modified
lib/lp/bugs/browser/bugtask.py (+2/-2)
lib/lp/bugs/browser/tests/test_bugtask.py (+2/-2)
lib/lp/bugs/model/bugtaskflat.py (+16/-1)
lib/lp/bugs/model/bugtasksearch.py (+201/-61)
lib/lp/bugs/tests/test_bugtask_search.py (+20/-17)
To merge this branch: bzr merge lp:~wgrant/launchpad/bugtaskflat-search-3
Reviewer Review Type Date Requested Status
Ian Booth (community) 2012-04-19 Approve on 2012-04-19
Review via email: mp+102615@code.launchpad.net

Commit Message

BugTaskFlat is now used for sorting and filtering when the flag is enabled.

Description of the Change

This branch basically completes the initial BugTaskFlat search implementation, porting filtering and sorting to fields on BugTaskFlat where possible. This lets us eliminate the Bug and BugTask joins from most searches.

The only radical difference between the two is the private bug filter. It's traditionally done by joining against BugSubscription, but with BugTaskFlat it's just a matter of checking intersection of the user's teams with a denormalised array. There's only one functional difference: assignees can no longer see their bugs unless they have an explicit grant, which isn't ensured by the code yet. But this is a corner case that was only added recently, so it shouldn't be a problem for the short time it's inconsistent.

To post a comment you must log in.
Ian Booth (wallyworld) wrote :

Looks ok, although I am not intimately familiar with the other branches in this pipe.
As discussed, we need a plan to ensure assignees can see their own bugs since the new work removes that "feature".

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/bugs/browser/bugtask.py'
2--- lib/lp/bugs/browser/bugtask.py 2012-04-17 07:54:24 +0000
3+++ lib/lp/bugs/browser/bugtask.py 2012-04-19 04:04:19 +0000
4@@ -217,7 +217,7 @@
5 from lp.bugs.interfaces.bugwatch import BugWatchActivityStatus
6 from lp.bugs.interfaces.cve import ICveSet
7 from lp.bugs.interfaces.malone import IMaloneApplication
8-from lp.bugs.model.bugtasksearch import orderby_expression
9+from lp.bugs.model.bugtasksearch import unflat_orderby_expression
10 from lp.code.interfaces.branchcollection import IAllBranches
11 from lp.layers import FeedsLayer
12 from lp.registry.enums import InformationType
13@@ -2802,7 +2802,7 @@
14 orderby_col = orderby_col[1:]
15
16 try:
17- orderby_expression[orderby_col]
18+ unflat_orderby_expression[orderby_col]
19 except KeyError:
20 raise UnexpectedFormData(
21 "Unknown sort column '%s'" % orderby_col)
22
23=== modified file 'lib/lp/bugs/browser/tests/test_bugtask.py'
24--- lib/lp/bugs/browser/tests/test_bugtask.py 2012-04-17 22:56:29 +0000
25+++ lib/lp/bugs/browser/tests/test_bugtask.py 2012-04-19 04:04:19 +0000
26@@ -52,7 +52,7 @@
27 IBugTask,
28 IBugTaskSet,
29 )
30-from lp.bugs.model.bugtasksearch import orderby_expression
31+from lp.bugs.model.bugtasksearch import unflat_orderby_expression
32 from lp.layers import (
33 FeedsLayer,
34 setFirstLayer,
35@@ -2325,7 +2325,7 @@
36 cache = IJSONRequestCache(view.request)
37 json_sort_keys = cache.objects['sort_keys']
38 json_sort_keys = set(key[0] for key in json_sort_keys)
39- valid_keys = set(orderby_expression.keys())
40+ valid_keys = set(unflat_orderby_expression.keys())
41 self.assertEqual(
42 valid_keys, json_sort_keys,
43 "Existing sort order values not available in JSON cache: %r; "
44
45=== modified file 'lib/lp/bugs/model/bugtaskflat.py'
46--- lib/lp/bugs/model/bugtaskflat.py 2012-04-16 08:12:26 +0000
47+++ lib/lp/bugs/model/bugtaskflat.py 2012-04-19 04:04:19 +0000
48@@ -2,6 +2,8 @@
49 Bool,
50 DateTime,
51 Int,
52+ Reference,
53+ Storm,
54 )
55
56 from lp.registry.enums import InformationType
57@@ -13,26 +15,39 @@
58 )
59
60
61-class BugTaskFlat(object):
62+class BugTaskFlat(Storm):
63
64 __storm_table__ = 'BugTaskFlat'
65
66 bugtask_id = Int(name='bugtask', primary=True)
67+ bugtask = Reference(bugtask_id, 'BugTask.id')
68 bug_id = Int(name='bug')
69+ bug = Reference(bug_id, 'Bug.id')
70 datecreated = DateTime()
71 duplicateof_id = Int(name='duplicateof')
72+ duplicateof = Reference(duplicateof_id, 'Bug.id')
73 bug_owner_id = Int(name='bug_owner')
74+ bug_owner = Reference(bug_owner_id, 'Person.id')
75 information_type = EnumCol(enum=InformationType)
76 date_last_updated = DateTime()
77 heat = Int()
78 product_id = Int(name='product')
79+ product = Reference(product_id, 'Product.id')
80 productseries_id = Int(name='productseries')
81+ productseries = Reference(productseries_id, 'ProductSeries.id')
82 distribution_id = Int(name='distribution')
83+ distribution = Reference(distribution_id, 'Distribution.id')
84 distroseries_id = Int(name='distroseries')
85+ distroseries = Reference(distroseries_id, 'DistroSeries.id')
86 sourcepackagename_id = Int(name='sourcepackagename')
87+ sourcepackagename = Reference(
88+ sourcepackagename_id, 'SourcePackageName.id')
89 status = EnumCol(schema=(BugTaskStatus, BugTaskStatusSearch))
90 importance = EnumCol(schema=BugTaskImportance)
91 assignee_id = Int(name='assignee')
92+ assignee = Reference(assignee_id, 'Person.id')
93 milestone_id = Int(name='milestone')
94+ milestone = Reference(milestone_id, 'Milestone.id')
95 owner_id = Int(name='owner')
96+ owner = Reference(owner_id, 'Person.id')
97 active = Bool()
98
99=== modified file 'lib/lp/bugs/model/bugtasksearch.py'
100--- lib/lp/bugs/model/bugtasksearch.py 2012-04-18 02:10:59 +0000
101+++ lib/lp/bugs/model/bugtasksearch.py 2012-04-19 04:04:19 +0000
102@@ -5,7 +5,7 @@
103
104 __all__ = [
105 'get_bug_privacy_filter',
106- 'orderby_expression',
107+ 'unflat_orderby_expression',
108 'search_bugs',
109 ]
110
111@@ -62,6 +62,7 @@
112 from lp.bugs.model.bugtask import BugTask
113 from lp.bugs.model.bugtaskflat import BugTaskFlat
114 from lp.bugs.model.structuralsubscription import StructuralSubscription
115+from lp.registry.enums import PUBLIC_INFORMATION_TYPES
116 from lp.registry.interfaces.distribution import IDistribution
117 from lp.registry.interfaces.distroseries import IDistroSeries
118 from lp.registry.interfaces.milestone import IProjectGroupMilestone
119@@ -75,10 +76,7 @@
120 from lp.services.database.bulk import load
121 from lp.services.database.decoratedresultset import DecoratedResultSet
122 from lp.services.database.lpstorm import IStore
123-from lp.services.database.sqlbase import (
124- quote,
125- sqlvalues,
126- )
127+from lp.services.database.sqlbase import sqlvalues
128 from lp.services.database.stormexpr import (
129 Array,
130 NullCount,
131@@ -97,7 +95,7 @@
132
133 # This abstracts most of the columns involved in search so we can switch
134 # to/from BugTaskFlat easily.
135-cols = {
136+unflat_cols = {
137 'Bug.id': Bug.id,
138 'Bug.duplicateof': Bug.duplicateof,
139 'Bug.owner': Bug.owner,
140@@ -125,11 +123,39 @@
141 'BugTask._status': BugTask._status,
142 }
143
144+flat_cols = {
145+ 'Bug.id': BugTaskFlat.bug_id,
146+ 'Bug.duplicateof': BugTaskFlat.duplicateof,
147+ 'Bug.owner': BugTaskFlat.bug_owner,
148+ 'Bug.date_last_updated': BugTaskFlat.date_last_updated,
149+ 'BugTask.id': BugTaskFlat.bugtask_id,
150+ 'BugTask.bug': BugTaskFlat.bug,
151+ 'BugTask.bugID': BugTaskFlat.bug_id,
152+ 'BugTask.importance': BugTaskFlat.importance,
153+ 'BugTask.product': BugTaskFlat.product,
154+ 'BugTask.productID': BugTaskFlat.product_id,
155+ 'BugTask.productseries': BugTaskFlat.productseries,
156+ 'BugTask.productseriesID': BugTaskFlat.productseries_id,
157+ 'BugTask.distribution': BugTaskFlat.distribution,
158+ 'BugTask.distributionID': BugTaskFlat.distribution_id,
159+ 'BugTask.distroseries': BugTaskFlat.distroseries,
160+ 'BugTask.distroseriesID': BugTaskFlat.distroseries_id,
161+ 'BugTask.sourcepackagename': BugTaskFlat.sourcepackagename,
162+ 'BugTask.sourcepackagenameID': BugTaskFlat.sourcepackagename_id,
163+ 'BugTask.milestone': BugTaskFlat.milestone,
164+ 'BugTask.milestoneID': BugTaskFlat.milestone_id,
165+ 'BugTask.assignee': BugTaskFlat.assignee,
166+ 'BugTask.owner': BugTaskFlat.owner,
167+ 'BugTask.date_closed': BugTask.date_closed,
168+ 'BugTask.datecreated': BugTaskFlat.datecreated,
169+ 'BugTask._status': BugTaskFlat.status,
170+ }
171+
172
173 bug_join = (Bug, Join(Bug, BugTask.bug == Bug.id))
174 Assignee = ClassAlias(Person)
175 Reporter = ClassAlias(Person)
176-orderby_expression = {
177+unflat_orderby_expression = {
178 "task": (BugTask.id, []),
179 "id": (BugTask.bugID, []),
180 "importance": (BugTask.importance, []),
181@@ -206,6 +232,83 @@
182 ),
183 }
184
185+flat_bug_join = (Bug, Join(Bug, Bug.id == BugTaskFlat.bug_id))
186+flat_bugtask_join = (
187+ BugTask, Join(BugTask, BugTask.id == BugTaskFlat.bugtask_id))
188+flat_orderby_expression = {
189+ "task": (BugTaskFlat.bugtask_id, []),
190+ "id": (BugTaskFlat.bug_id, []),
191+ "importance": (BugTaskFlat.importance, []),
192+ # TODO: sort by their name?
193+ "assignee": (
194+ Assignee.name,
195+ [
196+ (Assignee,
197+ LeftJoin(Assignee, BugTaskFlat.assignee == Assignee.id))
198+ ]),
199+ "targetname": (BugTask.targetnamecache, [flat_bugtask_join]),
200+ "status": (BugTaskFlat.status, []),
201+ "title": (Bug.title, [flat_bug_join]),
202+ "milestone": (BugTaskFlat.milestone_id, []),
203+ "dateassigned": (BugTask.date_assigned, [flat_bugtask_join]),
204+ "datecreated": (BugTaskFlat.datecreated, []),
205+ "date_last_updated": (BugTaskFlat.date_last_updated, []),
206+ "date_closed": (BugTask.date_closed, [flat_bugtask_join]),
207+ "number_of_duplicates": (Bug.number_of_duplicates, [flat_bug_join]),
208+ "message_count": (Bug.message_count, [flat_bug_join]),
209+ "users_affected_count": (Bug.users_affected_count, [flat_bug_join]),
210+ "heat": (BugTaskFlat.heat, []),
211+ "latest_patch_uploaded": (Bug.latest_patch_uploaded, [flat_bug_join]),
212+ "milestone_name": (
213+ Milestone.name,
214+ [
215+ (Milestone,
216+ LeftJoin(Milestone,
217+ BugTaskFlat.milestone_id == Milestone.id))
218+ ]),
219+ "reporter": (
220+ Reporter.name,
221+ [
222+ (Reporter, Join(Reporter, BugTaskFlat.bug_owner == Reporter.id))
223+ ]),
224+ "tag": (
225+ BugTag.tag,
226+ [
227+ (BugTag,
228+ LeftJoin(
229+ BugTag,
230+ BugTag.bug == BugTaskFlat.bug_id and
231+ # We want at most one tag per bug. Select the
232+ # tag that comes first in alphabetic order.
233+ BugTag.id == Select(
234+ BugTag.id, tables=[BugTag],
235+ where=(BugTag.bugID == BugTaskFlat.bug_id),
236+ order_by=BugTag.tag, limit=1))),
237+ ]
238+ ),
239+ "specification": (
240+ Specification.name,
241+ [
242+ (Specification,
243+ LeftJoin(
244+ Specification,
245+ # We want at most one specification per bug.
246+ # Select the specification that comes first
247+ # in alphabetic order.
248+ Specification.id == Select(
249+ Specification.id,
250+ tables=[
251+ SpecificationBug,
252+ Join(
253+ Specification,
254+ Specification.id ==
255+ SpecificationBug.specificationID)],
256+ where=(SpecificationBug.bugID == BugTaskFlat.bug_id),
257+ order_by=Specification.name, limit=1))),
258+ ]
259+ ),
260+ }
261+
262
263 def search_value_to_storm_where_condition(comp, search_value):
264 """Convert a search value to a Storm WHERE condition."""
265@@ -362,14 +465,12 @@
266 decorator to call on each returned row.
267 """
268 params = _require_params(params)
269+ cols = flat_cols if use_flat else unflat_cols
270
271 if use_flat:
272 extra_clauses = []
273 clauseTables = []
274- join_tables = [
275- (Bug, Join(Bug, Bug.id == BugTaskFlat.bug_id)),
276- (BugTask, Join(BugTask, BugTask.id == BugTaskFlat.bugtask_id)),
277- ]
278+ join_tables = []
279 else:
280 extra_clauses = [Bug.id == BugTask.bugID]
281 clauseTables = [BugTask, Bug]
282@@ -509,10 +610,12 @@
283 BugAttachment.type, params.attachmenttype))))
284
285 if params.searchtext:
286- extra_clauses.append(_build_search_text_clause(params))
287+ extra_clauses.append(_build_search_text_clause(
288+ params, use_flat=use_flat))
289
290 if params.fast_searchtext:
291- extra_clauses.append(_build_search_text_clause(params, fast=True))
292+ extra_clauses.append(_build_search_text_clause(
293+ params, fast=True, use_flat=use_flat))
294
295 if params.subscriber is not None:
296 clauseTables.append(BugSubscription)
297@@ -742,7 +845,8 @@
298 extra_clauses.append(
299 Milestone.dateexpected <= dateexpected_before)
300
301- clause, decorator = _get_bug_privacy_filter_with_decorator(params.user)
302+ clause, decorator = _get_bug_privacy_filter_with_decorator(
303+ params.user, use_flat=use_flat)
304 if clause:
305 extra_clauses.append(SQL(clause))
306 decorators.append(decorator)
307@@ -821,15 +925,6 @@
308 # decide whether we need to add the BugTask.bug or BugTask.id
309 # columns to make the sort consistent over runs -- which is good
310 # for the user and essential for the test suite.
311- unambiguous_cols = set([
312- Bug.date_last_updated,
313- Bug.datecreated,
314- Bug.id,
315- BugTask.bugID,
316- BugTask.date_assigned,
317- BugTask.datecreated,
318- BugTask.id,
319- ])
320 # Bug ID is unique within bugs on a product or source package.
321 if (params.product or
322 (params.distribution and params.sourcepackagename) or
323@@ -838,8 +933,34 @@
324 else:
325 in_unique_context = False
326
327- if in_unique_context:
328- unambiguous_cols.add(BugTask.bug)
329+ if use_flat:
330+ orderby_expression = flat_orderby_expression
331+ unambiguous_cols = set([
332+ BugTaskFlat.date_last_updated,
333+ BugTaskFlat.datecreated,
334+ BugTaskFlat.bugtask_id,
335+ Bug.datecreated,
336+ BugTask.date_assigned,
337+ ])
338+ if in_unique_context:
339+ unambiguous_cols.add(BugTaskFlat.bug)
340+ else:
341+ orderby_expression = unflat_orderby_expression
342+ # Bug.id and BugTask.bugID shouldn't really be here; they're
343+ # ambiguous in a distribution or distroseries context. They're
344+ # omitted from the new BugTaskFlat path, but kept in the legacy
345+ # code in case it affects index selection.
346+ unambiguous_cols = set([
347+ Bug.date_last_updated,
348+ Bug.datecreated,
349+ Bug.id,
350+ BugTask.bugID,
351+ BugTask.date_assigned,
352+ BugTask.datecreated,
353+ BugTask.id,
354+ ])
355+ if in_unique_context:
356+ unambiguous_cols.add(BugTask.bug)
357
358 # Translate orderby keys into corresponding Table.attribute
359 # strings.
360@@ -882,10 +1003,16 @@
361 orderby_arg.append(order_clause)
362
363 if ambiguous:
364- if in_unique_context:
365- orderby_arg.append(BugTask.bugID)
366+ if use_flat:
367+ if in_unique_context:
368+ orderby_arg.append(BugTaskFlat.bug_id)
369+ else:
370+ orderby_arg.append(BugTaskFlat.bugtask_id)
371 else:
372- orderby_arg.append(BugTask.id)
373+ if in_unique_context:
374+ orderby_arg.append(BugTask.bugID)
375+ else:
376+ orderby_arg.append(BugTask.id)
377
378 return tuple(orderby_arg), extra_joins
379
380@@ -899,7 +1026,7 @@
381 return params
382
383
384-def _build_search_text_clause(params, fast=False):
385+def _build_search_text_clause(params, fast=False, use_flat=False):
386 """Build the clause for searchtext."""
387 if fast:
388 assert params.searchtext is None, (
389@@ -910,12 +1037,14 @@
390 'Cannot use fast_searchtext at the same time as searchtext.')
391 searchtext = params.searchtext
392
393+ col = 'BugTaskFlat.fti' if use_flat else 'Bug.fti'
394+
395 if params.orderby is None:
396 # Unordered search results aren't useful, so sort by relevance
397 # instead.
398- params.orderby = [SQL("-rank(Bug.fti, ftq(?))", params=(searchtext,))]
399+ params.orderby = [SQL("-rank(%s, ftq(?))" % col, params=(searchtext,))]
400
401- return SQL("Bug.fti @@ ftq(?)", params=(searchtext,))
402+ return SQL("%s @@ ftq(?)" % col, params=(searchtext,))
403
404
405 def _build_status_clause(col, status):
406@@ -1428,7 +1557,8 @@
407 return cache_user_can_view_bug
408
409
410-def _get_bug_privacy_filter_with_decorator(user, private_only=False):
411+def _get_bug_privacy_filter_with_decorator(user, private_only=False,
412+ use_flat=False):
413 """Return a SQL filter to limit returned bug tasks.
414
415 :param user: The user whose visible bugs will be filtered.
416@@ -1438,38 +1568,48 @@
417 :return: A SQL filter, a decorator to cache visibility in a resultset that
418 returns BugTask objects.
419 """
420+ if use_flat:
421+ public_bug_filter = (
422+ 'BugTaskFlat.information_type IN %s'
423+ % sqlvalues(PUBLIC_INFORMATION_TYPES))
424+ else:
425+ public_bug_filter = 'Bug.private IS FALSE'
426+
427 if user is None:
428- return "Bug.private IS FALSE", _nocache_bug_decorator
429+ return public_bug_filter, _nocache_bug_decorator
430+
431 admin_team = getUtility(ILaunchpadCelebrities).admin
432 if user.inTeam(admin_team):
433 return "", _nocache_bug_decorator
434
435- public_bug_filter = ''
436+ if use_flat:
437+ query = ("""
438+ BugTaskFlat.access_grants &&
439+ (SELECT array_agg(team) FROM teamparticipation WHERE person = %d)
440+ """ % user.id)
441+ else:
442+ # A subselect is used here because joining through
443+ # TeamParticipation is only relevant to the "user-aware"
444+ # part of the WHERE condition (i.e. the bit below.) The
445+ # other half of this condition (see code above) does not
446+ # use TeamParticipation at all.
447+ query = ("""
448+ EXISTS (
449+ WITH teams AS (
450+ SELECT team from TeamParticipation
451+ WHERE person = %d
452+ )
453+ SELECT BugSubscription.bug
454+ FROM BugSubscription
455+ WHERE BugSubscription.person IN (SELECT team FROM teams) AND
456+ BugSubscription.bug = Bug.id
457+ UNION ALL
458+ SELECT BugTask.bug
459+ FROM BugTask
460+ WHERE BugTask.assignee IN (SELECT team FROM teams) AND
461+ BugTask.bug = Bug.id
462+ )
463+ """ % user.id)
464 if not private_only:
465- public_bug_filter = 'Bug.private IS FALSE OR'
466-
467- # A subselect is used here because joining through
468- # TeamParticipation is only relevant to the "user-aware"
469- # part of the WHERE condition (i.e. the bit below.) The
470- # other half of this condition (see code above) does not
471- # use TeamParticipation at all.
472- query = """
473- (%(public_bug_filter)s EXISTS (
474- WITH teams AS (
475- SELECT team from TeamParticipation
476- WHERE person = %(personid)s
477- )
478- SELECT BugSubscription.bug
479- FROM BugSubscription
480- WHERE BugSubscription.person IN (SELECT team FROM teams) AND
481- BugSubscription.bug = Bug.id
482- UNION ALL
483- SELECT BugTask.bug
484- FROM BugTask
485- WHERE BugTask.assignee IN (SELECT team FROM teams) AND
486- BugTask.bug = Bug.id
487- ))
488- """ % dict(
489- personid=quote(user.id),
490- public_bug_filter=public_bug_filter)
491- return query, _make_cache_user_can_view_bug(user)
492+ query = '%s OR %s' % (public_bug_filter, query)
493+ return '(%s)' % query, _make_cache_user_can_view_bug(user)
494
495=== modified file 'lib/lp/bugs/tests/test_bugtask_search.py'
496--- lib/lp/bugs/tests/test_bugtask_search.py 2012-04-17 23:50:01 +0000
497+++ lib/lp/bugs/tests/test_bugtask_search.py 2012-04-19 04:04:19 +0000
498@@ -121,18 +121,6 @@
499 params = self.getBugTaskSearchParams(user=admin)
500 self.assertSearchFinds(params, self.bugtasks)
501
502- def test_private_bug_in_search_result_assignees(self):
503- # Private bugs are included in search results for the assignee.
504- with person_logged_in(self.owner):
505- self.bugtasks[-1].bug.setPrivate(True, self.owner)
506- bugtask = self.bugtasks[-1]
507- user = self.factory.makePerson()
508- admin = getUtility(IPersonSet).getByEmail('foo.bar@canonical.com')
509- with person_logged_in(admin):
510- bugtask.transitionToAssignee(user)
511- params = self.getBugTaskSearchParams(user=user)
512- self.assertSearchFinds(params, self.bugtasks)
513-
514 def test_search_by_bug_reporter(self):
515 # Search results can be limited to bugs filed by a given person.
516 bugtask = self.bugtasks[0]
517@@ -1538,6 +1526,22 @@
518 FeatureFixture({'bugs.bugtaskflat.search.enabled': 'on'}))
519
520
521+class UsingLegacy:
522+ """Use Bug and BugTask directly for searching."""
523+
524+ def test_private_bug_in_search_result_assignees(self):
525+ # Private bugs are included in search results for the assignee.
526+ with person_logged_in(self.owner):
527+ self.bugtasks[-1].bug.setPrivate(True, self.owner)
528+ bugtask = self.bugtasks[-1]
529+ user = self.factory.makePerson()
530+ admin = getUtility(IPersonSet).getByEmail('foo.bar@canonical.com')
531+ with person_logged_in(admin):
532+ bugtask.transitionToAssignee(user)
533+ params = self.getBugTaskSearchParams(user=user)
534+ self.assertSearchFinds(params, self.bugtasks)
535+
536+
537 class TestMilestoneDueDateFiltering(TestCaseWithFactory):
538
539 layer = LaunchpadFunctionalLayer
540@@ -1573,14 +1577,13 @@
541 for bug_target_search_type_class in (
542 PreloadBugtaskTargets, NoPreloadBugtaskTargets, QueryBugIDs):
543 for target_mixin in bug_targets_mixins:
544- for feature_mixin in (None, UsingFlat):
545+ for feature_mixin in (UsingLegacy, UsingFlat):
546 class_name = 'Test%s%s%s' % (
547 bug_target_search_type_class.__name__,
548 target_mixin.__name__,
549- feature_mixin.__name__ if feature_mixin else '')
550- mixins = [target_mixin, bug_target_search_type_class]
551- if feature_mixin:
552- mixins.append(feature_mixin)
553+ feature_mixin.__name__)
554+ mixins = [
555+ target_mixin, bug_target_search_type_class, feature_mixin]
556 class_bases = (
557 tuple(mixins) + (SearchTestBase, TestCaseWithFactory))
558 # Dynamically build a test class from the target mixin class,